diff --git a/.changeset/kimi-native-tool-call-id.md b/.changeset/kimi-native-tool-call-id.md new file mode 100644 index 0000000000..2940557eb8 --- /dev/null +++ b/.changeset/kimi-native-tool-call-id.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions getting stuck with self-hosted Kimi models behind an OpenAI-compatible endpoint, where a turn ended with reasoning and no tool call. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 5d4bd99e33..3ee90019e9 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -22,6 +22,9 @@ * tool-result `extract_text` fallback and tool-declaration-only skip are * handed over to the trait wholesale: every history message is * base-converted, post-processed by the hook, and dropped on `null`. + * + * Its tool-call-id policy preserves Kimi's canonical native id shape; see + * `KIMI_NATIVE_TOOL_CALL_ID` for the rationale and trade-off. */ import OpenAI from 'openai'; @@ -78,13 +81,16 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallIdPreservingNative, +} from '../tool-call-id'; const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; export const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), + normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64), maxLength: 64, }; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 0018ad2179..f93a44f34f 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -11,6 +11,9 @@ * classification (already-converted errors crossing an outer catch pass * through without re-consulting). The developer-role model detection lives * here. + * + * Its tool-call-id policy preserves Kimi's canonical native id shape; see + * `KIMI_NATIVE_TOOL_CALL_ID` for the rationale and trade-off. */ import OpenAI from 'openai'; @@ -60,7 +63,10 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeOpenAIResponsesCallId } from '../tool-call-id'; +import { + normalizeToolCallIdsForProvider, + sanitizeOpenAIResponsesCallIdPreservingNative, +} from '../tool-call-id'; function normalizeResponsesFinishReason( status: string | null | undefined, @@ -92,7 +98,7 @@ function normalizeResponsesFinishReason( type RawObject = Record; const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), + normalize: (id) => sanitizeOpenAIResponsesCallIdPreservingNative(id, 64), maxLength: 64, }; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts b/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts index ca16143da6..5c55003165 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts @@ -4,6 +4,19 @@ * The shared `ToolCallIdPolicy` implementation: id sanitization plus * history-wide id normalization that rewrites every `toolCalls[].id` / * `toolCallId` pair consistently and keeps rewritten ids unique. + * + * It also owns the Kimi native-id carve-out. Kimi-K2 is trained to see its + * canonical id shape `functions.:` echoed back in history; + * sanitizing it makes the model reply with reasoning and no tool call, so the + * OpenAI and Kimi policies preserve that shape verbatim while + * charset-restricted providers (Anthropic) keep sanitizing. The guard is keyed + * on the id shape, not the provider, because self-hosted Kimi is configured as + * a plain `openai` provider and does not self-identify here — the trade-off is + * that a Kimi-authored id replayed onto a stricter OpenAI-compatible backend + * is passed through rather than sanitized. Length handling follows each API: + * the chat `tool_call.id` is uncapped, so a canonical id survives at any + * length and the cap bounds only the sanitized fallback, while the Responses + * `call_id` caps at 64, so an over-long canonical id normalizes instead. */ import type { Message, ToolCall } from '#/kosong/contract/message'; @@ -17,11 +30,35 @@ export function sanitizeToolCallId(id: string, maxLength?: number): string { return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength); } +const KIMI_NATIVE_TOOL_CALL_ID = /^functions\.[A-Za-z0-9_-]+:\d+$/; + +export function sanitizeToolCallIdPreservingNative( + id: string, + fallbackMaxLength?: number, +): string { + return KIMI_NATIVE_TOOL_CALL_ID.test(id) ? id : sanitizeToolCallId(id, fallbackMaxLength); +} + +function isPreservableResponsesCallId(id: string, maxLength?: number): boolean { + return KIMI_NATIVE_TOOL_CALL_ID.test(id) && (maxLength === undefined || id.length <= maxLength); +} + export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string { const [callId] = id.split('|', 1); return sanitizeToolCallId(callId ?? id, maxLength); } +export function sanitizeOpenAIResponsesCallIdPreservingNative( + id: string, + maxLength?: number, +): string { + const [callId] = id.split('|', 1); + const base = callId ?? id; + return isPreservableResponsesCallId(base, maxLength) + ? base + : sanitizeOpenAIResponsesCallId(id, maxLength); +} + export function normalizeToolCallIdsForProvider( messages: Message[], policy: ToolCallIdPolicy, diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index c7ebdb9feb..1c5534897c 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -657,6 +657,7 @@ async function captureGoogleBody( async function captureResponsesBody( provider: ChatProvider, options?: GenerateOptions, + history: Message[] = PROBE_HISTORY, ): Promise> { let captured: Record | undefined; const client = sdkClient(provider) as { responses: { create: unknown } }; @@ -664,7 +665,7 @@ async function captureResponsesBody( captured = params as Record; return Promise.resolve(responsesEventStream()); }); - await drain(await provider.generate('', [], PROBE_HISTORY, options)); + await drain(await provider.generate('', [], history, options)); if (captured === undefined) throw new Error('expected responses.create to be called'); return captured; } @@ -695,6 +696,114 @@ describe('per-turn intent wire encoding (behavior probes)', () => { expect(body).not.toHaveProperty('reasoning_effort'); }); + it('preserves a canonical Kimi-native tool call id end-to-end through the composed Kimi provider', async () => { + const provider = registry.createChatProvider({ + protocol: 'openai', + providerType: 'kimi', + modelName: 'kimi-k2', + apiKey: 'sk-probe', + }); + + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'functions.Bash:0', name: 'Bash', arguments: '{"command":"pwd"}' }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'functions.Bash:0', + toolCalls: [], + }, + ]; + + const body = await captureOpenAIBody(provider, undefined, history); + const messages = body['messages'] as Array>; + const assistant = messages.find((m) => m['role'] === 'assistant'); + const toolResult = messages.find((m) => m['role'] === 'tool'); + if (assistant === undefined || toolResult === undefined) { + throw new Error('expected assistant and tool messages on the wire'); + } + + const [toolCall] = assistant['tool_calls'] as Array>; + expect(toolCall?.['id']).toBe('functions.Bash:0'); + expect(toolResult['tool_call_id']).toBe('functions.Bash:0'); + }); + + it('sanitizes a non-canonical historical tool call id through the composed Kimi provider', async () => { + const provider = registry.createChatProvider({ + protocol: 'openai', + providerType: 'kimi', + modelName: 'kimi-k2', + apiKey: 'sk-probe', + }); + + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'Bash:7', name: 'Bash', arguments: '{"command":"pwd"}' }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'Bash:7', + toolCalls: [], + }, + ]; + + const body = await captureOpenAIBody(provider, undefined, history); + const messages = body['messages'] as Array>; + const assistant = messages.find((m) => m['role'] === 'assistant'); + const toolResult = messages.find((m) => m['role'] === 'tool'); + if (assistant === undefined || toolResult === undefined) { + throw new Error('expected assistant and tool messages on the wire'); + } + + const [toolCall] = assistant['tool_calls'] as Array>; + expect(toolCall?.['id']).toBe('Bash_7'); + expect(toolResult['tool_call_id']).toBe('Bash_7'); + }); + + it('preserves a canonical Kimi-native tool call id through OpenAIResponsesChatProvider.generate()', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'kimi-k2', apiKey: 'sk-probe' }); + + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'functions.Bash:0', name: 'Bash', arguments: '{"command":"pwd"}' }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'functions.Bash:0', + toolCalls: [], + }, + ]; + + const body = await captureResponsesBody(provider, undefined, history); + const input = body['input'] as Array>; + const call = input.find((i) => i['type'] === 'function_call'); + const output = input.find((i) => i['type'] === 'function_call_output'); + if (call === undefined || output === undefined) { + throw new Error('expected function_call and function_call_output items on the wire'); + } + + expect(call['call_id']).toBe('functions.Bash:0'); + expect(output['call_id']).toBe('functions.Bash:0'); + }); + it('encodes cacheKey on plain OpenAI as the native prompt_cache_key', async () => { const provider = registry.createChatProvider({ protocol: 'openai', diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 4496a432d9..ce5a464ef4 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -40,7 +40,7 @@ import { } from './request-auth'; import { normalizeToolCallIdsForProvider, - sanitizeToolCallId, + sanitizeToolCallIdPreservingNative, type ToolCallIdPolicy, } from './tool-call-id'; export interface KimiOptions { @@ -86,7 +86,7 @@ export interface ExtraBody { [key: string]: unknown; } const KIMI_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), + normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64), maxLength: 64, }; interface OpenAIMessage { diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index f40e59df7d..5516f3223f 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -39,7 +39,7 @@ import { } from './request-auth'; import { normalizeToolCallIdsForProvider, - sanitizeToolCallId, + sanitizeToolCallIdPreservingNative, type ToolCallIdPolicy, } from './tool-call-id'; @@ -54,8 +54,10 @@ import { * (the documented range is `[1, 131072]`). */ const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; +// Preserves a canonical Kimi-native id, because self-hosted Kimi is commonly served through an +// OpenAI-compatible endpoint configured as an `openai` provider. See `KIMI_NATIVE_TOOL_CALL_ID`. const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), + normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64), maxLength: 64, }; diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index bd4e86e08a..ed22d70f9e 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -36,7 +36,7 @@ import { } from './request-auth'; import { normalizeToolCallIdsForProvider, - sanitizeOpenAIResponsesCallId, + sanitizeOpenAIResponsesCallIdPreservingNative, type ToolCallIdPolicy, } from './tool-call-id'; @@ -78,8 +78,10 @@ function normalizeResponsesFinishReason( } type RawObject = Record; +// Preserves a canonical Kimi-native id for the same reason as the chat policy; here the cap is a +// real wire limit, so an over-long canonical id still normalizes. See `KIMI_NATIVE_TOOL_CALL_ID`. const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), + normalize: (id) => sanitizeOpenAIResponsesCallIdPreservingNative(id, 64), maxLength: 64, }; diff --git a/packages/kosong/src/providers/tool-call-id.ts b/packages/kosong/src/providers/tool-call-id.ts index fd70267182..70b345e52b 100644 --- a/packages/kosong/src/providers/tool-call-id.ts +++ b/packages/kosong/src/providers/tool-call-id.ts @@ -13,11 +13,69 @@ export function sanitizeToolCallId(id: string, maxLength?: number): string { return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength); } +// Kimi-K2 tool-call ids use the canonical native shape `functions.:` — the exact format +// the model is trained to see echoed back in conversation history. Sanitizing it (`.` and `:` become +// `_`) makes the model emit reasoning with no tool call and stop the turn (`APIEmptyResponseError` / +// `finishReason=stop`). Such an id is also a valid free-form id for OpenAI-compatible providers — +// the transport that serves self-hosted Kimi (litellm / vLLM / SGLang / Azure) — so those policies +// opt into preserving it verbatim, while charset-restricted providers (Anthropic, +// `^[a-zA-Z0-9_-]+$`) keep sanitizing. +// +// The guard is keyed on the id shape rather than on the provider, because self-hosted Kimi is +// configured as a plain `openai` provider and does not self-identify at the policy layer. Trade-off: +// a Kimi-authored id replayed onto a different, stricter OpenAI-compatible backend is passed through +// rather than sanitized. +// +// Refs: Moonshot's tool_call_guidance.md; vLLM's "Debugging Kimi K2 Tool-Calling" writeup. +const KIMI_NATIVE_TOOL_CALL_ID = /^functions\.[A-Za-z0-9_-]+:\d+$/; + +/** + * Preserve a canonical native id verbatim; sanitize every other id. + * + * A preserved id is never length-capped: the chat-completions `tool_call.id` has no limit in the + * OpenAI schema (the 64-char cap applies to the function *name*, `^[a-zA-Z0-9_-]{1,64}$`), and a + * long tool name or a multi-digit `idx` must still round-trip intact. Hence `fallbackMaxLength`, + * which bounds the sanitized fallback only — deliberately unlike + * `sanitizeOpenAIResponsesCallIdPreservingNative`, whose cap is a real wire limit and therefore + * also gates preservation. + */ +export function sanitizeToolCallIdPreservingNative( + id: string, + fallbackMaxLength?: number, +): string { + return KIMI_NATIVE_TOOL_CALL_ID.test(id) ? id : sanitizeToolCallId(id, fallbackMaxLength); +} + +function isPreservableResponsesCallId(id: string, maxLength?: number): boolean { + return KIMI_NATIVE_TOOL_CALL_ID.test(id) && (maxLength === undefined || id.length <= maxLength); +} + export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string { const [callId] = id.split('|', 1); return sanitizeToolCallId(callId ?? id, maxLength); } +/** + * Preserve a canonical native id verbatim, but only when it fits `maxLength`; sanitize every other + * id. + * + * Unlike the chat `tool_call.id`, the Responses `call_id` carries a real `maxLength` (64) on the + * wire, and a preserved id is sent untruncated — so an over-long canonical id must fall back to + * normalization rather than be rejected by the API. + */ +export function sanitizeOpenAIResponsesCallIdPreservingNative( + id: string, + maxLength?: number, +): string { + // Mirror `sanitizeOpenAIResponsesCallId` and drop the `|` suffix first, so a canonical + // native id survives even when the Responses transport has appended an item id. + const [callId] = id.split('|', 1); + const base = callId ?? id; + return isPreservableResponsesCallId(base, maxLength) + ? base + : sanitizeOpenAIResponsesCallId(id, maxLength); +} + export function normalizeToolCallIdsForProvider( messages: Message[], policy: ToolCallIdPolicy, diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 6b079a75a6..238c258588 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1205,6 +1205,42 @@ describe('AnthropicChatProvider', () => { ]); }); + it('still sanitizes a canonical Kimi-native id (Anthropic charset forbids `.`/`:`)', async () => { + // Anthropic tool ids must match `^[a-zA-Z0-9_-]+$`, so unlike the OpenAI/Kimi policies the + // Anthropic policy must NOT preserve `functions.:` — it keeps rewriting it. This + // pins that the native-id preservation did not leak across the provider boundary (#327). + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Read' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'functions.Read:0', + name: 'Read', + arguments: '{"path":"/tmp/a"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'ok' }], + toolCallId: 'functions.Read:0', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Array<{ content: Array> }>; + expect(messages[1]?.content[0]).toMatchObject({ type: 'tool_use', id: 'functions_Read_0' }); + expect(messages[2]?.content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'functions_Read_0', + }); + }); + it('tool call with image result wraps image source inside tool_result', async () => { const provider = createProvider(); const toolCall: ToolCall = { diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 35601942b9..accdc1cfbc 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -453,6 +453,52 @@ describe('KimiChatProvider', () => { ]); }); + it('preserves canonical native tool call ids (functions.:) targeting Kimi', async () => { + // Kimi-K2 requires its native id format echoed back verbatim in history; sanitizing it + // to `functions_Read_0` makes the model reason without emitting a tool call. The pair + // above ("normalizes invalid historical tool call ids") pins the complementary case: + // a non-canonical id is still rewritten for cross-provider safety. + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Read a file' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'functions.Read:0', + name: 'Read', + arguments: '{"path":"/tmp/file"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'content' }], + toolCallId: 'functions.Read:0', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Read a file' }, + { + role: 'assistant', + tool_calls: [ + { + type: 'function', + id: 'functions.Read:0', + function: { name: 'Read', arguments: '{"path":"/tmp/file"}' }, + }, + ], + }, + { role: 'tool', content: 'content', tool_call_id: 'functions.Read:0' }, + ]); + }); + it('tool call with image result', async () => { const provider = createProvider(); const toolCall: ToolCall = { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 3f5d8b5bf8..9a0b671f42 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -291,6 +291,81 @@ describe('OpenAILegacyChatProvider', () => { ]); }); + it('preserves canonical Kimi-native tool call ids (self-hosted Kimi behind an OpenAI endpoint)', async () => { + // Self-hosted Kimi (litellm / vLLM / SGLang / Azure) is configured as an `openai` + // provider. Kimi-K2 requires its native id format `functions.:` echoed back + // verbatim; sanitizing it to `functions_Bash_0` makes the model reason without emitting a + // tool call. The test above pins the complementary case: a non-canonical id is still + // rewritten for cross-provider safety. + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'functions.Bash:0', + name: 'Bash', + arguments: '{"command":"pwd"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'functions.Bash:0', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Run bash' }, + { + role: 'assistant', + tool_calls: [ + { + type: 'function', + id: 'functions.Bash:0', + function: { name: 'Bash', arguments: '{"command":"pwd"}' }, + }, + ], + }, + { role: 'tool', content: '/tmp', tool_call_id: 'functions.Bash:0' }, + ]); + }); + + it('preserves a canonical id whose length exceeds the policy maxLength (chat tool_call.id is uncapped)', async () => { + // The chat policy carries maxLength: 64, but that cap is for sanitized non-canonical ids — + // the chat `tool_call.id` itself has no length limit (OpenAI's 64-char cap is on the function + // name). A long MCP tool name plus a multi-digit idx yields a 77-char canonical id that must + // still round-trip verbatim, or the very regression this fixes would reappear for such ids. + const longName = 'mcp__github__'.padEnd(64, 'x'); + expect(longName).toHaveLength(64); + const longId = `functions.${longName}:12`; + expect(longId.length).toBeGreaterThan(64); + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: longId, name: longName, arguments: '{}' }], + }, + { role: 'tool', content: [{ type: 'text', text: 'ok' }], toolCallId: longId, toolCalls: [] }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Record[]; + const assistant = messages[1]!; + const toolCalls = assistant['tool_calls'] as Record[]; + expect(toolCalls[0]!['id']).toBe(longId); + expect(messages[2]!['tool_call_id']).toBe(longId); + }); + it('tool call with image result keeps the tool result textual and reattaches images as user input', async () => { // OpenAI Chat Completions `tool` messages only accept text content. // Even when toolMessageConversion is unset, a tool result containing diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index d8c7a6efcb..a629e01652 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -782,6 +782,46 @@ describe('OpenAIResponsesChatProvider', () => { }); }); + it('preserves canonical Kimi-native tool call ids (self-hosted Kimi behind an OpenAI Responses endpoint)', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'functions.Bash:0', + name: 'Bash', + arguments: '{"command":"pwd"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'functions.Bash:0', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const input = body['input'] as unknown[]; + + expect(input[1]).toEqual({ + arguments: '{"command":"pwd"}', + call_id: 'functions.Bash:0', + name: 'Bash', + type: 'function_call', + }); + expect(input[2]).toEqual({ + call_id: 'functions.Bash:0', + output: [{ type: 'input_text', text: '/tmp' }], + type: 'function_call_output', + }); + }); + it('assistant with reasoning (ThinkPart with encrypted)', async () => { const provider = createProvider(); const history: Message[] = [ diff --git a/packages/kosong/test/tool-call-id.test.ts b/packages/kosong/test/tool-call-id.test.ts new file mode 100644 index 0000000000..a78f5b7ad5 --- /dev/null +++ b/packages/kosong/test/tool-call-id.test.ts @@ -0,0 +1,73 @@ +// Which id shapes count as canonical (preserved) vs normalized, and how the two length regimes +// differ: the chat `tool_call.id` is uncapped so a canonical id survives at any length, while the +// Responses `call_id` is capped at 64 so an over-long canonical id falls back to normalization. +// End-to-end wiring through each provider is covered by the per-provider wire tests. +import { + sanitizeOpenAIResponsesCallIdPreservingNative, + sanitizeToolCallIdPreservingNative, +} from '#/providers/tool-call-id'; +import { describe, expect, it } from 'vitest'; + +describe('sanitizeToolCallIdPreservingNative', () => { + it('preserves canonical native ids verbatim', () => { + expect(sanitizeToolCallIdPreservingNative('functions.Read:0', 64)).toBe('functions.Read:0'); + expect(sanitizeToolCallIdPreservingNative('functions.web_search:12', 64)).toBe( + 'functions.web_search:12', + ); + // MCP tools are namespaced `mcp____` — all identifier chars, so their + // native ids are preserved like any builtin. + expect(sanitizeToolCallIdPreservingNative('functions.mcp__github__create_pr:3', 64)).toBe( + 'functions.mcp__github__create_pr:3', + ); + }); + + it('falls back to sanitizeToolCallId for non-canonical ids', () => { + expect(sanitizeToolCallIdPreservingNative('Read:9', 64)).toBe('Read_9'); // no `functions.` + expect(sanitizeToolCallIdPreservingNative('functions.Read:x', 64)).toBe('functions_Read_x'); // idx not numeric + expect(sanitizeToolCallIdPreservingNative('functions_Read_0', 64)).toBe('functions_Read_0'); // already sanitized + expect(sanitizeToolCallIdPreservingNative('call_abc123', 64)).toBe('call_abc123'); // OpenAI-style + // A name carrying a `.` is ambiguous under `functions.{name}:{idx}` parsing, so it is treated + // as non-canonical and normalized — the pre-existing behavior, no regression. + expect(sanitizeToolCallIdPreservingNative('functions.a.b:0', 64)).toBe('functions_a_b_0'); + }); + + it('preserves a canonical id longer than maxLength (chat tool_call.id is uncapped)', () => { + // `functions.` (10) + 54-char name + `:0` (2) = 66 chars. OpenAI's 64-char cap is on the + // function *name*, not on tool_call.id, so a long canonical id must round-trip intact — the + // maxLength only bounds the sanitization fallback for non-canonical ids. + const longId = `functions.${'a'.repeat(54)}:0`; + expect(longId).toHaveLength(66); + expect(sanitizeToolCallIdPreservingNative(longId, 64)).toBe(longId); + }); + + it('preserves a canonical id with a multi-digit idx', () => { + expect(sanitizeToolCallIdPreservingNative('functions.Read:10', 64)).toBe('functions.Read:10'); + }); +}); + +describe('sanitizeOpenAIResponsesCallIdPreservingNative', () => { + it('preserves canonical native ids, including under a `|` Responses suffix', () => { + expect(sanitizeOpenAIResponsesCallIdPreservingNative('functions.Read:0', 64)).toBe( + 'functions.Read:0', + ); + // The Responses transport can append `|`; the canonical call-id part must still survive. + expect(sanitizeOpenAIResponsesCallIdPreservingNative('functions.Read:0|item_abc', 64)).toBe( + 'functions.Read:0', + ); + }); + + it('falls back to the responses sanitizer for non-canonical ids', () => { + expect(sanitizeOpenAIResponsesCallIdPreservingNative('call_abc123|item_1', 64)).toBe( + 'call_abc123', + ); + expect(sanitizeOpenAIResponsesCallIdPreservingNative('Read:9', 64)).toBe('Read_9'); + }); + + it('does not preserve a shape-canonical call-id longer than maxLength', () => { + const longId = `functions.${'a'.repeat(54)}:0`; + expect(longId).toHaveLength(66); + const result = sanitizeOpenAIResponsesCallIdPreservingNative(longId, 64); + expect(result).not.toBe(longId); + expect(result.length).toBeLessThanOrEqual(64); + }); +});