From 07dada6b81e9b949c71995aa0f4a0ab85b3dd884 Mon Sep 17 00:00:00 2001 From: Austin Liu <193228693+Dingding-leo@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:34:51 +0930 Subject: [PATCH] fix: strip internal reasoning placeholder from user-visible content (#8081) (#8162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Probe Test Co-authored-by: Dingding-leo Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- config/quality/file-size-baseline.json | 5 +- open-sse/handlers/responseSanitizer.ts | 22 ++- open-sse/transformer/responsesTransformer.ts | 156 ++++++++++-------- .../translator/response/openai-responses.ts | 78 +++++---- .../translator/response/openai-to-claude.ts | 106 ++++++------ open-sse/utils/reasoningPlaceholder.ts | 10 ++ .../translator-resp-openai-to-claude.test.ts | 53 ++++++ 7 files changed, 270 insertions(+), 160 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 755dca997e..ddf67c5fa2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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": { diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 146e105c75..0e2ebd880c 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -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 : [], }; diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index ff9e86dfd8..3050930ac4 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -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("")) { - state.inThinking = true; - content = content.replaceAll("", ""); - startReasoning(controller, idx); - } - - if (content.includes("")) { - const parts = content.split(""); - const thinkPart = parts[0]; - const textPart = parts.slice(1).join(""); - - 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("")) { + state.inThinking = true; + content = content.replaceAll("", ""); + 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("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + + 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 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; } } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index f47cd1c5c0..2371db9adc 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -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("")) { - state.inThinking = true; - content = content.replaceAll("", ""); - startReasoning(state, emit, idx); - } - - if (content.includes("")) { - const parts = content.split(""); - const thinkPart = parts[0]; - const textPart = parts.slice(1).join(""); - 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("")) { + state.inThinking = true; + content = content.replaceAll("", ""); + startReasoning(state, emit, idx); + } + + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + 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 block ends this chunk's handling early. + return events; + } + } + + if (content) { + const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx; + emitTextContent(state, emit, msgIdx, content); + } } } diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index de34ddc139..ffe1887068 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -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 blocks that some models emit instead of JSON tool_calls - const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks(delta.content, state); + // Check for XML 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: {}, + }, }); } diff --git a/open-sse/utils/reasoningPlaceholder.ts b/open-sse/utils/reasoningPlaceholder.ts index d3669ba2b6..1531c9cb00 100644 --- a/open-sse/utils/reasoningPlaceholder.ts +++ b/open-sse/utils/reasoningPlaceholder.ts @@ -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(); +} diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index 1d831a05f5..f204c7d137 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -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(