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,
|
||||
|
||||
Reference in New Issue
Block a user