fix(server,app): coalesce tool call stream events to prevent JS thread freeze
Pi's ACP binary streams 200+ redundant tool_call_update events per tool call, bypassing the server coalescer and flooding the app with synchronous state updates. Two-layer fix: Server: extend AgentStreamCoalescer to handle tool_call items via last-write-wins per callId (200ms window, up from 33ms). Terminal statuses flush immediately. App: mergeToolCallDetail and appendAgentToolCall now return existing references when fields are unchanged, preserving React.memo equality checks.
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
reduceStreamUpdate,
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from "./stream";
|
||||
import type { AgentProvider, ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { buildToolCallDisplayModel } from "../../../server/src/shared/tool-call-display";
|
||||
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
@@ -99,6 +100,193 @@ function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallIte
|
||||
);
|
||||
}
|
||||
|
||||
describe("stream reducer tool call idempotency", () => {
|
||||
it("returns the same detail reference when tool call detail is identical", () => {
|
||||
const existing: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
cwd: "/tmp/repo",
|
||||
};
|
||||
const incoming: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
cwd: "/tmp/repo",
|
||||
};
|
||||
|
||||
const merged = mergeToolCallDetail(existing, incoming);
|
||||
|
||||
assert.strictEqual(merged, existing);
|
||||
});
|
||||
|
||||
it("returns a new detail reference when tool call detail changes", () => {
|
||||
const existing: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
cwd: "/tmp/repo",
|
||||
};
|
||||
const incoming: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "npm run typecheck",
|
||||
cwd: "/tmp/repo",
|
||||
};
|
||||
|
||||
const merged = mergeToolCallDetail(existing, incoming);
|
||||
|
||||
assert.notStrictEqual(merged, existing);
|
||||
assert.deepStrictEqual(merged, incoming);
|
||||
});
|
||||
|
||||
it("returns the same state array when status, error, detail, and metadata are identical", () => {
|
||||
const callId = "idempotent-tool-call";
|
||||
const initialState = reduceStreamUpdate(
|
||||
[],
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
metadata: {
|
||||
paneId: "%1",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:00:00Z"),
|
||||
);
|
||||
|
||||
const nextState = reduceStreamUpdate(
|
||||
initialState,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
metadata: {
|
||||
paneId: "%1",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:00:01Z"),
|
||||
);
|
||||
|
||||
assert.strictEqual(nextState, initialState);
|
||||
});
|
||||
|
||||
it("returns a new state array when tool call status changes", () => {
|
||||
const callId = "status-change-tool-call";
|
||||
const initialState = reduceStreamUpdate(
|
||||
[],
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:10:00Z"),
|
||||
);
|
||||
|
||||
const nextState = reduceStreamUpdate(
|
||||
initialState,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:10:01Z"),
|
||||
);
|
||||
|
||||
assert.notStrictEqual(nextState, initialState);
|
||||
});
|
||||
|
||||
it("returns a new state array when tool call detail changes", () => {
|
||||
const callId = "detail-change-tool-call";
|
||||
const initialState = reduceStreamUpdate(
|
||||
[],
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:20:00Z"),
|
||||
);
|
||||
|
||||
const nextState = reduceStreamUpdate(
|
||||
initialState,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run typecheck",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:20:01Z"),
|
||||
);
|
||||
|
||||
assert.notStrictEqual(nextState, initialState);
|
||||
});
|
||||
|
||||
it("returns a new state array when tool call error changes", () => {
|
||||
const callId = "error-change-tool-call";
|
||||
const initialState = reduceStreamUpdate(
|
||||
[],
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
error: { message: "first failure" },
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:30:00Z"),
|
||||
);
|
||||
|
||||
const nextState = reduceStreamUpdate(
|
||||
initialState,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId,
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
error: { message: "second failure" },
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
}),
|
||||
new Date("2025-01-01T12:30:01Z"),
|
||||
);
|
||||
|
||||
assert.notStrictEqual(nextState, initialState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream reducer canonical tool calls", () => {
|
||||
it("is deterministic for equivalent hydration sequences", () => {
|
||||
const updates = [
|
||||
|
||||
@@ -336,7 +336,36 @@ function mergeUnknownValue(existing: unknown | null, incoming: unknown | null):
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail): ToolCallDetail {
|
||||
function hasSameIncomingFields<T extends Record<string, unknown>>(
|
||||
existing: T,
|
||||
incoming: T,
|
||||
): boolean {
|
||||
return Object.entries(incoming).every(([key, value]) => existing[key] === value);
|
||||
}
|
||||
|
||||
function mergeToolCallMetadata(
|
||||
existing: Record<string, unknown> | undefined,
|
||||
incoming: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!incoming) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (hasSameIncomingFields(existing, incoming)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return { ...existing, ...incoming };
|
||||
}
|
||||
|
||||
export function mergeToolCallDetail(
|
||||
existing: ToolCallDetail,
|
||||
incoming: ToolCallDetail,
|
||||
): ToolCallDetail {
|
||||
if (existing.type === "unknown" && incoming.type !== "unknown") {
|
||||
return incoming;
|
||||
}
|
||||
@@ -346,14 +375,24 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
|
||||
}
|
||||
|
||||
if (existing.type === "unknown" && incoming.type === "unknown") {
|
||||
const input = mergeUnknownValue(existing.input, incoming.input);
|
||||
const output = mergeUnknownValue(existing.output, incoming.output);
|
||||
if (input === existing.input && output === existing.output) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: mergeUnknownValue(existing.input, incoming.input),
|
||||
output: mergeUnknownValue(existing.output, incoming.output),
|
||||
input,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing.type === incoming.type) {
|
||||
if (hasSameIncomingFields(existing, incoming)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return { ...existing, ...incoming } as ToolCallDetail;
|
||||
}
|
||||
|
||||
@@ -391,8 +430,7 @@ function appendAgentToolCall(
|
||||
const existingIndex = findExistingAgentToolCallIndex(state, data.callId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
const existing = next[existingIndex];
|
||||
const existing = state[existingIndex];
|
||||
if (!existing || !isAgentToolCallItem(existing)) {
|
||||
return state;
|
||||
}
|
||||
@@ -401,12 +439,22 @@ function appendAgentToolCall(
|
||||
mergedStatus === "failed"
|
||||
? (data.error ?? existing.payload.data.error ?? { message: "Tool call failed" })
|
||||
: null;
|
||||
const mergedMetadata =
|
||||
data.metadata || existing.payload.data.metadata
|
||||
? { ...existing.payload.data.metadata, ...data.metadata }
|
||||
: undefined;
|
||||
const mergedMetadata = mergeToolCallMetadata(existing.payload.data.metadata, data.metadata);
|
||||
const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail);
|
||||
|
||||
if (
|
||||
data.provider === existing.payload.data.provider &&
|
||||
data.callId === existing.payload.data.callId &&
|
||||
data.name === existing.payload.data.name &&
|
||||
mergedStatus === existing.payload.data.status &&
|
||||
mergedError === existing.payload.data.error &&
|
||||
mergedDetail === existing.payload.data.detail &&
|
||||
mergedMetadata === existing.payload.data.metadata
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const next = [...state];
|
||||
next[existingIndex] = {
|
||||
...existing,
|
||||
timestamp,
|
||||
|
||||
@@ -57,6 +57,28 @@ const TOOL_CALL: AgentTimelineItem = {
|
||||
},
|
||||
};
|
||||
|
||||
function toolCall(options?: {
|
||||
callId?: string;
|
||||
status?: "running" | "completed" | "failed" | "canceled";
|
||||
output?: string;
|
||||
error?: unknown;
|
||||
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
const status = options?.status ?? "running";
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: options?.callId ?? "tool-1",
|
||||
name: "shell",
|
||||
status,
|
||||
error: status === "failed" ? (options?.error ?? "failed") : null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: options?.output ?? "",
|
||||
exitCode: status === "completed" ? 0 : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class TestAgentSession implements AgentSession {
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id: string;
|
||||
@@ -347,7 +369,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("target coalesced behavior", () => {
|
||||
test("coalesces a same-tick assistant burst after the 33ms window", async () => {
|
||||
test("coalesces a same-tick assistant burst after the 200ms window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
@@ -358,7 +380,7 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(32);
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(0);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0);
|
||||
|
||||
@@ -386,7 +408,7 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("coalesces same-tick reasoning chunks after the 33ms window", async () => {
|
||||
test("coalesces same-tick reasoning chunks after the 200ms window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
@@ -397,7 +419,7 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
@@ -410,21 +432,26 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves order when an atomic timeline event lands between two large bursts", async () => {
|
||||
test("coalesces running tool calls without flushing buffered text", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const runningToolCall = toolCall({ output: "running" });
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
session.pushEvent(assistant("a"));
|
||||
}
|
||||
session.pushEvent(timelineEvent(TOOL_CALL));
|
||||
session.pushEvent(timelineEvent(runningToolCall));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(0);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
{ type: "assistant_message", text: "a".repeat(500) },
|
||||
TOOL_CALL,
|
||||
runningToolCall,
|
||||
]);
|
||||
expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2]);
|
||||
|
||||
@@ -432,7 +459,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(assistant("b"));
|
||||
}
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(32);
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
@@ -441,7 +468,7 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a".repeat(500) },
|
||||
TOOL_CALL,
|
||||
runningToolCall,
|
||||
{ type: "assistant_message", text: "b".repeat(500) },
|
||||
]);
|
||||
expectContiguousRowSeqs(rows, [1, 2, 3]);
|
||||
@@ -451,6 +478,126 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("coalesces same-window tool call updates to the latest snapshot", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
session.pushEvent(timelineEvent(toolCall({ output: `chunk-${i}` })));
|
||||
}
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(0);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]);
|
||||
expect(events).toHaveLength(1);
|
||||
expectContiguousRowSeqs(rows, [1]);
|
||||
expectContiguousLiveSeqs(events, [1]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("coalesces interleaved tool calls independently and preserves first arrival order", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ callId: "tool-1", output: "one-a" })));
|
||||
session.pushEvent(timelineEvent(toolCall({ callId: "tool-2", output: "two-a" })));
|
||||
session.pushEvent(timelineEvent(toolCall({ callId: "tool-1", output: "one-b" })));
|
||||
session.pushEvent(timelineEvent(toolCall({ callId: "tool-2", output: "two-b" })));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
toolCall({ callId: "tool-1", output: "one-b" }),
|
||||
toolCall({ callId: "tool-2", output: "two-b" }),
|
||||
]);
|
||||
expect(events).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rows, [1, 2]);
|
||||
expectContiguousLiveSeqs(events, [1, 2]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tool call statuses flush immediately with pending text", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
|
||||
session.pushEvent(assistant("before"));
|
||||
session.pushEvent(timelineEvent(toolCall({ output: "running" })));
|
||||
await waitForSessionEventQueue();
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(0);
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output: "done" })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "before" },
|
||||
toolCall({ status: "completed", output: "done" }),
|
||||
]);
|
||||
expect(events).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rows, [1, 2]);
|
||||
expectContiguousLiveSeqs(events, [1, 2]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves mixed text and tool call order after coalescing", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
|
||||
session.pushEvent(assistant("a"));
|
||||
session.pushEvent(timelineEvent(toolCall({ output: "first" })));
|
||||
session.pushEvent(reasoning("r"));
|
||||
session.pushEvent(timelineEvent(toolCall({ output: "latest" })));
|
||||
session.pushEvent(assistant("b"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
toolCall({ output: "latest" }),
|
||||
{ type: "reasoning", text: "r" },
|
||||
{ type: "assistant_message", text: "b" },
|
||||
]);
|
||||
expect(events).toHaveLength(4);
|
||||
expectContiguousRowSeqs(rows, [1, 2, 3, 4]);
|
||||
expectContiguousLiveSeqs(events, [1, 2, 3, 4]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves assistant and reasoning interleave", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
@@ -462,7 +609,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(reasoning("r2"));
|
||||
session.pushEvent(assistant("a2"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
@@ -491,7 +638,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(reasoning(`r${i}`));
|
||||
}
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
@@ -542,7 +689,7 @@ describe("target coalesced behavior", () => {
|
||||
first.session.pushEvent(assistant("a"));
|
||||
second.session.pushEvent(assistant("b"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const firstRows = await harness.manager.getTimelineRows(first.agentId);
|
||||
const secondRows = await harness.manager.getTimelineRows(second.agentId);
|
||||
@@ -572,7 +719,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(assistant("codex", "codex"));
|
||||
session.pushEvent(assistant("claude", "claude"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
@@ -605,7 +752,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(assistant("one", "codex", "turn-1"));
|
||||
session.pushEvent(assistant("two", "codex", "turn-2"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
@@ -646,7 +793,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(assistant("\n\t"));
|
||||
session.pushEvent(assistant("done"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
@@ -704,7 +851,7 @@ describe("target coalesced behavior", () => {
|
||||
: undefined,
|
||||
).toBe(undefined);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(
|
||||
expectedTimelineEventCount,
|
||||
);
|
||||
@@ -804,7 +951,7 @@ describe("target coalesced behavior", () => {
|
||||
});
|
||||
second.session.pushEvent(assistant("new"));
|
||||
await waitForSessionEventQueue();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(getTimelineItems(await reuseHarness.manager.getTimelineRows(second.agentId))).toEqual([
|
||||
{ type: "assistant_message", text: "new" },
|
||||
@@ -878,7 +1025,7 @@ describe("target coalesced behavior", () => {
|
||||
session.pushEvent(assistant("A"));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
const rowsAfterFirstFlush = await reentryHarness.manager.getTimelineRows(agentId);
|
||||
@@ -887,7 +1034,7 @@ describe("target coalesced behavior", () => {
|
||||
]);
|
||||
expect(getTimelineStreamEvents(reentryHarness.events, agentId)).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId);
|
||||
|
||||
@@ -60,6 +60,33 @@ function reasoning(
|
||||
return timeline({ type: "reasoning", text }, options);
|
||||
}
|
||||
|
||||
function toolCall(options?: {
|
||||
callId?: string;
|
||||
status?: "running" | "completed" | "failed" | "canceled";
|
||||
output?: string;
|
||||
provider?: AgentProvider;
|
||||
turnId?: string;
|
||||
error?: unknown;
|
||||
}): Extract<AgentStreamEvent, { type: "timeline" }> {
|
||||
const status = options?.status ?? "running";
|
||||
return timeline(
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: options?.callId ?? "tool-1",
|
||||
name: "shell",
|
||||
status,
|
||||
error: status === "failed" ? (options?.error ?? "failed") : null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: options?.output ?? "",
|
||||
exitCode: status === "completed" ? 0 : null,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AgentStreamCoalescer", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -76,7 +103,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
expect(coalescer.handle("agent-1", assistant("lo"))).toBe(true);
|
||||
|
||||
expect(flushes).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(32);
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
@@ -92,7 +119,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
test("uses constructor windowMs instead of a hard-coded value", async () => {
|
||||
const { coalescer, flushes } = createHarness(10);
|
||||
|
||||
expect(AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS).toBe(33);
|
||||
expect(AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS).toBe(200);
|
||||
expect(coalescer.handle("agent-1", assistant("fast"))).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
@@ -108,7 +135,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("flushes before non-chunkable boundaries when caller invokes flushFor", () => {
|
||||
test("flushFor drains pending coalesced events", () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
|
||||
expect(coalescer.handle("agent-1", assistant("before"))).toBe(true);
|
||||
@@ -121,30 +148,12 @@ describe("AgentStreamCoalescer", () => {
|
||||
provider: "codex",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
coalescer.handle(
|
||||
"agent-1",
|
||||
timeline({
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(coalescer.handle("agent-1", toolCall())).toBe(true);
|
||||
});
|
||||
|
||||
test("does not consume non-chunkable events", () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
const nonChunkableEvents: AgentStreamEvent[] = [
|
||||
timeline({
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
}),
|
||||
timeline({ type: "todo", items: [{ text: "ship it", completed: false }] }),
|
||||
timeline({ type: "user_message", text: "hi", messageId: "message-1" }),
|
||||
timeline({ type: "error", message: "boom" }),
|
||||
@@ -194,7 +203,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", reasoning("r1"));
|
||||
coalescer.handle("agent-1", assistant("a2"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -222,7 +231,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("a2"));
|
||||
coalescer.handle("agent-1", reasoning("r2"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||
{ type: "assistant_message", text: "a1" },
|
||||
{ type: "reasoning", text: "r1" },
|
||||
@@ -237,7 +246,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("c", { provider: "codex" }));
|
||||
coalescer.handle("agent-1", assistant("o", { provider: "opencode" }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -258,7 +267,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("one", { turnId: "turn-1" }));
|
||||
coalescer.handle("agent-1", assistant("two", { turnId: "turn-2" }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -281,7 +290,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
expect(coalescer.handle("agent-1", assistant(""))).toBe(true);
|
||||
expect(coalescer.handle("agent-2", reasoning(""))).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -293,7 +302,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("\t"));
|
||||
coalescer.handle("agent-1", assistant(" mixed \n\t whitespace "));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -323,7 +332,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant(chunk));
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -341,7 +350,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("b"));
|
||||
coalescer.handle("agent-2", assistant("y"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -422,7 +431,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -443,7 +452,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("manual"));
|
||||
coalescer.flushFor("agent-1");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -459,7 +468,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", assistant("durable"));
|
||||
coalescer.flushAndDiscard("agent-1");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -523,7 +532,7 @@ describe("AgentStreamCoalescer", () => {
|
||||
coalescer.handle("agent-1", timeline(firstItem));
|
||||
coalescer.handle("agent-1", assistant("llo"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
@@ -536,4 +545,131 @@ describe("AgentStreamCoalescer", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("coalesces tool call updates by callId with latest snapshot winning", async () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
|
||||
expect(coalescer.handle("agent-1", toolCall({ output: "first" }))).toBe(true);
|
||||
expect(coalescer.handle("agent-1", toolCall({ output: "second" }))).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(flushes).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: "second",
|
||||
exitCode: null,
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("coalesces interleaved tool call updates independently by callId", async () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
|
||||
coalescer.handle("agent-1", toolCall({ callId: "tool-1", output: "one-a" }));
|
||||
coalescer.handle("agent-1", toolCall({ callId: "tool-2", output: "two-a" }));
|
||||
coalescer.handle("agent-1", toolCall({ callId: "tool-1", output: "one-b" }));
|
||||
coalescer.handle("agent-1", toolCall({ callId: "tool-2", output: "two-b" }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: "one-b",
|
||||
exitCode: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-2",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: "two-b",
|
||||
exitCode: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("terminal tool call statuses flush immediately", () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
|
||||
coalescer.handle("agent-1", toolCall({ output: "running" }));
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
expect(coalescer.handle("agent-1", toolCall({ status: "completed", output: "done" }))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: "done",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves mixed text and tool call arrival order within a flush", async () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
|
||||
coalescer.handle("agent-1", assistant("a"));
|
||||
coalescer.handle("agent-1", toolCall({ output: "running" }));
|
||||
coalescer.handle("agent-1", reasoning("r"));
|
||||
coalescer.handle("agent-1", toolCall({ output: "latest" }));
|
||||
coalescer.handle("agent-1", assistant("b"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "printf ok",
|
||||
output: "latest",
|
||||
exitCode: null,
|
||||
},
|
||||
},
|
||||
{ type: "reasoning", text: "r" },
|
||||
{ type: "assistant_message", text: "b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 33;
|
||||
export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 200;
|
||||
|
||||
type ChunkableTimelineKind = "assistant_message" | "reasoning";
|
||||
type ChunkableTimelineItem = Extract<AgentTimelineItem, { type: ChunkableTimelineKind }>;
|
||||
type ChunkableTimelineEvent = Extract<AgentStreamEvent, { type: "timeline" }> & {
|
||||
item: ChunkableTimelineItem;
|
||||
type CoalescableTextKind = "assistant_message" | "reasoning";
|
||||
type CoalescableTimelineKind = CoalescableTextKind | "tool_call";
|
||||
type CoalescableTextItem = Extract<AgentTimelineItem, { type: CoalescableTextKind }>;
|
||||
type CoalescableTimelineItem = Extract<AgentTimelineItem, { type: CoalescableTimelineKind }>;
|
||||
type CoalescableTimelineEvent = Extract<AgentStreamEvent, { type: "timeline" }> & {
|
||||
item: CoalescableTimelineItem;
|
||||
};
|
||||
|
||||
export type AgentStreamCoalescerTimers = {
|
||||
@@ -15,7 +17,7 @@ export type AgentStreamCoalescerTimers = {
|
||||
|
||||
export type AgentStreamCoalescerFlush = {
|
||||
agentId: string;
|
||||
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>;
|
||||
item: CoalescableTimelineItem;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
@@ -26,25 +28,48 @@ export type AgentStreamCoalescerOptions = {
|
||||
onFlush: (payload: AgentStreamCoalescerFlush) => void;
|
||||
};
|
||||
|
||||
type PendingAgentStreamChunk = {
|
||||
item: ChunkableTimelineItem;
|
||||
type PendingTextEntry = {
|
||||
kind: "text";
|
||||
item: CoalescableTextItem;
|
||||
text: string;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
|
||||
type PendingToolCallEntry = {
|
||||
kind: "tool_call";
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
|
||||
type PendingAgentStreamEntry = PendingTextEntry | PendingToolCallEntry;
|
||||
|
||||
type PendingAgentStreamBuffer = {
|
||||
agentId: string;
|
||||
chunks: PendingAgentStreamChunk[];
|
||||
entries: PendingAgentStreamEntry[];
|
||||
toolCallEntryIndexes: Map<string, number>;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
flushing: boolean;
|
||||
};
|
||||
|
||||
function isChunkableTimelineEvent(event: AgentStreamEvent): event is ChunkableTimelineEvent {
|
||||
function isCoalescableTimelineEvent(event: AgentStreamEvent): event is CoalescableTimelineEvent {
|
||||
return (
|
||||
event.type === "timeline" &&
|
||||
(event.item.type === "assistant_message" || event.item.type === "reasoning") &&
|
||||
typeof event.item.text === "string"
|
||||
(event.item.type === "assistant_message" ||
|
||||
event.item.type === "reasoning" ||
|
||||
event.item.type === "tool_call")
|
||||
);
|
||||
}
|
||||
|
||||
function isTextTimelineItem(item: CoalescableTimelineItem): item is CoalescableTextItem {
|
||||
return item.type === "assistant_message" || item.type === "reasoning";
|
||||
}
|
||||
|
||||
function isTerminalToolCall(item: CoalescableTimelineItem): boolean {
|
||||
return (
|
||||
item.type === "tool_call" &&
|
||||
(item.status === "completed" || item.status === "failed" || item.status === "canceled")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,21 +86,21 @@ export class AgentStreamCoalescer {
|
||||
}
|
||||
|
||||
handle(agentId: string, event: AgentStreamEvent): boolean {
|
||||
if (!isChunkableTimelineEvent(event)) {
|
||||
if (!isCoalescableTimelineEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.item.text === "") {
|
||||
if (isTextTimelineItem(event.item) && event.item.text === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const buffer = this.getOrCreateBuffer(agentId);
|
||||
buffer.chunks.push({
|
||||
item: event.item,
|
||||
text: event.item.text,
|
||||
provider: event.provider,
|
||||
...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
|
||||
});
|
||||
this.appendToBuffer(buffer, event);
|
||||
|
||||
if (isTerminalToolCall(event.item)) {
|
||||
this.flushBuffer(agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!buffer.timer) {
|
||||
this.scheduleFlush(buffer);
|
||||
@@ -111,7 +136,8 @@ export class AgentStreamCoalescer {
|
||||
|
||||
const buffer: PendingAgentStreamBuffer = {
|
||||
agentId,
|
||||
chunks: [],
|
||||
entries: [],
|
||||
toolCallEntryIndexes: new Map(),
|
||||
timer: null,
|
||||
flushing: false,
|
||||
};
|
||||
@@ -119,6 +145,35 @@ export class AgentStreamCoalescer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private appendToBuffer(buffer: PendingAgentStreamBuffer, event: CoalescableTimelineEvent): void {
|
||||
if (isTextTimelineItem(event.item)) {
|
||||
buffer.entries.push({
|
||||
kind: "text",
|
||||
item: event.item,
|
||||
text: event.item.text,
|
||||
provider: event.provider,
|
||||
...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId);
|
||||
const entry: PendingToolCallEntry = {
|
||||
kind: "tool_call",
|
||||
item: event.item,
|
||||
provider: event.provider,
|
||||
...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
|
||||
};
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
buffer.entries[existingIndex] = entry;
|
||||
return;
|
||||
}
|
||||
|
||||
buffer.toolCallEntryIndexes.set(event.item.callId, buffer.entries.length);
|
||||
buffer.entries.push(entry);
|
||||
}
|
||||
|
||||
private scheduleFlush(buffer: PendingAgentStreamBuffer): void {
|
||||
const timer = this.timers.setTimeout(() => {
|
||||
this.flushBuffer(buffer.agentId, buffer);
|
||||
@@ -148,24 +203,28 @@ export class AgentStreamCoalescer {
|
||||
}
|
||||
|
||||
this.clearTimer(buffer);
|
||||
if (buffer.chunks.length === 0) {
|
||||
if (buffer.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = buffer.chunks;
|
||||
buffer.chunks = [];
|
||||
const entries = buffer.entries;
|
||||
buffer.entries = [];
|
||||
buffer.toolCallEntryIndexes.clear();
|
||||
buffer.flushing = true;
|
||||
|
||||
try {
|
||||
for (const chunk of this.collapseChunks(chunks)) {
|
||||
for (const entry of this.collapseEntries(entries)) {
|
||||
this.onFlush({
|
||||
agentId,
|
||||
item: {
|
||||
...chunk.item,
|
||||
text: chunk.text,
|
||||
},
|
||||
provider: chunk.provider,
|
||||
...(chunk.turnId !== undefined ? { turnId: chunk.turnId } : {}),
|
||||
item:
|
||||
entry.kind === "text"
|
||||
? {
|
||||
...entry.item,
|
||||
text: entry.text,
|
||||
}
|
||||
: entry.item,
|
||||
provider: entry.provider,
|
||||
...(entry.turnId !== undefined ? { turnId: entry.turnId } : {}),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -173,22 +232,24 @@ export class AgentStreamCoalescer {
|
||||
}
|
||||
}
|
||||
|
||||
private collapseChunks(chunks: PendingAgentStreamChunk[]): PendingAgentStreamChunk[] {
|
||||
const collapsed: PendingAgentStreamChunk[] = [];
|
||||
private collapseEntries(entries: PendingAgentStreamEntry[]): PendingAgentStreamEntry[] {
|
||||
const collapsed: PendingAgentStreamEntry[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
for (const entry of entries) {
|
||||
const previous = collapsed.at(-1);
|
||||
if (
|
||||
previous &&
|
||||
previous.item.type === chunk.item.type &&
|
||||
previous.provider === chunk.provider &&
|
||||
previous.turnId === chunk.turnId
|
||||
previous.kind === "text" &&
|
||||
entry.kind === "text" &&
|
||||
previous.item.type === entry.item.type &&
|
||||
previous.provider === entry.provider &&
|
||||
previous.turnId === entry.turnId
|
||||
) {
|
||||
previous.text += chunk.text;
|
||||
previous.text += entry.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
collapsed.push({ ...chunk });
|
||||
collapsed.push({ ...entry });
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
|
||||
Reference in New Issue
Block a user