diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 577d268d2b..ff7bb4fdfd 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -198,7 +198,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon const { context, options } = parsed; // Mirror the bridge's replay-cache scope (issue #950): provider call ids are // not globally unique, so reasoning must not cross conversation boundaries. - const replayCacheScope = parsed._clientThreadId ?? "global"; + const replayCacheScope = parsed._clientThreadId; // 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening): // strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered diff --git a/src/bridge.ts b/src/bridge.ts index 45f046f424..a34ed6dbcd 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -197,7 +197,7 @@ export function bridgeToResponsesSSE( }; }, ): ReadableStream { - const replayCacheScope = options?.replayCacheScope ?? "global"; + const replayCacheScope = options?.replayCacheScope; const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms)); const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType)); // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a @@ -1364,7 +1364,7 @@ function buildResponseJSONWithBudget( }, ): Record { const responseId = `resp_${uuid()}`; - const replayCacheScope = options?.replayCacheScope ?? "global"; + const replayCacheScope = options?.replayCacheScope; const output: OutputItem[] = []; const budget = options?.translatorBudget; const encoder = new TextEncoder(); diff --git a/src/images/loop.ts b/src/images/loop.ts index e1eb47f089..258b301719 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -905,7 +905,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise number) | null = null; const now = (): number => clockForTests?.() ?? Date.now(); -const keyFor = (callId: string, scope: string | undefined): string => - `${scope ?? "global"}\u0000${callId}`; +const keyFor = (callId: string, scope: string): string => `${scope}\u0000${callId}`; /** * Record the raw reasoning text that preceded the given tool call. @@ -44,7 +43,11 @@ const keyFor = (callId: string, scope: string | undefined): string => * id is never read again. */ export function rememberReasoningForCall(callId: string, text: string, scope?: string): void { - if (!callId || typeof text !== "string" || text.length === 0) return; + // Never fall back to a process-wide namespace. Call ids are supplied by + // clients/providers and are therefore neither unique nor trustworthy; an + // unscoped entry could be recovered by an unrelated request that reuses the + // same id. + if (!scope || !callId || typeof text !== "string" || text.length === 0) return; const bytes = Buffer.byteLength(text, "utf8"); // A single entry larger than the whole budget would immediately evict itself. if (bytes > MAX_TOTAL_BYTES) return; @@ -85,7 +88,7 @@ export function rememberReasoningForCall(callId: string, text: string, scope?: s * a failed continuation reuse the same fallback. */ export function peekReasoningForCall(callId: string, scope?: string): string | undefined { - if (!callId) return undefined; + if (!scope || !callId) return undefined; const key = keyFor(callId, scope); const entry = entries.get(key); if (!entry) return undefined; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d00ab0c43..767047c3a1 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2567,7 +2567,7 @@ async function handleResponsesInner( }, 2_000, { translatorBudget, - replayCacheScope: parsed._clientThreadId ?? "global", + replayCacheScope: parsed._clientThreadId, ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, @@ -2614,7 +2614,7 @@ async function handleResponsesInner( let providerState: OcxProviderContinuationState | undefined; const json = buildResponseJSON(events, parsed.modelId, { translatorBudget, - replayCacheScope: parsed._clientThreadId ?? "global", + replayCacheScope: parsed._clientThreadId, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, freeformToolNames, @@ -3258,7 +3258,7 @@ async function handleResponsesInner( () => upstream.abort(), 2_000, { translatorBudget, - replayCacheScope: parsed._clientThreadId ?? "global", + replayCacheScope: parsed._clientThreadId, ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, @@ -3316,7 +3316,7 @@ async function handleResponsesInner( let providerState: OcxProviderContinuationState | undefined; const json = buildResponseJSON(events, parsed.modelId, { translatorBudget, - replayCacheScope: parsed._clientThreadId ?? "global", + replayCacheScope: parsed._clientThreadId, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, freeformToolNames, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index ce4e4eb45a..3c00871252 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -779,7 +779,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise): Promise<{ event?: }); } -const sseOpts = (hide: boolean) => ({ hideThinkingSummary: hide }); +const REPLAY_SCOPE = "test-thread"; +const sseOpts = (hide: boolean) => ({ hideThinkingSummary: hide, replayCacheScope: REPLAY_SCOPE }); describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_delta)", () => { beforeEach(() => { @@ -151,8 +152,8 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del { type: "tool_call_end" }, { type: "done" }, ]), "routed/model", undefined, undefined, undefined, undefined, undefined, sseOpts(true))); - expect(peekReasoningForCall("call_1")).toBe("chain of thought"); - expect(peekReasoningForCall("call_other")).toBeUndefined(); + expect(peekReasoningForCall("call_1", REPLAY_SCOPE)).toBe("chain of thought"); + expect(peekReasoningForCall("call_other", REPLAY_SCOPE)).toBeUndefined(); }); test("non-streaming hidden: raw reasoning is recorded for the following tool call", () => { @@ -162,8 +163,8 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, { type: "done" }, - ], "routed/model", { hideThinkingSummary: true }); - expect(peekReasoningForCall("call_2")).toBe("quiet"); + ], "routed/model", { hideThinkingSummary: true, replayCacheScope: REPLAY_SCOPE }); + expect(peekReasoningForCall("call_2", REPLAY_SCOPE)).toBe("quiet"); }); test("raw reasoning consumed by a text turn is NOT cached for a later tool call", async () => { @@ -175,7 +176,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del { type: "tool_call_end" }, { type: "done" }, ]), "routed/model", undefined, undefined, undefined, undefined, undefined, sseOpts(true))); - expect(peekReasoningForCall("call_later")).toBeUndefined(); + expect(peekReasoningForCall("call_later", REPLAY_SCOPE)).toBeUndefined(); }); test("hidden thinking_delta clears raw reasoning pending for a later tool call", async () => { @@ -187,6 +188,6 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del { type: "tool_call_end" }, { type: "done" }, ]), "routed/model", undefined, undefined, undefined, undefined, undefined, sseOpts(true))); - expect(peekReasoningForCall("call_after_thinking")).toBeUndefined(); + expect(peekReasoningForCall("call_after_thinking", REPLAY_SCOPE)).toBeUndefined(); }); }); diff --git a/tests/deepseek-reasoning-replay-gaps.test.ts b/tests/deepseek-reasoning-replay-gaps.test.ts index 74787adc52..5ac4a7c841 100644 --- a/tests/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/deepseek-reasoning-replay-gaps.test.ts @@ -3,8 +3,8 @@ import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { parseRequest } from "../src/responses/parser"; import { clearReasoningReplayCacheForTests, - peekReasoningForCall, - rememberReasoningForCall, + peekReasoningForCall as peekReasoningForCallRaw, + rememberReasoningForCall as rememberReasoningForCallRaw, } from "../src/responses/reasoning-replay-cache"; import { routeModel } from "../src/router"; import type { OcxConfig, OcxParsedRequest } from "../src/types"; @@ -25,6 +25,11 @@ import type { OcxConfig, OcxParsedRequest } from "../src/types"; const MODEL = "opencode-go/deepseek-v4-flash"; const REASONING = "I need to inspect files before answering."; +const REPLAY_SCOPE = "test-thread"; +const rememberReasoningForCall = (callId: string, text: string, scope = REPLAY_SCOPE): void => + rememberReasoningForCallRaw(callId, text, scope); +const peekReasoningForCall = (callId: string, scope = REPLAY_SCOPE): string | undefined => + peekReasoningForCallRaw(callId, scope); function configFor(): OcxConfig { return { @@ -43,6 +48,7 @@ function configFor(): OcxConfig { function wireFor(input: unknown[]): { messages: Array> } { const parsed = parseRequest({ model: MODEL, input, stream: true }); + parsed._clientThreadId = REPLAY_SCOPE; const route = routeModel(configFor(), parsed.modelId); parsed.modelId = route.modelId; const req = createOpenAIChatAdapter(route.provider).buildRequest(parsed as OcxParsedRequest); @@ -173,7 +179,12 @@ describe("issue #950 — reasoning replay cache bounds", () => { expect(peekReasoningForCall("call_1", "thread-a")).toBe("thread alpha reasoning"); expect(peekReasoningForCall("call_1", "thread-b")).toBe("thread beta reasoning"); // An unscoped read must not see either scoped entry. - expect(peekReasoningForCall("call_1")).toBeUndefined(); + expect(peekReasoningForCallRaw("call_1")).toBeUndefined(); + }); + + test("unscoped entries are rejected instead of sharing a process-wide namespace", () => { + rememberReasoningForCallRaw("call_collision", "private reasoning"); + expect(peekReasoningForCallRaw("call_collision")).toBeUndefined(); }); test("entries expire after the TTL", () => {