From 53ad66d2fbde2c8db3f37521243cd47b19796f63 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sat, 8 Aug 2026 02:42:57 +0200 Subject: [PATCH] fix(responses-api): sync reasoning-cache write index with the fixed read side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-index-hardcoding fix updated the reasoning-cache read side (translator/index.ts's main replay loop) to key lookups by the assistant message's real position in the messages array, but two other spots still used the old hardcoded convention: - chatCore.ts's write side (both the streaming and non-streaming completion paths) still cached every response under a hardcoded messageIndex: 0. - translator/index.ts's own plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site — a second, previously undiscovered instance of the same class of bug, found while re-verifying this fix against the current upstream tip (the original fix only addressed the write side). Past the first assistant turn these conventions no longer matched, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache and fell back to the placeholder (or, once #9573 removed the placeholder fallback, to an absent field) in ordinary multi-turn conversations. Compute the write-side index from the incoming request's message count instead, and use the real loop-provided messageIndex on the read-side lookup, both matching the position the response occupies once the client appends it to history for the next turn. Note: this was originally part of a larger squashed fix (output_index collision prevention across reasoning/message/tool_call items, reasoning-content-alias generalization) that has since been superseded by upstream's own independent fix — translator/response/openai-responses.ts now has its own dense-output-index-sort + getReadableReasoningValue implementation (own comment: "mirrors upstream PR #721"). Only this narrower, still-genuinely-broken write/read index sync survives as a distinct bug. Test plan: - TDD: tests/unit/reasoning-cache.test.ts's new end-to-end "write side (chatCore's messageIndex) and read side (translateRequest) agree on the same key end-to-end" test, plus the pre-existing "should inject placeholder for a plain (non-tool-call) DeepSeek turn" and "should replay cached reasoning for a plain (non-tool-call) DeepSeek turn when available" tests — confirmed failing against the pre-fix code on a clean release/v3.8.50 checkout (both the hardcoded-0 write side AND the hardcoded-0 read-side lookup independently reproduce the mismatch), passing after both fixes - npm run typecheck:core — clean - npm run lint — clean - npm run check:file-size — clean (chatCore.ts rebaselined 5034->5042 for the messageIndex computation at both call sites; reasoning-cache.test.ts frozen at 1035, matching the original fix's own rebaseline) - 2 pre-existing, unrelated test failures in the same file ("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", "should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content") confirmed present on a completely clean, untouched release/v3.8.50 checkout — these test obsolete placeholder-injection behavior the code deliberately removed per #9573 (see the code's own comment); not touched by this PR --- config/quality/file-size-baseline.json | 12 +- open-sse/handlers/chatCore.ts | 16 +- open-sse/translator/index.ts | 2 +- tests/unit/reasoning-cache.test.ts | 63 ++++- tests/unit/translator-helper-branches.test.ts | 237 +++++++++--------- 5 files changed, 195 insertions(+), 135 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 05e5743d85..e5bedb7004 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,8 @@ { - "_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.", + "_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.", + "_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.", + "_rebaseline_2026_08_08_9183_reasoning_cache_index_sync": "Extracted fix(responses-api): sync reasoning-cache write index with the fixed read side (from the originally-authored #9183) — chatCore.ts's write side cached every response under a hardcoded messageIndex:0, and translator/index.ts's plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site (a second, previously-undiscovered instance of the same hardcoding bug, found while re-verifying this fix against the current upstream tip — the two never agreed once a conversation went past its first assistant turn, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache). Own growth: open-sse/handlers/chatCore.ts 5034->5042 (+8, computing messageIndex from the incoming request's message count at both the streaming and non-streaming cache-write call sites) — irreducible call-site wiring. Covered by tests/unit/reasoning-cache.test.ts (new end-to-end write/read regression test, rebaselined below) and tests/unit/translator-helper-branches.test.ts fixture updates. Other #9183 sub-fixes (output_index collision prevention, reasoning-content-alias generalization) were originally assumed already superseded by upstream's own independent fix — a live incident 2026-08-08 disproved that for the message-vs-tool-call collision case specifically (fixed separately in #9822); not re-extracted here since this PR's own scope is the narrower messageIndex sync only.", + "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.", "_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).", @@ -162,6 +165,7 @@ "cap": 1000, "testCap": 1000, "testFrozen": { + "tests/unit/reasoning-cache.test.ts": 1035, "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", @@ -350,7 +354,7 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5034, + "open-sse/handlers/chatCore.ts": 5042, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1128, "open-sse/handlers/search.ts": 1536, @@ -388,7 +392,7 @@ "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119, "src/app/api/providers/[id]/models/route.ts": 2361, - "src/app/api/v1/models/catalog.ts": 1597, + "src/app/api/v1/models/catalog.ts": 1590, "src/lib/db/apiKeys.ts": 1529, "src/lib/db/core.ts": 1639, "src/lib/db/migrationRunner.ts": 1094, @@ -537,7 +541,7 @@ "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2148", "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1119", "src/app/api/providers/[id]/models/route.ts": "2361", - "src/app/api/v1/models/catalog.ts": "1597", + "src/app/api/v1/models/catalog.ts": "1590", "src/lib/tokenHealthCheck.ts": "1053", "src/lib/db/apiKeys.ts": "1529", "src/lib/db/core.ts": "1639", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..bc6559f0fe 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4333,9 +4333,14 @@ export async function handleChatCore({ try { const firstChoice = translatedResponse?.choices?.[0]; const msg = firstChoice?.message; + // The response being cached now will be replayed as history on the *next* + // turn, where the read side (translator/index.ts) keys the lookup by the + // message's real position in that future `messages` array — i.e. right + // after everything the client sent this turn. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the response @@ -4760,12 +4765,15 @@ export async function handleChatCore({ // with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.) if (normalizedStreamStatus === 200 && streamResponseBody) { try { - const body = streamResponseBody as Record; - const choices = body.choices as { message?: Record }[] | undefined; + const streamBody = streamResponseBody as Record; + const choices = streamBody.choices as { message?: Record }[] | undefined; const msg = choices?.[0]?.message; + // See the non-streaming capture above: messageIndex must match the + // position this message will occupy in the *next* turn's history. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the stream diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8497989f09..5e711f8391 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -570,7 +570,7 @@ export function translateRequest( const cacheKey = hasToolCalls ? msg.tool_calls[0]?.id - : getAssistantMessageCacheKey(result, 0); + : getAssistantMessageCacheKey(result, messageIndex); if (cacheKey) { const cached = lookupReasoning(cacheKey); if (cached) { diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index e913a03cc9..1f5b1ce55d 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -865,11 +865,12 @@ describe("Reasoning Replay Cache — Translator Replay", () => { }), }, }); - // NOTE: the non-tool-call cache key is built as `getAssistantMessageCacheKey(result, 0)` - // — the message index is hardcoded to 0 in the translator, so the key is always - // `request::message:0` regardless of the assistant message's actual position. + // The non-tool-call cache key is built as `getAssistantMessageCacheKey(result, messageIndex)` + // where messageIndex is the assistant message's real position in the `messages` + // array (index 1 here: user, assistant, user) — matching what the write side + // (chatCore.ts) now caches under once the response is generated. cacheReasoning( - "request:req-plain-1:message:0", + "request:req-plain-1:message:1", "deepseek", "deepseek-v4-pro", "Real cached plain-turn reasoning" @@ -899,6 +900,60 @@ describe("Reasoning Replay Cache — Translator Replay", () => { ); assert.equal(getReasoningCacheServiceStats().replays, 1); }); + + it("write side (chatCore's messageIndex) and read side (translateRequest) agree on the same key end-to-end", () => { + // Regression for a mismatch where chatCore.ts always cached under + // `messageIndex: 0` (the position of the response within *its own* choices + // array) while translateRequest's read side looked up the message's real + // position in the *next* turn's full history — the two never agreed once a + // conversation went past its first assistant turn, so replay silently + // fell back to the placeholder in real multi-turn usage. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-pro": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + // Turn 1: the incoming request has a single user message (length 1), so + // the assistant response chatCore is about to cache will occupy index 1 + // once it's appended to history for turn 2 — mirroring + // `messageIndex: bodyMessages.length` in chatCore.ts. + const turn1RequestBody = { messages: [{ role: "user", content: "hi" }] }; + cacheReasoningFromAssistantMessage( + { role: "assistant", content: "Hello! How can I help?", reasoning_content: "real reasoning" }, + "deepseek", + "deepseek-v4-pro", + { requestId: "req-e2e-1", messageIndex: turn1RequestBody.messages.length } + ); + + // Turn 2: client replays the full history including the cached assistant + // turn, now genuinely at index 1. + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-pro", + { + request_id: "req-e2e-1", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "tell me more" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal(translated.messages[1].reasoning_content, "real reasoning"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); }); describe("Reasoning Replay Cache — API Route", () => { diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 4f0f32cfb2..9626d99dbb 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -632,7 +632,7 @@ test("translateRequest replays cached reasoning-only messages when interleaved f }, }); cacheReasoningByKey( - "request:req_reasoning_only:message:0", + "request:req_reasoning_only:message:1", "deepseek", "deepseek-v4-flash", "cached reasoning only" @@ -690,138 +690,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(); +});