From 9493a53eddaaa27ad182914bbf991deaa3ead76a Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Fri, 21 Aug 2026 19:54:14 -0300 Subject: [PATCH] fix(command-code): surface reasoning-only output as content when no text-delta (#10986) --- .../fixes/10986-reasoning-only-content.md | 1 + config/quality/file-size-baseline.json | 5 +- open-sse/executors/commandCode.ts | 23 ++++++- tests/unit/command-code-executor.test.ts | 62 +++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/10986-reasoning-only-content.md diff --git a/changelog.d/fixes/10986-reasoning-only-content.md b/changelog.d/fixes/10986-reasoning-only-content.md new file mode 100644 index 0000000000..0d293482bd --- /dev/null +++ b/changelog.d/fixes/10986-reasoning-only-content.md @@ -0,0 +1 @@ +- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986) \ No newline at end of file diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2e7fa58dac..fb675933a2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -455,9 +455,10 @@ "src/sse/handlers/chatHelpers.ts": 1019, "src/shared/middleware/chatBodyAdmission.ts": 1005, "_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).", - "open-sse/executors/commandCode.ts": 1038, + "open-sse/executors/commandCode.ts": 1059, "_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).", - "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts." + "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.", + "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)." }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index b736e6cdf1..2479033132 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -738,6 +738,7 @@ function createStreamResponse( const decoder = new TextDecoder(); let buffer = ""; let sentRole = false; + let sentContent = false; let closed = false; const state: AggregateState = { content: "", @@ -772,7 +773,10 @@ function createStreamResponse( switch (event.type) { case "text-delta": { const text = stringValue(event.text) || ""; - if (text) controller.enqueue(sse(chatCompletionChunk(id, model, { content: text }))); + if (text) { + sentContent = true; + controller.enqueue(sse(chatCompletionChunk(id, model, { content: text }))); + } state.content += text; break; } @@ -810,6 +814,12 @@ function createStreamResponse( break; case "finish": { state.finishReason = mapFinishReason(event.finishReason); + // If the model only produced reasoning-delta events (no text-delta), the + // client-visible stream would otherwise end with no content. Emit one + // content delta carrying the accumulated reasoning text (#10986). + if (!sentContent && state.reasoning && state.toolCalls.length === 0) { + controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning }))); + } controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason))); // Emit a standards-compliant usage-only chunk (choices: []) before // [DONE] when upstream reported usage. stream.ts's extractUsage @@ -857,6 +867,9 @@ function createStreamResponse( if (!closed) { if (!sentRole) controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" }))); + if (!sentContent && state.reasoning && state.toolCalls.length === 0) { + controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning }))); + } controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason))); controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); @@ -947,6 +960,14 @@ async function createJsonResponse( } const message: JsonRecord = { role: "assistant", content: state.content }; + // Some Command Code models emit the whole answer as reasoning-delta events and + // never a text-delta. When that leaves content empty, surface the reasoning text + // as content too (#10986) so OpenAI-compatible clients get a usable answer. Keep + // reasoning_content populated as well for reasoning-aware clients, and do not + // override content when real text OR tool calls are present. + if (!state.content && state.reasoning && state.toolCalls.length === 0) { + message.content = state.reasoning; + } if (state.reasoning) message.reasoning_content = state.reasoning; if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls; diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index af1d317f5a..fbc66c6e6e 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -333,6 +333,68 @@ test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON }); }); +test("Command Code reasoning-only output falls back to reasoning as content (non-stream)", async () => { + globalThis.fetch = async () => + commandCodeStream( + [ + { type: "reasoning-delta", text: "The user wants 79874+93658. " }, + { type: "reasoning-delta", text: "That equals 173532." }, + { + type: "finish", + finishReason: "stop", + totalUsage: { inputTokens: 20, outputTokens: 64, outputTokenDetails: { reasoningTokens: 61 } }, + }, + ], + { sse: true } + ); + + const { response } = await getExecutor("command-code").execute({ + model: "meta/muse-spark-1.2-contributor", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Calculate 79874+93658, and reply with the result only." }] }, + }); + + const json = await response.json(); + const message = json.choices[0].message; + // Regression #10986: when the model emits only reasoning-delta events (never a + // text-delta), content must fall back to the reasoning text instead of "" (which + // OpenAI-compatible clients treat as null/no answer). + assert.equal(message.content, "The user wants 79874+93658. That equals 173532."); + // reasoning_content must STAY populated for reasoning-aware clients. + assert.equal(message.reasoning_content, "The user wants 79874+93658. That equals 173532."); +}); + +test("Command Code reasoning-only output emits a content delta chunk when streaming", async () => { + globalThis.fetch = async () => + commandCodeStream( + [ + { type: "reasoning-delta", text: "The result is 173532." }, + { type: "finish", finishReason: "stop" }, + ], + { sse: true } + ); + + const { response } = await getExecutor("command-code").execute({ + model: "meta/muse-spark-1.2-contributor", + stream: true, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Calcular 79874+93658" }] }, + }); + + const sse = await response.text(); + assert.match(sse, /data: \[DONE\]/); + const chunks = parseSsePayloads(sse); + assert.equal(chunks[0].choices[0].delta.role, "assistant"); + // Regression #10986: the reasoning-only stream must emit a content delta when it + // otherwise ends with no content. reasoning_content stays present too. + const contentDelta = chunks.find((c) => c.choices[0].delta.content !== undefined); + assert.equal(contentDelta.choices[0].delta.content, "The result is 173532."); + const reasoningDelta = chunks.find((c) => c.choices[0].delta.reasoning_content !== undefined); + assert.equal(reasoningDelta.choices[0].delta.reasoning_content, "The result is 173532."); + assert.equal(chunks.at(-1).choices[0].finish_reason, "stop"); +}); + test("Command Code executor surfaces upstream and streamed errors", async () => { globalThis.fetch = async () => new Response("bad key", { status: 401, statusText: "Unauthorized" });