diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts index 22830dcbf..2a4e56b22 100644 --- a/tools/web-ux/browser-tests/console_history_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -57,6 +57,20 @@ function historyEntry(index: number, role: "user" | "assistant") { }; } +function historyTurnEntries(index: number) { + const tools = ["Bash", "Grep"].flatMap((name) => { + const callId = `${name}-${index}`; + return [{ + kind: "tool_call", entry_id: `call-${callId}`, timestamp: index, + provenance: "model_output", call_id: callId, name, arguments: "{}", + }, { + kind: "tool_result", entry_id: `result-${callId}`, timestamp: index, + provenance: "tool_output", call_id: callId, summary: "done", content: "done", is_error: false, + }]; + }); + return [historyEntry(index, "user"), ...tools, historyEntry(index, "assistant")]; +} + function historyPage(indices: number[], cursor: string | null) { return { availability: "page", @@ -66,7 +80,7 @@ function historyPage(indices: number[], cursor: string | null) { compact_ancestor_lineage_ids: [], turns: indices.map((index) => ({ turn_id: `user-${index}`, - entries: [historyEntry(index, "user"), historyEntry(index, "assistant")], + entries: historyTurnEntries(index), })), next_cursor: cursor, has_more: cursor !== null, @@ -162,7 +176,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { ) { return json({ availability: "retained_snapshot", - identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 12 }, + identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 }, snapshot: { pending_submissions: { revision: 0, @@ -170,10 +184,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { head_id: null, submissions: [], }, - entries: [7, 8, 9, 10, 11, 12].flatMap((index) => [ - historyEntry(index, "user"), - historyEntry(index, "assistant"), - ]), + entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), }, }); } diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 5bf782b2d..600ce2691 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -103,6 +103,8 @@ async function checkConsoleHistory(viewportHeight: number): Promise { assertEquals(await historyRequestCount(baseUrl), 1); assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6); assertEquals(await navigation.getByRole("button", { name: /^Turn \d+: question (7|8|9|10|11|12)$/ }).count(), 6); + assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 6, + "overlapping snapshot and history must render each tool activity once"); const turnList = navigation.locator(".turn-list"); const initialGeometry = await turnList.evaluate((element) => { const boundary = element.querySelector(".history-boundary")!; @@ -252,6 +254,14 @@ async function checkConsoleHistory(viewportHeight: number): Promise { return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2); }); assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`); + assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); + await page.getByRole("button", { name: "Normal", exact: true }).click(); + const rowIds = await transcript.locator("[data-console-line-id]").evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-console-line-id"))); + assertEquals(rowIds.length, 48); + assertEquals(new Set(rowIds).size, rowIds.length); + await page.getByRole("button", { name: "Overview", exact: true }).click(); + assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); assertEquals(errors, []); await context.close(); } finally { diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 6dc627c7a..787274f1f 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -1,5 +1,6 @@ import type { Event, + SessionSnapshotEntry, WorkerStateSnapshot, WorkerStatus, } from "$lib/generated/protocol"; @@ -13,6 +14,7 @@ import { projectConsole, projectConsoleLines, projectOverviewLines, + projectSessionHistoryEntries, resolveConsoleViewScrollTop, resolveConsoleWorkerView, segmentsToText, @@ -3062,6 +3064,57 @@ Deno.test("committed tool identities reconcile one live call block", () => { assertEquals(lines[0].toolCall?.state, "done"); }); +function readHistory(ids: string[], summary: string): ConsoleLine[] { + const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{ + kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1, + provenance: "model_output", call_id: id, name: "Read", + arguments: JSON.stringify({ file_path: id }), + }, { + kind: "tool_result", entry_id: `result-entry-${id}`, timestamp: 2, + provenance: "tool_output", call_id: id, summary, content: summary, is_error: false, + }]); + return projectSessionHistoryEntries(entries, null); +} + +Deno.test("committed history reconciles overlapping Read groups before counting activity", () => { + const history = readHistory(["a", "b", "c"], "retained"); + for (const ids of [["a", "b", "c"], ["a", "b"], ["b", "c"], ["a", "c"]]) { + const merged = mergeCommittedHistoryLines(history, readHistory(ids, "current")); + assertEquals(merged.length, 1); + assertEquals(merged[0].toolCallCount, 3); + assertEquals(merged[0].toolCallLines?.map((line) => line.toolCall?.id), ["a", "b", "c"]); + assertEquals(merged[0].toolCallLines?.map((line) => line.toolCall?.summary), + ["a", "b", "c"].map((id) => ids.includes(id) ? "current" : "retained")); + assertEquals(projectOverviewLines(merged)[0].body, "3 files read"); + assertEquals(mergeCommittedHistoryLines(history, merged)[0].toolCallCount, 3); + } +}); + +Deno.test("committed history matches tool calls across live and durable result identities", () => { + const retained: ConsoleLine = { + ...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a", + toolCall: { id: "a", name: "Bash", argsStream: "", state: "running" }, + }; + for (const entryId of [undefined, "result-entry-a"]) { + const current: ConsoleLine = { + ...retained, entryId, + toolCall: { ...retained.toolCall!, state: "done", summary: "latest result" }, + }; + const merged = mergeCommittedHistoryLines([retained], [current]); + assertEquals(merged, [current]); + assertEquals(projectOverviewLines(merged)[0].body, "ran 1 command"); + } +}); + +Deno.test("committed history restores retained separators inside compacted Read groups", () => { + const separator = { ...consoleLine("assistant", "assistant"), entryId: "assistant-entry" }; + const history = [...readHistory(["a"], "retained"), separator, ...readHistory(["b"], "retained")]; + const merged = mergeCommittedHistoryLines(history, readHistory(["a", "b"], "current")); + assertEquals(merged.map((line) => line.kind), ["tool", "assistant", "tool"]); + assertEquals(merged.filter((line) => line.kind === "tool").map((line) => line.toolCallCount), [1, 1]); + assertEquals(projectOverviewLines(merged).map((line) => line.kind), ["activity", "assistant", "activity"]); +}); + Deno.test("committed history preserves unmatched current rows in stable entry order", () => { const history = [2, 3, 4, 5, 6].flatMap((turn) => { const user = consoleLine(`history-user-${turn}`, "user"); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index ac052a405..708b0e32b 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -99,6 +99,8 @@ export type ConsoleLine = { toolCall?: ToolCallView; /** Number of calls represented by a lower-level aggregate line. */ toolCallCount?: number; + /** Original Read rows, retained so history can reconcile before aggregation. */ + toolCallLines?: ConsoleLine[]; /** Typed `SystemItem.kind` used by presentation-only projections. */ systemItemKind?: string; }; @@ -253,15 +255,30 @@ export function mergeCommittedHistoryLines( history: ConsoleLine[], current: ConsoleLine[], ): ConsoleLine[] { - const currentEntryIds = new Set( - current.map((line) => line.entryId).filter((id): id is string => - Boolean(id) - ), + return aggregateReadToolLines(mergeHistorySourceLines( + history.flatMap((line) => line.toolCallLines ?? [line]), + current.flatMap((line) => line.toolCallLines ?? [line]), + )); +} + +function historyLineKey(line: ConsoleLine): string | undefined { + // Calls and results have different entry IDs but update the same tool row. + if (line.toolCall) return `tool:${line.toolCall.id}`; + return line.entryId ? `entry:${line.entryId}` : undefined; +} + +function mergeHistorySourceLines( + history: ConsoleLine[], + current: ConsoleLine[], +): ConsoleLine[] { + const currentKeys = new Set( + current.map(historyLineKey).filter((id): id is string => Boolean(id)), ); const historyPositions = new Map(); history.forEach((line, index) => { - if (line.entryId && !historyPositions.has(line.entryId)) { - historyPositions.set(line.entryId, index); + const key = historyLineKey(line); + if (key && !historyPositions.has(key)) { + historyPositions.set(key, index); } }); @@ -272,9 +289,8 @@ export function mergeCommittedHistoryLines( const anchors: Array<{ history: number; current: number }> = []; let lastHistoryIndex = -1; current.forEach((line, currentIndex) => { - const historyIndex = line.entryId - ? historyPositions.get(line.entryId) - : undefined; + const key = historyLineKey(line); + const historyIndex = key ? historyPositions.get(key) : undefined; if (historyIndex !== undefined && historyIndex > lastHistoryIndex) { anchors.push({ history: historyIndex, current: currentIndex }); lastHistoryIndex = historyIndex; @@ -283,9 +299,10 @@ export function mergeCommittedHistoryLines( if (anchors.length === 0) { return [ - ...history.filter((line) => - !line.entryId || !currentEntryIds.has(line.entryId) - ), + ...history.filter((line) => { + const key = historyLineKey(line); + return key === undefined || !currentKeys.has(key); + }), ...current, ]; } @@ -296,7 +313,8 @@ export function mergeCommittedHistoryLines( const appendHistoryInterval = (end: number) => { for (; historyCursor < end; historyCursor += 1) { const line = history[historyCursor]!; - if (!line.entryId || !currentEntryIds.has(line.entryId)) { + const key = historyLineKey(line); + if (key === undefined || !currentKeys.has(key)) { merged.push(line); } } @@ -1605,6 +1623,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine { isError: hasError, }, toolCallCount: count, + toolCallLines: group, }; } 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 83cca483c..d6ce44f7c 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 @@ -217,20 +217,20 @@ const selectedHistory = $derived( historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(), ); - const currentLines = $derived( - projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode), - ); const committedHistoryLines = $derived( - projectConsoleLines( - projectSessionHistoryEntries( - historyEntries(selectedHistory), - selectedConsoleProjection.cwd, - ), - consoleViewMode, + projectSessionHistoryEntries( + historyEntries(selectedHistory), + selectedConsoleProjection.cwd, ), ); const lines = $derived( - mergeCommittedHistoryLines(committedHistoryLines, currentLines), + projectConsoleLines( + mergeCommittedHistoryLines( + committedHistoryLines, + selectedConsoleProjection.lines, + ), + consoleViewMode, + ), ); const turnNavigationItems = $derived( conversationTurnPreviewsFromLines(lines), diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index bcc93adc3..f652275cc 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -223,6 +223,46 @@ afterEach(() => { vi.restoreAllMocks(); }); +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"); + for (const [callId, name] of [["call-a", "Bash"], ["call-b", "Grep"]]) { + session.entries.push({ + kind: "tool_call", entry_id: `entry-${callId}`, timestamp: 2, + provenance: "model_output", call_id: callId, name, arguments: "{}", + }, { + kind: "tool_result", entry_id: `result-${callId}`, timestamp: 3, + provenance: "tool_output", call_id: callId, summary: "done", content: "done", is_error: false, + }); + } + const historyResponse = deferred(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("/session/history")) return historyResponse.promise; + return Response.json({ availability, snapshot: session }); + })); + const view = render(ConsolePage, { data: pageData() }); + if (availability === "live_protocol") { + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame(subscribedFrame(session)); + } + await screen.findByText("searched 1 time・ran 1 command"); + historyResponse.resolve(Response.json({ + availability: "page", page: { + ...emptyHistoryPage().page, + turns: [{ turn_id: session.entries[0].entry_id, entries: session.entries }], + }, + })); + await screen.findByText("Start of conversation"); + expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); + expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1); + await fireEvent.click(screen.getByRole("button", { name: "Normal" })); + const transcript = screen.getByRole("article", { name: "main transcript" }); + 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(ids.length); + await fireEvent.click(screen.getByRole("button", { name: "Overview" })); + expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); +}); + test("pending inputs show literal previews above Composer with revision-fenced icon actions", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); const view = render(ConsolePage, { data: pageData() });