diff --git a/changelog.d/features/12905-dsml-tool-call-markers-adaptive-thinking.md b/changelog.d/features/12905-dsml-tool-call-markers-adaptive-thinking.md new file mode 100644 index 0000000000..787db0160f --- /dev/null +++ b/changelog.d/features/12905-dsml-tool-call-markers-adaptive-thinking.md @@ -0,0 +1 @@ +- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru diff --git a/changelog.d/fixes/12905-directive-preamble-strip-analysis-preserve.md b/changelog.d/fixes/12905-directive-preamble-strip-analysis-preserve.md new file mode 100644 index 0000000000..2638365887 --- /dev/null +++ b/changelog.d/fixes/12905-directive-preamble-strip-analysis-preserve.md @@ -0,0 +1 @@ +- **fix(sse):** strip echoed system/directive preamble on /v1/messages responses and preserve large analysis/summary blocks in systemPreambleStripper to stop autocompact empty-response ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru diff --git a/changelog.d/fixes/12905-nonstream-thinking-intent-parity.md b/changelog.d/fixes/12905-nonstream-thinking-intent-parity.md new file mode 100644 index 0000000000..f8f2a4e2fc --- /dev/null +++ b/changelog.d/fixes/12905-nonstream-thinking-intent-parity.md @@ -0,0 +1 @@ +- **fix(sse):** thread the client's thinking intent into the non-streaming translation path so the same request answered with `stream:false` no longer leaks a thinking block that `stream:true` withholds ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru diff --git a/changelog.d/fixes/12905-reasoning-gate-thinking-leak.md b/changelog.d/fixes/12905-reasoning-gate-thinking-leak.md new file mode 100644 index 0000000000..f3524acd32 --- /dev/null +++ b/changelog.d/fixes/12905-reasoning-gate-thinking-leak.md @@ -0,0 +1 @@ +- **fix(sse):** gate thinking block emission on requestedThinking (streaming + non-stream) and flush reasoning-only responses as text to stop reasoning leak, autocompact loops, and 502 ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru diff --git a/changelog.d/fixes/12905-system-preamble-opt-in-and-flush.md b/changelog.d/fixes/12905-system-preamble-opt-in-and-flush.md new file mode 100644 index 0000000000..e8aa911d1e --- /dev/null +++ b/changelog.d/fixes/12905-system-preamble-opt-in-and-flush.md @@ -0,0 +1 @@ +- **fix(sse):** make the system-preamble stripper opt-in (`OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1`) and flush both preamble strippers at stream end, so English-prose heuristics no longer delete a legitimate section of every openai→claude reply and an unterminated echo block no longer reaches the client as an empty message ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 329c0b9da9..c9e23402b7 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -247,6 +247,7 @@ export function translateSseResponse( suppressThinkClose, undefined, undefined, + undefined, GLM_STREAM_BUFFER_BYTES ); const headers = cloneHeaders(response.headers); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9eb2a3e126..82a0aa7554 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -145,6 +145,7 @@ import { ensureStreamReadiness } from "../utils/streamReadiness.ts"; import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts"; import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts"; import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts"; +import { hasActiveClaudeThinking } from "../utils/thinkingBudget.ts"; import { createStreamController } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { normalizeUsage } from "../utils/usageTracking.ts"; @@ -6088,6 +6089,19 @@ export async function handleChatCore({ !isDroidCLI; const streamStateBody = finalBody || body; + // Client's explicit thinking intent (Anthropic Messages shape). Claude Code + // sends `{type:"enabled"}` or `{type:"adaptive"}` to opt into relaying + // upstream reasoning_content as Claude thinking blocks; `{type:"disabled"}` + // or an omitted `thinking` field opts out. Kept false for every other + // client schema (OpenAI / Responses), which never express intent through + // `body.thinking`. Mirrors hasActiveClaudeThinking() so the request and + // response sides agree on what counts as "thinking requested" — a prior + // inline `=== "enabled"` check silently suppressed `adaptive` (the intent + // Claude Code actually sends), leaking the mismatch as a broken tool-call + // turn (call log 1787566395384-bab9ab: reasoning dropped → model emitted + // DSML tool-call markers as plain text → incomplete `stop` finish). + const requestedThinking = hasActiveClaudeThinking((body ?? {}) as Record); + if (needsResponsesTranslation) { // Provider returns openai-responses, translate to openai (Chat Completions) that clients expect log?.debug?.("STREAM", `Responses translation mode: openai-responses → openai`); @@ -6105,6 +6119,7 @@ export async function handleChatCore({ handleStreamFailure, copilotCompatibleReasoning, false, + requestedThinking, customToolNames, // openai-responses → openai translation still wants the namespace identity // map for #7936-style round-trip closure when the client also speaks @@ -6138,6 +6153,7 @@ export async function handleChatCore({ thinkingMarkerHeader, clientResponseFormat, }), + requestedThinking, customToolNames, requestToolIdentityMap ); diff --git a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts index c861ef65c4..1888d6c11c 100644 --- a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts +++ b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts @@ -59,6 +59,7 @@ export function translateNonStreamingClientResponse( reasoningCacheScope, clientHeaders, isClaudeCodeCompatible, + requestedThinking, phase, } = input; @@ -73,7 +74,8 @@ export function translateNonStreamingClientResponse( responsePayloadFormat, clientResponseFormat, responseToolNameMap, - responseToolSchemas + responseToolSchemas, + requestedThinking ) : responseBody; const responseForMemoryExtraction = translatedResponse; diff --git a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts index ba17639af9..15d3bf3e7f 100644 --- a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts +++ b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts @@ -35,6 +35,7 @@ import { } from "../../services/modelFamilyFallback.ts"; import { isEmptyContentResponse } from "../../services/errorClassifier.ts"; import { FORMATS } from "../../translator/formats.ts"; +import { hasActiveClaudeThinking } from "../../utils/thinkingBudget.ts"; /* -- exported types -------------------------------------------------------- */ @@ -287,6 +288,11 @@ function finishOk( reasoningCacheScope: input.reasoningCacheScope ?? null, clientHeaders: input.clientHeaders ?? null, isClaudeCodeCompatible: input.isClaudeCodeCompatible ?? false, + // Same intent the streaming path computes in chatCore before building the + // SSE transform. Threading it here is what makes `stream:false` and + // `stream:true` agree on whether upstream reasoning may surface as a + // thinking block; the non-streaming converter used to never receive it. + requestedThinking: hasActiveClaudeThinking(input.sourceBody ?? {}), phase: input.phase === "initial" ? "final" : "intermediate", }); const receipt = buildReceipt(input, { diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 07c55e964f..5ec01711d6 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -148,21 +148,24 @@ export function translateNonStreamingResponse( targetFormat: string, sourceFormat: string, toolNameMap?: Map | null, - toolSchemas?: Map | null + toolSchemas?: Map | null, + requestedThinking?: boolean ): JsonRecord; export function translateNonStreamingResponse( responseBody: unknown, targetFormat: string, sourceFormat: string, toolNameMap?: Map | null, - toolSchemas?: Map | null + toolSchemas?: Map | null, + requestedThinking?: boolean ): unknown; export function translateNonStreamingResponse( responseBody: unknown, targetFormat: string, sourceFormat: string, toolNameMap?: Map | null, - toolSchemas?: Map | null + toolSchemas?: Map | null, + requestedThinking?: boolean ): unknown { // If already in source format, return as-is if (targetFormat === sourceFormat) { @@ -675,7 +678,11 @@ export function translateNonStreamingResponse( // Phase 3: Translate from OpenAI back to Client Source format if (sourceFormat === FORMATS.CLAUDE && sourceFormat !== targetFormat) { - return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI), toolNameMap ?? null); + return convertOpenAINonStreamingToClaude( + toRecord(intermediateOpenAI), + toolNameMap ?? null, + requestedThinking + ); } // Gemini-family clients (Gemini, Antigravity): the streaming SSE path already @@ -721,7 +728,8 @@ function resolveReasoningText(messageObj: JsonRecord): string { */ function convertOpenAINonStreamingToClaude( openaiResponse: JsonRecord, - toolNameMap?: Map | null + toolNameMap?: Map | null, + requestedThinking?: boolean ): JsonRecord { const choices = openaiResponse.choices as unknown[] | undefined; const isChoicesArray = Array.isArray(choices); @@ -738,7 +746,16 @@ function convertOpenAINonStreamingToClaude( let hasTextOrReasoning = false; const reasoningText = resolveReasoningText(messageObj); - if (reasoningText) { + // `requestedThinking === false` (client explicitly opted out): mirror the + // streaming translator's gate. When ordinary content is present, reasoning + // is suppressed entirely (no thinking leak). When the response is + // reasoning-ONLY (empty content — the GLM-5.2 autocompact pattern), relay + // reasoning as an ordinary text block so the response is not empty (no 502) + // and no thinking block leaks to a thinking-opt-out client. + // `requestedThinking === undefined` (legacy callers that do not pass it) + // keeps the original "always a thinking block" relay. + const suppressThinking = requestedThinking === false; + if (reasoningText && !suppressThinking) { hasTextOrReasoning = true; content.push({ type: "thinking", @@ -756,6 +773,16 @@ function convertOpenAINonStreamingToClaude( type: "text", text: resolvedText === "" ? "(empty response)" : resolvedText, }); + } else if (suppressThinking && reasoningText) { + // Reasoning-ONLY response with thinking opted out (requestedThinking===false): + // no ordinary content, reasoning suppressed above. Relay the reasoning text as + // an ordinary text block so the response is not empty (no 502) and no thinking + // block leaks — mirrors the streaming translator's finish-time fallback. + hasTextOrReasoning = true; + content.push({ + type: "text", + text: reasoningText, + }); } else if (!hasTextOrReasoning) { content.push({ type: "text", diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index eda968bae1..bf0bc91da9 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -889,6 +889,11 @@ export function initState(sourceFormat) { finishReasonSent: false, usage: null, contentBlockIndex: -1, + // Client thinking intent threaded from the request side. The response + // translator only relays upstream reasoning (thinking blocks) when the + // client explicitly opted in — otherwise DeepSeek/GLM reasoning_content + // would leak into the UI as a thinking block it never asked for. + requestedThinking: false, }; // Add openai-responses specific fields diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 5ecdd2348a..f00aabdfec 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -11,6 +11,11 @@ import { import { REVERSE_MAP, restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { splitMarkdownBoundary } from "../helpers/markdownBoundary.ts"; +import { hasDsmlToolCalls, parseDsmlToolCalls } from "../../utils/dsmlToolCalls.ts"; +import { + createDirectivePreambleStripper, + createSystemPreambleStripper, +} from "../../utils/directivePreambleStripper.ts"; function normalizeToolName(name: string): string { return REVERSE_MAP[name] ?? name; @@ -47,10 +52,22 @@ function extractXmlInvokeBlocks( const toolCallTextMatch = remaining.match(/TOOL_CALL\s+([A-Za-z0-9_]+):\s*/); const matches = [ - invokeMatch ? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch } : null, - toolCallTagMatch ? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch } : null, - toolCallTextMatch ? { type: "tool_call_text" as const, index: toolCallTextMatch.index!, data: toolCallTextMatch } : null, - ].filter(Boolean).sort((a, b) => a!.index - b!.index); + invokeMatch + ? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch } + : null, + toolCallTagMatch + ? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch } + : null, + toolCallTextMatch + ? { + type: "tool_call_text" as const, + index: toolCallTextMatch.index!, + data: toolCallTextMatch, + } + : null, + ] + .filter(Boolean) + .sort((a, b) => a!.index - b!.index); if (matches.length === 0) { cleaned += remaining; @@ -95,9 +112,7 @@ function extractXmlInvokeBlocks( const name = (parsed.name || parsed.tool_name || "") as string; const rawArgs = parsed.arguments || parsed.args || parsed.parameters || {}; const args: Record = - typeof rawArgs === "string" - ? JSON.parse(rawArgs) - : (rawArgs as Record); + typeof rawArgs === "string" ? JSON.parse(rawArgs) : (rawArgs as Record); if (name) { toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name, args }); } @@ -115,12 +130,27 @@ function extractXmlInvokeBlocks( let jsonEndIndex = -1; for (let i = 0; i < afterPrefix.length; i++) { const c = afterPrefix[i]; - if (escape) { escape = false; continue; } - if (c === "\\" && inString) { escape = true; continue; } - if (c === '"') { inString = !inString; continue; } + if (escape) { + escape = false; + continue; + } + if (c === "\\" && inString) { + escape = true; + continue; + } + if (c === '"') { + inString = !inString; + continue; + } if (!inString) { if (c === "{") depth++; - else if (c === "}") { depth--; if (depth === 0) { jsonEndIndex = i + 1; break; } } + else if (c === "}") { + depth--; + if (depth === 0) { + jsonEndIndex = i + 1; + break; + } + } } } if (jsonEndIndex === -1) { @@ -274,6 +304,7 @@ export function openaiToClaudeResponse(chunk, state) { state.model = chunk.model || "unknown"; state.nextBlockIndex = 0; state._pendingXmlToolCalls = []; + state._dsmlHoldback = undefined; state._xmlInvokeBuffer = ""; state._markdownBuffer = ""; state._markdownCodeSpanRun = 0; @@ -310,28 +341,43 @@ export function openaiToClaudeResponse(chunk, state) { } if (parts.length > 0) reasoningContent = parts.join(""); } - if ( + const hasReasoning = typeof reasoningContent === "string" && reasoningContent !== "" && - !isInternalReasoningPlaceholder(reasoningContent) - ) { - stopTextBlock(state, results); + !isInternalReasoningPlaceholder(reasoningContent); + if (hasReasoning) { + // Re-gate the thinking block EMISSION on requestedThinking === true. The + // _reasoningAccum accumulation below stays OUTSIDE the gate and always runs, + // so fix B still synthesizes a text block for reasoning-only responses (no + // 502, compact applies). Gating the whole block including accumulation + // breaks fix B => 502/compact loop. + if (state.requestedThinking === true) { + stopTextBlock(state, results); + + if (!state.thinkingBlockStarted) { + state.thinkingBlockIndex = state.nextBlockIndex++; + state.thinkingBlockStarted = true; + results.push({ + type: "content_block_start", + index: state.thinkingBlockIndex, + content_block: { type: "thinking", thinking: "" }, + }); + } - if (!state.thinkingBlockStarted) { - state.thinkingBlockIndex = state.nextBlockIndex++; - state.thinkingBlockStarted = true; results.push({ - type: "content_block_start", + type: "content_block_delta", index: state.thinkingBlockIndex, - content_block: { type: "thinking", thinking: "" }, + delta: { type: "thinking_delta", thinking: reasoningContent }, }); } - results.push({ - type: "content_block_delta", - index: state.thinkingBlockIndex, - delta: { type: "thinking_delta", thinking: reasoningContent }, - }); + // FIX B: accumulate the reasoning text so the finish handler can synthesize + // a text content block when the response ends reasoning-only (no ordinary + // content block). Claude Code's autocompact parser extracts the summary from + // a TEXT content block — a thinking block alone is judged "empty response" + // and the compact is rejected, looping the session. When real content DOES + // arrive, it starts its own text block and this buffer is simply ignored. + state._reasoningAccum = (state._reasoningAccum || "") + reasoningContent; } // Handle regular content — strip the internal reasoning placeholder if @@ -341,24 +387,86 @@ export function openaiToClaudeResponse(chunk, state) { if (delta?.content) { const strippedContent = stripInternalReasoningPlaceholder(delta.content); if (strippedContent) { - stopThinkingBlock(state, results); + // #reasoning-bilingual response side: DeepSeek-V4 and similar models echo the + // OMNIROUTE_SYSTEM_INSTRUCTION_APPEND directive (appended to the system tail by + // claude-to-openai.ts) verbatim at the START of their reply — the "system message + // leak" the operator reports. When the directive is configured, run the stream's + // first text chunk(s) through a preamble stripper so a leading reproduction is + // dropped before it reaches the client. + const directive = process.env.OMNIROUTE_SYSTEM_INSTRUCTION_APPEND?.trim(); + if (directive) { + state._directiveStripper ??= createDirectivePreambleStripper(directive); + } + // #reasoning-bilingual response side (Phase B): DeepSeek-V4 and similar models + // may also echo whole chunks of the system prompt at the START of their reply — + // // blocks or prose reproductions of the + // superpowers skill section. Chained after the exact-directive stripper. + // + // OPT-IN (OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1), mirroring the directive + // stripper right above, which only runs when the operator configured + // OMNIROUTE_SYSTEM_INSTRUCTION_APPEND. Unlike the exact-directive match, + // this one recognises constructs by English-prose heuristics, so leaving it + // default-on would mutate the payload of EVERY openai→claude stream and can + // delete a legitimate section (a reply that genuinely opens with + // "# Skill usage: ..." loses it). Operators who hit the system-echo leak + // turn it on explicitly. + if (process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE === "1") { + state._systemPreambleStripper ??= createSystemPreambleStripper(); + } + let scrubbedContent = state._directiveStripper + ? state._directiveStripper(strippedContent) + : strippedContent; + if (state._systemPreambleStripper) { + scrubbedContent = state._systemPreambleStripper(scrubbedContent); + } + if (scrubbedContent) { + stopThinkingBlock(state, results); + } // Rehydrate any Markdown boundary suffix buffered from the previous chunk // before searching for XML tool calls, so the prefix is not lost. const bufferedPrefix = state._markdownBuffer || ""; state._markdownBuffer = ""; + // DSML tool calls (DeepSeek-V4-Flash's full-width-pipe format) can appear + // in content instead of the standard JSON tool_calls. Run the DSML + // parser/scrubber BEFORE extractXmlInvokeBlocks: complete <|DSML|:Tool> + // blocks become tool calls, stray closing markers are stripped, and the + // scrubbed remainder is handed to the XML-invoke path below. A partial + // opener at the chunk tail is held back in state for the next chunk so + // the marker cannot leak as visible text mid-stream. + let dsmlContent = scrubbedContent; + const dsmlPending = state._dsmlHoldback; + if (dsmlPending) { + dsmlContent = dsmlPending + dsmlContent; + state._dsmlHoldback = undefined; + } + let dsmlToolCalls: { id: string; name: string; args: Record }[] = []; + if (hasDsmlToolCalls(dsmlContent)) { + const dsmlResult = parseDsmlToolCalls(dsmlContent); + dsmlContent = dsmlResult.content; + if (dsmlResult.holdback) { + state._dsmlHoldback = dsmlResult.holdback; + } + dsmlToolCalls = dsmlResult.toolCalls.map((tc) => ({ + id: tc.id, + name: tc.function.name, + args: JSON.parse(tc.function.arguments) as Record, + })); + } + // Check for XML blocks that some models emit instead of JSON tool_calls const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks( - bufferedPrefix + strippedContent, + bufferedPrefix + dsmlContent, state ); - // Accumulate extracted tool calls for emission at finish - if (xmlToolCalls.length > 0) { + // Accumulate extracted tool calls for emission at finish. DSML and XML + // invoke tool calls share the same pending queue and finish emission. + if (xmlToolCalls.length > 0 || dsmlToolCalls.length > 0) { // Close any ongoing text block before tool calls stopTextBlock(state, results); - state._pendingXmlToolCalls.push(...xmlToolCalls); + state._pendingXmlToolCalls.push(...xmlToolCalls, ...dsmlToolCalls); } // Defer any trailing incomplete Markdown boundary token to the next chunk. @@ -378,7 +486,7 @@ export function openaiToClaudeResponse(chunk, state) { state._markdownFenceRun || 0, state._markdownFenceOpening === true, state._markdownFenceClosingRun || 0, - state._markdownLineIndent || 0, + state._markdownLineIndent || 0 ); state._markdownBuffer = textToHold; state._markdownCodeSpanRun = backtickRun || 0; @@ -521,6 +629,57 @@ export function openaiToClaudeResponse(chunk, state) { state.claudeFinishEmitted = true; stopThinkingBlock(state, results); + + // Both preamble strippers buffer while a construct is still undecided (a + // directive prefix that never completed, an / block that + // never closed). Nothing flushed that buffer, so a stream ending mid-construct + // dropped the held text silently — for a single-chunk response whose block + // never closes, that is the ENTIRE answer replaced by an empty message. + // Release whatever is still held before the terminal blocks are emitted. + const flushedPreamble = + (state._directiveStripper?.flush?.() ?? "") + + (state._systemPreambleStripper?.flush?.() ?? ""); + if (flushedPreamble) { + if (!state.textBlockStarted || state.textBlockClosed) { + state.textBlockIndex = state.nextBlockIndex++; + state.textBlockStarted = true; + state.textBlockClosed = false; + results.push({ + type: "content_block_start", + index: state.textBlockIndex, + content_block: { type: "text", text: "" }, + }); + } + results.push({ + type: "content_block_delta", + index: state.textBlockIndex, + delta: { type: "text_delta", text: flushedPreamble }, + }); + } + + // FIX B: when the response ended reasoning-only (no ordinary text block was + // started) and the client did NOT explicitly request thinking, synthesize a + // text content block from the accumulated reasoning. Claude Code's + // autocompact parser extracts the summary from a TEXT content block — a + // thinking block alone is judged "empty response" and the compact is + // rejected, looping the session. When requestedThinking===true, skip this so + // reasoning is not double-exposed (thinking block + text block both carrying it). + if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking !== true) { + state.textBlockIndex = state.nextBlockIndex++; + state.textBlockStarted = true; + state.textBlockClosed = false; + results.push({ + type: "content_block_start", + index: state.textBlockIndex, + content_block: { type: "text", text: "" }, + }); + results.push({ + type: "content_block_delta", + index: state.textBlockIndex, + delta: { type: "text_delta", text: state._reasoningAccum }, + }); + } + stopTextBlock(state, results); for (const [, toolInfo] of state.toolCalls) { diff --git a/open-sse/utils/directivePreambleStripper.ts b/open-sse/utils/directivePreambleStripper.ts new file mode 100644 index 0000000000..5bf9afbc62 --- /dev/null +++ b/open-sse/utils/directivePreambleStripper.ts @@ -0,0 +1,259 @@ +/** + * Streaming-safe preamble stripper. + * + * Background: DeepSeek-V4 and similar models sometimes echo the tail of the + * system message (highest recency) — e.g. the OMNIROUTE_SYSTEM_INSTRUCTION_APPEND + * directive appended by claude-to-openai.ts — verbatim at the START of their + * response content. The user then sees the system instruction inside the + * assistant's reply ("시스템 메시지" leak). This helper deterministically + * removes a known directive when it appears as a leading prefix, across + * arbitrary streaming chunk boundaries, and only ever at the very start: once a + * stream diverges from the directive (or fully consumes it), matching is done + * and everything after passes through untouched. + * + * The directive text is operator-configured (opt-in via the same env var used + * on the request side), so this is a targeted remedy of the mitigation's side + * effect — legitimate responses that do not begin with the directive are never + * modified. + * + * API: `createDirectivePreambleStripper(directive)` returns a per-stream + * function. Call it with each incoming text chunk; it returns the portion of + * that chunk that may be emitted downstream. Chunks that are still matching a + * directive prefix yield "" (buffered implicitly by a position counter); if the + * stream later diverges from the directive, the so-far-matched prefix is + * released as real content together with the rest of the chunk. + * + * Every stripper also exposes `flush()`, which MUST be called once at the end + * of the stream: a stream that ends while a construct is still undecided (a + * directive prefix that never completed, a block that never closed) leaves the + * suppressed text in the internal buffer, and without a flush the client would + * receive an empty — or truncated — response. `flush()` releases whatever is + * still held and puts the stripper in the terminal state. + */ + +/** A per-stream stripper: call it per chunk, then `flush()` once at stream end. */ +export type PreambleStripper = ((text: string) => string) & { flush: () => string }; + +export function createDirectivePreambleStripper(directive: string): PreambleStripper { + if (!directive) { + return Object.assign((text: string) => text, { flush: () => "" }); + } + + let done = false; // stream diverged from, or fully consumed, the directive + let position = 0; // directive chars matched so far (contiguous from stream start) + + const push = (text: string): string => { + if (done || text === "") return text; + + let i = 0; + while (position < directive.length && i < text.length) { + if (text[i] === directive[position]) { + position++; + i++; + } else { + // Divergence at position: everything matched so far is real content, + // and the rest of this chunk (from the diverging char on) is real too. + done = true; + return directive.slice(0, position) + text; + } + } + + if (position === directive.length) { + // Full directive consumed; emit only the tail after it within this chunk. + done = true; + return text.slice(i); + } + + // Chunk was entirely a directive prefix (or an empty remainder) — buffer it. + return ""; + }; + + // Stream ended while still matching a directive prefix: the buffered chars + // are the only content the model produced, so release them instead of + // swallowing the whole response. + const flush = (): string => { + if (done) return ""; + done = true; + const held = directive.slice(0, position); + position = 0; + return held; + }; + + return Object.assign(push, { flush }); +} + +/** + * Streaming-safe system-preamble stripper (Phase B). + * + * DeepSeek-V4 and similar models sometimes echo not just the exact + * OMNIROUTE_SYSTEM_INSTRUCTION_APPEND directive but whole chunks of the system + * prompt at the START of their reply: `...` blocks, + * `` blocks, `` blocks, or prose reproductions of the + * superpowers skill section ("Skill usage (when superpowers skills are + * installed)", "# Verification Process"). The exact-directive stripper does not + * match these, so they leak to the client. This helper removes any sequence of + * such known system-echo constructs from the very start of a stream, across + * arbitrary chunk boundaries, and only while the stream is still a preamble. + * + * OFF by default — see the OMNIROUTE_STRIP_SYSTEM_PREAMBLE gate at the call + * site (translator/response/openai-to-claude.ts). The heuristics below are + * English-prose shaped and DO mutate response payloads, so they only run when + * the operator opts in, exactly like the directive stripper above only runs + * when OMNIROUTE_SYSTEM_INSTRUCTION_APPEND is configured. + */ +// System-echo blocks (the system prompt's tail echoed at the start of a reply) are +// short — a few hundred chars at most. A block carrying >= this many chars of +// content is the model's real response (e.g. a Claude Code autocompact summary), +// not an echo: preserve it instead of suppressing it as a preamble. +const SYSTEM_ECHO_THRESHOLD = 1000; + +export function createSystemPreambleStripper(): PreambleStripper { + const tagNames = ["analysis", "system-reminder", "summary"] as const; + const proseHeads = [ + "# Verification Process", + "# Skill usage", + "Skill usage (when superpowers skills are installed)", + "Required skills for implementation work", + ] as const; + + let done = false; + // "leading" (whitespace only) | "head" (matching a known opener) | + // "block" (inside a suppressed construct; stack holds open tag names). + let phase: "leading" | "head" | "block" = "leading"; + let stack: string[] = []; + let buf = ""; // suppressed, not-yet-decided prefix + // Opener text already consumed for the construct currently in flight (the + // `` itself). Cleared whenever a construct is FINALLY classified as an + // echo; kept so flush() can restore a construct that never closed. + let pendingDrop = ""; + + const isAllWs = (s: string): boolean => s.trim() === ""; + + // First "\n\n" that is followed by a structural marker (an opening tag, a + // markdown heading, or a divider line) — the boundary where a prose-echo + // section ends and the next construct (or real content) begins. -1 = none. + const proseBoundary = (s: string): number => { + const re = /\n[ \t]*\n[ \t]*(?=<|<[^>]*>|#{1,6} |[-=]{3,})/; + const m = re.exec(s); + return m ? m.index : -1; + }; + + const tryOpeners = (s: string): { kind: "tag" | "prose"; name?: string } | null => { + const t = s.trimStart(); + for (const name of tagNames) { + if (t.startsWith(`<${name}>`)) return { kind: "tag", name }; + } + for (const h of proseHeads) { + if (t.startsWith(h)) { + const rest = t.slice(h.length); + if (rest === "" || /^[\s;:.,\-]/.test(rest)) return { kind: "prose" }; + } + } + return null; + }; + + const anyOpenerPrefix = (s: string): boolean => { + const t = s.trimStart(); + for (const name of tagNames) { + if (`<${name}>`.startsWith(t)) return true; + } + for (const h of proseHeads) { + if (h.startsWith(t)) return true; + } + return false; + }; + + const push = (text: string): string => { + if (done || text === "") return text; + buf += text; + + while (true) { + if (phase === "leading") { + if (isAllWs(buf)) return ""; + phase = "head"; + } + + if (phase === "head") { + if (isAllWs(buf)) return ""; + const open = tryOpeners(buf); + if (open) { + if (open.kind === "tag") { + const tagStart = buf.indexOf("<"); + const afterOpen = buf.indexOf(">", tagStart) + 1; + pendingDrop += buf.slice(0, afterOpen); + buf = buf.slice(afterOpen); + stack.push(open.name!); + phase = "block"; + continue; + } + // Prose head matched: suppress the echoed section until a structural + // boundary ends it (or the stream diverges into real content). + stack.push("__prose__"); + phase = "block"; + continue; + } + if (anyOpenerPrefix(buf)) { + return ""; // strict prefix of a known opener — keep buffering + } + // Diverged from every known opener: buffered prefix is real content. + done = true; + const out = pendingDrop + buf; + pendingDrop = ""; + buf = ""; + return out; + } + + if (phase === "block") { + const top = stack[stack.length - 1]; + if (top === "__prose__") { + const b = proseBoundary(buf); + if (b === -1) return ""; // stay suppressed until boundary or divergence + buf = buf.slice(b); + pendingDrop = ""; // prose echo finally classified: the drop is final + stack.pop(); + phase = "head"; + continue; + } + const closeTag = ""; + const ci = buf.indexOf(closeTag); + if (ci === -1) return ""; // block not closed yet — keep suppressing + const afterClose = buf.slice(ci + closeTag.length); + const trailing = afterClose.replace(/^[ \t\r\n]+/, ""); + // A block carrying substantial content (>= SYSTEM_ECHO_THRESHOLD) is the + // model's real response (e.g. a Claude Code autocompact summary), not a + // short system-echo — preserve it. A small block with NO trailing content + // is also the real response (the block IS the entire reply) — preserve it + // too. Otherwise (small block followed by more content) it's a system + // echo — drop it (existing behavior). + if (ci >= SYSTEM_ECHO_THRESHOLD || trailing === "") { + done = true; + const out = "<" + top + ">" + buf.slice(0, ci) + closeTag + afterClose; + pendingDrop = ""; + buf = ""; + return out; + } + buf = trailing; + pendingDrop = ""; // block finally classified as an echo: the drop is final + stack.pop(); + phase = stack.length === 0 ? "leading" : "block"; + continue; + } + } + }; + + // Stream ended with a construct still undecided (e.g. `` that never + // closed, or a prose head with no structural boundary). Without this the + // buffered text — possibly the ENTIRE response — would be dropped and the + // client would get an empty message. Release the opener plus everything held. + const flush = (): string => { + if (done) return ""; + done = true; + const out = pendingDrop + buf; + pendingDrop = ""; + buf = ""; + stack = []; + return out; + }; + + return Object.assign(push, { flush }); +} diff --git a/open-sse/utils/dsmlToolCalls.ts b/open-sse/utils/dsmlToolCalls.ts new file mode 100644 index 0000000000..244b71ea8e --- /dev/null +++ b/open-sse/utils/dsmlToolCalls.ts @@ -0,0 +1,193 @@ +/** + * Parser/scrubber for DeepSeek-V4-Flash's non-standard "DSML" tool-call text + * format. + * + * Background: DeepSeek-V4-Flash occasionally emits tool calls as inline text + * wrapped in full-width-pipe (U+FF5C) markers instead of the standard OpenAI + * `tool_calls` JSON. Two shapes appear in production call logs: + * + * 1. Complete block (call log 1787040934679-f8f772): + * <|DSML|:Read> + * images/omniroute.png + * + * The tool name follows a leading colon (`:Read`); child elements + * (``, ``) carry the arguments. + * + * 2. Stray closing markers with no opener (call logs 1787566395384-bab9ab, + * 2026-08-20 22:59–23:49 run, 2026-08-23 22:51–23:38 run): the model + * starts a tool call, truncates it, and emits only the closers — sometimes + * trailing a system-prompt echo — then finishes with `stop` and no + * `tool_calls`: + * + * + * + * or a mixed ASCII/DSML variant: + * + * + * + * + * The openai-compatible path (default.ts + openai-to-claude.ts) never parsed + * these, so the markers leaked to the client as visible content and the turn + * ended incomplete. This module converts complete DSML blocks into OpenAI + * `tool_calls` and strips stray closing markers from content so neither the + * markers nor the tool-call grammar reach the client. + * + * Markers use full-width pipes `|` (U+FF5C); we also accept the ASCII `|` + * defensively. The marker body is the literal `DSML` followed by either a + * colon-prefixed tool name (opening tag) or one of the structural words + * `tool_calls` / `invoke` / `parameter` (structural closing tags). + */ + +// Full-width or ASCII pipe — matches composerToolCalls.ts's FW class. +const FW = "[||]"; + +// Opening tool-call tag: <|DSML|:ToolName> (colon + name, optional attrs). +const DSML_OPEN_RE = new RegExp(`<${FW}DSML${FW}:(\\w+)([^>]*)>`, "i"); + +// The matching closing tag for a tool named ToolName: . +function dsmlCloseRe(name: string): RegExp { + return new RegExp(``, "i"); +} + +// A single child element carrying an argument: value (name only) +// or value (named). Value is verbatim inner +// text up to the matching close. +const DSML_CHILD_NAME_ONLY_RE = /<(\w+)>([\s\S]*?)<\/\1>/g; +const DSML_CHILD_NAMED_RE = /]*>([\s\S]*?)<\/parameter>/gi; + +// Source string for the stray-closer regex (kept as a constant so the pattern +// is buildable from the `FW` class without re-interpolating it inline). Each +// alternative matches the closer + any trailing newlines, so the marker and +// its separator are removed together while a leading newline (an echo's own +// trailing newline) stays intact. +const STRAY_CLOSE_SRC = + `\\n*` + `|\\n*`; + +// Structural closing markers that appear with NO opener when a tool call is +// truncated: , , , plus +// the mixed ASCII variant . The +// closers are usually \n-separated and often trail a system-prompt echo, so +// each alternative also consumes the newline(s) that FOLLOW it — that keeps an +// echoed preamble intact while removing the marker together with its trailing +// separator newline. A leading newline before the first closer is NOT +// consumed, so the echo's own trailing newline is preserved. +const DSML_STRAY_CLOSE_RE = new RegExp(STRAY_CLOSE_SRC, "gi"); + +// Partial opening marker at the very tail of a (streaming) chunk: a `<|DSML|` +// that never closes — hold it back so it cannot leak as visible text and can +// be re-evaluated once more bytes arrive. +const DSML_PARTIAL_OPEN_RE = new RegExp(`(<${FW}DSML${FW}[^<]*)$`, "i"); + +export interface DsmlToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +export interface ParseDsmlResult { + content: string; + toolCalls: DsmlToolCall[]; + /** Partial opening marker held back for the next chunk (streaming-safe). */ + holdback: string; +} + +/** + * Parse a complete (non-streaming) DSML content string into OpenAI tool calls + * + scrubbed content. Complete `<|DSML|:Tool>` blocks become tool calls; + * stray closing markers are removed; everything else passes through. + */ +export function parseDsmlToolCalls(text: string): ParseDsmlResult { + if (!text || typeof text !== "string") { + return { content: text || "", toolCalls: [], holdback: "" }; + } + + let remaining = text; + const toolCalls: DsmlToolCall[] = []; + let cleaned = ""; + + while (remaining.length > 0) { + const openMatch = remaining.match(DSML_OPEN_RE); + if (!openMatch || openMatch.index === undefined) { + // No more complete openers. Strip stray closing markers from the tail + // (and a trailing partial opener, if any). + let tail = remaining.replace(DSML_STRAY_CLOSE_RE, ""); + const partial = tail.match(DSML_PARTIAL_OPEN_RE); + let holdback = ""; + if (partial) { + tail = tail.slice(0, partial.index); + holdback = partial[1]; + } + cleaned += tail; + return { content: cleaned, toolCalls, holdback }; + } + + // Emit any text before the opener (stray closers already removed). + const before = remaining.slice(0, openMatch.index).replace(DSML_STRAY_CLOSE_RE, ""); + cleaned += before; + + const toolName = openMatch[1]; + const afterOpen = remaining.slice(openMatch.index + openMatch[0].length); + const closeRe = dsmlCloseRe(toolName); + const closeMatch = afterOpen.match(closeRe); + if (!closeMatch) { + // Opener without a closer — incomplete block. Treat the opener itself + // as held-back (drop it from content) rather than leaking the marker. + return { content: cleaned, toolCalls, holdback: openMatch[0] + afterOpen }; + } + + const inner = afterOpen.slice(0, closeMatch.index); + const args = parseDsmlArgs(inner); + toolCalls.push({ + id: `call_dsml_${crypto.randomUUID().replace(/-/g, "").slice(0, 10)}`, + type: "function", + function: { name: toolName, arguments: JSON.stringify(args) }, + }); + + remaining = afterOpen.slice(closeMatch.index + closeMatch[0].length); + } + + return { content: cleaned, toolCalls, holdback: "" }; +} + +/** + * Extract arguments from a DSML block body. Named `` + * children win; bare `value` children fall back to the tag name as + * the argument key. + */ +function parseDsmlArgs(inner: string): Record { + const args: Record = {}; + // Named parameters first (explicit argument names). + const namedRe = new RegExp(DSML_CHILD_NAMED_RE); + let m; + const consumed = new Set(); + while ((m = namedRe.exec(inner)) !== null) { + args[m[1]] = m[2].trim(); + consumed.add(m[0]); + } + // Bare-name children that were not part of a named . + const bareRe = new RegExp(DSML_CHILD_NAME_ONLY_RE); + while ((m = bareRe.exec(inner)) !== null) { + if (consumed.has(m[0])) continue; + // Skip nested children already captured by an outer bare element. + let nested = false; + for (const c of consumed) { + if (c.includes(m[0]) && c !== m[0]) { + nested = true; + break; + } + } + if (nested) continue; + args[m[1]] = m[2].trim(); + consumed.add(m[0]); + } + return args; +} + +/** Cheap detection used by callers to decide whether to run the full parser. */ +export function hasDsmlToolCalls(text: string): boolean { + if (!text || typeof text !== "string") return false; + return /[/]?[||]DSML[||]/.test(text); +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 119386dd17..f990c85822 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -157,6 +157,13 @@ type StreamOptions = { copilotCompatibleReasoning?: boolean; /** Suppress the `` close marker for clients that render it verbatim (#5245). */ suppressThinkClose?: boolean; + /** + * True when the CLIENT explicitly asked for thinking (body.thinking.type === + * "enabled"). The response translator only relays upstream reasoning_content + * as Claude thinking blocks when this is set — otherwise DeepSeek/GLM + * reasoning would leak into UIs that never opted in. + */ + requestedThinking?: boolean; /** * Drop internal commentary-phase output items from Responses API passthrough * streams before forwarding (#6199). When omitted, falls back to the @@ -201,6 +208,8 @@ type TranslateState = ReturnType & { copilotCompatibleReasoning?: boolean; /** Suppress the `` close marker for clients that render it verbatim (#5245). */ suppressThinkClose?: boolean; + /** Client's explicit thinking intent — see StreamOptions.requestedThinking. */ + requestedThinking?: boolean; /** Accumulated message content for call log response body */ accumulatedContent?: string; /** Accumulated reasoning content (separate from content) */ @@ -650,6 +659,11 @@ export function createSSEStream(options: StreamOptions = {}) { clientResponseFormat = null, copilotCompatibleReasoning = false, suppressThinkClose = false, + // No default: "absent" must stay absent instead of being coerced into an + // explicit "thinking NOT requested". Mirrors translateNonStreamingResponse's + // `requestedThinking?: boolean` so both translation paths spell the + // no-intent case the same way. + requestedThinking, provider = null, reqLogger = null, toolNameMap = null, @@ -755,6 +769,7 @@ export function createSSEStream(options: StreamOptions = {}) { signatureNamespace, copilotCompatibleReasoning, suppressThinkClose, + requestedThinking, accumulatedContent: "", accumulatedReasoning: "", toolSchemas: extractToolSchemaMap(body), @@ -3065,6 +3080,7 @@ export function createSSETransformStreamWithLogger( onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, copilotCompatibleReasoning = false, suppressThinkClose = false, + requestedThinking: boolean | undefined = undefined, customToolNames: ReadonlySet = new Set(), requestToolIdentityMap: Map | null = null, streamBufferBytes: number = DEFAULT_STREAM_BUFFER_BYTES @@ -3084,6 +3100,7 @@ export function createSSETransformStreamWithLogger( onFailure, copilotCompatibleReasoning, suppressThinkClose, + requestedThinking, customToolNames, requestToolIdentityMap, streamBufferBytes, diff --git a/src/lib/skills/toolLoopTypes.ts b/src/lib/skills/toolLoopTypes.ts index f3af5a3871..21c4017ee7 100644 --- a/src/lib/skills/toolLoopTypes.ts +++ b/src/lib/skills/toolLoopTypes.ts @@ -193,6 +193,15 @@ export interface NonStreamingClientTranslateInput { reasoningCacheScope: string | null; clientHeaders: Headers | Record | null; isClaudeCodeCompatible: boolean; + /** + * The client's explicit thinking intent for THIS request (same value the + * streaming path threads into the SSE translator). Without it the + * non-streaming OpenAI→Claude conversion falls back to its legacy + * "always relay a thinking block" default, so the very same request answered + * with `stream:false` leaked reasoning that `stream:true` correctly withheld. + * `undefined` keeps the legacy relay for callers that cannot express intent. + */ + requestedThinking?: boolean; phase: "intermediate" | "final"; } diff --git a/tests/unit/directive-preamble-strip.test.ts b/tests/unit/directive-preamble-strip.test.ts new file mode 100644 index 0000000000..f15e1bbd77 --- /dev/null +++ b/tests/unit/directive-preamble-strip.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createDirectivePreambleStripper } = + await import("../../open-sse/utils/directivePreambleStripper.ts"); + +const DIRECTIVE = + "Respond ONLY in the same language as the user message. " + + "Put all planning, reasoning, and chain-of-thought in the reasoning_content " + + "field, never in the content field. Do not repeat or translate your response " + + "in another language."; + +test("strips a single-chunk full directive preamble", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + assert.equal( + strip(DIRECTIVE + "\n\n안녕하세요. 준비된 답변입니다."), + "\n\n안녕하세요. 준비된 답변입니다." + ); +}); + +test("strips directive split across streaming chunks", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + const a = DIRECTIVE.slice(0, 10); + const b = DIRECTIVE.slice(10, 40); + const c = DIRECTIVE.slice(40, 90); + const d = DIRECTIVE.slice(90, 120); + const rest = DIRECTIVE.slice(120); + assert.equal(strip(a), "", "nothing emitted while still matching prefix"); + assert.equal(strip(b), ""); + assert.equal(strip(c), ""); + assert.equal(strip(d), ""); + assert.equal(strip(rest + "\n\n후반부"), "\n\n후반부"); +}); + +test("flushes matched prefix as real content when stream diverges from directive", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + assert.equal(strip("Respond ONLY in the sam"), ""); // partial match so far + assert.equal(strip("x"), "Respond ONLY in the samx"); // diverges at 'x' +}); + +test("passes content through untouched when it never matches the directive", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + assert.equal(strip("안녕하세요. 일반적인 응답입니다."), "안녕하세요. 일반적인 응답입니다."); +}); + +test("handles exact full-directive stream with no trailing text", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + assert.equal(strip(DIRECTIVE.slice(0, 50)), ""); + assert.equal(strip(DIRECTIVE.slice(50)), ""); +}); + +test("subsequent chunks pass through untouched once directive is consumed", () => { + const strip = createDirectivePreambleStripper(DIRECTIVE); + assert.equal(strip(DIRECTIVE), ""); + assert.equal(strip("\n\n후반부"), "\n\n후반부"); + assert.equal(strip("추가 텍스트"), "추가 텍스트"); +}); + +test("empty directive behaves as no-op passthrough", () => { + const strip = createDirectivePreambleStripper(""); + assert.equal(strip("text"), "text"); +}); diff --git a/tests/unit/dsml-tool-calls.test.ts b/tests/unit/dsml-tool-calls.test.ts new file mode 100644 index 0000000000..6c338b1050 --- /dev/null +++ b/tests/unit/dsml-tool-calls.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// DeepSeek-V4-Flash occasionally emits tool calls in a non-standard "DSML" +// text format using full-width pipes (U+FF5C), e.g. +// <|DSML|:Read>\nimages/x.png\n +// or, when a tool call is truncated mid-stream, only the closing markers: +// \n\n +// The openai-compatible path must not leak these markers to the client as +// visible content. Complete DSML blocks are parsed into tool calls; stray +// closing markers (and the system-prompt echo they sometimes ride on) are +// stripped from content. +const { parseDsmlToolCalls } = await import("../../open-sse/utils/dsmlToolCalls.ts"); + +const FW = "\u{FF5C}"; // full-width vertical line | + +test("parseDsmlToolCalls: complete <|DSML|:Tool>... becomes a tool call", () => { + const content = `<${FW}DSML${FW}:Read>\nimages/omniroute.png\n`; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 1, "one tool call extracted"); + assert.equal(result.toolCalls[0].function.name, "Read"); + assert.deepEqual(JSON.parse(result.toolCalls[0].function.arguments), { + path: "images/omniroute.png", + }); + assert.equal(result.content.trim(), "", "no residual DSML in content"); +}); + +test("parseDsmlToolCalls: stray closing DSML markers are stripped from content", () => { + // The 2026-08-24 broken case (call log 1787566395384-bab9ab): only closing + // markers, no opening, tool_calls absent, finish_reason "stop". + const content = `\n\n`; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 0, "no tool call from stray closers"); + assert.equal(result.content.trim(), "", "stray markers removed from content"); +}); + +test("parseDsmlToolCalls: mixed ASCII + DSML closing markers are stripped", () => { + // 2026-08-23 variant: + const content = `\n\n`; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 0); + assert.equal(result.content.trim(), "", "mixed closers removed"); +}); + +test("parseDsmlToolCalls: closing markers trailing an echoed prompt are removed", () => { + // 2026-08-20 case: a system-prompt echo followed by three DSML closers. + const echo = "# Harness CWD note\n\nsome echoed text.\n"; + const content = `${echo}\n\n`; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 0); + // The echoed preamble is retained (it is not a DSML marker); only the + // trailing DSML closers are removed. + assert.equal(result.content, echo, "echo retained, markers removed"); + assert.ok(!result.content.includes("DSML"), "no DSML marker remains"); +}); + +test("parseDsmlToolCalls: content with no DSML markers passes through unchanged", () => { + const content = "Hello, this is a normal response with no markers."; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 0); + assert.equal(result.content, content); +}); + +test("parseDsmlToolCalls: partial opening marker at the tail is held back (streaming-safe)", () => { + // A truncated stream might end mid-marker; the partial opener must not leak. + const content = `normal text <${FW}DSML${FW}:Wri`; + const result = parseDsmlToolCalls(content); + assert.equal(result.toolCalls.length, 0); + assert.equal(result.content, "normal text ", "safe text emitted, partial opener held back"); + assert.equal( + result.holdback, + "<" + FW + "DSML" + FW + ":Wri", + "partial opener returned as holdback" + ); +}); diff --git a/tests/unit/nonstream-requested-thinking-parity.test.ts b/tests/unit/nonstream-requested-thinking-parity.test.ts new file mode 100644 index 0000000000..44da4cb5c2 --- /dev/null +++ b/tests/unit/nonstream-requested-thinking-parity.test.ts @@ -0,0 +1,122 @@ +/** + * Rework guard for PR #12905, item (c): the non-streaming translation path never + * received the client's thinking intent. + * + * `convertOpenAINonStreamingToClaude()` already accepts `requestedThinking` and + * suppresses the thinking block when it is explicitly `false` — but NO caller + * ever passed it, so `undefined` (the legacy "always relay a thinking block" + * default) was the only value it ever saw in production. The streaming path, by + * contrast, computes the intent in chatCore and threads it into the SSE + * translator, which relays reasoning only when it is explicitly `true`. + * + * Net effect before this fix: the SAME request answered with `stream:false` + * leaked a thinking block that `stream:true` correctly withheld. These tests + * pin the parity at the seam that was missing — the non-streaming provider leg, + * which owns the client body (`sourceBody`). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + runNonStreamingProviderLeg, + type ChatCoreExecutorResult, + type ProviderLegInput, +} from "../../open-sse/handlers/chatCore/nonStreamingProviderLeg.ts"; + +const UPSTREAM = { + id: "chatcmpl-thinking-parity", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "The answer is 4.", + reasoning_content: "Let me add two and two.", + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, +}; + +function makeResponse(body: object): Response { + const text = JSON.stringify(body); + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "application/json" }), + text: async () => text, + clone() { + return { ...this, text: async () => text } as unknown as Response; + }, + body: null, + } as unknown as Response; +} + +function legInput(thinking: Record | undefined): ProviderLegInput { + return { + phase: "initial", + sourceBody: { + model: "deepseek-v4", + messages: [{ role: "user", content: "2+2?" }], + ...(thinking ? { thinking } : {}), + }, + allowAccountRotation: true, + allowModelFallback: true, + executeProviderRequest: async (): Promise => ({ + response: makeResponse(UPSTREAM), + url: "https://upstream/v1/chat/completions", + headers: {}, + transformedBody: UPSTREAM, + }), + setRequestWireState: () => {}, + provider: "deepseek", + model: "deepseek-v4", + connectionId: "conn-thinking-parity", + // Client speaks Claude; upstream answered OpenAI chat.completion. + sourceFormat: "claude", + clientResponseFormat: "claude", + targetFormat: "openai", + }; +} + +function contentTypes(response: Record): string[] { + const content = response.content as { type: string }[] | undefined; + return Array.isArray(content) ? content.map((c) => c.type) : []; +} + +test("(c) PARITY: non-streaming with thinking NOT requested must NOT relay a thinking block (matches the streaming gate)", async () => { + const result = await runNonStreamingProviderLeg(legInput({ type: "disabled" })); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + const types = contentTypes(result.response as unknown as Record); + console.log(" thinking:disabled -> content types:", types); + assert.ok( + !types.includes("thinking"), + "thinking-opt-out client must not receive a thinking block on stream:false either" + ); + assert.ok(types.includes("text"), "the ordinary answer must still be relayed"); +}); + +test("(c) PARITY: non-streaming with thinking explicitly requested DOES relay the thinking block", async () => { + const result = await runNonStreamingProviderLeg( + legInput({ type: "enabled", budget_tokens: 1024 }) + ); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + const types = contentTypes(result.response as unknown as Record); + console.log(" thinking:enabled -> content types:", types); + assert.ok(types.includes("thinking"), "an opted-in client keeps its thinking block"); + assert.ok(types.includes("text"), "the ordinary answer is relayed too"); +}); + +test("(c) PARITY: `adaptive` thinking counts as requested (same helper the streaming path uses)", async () => { + const result = await runNonStreamingProviderLeg(legInput({ type: "adaptive" })); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + const types = contentTypes(result.response as unknown as Record); + console.log(" thinking:adaptive -> content types:", types); + assert.ok(types.includes("thinking"), "adaptive is an ACTIVE thinking intent"); +}); diff --git a/tests/unit/system-preamble-gate-and-flush.test.ts b/tests/unit/system-preamble-gate-and-flush.test.ts new file mode 100644 index 0000000000..d09f8702a8 --- /dev/null +++ b/tests/unit/system-preamble-gate-and-flush.test.ts @@ -0,0 +1,149 @@ +/** + * Rework guards for PR #12905, Group E (system-preamble stripper). + * + * Three defects were confirmed by probe on the PR head: + * + * (a) `createSystemPreambleStripper()` was wired DEFAULT-ON and unconditional + * in openai-to-claude.ts, unlike the directive stripper right above it + * (gated on OMNIROUTE_SYSTEM_INSTRUCTION_APPEND). Its openers are English + * prose heuristics, so a legitimate reply that opens with "# Skill usage: + * ..." had that whole section deleted from every openai→claude stream. + * + * (b) Neither stripper was ever flushed. A block that never closes keeps the + * buffered text forever, so a reply consisting of an unterminated + * `` reached the client as an EMPTY message. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createSystemPreambleStripper, createDirectivePreambleStripper } = + await import("../../open-sse/utils/directivePreambleStripper.ts"); +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +function createState(): Record { + return { toolCalls: new Map() }; +} + +function emittedText(events: Record[]): string { + return events + .filter( + (e) => + e?.type === "content_block_delta" && + (e.delta as Record)?.type === "text_delta" + ) + .map((e) => (e.delta as Record).text as string) + .join(""); +} + +function contentChunk(content: string): Record { + return { + id: "chatcmpl-rework-12905", + model: "auto/deepseek-v4", + choices: [{ index: 0, delta: { content }, finish_reason: null }], + }; +} + +function finishChunk(): Record { + return { + id: "chatcmpl-rework-12905", + model: "auto/deepseek-v4", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +function withEnv(value: string | undefined, fn: () => void): void { + const previous = process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE; + if (value === undefined) delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE; + else process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE; + else process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = previous; + } +} + +// ── (a) opt-in gate ──────────────────────────────────────────────────────────── + +test("(a) GATE: with OMNIROUTE_STRIP_SYSTEM_PREAMBLE unset, a legitimate reply opening with a prose head is relayed VERBATIM", () => { + withEnv(undefined, () => { + const state = createState(); + const reply = "# Skill usage: how to write one\n\nHere is the guide.\n\n# Next"; + const events = [ + ...openaiToClaudeResponse(contentChunk(reply), state), + ...openaiToClaudeResponse(finishChunk(), state), + ]; + assert.equal( + emittedText(events as Record[]), + reply, + "default-off: the response payload must not be mutated by prose heuristics" + ); + }); +}); + +test("(a) GATE: with OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1 the operator opts in and the echo block IS stripped", () => { + withEnv("1", () => { + const state = createState(); + const echo = "\nchronological analysis\n\n\nReal answer body."; + const events = [ + ...openaiToClaudeResponse(contentChunk(echo), state), + ...openaiToClaudeResponse(finishChunk(), state), + ]; + const text = emittedText(events as Record[]); + assert.ok(!text.includes(" { + const strip = createSystemPreambleStripper(); + const unterminated = "\nthis block never closes and IS the whole reply"; + assert.equal(strip(unterminated), "", "still buffered while the block may be an echo"); + assert.equal( + strip.flush(), + unterminated, + "flush must release the buffered text (opener included) at end of stream" + ); + assert.equal(strip.flush(), "", "flush is idempotent"); +}); + +test("(b) FLUSH (unit): a directive prefix that never completes is released by flush()", () => { + const strip = createDirectivePreambleStripper("SYSTEM DIRECTIVE TAIL"); + assert.equal(strip("SYSTEM DIR"), "", "still matching the directive prefix"); + assert.equal(strip.flush(), "SYSTEM DIR", "flush must release the partially matched prefix"); +}); + +test("(b) FLUSH (wiring): an unterminated reply reaches the client instead of an EMPTY message", () => { + withEnv("1", () => { + const state = createState(); + const unterminated = "\nthe model never closed the tag and this is the answer"; + const events = [ + ...openaiToClaudeResponse(contentChunk(unterminated), state), + ...openaiToClaudeResponse(finishChunk(), state), + ]; + const text = emittedText(events as Record[]); + assert.notEqual(text, "", "the whole response must not be swallowed by the stripper"); + assert.ok( + text.includes("the model never closed the tag and this is the answer"), + "the buffered answer must be flushed to the client" + ); + }); +}); + +test("(b) FLUSH: a CLOSED echo block stays stripped — flush must not resurrect a finalized drop", () => { + withEnv("1", () => { + const state = createState(); + const echo = "hi there\nReal answer body."; + const events = [ + ...openaiToClaudeResponse(contentChunk(echo), state), + ...openaiToClaudeResponse(finishChunk(), state), + ]; + const text = emittedText(events as Record[]); + assert.ok(!text.includes(""), "a finalized drop must not come back on flush"); + assert.ok(text.includes("Real answer body."), "real content preserved"); + }); +}); diff --git a/tests/unit/system-preamble-strip.test.ts b/tests/unit/system-preamble-strip.test.ts new file mode 100644 index 0000000000..4ffedbc3df --- /dev/null +++ b/tests/unit/system-preamble-strip.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createSystemPreambleStripper } = + await import("../../open-sse/utils/directivePreambleStripper.ts"); + +test("passes normal content through untouched", () => { + const strip = createSystemPreambleStripper(); + assert.equal(strip("안녕하세요. 준비된 답변입니다."), "안녕하세요. 준비된 답변입니다."); +}); + +test("strips a single-chunk full echo block", () => { + const strip = createSystemPreambleStripper(); + const echoed = + "\nLet me chronologically analyze the conversation.\nSteps:\n" + + "1. First\n2. Second\n\n\n답변 본문입니다."; + assert.equal(strip(echoed), "답변 본문입니다."); +}); + +test("strips analysis echo split across streaming chunks", () => { + const strip = createSystemPreambleStripper(); + assert.equal(strip("\nThink"), ""); + assert.equal(strip("ing and analyze.\n"), ""); + assert.equal(strip("\n\n대답입니다."), "대답입니다."); +}); + +test("strips consecutive analysis + summary echo blocks", () => { + const strip = createSystemPreambleStripper(); + const t = "\nA\n\n\nS\n\n\n결론"; + assert.equal(strip(t), "결론"); +}); + +test("strips a standalone system-reminder block", () => { + const strip = createSystemPreambleStripper(); + const t = "\ncontext\n\n\n바로 본문"; + assert.equal(strip(t), "바로 본문"); +}); + +test("strips # Verification Process prose + system-reminder echo (real session 06:42)", () => { + const strip = createSystemPreambleStripper(); + const echoed = + "# Verification Process\n\nWhen using WebFetch results for verification, " + + "note the following:\n- Web results are transient\n- Prefer official sources\n\n" + + "\nFound existing memory, loading all matching memories:\n" + + "
environment: user_bearer_token
\n
\n\n" + + "사용자가 실제 세션 검증 방법을 묻고 있습니다."; + assert.equal(strip(echoed), "사용자가 실제 세션 검증 방법을 묻고 있습니다."); +}); + +test("strips Skill usage prose head then continues through system-reminder echo", () => { + const strip = createSystemPreambleStripper(); + const echoed = + "Skill usage (when superpowers skills are installed)\n\n" + + "When superpowers skills are installed, use appropriate skills.\n" + + "- superpowers:brainstorming\n- superpowers:systematic-debugging\n\n" + + "\ncontext\n\n\n한국어 답변입니다."; + assert.equal(strip(echoed), "한국어 답변입니다."); +}); + +test("flushes partial tag text as real content when stream diverges", () => { + const strip = createSystemPreambleStripper(); + assert.equal(strip(" { + const strip = createSystemPreambleStripper(); + assert.equal(strip("일반 답변입니다."), "일반 답변입니다."); + assert.equal(strip(" 무언가 더."), " 무언가 더."); +}); + +// REGRESSION (3a8515, 2026-09-01): an autocompact summary whose ENTIRE content is +// a single block is indistinguishable from a system-echo block. +// The stripper suppresses the whole block until , then drops it; with no +// trailing real content the client receives "" => Claude Code autocompact reports +// "summarization produced empty response". This reproduces the defect at the code +// level (the fix is a separate task; this test documents the bug). +test("REGRESSION (3a8515): a whole-summary block with no trailing content is stripped to empty (autocompact empty-response root cause)", () => { + const strip = createSystemPreambleStripper(); + // The model's entire summary is one block (legitimate structured + // summary, not a system echo). Streamed across chunks like a real response. + const chunks = [ + "", + "\nThis conversation covered the OmniRoute reasoning-leak diagnosis. ", + "We traced the bug to the requestedThinking gate in openai-to-claude.ts ", + "and confirmed fix B synthesizes a text block for reasoning-only responses.", + "\n", + ]; + let out = ""; + for (const c of chunks) out += strip(c); + // DEFECT: the whole summary was suppressed (treated as an echo). The fix must + // preserve it. This assert will FAIL until the stripper is taught that a + // block carrying substantial real content (not a verbatim system + // echo) is the response, not a preamble. + assert.ok( + out.length > 0, + "a whole-summary block MUST NOT be stripped to empty (autocompact empty-response root cause)" + ); +}); + +test("REGRESSION (3a8515b): a whole-summary block + trailing newline is stripped to empty", () => { + const strip = createSystemPreambleStripper(); + const chunks = ["Summary of the 208k-token context for compaction.", "\n"]; + let out = ""; + for (const c of chunks) out += strip(c); + assert.ok( + out.length > 0, + "a summary + trailing newline MUST NOT be stripped to empty" + ); +}); + +test("REGRESSION (3a8515c): a long summary block loses ~90% of its content (only post-close-tag chars survive)", () => { + const strip = createSystemPreambleStripper(); + // Build a long summary inside , then a short tail after . + // ~5000 chars inside the block (mirrors the 5076 chars that were suppressed), + // ~500 chars after the close tag (mirrors the 552 chars that survived). + const inside = "Summary of the conversation. ".repeat(180); // ~5000 chars + const tail = "Final note. ".repeat(40); // ~480 chars + const full = "\n" + inside + "\n\n\n" + tail; + // Stream it in realistic-sized chunks (~8 chars each, like the 704 deltas) + const chunks = []; + for (let i = 0; i < full.length; i += 8) chunks.push(full.slice(i, i + 8)); + let out = ""; + for (const c of chunks) out += strip(c); + // DEFECT: only the post-close-tag tail (~480 chars) survives; the ~5000-char + // summary inside is suppressed. The fix must preserve the inside. + assert.ok( + out.includes(inside.slice(0, 40)), + "the summary INSIDE MUST survive (not be suppressed as an echo); only ~tail survives today" + ); +}); diff --git a/tests/unit/system-preamble-wiring.test.ts b/tests/unit/system-preamble-wiring.test.ts new file mode 100644 index 0000000000..e30c7aa72b --- /dev/null +++ b/tests/unit/system-preamble-wiring.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +function createState() { + return { toolCalls: new Map() }; +} + +function emittedText(events: Array>): string { + return events + .filter( + (e) => + e?.type === "content_block_delta" && + (e.delta as Record)?.type === "text_delta" + ) + .map((e) => (e.delta as Record).text as string) + .join(""); +} + +// The system-preamble stripper is OPT-IN (it mutates response payloads with +// English-prose heuristics). These wiring tests exercise the opted-in path; +// the default-off contract is pinned in system-preamble-gate-and-flush.test.ts. +test.before(() => { + process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = "1"; +}); +test.after(() => { + delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE; +}); + +function chunkWith(content: string) { + return { + id: "chatcmpl-preamble", + model: "auto/deepseek-v4", + choices: [ + { + index: 0, + delta: { content }, + finish_reason: null, + }, + ], + }; +} + +test("wiring: strips an echo block at the START of the streamed reply", () => { + const state = createState(); + const echo = + "\nLet me chronologically analyze the conversation.\n\n\n실제 답변입니다."; + const events1 = openaiToClaudeResponse(chunkWith(echo.slice(0, 20)), state); + const events2 = openaiToClaudeResponse(chunkWith(echo.slice(20)), state); + + const text = emittedText([...events1, ...events2]); + assert.ok(!text.includes(" echo block", () => { + const state = createState(); + const echo = "\ncontext\n\n\n바로 본문"; + const events = openaiToClaudeResponse(chunkWith(echo), state); + const text = emittedText(events); + assert.ok(!text.includes("system-reminder"), "echo must not reach the client"); + assert.ok(text.includes("바로 본문"), "real content must pass through"); +}); + +test("wiring: normal content is untouched (no false positives)", () => { + const state = createState(); + const events = openaiToClaudeResponse(chunkWith("안녕하세요. 일반 답변입니다."), state); + const text = emittedText(events); + assert.equal(text, "안녕하세요. 일반 답변입니다."); +}); diff --git a/tests/unit/thinking-active-claude-adapter.test.ts b/tests/unit/thinking-active-claude-adapter.test.ts new file mode 100644 index 0000000000..9024e117e0 --- /dev/null +++ b/tests/unit/thinking-active-claude-adapter.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// hasActiveClaudeThinking lives in open-sse/utils/thinkingBudget.ts (the pure +// thinking-budget helpers), distinct from open-sse/services/thinkingBudget.ts. +const { hasActiveClaudeThinking } = await import("../../open-sse/utils/thinkingBudget.ts"); + +test("hasActiveClaudeThinking: enabled is active", () => { + assert.equal(hasActiveClaudeThinking({ thinking: { type: "enabled" } }), true); +}); + +test("hasActiveClaudeThinking: adaptive is active", () => { + // Claude Code sends {type:"adaptive"} to opt into reasoning. The helper must + // recognise adaptive exactly as it recognises enabled, so the response + // translator relays upstream reasoning_content as a thinking block. + assert.equal(hasActiveClaudeThinking({ thinking: { type: "adaptive" } }), true); +}); + +test("hasActiveClaudeThinking: disabled is NOT active", () => { + assert.equal(hasActiveClaudeThinking({ thinking: { type: "disabled" } }), false); +}); + +test("hasActiveClaudeThinking: absent thinking is NOT active", () => { + assert.equal(hasActiveClaudeThinking({}), false); + assert.equal(hasActiveClaudeThinking({ thinking: undefined }), false); +}); + +test("hasActiveClaudeThinking: thinking without type is NOT active", () => { + assert.equal(hasActiveClaudeThinking({ thinking: {} }), false); +}); diff --git a/tests/unit/translator-nonstream-reasoning-leak-502.test.ts b/tests/unit/translator-nonstream-reasoning-leak-502.test.ts new file mode 100644 index 0000000000..193f220e93 --- /dev/null +++ b/tests/unit/translator-nonstream-reasoning-leak-502.test.ts @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; + +// Regression for the non-stream reasoning leak (call log 1787645055806-4256f2, +// 2026-08-25T08:06:52Z). The streaming translator (openai-to-claude.ts) gained a +// `requestedThinking` gate + reasoning-as-text fallback in ba0bd3af8 so a +// reasoning-only upstream response with thinking NOT requested still yields a +// content block (no 502) without leaking a thinking block. The NON-streaming +// translator (responseTranslator.ts::convertOpenAINonStreamingToClaude) is a +// separate, duplicated code path that never received `requestedThinking`, so it +// unconditionally pushes reasoning_content as a `type:"thinking"` block — leaking +// reasoning to a thinking-opt-out client (Claude Code sends `thinking:{type:"disabled"}`). +// +// This reproduces the requestedThinking===false path that the existing tests +// (issue-7856, issue-6623) do NOT cover — those call translateNonStreamingResponse +// WITHOUT a requestedThinking argument, so they keep the legacy "always thinking +// block" behaviour. The fix must only change behaviour when requestedThinking is +// EXPLICITLY false (opt-out), preserving the un-passed default for back-compat. + +// OpenAI-shape response that carries ONLY reasoning_content, empty content — the +// exact GLM-5.2 autocompact pattern (reasoning-only, no ordinary content). +const reasoningOnlyResponse = { + id: "chatcmpl-test", + object: "chat.completion", + model: "GLM-5.2", + choices: [ + { + index: 0, + message: { role: "assistant", content: null, reasoning_content: 'The user just said "hi"' }, + finish_reason: "length", + }, + ], + usage: { prompt_tokens: 117, completion_tokens: 5 }, +} as const; + +// OpenAI-shape response with BOTH reasoning_content and ordinary content. +const reasoningAndContentResponse = { + id: "chatcmpl-test2", + object: "chat.completion", + model: "GLM-5.2", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Answer", reasoning_content: "Plan" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20 }, +} as const; + +const typesOf = (r: unknown): string[] => { + const content = (r as { content?: Array<{ type?: string }> })?.content; + return Array.isArray(content) ? content.map((b) => b?.type ?? "?") : []; +}; + +test("REGRESSION guard: non-stream reasoning-only with requestedThinking=false relays reasoning as a TEXT block (no thinking leak, no 502)", () => { + // requestedThinking EXPLICITLY false — client opted out of thinking. + const translated = translateNonStreamingResponse( + reasoningOnlyResponse, + "openai", + "claude", + null, + null, + false + ) as { content?: Array<{ type?: string }> }; + + const ts = typesOf(translated); + console.log(" reasoning-only requestedThinking=false -> content types:", ts); + + // FIX: reasoning must NOT surface as a thinking block when the client opted out. + assert.ok(!ts.includes("thinking"), "no thinking block leaked to a thinking-opt-out client"); + // FIX: a content block must still be produced (reasoning relayed as text) so the + // response is not empty — mirrors the streaming 502 fix. + assert.ok( + ts.includes("text"), + "reasoning relayed as an ordinary text block (not dropped, not 502)" + ); +}); + +test("REGRESSION guard: non-stream reasoning+content with requestedThinking=false drops reasoning, keeps only the text block", () => { + // When ordinary content IS present, reasoning is suppressed (thinking not requested) + // and content is the sole block — matching the streaming translator's + // "content supersedes buffered reasoning" behaviour. + const translated = translateNonStreamingResponse( + reasoningAndContentResponse, + "openai", + "claude", + null, + null, + false + ) as { content?: Array<{ type?: string }> }; + + const ts = typesOf(translated); + console.log(" reasoning+content requestedThinking=false -> content types:", ts); + + assert.ok(!ts.includes("thinking"), "no thinking block leaked when content is present"); + assert.ok(ts.includes("text"), "ordinary content relayed as text"); + // Only one text block — reasoning suppressed, not concatenated into the text block. + const textCount = ts.filter((t) => t === "text").length; + assert.equal(textCount, 1, "exactly one text block (reasoning suppressed, not merged)"); +}); + +test("BACKCOMPAT: non-stream reasoning-only WITHOUT requestedThinking arg keeps the legacy thinking block (existing callers unchanged)", () => { + // No 6th argument — legacy callers (issue-7856, issue-6623) keep thinking-block relay. + const translated = translateNonStreamingResponse( + reasoningOnlyResponse, + "openai", + "claude", + null + ) as { content?: Array<{ type?: string }> }; + + const ts = typesOf(translated); + console.log(" reasoning-only no requestedThinking arg -> content types:", ts); + + assert.ok(ts.includes("thinking"), "legacy callers still get a thinking block (back-compat)"); +}); + +test("BACKCOMPAT: non-stream reasoning-only with requestedThinking=true keeps the thinking block (opted in)", () => { + const translated = translateNonStreamingResponse( + reasoningOnlyResponse, + "openai", + "claude", + null, + null, + true + ) as { content?: Array<{ type?: string }> }; + + const ts = typesOf(translated); + console.log(" reasoning-only requestedThinking=true -> content types:", ts); + + assert.ok(ts.includes("thinking"), "thinking block relayed when client opted in"); +}); diff --git a/tests/unit/translator-reasoning-gate-502-repro.test.ts b/tests/unit/translator-reasoning-gate-502-repro.test.ts new file mode 100644 index 0000000000..4b59b90e64 --- /dev/null +++ b/tests/unit/translator-reasoning-gate-502-repro.test.ts @@ -0,0 +1,111 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +function createState() { + return { + toolCalls: new Map(), + _pendingXmlToolCalls: [], + _xmlInvokeBuffer: "", + }; +} + +function flatten(items: unknown[]) { + return items.flatMap((item) => (item as unknown[]) || []); +} + +// Regression for the autocompact 502 empty_response. The no-502 guard must keep +// holding after the requestedThinking gate is restored on the thinking block +// emission. +// +// History: f6eb328ba/e28d02066 gated reasoning_content relay on +// state.requestedThinking === true. A reasoning-only upstream response +// (GLM-5.2 on a huge autocompact context returns ONLY reasoning_content, empty +// content) with requestedThinking===false produced ZERO content blocks — the +// stream reached flush with no content_block_start, and +// stream.ts::emitClaudeEmptyStreamErrorAndAbort raised a 502 "no content block". +// +// fix A (14534ee95) removed the gate so reasoning_content was ALWAYS relayed as +// a thinking block (content_block_start stays alive => no 502). But that leaked +// reasoning as a thinking block to thinking-opt-out clients (the operator +// reported "reasoning is exposed"). +// +// RESOLUTION (this fix): restore the requestedThinking gate on the thinking +// block EMISSION only, so requestedThinking=false emits NO thinking block (no +// reasoning leak). The _reasoningAccum accumulation stays OUTSIDE the gate and +// always runs, so fix B (translator/response/openai-to-claude.ts finish +// handler) synthesizes a TEXT block from the accumulated reasoning for +// reasoning-only responses — keeping a content_block_start alive so flush does +// NOT 502. This guard verifies the no-502 invariant holds via fix B (not via a +// leaked thinking block). + +test("REGRESSION guard: reasoning-only response with requestedThinking=false does NOT 502 (fix B synthesizes a text block; gate suppresses the thinking block)", () => { + const state = createState(); // requestedThinking absent => false + + // GLM-5.2 autocompact: ONLY reasoning_content, no content delta. + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-ec8832", + model: "glm-5.2", + choices: [ + { + index: 0, + delta: { reasoning_content: "Compacting 154k-token context..." }, + finish_reason: null, + }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-ec8832", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 154172, completion_tokens: 0, total_tokens: 154172 }, + }, + state + ); + const result = flatten([reasoning, final]); + + const contentBlockStarts = result.filter( + (e) => (e as { type?: string })?.type === "content_block_start" + ); + const thinkingBlocks = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const textBlocks = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + const thinkingDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta" + ); + + console.log( + ` content_block_start count=${contentBlockStarts.length} thinking=${thinkingBlocks.length} text=${textBlocks.length} thinkingDelta=${thinkingDeltas.length}` + ); + + // No 502: fix B synthesizes a text block so flush has >= 1 content block. + assert.ok( + contentBlockStarts.length >= 1, + "FIX A/B: requestedThinking=false + reasoning-only must emit >= 1 content block (fix B text block => no 502 at flush)" + ); + // Gate: requestedThinking=false => NO thinking block (no reasoning leak). + assert.equal( + thinkingBlocks.length, + 0, + "requestedThinking=false => NO thinking block (gate stops reasoning leak)" + ); + assert.equal( + thinkingDeltas.length, + 0, + "requestedThinking=false => NO thinking_delta (gate stops reasoning leak)" + ); + // Fix B: a text block is synthesized from the accumulated reasoning. + assert.ok( + textBlocks.length >= 1, + "fix B: reasoning-only synthesizes a text block (keeps content_block_start alive => no 502)" + ); +}); diff --git a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts new file mode 100644 index 0000000000..c29f048026 --- /dev/null +++ b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts @@ -0,0 +1,375 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for the reasoning-leak + compact-loop resolution. +// +// History (systematic-debugging, confirmed 2026-08-31): +// e28d02066 (2026-08-24) gated reasoning_content relay on +// state.requestedThinking === true. With Claude Code autocompact (thinking +// disabled) + GLM-5.2/DeepSeek-V4-Flash returning ONLY reasoning_content on +// huge contexts, the gate produced zero content blocks => 502 "no content +// block", or (ba0bd3af8) reasoning flushed as text that the compact parser +// rejected => compact loop. +// fix A (14534ee95) removed the gate entirely so reasoning_content is ALWAYS +// relayed as a thinking block. That stopped the 502 and (with fix B's text +// synthesis) the compact loop, but leaked reasoning as a thinking block to +// thinking-opt-out clients (requestedThinking=false) — the operator reported +// "reasoning is exposed". +// +// RESOLUTION (this fix): restore the requestedThinking gate on the thinking +// block EMISSION only (content_block_start type:thinking + thinking_delta), +// so requestedThinking=false emits NO thinking block (no reasoning leak). +// The _reasoningAccum accumulation stays OUTSIDE the gate and always runs, so +// fix B still synthesizes a text block for reasoning-only responses (no 502, +// compact applies). This avoids the e28d02066 regression (which gated the +// whole block including accumulation, breaking fix B => 502/compact loop). + +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +function createState() { + return { + toolCalls: new Map(), + _pendingXmlToolCalls: [], + _xmlInvokeBuffer: "", + }; +} + +function flatten(items: unknown[]) { + return items.flatMap((item) => (item as unknown[]) || []); +} + +// RED: requestedThinking=false (default, autocompact) + reasoning-only. The gate +// must suppress the thinking block (no reasoning leak), while fix B synthesizes +// a text block from the accumulated reasoning so flush has a content block (no +// 502) and Claude Code's autocompact parser has a real summary to apply. +test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate) but MUST synthesize a text block (fix B) => no 502, compact applies", () => { + const state = createState(); // requestedThinking absent => false (autocompact) + + // GLM-5.2 autocompact: ONLY reasoning_content, no content delta. + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-gate-fix", + model: "glm-5.2", + choices: [ + { + index: 0, + delta: { reasoning_content: "Compacting 145k-token context..." }, + finish_reason: null, + }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-gate-fix", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 145376, completion_tokens: 5, total_tokens: 145381 }, + }, + state + ); + const result = flatten([reasoning, final]); + + const thinkingStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const textStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + const contentBlockStarts = result.filter( + (e) => (e as { type?: string })?.type === "content_block_start" + ); + + // Gate: requestedThinking=false => NO thinking block (stops reasoning leak). + assert.equal( + thinkingStarts.length, + 0, + "requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate stops reasoning leak)" + ); + // Fix B: reasoning-only synthesizes a text block so flush has a content block. + assert.ok( + contentBlockStarts.length >= 1, + "reasoning-only must keep >= 1 content_block_start (fix B text block, no 502 at flush)" + ); + assert.ok( + textStarts.length >= 1, + "fix B: reasoning-only MUST synthesize a text block (autocompact summary)" + ); + // The message must finish normally (end_turn), not as an error. + const messageDeltas = result.filter((e) => e?.type === "message_delta"); + assert.equal(messageDeltas[0].delta.stop_reason, "end_turn"); +}); + +// RED: requestedThinking=false + reasoning THEN content. The gate suppresses +// the thinking block (no reasoning leak); the ordinary content still starts its +// own text block. e28d02066 suppressed the thinking block too but ALSO blocked +// accumulation; this fix keeps accumulation so fix B never false-fires (real +// content sets textBlockStarted, so the finish gate is skipped). +test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thinking block (gate) but a text block (content)", () => { + const state = createState(); // requestedThinking absent => false + + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-gate-fix-2", + model: "glm-5.2", + choices: [{ index: 0, delta: { reasoning_content: "Planning..." }, finish_reason: null }], + }, + state + ); + const text = openaiToClaudeResponse( + { + id: "chatcmpl-gate-fix-2", + model: "glm-5.2", + choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-gate-fix-2", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + state + ); + const result = flatten([reasoning, text, final]); + + const thinkingStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const textStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + + // Gate: no thinking block (requestedThinking=false, no reasoning leak). + assert.equal( + thinkingStarts.length, + 0, + "requestedThinking=false => no thinking block (gate, no reasoning leak)" + ); + // Ordinary content still emits a text block. + assert.ok(textStarts.length >= 1, "ordinary content still emits a text block"); +}); + +// RED (fix B guard): requestedThinking=false + reasoning-ONLY (no content). The +// gate suppresses the thinking block; fix B synthesizes a text block from the +// accumulated reasoning so Claude Code's autocompact parser has a real summary. +// The accumulation MUST stay outside the gate (e28d02066 gated it too => fix B +// never fired => 502/compact loop regression). +test("REGRESSION (fix B): requestedThinking=false + reasoning-ONLY MUST synthesize a text block (NOT a thinking block) so autocompact can use it as the summary", () => { + const state = createState(); // requestedThinking absent => false (autocompact) + + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-fix-b", + model: "glm-5.2", + choices: [ + { + index: 0, + delta: { reasoning_content: "Summary of the 153k-token context..." }, + finish_reason: null, + }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-fix-b", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 153225, completion_tokens: 8772, total_tokens: 161997 }, + }, + state + ); + const result = flatten([reasoning, final]); + + const thinkingStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const textStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + const textDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta" + ); + + // Gate: NO thinking block (requestedThinking=false, no reasoning leak). + assert.equal( + thinkingStarts.length, + 0, + "requestedThinking=false => no thinking block (gate, no reasoning leak)" + ); + // Fix B: a text block is synthesized from the reasoning so the autocompact + // parser has a real text summary to apply. + assert.ok(textStarts.length >= 1, "fix B: reasoning-only MUST synthesize a text block"); + assert.ok(textDeltas.length >= 1, "fix B: reasoning text carried as text_delta"); + // The synthesized text block carries the reasoning content. + const synthesizedText = (textDeltas[0] as { delta?: { text?: string } }).delta?.text; + assert.equal(synthesizedText, "Summary of the 153k-token context..."); +}); + +// GREEN guard: requestedThinking=true + reasoning MUST still emit a thinking +// block — the gate passes when the client explicitly opted into thinking. This +// guards against the gate over-suppressing (e.g. inverted condition) and +// regressing the thinking-opted-in path. +test("REGRESSION: requestedThinking=true + reasoning MUST emit a thinking block (gate passes)", () => { + const state = createState(); + state.requestedThinking = true; // client explicitly opted into thinking + + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-gate-true", + model: "glm-5.2", + choices: [ + { + index: 0, + delta: { reasoning_content: "Reasoning through the request..." }, + finish_reason: null, + }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-gate-true", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + state + ); + const result = flatten([reasoning, final]); + + const thinkingStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const thinkingDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta" + ); + const textStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + const textDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta" + ); + const contentBlockStarts = result.filter( + (e) => (e as { type?: string })?.type === "content_block_start" + ); + + assert.ok( + thinkingStarts.length >= 1, + "requestedThinking=true => thinking block MUST be emitted (gate passes)" + ); + assert.ok( + thinkingDeltas.length >= 1, + "requestedThinking=true => reasoning content carried as thinking_delta" + ); + // FIX-B double-exposure guard (2026-09-01): when the client opted into + // thinking (requestedThinking===true) AND the response is reasoning-only (no + // content), the thinking block above already provides a content_block_start + // (no 502 at flush). fix B's text-block synthesis MUST NOT fire here — it would + // double-expose the reasoning as BOTH a thinking block AND a text block, showing + // reasoning and response as indistinguishable plain text (the operator reported + // "구별이 전혀 안가고 지저분하다"). + assert.equal( + textStarts.length, + 0, + "requestedThinking=true + reasoning-only => NO fix-B text block (no double exposure)" + ); + assert.equal( + textDeltas.length, + 0, + "requestedThinking=true + reasoning-only => NO text_delta (fix B skipped)" + ); + assert.equal( + contentBlockStarts.length, + 1, + "requestedThinking=true + reasoning-only => exactly 1 content_block_start (the thinking block; no 502)" + ); +}); + +// RED (double-exposure fix, 2026-09-01): requestedThinking=true + reasoning-ONLY. +// The emission gate passes (thinking block emitted) and fix B MUST be skipped +// (requestedThinking===true) so the reasoning is carried ONLY by the thinking +// block — no redundant text block synthesised. Before the fix, fix B fired +// unconditionally and the reasoning appeared as BOTH a thinking block AND a text +// block (double exposure). +test("REGRESSION (double-exposure): requestedThinking=true + reasoning-ONLY MUST emit a thinking block AND MUST NOT synthesize a fix-B text block (no double exposure)", () => { + const state = createState(); + state.requestedThinking = true; // client explicitly opted into thinking + + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-double-expose", + model: "glm-5.2", + choices: [ + { + index: 0, + delta: { reasoning_content: "Reasoning through the huge context..." }, + finish_reason: null, + }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-double-expose", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 150000, completion_tokens: 12, total_tokens: 150012 }, + }, + state + ); + const result = flatten([reasoning, final]); + + const thinkingStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking" + ); + const thinkingDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta" + ); + const textStarts = result.filter( + (e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text" + ); + const textDeltas = result.filter( + (e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta" + ); + const contentBlockStarts = result.filter( + (e) => (e as { type?: string })?.type === "content_block_start" + ); + const messageDeltas = result.filter((e) => (e as { type?: string })?.type === "message_delta"); + + assert.equal( + thinkingStarts.length, + 1, + "requestedThinking=true + reasoning-only => exactly 1 thinking block (gate passes)" + ); + assert.ok( + thinkingDeltas.length >= 1, + "requestedThinking=true + reasoning-only => reasoning carried as thinking_delta" + ); + assert.equal( + textStarts.length, + 0, + "requestedThinking=true + reasoning-only => NO text block (fix B skipped, no double exposure)" + ); + assert.equal( + textDeltas.length, + 0, + "requestedThinking=true + reasoning-only => NO text_delta (fix B skipped)" + ); + assert.equal( + contentBlockStarts.length, + 1, + "requestedThinking=true + reasoning-only => exactly 1 content_block_start (the thinking block; no 502)" + ); + assert.equal( + messageDeltas[0].delta.stop_reason, + "end_turn", + "requestedThinking=true + reasoning-only => message finishes end_turn" + ); +}); diff --git a/tests/unit/translator-resp-dsml-integration.test.ts b/tests/unit/translator-resp-dsml-integration.test.ts new file mode 100644 index 0000000000..2dc3ae7582 --- /dev/null +++ b/tests/unit/translator-resp-dsml-integration.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Integration: DSML markers that DeepSeek-V4-Flash leaks into the content +// delta must be parsed/scrubbed by the openai-to-claude response translator +// before they reach the Claude client as visible text. Complete <|DSML|:Tool> +// blocks become tool_use blocks; stray closing markers (the broken +// 1787566395384-bab9ab case — closers only, finish_reason "stop", no +// tool_calls) are stripped so the client never sees the markers. +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +const FW = "\u{FF5C}"; // full-width vertical line | + +function createState() { + return { + toolCalls: new Map(), + _pendingXmlToolCalls: [], + _xmlInvokeBuffer: "", + }; +} + +function flatten(items) { + return items.flatMap((item) => item || []); +} + +function allText(result) { + return result + .filter((e) => e?.delta?.type === "text_delta") + .map((e) => e.delta.text) + .join(""); +} + +test("translator strips stray DSML closing markers from content delta", () => { + // The 2026-08-24 broken case: content is ONLY the three stray closers, no + // opener, tool_calls absent, finish_reason "stop". The client must not + // receive the markers as visible text. + const state = createState(); + const markers = `\n\n`; + const first = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-stray", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: { content: markers }, finish_reason: null }], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-stray", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + state + ); + const result = flatten([first, final]); + const text = allText(result); + + assert.ok( + !text.includes("DSML"), + `DSML markers must not reach client, got: ${JSON.stringify(text)}` + ); + assert.ok( + !text.includes("|"), + `full-width pipe must not reach client, got: ${JSON.stringify(text)}` + ); +}); + +test("translator keeps echoed preamble and strips only trailing DSML closers", () => { + // 2026-08-20 case: a real (echoed) text preamble precedes the stray closers. + // The preamble must be preserved; only the trailing markers removed. + const state = createState(); + const echo = "# Harness CWD note\n\nsome echoed text.\n"; + const markers = `\n\n`; + const first = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-echo", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: { content: echo + markers }, finish_reason: null }], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-echo", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + state + ); + const result = flatten([first, final]); + const text = allText(result); + + assert.ok( + text.includes("# Harness CWD note"), + `echo preamble preserved, got: ${JSON.stringify(text)}` + ); + assert.ok(!text.includes("DSML"), `DSML markers stripped, got: ${JSON.stringify(text)}`); +}); + +test("translator parses a complete DSML block into a tool_use block", () => { + // 2026-08-18 complete case: <|DSML|:Read>... + // should become a tool_use block, not visible text. + const state = createState(); + const block = `<${FW}DSML${FW}:Read>\nimages/omniroute.png\n`; + const first = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-complete", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: { content: block }, finish_reason: null }], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-dsml-complete", + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + state + ); + const result = flatten([first, final]); + const text = allText(result); + const toolUseStarts = result.filter((e) => e?.content_block?.type === "tool_use"); + + assert.ok( + !text.includes("DSML"), + `DSML markers must not reach client as text, got: ${JSON.stringify(text)}` + ); + assert.ok( + toolUseStarts.length >= 1, + `complete DSML block should produce a tool_use block, got ${JSON.stringify(result.map((e) => e?.type))}` + ); + assert.equal(toolUseStarts[0].content_block.name, "Read"); +}); diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index f8f1cfcfdb..5e630373e2 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -15,6 +15,14 @@ function createState() { }; } +/** State carrying explicit client thinking intent (thinking:{type:"enabled"}). */ +function createThinkingState() { + return { + ...createState(), + requestedThinking: true, + }; +} + function flatten(items) { return items.flatMap((item) => item || []); } @@ -52,7 +60,7 @@ test("OpenAI stream: text delta starts Claude message and closes cleanly on stop }); test("OpenAI stream: reasoning_content closes before text content starts", () => { - const state = createState(); + const state = createThinkingState(); const reasoning = openaiToClaudeResponse( { id: "chatcmpl-2", @@ -78,6 +86,37 @@ test("OpenAI stream: reasoning_content closes before text content starts", () => assert.equal(result[5].delta.text, "Answer"); }); +test("OpenAI stream: reasoning_content is suppressed by default when client did not request thinking", () => { + const state = createState(); + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-2d", + model: "gpt-4.1", + choices: [{ index: 0, delta: { reasoning_content: "Plan" }, finish_reason: null }], + }, + state + ); + const text = openaiToClaudeResponse( + { + id: "chatcmpl-2d", + model: "gpt-4.1", + choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }], + }, + state + ); + const result = flatten([reasoning, text]); + + assert.equal( + result.some( + (event) => event.type === "content_block_start" && event.content_block?.type === "thinking" + ), + false + ); + assert.equal(result[0].type, "message_start"); + assert.equal(result[1].content_block.type, "text"); + assert.equal(result[2].delta.text, "Answer"); +}); + test("OpenAI stream: internal reasoning replay placeholder stays hidden from Claude thinking block", () => { const state = createState(); const placeholder = openaiToClaudeResponse( @@ -594,3 +633,52 @@ test("OpenAI stream: no XML in content behaves normally", () => { test("OpenAI stream: null chunk is ignored", () => { assert.equal(openaiToClaudeResponse(null, createState()), null); }); + +// Regression for the autocompact 502 empty_response (call logs +// 1787569671800-5782c0, 1787570213960-d98520): Claude Code sends +// thinking:{type:"adaptive"} on autocompact. A prior gate (e28d02066) only +// recognized type === "enabled", so adaptive left requestedThinking false and +// the translator DROPPED a GLM-5.2 reasoning-only response — no content block +// survived, and stream.ts:emitClaudeEmptyStreamErrorAndAbort raised a 502. +// With adaptive now recognized (hasActiveClaudeThinking), reasoning_content +// must become a thinking block so the stream is never empty. +test("OpenAI stream: reasoning-only response with thinking:{type:adaptive} is relayed as a thinking block (not empty 502)", () => { + // Simulate the chatCore-side decision: adaptive is now treated as thinking + // requested (mirrors hasActiveClaudeThinking). + const state = createThinkingState(); + // GLM-5.2 returns ONLY reasoning_content, no content — the autocompact case. + const reasoning = openaiToClaudeResponse( + { + id: "chatcmpl-adaptive-502", + model: "glm-5.2", + choices: [ + { index: 0, delta: { reasoning_content: "Compacting context..." }, finish_reason: null }, + ], + }, + state + ); + const final = openaiToClaudeResponse( + { + id: "chatcmpl-adaptive-502", + model: "glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 100, completion_tokens: 5, total_tokens: 105 }, + }, + state + ); + const result = flatten([reasoning, final]); + + // A thinking block MUST be present — this is what prevents the empty-stream + // 502 at flush time. With the old enabled-only gate, requestedThinking would + // be false and this block would never be emitted. + const thinkingStarts = result.filter((e) => e?.content_block?.type === "thinking"); + assert.ok( + thinkingStarts.length >= 1, + "adaptive must produce a thinking block so the stream is not empty" + ); + const thinkingDeltas = result.filter((e) => e?.delta?.type === "thinking_delta"); + assert.equal(thinkingDeltas[0].delta.thinking, "Compacting context..."); + // The message must finish normally (end_turn), not as an error. + const messageDeltas = result.filter((e) => e?.type === "message_delta"); + assert.equal(messageDeltas[0].delta.stop_reason, "end_turn"); +});