fix: strip internal reasoning placeholder from user-visible content (#8081) (#8162)

* fix: strip internal reasoning placeholder from user-visible content (#8081)

The internal reasoning replay sentinel '(prior reasoning summary unavailable)'
can leak into user-visible assistant content when a model echoes it through
ordinary message.content / delta.content. Existing suppression only checked
reasoning_content fields and reasoning-specific events.

Changes:
- Add stripInternalReasoningPlaceholder() to reasoningPlaceholder.ts —
  removes all occurrences of the sentinel and trims; returns '' when
  nothing meaningful remains
- Streaming: strip in responsesTransformer.ts, openai-responses.ts, and
  openai-to-claude.ts at the delta.content entry point; skip emission
  entirely when only the placeholder was present
- Non-streaming: strip in responseSanitizer.ts sanitizeMessageContent()
  and sanitizeResponsesMessageContent() (all three text paths)

translateText is unaffected (uses mode='translate' via plain newsClient).
The per-provider reasoning_content check remains as defense-in-depth.

* fix: skip only empty content block on reasoning-placeholder, keep finish_reason/tool_calls (#8081)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline openai-responses.ts own-growth (#8081 guard)

---------

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Austin Liu
2026-07-23 07:34:51 +09:30
committed by GitHub
parent 066e9275c4
commit 07dada6b81
7 changed files with 270 additions and 160 deletions

View File

@@ -215,7 +215,7 @@
"open-sse/translator/request/openai-to-gemini.ts": 906,
"open-sse/translator/request/openai-to-kiro.ts": 912,
"_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.",
"open-sse/translator/response/openai-responses.ts": 1125,
"open-sse/translator/response/openai-responses.ts": 1137,
"open-sse/utils/cursorAgentProtobuf.ts": 1521,
"open-sse/utils/stream.ts": 2869,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
@@ -316,7 +316,8 @@
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
"src/shared/constants/sidebarVisibility/sections.ts": 806,
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size."
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size."
},
"testCap": 800,
"testFrozen": {

View File

@@ -2,6 +2,7 @@ import {
copyOpenAICompatibleReasoningFields,
getReadableReasoningValue,
} from "../utils/reasoningFields.ts";
import { stripInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
import { normalizeOpenAICompatibleFinishReason } from "../utils/finishReason.ts";
import {
collapseExcessiveNewlines,
@@ -395,7 +396,9 @@ function sanitizeChoice(
function sanitizeMessageContent(msgRecord: JsonRecord, options: ParseOptions = {}): JsonRecord {
if (typeof msgRecord.content === "string") {
const strippedContent = stripInternalToolEnvelopeText(msgRecord.content);
const strippedContent = stripInternalReasoningPlaceholder(
stripInternalToolEnvelopeText(msgRecord.content)
);
const nativeReasoning = getReadableReasoningValue(msgRecord);
const { content, thinking } =
options.parseTextualReasoningTags === true && !nativeReasoning
@@ -519,10 +522,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
const inputDetails = toRecord(normalized.input_tokens_details) || {};
const cachedTokens = normalized.cached_tokens ?? normalized.cache_read_input_tokens;
if (
cachedTokens !== undefined &&
inputDetails.cached_tokens === undefined
) {
if (cachedTokens !== undefined && inputDetails.cached_tokens === undefined) {
inputDetails.cached_tokens = cachedTokens;
}
if (
@@ -845,7 +845,9 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] {
return [
{
type: "output_text",
text: collapseExcessiveNewlines(stripInternalToolEnvelopeText(content)),
text: collapseExcessiveNewlines(
stripInternalReasoningPlaceholder(stripInternalToolEnvelopeText(content))
),
annotations: [],
},
];
@@ -860,7 +862,9 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] {
if (typeof part === "string") {
return {
type: "output_text",
text: collapseExcessiveNewlines(stripInternalToolEnvelopeText(part)),
text: collapseExcessiveNewlines(
stripInternalReasoningPlaceholder(stripInternalToolEnvelopeText(part))
),
annotations: [],
};
}
@@ -877,7 +881,9 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] {
...partRecord,
type: "output_text",
text: collapseExcessiveNewlines(
stripInternalToolEnvelopeText(toString(partRecord.text) || "")
stripInternalReasoningPlaceholder(
stripInternalToolEnvelopeText(toString(partRecord.text) || "")
)
),
annotations: Array.isArray(partRecord.annotations) ? partRecord.annotations : [],
};

View File

@@ -1,6 +1,9 @@
import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts";
import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
import {
isInternalReasoningPlaceholder,
stripInternalReasoningPlaceholder,
} from "../utils/reasoningPlaceholder.ts";
import * as fs from "fs";
import * as path from "path";
/**
@@ -533,93 +536,104 @@ export function createResponsesApiTransformStream(
// Handle text content. Generic prompt-format tags are visible text;
// only tag-native models opt into textual reasoning extraction.
// Strip the internal reasoning placeholder if the model echoed it
// through ordinary content (#8081). Only the text-content emission
// is skipped when nothing meaningful remains — this must NOT skip
// this message's tool_calls / finish_reason handling below, so we
// gate the whole block on strippedContent instead of returning /
// continuing out of the msg loop early.
if (delta.content) {
// Close reasoning if it was opened via native reasoning_content
// and is still open, before emitting message content. Without this
// the reasoning item is never closed and the message reuses the
// reasoning output_index, producing a protocol-invalid stream.
if (
state.reasoningId &&
!state.reasoningDone &&
(!parseTextualReasoningTags || !state.inThinking)
) {
closeReasoning(controller);
}
let content = delta.content;
if (parseTextualReasoningTags) {
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replaceAll("<think>", "");
startReasoning(controller, idx);
}
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(controller, thinkPart);
const strippedContent = stripInternalReasoningPlaceholder(delta.content);
if (strippedContent) {
// Close reasoning if it was opened via native reasoning_content
// and is still open, before emitting message content. Without this
// the reasoning item is never closed and the message reuses the
// reasoning output_index, producing a protocol-invalid stream.
if (
state.reasoningId &&
!state.reasoningDone &&
(!parseTextualReasoningTags || !state.inThinking)
) {
closeReasoning(controller);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(controller, content);
continue;
}
}
let content = strippedContent;
// Regular text content
if (content) {
// Use a distinct output_index for the message when reasoning was
// emitted, so the message item does not collide with the
// reasoning item's output_index.
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx;
if (parseTextualReasoningTags) {
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replaceAll("<think>", "");
startReasoning(controller, idx);
}
// Fix for #1211: Strip leading double-newlines / blank spaces from the very first text chunk
if (!state.msgTextBuf[msgIdx]) {
content = content.trimStart();
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(controller, thinkPart);
closeReasoning(controller);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(controller, content);
// Pre-existing behaviour (unrelated to #8081): a still-open
// textual <think> block ends this message's handling early.
continue;
}
}
if (!content) continue;
// Regular text content
if (content) {
// Use a distinct output_index for the message when reasoning was
// emitted, so the message item does not collide with the
// reasoning item's output_index.
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx;
if (!state.msgItemAdded[msgIdx]) {
state.msgItemAdded[msgIdx] = true;
const msgId = `msg_${state.responseId}_${msgIdx}`;
// Fix for #1211: Strip leading double-newlines / blank spaces from the very first text chunk
if (!state.msgTextBuf[msgIdx]) {
content = content.trimStart();
}
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: msgIdx,
item: { id: msgId, type: "message", content: [], role: "assistant" },
});
}
if (!content) continue;
if (!state.msgContentAdded[msgIdx]) {
state.msgContentAdded[msgIdx] = true;
if (!state.msgItemAdded[msgIdx]) {
state.msgItemAdded[msgIdx] = true;
const msgId = `msg_${state.responseId}_${msgIdx}`;
emit(controller, "response.content_part.added", {
type: "response.content_part.added",
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: msgIdx,
item: { id: msgId, type: "message", content: [], role: "assistant" },
});
}
if (!state.msgContentAdded[msgIdx]) {
state.msgContentAdded[msgIdx] = true;
emit(controller, "response.content_part.added", {
type: "response.content_part.added",
item_id: `msg_${state.responseId}_${msgIdx}`,
output_index: msgIdx,
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: "" },
});
}
emit(controller, "response.output_text.delta", {
type: "response.output_text.delta",
item_id: `msg_${state.responseId}_${msgIdx}`,
output_index: msgIdx,
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: "" },
delta: content,
logprobs: [],
});
if (!state.msgTextBuf[msgIdx]) state.msgTextBuf[msgIdx] = "";
state.msgTextBuf[msgIdx] += content;
}
emit(controller, "response.output_text.delta", {
type: "response.output_text.delta",
item_id: `msg_${state.responseId}_${msgIdx}`,
output_index: msgIdx,
content_index: 0,
delta: content,
logprobs: [],
});
if (!state.msgTextBuf[msgIdx]) state.msgTextBuf[msgIdx] = "";
state.msgTextBuf[msgIdx] += content;
}
}

View File

@@ -7,7 +7,10 @@ import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import { isInternalReasoningPlaceholder } from "../../utils/reasoningPlaceholder.ts";
import {
isInternalReasoningPlaceholder,
stripInternalReasoningPlaceholder,
} from "../../utils/reasoningPlaceholder.ts";
import {
normalizeToolName,
stripEmptyOptionalToolArgs,
@@ -168,43 +171,52 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
startReasoning(state, emit, idx);
emitReasoningDelta(state, emit, delta.reasoning_content);
}
// Strip the internal reasoning placeholder if the model echoed it
// through ordinary content (#8081). Only the text-content emission is
// skipped when nothing meaningful remains; tool_calls / finish_reason
// handling below must still run for this same chunk.
if (delta.content) {
if (
state.reasoningId &&
!state.reasoningDone &&
(!parseTextualReasoningTags || !state.inThinking)
) {
closeReasoning(state, emit);
}
let content = delta.content;
if (parseTextualReasoningTags) {
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replaceAll("<think>", "");
startReasoning(state, emit, idx);
}
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
const strippedContent = stripInternalReasoningPlaceholder(delta.content);
if (strippedContent) {
if (
state.reasoningId &&
!state.reasoningDone &&
(!parseTextualReasoningTags || !state.inThinking)
) {
closeReasoning(state, emit);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(state, emit, content);
return events;
}
}
let content = strippedContent;
if (content) {
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx;
emitTextContent(state, emit, msgIdx, content);
if (parseTextualReasoningTags) {
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replaceAll("<think>", "");
startReasoning(state, emit, idx);
}
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
closeReasoning(state, emit);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(state, emit, content);
// Pre-existing behaviour (unrelated to #8081): a still-open
// textual <think> block ends this chunk's handling early.
return events;
}
}
if (content) {
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx;
emitTextContent(state, emit, msgIdx, content);
}
}
}

View File

@@ -4,7 +4,10 @@ import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts";
import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
import { isInternalReasoningPlaceholder } from "../../utils/reasoningPlaceholder.ts";
import {
isInternalReasoningPlaceholder,
stripInternalReasoningPlaceholder,
} from "../../utils/reasoningPlaceholder.ts";
import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts";
function normalizeToolName(name: string): string {
@@ -208,58 +211,64 @@ export function openaiToClaudeResponse(chunk, state) {
});
}
// Handle regular content
// Handle regular content — strip the internal reasoning placeholder if
// the model echoed it through ordinary content (#8081). Only the content
// block emission is skipped when nothing meaningful remains; the chunk
// may still carry tool_calls / finish_reason below, which must still run.
if (delta?.content) {
stopThinkingBlock(state, results);
const strippedContent = stripInternalReasoningPlaceholder(delta.content);
if (strippedContent) {
stopThinkingBlock(state, results);
// Check for XML <invoke> blocks that some models emit instead of JSON tool_calls
const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks(delta.content, state);
// Check for XML <invoke> blocks that some models emit instead of JSON tool_calls
const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks(strippedContent, state);
// Accumulate extracted tool calls for emission at finish
if (xmlToolCalls.length > 0) {
// Close any ongoing text block before tool calls
stopTextBlock(state, results);
state._pendingXmlToolCalls.push(...xmlToolCalls);
}
// Accumulate extracted tool calls for emission at finish
if (xmlToolCalls.length > 0) {
// Close any ongoing text block before tool calls
stopTextBlock(state, results);
state._pendingXmlToolCalls.push(...xmlToolCalls);
}
// Emit remaining non-XML text content
if (!cleaned) {
// All content was XML invoke blocks — skip text block entirely
// (tool calls will be emitted at finish)
} else if (xmlToolCalls.length > 0) {
// Text before/between/after XML blocks — (re)start a text block
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
// Emit remaining non-XML text content
if (!cleaned) {
// All content was XML invoke blocks — skip text block entirely
// (tool calls will be emitted at finish)
} else if (xmlToolCalls.length > 0) {
// Text before/between/after XML blocks — (re)start a text block
if (!state.textBlockStarted) {
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_start",
type: "content_block_delta",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
delta: { type: "text_delta", text: cleaned },
});
} else {
// No XML — emit as regular text (original behaviour)
if (!state.textBlockStarted) {
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: cleaned },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: cleaned },
});
} else {
// No XML — emit as regular text (original behaviour)
if (!state.textBlockStarted) {
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: cleaned },
});
}
}
@@ -380,7 +389,12 @@ export function openaiToClaudeResponse(chunk, state) {
results.push({
type: "content_block_start",
index: toolInfo.blockIndex,
content_block: { type: "tool_use", id: toolInfo.id, name: toolInfo.name || "", input: {} },
content_block: {
type: "tool_use",
id: toolInfo.id,
name: toolInfo.name || "",
input: {},
},
});
}

View File

@@ -8,3 +8,13 @@ export const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unav
export function isInternalReasoningPlaceholder(value: unknown): boolean {
return typeof value === "string" && value.trim() === NON_ANTHROPIC_THINKING_PLACEHOLDER;
}
/**
* Strip the internal placeholder from user-visible content. Models sometimes
* echo the sentinel through ordinary `message.content` / `delta.content`
* (#8081). Removes all occurrences and trims; returns "" when nothing
* meaningful remains so callers can skip emission entirely.
*/
export function stripInternalReasoningPlaceholder(value: string): string {
return value.replaceAll(NON_ANTHROPIC_THINKING_PLACEHOLDER, "").trim();
}

View File

@@ -113,6 +113,59 @@ test("OpenAI stream: internal reasoning replay placeholder stays hidden from Cla
assert.equal(result[2].delta.text, "Answer");
});
test("OpenAI stream: placeholder-only content bundled with finish_reason still emits the stop event (#8081 regression)", () => {
const state = createState();
// Start a real message with some text first.
const first = openaiToClaudeResponse(
{
id: "chatcmpl-2c",
model: "gpt-4.1",
choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }],
},
state
);
// Final chunk carries the internal reasoning placeholder as ordinary content
// AND the finish_reason in the SAME chunk. The placeholder content must be
// suppressed, but the stop event must still fire (the pre-fix bare `return`
// dropped the finish entirely — this is the regression guard).
const final = openaiToClaudeResponse(
{
id: "chatcmpl-2c",
model: "gpt-4.1",
choices: [
{
index: 0,
delta: { content: "(prior reasoning summary unavailable)" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 },
},
state
);
const result = flatten([first, final]);
// The placeholder text is never emitted as a visible text delta.
assert.equal(
result.some(
(event) =>
event.type === "content_block_delta" &&
event.delta?.type === "text_delta" &&
typeof event.delta.text === "string" &&
event.delta.text.includes("prior reasoning summary unavailable")
),
false
);
// The stop event still fires despite the placeholder-only final content.
const stop = result.find((event) => event.type === "message_delta");
assert.ok(stop, "expected a message_delta stop event even with placeholder-only final content");
assert.equal(stop.delta.stop_reason, "end_turn");
assert.ok(
result.some((event) => event.type === "message_stop"),
"expected a message_stop event"
);
});
test("OpenAI stream: tool calls strip Claude OAuth prefix and keep cache usage", () => {
const state = createState();
const started = openaiToClaudeResponse(