From 2e66655652e3424b69c0c41eb43f49a7f278cc85 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 13 Jul 2026 23:30:48 +0900 Subject: [PATCH] workspace: render console snapshot entries --- .yoi/tickets/00001KXDXEJQS/item.md | 4 +- .yoi/tickets/00001KXDXEJQS/thread.md | 35 ++++++ .../src/lib/workspace-console/model.test.ts | 66 +++++++++- .../src/lib/workspace-console/model.ts | 113 ++++++++++++++++++ 4 files changed, 210 insertions(+), 8 deletions(-) diff --git a/.yoi/tickets/00001KXDXEJQS/item.md b/.yoi/tickets/00001KXDXEJQS/item.md index f463f01c..864ecaa3 100644 --- a/.yoi/tickets/00001KXDXEJQS/item.md +++ b/.yoi/tickets/00001KXDXEJQS/item.md @@ -1,8 +1,8 @@ --- title: 'Render console snapshot entries' -state: 'inprogress' +state: 'done' created_at: '2026-07-13T14:17:43Z' -updated_at: '2026-07-13T14:18:30Z' +updated_at: '2026-07-13T14:30:38Z' assignee: null queued_by: 'yoi ticket' queued_at: '2026-07-13T14:18:30Z' diff --git a/.yoi/tickets/00001KXDXEJQS/thread.md b/.yoi/tickets/00001KXDXEJQS/thread.md index e62e455e..98ed599e 100644 --- a/.yoi/tickets/00001KXDXEJQS/thread.md +++ b/.yoi/tickets/00001KXDXEJQS/thread.md @@ -39,4 +39,39 @@ Ticket を `yoi ticket` が queued にしました。 State changed to `inprogress`. +--- + + + +## Implementation report + +Implemented console rendering for WebSocket snapshot entries. + +Root cause: +- Browser Console consumed `snapshot.status` and `snapshot.in_flight`, but ignored `snapshot.entries`. +- After reconnect/restore the WebSocket snapshot arrived, but committed conversation history was not projected into console rows, so the console appeared empty without an error. + +Changes: +- `projectConsole` now rebuilds committed console rows from `snapshot.data.entries`. +- `SegmentStart.history` is replayed so seed history from restored/compacted sessions is visible. +- Snapshot log entries for user input, assistant messages, reasoning, tool calls, and tool results are projected into existing ConsoleLine shapes. +- Existing in-flight snapshot rendering is preserved. +- Unknown snapshot entries are ignored rather than surfaced as errors. + +Validation: +- `cd web/workspace && deno task check` +- `cd web/workspace && deno task test` +- `git diff --check` +- `nix build .#yoi --no-link` + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index b6f081ae..f2c3eda4 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -398,14 +398,64 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur assertEquals(projection.status, "running"); }); -Deno.test("projectConsole uses snapshot for state without rendering it as console output", () => { +Deno.test("projectConsole renders snapshot entries and in-flight output", () => { const projection = projectConsole([ { eventId: "20", event: { event: "snapshot", data: { - entries: [{ role: "user" }], + entries: [ + { + kind: "segment_start", + ts: 1, + session_id: "00000000-0000-0000-0000-000000000001", + system_prompt: null, + config: {}, + history: [ + { + kind: "message", + role: "user", + content: [{ kind: "text", text: "seed user" }], + }, + ], + }, + { + kind: "user_input", + ts: 2, + segments: [{ kind: "text", content: "new user" }], + }, + { + kind: "assistant_item", + ts: 3, + item: { + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "assistant reply" }], + }, + }, + { + kind: "assistant_item", + ts: 4, + item: { + kind: "tool_call", + call_id: "read-1", + name: "Read", + arguments: JSON.stringify({ file_path: "/tmp/a.md" }), + }, + }, + { + kind: "tool_result", + ts: 5, + item: { + kind: "tool_result", + call_id: "read-1", + summary: "read 3 lines", + content: "hidden file contents", + is_error: false, + }, + }, + ], greeting: { worker_name: "Worker", cwd: "/repo", @@ -429,9 +479,13 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol assertEquals(projection.status, "running"); assertEquals( - projection.lines.map((line) => - `${line.kind}:${line.body}:${line.streaming}` - ), - ["in_flight:partial:true"], + projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`), + [ + "user:seed user:false", + "user:new user:false", + "assistant:assistant reply:false", + "tool:Read — 1 file read\n /tmp/a.md:false", + "in_flight:partial:true", + ], ); }); diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index 207748c4..a865d7f5 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -219,6 +219,7 @@ export function applyProtocolEvent( break; case "snapshot": next.status = event.data.status; + next.lines = snapshotLinesFromEntries(envelope.eventId, event.data.entries); for (const block of event.data.in_flight?.blocks ?? []) { next.lines.push(inFlightLine(envelope.eventId, block)); } @@ -729,6 +730,11 @@ function stringField( return typeof field === "string" ? field : undefined; } +function arrayField(value: Record, key: string): unknown[] { + const field = value[key]; + return Array.isArray(field) ? field : []; +} + function compactLines(lines: Array): string { return lines.filter((line): line is string => Boolean(line)).join("\n"); } @@ -760,6 +766,113 @@ function usageText( } · cache ${data.cache_read_input_tokens ?? "unknown"}`; } +function snapshotLinesFromEntries(eventId: string, entries: unknown[]): ConsoleLine[] { + const projection: ConsoleProjection = { + lines: [], + status: null, + usage: null, + lastEventId: eventId, + }; + entries.forEach((entry, index) => applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)); + return projection.lines; +} + +function applyLogEntry( + projection: ConsoleProjection, + eventId: string, + entry: unknown, +): void { + if (!isRecord(entry)) return; + switch (stringField(entry, "kind")) { + case "segment_start": + arrayField(entry, "history").forEach((item, index) => + applyLoggedItem(projection, `${eventId}-history-${index}`, item) + ); + break; + case "user_input": + projection.lines.push( + line( + eventId, + "user", + "User", + segmentsToText(arrayField(entry, "segments") as Segment[]), + ), + ); + break; + case "assistant_item": + case "tool_result": + applyLoggedItem(projection, eventId, entry["item"]); + break; + default: + break; + } +} + +function applyLoggedItem( + projection: ConsoleProjection, + eventId: string, + item: unknown, +): void { + if (!isRecord(item)) return; + switch (stringField(item, "kind")) { + case "message": { + const body = loggedContentText(arrayField(item, "content")); + switch (stringField(item, "role")) { + case "user": + projection.lines.push(line(eventId, "user", "User", body)); + break; + case "assistant": + projection.lines.push(line(eventId, "assistant", "assistant", body)); + break; + default: + break; + } + break; + } + case "reasoning": { + const text = stringField(item, "text") ?? arrayField(item, "summary").filter((value) => typeof value === "string").join("\n"); + if (text) { + projection.lines.push(line(eventId, "thinking", "Thought", text)); + } + break; + } + case "tool_call": + upsertToolCall(projection, eventId, stringField(item, "call_id") ?? eventId, { + name: stringField(item, "name") ?? "Tool", + arguments: stringField(item, "arguments") ?? "", + argsStream: stringField(item, "arguments") ?? "", + state: "running", + }); + break; + case "tool_result": + attachToolResult(projection, eventId, stringField(item, "call_id") ?? eventId, { + summary: stringField(item, "summary") ?? "", + output: stringField(item, "content"), + isError: item["is_error"] === true, + }); + break; + default: + break; + } +} + +function loggedContentText(parts: unknown[]): string { + return parts + .map((part) => { + if (!isRecord(part)) return ""; + switch (stringField(part, "kind")) { + case "text": + return stringField(part, "text") ?? ""; + case "refusal": + return stringField(part, "refusal") ?? ""; + default: + return ""; + } + }) + .filter(Boolean) + .join("\n"); +} + function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine { switch (block.kind) { case "text":