| import assert from "node:assert/strict"; |
| import { describe, test } from "node:test"; |
| import type { AttachmentRef, StoredMessage } from "@maka/core"; |
| import { |
| finalAssistantReplyText, |
| materializeChat, |
| materializeTools, |
| materializeTurns, |
| overlayLiveTurn, |
| type TurnTimelineItem, |
| } from "../materialize.js"; |
| import { applyLiveTurnEvent, armLiveTurn } from "../live-turn-projection.js"; |
| |
| const imageAttachment: AttachmentRef = { |
| kind: "image", |
| name: "chart.png", |
| mimeType: "image/png", |
| bytes: 1024, |
| ref: { kind: "session_file", sessionId: "s1", relativePath: "chart.png" }, |
| }; |
| |
| const codeAttachment: AttachmentRef = { |
| kind: "code", |
| name: "main.ts", |
| mimeType: "text/typescript", |
| bytes: 512, |
| ref: { kind: "workspace_file", relativePath: "src/main.ts" }, |
| }; |
| |
| describe("materializeChat attachments", () => { |
| test("preserves the original prompt and projects same-turn steering separately", () => { |
| const messages: StoredMessage[] = [ |
| { type: "user", id: "original", turnId: "t1", ts: 1, text: "original request" }, |
| { type: "user", id: "steer-1", turnId: "t1", ts: 2, text: "inserted instruction" }, |
| { |
| type: "assistant", |
| id: "answer", |
| turnId: "t1", |
| ts: 3, |
| text: "done", |
| modelId: "fixture", |
| }, |
| ]; |
| |
| const [turn] = materializeTurns(messages); |
| assert.equal(turn?.user?.text, "original request"); |
| assert.deepEqual(turn?.userInterjections?.map((message) => message.text), [ |
| "inserted instruction", |
| ]); |
| }); |
| |
| test("overlays a steering message immediately and deduplicates its persisted row", () => { |
| const settled = materializeTurns([ |
| { type: "user", id: "original", turnId: "t1", ts: 1, text: "original request" }, |
| ]); |
| const live = applyLiveTurnEvent(armLiveTurn("t1"), { |
| type: "steering_message", |
| id: "event-steer", |
| messageId: "steer-1", |
| turnId: "t1", |
| ts: 2, |
| content: { text: "inserted instruction" }, |
| }); |
| |
| const [overlaid] = overlayLiveTurn(settled, live); |
| assert.deepEqual(overlaid?.userInterjections?.map((message) => message.text), [ |
| "inserted instruction", |
| ]); |
| |
| const persisted = materializeTurns([ |
| { type: "user", id: "original", turnId: "t1", ts: 1, text: "original request" }, |
| { type: "user", id: "steer-1", turnId: "t1", ts: 2, text: "inserted instruction" }, |
| ]); |
| const [deduplicated] = overlayLiveTurn(persisted, live); |
| assert.deepEqual(deduplicated?.userInterjections?.map((message) => message.text), [ |
| "inserted instruction", |
| ]); |
| }); |
| |
| test("projects frozen inline references onto chat and turn user messages", () => { |
| const inlineReferences = [ |
| { |
| kind: "skill" as const, |
| value: "/skill:writer", |
| label: "Writer", |
| start: 4, |
| }, |
| { |
| kind: "workspace_file" as const, |
| value: "@docs/my plan.md", |
| label: "my plan.md", |
| start: 21, |
| }, |
| ]; |
| const messages: StoredMessage[] = [ |
| { |
| type: "user", |
| id: "m1", |
| turnId: "t1", |
| ts: 1, |
| text: "model envelope", |
| displayText: "Use /skill:writer on @docs/my plan.md", |
| inlineReferences, |
| }, |
| ]; |
| |
| assert.deepEqual( |
| materializeChat(messages)[0]?.inlineReferences, |
| inlineReferences, |
| ); |
| assert.deepEqual( |
| materializeTurns(messages)[0]?.user?.inlineReferences, |
| inlineReferences, |
| ); |
| }); |
| |
| test("preserves an explicit empty reference projection as the new-format marker", () => { |
| const messages: StoredMessage[] = [ |
| { |
| type: "user", |
| id: "m-empty", |
| turnId: "t-empty", |
| ts: 1, |
| text: "plain text", |
| inlineReferences: [], |
| }, |
| ]; |
| assert.deepEqual(materializeChat(messages)[0]?.inlineReferences, []); |
| assert.deepEqual(materializeTurns(messages)[0]?.user?.inlineReferences, []); |
| }); |
| |
| test("projects user message attachments onto the chat item", () => { |
| const messages: StoredMessage[] = [ |
| { |
| type: "user", |
| id: "m1", |
| turnId: "t1", |
| ts: 1, |
| text: "see this", |
| attachments: [imageAttachment, codeAttachment], |
| }, |
| ]; |
| const items = materializeChat(messages); |
| assert.equal(items.length, 1); |
| assert.deepEqual(items[0].attachments, [imageAttachment, codeAttachment]); |
| }); |
| |
| test("leaves attachments absent when the user message has none", () => { |
| const messages: StoredMessage[] = [ |
| { type: "user", id: "m1", turnId: "t1", ts: 1, text: "plain prompt" }, |
| ]; |
| const items = materializeChat(messages); |
| assert.equal(items.length, 1); |
| assert.equal(items[0].attachments, undefined); |
| }); |
| |
| test("preserves Host provenance on a Goal continuation", () => { |
| const messages: StoredMessage[] = [ |
| { |
| type: "user", |
| id: "m1", |
| turnId: "t1", |
| ts: 1, |
| text: "[Goal continuation] Keep working.", |
| origin: { kind: "goal", goalId: "goal-1" }, |
| }, |
| ]; |
| |
| assert.deepEqual(materializeChat(messages)[0]?.hostOrigin, { |
| kind: "goal", |
| goalId: "goal-1", |
| }); |
| assert.deepEqual(materializeTurns(messages)[0]?.user?.hostOrigin, { |
| kind: "goal", |
| goalId: "goal-1", |
| }); |
| }); |
| |
| test("surfaces automatic context compaction system notes inline", () => { |
| const messages: StoredMessage[] = [ |
| { |
| type: "system_note", |
| id: "note-1", |
| turnId: "t1", |
| ts: 1, |
| kind: "context_compacted", |
| }, |
| ]; |
| const items = materializeChat(messages); |
| assert.equal(items.length, 1); |
| assert.equal(items[0].role, "system"); |
| assert.equal( |
| items[0].text, |
| "Context compacted to keep this session within the model window.", |
| ); |
| }); |
| |
| test("surfaces history compaction fail-open notices inline", () => { |
| const messages: StoredMessage[] = [ |
| { |
| type: "system_note", |
| id: "note-1", |
| turnId: "t1", |
| ts: 1, |
| kind: "context_compaction_failed_open", |
| }, |
| ]; |
| const items = materializeChat(messages); |
| assert.equal(items.length, 1); |
| assert.equal(items[0].role, "system"); |
| assert.equal( |
| items[0].text, |
| "Context summary failed; the session continued without a new summary.", |
| ); |
| }); |
| |
| test("surfaces a step-limit system notice inline", () => { |
| const items = materializeChat([ |
| { |
| type: "system_note", |
| id: "note-1", |
| turnId: "t1", |
| ts: 1, |
| kind: "step_limit", |
| }, |
| ]); |
| |
| assert.equal(items.length, 1); |
| assert.equal(items[0].role, "system"); |
| assert.equal( |
| items[0].text, |
| "Reached the configured step limit. The task may be incomplete. Send “continue” to resume.", |
| ); |
| }); |
| }); |
| |
| // ── #1307: the timeline model stays flat (fold is a render concern) ────────── |
| |
| function userMsg(turnId: string, ts: number, text: string): StoredMessage { |
| return { type: "user", id: `u-${turnId}`, turnId, ts, text }; |
| } |
| |
| test('retains persisted nested tool activity identity', () => { |
| const [tool] = materializeTools([{ |
| type: 'tool_call', |
| id: 'nested-1', |
| turnId: 'turn-1', |
| ts: 1, |
| toolName: 'Read', |
| args: { path: 'README.md' }, |
| origin: 'code_mode', |
| modelVisibility: 'hidden', |
| parentToolCallId: 'exec-1', |
| parentOperationId: 'exec-operation-1', |
| }]); |
| |
| assert.deepEqual(tool, { |
| toolUseId: 'nested-1', |
| toolName: 'Read', |
| activityKind: undefined, |
| displayName: undefined, |
| intent: undefined, |
| status: 'interrupted', |
| args: { path: 'README.md' }, |
| result: undefined, |
| durationMs: undefined, |
| origin: 'code_mode', |
| modelVisibility: 'hidden', |
| parentToolCallId: 'exec-1', |
| parentOperationId: 'exec-operation-1', |
| }); |
| }); |
| |
| function shellRunResult(revision: number) { |
| return { |
| kind: "shell_run" as const, |
| ref: "maka://runtime/background-tasks/pty-1", |
| mode: "pty" as const, |
| status: "running" as const, |
| cwd: "/repo", |
| cmd: "job", |
| startedAt: 1, |
| updatedAt: revision, |
| revision, |
| output: { |
| mode: "pty" as const, |
| screen: "ready", |
| scrollback: "", |
| cols: 80, |
| rows: 24, |
| cursor: { x: 0, y: 0, visible: true }, |
| alternateScreen: false, |
| truncated: false, |
| redacted: false, |
| }, |
| }; |
| } |
| |
| describe("flat timeline under tool projection (#1307 P1 regression)", () => { |
| test("shell-run folding away a turn’s only tool leaves a flat thinking-only timeline", () => { |
| // Turn t1 owns the Bash ShellRun parent; the live turn t2's ONLY tool is a |
| // Read carrying a shell_run result with the same ref, so foldShellRunTurns |
| // merges it into t1's Bash and drops it from t2 entirely. With the fold |
| // living in the model this used to strand an illegal thinking-only |
| // "processing" block with an empty summary; the flat model simply drops |
| // the emptied tools group. |
| const settled = materializeTurns([ |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 1, |
| toolName: "Bash", |
| args: { command: "job", pty: true }, |
| }, |
| { |
| type: "tool_result", |
| id: "r-bash-1", |
| turnId: "t1", |
| ts: 2, |
| toolUseId: "bash-1", |
| isError: false, |
| content: shellRunResult(1), |
| }, |
| userMsg("t2", 3, "q"), |
| ]); |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t2", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "a1", |
| thinking: { |
| text: "watching the background job", |
| truncated: false, |
| complete: false, |
| }, |
| tools: [ |
| { |
| toolUseId: "read-1", |
| toolName: "Read", |
| stepId: "a1", |
| status: "completed", |
| args: {}, |
| result: shellRunResult(2), |
| }, |
| ], |
| }, |
| ], |
| }); |
| const liveTurn = turns.find((turn) => turn.turnId === "t2"); |
| assert.deepEqual( |
| liveTurn?.timeline.map((item: TurnTimelineItem) => item.kind), |
| ["thinking"], |
| ); |
| }); |
| }); |
| |
| describe("live content over persisted partial rows", () => { |
| test("replaces persisted thinking with its live projection instead of rendering it twice", () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "inspect it"), |
| { |
| type: "turn_state", |
| id: "state-1", |
| turnId: "t1", |
| ts: 2, |
| status: "running", |
| partialOutputRetained: false, |
| }, |
| { |
| type: "assistant", |
| id: "assistant-1", |
| turnId: "t1", |
| ts: 3, |
| text: "", |
| modelId: "test-model", |
| thinking: { text: "persisted partial" }, |
| contentOrder: ["thinking"], |
| }, |
| ]); |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t1", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "assistant-1", |
| thinking: { |
| text: "complete live reasoning", |
| truncated: false, |
| complete: false, |
| }, |
| contentOrder: ["thinking"], |
| tools: [], |
| }, |
| ], |
| }); |
| const thinking = turns[0]?.timeline.filter( |
| (item) => item.kind === "thinking", |
| ); |
| |
| assert.equal(thinking?.length, 1); |
| assert.equal( |
| thinking?.[0]?.kind === "thinking" ? thinking[0].text : undefined, |
| "complete live reasoning", |
| ); |
| }); |
| }); |
| |
| describe("unfinished tools take their status from the turn", () => { |
| // A missing tool_result is the absence of evidence, not evidence of a |
| // terminal state. The turn record says which: a running turn means the call |
| // is still in flight. This is the persisted-only path — no live projection — |
| // which is what a reader sees after a renderer reload or when it re-attaches |
| // to a session running in the background. |
| test("reads an unfinished call in a running turn as running", () => { |
| const [turn] = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: "running", |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Bash", |
| args: { command: "sleep 600" }, |
| }, |
| ]); |
| assert.equal(turn?.status, "running"); |
| assert.equal(turn?.tools[0]?.status, "running"); |
| }); |
| |
| // Only a turn that has itself ended makes the missing result mean the tool |
| // never finished. |
| for (const turnStatus of ["aborted", "failed", "completed"] as const) { |
| test(`reads an unfinished call in a ${turnStatus} turn as interrupted`, () => { |
| const [turn] = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: turnStatus, |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Bash", |
| args: { command: "sleep 600" }, |
| }, |
| ]); |
| assert.equal(turn?.tools[0]?.status, "interrupted"); |
| }); |
| } |
| |
| // Sessions written before turn_state carry no record at all, so they keep |
| // reading as interrupted rather than stranding old rows on a spinner. |
| test("reads an unfinished call with no turn record as interrupted", () => { |
| const [turn] = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 2, |
| toolName: "Bash", |
| args: { command: "sleep 600" }, |
| }, |
| ]); |
| assert.equal(turn?.tools[0]?.status, "interrupted"); |
| }); |
| }); |
| |
| describe("live tool status over persisted", () => { |
| // Runtime appends turn_state when the turn opens, before any tool_call, so a |
| // turn that can have a live projection always has one on disk. |
| test("keeps a still-running tool running when persisted has no result yet", () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: "running", |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Bash", |
| args: { command: "sleep 60" }, |
| }, |
| ]); |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t1", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "a1", |
| tools: [ |
| { |
| toolUseId: "bash-1", |
| toolName: "Bash", |
| stepId: "a1", |
| status: "running", |
| args: { command: "sleep 60" }, |
| }, |
| ], |
| }, |
| ], |
| }); |
| const tools = turns |
| .find((turn) => turn.turnId === "t1") |
| ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); |
| assert.equal( |
| tools?.kind === "tools" ? tools.items[0]?.status : undefined, |
| "running", |
| ); |
| }); |
| |
| // Only the active session is subscribed and events do not replay, so a turn |
| // that ends while the user is looking elsewhere leaves the projection frozen |
| // mid-run; reconcileTerminalLiveTurn hands a tool off only once it is |
| // interrupted or has a result, so a frozen `running` never clears itself. A |
| // recorded terminal turn_state is what breaks the tie. |
| for (const turnStatus of ["aborted", "failed", "completed"] as const) { |
| test(`a stale live running loses to a ${turnStatus} turn`, () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: turnStatus, |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Bash", |
| args: { command: "sleep 60" }, |
| }, |
| ]); |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t1", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "a1", |
| tools: [ |
| { |
| toolUseId: "bash-1", |
| toolName: "Bash", |
| stepId: "a1", |
| status: "running", |
| args: { command: "sleep 60" }, |
| outputChunks: [ |
| { |
| seq: 0, |
| stream: "stdout", |
| text: "partial output", |
| redacted: false, |
| createdAt: 4, |
| }, |
| ], |
| }, |
| ], |
| }, |
| ], |
| }); |
| const tools = turns |
| .find((turn) => turn.turnId === "t1") |
| ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); |
| const tool = tools?.kind === "tools" ? tools.items[0] : undefined; |
| assert.equal(tool?.status, "interrupted"); |
| // The tail the user was watching still belongs to live — only the status |
| // is taken back. |
| assert.equal(tool?.outputChunks?.length, 1); |
| }); |
| } |
| |
| // A legacy turn has no turn_state, so `status` falls back to an inferred |
| // `completed`. That is a guess about old data, not evidence this turn ended, |
| // and must not be allowed to take a status away from live. |
| test("an inferred legacy status never overrides live", () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 2, |
| toolName: "Bash", |
| args: { command: "sleep 60" }, |
| }, |
| ]); |
| assert.equal(settled[0]?.statusSource, "inferred"); |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t1", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "a1", |
| tools: [ |
| { |
| toolUseId: "bash-1", |
| toolName: "Bash", |
| stepId: "a1", |
| status: "running", |
| args: {}, |
| }, |
| ], |
| }, |
| ], |
| }); |
| const tools = turns |
| .find((turn) => turn.turnId === "t1") |
| ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); |
| assert.equal( |
| tools?.kind === "tools" ? tools.items[0]?.status : undefined, |
| "running", |
| ); |
| }); |
| |
| test("keeps durable tool detail while a Runtime Host Turn is still live", () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "use the computer"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: "running", |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "computer-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "maka_computer", |
| args: { action: "click_element", element_id: "615" }, |
| }, |
| { |
| type: "tool_result", |
| id: "result-1", |
| turnId: "t1", |
| ts: 4, |
| toolUseId: "computer-1", |
| isError: false, |
| content: { kind: "text", text: "unsupported_action" }, |
| }, |
| ]); |
| |
| const turns = overlayLiveTurn(settled, { |
| turnId: "t1", |
| phase: "streamed", |
| steps: [ |
| { |
| stepId: "tool:computer-1", |
| tools: [ |
| { |
| toolUseId: "computer-1", |
| toolName: "maka_computer", |
| status: "running", |
| args: undefined, |
| // Runtime Host live events carry lifecycle but not the durable |
| // result payload. |
| result: { kind: "text", text: "" }, |
| }, |
| ], |
| }, |
| ], |
| }); |
| |
| const toolGroup = turns |
| .find((turn) => turn.turnId === "t1") |
| ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); |
| const tool = toolGroup?.kind === "tools" ? toolGroup.items[0] : undefined; |
| assert.deepEqual(tool?.args, { |
| action: "click_element", |
| element_id: "615", |
| }); |
| assert.deepEqual(tool?.result, { |
| kind: "text", |
| text: "unsupported_action", |
| }); |
| assert.equal(tool?.status, "running"); |
| }); |
| |
| // Deleting the merge exception rests entirely on the live side carrying its |
| // own interrupted signal, so drive the real chain — a tool_start followed by |
| // an abort — rather than hand-building an already-interrupted projection, |
| // which would pass on the spread alone. |
| test("an aborted turn interrupts its in-flight tool through the live chain", () => { |
| const settled = materializeTurns([ |
| userMsg("t1", 1, "run it"), |
| { |
| type: "turn_state", |
| id: "s1", |
| turnId: "t1", |
| ts: 2, |
| status: "running", |
| partialOutputRetained: false, |
| }, |
| { |
| type: "tool_call", |
| id: "bash-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Bash", |
| args: { command: "sleep 60" }, |
| }, |
| ]); |
| const started = applyLiveTurnEvent(armLiveTurn("t1"), { |
| type: "tool_start", |
| id: "event-1", |
| turnId: "t1", |
| toolUseId: "bash-1", |
| toolName: "Bash", |
| args: { command: "sleep 60" }, |
| ts: 4, |
| }); |
| const running = applyLiveTurnEvent(started, { |
| type: "tool_output_delta", |
| id: "event-2", |
| turnId: "t1", |
| sessionId: "s", |
| toolCallId: "bash-1", |
| toolUseId: "bash-1", |
| seq: 0, |
| stream: "stdout", |
| chunk: "still going\n", |
| redacted: false, |
| createdAt: 5, |
| ts: 5, |
| }); |
| assert.equal(running?.steps[0]?.tools[0]?.status, "running"); |
| |
| const aborted = applyLiveTurnEvent(running, { |
| type: "abort", |
| id: "event-3", |
| turnId: "t1", |
| reason: "user_stop", |
| ts: 6, |
| }); |
| const tools = overlayLiveTurn(settled, aborted!) |
| .find((turn) => turn.turnId === "t1") |
| ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); |
| assert.equal( |
| tools?.kind === "tools" ? tools.items[0]?.status : undefined, |
| "interrupted", |
| ); |
| }); |
| }); |
| |
| describe("final reply extraction for copy (#2407)", () => { |
| // The issue's shape: the model narrates, calls tools, narrates again, then |
| // answers — one assistant row per step with tool calls interleaved. |
| const multiStepTurn = (): StoredMessage[] => [ |
| { type: "user", id: "ask", turnId: "t1", ts: 1, text: "do the thing" }, |
| { |
| type: "assistant", |
| id: "step-1", |
| turnId: "t1", |
| ts: 2, |
| text: "Let me inspect the files first.", |
| modelId: "fixture", |
| }, |
| { |
| type: "tool_call", |
| id: "tool-1", |
| turnId: "t1", |
| ts: 3, |
| toolName: "Read", |
| args: { path: "flag.ts" }, |
| stepId: "step-2", |
| }, |
| { |
| type: "assistant", |
| id: "step-2", |
| turnId: "t1", |
| ts: 4, |
| text: "Found it — the flag was inverted.", |
| modelId: "fixture", |
| }, |
| { |
| type: "tool_call", |
| id: "tool-2", |
| turnId: "t1", |
| ts: 5, |
| toolName: "Edit", |
| args: { path: "flag.ts" }, |
| stepId: "step-3", |
| }, |
| { |
| type: "assistant", |
| id: "step-3", |
| turnId: "t1", |
| ts: 6, |
| text: "Done: the fix is committed and the tests pass.", |
| modelId: "fixture", |
| }, |
| ]; |
| |
| test("returns only the last answer step, not the intermediate narration", () => { |
| const [turn] = materializeTurns(multiStepTurn()); |
| assert.ok(turn); |
| assert.equal(finalAssistantReplyText(turn), "Done: the fix is committed and the tests pass."); |
| }); |
| |
| test("keeps every narration step on the timeline in production order", () => { |
| const [turn] = materializeTurns(multiStepTurn()); |
| assert.deepEqual( |
| turn?.timeline.flatMap((item) => (item.kind === "text" ? [item.text] : [])), |
| [ |
| "Let me inspect the files first.", |
| "Found it — the flag was inverted.", |
| "Done: the fix is committed and the tests pass.", |
| ], |
| ); |
| }); |
| |
| test("leaves the legacy aggregate concatenation untouched for its other consumers", () => { |
| const [turn] = materializeTurns(multiStepTurn()); |
| assert.equal( |
| turn?.assistant?.text, |
| "Let me inspect the files first.\n\nFound it — the flag was inverted.\n\nDone: the fix is committed and the tests pass.", |
| ); |
| }); |
| |
| test("falls back to the aggregate when the timeline has no text entry", () => { |
| const [turn] = materializeTurns(multiStepTurn()); |
| assert.ok(turn); |
| const withoutText = { |
| ...turn, |
| timeline: turn.timeline.filter((item) => item.kind !== "text"), |
| }; |
| assert.equal(finalAssistantReplyText(withoutText), turn.assistant?.text); |
| }); |
| }); |