Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/providers/src/api/transform-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -168,7 +179,7 @@ export function transformMessages<TApi extends Api>(
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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") }],
});
});

Expand Down Expand Up @@ -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" }] });
});
});
Loading