From 8952cef65f23c7327154e10b241e3b4c78139538 Mon Sep 17 00:00:00 2001 From: Simon Klee <52795+simonklee@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:59:56 +0000 Subject: [PATCH] fix(llm): finalize pending response tool calls --- packages/ai/src/protocols/openai-responses.ts | 18 ++++++++----- .../ai/test/provider/openai-responses.test.ts | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index d03cb003ff..cc8727d55b 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -878,10 +878,15 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* return [state, NO_EVENTS] satisfies StepResult }) -const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - const events: LLMEvent[] = [] +const onResponseFinish = Effect.fn("OpenAIResponses.onResponseFinish")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + const pending = yield* ToolStream.finishAll(ADAPTER, state.tools) + const events = [...pending.events] + const hasFunctionCall = state.hasFunctionCall || pending.events.some(LLMEvent.is.toolCall) const lifecycle = Lifecycle.finish(state.lifecycle, events, { - reason: mapFinishReason(event, state.hasFunctionCall), + reason: mapFinishReason(event, hasFunctionCall), usage: mapUsage(event.response?.usage), providerMetadata: event.response?.id || event.response?.service_tier @@ -891,8 +896,8 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step }) : undefined, }) - return [{ ...state, lifecycle }, events] -} + return [{ ...state, tools: pending.tools, hasFunctionCall, lifecycle }, events] satisfies StepResult +}) // Build a single human-readable message from whatever the provider supplied. // When both code and message are present, prefix the code so consumers see @@ -939,8 +944,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) if (event.type === "response.output_item.done") return onOutputItemDone(state, event) - if (event.type === "response.completed" || event.type === "response.incomplete") - return Effect.succeed(onResponseFinish(state, event)) + if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event) if (event.type === "response.failed") return providerError(event, "OpenAI Responses response failed") if (event.type === "error") return providerError(event, "OpenAI Responses stream error") return Effect.succeed([state, NO_EVENTS]) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 7548340690..aac348db51 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1259,6 +1259,33 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("finalizes an empty function call when the terminal response omits output_item.done", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "item_1", call_id: "call_1", name: "patch", arguments: "" }, + }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "patch", description: "Apply a patch", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toContainEqual({ + type: "tool-call", + id: "call_1", + name: "patch", + input: {}, + providerExecuted: undefined, + providerMetadata: { openai: { itemId: "item_1" } }, + }) + expect(response.finishReason).toBe("tool-calls") + }), + ) + it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => Effect.gen(function* () { const item = {