fix: deduplicate console entries and contain worker rendering errors
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
+111
@@ -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"]]) {
|
||||||
|
|||||||
+26
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user