Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function bridgeToResponsesSSE(
};
},
): ReadableStream<Uint8Array> {
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<typeof setInterval>));
// Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
Expand Down Expand Up @@ -1364,7 +1364,7 @@ function buildResponseJSONWithBudget(
},
): Record<string, unknown> {
const responseId = `resp_${uuid()}`;
const replayCacheScope = options?.replayCacheScope ?? "global";
const replayCacheScope = options?.replayCacheScope;
const output: OutputItem[] = [];
const budget = options?.translatorBudget;
const encoder = new TextEncoder();
Expand Down
2 changes: 1 addition & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}, 2_000,
{
translatorBudget,
replayCacheScope: parsed._clientThreadId ?? "global",
replayCacheScope: parsed._clientThreadId,
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
hideThinkingSummary: parsed.options.hideThinkingSummary,
stallTimeoutSec: deps.stallTimeoutSec,
Expand Down
11 changes: 7 additions & 4 deletions src/responses/reasoning-replay-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ let totalBytes = 0;
let clockForTests: (() => 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.
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
}, undefined,
{
translatorBudget,
replayCacheScope: parsed._clientThreadId ?? "global",
replayCacheScope: parsed._clientThreadId,
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
hideThinkingSummary: parsed.options.hideThinkingSummary,
...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
Expand Down
15 changes: 8 additions & 7 deletions tests/bridge-raw-reasoning-hidden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ async function collectSse(stream: ReadableStream<Uint8Array>): 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(() => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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();
});
});
17 changes: 14 additions & 3 deletions tests/deepseek-reasoning-replay-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -43,6 +48,7 @@ function configFor(): OcxConfig {

function wireFor(input: unknown[]): { messages: Array<Record<string, unknown>> } {
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);
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading