From 3fba807abf68adb507fcd0aedfc843ebf16041e5 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 28 May 2026 22:05:01 +0200 Subject: [PATCH] fix(stream): close thinking block before text or tool output If upstream omits reasoning-end and starts emitting text-delta or tool-call events, the thinking block remained open causing thinking_start -> thinking_delta -> text_start -> ... -> thinking_end ordering. Now endThinking() is called at the start of text-delta and tool-call handlers so thinking_end always precedes text_start or toolcall_start. Adds 2 regression tests for the missing reasoning-end edge case. --- src/core.ts | 2 ++ tests/test-stream.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/core.ts b/src/core.ts index f01198f..e80b44e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -223,6 +223,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { switch (event.type) { case "text-delta": { + endThinking() if (!textBlock) { textBlock = { type: "text", text: "" } output.content.push(textBlock) @@ -286,6 +287,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { case "tool-call": { endTextBlock() + endThinking() const toolCall: ToolCallContent = { type: "toolCall", id: stringValue(event.toolCallId) ?? "", diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 800a972..57b630a 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -202,6 +202,66 @@ describe("streamCommandCode — successful streams", () => { if (done?.type !== "done") throw new Error("expected done") assert.equal(done.message.content[0]?.type, "thinking") }) + + it("closes thinking block before text when reasoning-end is missing", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-start" }), + JSON.stringify({ type: "reasoning-delta", text: "thinking" }), + JSON.stringify({ type: "text-delta", text: "answer" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "done", + ]) + }) + + it("closes thinking block before tool-call when reasoning-end is missing", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-start" }), + JSON.stringify({ type: "reasoning-delta", text: "thinking" }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: JSON.stringify({ path: "/tmp/x" }), + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "toolcall_start", + "toolcall_end", + "done", + ]) + }) }) describe("streamCommandCode — request serialization", () => {