From a6489ab5e96b140e84d9ccd74eaa0e3c917cd9f2 Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Mon, 2 Mar 2026 11:08:20 -0700 Subject: [PATCH] fix(agents): cap openai-completions tool call ids to provider-safe format (#31947) Co-authored-by: bmendonca3 --- ...ssistant-text-blocks-but-preserves.test.ts | 6 ++--- src/agents/pi-embedded-helpers/images.ts | 8 +++---- ...ed-runner.sanitize-session-history.test.ts | 23 +++++++++++++++++++ src/agents/transcript-policy.test.ts | 10 ++++++++ src/agents/transcript-policy.ts | 7 ++++-- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 878b1199e..248a4cb1d 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -146,7 +146,7 @@ describe("sanitizeSessionMessagesImages", () => { expect(toolResult.toolUseId).toBe("callabcitem123"); }); - it("does not sanitize tool IDs in images-only mode", async () => { + it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => { const input = [ { role: "assistant", @@ -169,10 +169,10 @@ describe("sanitizeSessionMessagesImages", () => { const assistant = out[0] as unknown as { content?: Array<{ type?: string; id?: string }> }; const toolCall = assistant.content?.find((b) => b.type === "toolCall"); - expect(toolCall?.id).toBe("call_123|fc_456"); + expect(toolCall?.id).toBe("call123fc456"); const toolResult = out[1] as unknown as { toolCallId?: string }; - expect(toolResult.toolCallId).toBe("call_123|fc_456"); + expect(toolResult.toolCallId).toBe("call123fc456"); }); it("filters whitespace-only assistant text blocks", async () => { const input = [ diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/pi-embedded-helpers/images.ts index c3b4d0a37..ddf8aa76d 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/pi-embedded-helpers/images.ts @@ -54,12 +54,12 @@ export async function sanitizeSessionMessagesImages( maxDimensionPx: options?.maxDimensionPx, maxBytes: options?.maxBytes, }; + const shouldSanitizeToolCallIds = options?.sanitizeToolCallIds === true; // We sanitize historical session messages because Anthropic can reject a request // if the transcript contains oversized base64 images (default max side 1200px). - const sanitizedIds = - allowNonImageSanitization && options?.sanitizeToolCallIds - ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode) - : messages; + const sanitizedIds = shouldSanitizeToolCallIds + ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode) + : messages; const out: AgentMessage[] = []; for (const msg of sanitizedIds) { if (!msg || typeof msg !== "object") { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 6b65bc9d3..b56163632 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -191,6 +191,29 @@ describe("sanitizeSessionHistory", () => { ); }); + it("sanitizes tool call ids for openai-completions", async () => { + setNonGoogleModelApi(); + + await sanitizeSessionHistory({ + messages: mockMessages, + modelApi: "openai-completions", + provider: "openai", + modelId: "gpt-5.2", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + }); + + expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( + mockMessages, + "session:history", + expect.objectContaining({ + sanitizeMode: "images-only", + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + }), + ); + }); + it("annotates inter-session user messages before context sanitization", async () => { setNonGoogleModelApi(); diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 5f7d151ee..13686c2f6 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -44,6 +44,16 @@ describe("resolveTranscriptPolicy", () => { expect(policy.toolCallIdMode).toBeUndefined(); }); + it("enables strict tool call id sanitization for openai-completions APIs", () => { + const policy = resolveTranscriptPolicy({ + provider: "openai", + modelId: "gpt-5.2", + modelApi: "openai-completions", + }); + expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.toolCallIdMode).toBe("strict"); + }); + it("enables user-turn merge for strict OpenAI-compatible providers", () => { const policy = resolveTranscriptPolicy({ provider: "moonshot", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index baa12eda9..43238786e 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -94,6 +94,7 @@ export function resolveTranscriptPolicy(params: { (provider === "openrouter" || provider === "opencode" || provider === "kilocode") && modelId.toLowerCase().includes("gemini"); const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude"); + const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). @@ -102,7 +103,8 @@ export function resolveTranscriptPolicy(params: { const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; - const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic; + const sanitizeToolCallIds = + isGoogle || isMistral || isAnthropic || requiresOpenAiCompatibleToolIdSanitization; const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : sanitizeToolCallIds @@ -117,7 +119,8 @@ export function resolveTranscriptPolicy(params: { return { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", - sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, + sanitizeToolCallIds: + (!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization, toolCallIdMode, repairToolUseResultPairing, preserveSignatures: false,