From b77dbb511ed8af1117dcc744eed05b2472145a3e Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 15 Jul 2026 23:31:58 +0200 Subject: [PATCH] ix(ai): reject incomplete pending tool calls Only finalize pending Responses tool calls after response.completed. Reject incomplete responses with pending calls so partial input cannot be dispatched. --- packages/ai/src/protocols/openai-responses.ts | 7 ++++- .../ai/test/provider/openai-responses.test.ts | 31 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index cc8727d55b..c50d1693ba 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -882,7 +882,10 @@ const onResponseFinish = Effect.fn("OpenAIResponses.onResponseFinish")(function* state: ParserState, event: OpenAIResponsesEvent, ) { - const pending = yield* ToolStream.finishAll(ADAPTER, state.tools) + const pending = + event.type === "response.completed" + ? yield* ToolStream.finishAll(ADAPTER, state.tools) + : { tools: state.tools, events: NO_EVENTS } const events = [...pending.events] const hasFunctionCall = state.hasFunctionCall || pending.events.some(LLMEvent.is.toolCall) const lifecycle = Lifecycle.finish(state.lifecycle, events, { @@ -944,6 +947,8 @@ 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.incomplete" && Object.keys(state.tools).length > 0) + return ProviderShared.eventError(ADAPTER, "OpenAI Responses response incomplete with pending tool calls") 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") diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index aac348db51..dc8cdf217e 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -1286,6 +1286,35 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("rejects an incomplete pending function call", () => + 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.incomplete", + response: { incomplete_details: { reason: "max_output_tokens" } }, + }, + ) + const events: LLMEvent[] = [] + const error = yield* LLMClient.stream( + LLM.updateRequest(request, { + tools: [{ name: "patch", description: "Apply a patch", inputSchema: { type: "object" } }], + }), + ).pipe( + Stream.runForEach((event) => Effect.sync(() => events.push(event))), + Effect.flip, + Effect.provide(fixedResponse(body)), + ) + + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("OpenAI Responses response incomplete with pending tool calls") + }), + ) + it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => Effect.gen(function* () { const item = {