fix: reconcile console history before grouping activity

This commit is contained in:
2026-09-30 14:16:58 +09:00
parent cbf1b83599
commit 8627d45085
6 changed files with 162 additions and 29 deletions
@@ -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),
},
});
}
@@ -103,6 +103,8 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
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<void> {
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 {
@@ -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");
@@ -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<string, number>();
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,
};
}
@@ -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),
@@ -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<Response>();
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() });