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:
parent
5d39958913
commit
033ff87861
2 changed files with 99 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue