Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline dc9b576172 fix(ai): settle pending Anthropic tool calls 2026-08-17 18:50:26 -05:00
Aiden Cline f87abfbdca fix(ai): reject incomplete Anthropic tool calls 2026-08-17 18:21:47 -05:00
2 changed files with 39 additions and 5 deletions
@@ -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<StepResult>([state, NO_EVENTS])
}
@@ -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(