diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 9ad0dec86f8..a712c83158a 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -224,7 +224,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv let reasoningContent = thinkingParts.map(p => p.thinking).join(""); if ( reasoningContent.length === 0 - && toolCalls.length > 0 + && (toolCalls.length > 0 || thinkingParts.length > 0) && modelInList(provider.preserveReasoningContentModels, parsed.modelId) ) { const cached = toolCalls @@ -235,11 +235,11 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (cached.length > 0) { reasoningContent = [...new Set(cached)].join("\n"); } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) { - // Fallback (extends #950, closes #1193): the replay cache is + // Fallback (extends #950 and #1193; fixes #5421): the replay cache is // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on - // long sessions, and some tool rounds carry no recorded reasoning - // at all. DeepSeek thinking mode rejects ANY tool_call assistant - // message missing reasoning_content with HTTP 400, so inject a + // long sessions, and some thinking/tool rounds carry no recorded + // reasoning at all. DeepSeek thinking mode rejects replay without + // reasoning_content with HTTP 400, so inject a // minimal placeholder rather than emit a bare continuation the // upstream will reject. Scoped to requiresReasoningPlaceholderModels // (defaulting to the preserve list): preserve-listed providers with diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1c7465bde6e..3067863efe2 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -365,6 +365,10 @@ the desktop thinking band shows the "Thinking…" placeholder, and raw text appe which only fits native OpenAI providers that author real summaries. Diagnosis and codex-rs grouping evidence: `devlog/_fin/260709_native_response_pattern/`. +For models that require a reasoning placeholder, a preserved thinking-only assistant turn with no +plaintext receives that placeholder even when it has no tool call. Otherwise the Chat serializer +drops the turn and strict DeepSeek continuations can reject the following request (#5421). + The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the diff --git a/tests/providers/deepseek-reasoning-replay-gaps.test.ts b/tests/providers/deepseek-reasoning-replay-gaps.test.ts index 4e5c6ea3cae..2ccdd1e641f 100644 --- a/tests/providers/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/providers/deepseek-reasoning-replay-gaps.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { buildResponseJSON } from "../../src/bridge"; import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import { clearReasoningReplayCacheForTests, peekReasoningForCall as peekReasoningForCallRaw, @@ -129,6 +130,29 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(assistant!["reasoning_content"]).toBe(REASONING); }); + test("GAP F (issue #5421): signed thinking-only turn without tools gets a placeholder", () => { + const { messages } = wireFor([ + { + type: "reasoning", + id: "rs_empty", + summary: [], + encrypted_content: encodeReasoningEnvelope({ sig: "opaque-signature" }), + }, + { + type: "agent_message", + author: "parent", + recipient: "child", + content: [{ type: "input_text", text: "return OK" }], + }, + ]); + + const assistantIndex = messages.findIndex(message => message.role === "assistant"); + const taskIndex = messages.findIndex(message => message.role === "user" && message.content === "return OK"); + expect(assistantIndex).toBeGreaterThanOrEqual(0); + expect(assistantIndex).toBeLessThan(taskIndex); + expect(messages[assistantIndex]!["reasoning_content"]).toBe(" "); + }); + test("GAP A: reasoning item arriving AFTER its function_call is attached to its turn", () => { // Reconstructed histories (resume/retry/synthetic) may order the reasoning // item after the call it belongs to. The parser used to clear the pending @@ -287,6 +311,23 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) const miss = toolCallAssistant(missResult.wire.messages); expect(miss).toBeDefined(); expect(miss!["reasoning_content"]).toBeUndefined(); + // Signed thinking without plaintext also stays omitted for an explicit + // placeholder opt-out, even though the parser preserves the thinking turn. + const thinkingOnly = minimaxWire([ + { + type: "reasoning", + id: "rs_minimax_empty", + summary: [], + encrypted_content: encodeReasoningEnvelope({ sig: "opaque-signature" }), + }, + { + type: "agent_message", + author: "parent", + recipient: "child", + content: [{ type: "input_text", text: "return OK" }], + }, + ]).wire.messages; + expect(thinkingOnly.some(message => message.role === "assistant")).toBeFalse(); // Cache hit on the same path: the recorded reasoning still replays. rememberReasoningForCall("call_1", REASONING, missResult.replayScope); const hit = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()]).wire.messages);