workspace: render console snapshot entries
This commit is contained in:
parent
bbb79afdd8
commit
2e66655652
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Render console snapshot entries'
|
title: 'Render console snapshot entries'
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: '2026-07-13T14:17:43Z'
|
created_at: '2026-07-13T14:17:43Z'
|
||||||
updated_at: '2026-07-13T14:18:30Z'
|
updated_at: '2026-07-13T14:30:38Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'yoi ticket'
|
queued_by: 'yoi ticket'
|
||||||
queued_at: '2026-07-13T14:18:30Z'
|
queued_at: '2026-07-13T14:18:30Z'
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,39 @@ Ticket を `yoi ticket` が queued にしました。
|
||||||
State changed to `inprogress`.
|
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`.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -398,14 +398,64 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur
|
||||||
assertEquals(projection.status, "running");
|
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([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "20",
|
eventId: "20",
|
||||||
event: {
|
event: {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
data: {
|
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: {
|
greeting: {
|
||||||
worker_name: "Worker",
|
worker_name: "Worker",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
|
|
@ -429,9 +479,13 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
|
||||||
|
|
||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines.map((line) =>
|
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`),
|
||||||
`${line.kind}:${line.body}:${line.streaming}`
|
[
|
||||||
),
|
"user:seed user:false",
|
||||||
["in_flight:partial:true"],
|
"user:new user:false",
|
||||||
|
"assistant:assistant reply:false",
|
||||||
|
"tool:Read — 1 file read\n /tmp/a.md:false",
|
||||||
|
"in_flight:partial:true",
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ export function applyProtocolEvent(
|
||||||
break;
|
break;
|
||||||
case "snapshot":
|
case "snapshot":
|
||||||
next.status = event.data.status;
|
next.status = event.data.status;
|
||||||
|
next.lines = snapshotLinesFromEntries(envelope.eventId, event.data.entries);
|
||||||
for (const block of event.data.in_flight?.blocks ?? []) {
|
for (const block of event.data.in_flight?.blocks ?? []) {
|
||||||
next.lines.push(inFlightLine(envelope.eventId, block));
|
next.lines.push(inFlightLine(envelope.eventId, block));
|
||||||
}
|
}
|
||||||
|
|
@ -729,6 +730,11 @@ function stringField(
|
||||||
return typeof field === "string" ? field : undefined;
|
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 {
|
function compactLines(lines: Array<string | undefined | null | false>): string {
|
||||||
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
||||||
}
|
}
|
||||||
|
|
@ -760,6 +766,113 @@ function usageText(
|
||||||
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
|
} · 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 {
|
function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine {
|
||||||
switch (block.kind) {
|
switch (block.kind) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user