workspace: render console snapshot entries
This commit is contained in:
parent
bbb79afdd8
commit
2e66655652
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user