workspace: render console snapshot entries

This commit is contained in:
Keisuke Hirata 2026-07-13 23:30:48 +09:00
parent bbb79afdd8
commit 2e66655652
No known key found for this signature in database
4 changed files with 210 additions and 8 deletions

View File

@ -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'

View File

@ -39,4 +39,39 @@ Ticket を `yoi ticket` が queued にしました。
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T14:30:37Z -->
## 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`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T14:30:38Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---

View File

@ -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",
],
);
});

View File

@ -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<string, unknown>, key: string): unknown[] {
const field = value[key];
return Array.isArray(field) ? field : [];
}
function compactLines(lines: Array<string | undefined | null | false>): 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":