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.
This commit is contained in:
Mohamed Boudra
2026-09-04 14:23:43 +02:00
committed by GitHub
parent 92442e7435
commit 140b0bb716
3 changed files with 173 additions and 26 deletions
@@ -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,
},
+102
View File
@@ -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 = [
{
+39 -13
View File
@@ -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(