From 1f7fc232b3ede94f50212ec6b9ffb7046755b66a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 17 May 2026 22:24:07 +0700 Subject: [PATCH] Limit tool call coalescing to prepend boundary --- .../timeline/session-stream-reducers.test.ts | 78 +++++++++++++++++++ .../src/timeline/session-stream-reducers.ts | 26 +++++-- packages/app/src/types/stream.ts | 24 +----- 3 files changed, 100 insertions(+), 28 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index b5a5ba9c0..019901591 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -601,6 +601,84 @@ describe("processTimelineResponse", () => { ]); }); + it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { + const callId = "toolu_not_boundary"; + const currentTail = hydrateStreamState( + [ + { + event: makeTimelineEvent("current chunk"), + timestamp: new Date(3000), + }, + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(4, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(4000), + }, + ], + { 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, + }), + makeTimelineEntry(2, "older chunk "), + ], + }, + }); + + expect( + result.tail.map((item) => ({ + kind: item.kind, + id: item.id, + status: item.kind === "tool_call" ? item.payload.data.status : null, + text: item.kind === "assistant_message" ? item.text : null, + })), + ).toEqual([ + { + kind: "tool_call", + id: `agent_tool_${callId}`, + status: "running", + text: null, + }, + { + kind: "assistant_message", + id: expect.any(String), + status: null, + text: "older chunk current chunk", + }, + { + kind: "tool_call", + id: `agent_tool_${callId}`, + status: "completed", + text: null, + }, + ]); + }); + it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => { const existingCursor: TimelineCursor = { epoch: "epoch-1", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 71e7e5c52..411021a7f 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -5,8 +5,9 @@ import { useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { applyStreamEvent, - coalesceAgentToolCallItems, hydrateStreamState, + isAgentToolCallItem, + mergeAgentToolCallItem, reduceStreamUpdate, } from "@/types/stream"; @@ -351,11 +352,26 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea const olderLast = olderTail.at(-1); const currentFirst = currentTail[0]; - if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { - return coalesceAgentToolCallItems([...olderTail, ...currentTail]); + + if ( + olderLast && + currentFirst && + isAgentToolCallItem(olderLast) && + isAgentToolCallItem(currentFirst) && + olderLast.payload.data.callId === currentFirst.payload.data.callId + ) { + return [ + ...olderTail.slice(0, -1), + mergeAgentToolCallItem(olderLast, currentFirst.payload.data, currentFirst.timestamp), + ...currentTail.slice(1), + ]; } - return coalesceAgentToolCallItems([ + if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { + return [...olderTail, ...currentTail]; + } + + return [ ...olderTail.slice(0, -1), { ...olderLast, @@ -363,7 +379,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea timestamp: currentFirst.timestamp, }, ...currentTail.slice(1), - ]); + ]; } function applyTimelineIncrementalPath(args: { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4a14998bf..105cdf1c7 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -438,7 +438,7 @@ function mergeAgentToolCallStatus( return "running"; } -function mergeAgentToolCallItem( +export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, @@ -515,28 +515,6 @@ function appendAgentToolCall( return [...state, item]; } -export function coalesceAgentToolCallItems(items: StreamItem[]): StreamItem[] { - const state: StreamItem[] = []; - const toolCallIndexByCallId = new Map(); - 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[] { const index = state.findIndex((existing) => existing.id === entry.id); if (index >= 0) {