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.
This commit is contained in:
Patrick Wozniak
2026-05-28 22:05:01 +02:00
parent 52ed72aa37
commit 3fba807abf
2 changed files with 62 additions and 0 deletions
+2
View File
@@ -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) ?? "",
+60
View File
@@ -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", () => {