Test plans — LLM orchestration use cases 1–13¶
This document is the test plan for the LLM-orchestration UsecaseKind union — the 13 use
cases described in usecase-01..13-*.puml PLUS the remaining UsecaseKind members that
share the same machinery (render-window, image-generation already inside the illustrated
ones, structure-title/overview/heading/headings, language-check, rewrite-selection, and
send-chapter-descriptions). It maps each use case to the existing test coverage (so we
never duplicate) and lists the planned test cases per use case — including the gaps that
are not yet asserted anywhere.
Full UsecaseKind union being planned (23 kinds): storyboard-direct · planned-enblock ·
planned-scenes · render-full · render-window · render-node · image-generation · image-send ·
structure-title · structure-overview · structure-heading · structure-headings ·
language-check · rewrite-selection · append · append-with-images · send-branch ·
send-insert · send-regenerate · send-rewrite · send-prepend · send-elaborate ·
send-chapter-descriptions.
The plans are grounded in the real implementation, not just the diagrams:
| Concern | Implementation |
|---|---|
| Dispatch / env / runner | src/app/core/llm/orchestration/usecases/ (was usecases.ts): runner.ts (pickUsecase, controllerFor, LlmUseCaseRunner), env.ts, storyboard.ts, renders.ts, append.ts, render-pipeline.ts |
| Structural flows (8–13) | src/app/core/llm/orchestration/flows/: runner.ts (FLOW_CONTROLLERS, LlmFlowRunner), sends.ts, text-send.ts (prepareTextSend, persistInterpretation, streamAnswerNode, generatedImageAttachments, imageOnlyAnswerContent) |
| Structure / language / rewrite flows (15–21) | src/app/core/llm/orchestration/flows/structure.ts (textCompletion, addStructureNode, structureResult, wholeStoryContext, the four structure-* controllers, languageCheck, rewriteSelection) |
| Transport | transport.ts (fallback tree, modalities, /images routing), orchestrator.ts, evaluators.ts, postprocessor.ts |
| UI entry points | chat-node.component.ts (illustrate, sendDraft/streamAppendOrchestrated, saveAsBranchAndSend, saveAsInsertAndSend, regenerateAnswer, regenerateInPlace, openPrependDialog), chat.component.ts (confirmElaborate, openElaborateDialog) |
Where the tests live & how to run¶
- Core orchestration (use cases 1–4b–5, image paths incl. 14):
src/app/core/llm/orchestration/orchestration.spec.ts - Append (use cases 6–7):
src/app/core/llm/orchestration/append.spec.ts - Structural flows (use cases 8–13 + 21 + lore/sync) and the structure/language/rewrite flows (use cases 15–20):
src/app/core/llm/orchestration/flows.spec.ts - Component/UI:
src/app/components/chat-node/chat-node.component.spec.ts,src/app/pages/chat/chat.component.spec.ts,src/app/components/illustrate-dialog/*,src/app/components/prepend-dialog/*,src/app/components/create-image-dialog/*
Run a single file:
npx ng test --watch=false --include="src/app/core/llm/orchestration/orchestration.spec.ts"
Coverage matrix¶
| UC | Use case | Core covered? | Gap candidates to add |
|---|---|---|---|
| 1 | storyboard-direct | ✅ fallback / progressive / no-fallback | refusal-per-scene, alert mapping, prompt shape of storyboardInstruction scenes |
| 2 | planned-enblock | ✅ reprise / 1-success-skip / attach | pure-mode prompt template, 0-success reprise skip, beat missing from batch |
| 3 | planned-scenes | ✅ shared pipeline + scene templates | scene template on reprise, batch-early-return invariant (already partly) |
| 4 | render-full | ✅ no attachments | legacy 'full' normalization, full-chat context + anchor shape |
| 4b | render-window | ✅ counts beats in ASSISTANT nodes only (in orchestration.spec.ts) |
window slice boundaries (1 keeps no prior beat), windowed planning scope, historyNodes clamp |
| 5 | render-node | ✅ no attachments | history-only-anchor (no context msgs), scene-text priority |
| 6 | append | ✅ basics | imageCapable image-only, non-stream config, log usage retarget, live paint |
| 7 | append-with-images | ✅ interpret + record | "description already part of content" skip clause, signature mismatch re-interpret |
| 8 | send-branch | ✅ happy + image-only + non-stream + /images | branch-from-answer vs branch-from-question parent rule, carried attachments dedupe |
| 9 | send-insert | ✅ adopt siblings | immediate active-path switch during stream, editor-closes-first timing |
| 10 | send-regenerate | ✅ subtree delete + re-stream | whole-subtree tombstone (children too), no attachment preservation |
| 11 | send-rewrite | ✅ keep-children + preserve attachments | renumber of preserved+new (illustration-2), legacy-only-preserved finalize |
| 12 | send-prepend | ✅ happy | followingText token cap, empty-director error slot, flag rollback on failure |
| 13 | send-elaborate | ✅ fresh question + reuse draft | chain under previous answer, isFirst template, Stop-cancels-chain |
| 14 | image-send | ✅ AS-IS prompt + refs + refusal | empty-prompt fallback, negative ref-forwarding |
| 15 | structure-title | ✅ wrap + set chat title | empty-title error, empty-chat context, reparent of first child |
| 16 | structure-overview | ✅ after-structure + first-node | empty-intro error |
| 17 | structure-heading | ✅ wrap new + patch existing | non-assistant guard, empty-heading error |
| 18 | structure-headings | ✅ happy + skip already-headed | abort/graceful-stop between chapters (persist current), empty-heading abort, active-path filter |
| 19 | language-check | ✅ raw variants, no mutation | empty-direction error |
| 20 | rewrite-selection | ✅ all 4 context modes + stream | empty-selection error, directions line, all-mode prefixes |
| 21 | send-chapter-descriptions | ✅ branch + history | param clamps, goal line, "Return only the chapter list" suffix, excludeFromContext |
| — | cross-cutting | dispatch, guards, postprocessor, evaluators, transport | glossary below |
Shared conventions for the new tests¶
- Mock transport. Existing core specs stub
global.fetchand buildResponse-shaped objects with the helpers inorchestration.spec.ts/flows.spec.ts/append.spec.ts(sseResponse,textResponse,jsonImagesResponse,sseImagesResponse). New tests reuse those helpers. - Fetch-index discipline. A
planned-enblock/planned-scenesrun issues the planning call first (index 0), then the render call. Tests asserting "raw chapter text never reaches the image model" must assert on the render fetch (index ≥ 1), because the planning call deliberately contains the beat (The beat to depict is: …). - Signals. Storyboard use cases receive
applySignal = chatService.beginOperation('illustrate'). To test Stop, abort the returned signal and assertisOperationCancelled()short-circuits and pictures already attached stay. - Versioning.
editAssistant/editAssistant(…, attachments, …)returns a NEW node id. Assert against the versioned id, not the placeholder. LLM-log usage must be re-targeted (llmLog.retargetNode) — assertentries()identity changes andnodeUsage(nodeId)> 0. - Component tests use
test-helpers/factories.ts(seedApi,makeNode,makeChat) test-helpers/in-memory-chat-api.ts, withprovideZonelessChangeDetection(),provideHttpClient(),{ provide: CHAT_API, useValue: api }and a mockedRouter.- localStorage persists across
its in Vitest —localStorage.clear()inbeforeEachbefore creating components when asserting dialog/option defaults.
Use case 1 — storyboard-direct¶
Dialog: count > 1, plan descriptions OFF. Image model gets the raw chat text + one "render N consistent scenes" prompt. No planning pass.
Dispatch (verified in pickUsecase): count > 1 && vars.planDescriptions === false → 'storyboard-direct'.
Existing coverage (orchestration.spec.ts):
- storyboard-direct does NOT send attachments to the image model either
- storyboard-direct: when the one-shot returns NOTHING, the per-scene fallback fills every picture and NO reprise runs
- storyboard-direct: fires onImages progressively as each per-scene fallback picture is ready
- storyboard-direct: a one-shot that returns every picture performs NO fallback at all
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC1-T1 | core | Batch returns N → one scene record | Mock render returning N images in ONE call | storyboard.scenes length 1 with all N images; exactly 1 image fetch; no per-scene calls |
| UC1-T2 | core | Batch returns 0 → per-scene fallback generates storyboardInstruction(i,count) scenes |
Mock 0 from batch, 1 image per scene call | N scene records; each scene prompt = anchor + storyboardInstruction(i, N); NO reprise fetch |
| UC1-T3 | core | Moderation/refusal of one scene in fallback | Scene call returns refusal with reason | That scene is refused: true, carries the reason; other scenes keep images (partial survival, mirror of existing keeps previously collected images…) |
| UC1-T4 | core | All scenes refused | All scene calls refused | plan().summary.allRefused === true, imagesTotal === 0; refused-prompt-1..N.txt attachments |
| UC1-T5 | core | Count==1 against storyboard-direct path | count:1, planDescriptions:false |
PickUseCase does NOT pick storyboard-direct (covered by dispatch, but assert via pickUsecase unit) |
| UC1-T6 | component | Partial → alert | illustrate() with model returning 2 of 3 |
alert fired with imagePartial (got:2, want:3); 2 images + prompt persisted on versioned chapter |
| UC1-T7 | component | Zero → alert with reply | Model refuses all | imageEmpty alert contains first 300 chars of model reply; refused-prompt attachments remain on node |
| UC1-T8 | component | Stop mid-fallback | Abort beginOperation('illustrate') signal between scene calls |
Fallback loop breaks; pictures already attached stay on the versioned node; isIllustrating cleared; no imageFailed alert |
Use case 2 — planned-enblock¶
Text (planning) model derives picture descriptions; ALL go as ONE block to the image model.
Dispatch (verified): purePictures → 'planned-enblock' (even count 1 — pure mode always plans);
else count > 1 && singleCall → 'planned-enblock'.
Existing coverage (orchestration.spec.ts):
- planned-enblock: reprise embeds ONLY the successful descriptions and adapts total to their count; skipped when ≤1 success
- planned-enblock: NO reprise runs when only ONE picture succeeded
- ATTACHES the fallback + reprise images to the chapter via the postprocessor
- planning includes the current ChatNode as the beat to depict
- tells the planning model the WINDOW scope (last N beats) when window mode is chosen
- does NOT forward reference attachments to the image model after planning
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC2-T1 | core | Pure-picture mode prompt template | purePictures: true, count 1 |
pickUsecase → planned-enblock; render fetch uses purePicturesPrompt (image.pure) — assert the render prompt does NOT contain raw chapter text |
| UC2-T2 | core | Non-pure template | purePictures: false |
Render prompt = oneShotStoryboardPrompt(instruction, descs, …); image model only ever sees the derived descriptions |
| UC2-T3 | core | Planning degraded → raw-anchor fallback | Planning returns unusable → descriptions = [] |
renderBatchWithFallback scene prompts fall back to plannedScenePrompt → raw anchor (+ storyboardInstruction when >1); no crash |
| UC2-T4 | core | plannedScenePrompt with description uses instruction, NOT anchor |
Success path, desc present | Per-scene fallback prompt starts from env.instruction.trim() ("Render exactly this picture description…"), never the chapter text |
| UC2-T5 | core | 0 successes → reprise builder returns null | All images refused | No reprise fetch (only batch + per-scene); spaces where successTexts.length <= 1 |
| UC2-T6 | core | Partial reprise merges into missing slots | Reprise returns fewer than count | Each returned reprise image merged into a still-missing slot; successful originals kept |
| UC2-T7 | core | Beat missing from render call | Assert render fetch body | Render fetch message list contains NO The beat to depict is:… (only present on planning fetch index 0) |
| UC2-T8 | core | Abort during planning | Abort signal during planning call | Planning aborts → error slot; no render call |
| UC2-T9 | component | Pure + count 1 → storyboard-style run | illustrate() with purePictures, count 1 |
isStoryboard true (Stop button), progressive onImages writes, final plan replaces interim |
Use case 3 — planned-scenes¶
Identical pipeline to planned-enblock; different prompt templates (
pictureScenePlanningInstruction+oneShotScenesPrompt).
Dispatch (verified): count > 1 (no singleCall, not pure, planning ON) → 'planned-scenes'.
Existing coverage (orchestration.spec.ts):
- uses the SCENE-oriented planning prompt for planned-scenes (fewer stills, more scenes)
- planned-scenes: when the scene one-shot returns NOTHING, the per-scene fallback + reprise behave exactly like planned-enblock (scene template)
- plans then renders ALL scenes in ONE en-block call; a batch that returns anything stops immediately (one picture or none)
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC3-T1 | core | Render + reprise both use oneShotScenesPrompt |
Success, then partial fallback | Both the initial batch prompt AND the reprise prompt are built by oneShotScenesPrompt (identical template, different description list) |
| UC3-T2 | core | Batch early-return invariant | Batch returns ≥1 picture (fewer than count) | Use case STOPS — no per-scene fallback, no reprise; the batch scene record carries the returned images |
| UC3-T3 | core | Scene planning prompt shape | Assert planning fetch (index 0) | Prompt built by pictureScenePlanningInstruction with scope line when window mode (--storyboardHistoryNodes) |
Use case 4 — render-full¶
Count 1, historyMode
'full': render the current node knowing the COMPLETE chat.
Dispatch (verified): count === 1 && historyMode === 'full' → 'render-full' (kept for backward compat; the dialog no longer produces it — legacy stored 'full' normalizes to 'single').
Existing coverage (orchestration.spec.ts):
- render-full does NOT send attachments to the image model either
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC4-T1 | core | Full context + anchor | Mock one image | Request messages = text-only full-history context + single renderMessages(anchor); exactly 1 image fetch |
| UC4-T2 | core | Legacy normalization | Component load with stored historyMode:'full' (chat.illustrateOptions.v1) |
Normalizes to 'single' → render-node on next use (memory-bullet behavior), no 'full' emitted |
| UC4-T3 | component | Placement | Success | illustration-1 + prompt-1.txt attached via editAssistant (versioned), activate emitted, pendingAction cleared |
| UC4-T4 | component | Refusal | Model refuses | refused-prompt-1.txt + imageEmpty alert |
Use case 4b — render-window¶
Count 1, historyMode
'window': render the current node knowing a bounded WINDOW of the lasthistoryNodesbeats (counted in ASSISTANT nodes only — the current node is the anchor, so a window of N keeps N-1 earlier chapters; user directions/lore never count).
Dispatch (verified): count === 1 && historyMode === 'window' → 'render-window'.
Runner = buildContextMessagesWindow(chatId, cParentId, precedingNodes) where
precedingNodes = max(0, historyNodes - 1). The same windowed context feeds the storyboard
planning pass (runPlanning uses env.contextMessages), so storyboards can be bounded too.
Existing coverage (orchestration.spec.ts):
- render-window counts beats in ASSISTANT nodes only and keeps the current chapter
- (window scope line for planning: tells the planning model the WINDOW scope …, planning includes the current ChatNode as the beat to depict — shared with planned-enblock)
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC4B-T1 | core | Window slice boundaries | historyNodes: 2, structure: dir → a1(assistant) → dir → a2(assistant, current) |
Context contains ONLY a1 (the single prior assistant beat), NOT the first direction system/lore/user gaps beyond it; anchor = current node text |
| UC4B-T2 | core | Window of 1 keeps NO prior beat | historyNodes: 1 |
Context empty; only renderMessages(anchor) + the anchor message reaches the model |
| UC4B-T3 | core | Non-window storage 'full' normalizes |
Legacy stored historyMode:'full' loaded (component) |
Normalizes to 'single' (render-node), 'window' no longer emitted by the dialog (memory-bullet behavior) |
| UC4B-T4 | core | Windowed planning scope for storyboards | historyMode:'window', count>1 → any planned storyboard |
Planning prompt carries illustrationScopeText('window', historyNodes) "last N beats" scope line; the runner bounded the planning context with the same historyNodes - 1 window |
| UC4B-T5 | core | historyNodes clamp |
Dialog/state historyNodes < min / > max |
clampHistoryNodes clamps to ILLUSTRATE_HISTORY_NODES_MIN..MAX; window width uses the clamped value |
| UC4B-T6 | core | <=0 window width |
precedingNodes computed as max(0, …) |
No crash; behaves like full path when the width is 0 |
Use case 5 — render-node¶
Count 1, historyMode
'single': render the CURRENT NODE only.
Dispatch (verified): any other single-picture case (default) → 'render-node'.
Existing coverage (orchestration.spec.ts):
- render-node does NOT send attachments to the image model either
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC5-T1 | core | No context messages | Mock one image | Request = ONLY renderMessages(anchor) — no preceding history content messages |
| UC5-T2 | core | Scene-text priority | vars.promptText, vars.content, empty node |
Priority order promptText → content → node.content; empty → (no text) anchor suffix |
| UC5-T3 | core | Pure + count 1 regression guard | purePictures:true, historyMode:'single' |
pickUsecase → planned-enblock (NOT render-node) — guards the fixed raw-anchor leak |
| UC5-T4 | component | Per-use-case window width | Single picture uses historyNodes, storyboard uses storyboardHistoryNodes |
activeHistoryNodes correct per count; both remembered separately |
Use case 6 — append¶
Normal send: full history + topic system prompt, streamed text answer.
Entry: chat-node.sendDraft() → streamAppendOrchestrated() → runner.run({usecase:'append'}).
Runner resolves the WRITE model, builds full-chat messages, streams via orchestrator.
Existing coverage (append.spec.ts):
- streams the answer with full history as context, then appends an assistant node
- uses the saved node content when no draft override is given
- surfaces an empty stream as empty (no fragment), never throws
- uses the "default" generation task model when a node has no model binding
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC6-T1 | core | No writing model enabled | Remove all write-capable models | Error slot No writing model is enabled.; nothing streamed |
| UC6-T2 | core | Topic system prompt injected | Chat without a system message | Request has system (topic) message first (buildSendMessages/Ex defaultSystemPrompt) |
| UC6-T3 | core | imageCapable write model → image-only | Write model canGenerateImages, returns 200 {content:'',thinking:''} + image (the empty-200 fix) |
streamAnswerNode fills chapter with the ORIGINAL direction; generated attachment present |
| UC6-T4 | core | Non-stream chat config | stream:false in textExtras |
completion issued stream:false; full JSON answer persisted |
| UC6-T5 | core | Live paint during stream | onChunk capturing |
Node content grows per chunk (partial); final finalizeStream settles the text slot |
| UC6-T6 | core | Log + usage retarget | Success with usage | Log entry recorded under placeholder nodeId; llmLog.retargetNode to versioned id; nodeUsage(versionedId) reports tokens |
| UC6-T7 | component | Editor closes on Send / auto-exclude | sendDraft (Continue/Switch) |
closeEditor() runs before streaming; auto-generated directions patched excludeFromContext:true |
Use case 7 — append-with-images¶
Send with images: interpret ONLY when the description is not already stored.
Rule (verified in prepareTextSend): needsImageInterpretation =
images present AND no matching image-description.txt record AND stored description not already
part of node content.
Existing coverage: append.spec.ts (interprets the attached images…, falls back to a plain append when no image-interpret model is configured) + flows.spec.ts interpretation block
(needsImageInterpretation: fresh images → true; after a stored record → false; new images → true,
append-with-images: interprets + stores the record; a second run does NOT re-interpret,
forces text modality when the interpret model can also generate images,
does not merge a placeholder when the interpret reply carries no text).
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC7-T1 | core | "Already part of content" skip clause | Node content already contains the stored description string | NO interpret call; plain buildSendMessagesEx |
| UC7-T2 | core | Record signature mismatch | Different images than the stored record | Re-interpreted; new merged content + new record replaces stale (dedupe keeps images, record refreshed) |
| UC7-T3 | component | Persist merged direction + record | streamAppendOrchestrated, interpret success |
patchNode(question) content = direction + description; record attachment added; re-send does not interpret |
| UC7-T4 | component | Content already matches | Merged == question content | Only record appended to the question attachments (no content change) |
Use case 8 — send-branch¶
New sibling question + streamed answer. Model override from the editor wins.
Existing coverage (flows.spec.ts): happy path, IMAGE-ONLY answers (stream + non-stream),
NON-STREAMING full text, stream:false config, /images image-generation write model,
lore re-arm tests.
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC8-T1 | core | Branch from a QUESTION → sibling | node.role === 'user' |
branchQuestion; new question parent = node's parent (sibling), not node |
| UC8-T2 | core | Branch from an ANSWER → child question | node.role === 'assistant' |
addNode child under the answer; thread continues from that point |
| UC8-T3 | core | Carried attachments dedupe | Node has 2 attachments, draft repeats one by dataUrl | carryAttachments output has no duplicates |
| UC8-T4 | core | No writing model | Remove write models | LlmFlowRunner error slot, no chat mutation |
| UC8-T5 | component | Editor closes after OK, activate | saveAsBranchAndSend |
closeEditor() runs only when flow returns true; activate(newQuestion) emitted |
| UC8-T6 | component | Model override wins | Editor selected different model than node | New question + writer use vars.modelId/providerId |
Use case 9 — send-insert¶
New sibling + answer that ADOPTS the old siblings.
Implementation notes (verified): branchQuestion + IMMEDIATE setActiveChild(parentId, newQuestion.id)
(before streaming, so it paints), adoptNodeIds = all siblings under parentId except the new question,
streamAnswerNode({adoptNodeIds}), then setActiveChild(answerNodeId, firstAdopt) where
firstAdopt = node.id when the source node is in the adopt list.
Existing coverage (flows.spec.ts):
- send-insert: creates a sibling + answer and hangs the old siblings under the new answer
- send-insert: re-arms the shared lore node of the direction being inserted above so the new direction gets the lore
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC9-T1 | core | Active-path switch BEFORE stream | Mock a pending stream; inspect getActivePath() |
After branchQuestion, the active path already contains the new question (not the editing node) |
| UC9-T2 | core | Adopt set excludes new question | Siblings = [A, node, B] | adoptNodeIds = [A, node, B]; all re-parented under the versioned answer |
| UC9-T3 | core | firstAdopt = source node | Source node in adopt list | setActiveChild(answer, node) so the user stays on their leaf |
| UC9-T4 | component | Editor closes BEFORE flow | saveAsInsertAndSend |
closeEditor() runs before runFlow (timing mirroring Send) |
| UC9-T5 | component | Only from user nodes | Press on assistant | Guard returns early (no flow) |
Use case 10 — send-regenerate¶
Confirm → delete the answer + subtree → re-stream a fresh answer under the question.
Implementation notes (verified): parentQuestion(env, node) (error slot No question node to regenerate.
if absent), ensureLoreNodeBefore(question) re-arms lore, deleteNode(chatId, node.id) whole subtree,
prepare on the QUESTION (content+attachments), streamAnswerNode, activate question.
Existing coverage (flows.spec.ts):
- send-regenerate: deletes the old answer and re-streams under the same question
- send-regenerate: an IMAGE-ONLY answer fills the fresh answer with the direction and attaches the image
- send-regenerate: clears the exclude flag of the lore node before the assistant and re-sends the lore
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC10-T1 | core | Whole subtree tombstoned | Answer has child nodes | ALL descendants deleted (deleteNode full-subtree), NOT keepChildren |
| UC10-T2 | core | Old attachments NOT preserved | Old answer has illustration-1 | After regenerate, old image GONE; only fresh images attached (by design) |
| UC10-T3 | core | No parent question | Node with parentId: null or parent missing |
Error slot; no delete, no stream |
| UC10-T4 | component | Confirm cancel | regenerateAnswer() dialog cancelled |
Nothing deleted/streamed, isLoading false |
| UC10-T5 | component | Usage on fresh answer | Success with usage | Log entry re-targeted to the NEW versioned answer; node shows usage |
Use case 11 — send-rewrite¶
Delete ONLY the answer (keep children), re-stream, re-adopt children + preserve old attachments.
Implementation notes (verified): adoptNodeIds = children(node.id),
preservedAttachments = (node.attachments ?? []).slice(), deleteNode(..., {keepChildren:true}),
streamAnswerNode({adoptNodeIds, preserveAttachments}),
mergeAnswerAttachments = preserved + new generated images renumbered after kept
(startIndex = existingGeneratedImageCount(preserved) → new image becomes illustration-2).
Existing coverage (flows.spec.ts):
- send-rewrite: deletes only the answer and re-adopts the preserved children
- send-rewrite: routes an image-GENERATION WRITE model to /images and re-attaches the picture
- send-rewrite: PRESERVES the old answer's attachments and merges the new image (previous pictures stay available)
- send-rewrite: clears the exclude flag of the lore node before the assistant and re-sends the lore
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC11-T1 | core | Renumber preserved + new | Old illustration-1 present, model returns 1 new image |
Fresh node has illustration-1 (kept) + illustration-2 (new), names unique |
| UC11-T2 | core | Only preserved, no new image | No fresh image | Finalize still runs (guard preserveAttachments.length > 0); preserved attachments attached |
| UC11-T3 | core | Keep-children re-parent chain | Children under answer | After delete they re-parent to the question, then re-adopt under fresh answer; setActiveChild(answer, firstAdopt) |
| UC11-T4 | component | Confirm shows child count | regenerateInPlace() |
Confirm message includes children count; cancel → no mutation |
Use case 12 — send-prepend¶
Director (user) node + streamed result BEFORE the current direction, adopting the current node.
Implementation notes (verified): prompt = directorText + followingText (from
followingAssistantContent(node)), error slot No director text to prepend. when empty,
addNode director under parent, buildSendMessagesEx(directorNode, contentOverride),
streamAnswerNode({adoptNodeIds:[node.id]}), reparent + setActiveChild(parent, director).
On failure the component rolls back chat.prependByNodeId → prependEnabled = false.
Existing coverage (flows.spec.ts):
- send-prepend: inserts a director node + result BEFORE the current direction, adopting it
- prepend dialog component spec (prepend-dialog.component.spec.ts)
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC12-T1 | core | Following-text prompt | followingText set |
Final user message = director + following chapters (\n\n joined); history text-only up to current node |
| UC12-T2 | core | Empty director | no directorText | Error slot; no node created |
| UC12-T3 | core | Adopt the current direction | Success | Current node re-parented under the fresh answer; setActiveChild(parent, director) keeps leaf intact |
| UC12-T4 | core | Following chapters ≤ ~5000 tokens | Long following content | followingAssistantContent truncates; chapters after node included up to cap |
| UC12-T5 | component | Failure rollback | Flow throws / refused | chat.prependByNodeId removed → prepend re-enabled; director node may remain but no result inserted |
| UC12-T6 | component | One prepend per node guard | Flag already set | Second Prepend on same node disabled / silently no-op |
Use case 13 — send-elaborate¶
Append one chapter elaboration: create/reuse a question under the anchor and stream an answer.
Implementation notes (verified):
- Anchor = activeTreeAssistantNode() (currently visible tree, NOT most-recent-by-timestamp).
- Per chapter/name: render('structure.elaborate[-view]', { chapter[, name] }); model override from dialog wins.
- getOrCreateElaborateQuestion: reuse an empty user leaf (no content, no attachments) via
persistQuestion, else addNode; both get excludeFromContext: true (auto-generated meta-direction).
- Chain: next iteration anchors under the PREVIOUS answer (slots.flow.value.answerNodeId).
- beginOperation('elaborate'); a Stop cancels the whole chain.
Existing coverage (flows.spec.ts):
- send-elaborate: append an answer under a fresh question under the anchor, honoring the model override
- send-elaborate: reuses an existing empty user leaf draft under the anchor
- chat.component.spec regression: anchors elaborate on the currently visible tree, not the most recent branch
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC13-T1 | core | Fresh question created under anchor | No draft leaf | New user question under anchor; excludeFromContext:true; streamed answer under it |
| UC13-T2 | core | Reused draft path | Empty user leaf exists | persistQuestion onto the draft (not a new node); change excluded |
| UC13-T3 | core | Chained anchoring | 2+ chapters | Chapter 2's question is a child of chapter 1's ANSWER (parent chain); isFirst differs per iteration |
| UC13-T4 | core | Model override wins | Dialog model ≠ node model | resolveWriteModel(anchor, modelId, providerId) uses override; question + writer carry it |
| UC13-T5 | core | Empty prompt | blank content | Error slot No elaborate prompt.; nothing created |
| UC13-T6 | component | Stop cancels whole chain | Abort after chapter 1 completes | Chapter 1 answer stays; no further chapters; beginOperation('elaborate') cleaned up |
| UC13-T7 | component | Prompt template {{#if isFirst}} |
First vs follow-up view | First view: free writing; follow-up: "only new plot material, no repetition" from TEMPLATE text |
| UC13-T8 | component | Remembered state | saveElaborateState |
lastChapter + characters restored on next dialog open |
Use case 14 — image-send (bonus; same dispatch family)¶
Registered alongside the illustrated use cases (image-send): prompt sent AS-IS (no planning,
no drawing-instruction template), optional reference images forwarded as image_url parts,
picture attached to the assistant node by the caller.
Existing coverage (orchestration.spec.ts, describe('LLM orchestration — image-send …')):
- sends the combined constant + marked text AS-IS (no drawing instruction, no refs)
- an explicit modelId in vars wins over the settings image-create default
- a stale/missing providerId must NOT silently drop the chosen model for the settings default
- forwards reference images as image_url parts when attached
- turns a moderation refusal into a refused scene with the reason
- image-generation (explicit single picture) describe block
Gap candidates: reference images NOT forwarded when attachments empty/absent (already positive-tested;
add the negative assert), and promptText empty → (no text) / "See the attached reference image(s)." fallback.
Use case 15 — structure-title¶
Generate a story TITLE from ALL current chapters, wrap the story under a structural node at the root, and make the title the chat title.
Implementation (flows/structure.ts): textCompletion (non-streaming, write model) with
structureInstruction('title') + wholeStoryContext + "Return only the resulting text.".
Empty result → error slot No title generated.. Then addStructureNode(parentId: null), and
when a root child exists it is re-parented under the new title (reparentNodes + setActiveChild
chain); always updateChatTitle.
Existing coverage (flows.spec.ts):
- structure-title: wraps the story under a root title and sets the chat title
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC15-T1 | core | Empty chat context | No current assistant chapters | Completion body contains (empty chat); title still produced |
| UC15-T2 | core | Empty title result | Model returns blank/whitespace | Error slot No title generated.; NO structural node created, chat title untouched |
| UC15-T3 | core | Refused/error completion | Completion refused | Error/refused slot surfaces; no flow.structureNodeId |
| UC15-T4 | core | Log usecase attribution | Spy on LlmLogService.record |
Entry usecase === 'structure-title', chatId set |
| UC15-T5 | core | Whole-story context only | Direction text in the chat | wholeStoryContext includes ONLY current assistant chapters, not user directions |
| UC15-T6 | component | Chat title updates | Confirm | updateChatTitle called with generated title (row title reflects it) |
Use case 16 — structure-overview¶
Generate an INTRODUCTION from ALL current chapters; place it right after an existing structural node, or as the FIRST node when none exists.
Existing coverage (flows.spec.ts):
- structure-overview: places the introduction right after an existing structure node
- structure-overview: becomes the first node when the story has no structure node
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC16-T1 | core | Empty intro result | Model returns whitespace | Error slot No introduction generated.; no node created |
| UC16-T2 | core | Placement under existing structure | Title exists → rootChildren.find(role==='structural') |
Intro parentId === title.id; the first active child of the title is re-parented under intro; setActiveChild chain restored |
| UC16-T3 | core | Re-parent preserves tree | Story already has children under the title | reparentNodes([firstChild], intro.id) then active = intro → firstChild (order preserved) |
| UC16-T4 | core | Whole-story context rule | Directions present | Same assistant-only wholeStoryContext rule as UC15-T5 |
Use case 17 — structure-heading¶
Generate a heading for ONE chapter (the current node). Patch the chapter's structural parent when one exists, else wrap the chapter under a new structural node.
Implementation: guard node.role !== 'assistant' → error No assistant chapter to head.;
context = ONLY the chapter's own text; empty result → No heading generated.;
existingHeading = parent node is structural → patchNode on it; else addStructureNode +
reparentNodes([node]) + active chain.
Existing coverage (flows.spec.ts):
- structure-heading: wraps the chapter under a NEW heading using only the chapter text
- structure-heading: PATCHES the existing structural parent of the chapter
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC17-T1 | core | Non-assistant guard | Run on a user node | Error slot No assistant chapter to head.; no fetch, no mutation |
| UC17-T2 | core | Empty heading result | Blank completion | Error No heading generated.; nothing wrapped/patched |
| UC17-T3 | core | Only chapter text reaches model | Direction text in parent | Completion body contains chapter content, NOT the parent direction |
| UC17-T4 | core | Wrapped under new structure with parentId | Chapter parent = a user node | New heading parentId === chapter.parentId; chapter re-parented under heading; active = heading → chapter |
| UC17-T5 | core | Patched node provenance | Existing heading patched | patchNode sets content + modelId/providerId of the flow writer |
Use case 18 — structure-headings¶
Generate a heading for EVERY chapter on the active path that has none yet. Already-headed chapters stay in context (as previous chapters incl. their headings) but are NOT regenerated. One Stop (hard abort OR graceful
shouldStopAfterCurrentStep) cancels the run between chapters, but the heading that is CURRENTLY being generated is still fetched and persisted.
Implementation (flows/structure.ts): loop over getActivePath() assistant chapters with
content; per chapter: stop-check → skip if parent is structural (push to previous) → build
blocks (prior chapters + generated headings + the current unheaded one) → completion →
hard abort break → empty-heading → error (aborts whole run) → add/reparent/active →
push to previous + createdIds. Result structureResult({}, lastId, createdIds).
Existing coverage (flows.spec.ts):
- structure-headings: heads every unheaded chapter, keeping prior headings in context
- structure-headings: skips chapters that already have a heading but keeps them in context
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC18-T1 | core | Hard abort between chapters | 2 unheaded chapters; abort signal after first completion resolves | First heading persisted; loop breaks; NO request for second chapter; no error slot |
| UC18-T2 | core | Graceful shouldStopAfterCurrentStep |
Stop flag set before a chapter's request | Current in-flight heading STILL fetched + persisted; run ends after that chapter |
| UC18-T3 | core | Empty heading mid-run | Second completion blank | Error slot No heading generated.; whole run aborts; previously created headings stay |
| UC18-T4 | core | Active-path filter | An assistant exists off the active path | Only active-path assistants are headed; off-path chapter untouched |
| UC18-T5 | core | Context includes earlier generated headings | Before heading for chapter N | Body contains <that chapter> — heading: "…" blocks for chapters 1..N-1 with their generated headings |
| UC18-T6 | core | StructureNodeIds creation order + activation | 3 unheaded chapters | structureNodeIds length 3 in creation order; flow structureNodeId = last created |
| UC18-T7 | core | No unheaded chapters | All headed | No fetches; structureNodeIds empty; active path unchanged |
Use case 19 — language-check¶
Copy-edit a writing direction and return the RAW corrected variants (no chat mutation — the caller parses
text).
Existing coverage (flows.spec.ts):
- language-check: returns the raw corrected variants (no chat mutation)
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC19-T1 | core | Empty direction | vars.content blank |
Error slot No direction to check.; no fetch, no mutation |
| UC19-T2 | core | Prompt shape | Completion | Body = englishCheckInstruction + "Original direction:" + the text; non-streaming |
| UC19-T3 | core | No chat mutation (all paths) | Success + refusal | Node count unchanged in both cases; no structural nodes created |
| UC19-T4 | core | Raw value | JSON-list reply | slots.text.value === exact raw string (caller parses) |
Use case 20 — rewrite-selection¶
Propose corrected variants of a MARKED piece of text, optionally grounded in surrounding context. No chat mutation; STREAMED (so the dialog shows live progress).
Implementation (flows/structure.ts): context scope 'none' | 'node' | 'upto' | 'all'
(default 'node'); body = rewriteInstruction + optional User directions — the rewrite MUST
follow these: + optional Context the marked text appears in: + Marked text to rewrite:;
upto slices nodeContent.slice(0, max(0, selectionEnd)); all uses buildSendMessagesEx
rendered as User:/Assistant:/System: prefixed blocks; textCompletion(…, { stream: true }).
Existing coverage (flows.spec.ts):
- isFlowUsecase recognizes rewrite-selection
- no context: sends only instructions + directions + the marked text
- current-node context: the whole node content is included
- upto context: only the node text up to and including the marked part
- whole-text context: the entire thread up to the node is included
- streams chunks through onChunk so the caller can show live progress
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC20-T1 | core | Empty marked text | content blank |
Error slot No text to rewrite.; no fetch |
| UC20-T2 | core | Default mode is node |
contextMode omitted |
Context = whole node content (existing "current-node" path) — assert no all-mode prefixes |
| UC20-T3 | core | upto boundary |
selectionEnd beyond length or negative |
Slice clamped via max(0, end); no throw |
| UC20-T4 | core | Stream flag + onChunk | Spy on transport body | stream: true; onChunk invoked per chunk so dialog can refresh |
| UC20-T5 | core | No chat mutation | Success | Node/attachment counts unchanged; only text slot filled |
| UC20-T6 | core | all-mode prefixes |
contextMode:'all' |
Body contains User:, Assistant:, System: prefixed history blocks and the final Marked text to rewrite: line |
Use case 21 — send-chapter-descriptions¶
Plan a chapter description list as ANOTHER branch: branch a new sibling question off the empty leaf (the FULL previous chat content is the history) whose text requests the list with the parameters, then stream the answer under it. The empty leaf question where Continue lives stays untouched.
Implementation (flows/sends.ts): request = chapterDescriptionsInstruction() + parameter
lines (count, sentences, first, optional goal, Return only the chapter list, starting
at chapter {first}.); branchQuestion off the current (empty-leaf) node, setActiveChild
IMMEDIATELY, patchNode(excludeFromContext:true), ensureLoreNodeBefore, prepareTextSend,
streamAnswerNode; result flowResult({ questionNodeId, answerNodeId, activateId: answer, branchNodeId }).
Parameter clamps: max(1, floor(Number(...) || 1)) for count/sentences/firstChapter.
Existing coverage (flows.spec.ts):
- send-chapter-descriptions: branches a sibling question with the request and streams the list (previous chat content as history)
Planned new tests:
| ID | Level | Scenario | Steps | Expected |
|---|---|---|---|---|
| UC21-T1 | core | Param clamps | chapterCount: 0, sentencesPerChapter: NaN, firstChapter: -3 |
Eff 1 / 1 / 1; request lines say Number of chapters to plan: 1 etc. |
| UC21-T2 | core | Goal included when set / omitted when absent | Goal 'end a scene' vs none |
Only present builds the What the chapters are meant to achieve: block |
| UC21-T3 | core | Request tail | Any | Request ends with Return only the chapter list, starting at chapter {first}. |
| UC21-T4 | core | Empty leaf stays untouched | Flow on an empty composer leaf | Leaf node itself NOT mutated; branch sibling created; excludeFromContext:true on the new question |
| UC21-T5 | core | Lore + exclusion | After send | markSentOnceExcluded finalizes the one-shot lore; excludeFromContext remains on the request question |
Cross-cutting test responsibilities¶
| Area | Covered by | Planned additions |
|---|---|---|
Dispatch table pickUsecase |
maps dialog vars to the 5 use cases |
Exhaustive table incl. pure+count1 → planned-enblock, window, singleCall, planDescriptions:false |
controllerFor unknown |
— | Throws for an unknown kind |
| No-model guards (image & write) | orchestration.spec error-slot cases | Component: node.imageModelMissing alert when no image-create model |
| Evaluators | LLM orchestration — evaluators (pure, never throw) block |
— |
| Transport fallback tree | LLM orchestration — transport fallbacks block (refusals, /images, modalities retry, non-stream retry, include_usage, timeout/abort) |
— |
| Postprocessor placement | partially in ATTACHES the fallback + reprise images… |
Pure plan tests: naming illustration-N/prompt-N/refused-prompt-N, allRefused/partial/imagesTotal/imagesRefused, resolveChapterNode assistant-vs-current-assistant-child, plan() → null when no chapter |
Progressive storyboard writes (component onImages) |
fires onImages progressively… (core) |
Component: interim writes version + re-target currentTargetId, final plan replaces interim without duplicates |
| Versioning + log retarget | usage.spec / llm-log.service.spec / append.spec | Covered per-use-case above (UC6-T6, UC10-T5) |
| Lore re-arm per flow | flows.spec lore block (regenerate/rewrite/insert) | Add branch + prepend + elaborate lore re-arm |
markSentOnceExcluded after send |
ensureLoreNodeBefore… tests |
Assert it runs in finally even when the stream errors |
Suggested implementation order¶
- Dispatch + guards (small, unblocks everything): exhaustive
pickUsecase+controllerFor+ no-model guards. - Postprocessor pure plan suite (pure functions, no mocks): attachment naming + summaries +
resolveChapterNode. - Use-case gaps with the highest regression value first:
UC2-T1(pure→planned-enblock prompt leak guard — ties to a real fixed bug),UC1-T3/UC1-T4(refusal per scene),UC5-T3(pure+count1 regression guard),UC9-T1(send-insert active-path timing — real user symptom),UC11-T1(attachment renumber),UC12-T5(prepend rollback),UC13-T3/T6(chain + Stop). - Component-level tests (alerts, editor timing, progressive interim writes) in
chat-node.component.spec.ts/chat.component.spec.ts.