mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user