diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 338700c04..a670b7e03 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Fixed Kimi Code OAuth expiry handling to refresh access tokens 5 minutes before server expiry, avoiding daily 401s from using tokens right up to the cutoff. +- Fixed OpenAI Responses custom tool replay to preserve custom tool call item IDs with the `ctc_` prefix instead of rewriting them as `fc_` function-call IDs ([#977](https://github.com/can1357/oh-my-pi/issues/977)). ## [14.7.6] - 2026-05-07 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f1d639289..8f14972bb 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -2438,7 +2438,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex } if (block.type === "toolCall") { const toolCall = block as ToolCall; - const normalized = normalizeResponsesToolCallId(toolCall.id); + const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc"); if (toolCall.customWireName) { const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : ""; customCallIds.add(normalized.callId); diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index e15b85a9a..21e944f90 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -187,9 +187,9 @@ export function convertResponsesAssistantMessage( continue; } - const normalized = normalizeResponsesToolCallId(block.id); + const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc"); let itemId: string | undefined = normalized.itemId; - if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_"))) { + if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))) { itemId = undefined; } knownCallIds.add(normalized.callId); diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index eedf4fe64..226511d91 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -36,16 +36,21 @@ export function normalizeToolCallId(id: string): string { return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; } -export function normalizeResponsesToolCallId(id: string): { callId: string; itemId: string } { +type ResponsesToolItemIdPrefix = "fc" | "ctc"; + +export function normalizeResponsesToolCallId( + id: string, + itemPrefix: ResponsesToolItemIdPrefix = "fc", +): { callId: string; itemId: string } { const [callId, itemId] = id.split("|"); if (callId && itemId) { const normalizedCallId = truncateResponseItemId(callId, getIdPrefix(callId, "call")); - const normalizedItemId = normalizeResponsesItemId(itemId); + const normalizedItemId = normalizeResponsesItemId(itemId, itemPrefix); return { callId: normalizedCallId, itemId: normalizedItemId }; } const hash = Bun.hash(id).toString(36); const normalizedCallId = id.startsWith("call_") ? truncateResponseItemId(id, "call") : `call_${hash}`; - return { callId: normalizedCallId, itemId: `fc_${hash}` }; + return { callId: normalizedCallId, itemId: `${itemPrefix}_${hash}` }; } function getIdPrefix(id: string, fallback: string): string { @@ -53,10 +58,19 @@ function getIdPrefix(id: string, fallback: string): string { return prefix || fallback; } -function normalizeResponsesItemId(itemId: string): string { - const prefix = getIdPrefix(itemId, "fc"); - if (prefix !== "fc" && prefix !== "fcr") { - return `fc_${Bun.hash(itemId).toString(36)}`; +function getExplicitIdPrefix(id: string): string | undefined { + return id.match(/^([a-zA-Z][a-zA-Z0-9]*)_/)?.[1]; +} + +function normalizeResponsesItemId(itemId: string, fallbackPrefix: ResponsesToolItemIdPrefix): string { + const prefix = getExplicitIdPrefix(itemId); + const isAllowedPrefix = prefix + ? fallbackPrefix === "ctc" + ? prefix === "ctc" + : prefix === "fc" || prefix === "fcr" + : false; + if (!prefix || !isAllowedPrefix) { + return `${fallbackPrefix}_${Bun.hash(itemId).toString(36)}`; } return truncateResponseItemId(itemId, prefix); } diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index 497b13e3b..ca37a473c 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -490,13 +490,59 @@ describe("history replay: custom_tool_call round-trip", () => { const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true, customCallIds); expect(items).toHaveLength(1); - const item = items[0] as { type: string; name?: string; input?: string }; + const item = items[0] as { type: string; id?: string; name?: string; input?: string }; expect(item.type).toBe("custom_tool_call"); + expect(item.id).toBe("ctc_1"); expect(item.name).toBe("apply_patch"); expect(item.input).toBe("*** Begin Patch\n*** End Patch\n"); expect(customCallIds.has("call_1")).toBe(true); }); + test("custom tool call omits item id when replayed across same-provider model switch", () => { + const assistantMsg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1|ctc_1", + name: "edit", + arguments: { input: "*** Begin Patch\n*** End Patch\n" }, + customWireName: "apply_patch", + }, + ], + timestamp: Date.now(), + provider: "openai", + model: "gpt-5", + api: "openai-responses", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; + const knownCallIds = new Set(); + const customCallIds = new Set(); + const items = convertResponsesAssistantMessage( + assistantMsg, + makeModel({ id: "gpt-5.1" }), + 0, + knownCallIds, + true, + customCallIds, + ); + + expect(items).toHaveLength(1); + const item = items[0] as { type: string; id?: string; call_id?: string }; + expect(item.type).toBe("custom_tool_call"); + expect(item.id).toBeUndefined(); + expect(item.call_id).toBe("call_1"); + expect(customCallIds.has("call_1")).toBe(true); + }); + test("paired tool result emits custom_tool_call_output when custom id is tracked", () => { const messages: unknown[] = []; const toolResult: ToolResultMessage = { diff --git a/packages/ai/test/utils-responses-id.test.ts b/packages/ai/test/utils-responses-id.test.ts index 226b51e62..2b2066780 100644 --- a/packages/ai/test/utils-responses-id.test.ts +++ b/packages/ai/test/utils-responses-id.test.ts @@ -36,4 +36,35 @@ describe("normalizeResponsesToolCallId", () => { expect(normalized.itemId.startsWith("fc_")).toBe(true); expect(normalized.itemId).not.toBe("item_legacy"); }); + + it("rehashes item ids without explicit prefixes to fc-prefixed ids by default", () => { + const normalized = normalizeResponsesToolCallId("call_abc|legacy"); + + expect(normalized.callId).toBe("call_abc"); + expect(normalized.itemId.startsWith("fc_")).toBe(true); + expect(normalized.itemId).not.toBe("legacy"); + }); + + it("preserves ctc-prefixed item ids for custom tool calls", () => { + const normalized = normalizeResponsesToolCallId("call_abc|ctc_12345", "ctc"); + + expect(normalized.callId).toBe("call_abc"); + expect(normalized.itemId).toBe("ctc_12345"); + }); + + it("rehashes non-ctc item ids to ctc-prefixed ids for custom tool calls", () => { + const normalized = normalizeResponsesToolCallId("call_abc|fc_legacy", "ctc"); + + expect(normalized.callId).toBe("call_abc"); + expect(normalized.itemId.startsWith("ctc_")).toBe(true); + expect(normalized.itemId).not.toBe("fc_legacy"); + }); + + it("rehashes custom item ids without explicit ctc prefixes to ctc-prefixed ids", () => { + const normalized = normalizeResponsesToolCallId("call_abc|legacy", "ctc"); + + expect(normalized.callId).toBe("call_abc"); + expect(normalized.itemId.startsWith("ctc_")).toBe(true); + expect(normalized.itemId).not.toBe("legacy"); + }); });