diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 22af8213b6..da553489ae 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -102,8 +102,12 @@ export function claudeToOpenAIResponse(chunk, state) { case "content_block_stop": { if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) { - // Thinking block closed — no additional content needed; - // reasoning_content chunks have already been streamed + // Emit explicit close marker so clients that scan content for `` + // (Claude Code, Cursor, etc.) know the thinking section ended; without + // it the UI stays stuck on the "thinking" indicator after the upstream + // stream completes. Ported from decolua/9router#454. `reasoning_content` + // consumers are unaffected — they already saw the streamed deltas above. + results.push(createChunk(state, { content: "" })); state.inThinkingBlock = false; } state.textBlockStarted = false; diff --git a/tests/unit/anthropic-stream-thinking-close-marker.test.ts b/tests/unit/anthropic-stream-thinking-close-marker.test.ts new file mode 100644 index 0000000000..8b8e5e4e48 --- /dev/null +++ b/tests/unit/anthropic-stream-thinking-close-marker.test.ts @@ -0,0 +1,109 @@ +// Regression test for the Anthropic-compatible stream "thinking block never closes" bug. +// +// When clients consume the OpenAI-compatible stream that OmniRoute synthesises from +// Claude-native SSE, they need an explicit signal that the thinking/reasoning section +// has ended; otherwise the UI stays stuck on the "thinking" indicator even after the +// upstream stream has cleanly completed. +// +// Inspired by upstream decolua/9router PR #454. +// +// Before the fix, the `content_block_stop` event for a thinking block emitted NO +// terminating chunk at all (a previous drift had emitted `reasoning_content: ""`, +// which is semantically a no-op and does not signal "thinking complete" to clients +// such as Claude Code). +// +// The fix emits a `content: ""` chunk on close — matching the convention +// already used throughout OmniRoute (see openai-responses.ts / responsesTransformer.ts +// which split on `` to separate reasoning from final content). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToOpenAIResponse } = await import( + "../../open-sse/translator/response/claude-to-openai.ts" +); + +function newState() { + return { + toolCalls: new Map(), + toolNameMap: new Map(), + messageId: "msg_test", + model: "claude-3-7-sonnet", + toolCallIndex: 0, + }; +} + +test("claudeToOpenAIResponse emits close marker on thinking content_block_stop", () => { + const state = newState(); + + // Open thinking block. + claudeToOpenAIResponse( + { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }, + state + ); + + // Stream reasoning delta. + claudeToOpenAIResponse( + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Plan first." }, + }, + state + ); + + // Close the thinking block. + const closeChunks = claudeToOpenAIResponse( + { type: "content_block_stop", index: 0 }, + state + ); + + assert.ok(Array.isArray(closeChunks), "stop event must return an array of chunks"); + assert.ok( + closeChunks.length >= 1, + "stop event for thinking block must emit at least one close-marker chunk" + ); + + const hasCloseMarker = closeChunks.some( + (chunk) => chunk?.choices?.[0]?.delta?.content === "" + ); + assert.ok( + hasCloseMarker, + `expected a chunk with delta.content === ""; got ${JSON.stringify(closeChunks)}` + ); + + // After close, state flag must be cleared so subsequent thinking blocks are tracked correctly. + assert.equal(state.inThinkingBlock, false); +}); + +test("claudeToOpenAIResponse does not emit on stop of non-thinking blocks", () => { + const state = newState(); + + // Open + immediately close a text block — must NOT inject . + claudeToOpenAIResponse( + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + state + ); + const closeChunks = claudeToOpenAIResponse( + { type: "content_block_stop", index: 0 }, + state + ); + + const arr = Array.isArray(closeChunks) ? closeChunks : []; + const hasCloseMarker = arr.some( + (chunk) => chunk?.choices?.[0]?.delta?.content === "" + ); + assert.equal( + hasCloseMarker, + false, + "text-block close must not emit sentinel" + ); +});