fix: deduplicate console entries and contain worker rendering errors

This commit is contained in:
2026-10-05 19:13:15 +09:00
parent a3c3bda3fa
commit f5c6b0aeb1
6 changed files with 256 additions and 2 deletions
@@ -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"); 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", () => { Deno.test("committed assistant identity reconciles the live block without text matching", () => {
const lines = projectConsole([ const lines = projectConsole([
{ {
@@ -1048,6 +1048,7 @@ export function applyProtocolEvent(
switch (event.event) { switch (event.event) {
case "user_message": case "user_message":
if (event.data.entry_id && next.lines.some((line) => line.entryId === event.data.entry_id)) break;
next.lines.push({ next.lines.push({
...line( ...line(
envelope.eventId, envelope.eventId,
@@ -1059,6 +1060,9 @@ export function applyProtocolEvent(
}); });
break; break;
case "session_entry_committed": { 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( const replacementIndex = reconcileCommittedSessionEntry(
next, next,
event.data.entry, event.data.entry,
@@ -2332,12 +2336,25 @@ function reconcileCommittedSessionEntry(
} }
} }
function isRepeatedSessionEntry(
projection: ConsoleProjection,
entry: Record<string, unknown>,
): 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( function applySessionEntry(
projection: ConsoleProjection, projection: ConsoleProjection,
fallbackEventId: string, fallbackEventId: string,
value: unknown, value: unknown,
): void { ): void {
if (!isRecord(value)) return; if (!isRecord(value) || isRepeatedSessionEntry(projection, value)) return;
const eventId = stringField(value, "entry_id") ?? fallbackEventId; const eventId = stringField(value, "entry_id") ?? fallbackEventId;
const lineCountBefore = projection.lines.length; const lineCountBefore = projection.lines.length;
switch (stringField(value, "kind")) { switch (stringField(value, "kind")) {
@@ -0,0 +1,14 @@
<script lang="ts">
import { page } from "$app/state";
import { workspaceRoute } from "#lib/workspace/api/http.ts";
</script>
<svelte:head><title>Worker page error · Yoi</title></svelte:head>
<section class="card" role="alert">
<h1>Unable to open this Worker page</h1>
<p>The Worker page could not be loaded or displayed. This does not mean the Workspace is unavailable.</p>
<p>Status: {page.status}</p>
<button type="button" class="secondary-button" onclick={() => window.location.reload()}>Reload page</button>
<a href={workspaceRoute(page.params.workspaceId ?? "", "/workers")}>Back to Workers</a>
</section>
@@ -167,7 +167,7 @@
let selectedWorkerViewSessionId = $state<string | null>(null); let selectedWorkerViewSessionId = $state<string | null>(null);
let workerViewSelectionGeneration = 0; let workerViewSelectionGeneration = 0;
let consoleViewMode = $state<ConsoleViewMode>("overview"); let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement = $state<HTMLElement | null>(null);
let turnNavigationElement = $state<HTMLElement | null>(null); let turnNavigationElement = $state<HTMLElement | null>(null);
let composerInputElement = $state< let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null (SvelteComponent & ComposerInputHandle) | null
@@ -1685,6 +1685,8 @@
</section> </section>
{/if} {/if}
{#key selectedHistoryKey}
<svelte:boundary onerror={(error) => console.error("Console transcript rendering failed", error)}>
<div class:with-task-pane={taskPaneOpen} class="console-history"> <div class:with-task-pane={taskPaneOpen} class="console-history">
<section class="console-body"> <section class="console-body">
<div <div
@@ -1768,6 +1770,15 @@
<ConsoleTasks {tasks} mode="pane" paneId="console-task-pane" /> <ConsoleTasks {tasks} mode="pane" paneId="console-task-pane" />
{/if} {/if}
</div> </div>
{#snippet failed(_error, reset)}
<section class="card" role="alert">
<h2>Unable to display conversation</h2>
<p>This transcript could not be rendered. Try again or select another Worker view.</p>
<button type="button" class="secondary-button" onclick={reset}>Retry display</button>
</section>
{/snippet}
</svelte:boundary>
{/key}
{#if workerDetailsOpen} {#if workerDetailsOpen}
<aside class="console-side-panel" aria-label="Worker detail"> <aside class="console-side-panel" aria-label="Worker detail">
@@ -17,6 +17,7 @@ import type {
import type { Worker } from "#lib/workspace/sidebar/types.ts"; import type { Worker } from "#lib/workspace/sidebar/types.ts";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import * as alerts from "#lib/workspace/alerts/store.ts"; import * as alerts from "#lib/workspace/alerts/store.ts";
import * as consoleModel from "#lib/workspace/console/model.ts";
import ConsolePage from "./+page.svelte"; import ConsolePage from "./+page.svelte";
// happy-dom does not implement Web Animations. Motion itself is covered in Chromium. // happy-dom does not implement Web Animations. Motion itself is covered in Chromium.
@@ -230,6 +231,116 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
function subworkerFrame() {
const child = { session_id: "child-session", name: "reviewer", kind: "sub_worker" as const };
const session = sessionWithUserMessage("child question");
const reply = {
kind: "message" as const, entry_id: "child-reply", timestamp: 2,
provenance: "model_output" as const, role: "assistant" as const,
content: [{ kind: "text" as const, text: "child answer" }],
};
session.entries.push(reply, { ...reply, entry_id: "child-reply-2" });
const snapshot = snapshotEvent(sessionWithUserMessage("parent question")) as Extract<ProtocolEvent, { event: "snapshot" }>;
snapshot.data.internal_workers = [{
worker: child, revision: 5, session, status: "idle",
in_flight: { blocks: [] }, internal_workers: [],
}];
const frame = subscribedFrame();
frame.message.payload.snapshot.data.events = [snapshot, ...[6, 7].map((revision): ProtocolEvent => ({
event: "internal_worker", data: {
worker: child, revision, event: { event: "session_entry_committed", data: { entry: reply } },
},
}))];
return frame;
}
function mockLiveSessionFetch() {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => Response.json(
String(input).includes("/session/history") ? emptyHistoryPage() : { availability: "live_protocol" },
)));
}
test("switching to a SubWorker after snapshot/commit overlap renders each entry once in both modes", async () => {
mockLiveSessionFetch();
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(2000);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(300);
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subworkerFrame());
await screen.findByText("parent question");
const mainScroll = view.container.querySelector(".console-scroll") as HTMLElement;
mainScroll.scrollTop = 250;
await fireEvent.scroll(mainScroll);
const selector = await screen.findByRole("group", { name: "Worker transcript view" });
await fireEvent.click(within(selector).getByRole("button", { name: "reviewer" }));
const transcript = await screen.findByRole("article", { name: "reviewer transcript" });
const assertEntries = () => {
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(3);
expect(within(transcript).getAllByText("child answer")).toHaveLength(2);
expect(within(transcript).queryByText("parent question")).toBeNull();
expect(screen.queryByText("Unable to display conversation")).toBeNull();
};
assertEntries();
await fireEvent.click(screen.getByRole("switch", { name: "Overview" }));
assertEntries();
const childScroll = view.container.querySelector(".console-scroll") as HTMLElement;
childScroll.scrollTop = 140;
await fireEvent.scroll(childScroll);
await fireEvent.click(within(selector).getByRole("button", { name: "main" }));
expect(await screen.findByText("parent question")).toBeTruthy();
expect(screen.queryByText("child answer")).toBeNull();
await waitFor(() => expect(view.container.querySelector(".console-scroll")?.scrollTop).toBe(250));
await fireEvent.click(within(selector).getByRole("button", { name: "reviewer" }));
await waitFor(() => expect(view.container.querySelector(".console-scroll")?.scrollTop).toBe(140));
expect(multiplexer.close).not.toHaveBeenCalled();
});
test.each(["switch", "retry"] as const)("a transcript rendering failure stays local and allows %s recovery", async (recovery) => {
mockLiveSessionFetch();
const project = consoleModel.projectConsoleLines;
const projection = vi.spyOn(consoleModel, "projectConsoleLines").mockImplementation((lines, mode) => {
const result = project(lines, mode);
// Deliberately inject a renderer defect after projection to exercise the
// actual Svelte each_key_duplicate boundary, independently of deduplication.
const childReply = result.find((line) => line.entryId === "child-reply");
return childReply ? [...result, childReply] : result;
});
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subworkerFrame());
await screen.findByText("parent question");
const composer = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
composer.dispatch({ changes: { from: 0, insert: "keep my draft" } });
const selector = screen.getByRole("group", { name: "Worker transcript view" });
await fireEvent.click(within(selector).getByRole("button", { name: "reviewer" }));
const failure = await screen.findByRole("alert");
expect(within(failure).getByText("Unable to display conversation")).toBeTruthy();
expect(errorLog).toHaveBeenCalledWith("Console transcript rendering failed", expect.any(Error));
expect(screen.queryByText("The selected Workspace cannot be opened")).toBeNull();
expect(EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)).toBe(composer);
expect(composer.state.doc.toString()).toBe("keep my draft");
expect(multiplexer.close).not.toHaveBeenCalled();
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
if (recovery === "switch") {
await fireEvent.click(within(selector).getByRole("button", { name: "main" }));
expect(await screen.findByText("parent question")).toBeTruthy();
} else {
projection.mockRestore();
const recoveredProjection = vi.spyOn(consoleModel, "projectConsoleLines");
// Wait for the queued snapshot to reach projection before retrying the renderer.
latestListener().onFrame(subworkerFrame());
await waitFor(() => expect(recoveredProjection).toHaveBeenCalled());
await fireEvent.click(within(failure).getByRole("button", { name: "Retry display" }));
expect(await screen.findAllByText("child answer")).toHaveLength(2);
}
expect(screen.queryByText("Unable to display conversation")).toBeNull();
expect(composer.state.doc.toString()).toBe("keep my draft");
expect(multiplexer.subscribe).toHaveBeenCalledOnce();
});
test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history and %s render tool activity once in both modes", async (availability) => { test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history and %s render tool activity once in both modes", async (availability) => {
const session = sessionWithUserMessage("inspect tools"); const session = sessionWithUserMessage("inspect tools");
for (const [callId, name] of [["call-a", "Bash"], ["call-b", "Grep"]]) { for (const [callId, name] of [["call-a", "Bash"], ["call-b", "Grep"]]) {
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import WorkerError from "./+error.svelte";
vi.mock("$app/state", () => ({
page: { params: { workspaceId: "workspace-a", workerRef: "worker-a" }, status: 500 },
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test("Worker route failures offer local recovery instead of claiming Workspace removal", async () => {
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {});
const view = render(WorkerError);
expect(view.getByRole("heading", { name: "Unable to open this Worker page" })).toBeTruthy();
expect(view.getByRole("link", { name: "Back to Workers" }).getAttribute("href"))
.toBe("/w/workspace-a/workers");
expect(view.queryByText("The selected Workspace cannot be opened")).toBeNull();
expect(view.queryByText("Create Workspace")).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "Reload page" }));
expect(reload).toHaveBeenCalledOnce();
});