From 7f51da509be509a3116c03bd2c1efcd17b2cdbca Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:37:49 -0500 Subject: [PATCH] fix(ai): preserve terminal reasoning metadata (#44997) --- packages/ai/src/protocols/open-responses.ts | 6 +- .../openai-compatible-responses.test.ts | 32 ++++ .../ai/test/provider/openai-responses.test.ts | 141 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 71ca8c39277..24dcfb3db9b 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -1118,7 +1118,11 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* ( event.response?.output ?? [], () => [state, NO_EVENTS] satisfies StepResult, ([current, events], item) => { - if (item.type !== "function_call" || !item.id || !current.tools[item.id]) + if ( + !item.id || + ((item.type !== "function_call" || !current.tools[item.id]) && + (item.type !== "reasoning" || !current.reasoningItems[item.id])) + ) return Effect.succeed([current, events] satisfies StepResult) return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe( Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult), diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 18b1db7e60f..93ade26eb71 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -267,6 +267,38 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("preserves terminal reasoning metadata when item completion is missing", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + }).model("example-model") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_raw", encrypted_content: null }, + }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" }, + { + type: "response.completed", + response: { + output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }], + }, + }, + ), + ), + ), + ) + + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ + providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } }, + }) + }), + ) + it.effect("reconciles raw reasoning finals without streamed deltas", () => Effect.gen(function* () { const model = configure({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 452601de90e..0bcf24a26be 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2285,6 +2285,147 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves terminal reasoning metadata when output item completion is missing", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLMRequest.update(request, { providerOptions: { store: false } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { + type: "response.reasoning_summary_text.delta", + item_id: "rs_1", + summary_index: 0, + delta: "Checked the diff.", + }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { + type: "response.completed", + response: { + id: "resp_1", + output: [ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "terminal-state", + summary: [{ type: "summary_text", text: "Checked the diff." }], + }, + ], + }, + }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Checked the diff.") + expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([ + { + type: "reasoning-end", + id: "rs_1:0", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } }, + }, + ]) + expect(response.message.content).toContainEqual({ + type: "reasoning", + text: "Checked the diff.", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } }, + }) + + const prepared = yield* compileRequest( + LLM.request({ model, messages: [response.message], providerOptions: { store: false } }), + ) + expect(prepared.body.input).toEqual([ + { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Checked the diff." }], + encrypted_content: "terminal-state", + }, + ]) + }), + ) + + it.effect("does not repeat reasoning already finalized by an output item", () => + Effect.gen(function* () { + const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" } + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", item: { ...item, encrypted_content: null } }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" }, + { type: "response.output_item.done", item }, + { type: "response.completed", response: { output: [item] } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1) + expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1) + expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1) + expect(response.reasoning).toBe("Thinking") + }), + ) + + it.effect("reconciles pending reasoning and function calls in completed output order", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLMRequest.update(request, { providerOptions: { store: false } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" }, + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"wea' }, + { + type: "response.completed", + response: { + output: [ + { type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" }, + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"weather"}', + }, + ], + }, + }, + ), + ), + ), + ) + + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } }, + }) + expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([ + expect.objectContaining({ id: "call_1", input: { query: "weather" } }), + ]) + expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( + response.events.findIndex(LLMEvent.is.toolCall), + ) + expect(response.finishReason.normalized).toBe("tool-calls") + }), + ) + it.effect("streams each reasoning summary part as a separate block", () => Effect.gen(function* () { const response = yield* LLMClient.generate(