fix(agent): strip toolCalls from aborted/error assistant messages in transcript repair

When a streaming request is aborted mid-toolCall, the session persists an
assistant message with stopReason "aborted" containing partial toolCall blocks.
Our sanitizeToolUseResultPairing then inserts synthetic toolResults for these
toolCalls. However, pi-ai's transformMessages drops the entire aborted assistant
message downstream, leaving orphaned toolResults that reference non-existent
tool_use_ids — causing persistent 400 errors that block all subsequent
conversations in the session.

Fix: in repairToolCallInputs, strip toolCall blocks from assistant messages
with stopReason "aborted" or "error" before the result-pairing sanitizer runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-26 17:33:58 +08:00
parent 5d39958913
commit 033ff87861
2 changed files with 99 additions and 0 deletions

View file

@ -257,4 +257,84 @@ describe("sanitizeToolCallInputs", () => {
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user"]);
});
it("strips toolCalls from aborted assistant but keeps text", () => {
const input = [
{
role: "assistant",
stopReason: "aborted",
content: [
{ type: "text", text: "Let me try" },
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "/tmp/x" } },
],
},
{ role: "user", content: "hello" },
] as AgentMessage[];
const out = sanitizeToolCallInputs(input);
expect(out).toHaveLength(2);
expect(out[0]?.role).toBe("assistant");
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const types = Array.isArray(assistant.content)
? assistant.content.map((b) => (b as { type?: unknown }).type)
: [];
expect(types).toEqual(["text"]);
expect(out[1]?.role).toBe("user");
});
it("drops aborted assistant entirely when only toolCalls remain", () => {
const input = [
{
role: "assistant",
stopReason: "aborted",
content: [
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "/tmp/x" } },
],
},
{ role: "user", content: "hello" },
] as AgentMessage[];
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user"]);
});
it("strips toolCalls from error assistant messages", () => {
const input = [
{
role: "assistant",
stopReason: "error",
content: [
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "a" } },
],
},
{ role: "user", content: "retry" },
] as AgentMessage[];
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user"]);
});
it("prevents orphan toolResults when aborted assistant is followed by user message", () => {
// Full scenario: aborted assistant with toolCall → sanitizeToolCallInputs strips toolCall
// → sanitizeToolUseResultPairing should NOT insert synthetic toolResult
const input = [
{
role: "assistant",
stopReason: "aborted",
content: [
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "/tmp/x" } },
],
},
{ role: "user", content: "continue" },
{ role: "assistant", content: [] },
] as AgentMessage[];
// Run both sanitizers in sequence (same as transformContext)
const step1 = sanitizeToolCallInputs(input);
const step2 = sanitizeToolUseResultPairing(step1);
// No orphan toolResults should exist
const toolResults = step2.filter((m) => m.role === "toolResult");
expect(toolResults).toHaveLength(0);
});
});

View file

@ -118,6 +118,25 @@ export function repairToolCallInputs(messages: AgentMessage[]): ToolCallInputRep
continue;
}
// Drop toolCalls from aborted/error assistant messages.
// pi-ai's transformMessages drops these messages entirely, so any
// synthetic toolResults we'd insert would become orphaned.
const stopReason = (msg as { stopReason?: unknown }).stopReason;
if (stopReason === "aborted" || stopReason === "error") {
const filtered = msg.content.filter((block: unknown) => !isToolCallBlock(block));
if (filtered.length === 0) {
droppedAssistantMessages += 1;
changed = true;
continue;
}
if (filtered.length !== msg.content.length) {
droppedToolCalls += msg.content.length - filtered.length;
changed = true;
out.push({ ...msg, content: filtered });
continue;
}
}
const nextContent = [];
let droppedInMessage = 0;