diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 59a7fae0..132ef082 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_` IDs instead of preserving backend-specific normalized shapes that OpenAI Codex rejects. + ## [0.61.1] - 2026-03-20 ### Changed diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index e74e6d13..63858536 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -97,14 +97,20 @@ export function convertResponsesMessages( return normalized.replace(/_+$/, ""); }; - const normalizeToolCallId = (id: string): string => { + const buildForeignResponsesItemId = (itemId: string): string => { + const normalized = `fc_${shortHash(itemId)}`; + return normalized.length > 64 ? normalized.slice(0, 64) : normalized; + }; + + const normalizeToolCallId = (id: string, _targetModel: Model, source: AssistantMessage): string => { if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id); if (!id.includes("|")) return normalizeIdPart(id); const [callId, itemId] = id.split("|"); const normalizedCallId = normalizeIdPart(callId); - let normalizedItemId = normalizeIdPart(itemId); + const isForeignToolCall = source.provider !== model.provider || source.api !== model.api; + let normalizedItemId = isForeignToolCall ? buildForeignResponsesItemId(itemId) : normalizeIdPart(itemId); // OpenAI Responses API requires item id to start with "fc" - if (!normalizedItemId.startsWith("fc")) { + if (!normalizedItemId.startsWith("fc_")) { normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`); } return `${normalizedCallId}|${normalizedItemId}`; diff --git a/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts b/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts new file mode 100644 index 00000000..c50fcc88 --- /dev/null +++ b/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { convertResponsesMessages } from "../src/providers/openai-responses-shared.js"; +import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.js"; +import { shortHash } from "../src/utils/hash.js"; + +const COPILOT_RAW_TOOL_CALL_ID = + "call_4VnzVawQXPB9MgYib7CiQFEY|I9b95oN1wD/cHXKTw3PpRkL6KkCtzTJhUxMouMWYwHeTo2j3htzfSk7YPx2vifiIM4g3A8XXyOj8q4Bt6SLUG7gqY1E3ELkrkVQNHglRfUmWj84lqxJY+Puieb3VKyX0FB+83TUzn91cDMF/4gzt990IzqVrc+nIb9RRscRD070Du16q1glydVjWR0SBJsE6TbY/esOjFpqplogQqrajm1eI++f3eLi73R6q7hVusY0QbeFySVxABCjhN0lXB04caBe1rzHjYzul6MAXj7uq+0r17VLq+yrtyYhN12wkmFqHeqTyEei6EFPbMy24Nc+IbJlkP0OCg02W+gOnyBFcbi2ctvJFSOhSjt1CqBdqCnnhwUqXjbWiT0wh3DmLScRgTHmGkaI+oAcQQjfic65nxj+TnEkReA=="; + +const usage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +describe("OpenAI Responses foreign tool call ID normalization", () => { + it("hashes foreign Copilot tool item IDs into a bounded Codex-safe fc_ shape", () => { + const model = getModel("openai-codex", "gpt-5.3-codex"); + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { + type: "toolCall", + id: COPILOT_RAW_TOOL_CALL_ID, + name: "edit", + arguments: { path: "src/styles/app.css" }, + }, + ], + api: "openai-responses", + provider: "github-copilot", + model: "gpt-5.3-codex", + usage, + stopReason: "toolUse", + timestamp: Date.now() - 2000, + }; + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: COPILOT_RAW_TOOL_CALL_ID, + toolName: "edit", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: Date.now() - 1000, + }; + const context: Context = { + systemPrompt: "You are concise.", + messages: [{ role: "user", content: "Use the tool.", timestamp: Date.now() - 3000 }, assistant, toolResult], + }; + + const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); + const functionCall = input.find((item) => item.type === "function_call"); + + expect(functionCall).toBeDefined(); + expect(functionCall?.type).toBe("function_call"); + if (!functionCall || functionCall.type !== "function_call") { + throw new Error("Expected function_call item"); + } + + const expectedItemId = `fc_${shortHash(COPILOT_RAW_TOOL_CALL_ID.split("|")[1]!)}`; + expect(functionCall.id).toBe(expectedItemId); + expect(functionCall.id?.length ?? 0).toBeLessThanOrEqual(64); + expect(functionCall.id).toMatch(/^fc_[A-Za-z0-9]+$/); + }); +});