From f5c6b0aeb11016b84db49e61da52c654cb53eb4b Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 19:13:15 +0900 Subject: [PATCH] fix: deduplicate console entries and contain worker rendering errors --- .../src/lib/workspace/console/model.test.ts | 75 ++++++++++++ .../src/lib/workspace/console/model.ts | 19 ++- .../workers/[workerRef]/+error.svelte | 14 +++ .../workers/[workerRef]/console/+page.svelte | 13 +- .../console/console-display.browser.spec.ts | 111 ++++++++++++++++++ .../[workerRef]/worker-error.browser.spec.ts | 26 ++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+error.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/worker-error.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 9ba89bec5..0416b2f51 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3645,6 +3645,81 @@ Deno.test("new invoke resets stats before the next RunEnd", () => { assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0"); }); +Deno.test("snapshot and replayed commits preserve one row per entry without consuming the next live reply", () => { + const entry: SessionSnapshotEntry = { + kind: "message", entry_id: "assistant-durable", timestamp: 1, + provenance: "model_output", role: "assistant", + content: [{ kind: "text", text: "same answer" }], + }; + const snapshot = snapshotEvent("/repo") as SnapshotEvent; + snapshot.data.session.entries = [entry]; + const events: ConsoleEventInput[] = [ + { eventId: "snapshot", event: snapshot }, + { eventId: "next-reply", event: { event: "text_done", data: { text: "next reply" } } }, + { eventId: "replayed-commit", event: { event: "session_entry_committed", data: { entry } } }, + { eventId: "replayed-again", event: { event: "session_entry_committed", data: { entry } } }, + ]; + const projector = createConsoleProjector(); + for (const event of events) projector.append([event]); + const lines = projector.snapshot().lines; + assertEquals(lines.map((line) => line.body), ["same answer", "next reply"]); + assertEquals(lines.map((line) => line.entryId), ["assistant-durable", undefined]); + assertEquals(new Set(lines.map((line) => line.id)).size, lines.length); + assertEquals(projectConsole(events).lines, lines); +}); + +Deno.test("snapshot entry replay is idempotent by identity, not text, including direct SubWorkers", () => { + const user: SessionSnapshotEntry = { + kind: "user_input", entry_id: "user-durable", timestamp: 1, + provenance: "human_input", segments: [{ kind: "text", content: "question" }], + }; + const assistant: SessionSnapshotEntry = { + kind: "message", entry_id: "assistant-durable", timestamp: 2, + provenance: "model_output", role: "assistant", + content: [{ kind: "text", text: "same answer" }], + }; + const system: SessionSnapshotEntry = { + kind: "system_item", entry_id: "system-durable", timestamp: 3, + provenance: "backend_instruction", item_kind: "notice", content: "notice", + data: { kind: "notice", body: "notice" }, + }; + const entries = [user, assistant, system, user, assistant, system, + { ...assistant, entry_id: "another-assistant" }]; + const expectedIds = ["user-durable", "assistant-durable", "system-durable", "another-assistant"]; + assertEquals(projectSessionHistoryEntries(entries, null).map((line) => line.entryId), expectedIds); + + const child = { session_id: "child-session", name: "reviewer", kind: "sub_worker" as const }; + const snapshot = snapshotEvent("/repo") as SnapshotEvent; + snapshot.data.internal_workers = [{ + worker: child, revision: 5, + session: { ...snapshot.data.session, entries }, status: "idle", + in_flight: { blocks: [] }, internal_workers: [], + }]; + const events: ConsoleEventInput[] = [{ eventId: "parent-snapshot", event: snapshot }]; + for (const [index, entry] of [user, assistant, system].entries()) { + events.push({ eventId: `parent-${index}`, event: { + event: "internal_worker", data: { + worker: child, revision: 6 + index, + event: { event: "session_entry_committed", data: { entry } }, + }, + } }); + } + events.push({ eventId: "replayed-user", event: { + event: "internal_worker", data: { worker: child, revision: 9, event: { + event: "user_message", data: { entry_id: user.entry_id, segments: user.segments }, + } }, + } }); + const projection = projectConsole(events); + assertEquals(projection.lines, []); + const lines = resolveConsoleWorkerView(projection, child.session_id).console.lines; + assertEquals(lines.map((line) => line.entryId), expectedIds); + for (const mode of ["normal", "overview"] as const) { + const visible = projectConsoleLines(lines, mode); + assertEquals(new Set(visible.map((line) => line.id)).size, visible.length); + assertEquals(visible.filter((line) => line.body === "same answer").length, 2); + } +}); + Deno.test("committed assistant identity reconciles the live block without text matching", () => { const lines = projectConsole([ { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index d9ee2f4f3..6d782d7a0 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -1048,6 +1048,7 @@ export function applyProtocolEvent( switch (event.event) { case "user_message": + if (event.data.entry_id && next.lines.some((line) => line.entryId === event.data.entry_id)) break; next.lines.push({ ...line( envelope.eventId, @@ -1059,6 +1060,9 @@ export function applyProtocolEvent( }); break; case "session_entry_committed": { + // A snapshot may already include this commit. Check before reconciling so + // replay cannot consume a later, unrelated live assistant block. + if (isRepeatedSessionEntry(next, event.data.entry)) break; const replacementIndex = reconcileCommittedSessionEntry( next, event.data.entry, @@ -2332,12 +2336,25 @@ function reconcileCommittedSessionEntry( } } +function isRepeatedSessionEntry( + projection: ConsoleProjection, + entry: Record, +): boolean { + // Tool entries update an existing call row, and a result may be corrected + // under the same entry_id (for example, provider image rejection). + const kind = stringField(entry, "kind"); + if (kind === "tool_call" || kind === "tool_result") return false; + const entryId = stringField(entry, "entry_id"); + return entryId !== undefined && + projection.lines.some((line) => line.entryId === entryId); +} + function applySessionEntry( projection: ConsoleProjection, fallbackEventId: string, value: unknown, ): void { - if (!isRecord(value)) return; + if (!isRecord(value) || isRepeatedSessionEntry(projection, value)) return; const eventId = stringField(value, "entry_id") ?? fallbackEventId; const lineCountBefore = projection.lines.length; switch (stringField(value, "kind")) { diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+error.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+error.svelte new file mode 100644 index 000000000..e08d26d3a --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+error.svelte @@ -0,0 +1,14 @@ + + +Worker page error · Yoi + + diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index c3b5713af..f7b6720f4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -167,7 +167,7 @@ let selectedWorkerViewSessionId = $state(null); let workerViewSelectionGeneration = 0; let consoleViewMode = $state("overview"); - let consoleBodyElement: HTMLElement | null = null; + let consoleBodyElement = $state(null); let turnNavigationElement = $state(null); let composerInputElement = $state< (SvelteComponent & ComposerInputHandle) | null @@ -1685,6 +1685,8 @@ {/if} + {#key selectedHistoryKey} + console.error("Console transcript rendering failed", error)}>
{/if}
+ {#snippet failed(_error, reset)} + + {/snippet} + + {/key} {#if workerDetailsOpen}