feat(core-agent): bump xsai / remove patches (#2164)

## Summary

- Bump catalog `@xsai/*`, `@xsai-ext/providers`, and `xsschema` to
**0.5.0-beta.8**, and delete the three `@xsai/*` pnpm patches — the
beta.2 ones from #1602 and the beta.8 regenerations that landed on main
in `38a008500`.
- Always capture tool failures on the core-agent chat path: xsAI beta.8
marks failed tool executions with `isError: true` on `tool-result.done`,
and `llm-service.ts` maps that to AIRI's `tool-error` event via
`toAiriStreamEvent`, so the agent loop continues instead of aborting.
- Remove the `captureToolErrors` request flag (and stop forwarding it
into `streamText`).
- Rebased onto latest `main` (`b230e16b2`). Includes one follow-up fix:
steps are marked settled before the finish listener runs, and finish
listener failures still reject the stream.

### Related

- Supersedes / follows up on
[#1602](https://github.com/moeru-ai/airi/pull/1602) (`captureToolErrors`
+ xsai patches).

### Scope of capture

| Case | Covered |
| --- | --- |
| A — unknown tool | yes |
| B — invalid / unparseable arguments JSON | yes |
| C — `validate` failure | yes |
| D — `execute` throw | yes |
| `missing_name` / `missing_arguments` | no (xsai still aborts) |
| `repairToolCall` | no |

Error copy on beta.8: `Tool "<toolName>" execution failed: …` (produced
by xsAI).

## Test plan

### Automated (Vitest)

- [x] core-agent `llm-service.test.ts` — 16/16
- [x] core-agent full suite — 82/82
- [x] stage-ui `llm.test.ts` + `chat.contract.test.ts` — 44/44 (the
previous `stepsSettled` timing failure is fixed in this branch)
- [x] stage-ui full suite — 594 passed / 0 failed (one
browser-test-runner teardown error, not a test failure)
- [x] typecheck — core-agent, stage-ui, component-calling, satori-bot
pass; telegram-bot fails only at `src/utils/velin.ts`, which is
pre-existing on main and untouched by this PR

### Real-environment E2E (rebase branch, DeepSeek V4 Flash via DeepSeek
API)

Harness: `/Users/lulu/GitHub/airi-e2e/pr2164/tool-error-e2e-rebase.mjs`
— drives the built `core-agent` `streamFrom` with a deliberately failing
tool.

| Case | Result | Evidence |
| --- | --- | --- |
| D — execute throw | **pass** | `tool-error` carried `Tool
"always_fail" execution failed: boom: deterministic tool failure`; the
model answered: "The always_fail tool threw a deterministic error as
expected." |
| A — unknown tool | **pass** | The model called the unavailable
`search_the_moon_database` after being told truthfully that this tests
AIRI's error capture; runtime returned `tool-error` and the conversation
continued. |
| B — bad arguments JSON | not observed on real model | providers rarely
emit invalid `arguments`; covered by unit test |
| C — `validate` failure | pass (earlier manual run with a temporary
validate-gated tool) | — |

Evidence artifacts:
`/Users/lulu/GitHub/airi-e2e/artifacts/pr2164/tool-error-e2e-2026-08-10T16-49-15-384Z.{json,log}`

### Notes / non-goals

