fix(ai): preserve custom tool call ids
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user