From 140b0bb716205cf0e00c0ff5b6b3c20af6c79413 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 14:23:43 +0200 Subject: [PATCH] fix(app): keep long agent timelines smooth across autonomous turns Scope provider tool-call identity to its autonomous turn so repeated IDs remain distinct, lifecycle updates still merge correctly, and native timeline lists no longer receive duplicate keys. --- .../timeline/session-stream-reducers.test.ts | 45 +++++--- packages/app/src/types/stream.test.ts | 102 ++++++++++++++++++ packages/app/src/types/stream.ts | 52 ++++++--- 3 files changed, 173 insertions(+), 26 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index ae2ba9bf53..13897f8454 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -49,6 +49,7 @@ function makeToolCallTimelineEntry( callId: string, status: "running" | "completed", detail: ToolCallDetail, + turnId?: string, ): TimelineResponseEntry { return { seqStart: seq, @@ -62,6 +63,7 @@ function makeToolCallTimelineEntry( detail, error: null, }, + ...(turnId ? { turnId } : {}), timestamp: new Date(1000 + seq).toISOString(), }; } @@ -3197,12 +3199,14 @@ describe("processTimelineResponse", () => { it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => { const callId = "toolu_boundary"; + const turnId = "turn-boundary"; const currentTail = hydrateStreamState( [ { event: { type: "timeline", provider: "claude", + turnId, item: makeToolCallTimelineEntry(3, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3230,11 +3234,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + turnId, + ), ], }, }); @@ -3249,7 +3259,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${turnId}/${callId}`, callId, status: "completed", detailType: "read", @@ -3358,6 +3368,8 @@ describe("processTimelineResponse", () => { it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; + const olderTurnId = "autonomous-turn-1"; + const currentTurnId = "autonomous-turn-2"; const currentTail = hydrateStreamState( [ { @@ -3368,6 +3380,7 @@ describe("processTimelineResponse", () => { event: { type: "timeline", provider: "claude", + turnId: currentTurnId, item: makeToolCallTimelineEntry(4, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3395,11 +3408,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + olderTurnId, + ), makeTimelineEntry(2, "older chunk "), ], }, @@ -3415,7 +3434,7 @@ describe("processTimelineResponse", () => { ).toEqual([ { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${olderTurnId}/${callId}`, status: "running", text: null, }, @@ -3427,7 +3446,7 @@ describe("processTimelineResponse", () => { }, { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${currentTurnId}/${callId}`, status: "completed", text: null, }, diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 2bc47c9288..cd05fd0826 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -312,6 +312,7 @@ function reasoningTimeline( function canonicalToolTimeline(params: { provider: AgentProvider; callId: string; + turnId?: string; name: string; status: CanonicalToolStatus; input?: unknown; @@ -350,6 +351,7 @@ function canonicalToolTimeline(params: { return { type: "timeline", provider: params.provider, + ...(params.turnId ? { turnId: params.turnId } : {}), item, }; } @@ -584,6 +586,106 @@ describe("stream reducer tool call idempotency", () => { }); describe("stream reducer canonical tool calls", () => { + it("keeps repeated call ids in different turns as distinct timeline rows", () => { + const callId = "tool-reused-across-turns"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-1", + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-2", + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + const tools = state.filter(isAgentToolCallItem); + expect(tools.map((tool) => tool.turnId)).toEqual(["autonomous-turn-1", "autonomous-turn-2"]); + expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); + }); + + it("merges lifecycle updates for the same call occurrence", () => { + const callId = "tool-updated-within-turn"; + const turnId = "autonomous-turn-1"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_turn:${turnId}/${callId}`, + turnId, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + + it("preserves call-id lifecycle merging for events without turn identity", () => { + const callId = "legacy-unscoped-tool"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_${callId}`, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("is deterministic for equivalent hydration sequences", () => { const updates = [ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 93c404804f..4ebd0972db 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -449,7 +449,11 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S return null; } if (isAgentToolCallItem(retained)) { - const tailIndex = findExistingTimelineIdentityIndex(tail, retained.payload.data.callId); + const identity = agentToolCallIdentity({ + callId: retained.payload.data.callId, + turnId: retained.turnId, + }); + const tailIndex = findExistingTimelineIdentityIndex(tail, identity); const existing = tail[tailIndex]; if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { return null; @@ -1022,11 +1026,26 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] { } export function streamTimelineItemIdentity(item: StreamItem): string | null { - if (isAgentToolCallItem(item)) return item.payload.data.callId; + if (isAgentToolCallItem(item)) { + return agentToolCallIdentity({ + callId: item.payload.data.callId, + turnId: item.turnId, + }); + } if (item.kind === "plugin") return `${item.pluginId}/${item.pluginItemId}`; return null; } +interface AgentToolCallIdentityInput { + callId: string; + turnId?: string; +} + +function agentToolCallIdentity(input: AgentToolCallIdentityInput): string { + if (!input.turnId) return input.callId; + return `turn:${encodeURIComponent(input.turnId)}/${encodeURIComponent(input.callId)}`; +} + function findExistingTimelineIdentityIndex(state: StreamItem[], identity: string): number { return state.findIndex((entry) => streamTimelineItemIdentity(entry) === identity); } @@ -1165,13 +1184,18 @@ export function mergeAgentToolCallItem( }; } -function appendAgentToolCall( - state: StreamItem[], - data: AgentToolCallData, - timestamp: Date, - timelineCursor?: TimelinePosition, -): StreamItem[] { - const existingIndex = findExistingTimelineIdentityIndex(state, data.callId); +interface AppendAgentToolCallInput { + state: StreamItem[]; + data: AgentToolCallData; + timestamp: Date; + turnId?: string; + timelineCursor?: TimelinePosition; +} + +function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { + const { state, data, timestamp, turnId, timelineCursor } = input; + const identity = agentToolCallIdentity({ callId: data.callId, turnId }); + const existingIndex = findExistingTimelineIdentityIndex(state, identity); if (existingIndex >= 0) { const existing = state[existingIndex]; @@ -1200,8 +1224,9 @@ function appendAgentToolCall( const item: ToolCallItem = { kind: "tool_call", - id: `agent_tool_${data.callId}`, + id: `agent_tool_${identity}`, ...(timelineCursor ? { timelineCursor } : {}), + ...(turnId ? { turnId } : {}), timestamp, payload: { source: "agent", @@ -1415,9 +1440,9 @@ function reduceTimelineToolCall( ); } - return appendAgentToolCall( + return appendAgentToolCall({ state, - { + data: { provider: event.provider, callId: item.callId, name: item.name, @@ -1428,7 +1453,8 @@ function reduceTimelineToolCall( }, timestamp, timelineCursor, - ); + turnId: event.turnId, + }); } function reduceTimelineCompaction(