Files
OmniRoute/tests/unit/chatcore-reasoning-cache-write-guard-responses.test.ts
James 3ebea07278 fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients (#13031)
* fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients

DeepSeek thinking mode requires the reasoning of every prior assistant turn
to be passed back once the request carries `tools`, including turns that
made no tool call. Since #10540 routed opencode-go/deepseek-v4-* to
`/responses`, the reasoning replay cache had two gaps on Responses-API
targets, and clients that drop `reasoning_content` hit intermittent
`400 The reasoning_text in the thinking mode must be passed back`.

1. Plain (non-tool-call) turns are keyed on a digest of the normalized
   OpenAI transcript. Both capture sites used `translatedBody.messages` as
   the history, which a Responses body (`input`) does not carry, so the
   write-time digest never matched the read side. translateRequest now
   reports the pivot transcript it digested via `onReasoningReplayHistory`,
   and the streaming / non-streaming capture sites digest that transcript.
2. The Responses replay pass was gated on `sourceFormat === "openai"`, so
   Anthropic Messages clients (Claude -> OpenAI -> Responses) got no replay
   at all. The pass now runs on the OpenAI pivot for every source format,
   right before the Responses conversion discards `messages`.

The reported transcript is a shallow snapshot of the digested fields only
and travels through a callback, not the body, so nothing new reaches the
upstream payload.

* docs(changelog): add fragment for #13031

* fix(sse): guard the Responses capture sites and skip plain-turn writes with no history

Review follow-ups for #13031:

- Add tests/unit/chatcore-reasoning-cache-write-guard-responses.test.ts:
  runs the real handleChatCore against a mocked opencode-go/deepseek-v4-flash
  Responses upstream (JSON and SSE), then asserts the next turn's upstream
  body carries the replayed `reasoning` input item. Removing either capture
  site fallback turns both cases red.
- Project the reported transcript down to the digested fields only
  (tool_calls keep type/name/arguments, ids are dropped) and document that
  `content` is shared by reference.
- Skip the plain-turn cache write when the history is empty: a real request
  always has a prior user turn, so an empty history means the transcript
  could not be recovered and a one-message digest can never match.
- Changelog wording: the pre-fix write digested only the assistant message.

* test(sse): select the /responses dispatch by URL in the Responses replay guard

Review follow-ups for #13031: the guard picks the upstream body by URL
(`/responses`) and asserts exactly one such dispatch per turn instead of
taking the last fetch, the streaming case asserts the same body shape as the
non-streaming one, and the `historyMessages` doc on
NonStreamingClientTranslateInput names the Responses-shaped fallback.

* docs(routing): name the replay-history hand-off without tripping the hook heuristic

The fabricated-docs gate treats any `onXxx` token in prose as a plugin hook
name and flagged `onReasoningReplayHistory` (a translateRequest option, not a
hook). Point at the option's home file instead.

* chore(quality): freeze chatCore.ts at 6159 for the Responses replay wiring

check:file-size in PR mode caps a frozen file at max(frozen, base). The rebase onto
the v3.8.51 tip (cde49c937) leaves chatCore.ts at 6159 lines against a 6146 ceiling:
the onReasoningReplayHistory callback on both Responses-capable translateRequest call
sites, reasoningReplayHistory on both non-streaming leg inputs, and the historyMessages
fallback at the streaming cache write. Record the growth with a justification key, as
#13033 did for the same file.

---------

Co-authored-by: jmche <jmche@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:59:06 -03:00

269 lines
9.0 KiB
TypeScript

// Integration guard for the reasoning-cache write path on a Responses-API target.
//
// tests/unit/chatcore-reasoning-cache-write-guard.test.ts proves the capture sites
// fire for a Chat-format upstream (xiaomi-mimo) and deliberately avoids DeepSeek
// because its wire format is openai-responses. This file covers exactly that lane:
// opencode-go/deepseek-v4-flash is dispatched to `/responses`, so the translated
// upstream body carries `input`, not `messages`, and the plain-turn cache key must
// be built from the pivot transcript `translateRequest` reports instead.
//
// Same convention as the sibling file: mock fetch, call the real handleChatCore,
// assert real behavior — here the observable behavior is the SECOND turn's upstream
// request body: a client that replays history without `reasoning_content` must
// still produce a `reasoning` input item ahead of the assistant message, or
// DeepSeek answers `400 The reasoning_text in the thinking mode must be passed back`.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-chatcore-reasoning-cache-write-guard-responses-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { clearReasoningCacheAll } = await import("../../open-sse/services/reasoningCache.ts");
const core = await import("../../src/lib/db/core.ts");
type JsonRecord = Record<string, unknown>;
const PROVIDER = "opencode-go";
const MODEL = "deepseek-v4-flash";
const REASONING = "The user only wants a greeting, so no tool is needed.";
const TOOLS = [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: { type: "object", properties: { path: { type: "string" } } },
},
},
];
function noopLog() {
return { debug() {}, info() {}, warn() {}, error() {} };
}
function completedResponse() {
return {
id: "resp_reasoning_guard",
object: "response",
status: "completed",
model: MODEL,
output: [
{
type: "reasoning",
id: "rs_reasoning_guard",
summary: [],
content: [{ type: "reasoning_text", text: REASONING }],
},
{
type: "message",
id: "msg_reasoning_guard",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hi!", annotations: [] }],
},
],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
};
}
function nonStreamingUpstreamResponse() {
return new Response(JSON.stringify(completedResponse()), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function streamingUpstreamResponse() {
const completed = completedResponse();
const events: Array<[string, JsonRecord]> = [
[
"response.created",
{ type: "response.created", response: { ...completed, status: "in_progress", output: [] } },
],
[
"response.output_item.added",
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "rs_reasoning_guard", summary: [], content: [] },
},
],
[
"response.reasoning_text.delta",
{
type: "response.reasoning_text.delta",
item_id: "rs_reasoning_guard",
output_index: 0,
content_index: 0,
delta: REASONING,
},
],
[
"response.output_item.done",
{ type: "response.output_item.done", output_index: 0, item: completed.output[0] },
],
[
"response.output_item.added",
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "message",
id: "msg_reasoning_guard",
role: "assistant",
status: "in_progress",
content: [],
},
},
],
[
"response.output_text.delta",
{
type: "response.output_text.delta",
item_id: "msg_reasoning_guard",
output_index: 1,
content_index: 0,
delta: "Hi!",
},
],
[
"response.output_item.done",
{ type: "response.output_item.done", output_index: 1, item: completed.output[1] },
],
["response.completed", { type: "response.completed", response: completed }],
];
const sseBody = events
.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
.join("");
return new Response(sseBody, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
async function drain(result: { success?: boolean; response?: Response }) {
if (!result.success || !result.response) return;
if (result.response.body) {
const reader = result.response.body.getReader();
for (;;) {
const { done } = await reader.read();
if (done) break;
}
} else {
try {
await result.response.text();
} catch {}
}
await new Promise((resolve) => setImmediate(resolve));
}
/**
* Runs one client turn through the real handleChatCore against a mocked upstream and
* returns the JSON body OmniRoute sent upstream.
*/
async function runTurn(
session: string,
messages: unknown[],
stream: boolean,
upstream: () => Response
): Promise<JsonRecord> {
const originalFetch = globalThis.fetch;
const sent: Array<{ url: string; body: JsonRecord }> = [];
globalThis.fetch = (async (input: unknown, init?: { body?: unknown }) => {
const url = String((input as { url?: unknown })?.url ?? input);
if (typeof init?.body === "string") {
sent.push({ url, body: JSON.parse(init.body) as JsonRecord });
}
return upstream();
}) as typeof fetch;
try {
const body = { model: MODEL, messages, tools: TOOLS, stream };
const result = await handleChatCore({
body,
modelInfo: { provider: PROVIDER, model: MODEL, extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/chat/completions",
body,
headers: new Headers({ accept: stream ? "text/event-stream" : "application/json" }),
},
userAgent: "unit-test",
sessionAffinityKey: session,
} as never);
await drain(result as { success?: boolean; response?: Response });
} finally {
globalThis.fetch = originalFetch;
}
// Select the Responses-lane dispatch by URL rather than "last fetch", so an
// unrelated POST inside the handler window can neither satisfy nor break this.
const responsesCalls = sent.filter((call) => /\/responses(\?|$)/.test(call.url));
assert.equal(responsesCalls.length, 1, `exactly one /responses dispatch (saw ${sent.length})`);
return responsesCalls[0].body;
}
function reasoningTexts(input: unknown): string[] {
return (Array.isArray(input) ? input : [])
.filter((item) => (item as JsonRecord)?.type === "reasoning")
.map((item) =>
((item as JsonRecord).content as Array<{ text?: string }>)
.map((part) => part.text ?? "")
.join("")
);
}
const TURN_1 = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Say hi first, no tools." },
];
const TURN_2 = [
...TURN_1,
// The client replays the plain assistant turn WITHOUT reasoning_content.
{ role: "assistant", content: "Hi!" },
{ role: "user", content: "Now read README.md" },
];
test.after(() => {
try {
core.resetDbInstance();
} catch {}
try {
clearReasoningCacheAll();
} catch {}
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("non-streaming: a plain turn captured from a Responses upstream is replayed on the next turn", async () => {
clearReasoningCacheAll();
const session = "reasoning-cache-write-guard-responses-nonstream";
const first = await runTurn(session, TURN_1, false, nonStreamingUpstreamResponse);
assert.ok(Array.isArray(first.input), "turn 1 went upstream as a Responses body");
assert.equal(first.messages, undefined);
const second = await runTurn(session, TURN_2, false, nonStreamingUpstreamResponse);
const input = second.input as JsonRecord[];
assert.deepEqual(reasoningTexts(input), [REASONING]);
const reasoningIndex = input.findIndex((item) => item.type === "reasoning");
const assistantIndex = input.findIndex(
(item) => item.type === "message" && item.role === "assistant"
);
assert.ok(reasoningIndex >= 0 && reasoningIndex < assistantIndex);
});
test("streaming: a plain turn captured from a Responses SSE upstream is replayed on the next turn", async () => {
clearReasoningCacheAll();
const session = "reasoning-cache-write-guard-responses-stream";
const first = await runTurn(session, TURN_1, true, streamingUpstreamResponse);
assert.ok(Array.isArray(first.input), "turn 1 went upstream as a Responses body");
assert.equal(first.messages, undefined);
const second = await runTurn(session, TURN_2, false, nonStreamingUpstreamResponse);
assert.deepEqual(reasoningTexts(second.input), [REASONING]);
});