fix: reconcile console history before grouping activity
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
const lines = $derived(
|
||||
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
|
||||
projectConsoleLines(
|
||||
mergeCommittedHistoryLines(
|
||||
committedHistoryLines,
|
||||
selectedConsoleProjection.lines,
|
||||
),
|
||||
consoleViewMode,
|
||||
),
|
||||
);
|
||||
const turnNavigationItems = $derived(
|
||||
conversationTurnPreviewsFromLines(lines),
|
||||
|
||||
+40
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user