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) {
|
function historyPage(indices: number[], cursor: string | null) {
|
||||||
return {
|
return {
|
||||||
availability: "page",
|
availability: "page",
|
||||||
@@ -66,7 +80,7 @@ function historyPage(indices: number[], cursor: string | null) {
|
|||||||
compact_ancestor_lineage_ids: [],
|
compact_ancestor_lineage_ids: [],
|
||||||
turns: indices.map((index) => ({
|
turns: indices.map((index) => ({
|
||||||
turn_id: `user-${index}`,
|
turn_id: `user-${index}`,
|
||||||
entries: [historyEntry(index, "user"), historyEntry(index, "assistant")],
|
entries: historyTurnEntries(index),
|
||||||
})),
|
})),
|
||||||
next_cursor: cursor,
|
next_cursor: cursor,
|
||||||
has_more: cursor !== null,
|
has_more: cursor !== null,
|
||||||
@@ -162,7 +176,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
) {
|
) {
|
||||||
return json({
|
return json({
|
||||||
availability: "retained_snapshot",
|
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: {
|
snapshot: {
|
||||||
pending_submissions: {
|
pending_submissions: {
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -170,10 +184,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
head_id: null,
|
head_id: null,
|
||||||
submissions: [],
|
submissions: [],
|
||||||
},
|
},
|
||||||
entries: [7, 8, 9, 10, 11, 12].flatMap((index) => [
|
entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries),
|
||||||
historyEntry(index, "user"),
|
|
||||||
historyEntry(index, "assistant"),
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
|||||||
assertEquals(await historyRequestCount(baseUrl), 1);
|
assertEquals(await historyRequestCount(baseUrl), 1);
|
||||||
assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6);
|
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 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 turnList = navigation.locator(".turn-list");
|
||||||
const initialGeometry = await turnList.evaluate((element) => {
|
const initialGeometry = await turnList.evaluate((element) => {
|
||||||
const boundary = element.querySelector(".history-boundary")!;
|
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);
|
return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2);
|
||||||
});
|
});
|
||||||
assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`);
|
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, []);
|
assertEquals(errors, []);
|
||||||
await context.close();
|
await context.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Event,
|
Event,
|
||||||
|
SessionSnapshotEntry,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
projectConsole,
|
projectConsole,
|
||||||
projectConsoleLines,
|
projectConsoleLines,
|
||||||
projectOverviewLines,
|
projectOverviewLines,
|
||||||
|
projectSessionHistoryEntries,
|
||||||
resolveConsoleViewScrollTop,
|
resolveConsoleViewScrollTop,
|
||||||
resolveConsoleWorkerView,
|
resolveConsoleWorkerView,
|
||||||
segmentsToText,
|
segmentsToText,
|
||||||
@@ -3062,6 +3064,57 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
assertEquals(lines[0].toolCall?.state, "done");
|
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", () => {
|
Deno.test("committed history preserves unmatched current rows in stable entry order", () => {
|
||||||
const history = [2, 3, 4, 5, 6].flatMap((turn) => {
|
const history = [2, 3, 4, 5, 6].flatMap((turn) => {
|
||||||
const user = consoleLine(`history-user-${turn}`, "user");
|
const user = consoleLine(`history-user-${turn}`, "user");
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export type ConsoleLine = {
|
|||||||
toolCall?: ToolCallView;
|
toolCall?: ToolCallView;
|
||||||
/** Number of calls represented by a lower-level aggregate line. */
|
/** Number of calls represented by a lower-level aggregate line. */
|
||||||
toolCallCount?: number;
|
toolCallCount?: number;
|
||||||
|
/** Original Read rows, retained so history can reconcile before aggregation. */
|
||||||
|
toolCallLines?: ConsoleLine[];
|
||||||
/** Typed `SystemItem.kind` used by presentation-only projections. */
|
/** Typed `SystemItem.kind` used by presentation-only projections. */
|
||||||
systemItemKind?: string;
|
systemItemKind?: string;
|
||||||
};
|
};
|
||||||
@@ -253,15 +255,30 @@ export function mergeCommittedHistoryLines(
|
|||||||
history: ConsoleLine[],
|
history: ConsoleLine[],
|
||||||
current: ConsoleLine[],
|
current: ConsoleLine[],
|
||||||
): ConsoleLine[] {
|
): ConsoleLine[] {
|
||||||
const currentEntryIds = new Set(
|
return aggregateReadToolLines(mergeHistorySourceLines(
|
||||||
current.map((line) => line.entryId).filter((id): id is string =>
|
history.flatMap((line) => line.toolCallLines ?? [line]),
|
||||||
Boolean(id)
|
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>();
|
const historyPositions = new Map<string, number>();
|
||||||
history.forEach((line, index) => {
|
history.forEach((line, index) => {
|
||||||
if (line.entryId && !historyPositions.has(line.entryId)) {
|
const key = historyLineKey(line);
|
||||||
historyPositions.set(line.entryId, index);
|
if (key && !historyPositions.has(key)) {
|
||||||
|
historyPositions.set(key, index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,9 +289,8 @@ export function mergeCommittedHistoryLines(
|
|||||||
const anchors: Array<{ history: number; current: number }> = [];
|
const anchors: Array<{ history: number; current: number }> = [];
|
||||||
let lastHistoryIndex = -1;
|
let lastHistoryIndex = -1;
|
||||||
current.forEach((line, currentIndex) => {
|
current.forEach((line, currentIndex) => {
|
||||||
const historyIndex = line.entryId
|
const key = historyLineKey(line);
|
||||||
? historyPositions.get(line.entryId)
|
const historyIndex = key ? historyPositions.get(key) : undefined;
|
||||||
: undefined;
|
|
||||||
if (historyIndex !== undefined && historyIndex > lastHistoryIndex) {
|
if (historyIndex !== undefined && historyIndex > lastHistoryIndex) {
|
||||||
anchors.push({ history: historyIndex, current: currentIndex });
|
anchors.push({ history: historyIndex, current: currentIndex });
|
||||||
lastHistoryIndex = historyIndex;
|
lastHistoryIndex = historyIndex;
|
||||||
@@ -283,9 +299,10 @@ export function mergeCommittedHistoryLines(
|
|||||||
|
|
||||||
if (anchors.length === 0) {
|
if (anchors.length === 0) {
|
||||||
return [
|
return [
|
||||||
...history.filter((line) =>
|
...history.filter((line) => {
|
||||||
!line.entryId || !currentEntryIds.has(line.entryId)
|
const key = historyLineKey(line);
|
||||||
),
|
return key === undefined || !currentKeys.has(key);
|
||||||
|
}),
|
||||||
...current,
|
...current,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -296,7 +313,8 @@ export function mergeCommittedHistoryLines(
|
|||||||
const appendHistoryInterval = (end: number) => {
|
const appendHistoryInterval = (end: number) => {
|
||||||
for (; historyCursor < end; historyCursor += 1) {
|
for (; historyCursor < end; historyCursor += 1) {
|
||||||
const line = history[historyCursor]!;
|
const line = history[historyCursor]!;
|
||||||
if (!line.entryId || !currentEntryIds.has(line.entryId)) {
|
const key = historyLineKey(line);
|
||||||
|
if (key === undefined || !currentKeys.has(key)) {
|
||||||
merged.push(line);
|
merged.push(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1605,6 +1623,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
isError: hasError,
|
isError: hasError,
|
||||||
},
|
},
|
||||||
toolCallCount: count,
|
toolCallCount: count,
|
||||||
|
toolCallLines: group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,20 +217,20 @@
|
|||||||
const selectedHistory = $derived(
|
const selectedHistory = $derived(
|
||||||
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||||
);
|
);
|
||||||
const currentLines = $derived(
|
|
||||||
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
|
|
||||||
);
|
|
||||||
const committedHistoryLines = $derived(
|
const committedHistoryLines = $derived(
|
||||||
projectConsoleLines(
|
projectSessionHistoryEntries(
|
||||||
projectSessionHistoryEntries(
|
historyEntries(selectedHistory),
|
||||||
historyEntries(selectedHistory),
|
selectedConsoleProjection.cwd,
|
||||||
selectedConsoleProjection.cwd,
|
|
||||||
),
|
|
||||||
consoleViewMode,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const lines = $derived(
|
const lines = $derived(
|
||||||
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
|
projectConsoleLines(
|
||||||
|
mergeCommittedHistoryLines(
|
||||||
|
committedHistoryLines,
|
||||||
|
selectedConsoleProjection.lines,
|
||||||
|
),
|
||||||
|
consoleViewMode,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const turnNavigationItems = $derived(
|
const turnNavigationItems = $derived(
|
||||||
conversationTurnPreviewsFromLines(lines),
|
conversationTurnPreviewsFromLines(lines),
|
||||||
|
|||||||
+40
@@ -223,6 +223,46 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
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 () => {
|
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" })));
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
const view = render(ConsolePage, { data: pageData() });
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
|||||||
Reference in New Issue
Block a user