Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/adapters/openai-chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/providers/deepseek-reasoning-replay-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading