diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index ba6a20367c..04cedf5999 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -957,9 +957,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = ] } -const onMessageStop = (state: ParserState): StepResult => { +const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) { + const result = yield* ToolStream.finishAll(ADAPTER, state.tools) const events: LLMEvent[] = [] - const lifecycle = Lifecycle.finish(state.lifecycle, events, { + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + const finished = Lifecycle.finish(lifecycle, events, { reason: state.pendingFinish?.reason ?? { normalized: "unknown", raw: undefined, @@ -967,8 +970,8 @@ const onMessageStop = (state: ParserState): StepResult => { usage: state.usage, providerMetadata: state.pendingFinish?.providerMetadata, }) - return [{ ...state, lifecycle }, events] -} + return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult +}) // Prefix `error.type` so overloads, rate limits, and quota errors are visible // even when the provider message is generic or empty. @@ -992,7 +995,7 @@ const step = (state: ParserState, event: AnthropicEvent) => { if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) - if (event.type === "message_stop") return Effect.succeed(onMessageStop(state)) + if (event.type === "message_stop") return onMessageStop(state) if (event.type === "error") return onError(event) return Effect.succeed([state, NO_EVENTS]) } diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 4d7d0714f6..f4e2fc7dcf 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -955,6 +955,37 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("settles pending tool calls at message_stop", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "call_1", name: "lookup" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' }, + }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ), + ), + ), + ) + + expect(response.toolCalls).toMatchObject([ + { id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" }) + }), + ) + it.effect("assembles and persists multiple tool calls from one Anthropic response", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe(