fix(ai): preserve custom tool call ids

This commit is contained in:
shoucandanghehe
2026-05-09 10:00:44 +08:00
parent a84906b3b1
commit 9f175d539e
6 changed files with 103 additions and 11 deletions
+1
View File
@@ -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
@@ -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);
@@ -187,9 +187,9 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
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);
+21 -7
View File
@@ -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);
}
+47 -1
View File
@@ -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<string>();
const customCallIds = new Set<string>();
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 = {
@@ -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");
});
});