From 3f4870140e611067315bcfd62fd621ca375cb3a2 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Tue, 22 Sep 2026 20:06:33 +0100 Subject: [PATCH] fix(providers): synthetic tool-result text must say outcome is unknown Invariant: recovery closes an interrupted tool call with a structured error that says the side effect may or may not have happened; it never re-executes. transform-messages.ts synthesized "No result provided" (isError true) for orphaned tool calls left after a crash/kill mid-tool or an abort mid-batch (agent-loop.ts sequential/parallel executors break on signal.aborted). That wording reads as "nothing happened" and gave the model no reason to verify state before blindly retrying non-idempotent commands like bash. Replaced the literal string with synthesizeInterruptedToolResultText(), which names the interrupted tool, states the call was interrupted (not failed), says its side effects may or may not have happened, and tells the model to verify current state before retrying. isError and message shape are unchanged. agent-loop.ts's abort break semantics are untouched -- only the synthesized wording changed. --- .../providers/src/api/transform-messages.ts | 13 ++++- ...ssages-copilot-openai-to-anthropic.test.ts | 53 +++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/providers/src/api/transform-messages.ts b/packages/providers/src/api/transform-messages.ts index 45e90a09..19fd4721 100644 --- a/packages/providers/src/api/transform-messages.ts +++ b/packages/providers/src/api/transform-messages.ts @@ -12,6 +12,17 @@ import type { const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; +// Text synthesized for a tool call that was interrupted (session ended, aborted, or crashed) +// before a result was ever recorded. This must never claim the tool failed or succeeded -- +// the outcome is genuinely unknown, so the model must verify current state before retrying +// instead of blindly re-running potentially non-idempotent side effects. +export function synthesizeInterruptedToolResultText(toolName: string): string { + return ( + `Tool call "${toolName}" was interrupted before a result was recorded (session ended, aborted, or crashed). ` + + "Its side effects may or may not have happened -- verify the current state (e.g. re-read files, check processes) before retrying." + ); +} + function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] { const result: TextContent[] = []; let previousWasPlaceholder = false; @@ -168,7 +179,7 @@ export function transformMessages( role: "toolResult", toolCallId: tc.id, toolName: tc.name, - content: [{ type: "text", text: "No result provided" }], + content: [{ type: "text", text: synthesizeInterruptedToolResultText(tc.name) }], isError: true, timestamp: Date.now(), } as ToolResultMessage); diff --git a/packages/providers/test/transform-messages-copilot-openai-to-anthropic.test.ts b/packages/providers/test/transform-messages-copilot-openai-to-anthropic.test.ts index 24f218df..51311be2 100644 --- a/packages/providers/test/transform-messages-copilot-openai-to-anthropic.test.ts +++ b/packages/providers/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { transformMessages } from "../src/api/transform-messages.ts"; +import { synthesizeInterruptedToolResultText, transformMessages } from "../src/api/transform-messages.ts"; import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.ts"; // Normalize function matching what anthropic.ts uses @@ -155,7 +155,7 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { toolCallId: "call_123_fc_123", toolName: "read", isError: true, - content: [{ type: "text", text: "No result provided" }], + content: [{ type: "text", text: synthesizeInterruptedToolResultText("read") }], }); }); @@ -185,7 +185,54 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { role: "toolResult", toolCallId: "call_2_fc_2", toolName: "bash", - content: [{ type: "text", text: "No result provided" }], + content: [{ type: "text", text: synthesizeInterruptedToolResultText("bash") }], }); }); + + it("synthesized interrupted tool result names the tool, marks it an error, and warns the outcome is unknown (not a failure)", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + { role: "user", content: "run a command", timestamp: Date.now() }, + makeAssistantMessage([ + { type: "toolCall", id: "call_1|fc_1", name: "bash", arguments: { command: "rm -rf build" } }, + ]), + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + const synthetic = result[result.length - 1] as Message & { role: "toolResult" }; + + expect(synthetic.role).toBe("toolResult"); + expect((synthetic as { isError?: boolean }).isError).toBe(true); + const text = (synthetic as { content: { type: string; text: string }[] }).content[0].text; + // Names the interrupted tool. + expect(text).toContain('"bash"'); + // States the call was interrupted, not that it failed or succeeded. + expect(text).toMatch(/interrupted/i); + // Makes explicit that the side effect's outcome is unknown. + expect(text).toMatch(/may or may not have happened/i); + // Instructs verification before any retry, ruling out blind re-execution. + expect(text).toMatch(/verify/i); + }); + + it("does not duplicate a synthetic result when a real tool result is already present", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + { role: "user", content: "run a command", timestamp: Date.now() }, + makeAssistantMessage([{ type: "toolCall", id: "call_1|fc_1", name: "bash", arguments: { command: "pwd" } }]), + { + role: "toolResult", + toolCallId: "call_1|fc_1", + toolName: "bash", + content: [{ type: "text", text: "/repo" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + const toolResults = result.filter((message) => message.role === "toolResult"); + + expect(toolResults).toHaveLength(1); + expect(toolResults[0]).toMatchObject({ isError: false, content: [{ type: "text", text: "/repo" }] }); + }); });