fix(translator): join reasoning summary segments with newline separators (#9500)

Closes #9500
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 16:49:37 -03:00
committed by GitHub
parent 466d843a2a
commit 0a0fdad001
6 changed files with 161 additions and 21 deletions

View File

@@ -0,0 +1 @@
- fix(translator): join reasoning summary segments with newline separators (#9500)

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
});