From f2ca3f05bc076c5cbb33b89fc1cf61566edaefaf Mon Sep 17 00:00:00 2001 From: jackjinke Date: Wed, 5 Aug 2026 14:43:17 +0800 Subject: [PATCH] fix(translator): preserve Kimi K3 Responses reasoning --- .../fixes/9496-kimi-k3-responses-replay.md | 1 + open-sse/handlers/responsesHandler.ts | 7 +- open-sse/translator/helpers/claudeHelper.ts | 4 + .../translator/helpers/responsesApiHelper.ts | 26 +- open-sse/translator/index.ts | 30 ++- .../translator/request/openai-responses.ts | 63 ++++- open-sse/utils/reasoningContentInjector.ts | 14 + tests/unit/kimi-coding-translator.test.ts | 162 ++++++++++++ tests/unit/moonshot-k3.test.ts | 54 +++- tests/unit/responses-handler.test.ts | 38 +++ .../unit/responses-translation-fixes.test.ts | 36 +++ tests/unit/translator-helper-branches.test.ts | 243 +++++++++--------- 12 files changed, 535 insertions(+), 143 deletions(-) create mode 100644 changelog.d/fixes/9496-kimi-k3-responses-replay.md diff --git a/changelog.d/fixes/9496-kimi-k3-responses-replay.md b/changelog.d/fixes/9496-kimi-k3-responses-replay.md new file mode 100644 index 0000000000..d6ba06fe78 --- /dev/null +++ b/changelog.d/fixes/9496-kimi-k3-responses-replay.md @@ -0,0 +1 @@ +- fix(translator): preserve Kimi K3 Responses reasoning through Kimi Coding Claude-format and native Moonshot requests, keeping it on the matching assistant tool call or completed turn instead of dropping it or carrying it across a user boundary (#9496) diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 37c4169d64..d0a55241c1 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -40,7 +40,12 @@ export async function handleResponsesCore({ const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems); // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider); + const convertedBody = convertResponsesApiFormat( + body, + credentials, + modelInfo?.provider, + modelInfo?.model + ); // Ensure stream is enabled convertedBody.stream = true; diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index b490fe4bd5..05dad32149 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean { return msg.content.some( (block) => (block.type === "text" && block.text?.trim()) || + (block.type === "thinking" && block.thinking?.trim()) || + (block.type === "redacted_thinking" && + typeof block.data === "string" && + block.data.trim()) || block.type === "tool_use" || block.type === "tool_result" || // #7777: media-only user turns are real content — dropping them diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 1f856b11f1..c0148956b8 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -2,10 +2,16 @@ * Convert OpenAI Responses API format to standard chat completions format. * Delegates to the canonical translator to avoid logic duplication. */ +import { shouldPreserveResponsesReasoningContent } from "../../utils/reasoningContentInjector.ts"; import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; import { toRecord } from "../request/openai-responses/helpers.ts"; -export function convertResponsesApiFormat(body, credentials = null, provider = null) { +export function convertResponsesApiFormat( + body: Record, + credentials: unknown = null, + provider: unknown = null, + model: unknown = null +): Record { const bodyModel = toRecord(body).model; const requestedModel = typeof bodyModel === "string" && bodyModel.trim().length > 0 @@ -13,5 +19,21 @@ export function convertResponsesApiFormat(body, credentials = null, provider = n ? bodyModel : `${provider}/${bodyModel}` : provider; - return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials); + const credentialRecord = + credentials && typeof credentials === "object" && !Array.isArray(credentials) + ? (credentials as Record) + : {}; + const translationCredentials = shouldPreserveResponsesReasoningContent(provider, model) + ? { ...credentialRecord, _preserveReasoningContent: true } + : credentials; + const converted = openaiResponsesToOpenAIRequest( + requestedModel, + body, + null, + translationCredentials + ); + if (!converted || typeof converted !== "object" || Array.isArray(converted)) { + throw new TypeError("Responses request conversion must produce an object"); + } + return converted as Record; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8497989f09..da1c7963a6 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -13,7 +13,10 @@ import { providerHonorsOpenAIFormatCacheControl, resolveConnectionCacheOverride, } from "../utils/cacheControlPolicy.ts"; -import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts"; +import { + requiresAuthenticReasoningContent, + shouldPreserveResponsesReasoningContent, +} from "../utils/reasoningContentInjector.ts"; import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; import { coerceToolSchemas, @@ -233,6 +236,17 @@ export function translateRequest( const connectionCacheOverride = resolveConnectionCacheOverride( (credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData ); + const normalizedProvider = String(provider ?? ""); + const normalizedModel = String(model ?? ""); + const isKimiCoding = + normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey"; + const requiresAuthenticReasoning = requiresAuthenticReasoningContent( + normalizedProvider, + normalizedModel + ); + const preserveResponsesReasoning = + sourceFormat === FORMATS.OPENAI_RESPONSES && + shouldPreserveResponsesReasoningContent(normalizedProvider, normalizedModel); // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); @@ -318,12 +332,16 @@ export function translateRequest( options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride); const step1Credentials = - options?.copilotClient || hasTargetHint || preserveCacheControl + options?.copilotClient || + hasTargetHint || + preserveCacheControl || + preserveResponsesReasoning ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(options?.copilotClient ? { _copilotClient: true } : {}), ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), ...(preserveCacheControl ? { _preserveCacheControl: true } : {}), + ...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}), } : credentials; result = toOpenAI(model, result, stream, step1Credentials); @@ -361,14 +379,6 @@ export function translateRequest( // Resolve reasoning-replay status up-front: it gates both the reasoning_content // strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for // replay providers) and the cache re-injection further down. - const normalizedProvider = String(provider ?? ""); - const normalizedModel = String(model ?? ""); - const isKimiCoding = - normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey"; - const requiresAuthenticReasoning = requiresAuthenticReasoningContent( - normalizedProvider, - normalizedModel - ); const resolvedCapabilities = getResolvedModelCapabilities({ provider: normalizedProvider, model: normalizedModel, diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6d7a79b8a4..e33586fdef 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -73,6 +73,19 @@ function toolOutputContentToString(output: unknown): string { return parts.join("\n"); } +function getReasoningSummaryText(item: JsonRecord): string { + if (!Array.isArray(item.summary)) return ""; + return item.summary + .map((part) => toString(toRecord(part).text)) + .filter((text) => text.length > 0) + .join("\n\n"); +} + +function appendReasoningContent(current: unknown, next: string): string { + const existing = typeof current === "string" ? current : ""; + return existing ? `${existing}\n\n${next}` : next; +} + /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ @@ -83,13 +96,13 @@ export function openaiResponsesToOpenAIRequest( credentials: unknown ): unknown { void stream; - void credentials; const collapseToPlainString = requiresPlainStringContent(extractProviderHint(model)); const root = toRecord(body); if (root.input === undefined) return body; const credentialRecord = toRecord(credentials); const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const preserveReasoningContent = credentialRecord._preserveReasoningContent === true; const rawInputItems = normalizeResponsesInputForChat(root.input); // Tools may be declared at the Responses top level or in one or more @@ -204,6 +217,7 @@ export function openaiResponsesToOpenAIRequest( // Group items by conversation turn let currentAssistantMsg: JsonRecord | null = null; let pendingToolResults: JsonRecord[] = []; + let pendingReasoningContent = ""; // Upstream providers reject messages:[] with "400: at least one message is required". // When the client sends input:[] (empty), inject a placeholder user message — mirrors @@ -220,11 +234,20 @@ export function openaiResponsesToOpenAIRequest( const itemType = toString(item.type) || (item.role ? "message" : ""); if (itemType === "message") { + const role = toString(item.role); // Flush pending assistant message with tool calls if (currentAssistantMsg) { messages.push(currentAssistantMsg); currentAssistantMsg = null; } + if (role !== "assistant" && pendingReasoningContent) { + messages.push({ + role: "assistant", + content: null, + reasoning_content: pendingReasoningContent, + }); + pendingReasoningContent = ""; + } // Flush pending tool results if (pendingToolResults.length > 0) { @@ -269,7 +292,12 @@ export function openaiResponsesToOpenAIRequest( }) : item.content; - messages.push({ role: toString(item.role), content }); + const message: JsonRecord = { role, content }; + if (role === "assistant" && pendingReasoningContent) { + message.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } + messages.push(message); continue; } @@ -294,6 +322,10 @@ export function openaiResponsesToOpenAIRequest( content: null, tool_calls: [], }; + if (pendingReasoningContent) { + currentAssistantMsg.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } } const toolCalls = Array.isArray(currentAssistantMsg.tool_calls) @@ -353,6 +385,10 @@ export function openaiResponsesToOpenAIRequest( content: null, tool_calls: [], }; + if (pendingReasoningContent) { + currentAssistantMsg.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } } const toolCalls = Array.isArray(currentAssistantMsg.tool_calls) ? currentAssistantMsg.tool_calls @@ -401,7 +437,21 @@ export function openaiResponsesToOpenAIRequest( } if (itemType === "reasoning") { - // Skip reasoning items - they are display-only metadata + // Responses reasoning summaries are normally display metadata. Preserve them only + // when the routed upstream explicitly requires prior reasoning to continue a turn. + if (preserveReasoningContent) { + const reasoning = getReasoningSummaryText(item); + if (reasoning) { + if (currentAssistantMsg) { + currentAssistantMsg.reasoning_content = appendReasoningContent( + currentAssistantMsg.reasoning_content, + reasoning + ); + } else { + pendingReasoningContent = appendReasoningContent(pendingReasoningContent, reasoning); + } + } + } continue; } @@ -430,6 +480,13 @@ export function openaiResponsesToOpenAIRequest( if (currentAssistantMsg) { messages.push(currentAssistantMsg); } + if (pendingReasoningContent) { + messages.push({ + role: "assistant", + content: null, + reasoning_content: pendingReasoningContent, + }); + } if (pendingToolResults.length > 0) { for (const toolResult of pendingToolResults) { messages.push(toolResult); diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 72c864dfa7..b36a44f4fb 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -48,6 +48,20 @@ export function requiresAuthenticReasoningContent(provider: unknown, model: unkn ); } +export function shouldPreserveResponsesReasoningContent( + provider: unknown, + model: unknown +): boolean { + const normalizedProvider = String(provider ?? "") + .trim() + .toLowerCase(); + return ( + normalizedProvider === "kimi-coding" || + normalizedProvider === "kimi-coding-apikey" || + requiresAuthenticReasoningContent(normalizedProvider, model) + ); +} + export function isThinkingMessageModel(model: string | undefined | null): boolean { if (!model || typeof model !== "string") return false; return THINKING_MODEL_PATTERNS.some((re) => re.test(model)); diff --git a/tests/unit/kimi-coding-translator.test.ts b/tests/unit/kimi-coding-translator.test.ts index b3f4c35d65..f1ec07421b 100644 --- a/tests/unit/kimi-coding-translator.test.ts +++ b/tests/unit/kimi-coding-translator.test.ts @@ -79,3 +79,165 @@ test("Kimi Anthropic preserves an explicit empty thinking block", () => { thinking: "", }); }); + +test("Responses history preserves Kimi reasoning before a tool call", () => { + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "k3-256k", + { + model: "k3-256k", + max_output_tokens: 4096, + reasoning: { effort: "high" }, + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Call ping, then answer DONE." }], + }, + { + id: "rs_1", + type: "reasoning", + summary: [{ type: "summary_text", text: "I should call ping first." }], + }, + { + id: "fc_1", + type: "function_call", + call_id: "call_1", + name: "ping", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_1", + output: "pong", + }, + ], + }, + false, + {}, + "kimi-coding-apikey" + ) as KimiClaudeRequest; + + assert.deepEqual(translated.messages[1].content[0], { + type: "thinking", + thinking: "I should call ping first.", + }); + assert.deepEqual(translated.messages[1].content[1], { + type: "tool_use", + id: "call_1", + name: "proxy_ping", + input: {}, + }); +}); + +test("Responses history preserves Kimi reasoning on completed assistant turns", () => { + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "k3-256k", + { + model: "k3-256k", + max_output_tokens: 4096, + reasoning: { effort: "high" }, + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Remember cobalt-orchid." }], + }, + { + id: "rs_1", + type: "reasoning", + summary: [{ type: "summary_text", text: "I should retain the nonce." }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Noted." }], + }, + { + role: "user", + content: [{ type: "input_text", text: "What was it?" }], + }, + ], + }, + false, + {}, + "kimi-coding-apikey" + ) as KimiClaudeRequest; + + assert.deepEqual(translated.messages[1].content, [ + { type: "thinking", thinking: "I should retain the nonce." }, + { type: "text", text: "Noted." }, + ]); +}); + +test("Responses history preserves Kimi reasoning before a custom tool call", () => { + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "k3-256k", + { + model: "k3-256k", + reasoning: { effort: "high" }, + input: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "I should apply the patch." }], + }, + { + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "*** Begin Patch", + }, + { + type: "custom_tool_call_output", + call_id: "call_1", + output: "Done", + }, + ], + }, + false, + {}, + "kimi-coding-apikey" + ) as KimiClaudeRequest; + + assert.deepEqual(translated.messages[0].content[0], { + type: "thinking", + thinking: "I should apply the patch.", + }); +}); + +test("Responses history does not carry reasoning across a user boundary", () => { + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "k3-256k", + { + model: "k3-256k", + reasoning: { effort: "high" }, + input: [ + { + role: "user", + content: [{ type: "input_text", text: "First turn" }], + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Prior turn reasoning." }], + }, + { + role: "user", + content: [{ type: "input_text", text: "Next turn" }], + }, + ], + }, + false, + {}, + "kimi-coding-apikey" + ) as KimiClaudeRequest; + + assert.deepEqual(translated.messages[1].content, [ + { type: "thinking", thinking: "Prior turn reasoning." }, + ]); + assert.deepEqual(translated.messages[2].content, [{ type: "text", text: "Next turn" }]); +}); diff --git a/tests/unit/moonshot-k3.test.ts b/tests/unit/moonshot-k3.test.ts index 52f7e6131a..55ed7f0275 100644 --- a/tests/unit/moonshot-k3.test.ts +++ b/tests/unit/moonshot-k3.test.ts @@ -24,12 +24,7 @@ import { import { translateRequest } from "../../open-sse/translator/index.ts"; import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; -const EXPECTED_MODELS = [ - "kimi-k3", - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", -]; +const EXPECTED_MODELS = ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"]; function registryModelIds(provider: string): string[] { const entry = getRegistryEntry(provider); @@ -190,6 +185,53 @@ test("Moonshot K3 participates in reasoning replay", () => { ); }); +test("Responses history preserves authentic reasoning for native Moonshot K3", () => { + for (const provider of ["moonshot", "kimi"]) { + const output = translateRequest( + "openai-responses", + "openai", + "kimi-k3", + { + model: "kimi-k3", + reasoning: { effort: "max" }, + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Call search." }], + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "I should search first." }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_1", + output: "found", + }, + ], + }, + false, + null, + provider + ) as { messages: Array> }; + + assert.equal(output.messages[1].reasoning_content, "I should search first."); + assert.deepEqual(output.messages[1].tool_calls, [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ]); + } +}); + test("video_url is preserved only for Moonshot's OpenAI-compatible extension", () => { const moonshot = translateRequest( "openai", diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 2dd31b34c6..fee20b5588 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -225,6 +225,44 @@ test("handleResponsesCore converts Responses API input, instructions, tools, met assert.equal("store" in call.body, false); }); +test("handleResponsesCore preserves Kimi K3 reasoning through provider translation", async () => { + const body = { + model: "k3-256k", + reasoning: { effort: "high" }, + input: [ + { role: "user", content: [{ type: "input_text", text: "Call search." }] }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "I should search first." }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_1", output: "found" }, + ], + }; + + const coding = await invokeResponsesCore({ + body, + provider: "kimi-coding-apikey", + model: "k3-256k", + }); + assert.deepEqual(coding.call.body.messages?.[1]?.content?.[0], { + type: "thinking", + thinking: "I should search first.", + }); + + const native = await invokeResponsesCore({ + body: { ...body, model: "kimi-k3", reasoning: { effort: "max" } }, + provider: "moonshot", + model: "kimi-k3", + }); + assert.equal(native.call.body.messages?.[1]?.reasoning_content, "I should search first."); +}); + test("handleResponsesCore strips previous_response_id by default and handles empty input arrays", async () => { const { call, result } = await invokeResponsesCore({ body: { diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 1c91e64952..c4b981b1c7 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -37,6 +37,42 @@ test("convertResponsesApiFormat skips function_call items with empty names", () assert.equal(assistantMsgs.length, 0); }); +test("production Responses conversion preserves Kimi K3 reasoning history", () => { + const body = { + model: "kimi-k3", + input: [ + { role: "user", content: [{ type: "input_text", text: "Call search." }] }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "I should search first." }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_1", output: "found" }, + ], + }; + + for (const { provider, model } of [ + { provider: "kimi-coding-apikey", model: "k3-256k" }, + { provider: "moonshot", model: "kimi-k3" }, + { provider: "kimi", model: "kimi-k3" }, + ]) { + const converted = convertResponsesApiFormat(body, {}, provider, model) as { + messages: Array>; + }; + assert.equal(converted.messages[1].reasoning_content, "I should search first."); + } + + const generic = convertResponsesApiFormat(body, {}, "openai", "gpt-5") as { + messages: Array>; + }; + assert.equal(Object.hasOwn(generic.messages[1], "reasoning_content"), false); +}); + test("Responses→Chat: input_image converted to image_url with detail", () => { const body = { model: "gpt-4", diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 4f0f32cfb2..6868980f39 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -259,6 +259,14 @@ test("openaiHelper keeps unmatched tool choices and deletes empty tools arrays", test("claudeHelper validates content, ordering and request preparation branches", () => { assert.equal(claudeHelper.hasValidContent({ content: " hello " }), true); assert.equal(claudeHelper.hasValidContent({ content: [{ type: "tool_use", id: "call" }] }), true); + assert.equal( + claudeHelper.hasValidContent({ content: [{ type: "thinking", thinking: "reasoning" }] }), + true + ); + assert.equal( + claudeHelper.hasValidContent({ content: [{ type: "redacted_thinking", data: "opaque" }] }), + true + ); assert.equal(claudeHelper.hasValidContent({ content: [{ type: "text", text: " " }] }), false); assert.deepEqual(claudeHelper.fixToolUseOrdering([{ role: "user", content: "single" }]), [ @@ -690,138 +698,131 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek clearReasoningCacheAll(); }); - test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => { - clearReasoningCacheAll(); - cacheReasoningByKey( - "toolu_kimi_claude", - "kimi-coding", - "kimi-for-coding", - "cached thinking for Kimi tool call" - ); +test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => { + clearReasoningCacheAll(); + cacheReasoningByKey( + "toolu_kimi_claude", + "kimi-coding", + "kimi-for-coding", + "cached thinking for Kimi tool call" + ); - // Claude-format request: assistant has tool_use in content[] but NO thinking block - // This simulates the scenario that causes infinite loops - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - reasoning_effort: "high", - messages: [ - { role: "user", content: "read the file" }, - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_kimi_claude", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, - ], - }, - false, - null, - "kimi-coding" - ); + // Claude-format request: assistant has tool_use in content[] but NO thinking block + // This simulates the scenario that causes infinite loops + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + reasoning_effort: "high", + messages: [ + { role: "user", content: "read the file" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_kimi_claude", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); - assert.ok(Array.isArray(assistantMsg.content), "content should be array"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + assert.ok(Array.isArray(assistantMsg.content), "content should be array"); - // Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use. - const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected"); - assert.equal(thinkingBlock.thinking, ""); + // Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use. + const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected"); + assert.equal(thinkingBlock.thinking, ""); - // Thinking block should appear before tool_use - const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); - const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); - assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); + // Thinking block should appear before tool_use + const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); + const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); + assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); - assert.equal(getReasoningCacheServiceStats().replays, 0); - clearReasoningCacheAll(); - }); + assert.equal(getReasoningCacheServiceStats().replays, 0); + clearReasoningCacheAll(); +}); - test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => { - clearReasoningCacheAll(); +test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => { + clearReasoningCacheAll(); - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - reasoning_effort: "high", - messages: [ - { role: "user", content: "do it" }, - { - role: "assistant", - content: [ - { type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }, - ], - }, - { role: "tool", tool_call_id: "toolu_miss", content: "output" }, - ], - }, - false, - null, - "kimi-coding" - ); + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + reasoning_effort: "high", + messages: [ + { role: "user", content: "do it" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }], + }, + { role: "tool", tool_call_id: "toolu_miss", content: "output" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); - const thinkingBlock = - Array.isArray(assistantMsg.content) && - assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); - assert.equal(thinkingBlock.thinking, ""); + const thinkingBlock = + Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); + assert.equal(thinkingBlock.thinking, ""); - clearReasoningCacheAll(); - }); + clearReasoningCacheAll(); +}); - test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { - clearReasoningCacheAll(); +test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { + clearReasoningCacheAll(); - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - messages: [ - { role: "user", content: "hi" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I already have this" }, - { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, - ], - }, - { role: "tool", tool_call_id: "toolu_existing", content: "data" }, - ], - }, - false, - null, - "kimi-coding" - ); + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, + ], + }, + { role: "tool", tool_call_id: "toolu_existing", content: "data" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - const thinkingBlocks = - Array.isArray(assistantMsg.content) && - assistantMsg.content.filter((b) => b?.type === "thinking"); - assert.equal( - thinkingBlocks?.length, - 1, - "should have exactly one thinking block (no duplicate)" - ); - assert.equal( - thinkingBlocks[0].thinking, - "I already have this", - "original thinking should be preserved" - ); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const thinkingBlocks = + Array.isArray(assistantMsg.content) && + assistantMsg.content.filter((b) => b?.type === "thinking"); + assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)"); + assert.equal( + thinkingBlocks[0].thinking, + "I already have this", + "original thinking should be preserved" + ); - clearReasoningCacheAll(); - }); + clearReasoningCacheAll(); +});