Merge 8c25ba3a0f8c7300de6b3d3d419f751946db7deb into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
4c65b7e57d
@ -144,10 +144,11 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]);
|
||||
});
|
||||
|
||||
it("skips tool call extraction for assistant messages with stopReason 'error'", () => {
|
||||
// When an assistant message has stopReason: "error", its tool_use blocks may be
|
||||
// incomplete/malformed. We should NOT create synthetic tool_results for them,
|
||||
// as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
|
||||
it("strips tool_use blocks from assistant messages with stopReason 'error'", () => {
|
||||
// When an assistant message has stopReason: "error", its tool_use blocks are
|
||||
// orphaned (no tool_result will ever come). Instead of passing them through
|
||||
// (which causes permanent session corruption — see #48354), strip the tool
|
||||
// call blocks entirely. Do NOT create synthetic tool_results either (#4597).
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
@ -159,17 +160,19 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
|
||||
const result = repairToolUseResultPairing(input);
|
||||
|
||||
// Should NOT add synthetic tool results for errored messages
|
||||
expect(result.added).toHaveLength(0);
|
||||
// The assistant message should be passed through unchanged
|
||||
// Should strip the tool call block, not create synthetic results
|
||||
expect(result.messages[0]?.role).toBe("assistant");
|
||||
const assistantContent = (result.messages[0] as { content: { type: string }[] }).content;
|
||||
// Tool call block should be gone, replaced with placeholder text
|
||||
expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true);
|
||||
expect(result.messages[1]?.role).toBe("user");
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.moved).toBe(true);
|
||||
});
|
||||
|
||||
it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => {
|
||||
// When a request is aborted mid-stream, the assistant message may have incomplete
|
||||
// tool_use blocks (with partialJson). We should NOT create synthetic tool_results.
|
||||
it("strips tool_use blocks from assistant messages with stopReason 'aborted'", () => {
|
||||
// When a request is aborted mid-stream, strip orphaned tool_use blocks
|
||||
// to prevent permanent session corruption from dangling tool calls.
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
@ -181,12 +184,37 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
|
||||
const result = repairToolUseResultPairing(input);
|
||||
|
||||
// Should NOT add synthetic tool results for aborted messages
|
||||
expect(result.added).toHaveLength(0);
|
||||
// Messages should be passed through without synthetic insertions
|
||||
// Tool call blocks should be stripped
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0]?.role).toBe("assistant");
|
||||
const assistantContent = (result.messages[0] as { content: { type: string }[] }).content;
|
||||
expect(assistantContent.every((b: { type: string }) => b.type !== "toolCall")).toBe(true);
|
||||
expect(result.messages[1]?.role).toBe("user");
|
||||
expect(result.moved).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content alongside stripped tool_use in error messages", () => {
|
||||
// If the errored assistant message has both text and tool_use blocks,
|
||||
// only strip the tool calls and keep the text.
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me try running that..." },
|
||||
{ type: "toolCall", id: "call_err2", name: "exec", arguments: {} },
|
||||
],
|
||||
stopReason: "error",
|
||||
},
|
||||
{ role: "user", content: "what happened?" },
|
||||
]);
|
||||
|
||||
const result = repairToolUseResultPairing(input);
|
||||
|
||||
const assistantContent = (result.messages[0] as { content: { type: string; text?: string }[] })
|
||||
.content;
|
||||
expect(assistantContent).toHaveLength(1);
|
||||
expect(assistantContent[0]?.type).toBe("text");
|
||||
expect(assistantContent[0]?.text).toBe("Let me try running that...");
|
||||
});
|
||||
|
||||
it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => {
|
||||
|
||||
@ -348,7 +348,7 @@ export type ToolUseRepairReport = {
|
||||
|
||||
export function repairToolUseResultPairing(
|
||||
messages: AgentMessage[],
|
||||
options?: ToolUseResultPairingOptions,
|
||||
_options?: ToolUseResultPairingOptions,
|
||||
): ToolUseRepairReport {
|
||||
// Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
|
||||
// immediately followed by matching tool results. Session files can end up with results
|
||||
@ -400,6 +400,36 @@ export function repairToolUseResultPairing(
|
||||
|
||||
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
|
||||
// For aborted or errored assistant messages, strip any tool_use/toolCall blocks
|
||||
// rather than skipping the message entirely. These messages are the most likely
|
||||
// to contain orphaned tool_use blocks (tool call started but result never generated).
|
||||
// Previously we skipped them to avoid creating synthetic tool_results for incomplete
|
||||
// tool calls (see #4597), but skipping preserves the orphaned blocks, which then
|
||||
// cause permanent session corruption — Anthropic rejects every subsequent request
|
||||
// with "tool_use ids were found without tool_result blocks" (see #48354).
|
||||
// The safe fix: strip the tool call blocks entirely instead of trying to pair them.
|
||||
const stopReason = (assistant as { stopReason?: string }).stopReason;
|
||||
if (stopReason === "error" || stopReason === "aborted") {
|
||||
const errorToolCalls = extractToolCallsFromAssistant(assistant);
|
||||
if (errorToolCalls.length > 0) {
|
||||
const content = Array.isArray(assistant.content) ? assistant.content : [];
|
||||
const stripped = content.filter(
|
||||
(b: { type?: string }) => b && b.type !== "toolCall" && b.type !== "toolUse",
|
||||
);
|
||||
changed = true;
|
||||
out.push({
|
||||
...assistant,
|
||||
content:
|
||||
stripped.length > 0
|
||||
? stripped
|
||||
: [{ type: "text" as const, text: "[tool calls from errored turn stripped]" }],
|
||||
} as typeof assistant);
|
||||
} else {
|
||||
out.push(msg);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolCalls = extractToolCallsFromAssistant(assistant);
|
||||
if (toolCalls.length === 0) {
|
||||
out.push(msg);
|
||||
@ -457,27 +487,9 @@ export function repairToolUseResultPairing(
|
||||
}
|
||||
}
|
||||
|
||||
// Aborted/errored assistant turns should never synthesize missing tool results, but
|
||||
// the replay sanitizer can still legitimately retain real tool results for surviving
|
||||
// tool calls in the same turn after malformed siblings are dropped.
|
||||
const stopReason = (assistant as { stopReason?: string }).stopReason;
|
||||
if (stopReason === "error" || stopReason === "aborted") {
|
||||
out.push(msg);
|
||||
if (options?.preserveErroredAssistantResults) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const result = spanResultsById.get(toolCall.id);
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
pushToolResult(result);
|
||||
}
|
||||
}
|
||||
for (const rem of remainder) {
|
||||
out.push(rem);
|
||||
}
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
// Note: aborted/errored assistant turns are already handled above (tool_use blocks
|
||||
// are stripped and the message is emitted with `continue`). This point is only
|
||||
// reached for normal assistant messages, so no stopReason guard is needed here.
|
||||
|
||||
out.push(msg);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user