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:
co-authored by
Cursor
autofix-ci[bot]
parent
b230e16b2e
commit
fd8b7a0ae3
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user