Compare commits

...
Author SHA1 Message Date
Simon Klee b77dbb511e 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.
2026-07-15 23:31:58 +02:00
Simon Kleeand𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 8952cef65f fix(llm): finalize pending response tool calls 2026-07-15 19:50:04 +00:00
2 changed files with 73 additions and 8 deletions
+16 -7
View File
@@ -878,10 +878,18 @@ 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 =
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, {
reason: mapFinishReason(event, state.hasFunctionCall),
reason: mapFinishReason(event, hasFunctionCall),
usage: mapUsage(event.response?.usage),
providerMetadata:
event.response?.id || event.response?.service_tier
@@ -891,8 +899,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 +947,9 @@ 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.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")
return Effect.succeed<StepResult>([state, NO_EVENTS])
@@ -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"
@@ -1259,6 +1259,62 @@ 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("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 = {