From 0a0fdad00104658f2f30df5288aa5f52f65b433e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:49:37 -0300 Subject: [PATCH] fix(translator): join reasoning summary segments with newline separators (#9500) Closes #9500 --- .../fixes/9500-reasoning-summary-separator.md | 1 + open-sse/handlers/responseTranslator.ts | 12 ++- open-sse/handlers/sseParser.ts | 33 ++++--- .../translator/response/openai-responses.ts | 14 +-- .../response/openai-responses/pureHelpers.ts | 37 +++++++- .../repro-9500-reasoning-separator.test.ts | 85 +++++++++++++++++++ 6 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fixes/9500-reasoning-summary-separator.md create mode 100644 tests/unit/repro-9500-reasoning-separator.test.ts diff --git a/changelog.d/fixes/9500-reasoning-summary-separator.md b/changelog.d/fixes/9500-reasoning-summary-separator.md new file mode 100644 index 0000000000..95f5565a72 --- /dev/null +++ b/changelog.d/fixes/9500-reasoning-summary-separator.md @@ -0,0 +1 @@ +- fix(translator): join reasoning summary segments with newline separators (#9500) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 7c03f1f623..0c297b0491 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -166,14 +166,18 @@ export function translateNonStreamingResponse( if (!part || typeof part !== "object") continue; const partObj = toRecord(part); if (partObj.type === "summary_text" && typeof partObj.text === "string") { - reasoningContent += partObj.text; + // #9500 — reasoning summary parts are discrete segments; join with "\n\n" + // (matches extractThinkingFromContent convention) so they don't glue back-to-back. + reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; } } } else if (itemObj.type === "reasoning" && Array.isArray(itemObj.summary)) { for (const part of itemObj.summary) { const partObj = toRecord(part); if (partObj.type === "summary_text" && typeof partObj.text === "string") { - reasoningContent += partObj.text; + // #9500 — reasoning summary parts are discrete segments; join with "\n\n" + // (matches extractThinkingFromContent convention) so they don't glue back-to-back. + reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; } } } else if (itemObj.type === "function_call") { @@ -328,7 +332,9 @@ export function translateNonStreamingResponse( for (const part of content.parts) { const partObj = toRecord(part); if (partObj.thought === true && typeof partObj.text === "string") { - reasoningContent += partObj.text; + // #9500 — Gemini thinking parts are discrete segments; join with "\n\n" + // (matches extractThinkingFromContent convention) so they don't glue back-to-back. + reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; continue; } diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index d2e634e12a..3635f0149d 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -711,11 +711,19 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { toIdString(evt.item_id) ); const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; - const firstPart = - summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; - firstPart.type = firstPart.type || "summary_text"; - firstPart.text = `${toString(firstPart.text)}${toString(evt.delta)}`; - summary[0] = firstPart; + // #9500 — respect summary_index: each segment is a distinct summary_text + // part. Place deltas at summary[summary_index] (growing the array) so + // segments are preserved for later "\n\n" joining on the non-stream path, + // instead of overwriting summary[0] regardless of index. + const summaryIndex = + typeof evt.summary_index === "number" ? evt.summary_index : 0; + const part = + summary[summaryIndex] && typeof summary[summaryIndex] === "object" + ? { ...toRecord(summary[summaryIndex]) } + : { type: "summary_text", text: "" }; + part.type = part.type || "summary_text"; + part.text = `${toString(part.text)}${toString(evt.delta)}`; + summary[summaryIndex] = part; reasoningItem.summary = summary; } @@ -726,11 +734,16 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { toIdString(evt.item_id) ); const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; - const firstPart = - summary.length > 0 ? { ...toRecord(summary[0]) } : { type: "summary_text", text: "" }; - firstPart.type = firstPart.type || "summary_text"; - firstPart.text = toString(evt.text, toString(firstPart.text)); - summary[0] = firstPart; + // #9500 — respect summary_index on the terminal done event too. + const summaryIndex = + typeof evt.summary_index === "number" ? evt.summary_index : 0; + const part = + summary[summaryIndex] && typeof summary[summaryIndex] === "object" + ? { ...toRecord(summary[summaryIndex]) } + : { type: "summary_text", text: "" }; + part.type = part.type || "summary_text"; + part.text = toString(evt.text, toString(part.text)); + summary[summaryIndex] = part; reasoningItem.summary = summary; } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 112355381f..1b22d5ce3a 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -18,6 +18,7 @@ import { normalizeOutputIndex, normalizeUpstreamFailure, getVisibleResponsesReasoningSummaryText, + buildResponsesReasoningSummaryDelta, } from "./openai-responses/pureHelpers.ts"; import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; import { buildResponsesToolCallItem } from "./responsesToolItem.ts"; @@ -1122,17 +1123,16 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { }; } - // Handle true reasoning summary ("Thought for 15s"). - // Emit as `delta.reasoning_content` — matches the shape used by the - // `reasoning_content_text.delta` branch above and is what Chat clients - // (OpenCode, Claude Code, Cursor, etc.) actually render in their thinking - // panel. A nested `delta.reasoning.summary` object is swallowed by most - // stream mergers and never reaches the user. + // Handle true reasoning summary ("Thought for 15s"). Emit as `delta.reasoning_content` + // — matches the `reasoning_content_text.delta` branch above and is what Chat clients + // (OpenCode, Claude Code, Cursor, etc.) render in their thinking panel. A nested + // `delta.reasoning.summary` object is swallowed by most stream mergers. if (eventType === "response.reasoning_summary_text.delta") { const reasoningDelta = data.delta || ""; if (!reasoningDelta) return null; markResponsesReasoningDeltaEmitted(state, data.item_id); - return buildResponsesReasoningDeltaChunk(state, reasoningDelta); + const deltaText = buildResponsesReasoningSummaryDelta(state, data, reasoningDelta); + return buildResponsesReasoningDeltaChunk(state, deltaText); } // #5786 — reasoning summary exposed ONLY as a terminal snapshot on diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index e2cc70fce4..e010b2c6f4 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -166,11 +166,46 @@ export function normalizeUpstreamFailure(data, fallbackType = "server_error") { export function extractResponsesReasoningSummaryText(item) { if (!item || !Array.isArray(item.summary)) return ""; + // #9500 — reasoning summary parts are discrete segments; join with "\n\n" + // (matches extractThinkingFromContent convention). Filter empties so an + // empty summary_text element does not produce a dangling separator. return item.summary .map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "" ) - .join(""); + .filter((text) => text.length > 0) + .join("\n\n"); +} + +// #9500 — streaming separator helper. When summary_index increments mid-stream +// for a given item_id, a new reasoning segment begins; prefix "\n\n" so segments +// don't arrive back-to-back. Only prefixes when a delta was already emitted for +// the item AND the index advanced — never on the first segment. +export function buildResponsesReasoningSummaryDelta(state, data, reasoningDelta) { + const itemId = data.item_id != null ? String(data.item_id) : ""; + const summaryIndex = + typeof data.summary_index === "number" ? data.summary_index : null; + if (!(state.reasoningSummaryIndex instanceof Map)) { + state.reasoningSummaryIndex = new Map(); + } + const lastIndex = itemId ? state.reasoningSummaryIndex.get(itemId) : undefined; + const alreadyEmittedForItem = itemId + ? state.reasoningItemsWithDelta instanceof Set && + state.reasoningItemsWithDelta.has(itemId) + : Boolean(state.reasoningDeltaEmitted); + let deltaText = reasoningDelta; + if ( + summaryIndex !== null && + lastIndex !== undefined && + summaryIndex > lastIndex && + alreadyEmittedForItem + ) { + deltaText = `\n\n${reasoningDelta}`; + } + if (itemId && (lastIndex === undefined || summaryIndex > lastIndex)) { + state.reasoningSummaryIndex.set(itemId, summaryIndex); + } + return deltaText; } // #7095/#7176 — when Codex exposes a reasoning item only as encrypted private diff --git a/tests/unit/repro-9500-reasoning-separator.test.ts b/tests/unit/repro-9500-reasoning-separator.test.ts new file mode 100644 index 0000000000..4f4de5d8d0 --- /dev/null +++ b/tests/unit/repro-9500-reasoning-separator.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { translateNonStreamingResponse } = await import( + "../../open-sse/handlers/responseTranslator.ts" +); +const { extractResponsesReasoningSummaryText } = await import( + "../../open-sse/translator/response/openai-responses/pureHelpers.ts" +); +const { openaiResponsesToOpenAIResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); + +const SEG_A = "**Planning exact formatted output**"; +const SEG_B = "**Confirming exact reproduction requirement**"; +const EXPECTED = `${SEG_A}\n\n${SEG_B}`; + +function readReasoning(msg) { + if (!msg) return null; + return ( + msg.reasoning_content ?? + msg.reasoning ?? + (Array.isArray(msg.reasoning_summary) + ? msg.reasoning_summary.map((p) => p?.text ?? "").join("") + : null) + ); +} + +test("#9500 site 1: non-streaming reasoning summary parts joined with separator", () => { + const responseBody = { + object: "response", + model: "cx/gpt-test", + output: [ + { type: "reasoning", id: "rs_1", summary: [ + { type: "summary_text", text: SEG_A }, + { type: "summary_text", text: SEG_B }, + ]}, + { type: "message", content: [{ type: "output_text", text: "ok" }] }, + ], + usage: {}, + }; + const projected = translateNonStreamingResponse( + responseBody, + FORMATS.OPENAI_RESPONSES, // target — flattens into chat.completion + FORMATS.OPENAI // source + ); + const msg = projected?.choices?.[0]?.message; + const reasoning = readReasoning(msg); + assert.ok(reasoning !== null, `could not locate reasoning field: ${JSON.stringify(msg)}`); + assert.equal(reasoning, EXPECTED, `segments must be separated by "\n\n", got: ${JSON.stringify(reasoning)}`); +}); + +test("#9500 site 2: extractResponsesReasoningSummaryText joins with separator", () => { + const item = { + type: "reasoning", id: "rs_1", + summary: [ + { type: "summary_text", text: SEG_A }, + { type: "summary_text", text: SEG_B }, + ], + }; + const text = extractResponsesReasoningSummaryText(item); + assert.equal(text, EXPECTED, `helper must join with "\n\n", got: ${JSON.stringify(text)}`); +}); + +test("#9500 site 3: streaming emits separator when summary_index changes", () => { + const state = { started: false, chatId: null, created: null, toolCallIndex: 0, finishReasonSent: false }; + const delta1 = openaiResponsesToOpenAIResponse( + { type: "response.reasoning_summary_text.delta", delta: SEG_A, item_id: "rs_1", output_index: 0, summary_index: 0 }, + state + ); + assert.ok(delta1, "first delta should produce a chunk"); + const a = delta1.choices[0].delta.reasoning_content ?? delta1.choices[0].delta.reasoning_text; + + const delta2 = openaiResponsesToOpenAIResponse( + { type: "response.reasoning_summary_text.delta", delta: SEG_B, item_id: "rs_1", output_index: 0, summary_index: 1 }, + state + ); + assert.ok(delta2, "second delta should produce a chunk"); + const b = delta2.choices[0].delta.reasoning_content ?? delta2.choices[0].delta.reasoning_text; + + assert.ok(b.startsWith("\n\n"), `new-segment delta must be prefixed with "\n\n", got: ${JSON.stringify(b)}`); + assert.equal(b, `\n\n${SEG_B}`); + assert.equal(a, SEG_A); +});