Coalesce prepended tool call history
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user