- Fallout-only updates for the xsai beta.8 API rename: `textStream`,
`inputTokens` / `outputTokens` / `totalTokens` in component-calling /
telegram / satori.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lulu
2026-08-11 17:51:15 +08:00
committed by GitHub
co-authored by Cursor autofix-ci[bot]
parent b230e16b2e
commit fd8b7a0ae3
18 changed files with 219 additions and 1034 deletions
@@ -245,8 +245,8 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
return
}
if (streamEvent.type === 'tool-result') {
await emit({ type: 'tool-execution', payload: { eventId: request.event.data.eventId, toolCallId: streamEvent.toolCallId, output: streamEvent.result } })
if (streamEvent.type === 'tool-result' || streamEvent.type === 'tool-error') {
await emit({ type: 'tool-execution', payload: { eventId: request.event.data.eventId, kind: streamEvent.type, toolCallId: streamEvent.toolCallId, output: streamEvent.result } })
return
}
@@ -726,7 +726,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
},
tools: options.tools,
waitForTools: true,
captureToolErrors: true,
onUsage: (usage) => {
generationUsage = usage
deps.onLlmGeneration?.({
@@ -1,7 +1,7 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool } from '@xsai/shared-chat'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isContentArrayRelatedError, sanitizeMessages, streamFrom } from './llm-service'
@@ -39,7 +39,10 @@ function createMockStreamResult(
}
}
describe('streamFrom tool error capture', () => {
describe('streamFrom tool errors', () => {
beforeEach(() => {
streamTextMock.mockReset()
})
it('requests final streaming usage and emits the reported token totals once', async () => {
const onUsage = vi.fn()
streamTextMock.mockReturnValueOnce(createMockStreamResult(
@@ -140,11 +143,7 @@ describe('streamFrom tool error capture', () => {
})).resolves.toBeUndefined()
})
/**
* @example
* await streamFrom({ model, chatProvider, messages, options: { captureToolErrors: true } })
*/
it('keeps captureToolErrors internal while forwarding failed tool calls as tool-error events', async () => {
it('maps xsai tool-error results to AIRI tool-error events without wrapping tools', async () => {
let resolveSteps: ((steps: unknown[]) => void) | undefined
const events: unknown[] = []
const failingTool = {
@@ -160,8 +159,8 @@ describe('streamFrom tool error capture', () => {
} satisfies Tool
streamTextMock.mockImplementationOnce((options: {
captureToolErrors?: boolean
onEvent: (event: unknown) => Promise<void>
preToolCall?: unknown
tools?: Tool[]
}) => {
const steps = new Promise<unknown[]>((resolve) => {
@@ -169,19 +168,15 @@ describe('streamFrom tool error capture', () => {
})
queueMicrotask(async () => {
const result = await options.tools?.[0]?.execute({}, {
messages: [],
toolCallId: 'call-1',
})
await options.onEvent({
type: 'tool-result',
type: 'tool-result.done',
args: {},
result,
isError: true,
result: 'Tool "play_chess" execution failed: Focus mode does not accept game-state mutation inputs.',
toolCallId: 'call-1',
toolName: 'play_chess',
})
await options.onEvent({ type: 'finish', finishReason: 'stop' })
await options.onEvent({ type: 'text.delta', delta: 'ok' })
resolveSteps?.([])
})
@@ -193,7 +188,6 @@ describe('streamFrom tool error capture', () => {
chatProvider: provider,
messages: [{ role: 'user', content: 'play chess' }] as Message[],
options: {
captureToolErrors: true,
tools: [failingTool],
onStreamEvent: (event) => {
events.push(event)
@@ -202,16 +196,35 @@ describe('streamFrom tool error capture', () => {
})
const streamOptions = streamTextMock.mock.calls[0]?.[0]
expect(streamOptions.captureToolErrors).toBeUndefined()
expect(streamOptions.tools?.[0]).not.toBe(failingTool)
expect(failingTool.execute).toHaveBeenCalledTimes(1)
expect(events).toContainEqual(expect.objectContaining({
expect(streamOptions.preToolCall).toBeUndefined()
expect(streamOptions.tools?.[0]).toBe(failingTool)
expect(failingTool.execute).not.toHaveBeenCalled()
expect(events).toContainEqual({
type: 'tool-error',
args: {},
isError: true,
result: 'Tool "play_chess" execution failed: Focus mode does not accept game-state mutation inputs.',
toolCallId: 'call-1',
toolName: 'play_chess',
result: expect.stringContaining('Focus mode does not accept game-state mutation inputs.'),
}))
})
expect(events).toContainEqual({ type: 'text-delta', text: 'ok' })
expect(events).toContainEqual({ type: 'finish' })
})
it('rejects when the finish listener throws instead of leaving the stream pending', async () => {
streamTextMock.mockReturnValueOnce(createMockStreamResult())
await expect(streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
options: {
onStreamEvent: async (event) => {
if (event.type === 'finish')
throw new Error('finish listener failed')
},
},
})).rejects.toThrow('finish listener failed')
})
})
+64 -88
View File
@@ -1,13 +1,11 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool, Usage } from '@xsai/shared-chat'
import type { Event, Message, Usage } from '@xsai/shared-chat'
import type { StreamFromOptions, StreamOptions } from '../types/llm'
import type { StreamEvent, StreamFromOptions, StreamOptions } from '../types/llm'
import { stepCountAtLeast } from '@xsai/shared-chat'
import { streamText } from '@xsai/stream-text'
import { errorMessageFromValue } from '../utils/error-message'
/**
* Normalize chat messages so they match the wire format the active provider
* actually accepts, flattening content-part arrays back to plain strings when
@@ -103,75 +101,45 @@ async function resolveTools(options?: StreamOptions) {
return tools ?? []
}
function isAbortError(error: unknown): boolean {
return typeof error === 'object'
&& error !== null
&& (error as { name?: unknown }).name === 'AbortError'
}
function createCapturedToolErrorResult(toolName: string, error: unknown): string {
return `Tool call error for "${toolName}": ${errorMessageFromValue(error)}`
}
function normalizeUsage(usage: Usage | undefined) {
if (usage?.inputTokens == null || usage.outputTokens == null || usage.totalTokens == null) {
return { source: 'unavailable' as const }
}
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
source: 'reported' as const,
}
}
function withCapturedToolErrors(
tools: Tool[],
capturedToolErrorByCallId: Map<string, string>,
): Tool[] {
return tools.map(tool => ({
...tool,
execute: async (input, executeOptions) => {
try {
return await tool.execute(input, executeOptions)
/**
* Maps xsAI stream events onto the AIRI {@link StreamEvent} contract.
*
* xsAI 0.5.0-beta.8 marks failed tool executions with `isError: true` on
* `tool-result.done` instead of aborting the stream, so AIRI can distinguish
* `tool-error` from `tool-result` directly from the event payload.
*/
function toAiriStreamEvent(event: Event): StreamEvent | null {
switch (event.type) {
case 'text.delta':
return { type: 'text-delta', text: event.delta }
case 'reasoning.delta':
return { type: 'reasoning-delta', text: event.delta }
case 'tool-call.done':
return { ...event, type: 'tool-call' }
case 'tool-result.done':
if (event.isError === true)
return { ...event, type: 'tool-error', isError: true }
return {
type: 'tool-result',
toolCallId: event.toolCallId,
result: typeof event.result === 'string' || Array.isArray(event.result)
? event.result
: JSON.stringify(event.result),
}
catch (error) {
if (isAbortError(error))
throw error
const result = createCapturedToolErrorResult(tool.function.name, error)
capturedToolErrorByCallId.set(executeOptions.toolCallId, result)
return result
case 'error':
return {
type: 'error',
error: event.cause ?? new Error(event.message),
}
},
}))
}
function resolveCapturedToolErrorEvent(
event: unknown,
capturedToolErrorByCallId: Map<string, string>,
) {
if (
typeof event !== 'object'
|| event === null
|| (event as { type?: unknown }).type !== 'tool-result'
|| typeof (event as { toolCallId?: unknown }).toolCallId !== 'string'
) {
return event
}
const toolCallId = (event as { toolCallId: string }).toolCallId
const result = capturedToolErrorByCallId.get(toolCallId)
if (result == null)
return event
capturedToolErrorByCallId.delete(toolCallId)
return {
...event,
type: 'tool-error',
isError: true,
result,
case 'text.start':
case 'text.done':
case 'reasoning.start':
case 'reasoning.done':
case 'step.start':
case 'step.done':
case 'tool-call.start':
case 'tool-call.delta':
return null
}
}
@@ -193,10 +161,6 @@ export async function streamFrom({
const customTools = supportedTools ? await resolveTools(options) : []
const mergedTools = supportedTools ? [...builtinTools, ...customTools] : []
const tools = mergedTools.length > 0 ? mergedTools : undefined
const capturedToolErrorByCallId = new Map<string, string>()
const streamTools = options?.captureToolErrors && tools != null
? withCapturedToolErrors(tools, capturedToolErrorByCallId)
: tools
return new Promise<void>((resolve, reject) => {
let settled = false
@@ -214,13 +178,13 @@ export async function streamFrom({
reject(error)
}
const onEvent = async (event: unknown) => {
const onEvent = async (event: Event) => {
try {
const streamEvent = resolveCapturedToolErrorEvent(event, capturedToolErrorByCallId)
await options?.onStreamEvent?.(streamEvent as any)
if (event && (event as any).type === 'error') {
rejectOnce((event as any).error ?? new Error('Stream error'))
}
const streamEvent = toAiriStreamEvent(event)
if (streamEvent != null)
await options?.onStreamEvent?.(streamEvent)
if (streamEvent?.type === 'error')
rejectOnce(streamEvent.error)
}
catch (error) {
rejectOnce(error)
@@ -235,12 +199,7 @@ export async function streamFrom({
headers: options?.headers,
streamOptions: { includeUsage: true },
stopWhen: stepCountAtLeast(10),
// NOTICE:
// Do not pass xsAI's `captureToolErrors` option here. In the installed
// @xsai/stream-text version, stream options are spread into the provider
// chat body, so unknown runtime-only fields can be rejected upstream.
// AIRI captures tool failures by wrapping local tool executors instead.
tools: streamTools,
tools,
toolChoice: options?.toolChoice,
onEvent,
})
@@ -263,6 +222,19 @@ export async function streamFrom({
// Ignore any late provider error event emitted after xsAI has already
// resolved the authoritative full-step lifecycle.
stepsSettled = true
try {
await options?.onStreamEvent?.({ type: 'finish' } as const)
}
catch (error) {
// The finish listener runs after steps settled, so rejectOnce would
// ignore this error as a "late provider event". A listener failure
// is still a real failure and must reject the outer promise.
if (!settled) {
settled = true
reject(error)
}
return
}
let usage: Usage | undefined
try {
usage = await streamResult.totalUsage
@@ -271,7 +243,11 @@ export async function streamFrom({
console.error('Stream totalUsage error:', error)
}
try {
await options?.onUsage?.(normalizeUsage(usage))
const normalizedUsage = !usage
|| (usage.inputTokens == null && usage.outputTokens == null && usage.totalTokens == null)
? { source: 'unavailable' as const }
: { ...usage, source: 'reported' as const }
await options?.onUsage?.(normalizedUsage)
}
catch (error) {
// Usage observers are telemetry-only and must not turn a completed
+1 -2
View File
@@ -17,7 +17,7 @@ export type StreamEvent
| { type: 'reasoning-delta', text: string }
| ({ type: 'finish' } & any)
| ({ type: 'tool-call' } & CompletionToolCall)
| (CompletionToolResult & { type: 'tool-error' })
| (CompletionToolResult & { type: 'tool-error', isError: true })
| { type: 'tool-result', toolCallId: string, result?: string | CommonContentPart[] }
| { type: 'error', error: any }
@@ -35,7 +35,6 @@ export interface StreamOptions {
toolsCompatibility?: Map<string, boolean>
supportsTools?: boolean
waitForTools?: boolean
captureToolErrors?: boolean
/** Provider tool-selection directive for one request. */
toolChoice?: ToolChoice
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
@@ -129,40 +129,32 @@ describe('isToolRelatedError', () => {
})
}
it('resolves from steps while still forwarding tool_calls finish events', async () => {
let onEvent: ((event: unknown) => Promise<void>) | undefined
streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise<void> }) => {
onEvent = options.onEvent
return createMockStreamResult()
})
it('resolves from steps and emits a single finish event', async () => {
streamTextMock.mockImplementation(() => createMockStreamResult())
const store = useLLM()
const onStreamEvent = vi.fn()
let resolved = false
const pending = store.stream('model-a', provider, [{ role: 'user', content: 'hello' }] as Message[], {
await store.stream('model-a', provider, [{ role: 'user', content: 'hello' }] as Message[], {
waitForTools: true,
onStreamEvent,
}).then(() => {
resolved = true
})
await vi.waitFor(() => expect(onEvent).toBeTypeOf('function'))
await onEvent!({ type: 'finish', finishReason: 'tool_calls' })
await Promise.resolve()
expect(resolved).toBe(true)
await onEvent!({ type: 'finish', finishReason: 'stop' })
await pending
expect(onStreamEvent).toHaveBeenCalledTimes(2)
expect(onStreamEvent).toHaveBeenCalledTimes(1)
expect(onStreamEvent).toHaveBeenCalledWith({ type: 'finish' })
})
it('ignores later error events after steps have resolved', async () => {
let onEvent: ((event: unknown) => Promise<void>) | undefined
let resolveSteps: ((steps: unknown[]) => void) | undefined
streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise<void> }) => {
onEvent = options.onEvent
return createMockStreamResult()
return {
...createMockStreamResult(),
steps: new Promise<unknown[]>((resolve) => {
resolveSteps = resolve
}),
}
})
const store = useLLM()
@@ -171,18 +163,27 @@ describe('isToolRelatedError', () => {
})
await vi.waitFor(() => expect(onEvent).toBeTypeOf('function'))
await onEvent!({ type: 'finish', finishReason: 'tool_calls' })
await onEvent!({ type: 'error', error: new Error('stream failed') })
resolveSteps?.([])
await Promise.resolve()
await onEvent!({ type: 'error', message: 'stream failed', cause: new Error('stream failed') })
await expect(pending).resolves.toBeUndefined()
})
it('keeps builtin tools when stream steps resolve before a tool-related error event', async () => {
const store = useLLM()
const llmToolsStore = useLlmToolsStore()
const customTool = { name: 'custom-tool' } as any
const customTool = {
type: 'function',
function: {
name: 'custom-tool',
description: 'Custom tool.',
parameters: { type: 'object', properties: {} },
},
execute: vi.fn(async () => 'ok'),
} satisfies Tool
const runtimeTool = {
id: 'plugin:chess:runtime_play_chess_match',
type: 'function',
type: 'function' as const,
function: {
name: 'runtime_play_chess_match',
description: 'Start a runtime chess match.',
@@ -195,7 +196,7 @@ describe('isToolRelatedError', () => {
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
queueMicrotask(async () => {
await options.onEvent({ type: 'error', error: new Error('model does not support tools') })
await options.onEvent({ type: 'error', message: 'model does not support tools', cause: new Error('model does not support tools') })
})
return createMockStreamResult()
})
@@ -208,15 +209,10 @@ describe('isToolRelatedError', () => {
expect(Array.isArray(firstCallTools)).toBe(true)
expect(mcpMock).toHaveBeenCalledTimes(1)
expect(debugMock).toHaveBeenCalledTimes(1)
expect(firstCallTools).toContain(customTool)
expect(firstCallTools?.map(toolNameFrom)).toContain('custom-tool')
expect(firstCallTools?.map(toolNameFrom)).toContain('runtime_play_chess_match')
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
queueMicrotask(async () => {
await options.onEvent({ type: 'finish', finishReason: 'stop' })
})
return createMockStreamResult()
})
streamTextMock.mockImplementationOnce(() => createMockStreamResult())
await store.stream('model-a', provider, [{ role: 'user', content: 'hello again' }] as Message[], {
tools: [customTool],
@@ -232,7 +228,7 @@ describe('isToolRelatedError', () => {
const llmToolsStore = useLlmToolsStore()
const playChessTool = {
id: 'plugin:chess:runtime_open_chess_board',
type: 'function',
type: 'function' as const,
function: {
name: 'runtime_open_chess_board',
description: 'Open the runtime chess board.',
@@ -242,7 +238,7 @@ describe('isToolRelatedError', () => {
} satisfies ExecutableTool
const runtimeMcpStatusTool = {
id: 'mcp:runtime_sync_mcp_status',
type: 'function',
type: 'function' as const,
function: {
name: 'runtime_sync_mcp_status',
description: 'Sync runtime MCP status.',
@@ -253,12 +249,7 @@ describe('isToolRelatedError', () => {
llmToolsStore.addTools(runtimeMcpStatusTool, playChessTool)
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
queueMicrotask(async () => {
await options.onEvent({ type: 'finish', finishReason: 'stop' })
})
return createMockStreamResult()
})
streamTextMock.mockImplementationOnce(() => createMockStreamResult())
await store.stream('model-a', provider, [{ role: 'user', content: 'play chess' }] as Message[])
@@ -273,6 +264,7 @@ describe('isToolRelatedError', () => {
const store = useLLM()
const llmToolsStore = useLlmToolsStore()
const builtinTool = {
type: 'function',
function: {
name: 'duplicate_runtime_tool',
description: 'Builtin version.',
@@ -282,7 +274,7 @@ describe('isToolRelatedError', () => {
} as unknown as Tool
const runtimeTool = {
id: 'plugin:runtime:duplicate_runtime_tool',
type: 'function',
type: 'function' as const,
function: {
name: 'duplicate_runtime_tool',
description: 'Runtime version.',
@@ -294,16 +286,11 @@ describe('isToolRelatedError', () => {
mcpMock.mockResolvedValueOnce([builtinTool] as Tool[])
llmToolsStore.addTools(runtimeTool)
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
queueMicrotask(async () => {
await options.onEvent({ type: 'finish', finishReason: 'stop' })
})
return createMockStreamResult()
})
streamTextMock.mockImplementationOnce(() => createMockStreamResult())
await store.stream('model-a', provider, [{ role: 'user', content: 'play chess' }] as Message[])
const mergedTools = streamTextMock.mock.calls[0]?.[0]?.tools as Array<{ function?: { name?: string } }>
const mergedTools = streamTextMock.mock.calls[0]?.[0]?.tools as Array<{ function?: { name?: string, description?: string } }>
const duplicateNameTools = mergedTools.filter(tool => tool.function?.name === 'duplicate_runtime_tool')
expect(duplicateNameTools).toHaveLength(1)
@@ -518,7 +518,7 @@ describe('chat store contract', () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, messages: Message[], options: any) => {
composedMessages = messages
expect(options.waitForTools).toBe(true)
expect(options.captureToolErrors).toBe(true)
expect(options.captureToolErrors).toBeUndefined()
await options.onStreamEvent({ type: 'text-delta', text: 'hello' })
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
@@ -1,294 +0,0 @@
import type { Message, Tool, ToolCall } from '@xsai/shared-chat'
import { InvalidToolCallError, InvalidToolInputError, ToolExecutionError } from '@xsai/shared'
import { executeTool } from '@xsai/shared-chat'
import { describe, expect, it, vi } from 'vitest'
function createToolCall(overrides: Partial<ToolCall> & {
function?: Partial<ToolCall['function']> & { name?: string, arguments?: string }
} = {}): ToolCall {
const fn = overrides.function ?? {}
return {
id: 'call_1',
type: 'function',
function: {
name: 'myTool',
arguments: '{}',
...fn,
},
...overrides,
} as ToolCall
}
function createTool(name: string, execute: Tool['execute']): Tool {
return {
type: 'function',
function: { name, description: '', parameters: {} },
execute,
}
}
const emptyMessages: Message[] = []
describe('executeTool (patched @xsai/shared-chat)', () => {
it('returns success tool message when tool executes', async () => {
const tools = [createTool('myTool', async () => 'ok')]
const toolCall = createToolCall()
const out = await executeTool({
messages: emptyMessages,
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBeUndefined()
expect(out.completionToolResult.result).toBe('ok')
expect(out.message.role).toBe('tool')
expect(out.message.content).toBe('ok')
expect(out.message.tool_call_id).toBe('call_1')
})
it('captures unknown tool as error result instead of throwing', async () => {
const tools = [createTool('other', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'missingTool', arguments: '{}' } })
const out = await executeTool({
captureToolErrors: true,
messages: emptyMessages,
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBe(true)
expect(out.completionToolResult.error).toBeDefined()
expect(InvalidToolCallError.isInstance(out.completionToolResult.error)).toBe(true)
expect(String(out.message.content)).toContain('missingTool')
expect(out.message.role).toBe('tool')
})
it('captures invalid JSON arguments as error result', async () => {
const tools = [createTool('myTool', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'myTool', arguments: '{broken' } })
const out = await executeTool({
captureToolErrors: true,
messages: emptyMessages,
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBe(true)
expect(InvalidToolInputError.isInstance(out.completionToolResult.error)).toBe(true)
expect(String(out.message.content)).toContain('myTool')
})
it('captures tool execute rejection as error result', async () => {
const tools = [
createTool('myTool', async () => {
throw new Error('execute failed')
}),
]
const toolCall = createToolCall()
const out = await executeTool({
captureToolErrors: true,
messages: emptyMessages,
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBe(true)
expect(ToolExecutionError.isInstance(out.completionToolResult.error)).toBe(true)
expect(String(out.message.content)).toContain('myTool')
expect(String(out.message.content)).toContain('execution failed')
})
it('rethrows AbortError from tool execute', async () => {
const controller = new AbortController()
const tools = [
createTool('myTool', async () => {
const err = new Error('aborted')
err.name = 'AbortError'
throw err
}),
]
const toolCall = createToolCall()
await expect(
executeTool({
abortSignal: controller.signal,
messages: emptyMessages,
toolCall,
tools,
}),
).rejects.toMatchObject({ name: 'AbortError' })
})
it('repairs invalid tool call when repairToolCall returns a valid call', async () => {
const tools = [
createTool('goodTool', async () => 'repaired'),
]
const toolCall = createToolCall({ function: { name: 'badTool', arguments: '{}' } })
const out = await executeTool({
messages: emptyMessages,
repairToolCall: async () => ({
id: 'call_1',
type: 'function',
function: { name: 'goodTool', arguments: '{}' },
}),
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBeUndefined()
expect(out.completionToolResult.result).toBe('repaired')
})
it('returns error when repairToolCall returns null', async () => {
const tools = [createTool('goodTool', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'badTool', arguments: '{}' } })
const out = await executeTool({
captureToolErrors: true,
messages: emptyMessages,
repairToolCall: async () => null,
toolCall,
tools,
})
expect(out.completionToolResult.isError).toBe(true)
expect(InvalidToolCallError.isInstance(out.completionToolResult.error)).toBe(true)
})
it('invokes lifecycle callbacks on success and on error', async () => {
const onToolCallStart = vi.fn()
const onToolCallFinish = vi.fn()
const tools = [createTool('myTool', async () => 'done')]
const successCall = createToolCall()
const successOut = await executeTool({
messages: emptyMessages,
onToolCallFinish,
onToolCallStart,
toolCall: successCall,
tools,
})
expect(onToolCallStart).toHaveBeenCalledOnce()
expect(onToolCallStart).toHaveBeenCalledWith({
input: {},
toolCallId: 'call_1',
toolName: 'myTool',
})
expect(onToolCallFinish).toHaveBeenCalled()
const successFinish = onToolCallFinish.mock.calls[0][0]
expect(successFinish.toolName).toBe('myTool')
expect(successFinish.toolCallId).toBe('call_1')
expect(successFinish.output).toBe(successOut.completionToolResult.result)
expect(successFinish.error).toBeUndefined()
expect(typeof successFinish.durationMs).toBe('number')
onToolCallStart.mockClear()
onToolCallFinish.mockClear()
const badCall = createToolCall({ function: { name: 'nope', arguments: '{}' } })
await executeTool({
captureToolErrors: true,
messages: emptyMessages,
onToolCallFinish,
onToolCallStart,
toolCall: badCall,
tools,
})
expect(onToolCallStart).not.toHaveBeenCalled()
expect(onToolCallFinish).toHaveBeenCalledOnce()
const errFinish = onToolCallFinish.mock.calls[0][0]
expect(errFinish.output).toBeUndefined()
expect(InvalidToolCallError.isInstance(errFinish.error)).toBe(true)
})
describe('without captureToolErrors (default upstream behavior)', () => {
it('throws InvalidToolCallError for unknown tool', async () => {
const tools = [createTool('other', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'missingTool', arguments: '{}' } })
let thrown: unknown
try {
await executeTool({
messages: emptyMessages,
toolCall,
tools,
})
expect.fail('expected executeTool to throw')
}
catch (error) {
thrown = error
}
expect(InvalidToolCallError.isInstance(thrown)).toBe(true)
})
it('throws InvalidToolInputError for invalid JSON arguments', async () => {
const tools = [createTool('myTool', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'myTool', arguments: '{broken' } })
let thrown: unknown
try {
await executeTool({
messages: emptyMessages,
toolCall,
tools,
})
expect.fail('expected executeTool to throw')
}
catch (error) {
thrown = error
}
expect(InvalidToolInputError.isInstance(thrown)).toBe(true)
})
it('throws ToolExecutionError when tool execute rejects', async () => {
const tools = [
createTool('myTool', async () => {
throw new Error('execute failed')
}),
]
const toolCall = createToolCall()
let thrown: unknown
try {
await executeTool({
messages: emptyMessages,
toolCall,
tools,
})
expect.fail('expected executeTool to throw')
}
catch (error) {
thrown = error
}
expect(ToolExecutionError.isInstance(thrown)).toBe(true)
})
it('throws InvalidToolCallError when repairToolCall returns null', async () => {
const tools = [createTool('goodTool', async () => 'x')]
const toolCall = createToolCall({ function: { name: 'badTool', arguments: '{}' } })
let thrown: unknown
try {
await executeTool({
messages: emptyMessages,
repairToolCall: async () => null,
toolCall,
tools,
})
expect.fail('expected executeTool to throw')
}
catch (error) {
thrown = error
}
expect(InvalidToolCallError.isInstance(thrown)).toBe(true)
})
})
})