diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts new file mode 100644 index 000000000..d0fabcfd9 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import type { Event } from "$lib/generated/protocol"; +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import ConsoleLineItem from "./ConsoleLineItem.svelte"; +import { createConsoleProjector } from "./model.ts"; + +afterEach(cleanup); + +function reconnectSnapshot(text: string): Event { + return { + event: "snapshot", + data: { + session: { + entries: [], + pending_submissions: { + revision: 0, + head_id: null, + notification_count: 0, + submissions: [], + }, + }, + greeting: { + worker_name: "Worker", + cwd: "/repo", + provider: "provider", + model: "model", + scope_summary: "bounded", + tools: [], + context_window: 100, + context_tokens: 20, + }, + state: { + last_command_id: 0, + state: { kind: "busy", state: { kind: "run", state: "running" } }, + }, + in_flight: { + blocks: [{ kind: "text", text, finished: false }], + }, + }, + }; +} + +test("reconnected assistant output keeps the normal streaming Markdown path", async () => { + const projector = createConsoleProjector(); + let projection = projector.append([{ + eventId: "snapshot", + event: reconnectSnapshot("**hel"), + }]); + const { container, rerender } = render(ConsoleLineItem, { + item: projection.lines[0], + }); + + expect(container.querySelector("li")?.classList.contains("assistant")).toBe( + true, + ); + expect(container.querySelector(".message-heading")).toBeNull(); + expect(container.querySelector(".console-plain-text")).toBeNull(); + expect(container.textContent).not.toContain("in-flight"); + + projection = projector.append([{ + eventId: "inline-suffix", + event: { + event: "text_delta", + data: { text: "lo** and [safe](https://exam" }, + }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe("hello"); + expect(container.querySelector('a[href="https://example.com"]')).toBeNull(); + expect(container.textContent).toContain("[safe](https://exam"); + }); + + projection = projector.append([{ + eventId: "fence-prefix", + event: { + event: "text_delta", + data: { text: "ple.com)\n\n```ts\nconst answer = 42;" }, + }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); + expect(container.querySelector("pre code")?.textContent).toContain( + "const answer = 42;", + ); + }); + + projection = projector.append([{ + eventId: "fence-suffix", + event: { event: "text_delta", data: { text: "\n```" } }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe("hello"); + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); + expect(container.querySelector("pre code")?.textContent).toContain( + "const answer = 42;", + ); + }); + + expect(projection.lines).toHaveLength(1); + expect(projection.lines[0]).toMatchObject({ + kind: "assistant", + streaming: true, + body: + "**hello** and [safe](https://example.com)\n\n```ts\nconst answer = 42;\n```", + }); +}); diff --git a/web/workspace/src/lib/workspace/console/history.test.ts b/web/workspace/src/lib/workspace/console/history.test.ts index da5343067..1cc260d99 100644 --- a/web/workspace/src/lib/workspace/console/history.test.ts +++ b/web/workspace/src/lib/workspace/console/history.test.ts @@ -346,6 +346,36 @@ Deno.test("line previews include a current turn outside the retained five-turn p ); }); +Deno.test("line previews track a snapshot-restored assistant as its body streams", () => { + const lines = [{ + id: "user", + entryId: "user-entry", + kind: "user" as const, + title: "User", + body: "question", + source: "event" as const, + }, { + id: "restored-assistant", + kind: "assistant" as const, + title: "assistant streaming", + body: "**hel", + source: "event" as const, + streaming: true, + }]; + + assert( + conversationTurnPreviewsFromLines(lines)[0].assistant.join("|") === + "**hel", + "restored assistant prefix must use the ordinary assistant preview path", + ); + const continued = [lines[0], { ...lines[1], body: "**hello**" }]; + assert( + conversationTurnPreviewsFromLines(continued)[0].assistant.join("|") === + "**hello**", + "live suffixes must update the same turn preview", + ); +}); + Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => { const preview = conversationTurnPreviews([turn(4)])[0]; assert(preview.user === "question 4", "user preview must use its first line"); diff --git a/web/workspace/src/lib/workspace/console/history.ts b/web/workspace/src/lib/workspace/console/history.ts index baa26442b..340a59729 100644 --- a/web/workspace/src/lib/workspace/console/history.ts +++ b/web/workspace/src/lib/workspace/console/history.ts @@ -203,8 +203,7 @@ export function conversationTurnPreviewsFromLines( assistants: [], }; } else if ( - (line.kind === "assistant" || - (line.kind === "in_flight" && line.title === "in-flight assistant text")) && + line.kind === "assistant" && current && line.body.trim().length > 0 ) { current.assistants.push(line.body); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index adaa5c669..36b22d895 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -1,5 +1,6 @@ import type { Event, + InFlightBlock, SessionSnapshotEntry, WorkerStateSnapshot, WorkerStatus, @@ -176,6 +177,39 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event { }; } +type SnapshotEvent = Extract; + +function snapshotWithInFlight( + blocks: InFlightBlock[], + entries: unknown[] = [], +): SnapshotEvent { + const snapshot = snapshotEvent("/repo", entries) as SnapshotEvent; + snapshot.data.in_flight = { blocks }; + return snapshot; +} + +function semanticOutput(lines: readonly ConsoleLine[]) { + return lines.map((line) => ({ + kind: line.kind, + title: line.title, + body: line.body, + streaming: line.streaming, + entryId: line.entryId, + toolCall: line.toolCall + ? { + id: line.toolCall.id, + name: line.toolCall.name, + argsStream: line.toolCall.argsStream, + arguments: line.toolCall.arguments, + state: line.toolCall.state, + summary: line.toolCall.summary, + output: line.toolCall.output, + isError: line.toolCall.isError, + } + : undefined, + })); +} + Deno.test("large paste segments project compact artifact metadata", () => { const body = "secret pasted body"; const text = segmentsToText([{ @@ -2334,11 +2368,273 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () => "user:new user:false", "assistant:assistant reply:false", "tool:Read(1 file)\n /tmp/a.md:false", - "in_flight:partial:true", + "assistant:partial:true", ], ); }); +Deno.test("snapshot text blocks match live prefixes, suffixes, and ordered commits", () => { + const committedPrefix = [{ + kind: "user_input", + segments: [{ kind: "text", content: "question" }], + }]; + const livePrefix: ConsoleEventInput[] = [ + { eventId: "live-base", event: snapshotEvent("/repo", committedPrefix) }, + { + eventId: "live-first-delta", + event: { event: "text_delta", data: { text: "first draft" } }, + }, + { + eventId: "live-first-done", + event: { event: "text_done", data: { text: "first draft" } }, + }, + { + eventId: "live-second-delta", + event: { event: "text_delta", data: { text: "**hel" } }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored-base", + event: snapshotWithInFlight([ + { kind: "text", text: "first draft", finished: true }, + { kind: "text", text: "**hel", finished: false }, + ], committedPrefix), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const firstCommit: SessionSnapshotEntry = { + entry_id: "assistant-first", + timestamp: 2, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "first canonical" }], + }; + const secondCommit: SessionSnapshotEntry = { + entry_id: "assistant-second", + timestamp: 3, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "**hello**" }], + }; + const suffix: ConsoleEventInput[] = [ + { + eventId: "commit-first", + event: { + event: "session_entry_committed", + data: { entry: firstCommit }, + }, + }, + { + eventId: "second-suffix", + event: { event: "text_delta", data: { text: "lo**" } }, + }, + { + eventId: "second-done", + event: { event: "text_done", data: { text: "**hello**" } }, + }, + { + eventId: "commit-second", + event: { + event: "session_entry_committed", + data: { entry: secondCommit }, + }, + }, + ]; + const liveFinal = projectConsole([...livePrefix, ...suffix]); + const restoredFinal = projectConsole([...restoredPrefix, ...suffix]); + + assertEquals( + semanticOutput(restoredFinal.lines), + semanticOutput(liveFinal.lines), + ); + assertEquals( + restoredFinal.lines.filter((line) => line.kind === "assistant").map( + (line) => [line.body, line.entryId, line.streaming], + ), + [ + ["first canonical", "assistant-first", false], + ["**hello**", "assistant-second", false], + ], + ); +}); + +Deno.test("snapshot thinking blocks preserve boundaries and live continuation", () => { + const livePrefix: ConsoleEventInput[] = [ + { eventId: "base", event: snapshotEvent("/repo") }, + { eventId: "thinking-1-start", event: { event: "thinking_start" } }, + { + eventId: "thinking-1-delta", + event: { event: "thinking_delta", data: { text: "finished thought" } }, + }, + { + eventId: "thinking-1-done", + event: { event: "thinking_done", data: { text: "finished thought" } }, + }, + { eventId: "thinking-2-start", event: { event: "thinking_start" } }, + { + eventId: "thinking-2-delta", + event: { event: "thinking_delta", data: { text: "next tho" } }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored", + event: snapshotWithInFlight([ + { kind: "thinking", text: "finished thought", finished: true }, + { kind: "thinking", text: "next tho", finished: false }, + ]), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const suffix: ConsoleEventInput[] = [ + { + eventId: "thinking-2-suffix", + event: { event: "thinking_delta", data: { text: "ught" } }, + }, + { + eventId: "thinking-2-done", + event: { event: "thinking_done", data: { text: "next thought" } }, + }, + ]; + const restored = projectConsole([...restoredPrefix, ...suffix]); + assertEquals( + semanticOutput(restored.lines), + semanticOutput(projectConsole([...livePrefix, ...suffix]).lines), + ); + assertEquals( + restored.lines.map((line) => [line.kind, line.body, line.streaming]), + [ + ["thinking", "finished thought", false], + ["thinking", "next thought", false], + ], + ); + assertEquals(projectOverviewLines(restored.lines), []); +}); + +Deno.test("snapshot tool calls retain identity and state through live suffixes", () => { + const livePrefix: ConsoleEventInput[] = [ + { eventId: "base", event: snapshotEvent("/repo") }, + { + eventId: "pending-start", + event: { + event: "tool_call_start", + data: { id: "pending", name: "Read" }, + }, + }, + { + eventId: "streaming-start", + event: { + event: "tool_call_start", + data: { id: "streaming", name: "Bash" }, + }, + }, + { + eventId: "streaming-args", + event: { + event: "tool_call_args_delta", + data: { id: "streaming", json: '{"command":"ec' }, + }, + }, + { + eventId: "done-start", + event: { event: "tool_call_start", data: { id: "done", name: "Glob" } }, + }, + { + eventId: "done-args", + event: { + event: "tool_call_args_delta", + data: { id: "done", json: '{"pattern":"*.ts"}' }, + }, + }, + { + eventId: "done-call", + event: { + event: "tool_call_done", + data: { id: "done", name: "Glob", arguments: '{"pattern":"*.ts"}' }, + }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored", + event: snapshotWithInFlight([ + { + kind: "tool_call", + id: "pending", + name: "Read", + args: "", + state: "pending", + }, + { + kind: "tool_call", + id: "streaming", + name: "Bash", + args: '{"command":"ec', + state: "streaming_args", + }, + { + kind: "tool_call", + id: "done", + name: "Glob", + args: '{"pattern":"*.ts"}', + state: "done", + }, + ]), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const suffix: ConsoleEventInput[] = [ + { + eventId: "pending-done", + event: { + event: "tool_call_done", + data: { id: "pending", name: "Read", arguments: '{"file_path":"a"}' }, + }, + }, + { + eventId: "streaming-suffix", + event: { + event: "tool_call_args_delta", + data: { id: "streaming", json: 'ho hi"}' }, + }, + }, + { + eventId: "streaming-done", + event: { + event: "tool_call_done", + data: { + id: "streaming", + name: "Bash", + arguments: '{"command":"echo hi"}', + }, + }, + }, + ...["pending", "streaming", "done"].map((id): ConsoleEventInput => ({ + eventId: `${id}-result`, + event: { + event: "tool_result", + data: { id, summary: `${id} result`, output: id, is_error: false }, + }, + })), + ]; + assertEquals( + semanticOutput(projectConsole([...restoredPrefix, ...suffix]).lines), + semanticOutput(projectConsole([...livePrefix, ...suffix]).lines), + ); +}); + Deno.test("projectConsole restores system items from snapshot entries", () => { const projection = projectConsole([{ eventId: "system-snapshot", @@ -2666,6 +2962,65 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null); }); +Deno.test("Internal Worker snapshot output continues without entering the parent view", () => { + const worker = { + session_id: "child-reconnect", + name: "research", + parent_session_id: "parent-session", + kind: "sub_worker" as const, + }; + const snapshot = snapshotEvent("/repo") as SnapshotEvent; + snapshot.data.in_flight = { + blocks: [{ kind: "text", text: "parent", finished: false }], + }; + snapshot.data.internal_workers = [{ + worker, + revision: 4, + session: canonicalSession([]), + status: "running", + in_flight: { + blocks: [{ kind: "text", text: "**chi", finished: false }], + }, + internal_workers: [], + }]; + + const projector = createConsoleProjector(); + let projection = projector.append([{ eventId: "snapshot", event: snapshot }]); + assertEquals(projection.lines.map((line) => line.body), ["parent"]); + assertEquals( + projection.internalWorkers[0].console.lines.map((line) => line.body), + ["**chi"], + ); + + projection = projector.append([ + { + eventId: "parent-suffix", + event: { event: "text_delta", data: { text: " only" } }, + }, + { + eventId: "child-suffix", + event: { + event: "internal_worker", + data: { + worker, + revision: 5, + event: { event: "text_delta", data: { text: "ld**" } }, + }, + }, + }, + ]); + + assertEquals(projection.lines.map((line) => line.body), ["parent only"]); + assertEquals( + projection.internalWorkers[0].console.lines.map((line) => [ + line.kind, + line.body, + line.streaming, + ]), + [["assistant", "**child**", true]], + ); +}); + Deno.test("console Worker views expose only direct Internal Workers", () => { const projector = createConsoleProjector(); projector.append([{ @@ -3032,10 +3387,7 @@ Deno.test("overview hides thinking and aggregates uninterrupted tool activity", Deno.test("overview keeps tool failure counts without marking the activity as an error", () => { const overview = projectOverviewLines([ - { - ...consoleLine("thinking-in-flight", "in_flight"), - title: "in-flight thinking", - }, + consoleLine("thinking-active", "thinking"), { ...consoleLine("failed-read", "tool"), error: true, @@ -3398,7 +3750,8 @@ Deno.test("committed history inserts current live rows after their last stable a historyUser.entryId = "user-1"; const currentUser = consoleLine("current-user", "user"); currentUser.entryId = "user-1"; - const live = consoleLine("live-assistant", "in_flight"); + const live = consoleLine("live-assistant", "assistant"); + live.streaming = true; assertEquals( mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) => diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index cdfca96d2..af9a20446 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -40,7 +40,6 @@ export type ConsoleLineKind = | "status" | "error" | "usage" - | "in_flight" | "system"; type ToolCallState = @@ -361,8 +360,7 @@ function projectVisibleConsole( } function isOverviewThinkingLine(line: ConsoleLine): boolean { - return line.kind === "thinking" || - (line.kind === "in_flight" && line.title === "in-flight thinking"); + return line.kind === "thinking"; } function representedToolCallCount(line: ConsoleLine): number { @@ -559,12 +557,58 @@ function appendSnapshotInFlightLines( eventId: string, cwd: string | null, ): void { - const lineIds = new Set(projection.lines.map((line) => line.id)); blocks.forEach((block, index) => { - const pending = inFlightLine(`${eventId}:${index}`, block, cwd); - if (lineIds.has(pending.id)) return; - projection.lines.push(pending); - lineIds.add(pending.id); + const blockEventId = `${eventId}:${index}`; + switch (block.kind) { + case "text": + appendStreaming( + projection, + blockEventId, + "assistant", + "assistant streaming", + block.text, + ); + if (block.finished) { + finalizeStreaming( + projection, + "assistant", + blockEventId, + "assistant", + block.text, + ); + } + break; + case "thinking": + startStreaming(projection, blockEventId, "thinking", "Thinking..."); + if (block.text) { + appendStreaming( + projection, + blockEventId, + "thinking", + "Thinking...", + block.text, + ); + } + if (block.finished) { + finalizeStreaming( + projection, + "thinking", + blockEventId, + "Thought", + block.text, + ); + } + break; + case "tool_call": + upsertToolCall(projection, blockEventId, block.id, { + name: block.name, + argsStream: block.args, + arguments: block.state === "done" ? block.args : undefined, + state: inFlightToolState(block.state), + cwd, + }); + break; + } }); } @@ -924,10 +968,23 @@ export function applyProtocolEvent( entryId: event.data.entry_id ?? undefined, }); break; - case "session_entry_committed": - reconcileCommittedSessionEntry(next, event.data.entry); + case "session_entry_committed": { + const replacementIndex = reconcileCommittedSessionEntry( + next, + event.data.entry, + ); applySessionEntry(next, envelope.eventId, event.data.entry); + if (replacementIndex !== undefined) { + const committedIndex = next.lines.findIndex((line) => + line.entryId === event.data.entry.entry_id + ); + if (committedIndex >= 0) { + const [committed] = next.lines.splice(committedIndex, 1); + next.lines.splice(replacementIndex, 0, committed); + } + } break; + } case "system_item": next.lines.push(systemItemLine(envelope.eventId, event.data.item)); applyTaskSystemItem(next, event.data.item); @@ -951,9 +1008,7 @@ export function applyProtocolEvent( ); break; case "thinking_start": - next.lines.push( - line(envelope.eventId, "thinking", "Thinking...", "", undefined, true), - ); + startStreaming(next, envelope.eventId, "thinking", "Thinking..."); break; case "thinking_delta": appendStreaming( @@ -1296,7 +1351,8 @@ function appendDurableRunFailure( ) return; projection.lines = projection.lines.filter((line) => { if (isDurableRunFailure(line)) return true; - return !(line.kind === "error" && runFailureMessagesMatch(line.body, message)); + return !(line.kind === "error" && + runFailureMessagesMatch(line.body, message)); }); projection.lines.push({ ...line( @@ -1344,6 +1400,15 @@ function findLastLineIndex( return -1; } +function startStreaming( + projection: ConsoleProjection, + eventId: string, + kind: "assistant" | "thinking", + title: string, +): void { + projection.lines.push(line(eventId, kind, title, "", undefined, true)); +} + function appendStreaming( projection: ConsoleProjection, eventId: string, @@ -2131,16 +2196,22 @@ function snapshotProjectionFromSession( function reconcileCommittedSessionEntry( projection: ConsoleProjection, entry: SessionSnapshotEntry, -): void { +): number | undefined { if (entry.kind !== "message" || entry.role !== "assistant") return; - for (let index = projection.lines.length - 1; index >= 0; index -= 1) { + const turnStart = findLastLineIndex( + projection.lines, + (line) => line.kind === "user", + ); + for (let index = turnStart + 1; index < projection.lines.length; index += 1) { const line = projection.lines[index]; - if (line.entryId) continue; - if (line.kind === "assistant" || line.kind === "in_flight") { + if ( + line.kind === "assistant" && + !line.entryId && + line.streaming !== true + ) { projection.lines.splice(index, 1); - return; + return index; } - if (line.kind === "user") return; } } @@ -2358,42 +2429,6 @@ function loggedContentText(parts: unknown[]): string { .join("\n"); } -function inFlightLine( - eventId: string, - block: InFlightBlock, - cwd: string | null, -): ConsoleLine { - switch (block.kind) { - case "text": - return line( - eventId, - "in_flight", - "in-flight assistant text", - block.text, - undefined, - !block.finished, - ); - case "thinking": - return line( - eventId, - "in_flight", - "in-flight thinking", - block.text, - undefined, - !block.finished, - ); - case "tool_call": - return toolLine(eventId, { - id: block.id, - name: block.name, - argsStream: block.args, - arguments: block.state === "done" ? block.args : undefined, - state: inFlightToolState(block.state), - cwd, - }); - } -} - function inFlightToolState( state: InFlightToolCallState | undefined, ): ToolCallState { diff --git a/web/workspace/src/lib/workspace/console/turn-navigation.test.ts b/web/workspace/src/lib/workspace/console/turn-navigation.test.ts index df3aae3a5..3eb496ffa 100644 --- a/web/workspace/src/lib/workspace/console/turn-navigation.test.ts +++ b/web/workspace/src/lib/workspace/console/turn-navigation.test.ts @@ -54,11 +54,11 @@ Deno.test("streaming updates change the preview without creating another turn", equal(answer.body, "Hi"); }); -Deno.test("snapshot in-flight assistant text is previewed but in-flight thinking is not", () => { +Deno.test("snapshot-restored assistant text is previewed but thinking is not", () => { equal(consoleTurns([ line("u", "user", "Question"), - { ...line("reasoning", "in_flight", "Private"), title: "in-flight thinking" }, - { ...line("text", "in_flight", "Answer in progress"), title: "in-flight assistant text" }, + line("reasoning", "thinking", "Private"), + line("text", "assistant", "Answer in progress"), ]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]); }); diff --git a/web/workspace/src/lib/workspace/console/turn-navigation.ts b/web/workspace/src/lib/workspace/console/turn-navigation.ts index 038cb7a5f..09a52b13c 100644 --- a/web/workspace/src/lib/workspace/console/turn-navigation.ts +++ b/web/workspace/src/lib/workspace/console/turn-navigation.ts @@ -23,11 +23,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] { if (line.kind === "user") { current = { id: line.id, user: previewText(line.body), assistant: "" }; turns.push(current); - } else if ( - current && - (line.kind === "assistant" || - (line.kind === "in_flight" && line.title === "in-flight assistant text")) - ) { + } else if (current && line.kind === "assistant") { const assistant = previewText(line.body); if (assistant) current.assistant = assistant; }