Coalesce prepended tool call history

This commit is contained in:
Mohamed Boudra
2026-05-17 20:53:03 +07:00
parent e27734b218
commit c4e45af565
3 changed files with 163 additions and 34 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem } from "@/types/stream"; import { hydrateStreamState, type AgentToolCallItem, type StreamItem } from "@/types/stream";
import { import {
createAgentStreamReducerQueue, createAgentStreamReducerQueue,
processTimelineResponse, processTimelineResponse,
@@ -31,6 +31,28 @@ function makeTimelineEntry(
}; };
} }
function makeToolCallTimelineEntry(
seq: number,
callId: string,
status: "running" | "completed",
detail: Record<string, unknown>,
) {
return {
seqStart: seq,
seqEnd: seq,
provider: "claude",
item: {
type: "tool_call",
callId,
name: "Read",
status,
detail,
error: null,
},
timestamp: new Date(1000 + seq).toISOString(),
};
}
function makeTimelineEvent( function makeTimelineEvent(
text: string, text: string,
type: string = "assistant_message", type: string = "assistant_message",
@@ -97,6 +119,13 @@ function getUserTexts(items: StreamItem[]): string[] {
.map((item) => item.text); .map((item) => item.text);
} }
function getAgentToolCalls(items: StreamItem[]) {
return items.filter(
(item): item is AgentToolCallItem =>
item.kind === "tool_call" && item.payload.source === "agent",
);
}
const baseTimelineInput: ProcessTimelineResponseInput = { const baseTimelineInput: ProcessTimelineResponseInput = {
payload: { payload: {
agentId: "agent-1", agentId: "agent-1",
@@ -510,6 +539,68 @@ describe("processTimelineResponse", () => {
}); });
}); });
it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => {
const callId = "toolu_boundary";
const currentTail = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(3, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(3000),
},
],
{ source: "canonical" },
);
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
makeToolCallTimelineEntry(1, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
const toolCalls = getAgentToolCalls(result.tail);
expect(
toolCalls.map((item) => ({
id: item.id,
callId: item.payload.data.callId,
status: item.payload.data.status,
detailType: item.payload.data.detail.type,
})),
).toEqual([
{
id: `agent_tool_${callId}`,
callId,
status: "completed",
detailType: "read",
},
]);
});
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => { it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
const existingCursor: TimelineCursor = { const existingCursor: TimelineCursor = {
epoch: "epoch-1", epoch: "epoch-1",
@@ -3,7 +3,12 @@ import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; import {
applyStreamEvent,
coalesceAgentToolCallItems,
hydrateStreamState,
reduceStreamUpdate,
} from "@/types/stream";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
@@ -347,10 +352,10 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
const olderLast = olderTail.at(-1); const olderLast = olderTail.at(-1);
const currentFirst = currentTail[0]; const currentFirst = currentTail[0];
if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") {
return [...olderTail, ...currentTail]; return coalesceAgentToolCallItems([...olderTail, ...currentTail]);
} }
return [ return coalesceAgentToolCallItems([
...olderTail.slice(0, -1), ...olderTail.slice(0, -1),
{ {
...olderLast, ...olderLast,
@@ -358,7 +363,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
timestamp: currentFirst.timestamp, timestamp: currentFirst.timestamp,
}, },
...currentTail.slice(1), ...currentTail.slice(1),
]; ]);
} }
function applyTimelineIncrementalPath(args: { function applyTimelineIncrementalPath(args: {
+62 -29
View File
@@ -438,6 +438,36 @@ function mergeAgentToolCallStatus(
return "running"; return "running";
} }
function mergeAgentToolCallItem(
existing: AgentToolCallItem,
data: AgentToolCallData,
timestamp: Date,
): AgentToolCallItem {
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
const mergedError =
mergedStatus === "failed"
? (data.error ?? existing.payload.data.error ?? { message: "Tool call failed" })
: null;
const mergedMetadata = mergeToolCallMetadata(existing.payload.data.metadata, data.metadata);
const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail);
return {
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...data,
status: mergedStatus,
error: mergedError,
detail: mergedDetail,
metadata: mergedMetadata,
},
},
};
}
function appendAgentToolCall( function appendAgentToolCall(
state: StreamItem[], state: StreamItem[],
data: AgentToolCallData, data: AgentToolCallData,
@@ -450,42 +480,23 @@ function appendAgentToolCall(
if (!existing || !isAgentToolCallItem(existing)) { if (!existing || !isAgentToolCallItem(existing)) {
return state; return state;
} }
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const merged = mergeAgentToolCallItem(existing, data, timestamp);
const mergedError =
mergedStatus === "failed"
? (data.error ?? existing.payload.data.error ?? { message: "Tool call failed" })
: null;
const mergedMetadata = mergeToolCallMetadata(existing.payload.data.metadata, data.metadata);
const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail);
if ( if (
data.provider === existing.payload.data.provider && merged.timestamp === existing.timestamp &&
data.callId === existing.payload.data.callId && merged.payload.data.provider === existing.payload.data.provider &&
data.name === existing.payload.data.name && merged.payload.data.callId === existing.payload.data.callId &&
mergedStatus === existing.payload.data.status && merged.payload.data.name === existing.payload.data.name &&
mergedError === existing.payload.data.error && merged.payload.data.status === existing.payload.data.status &&
mergedDetail === existing.payload.data.detail && merged.payload.data.error === existing.payload.data.error &&
mergedMetadata === existing.payload.data.metadata merged.payload.data.detail === existing.payload.data.detail &&
merged.payload.data.metadata === existing.payload.data.metadata
) { ) {
return state; return state;
} }
const next = [...state]; const next = [...state];
next[existingIndex] = { next[existingIndex] = merged;
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...data,
status: mergedStatus,
error: mergedError,
detail: mergedDetail,
metadata: mergedMetadata,
},
},
};
return next; return next;
} }
@@ -505,6 +516,28 @@ function appendAgentToolCall(
return [...state, item]; return [...state, item];
} }
export function coalesceAgentToolCallItems(items: StreamItem[]): StreamItem[] {
const state: StreamItem[] = [];
const toolCallIndexByCallId = new Map<string, number>();
for (const item of items) {
if (!isAgentToolCallItem(item)) {
state.push(item);
continue;
}
const existingIndex = toolCallIndexByCallId.get(item.payload.data.callId);
if (existingIndex === undefined) {
toolCallIndexByCallId.set(item.payload.data.callId, state.length);
state.push(item);
continue;
}
const existing = state[existingIndex];
if (existing && isAgentToolCallItem(existing)) {
state[existingIndex] = mergeAgentToolCallItem(existing, item.payload.data, item.timestamp);
}
}
return state;
}
function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] {
const index = state.findIndex((existing) => existing.id === entry.id); const index = state.findIndex((existing) => existing.id === entry.id);
if (index >= 0) { if (index >= 0) {