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
@@ -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() });