fix(stage-ui,stage-tamagotchi,core-agent): tool error should be handled

This commit is contained in:
Neko Ayaka
2026-04-29 00:45:05 +08:00
parent 1e30b05bf1
commit c9fbbfd783
13 changed files with 600 additions and 19 deletions
@@ -1,3 +1,5 @@
// @vitest-environment jsdom
import type { Ref } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
@@ -142,6 +144,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
beforeEach(() => {
setActivePinia(createPinia())
MockBroadcastChannel.reset()
vi.restoreAllMocks()
const activeSessionId = ref('session-1')
const sessionMessages = ref<Record<string, Array<{ role: string, content: string }>>>({
@@ -194,6 +197,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
* })
*/
it('stores command ingest errors in authority session history', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
store.initialize('authority')
@@ -218,11 +222,55 @@ describe('useChatSyncStore authority ingest failures', async () => {
expect(persistedMessages).toHaveLength(2)
expect(persistedMessages[1]?.role).toBe('error')
expect(persistedMessages[1]?.content).toContain('This model is not available in your region')
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command failed', expect.objectContaining({
command: 'ingest',
requestId: 'req-1',
errorMessage: expect.stringContaining('This model is not available in your region'),
payload: expect.objectContaining({
text: 'hello',
sessionId: 'session-1',
}),
}))
peer.close()
store.dispose()
})
/**
* @example
* await expect(store.requestIngest({ text: 'hello' })).rejects.toThrow(/timed out/i)
* expect(console.error).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.any(Object))
*/
it('logs follower command timeouts with request metadata', async () => {
vi.useFakeTimers()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
store.initialize('follower')
const pending = store.requestIngest({
text: 'hello timeout',
sessionId: 'session-1',
})
const expectedRejection = expect(pending).rejects.toThrow('Timed out waiting for chat authority response')
await vi.advanceTimersByTimeAsync(30000)
await expectedRejection
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.objectContaining({
command: 'ingest',
mode: 'follower',
requestId: expect.any(String),
errorMessage: 'Timed out waiting for chat authority response',
payload: expect.objectContaining({
text: 'hello timeout',
sessionId: 'session-1',
}),
}))
store.dispose()
vi.useRealTimers()
})
/**
* @example
* it('replaces the last failed turn before retrying', async () => {
@@ -119,6 +119,44 @@ function resolveRetrySourceIndex(messages: ChatHistoryItem[], index: number): nu
return -1
}
function previewChatSyncPayload(payload: unknown): unknown {
if (!payload || typeof payload !== 'object') {
return payload
}
const record = payload as Record<string, unknown>
const text = typeof record.text === 'string' ? record.text : undefined
return {
...record,
text: text && text.length > 160 ? `${text.slice(0, 160)}...` : text,
attachments: Array.isArray(record.attachments)
? `[${record.attachments.length} attachment(s)]`
: record.attachments,
}
}
/**
* Logs chat-sync failures at the BroadcastChannel boundary.
*
* Use when:
* - A follower window times out waiting for the authority window
* - The authority window fails while executing a forwarded chat command
*
* Expects:
* - `details` only contains structured-clone-friendly diagnostic metadata
*
* Returns:
* - Writes an error entry to the renderer console for postmortem debugging
*/
function logChatSyncError(message: string, error: unknown, details: Record<string, unknown>) {
console.error(`[chat-sync] ${message}`, {
...details,
error,
errorMessage: errorMessageFrom(error) ?? String(error),
})
}
export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => {
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
const mode = ref<ChatSyncMode>('inactive')
@@ -363,6 +401,15 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
catch (error) {
const errorMessage = errorMessageFrom(error) ?? 'Unknown chat sync command failure'
logChatSyncError('command failed', error, {
mode: mode.value,
authorityId: authorityId.value,
requestId: message.requestId,
senderId: message.senderId,
command: message.command,
payload: previewChatSyncPayload(message.payload),
})
if (message.command === 'ingest')
appendIngestErrorMessage(message.payload, errorMessage)
@@ -469,7 +516,16 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(message.requestId)
reject(new Error('Timed out waiting for chat authority response'))
const error = new Error('Timed out waiting for chat authority response')
logChatSyncError('command timed out waiting for authority response', error, {
mode: mode.value,
authorityId: authorityId.value,
requestId: message.requestId,
senderId: message.senderId,
command: message.command,
payload: previewChatSyncPayload(message.payload),
})
reject(error)
}, REQUEST_TIMEOUT_MS)
pendingRequests.set(message.requestId, { resolve, reject, timeout })
@@ -1,7 +1,24 @@
import { resolveArtistryConfigFromStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { installStrictToolSchemaMatchers } from '../testing/strict-tool-schema'
installStrictToolSchemaMatchers()
describe('image_journal config snapshot', () => {
it('uses required nullable fields for strict provider schemas', async () => {
vi.stubGlobal('window', {
location: {
origin: 'http://localhost',
},
})
const { imageJournalTools } = await import('./image-journal')
const tools = await imageJournalTools()
expect(tools).toSatisfyStrictToolSchemas()
})
it('extracts plain values instead of leaking Ref objects', () => {
const config = resolveArtistryConfigFromStore({
activeProvider: { value: 'comfyui' },
@@ -1,5 +1,6 @@
import type { ResolvedArtistryConfig } from '@proj-airi/stage-ui/stores/modules/artistry'
import type { Tool } from '@xsai/shared-chat'
import type { JsonSchema } from 'xsschema'
import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
@@ -7,8 +8,7 @@ import { artistryGenerateHeadless } from '@proj-airi/stage-shared'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { resolveArtistryConfigFromStore, useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { tool } from '@xsai/tool'
import { z } from 'zod'
import { rawTool } from '@xsai/tool'
import { widgetsAdd } from '../../../../shared/eventa'
@@ -33,13 +33,41 @@ function getInvokers(): Invokers {
return invokeCache
}
const imageJournalParams = z.object({
action: z.enum(['create', 'apply']).describe('Choose "create" to generate a new image, or "apply" to use an existing one.'),
prompt: z.string().optional().describe('Description for the image (required for "create").'),
title: z.string().optional().describe('Label for the entry (optional).'),
query: z.string().optional().describe('Search term for existing images (required for "apply").'),
mode: z.enum(['inline', 'widget', 'bg', 'bg_widget']).optional().describe('Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.'),
})
const imageJournalParams = {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['create', 'apply'],
description: 'Choose "create" to generate a new image, or "apply" to use an existing one.',
},
prompt: {
type: ['string', 'null'],
description: 'Description for the image (required for "create").',
},
title: {
type: ['string', 'null'],
description: 'Label for the entry (optional).',
},
query: {
type: ['string', 'null'],
description: 'Search term for existing images (required for "apply").',
},
mode: {
type: ['string', 'null'],
enum: ['inline', 'widget', 'bg', 'bg_widget', null],
description: 'Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.',
},
},
required: [
'action',
'prompt',
'title',
'query',
'mode',
],
additionalProperties: false,
} satisfies JsonSchema
async function executeCreateImageJournalEntry(params: { prompt?: string, title?: string, mode?: 'inline' | 'widget' | 'bg' | 'bg_widget' }) {
if (!params.prompt?.trim())
@@ -199,12 +227,12 @@ async function executeImageJournalAction(params: any) {
}
const tools: Promise<Tool>[] = [
tool({
Promise.resolve(rawTool({
name: 'image_journal',
description: 'Manage AI-generated images. Use "create" to generate and display images. An optional "mode" (inline, widget, bg, bg_widget) can override the default character routing preference. Use "apply" to switch to an existing image from the journal.',
execute: params => executeImageJournalAction(params),
parameters: imageJournalParams,
}),
})),
]
export const imageJournalTools = async () => Promise.all(tools)
@@ -9,8 +9,11 @@ import { promisify } from 'node:util'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../../../widgets/extension-ui/host'
import { installStrictToolSchemaMatchers } from '../testing/strict-tool-schema'
import { executeWidgetAction, normalizeComponentProps, widgetsTools } from './widgets'
installStrictToolSchemaMatchers()
const execFile = promisify(execFileCallback)
const aihubmixApiKey = process.env.AIHUBMIX_API_KEY?.trim() || ''
const hasAihubmixApiKey = Boolean(aihubmixApiKey)
@@ -221,6 +224,7 @@ describe('widgets tool helpers', () => {
// nullable, then requires every nested key while allowing optional constraints to
// be expressed as `number | null`. That preserves the runtime behavior while
// satisfying strict tool validators that compare `required` against `properties`.
expect(stageWidgetsTool).toSatisfyStrictToolSchema()
expect(windowSize).toBeDefined()
expect(windowSize?.additionalProperties).toBe(false)
expect(Object.keys(windowSize?.properties ?? {})).toEqual([
@@ -0,0 +1,73 @@
import type { Tool } from '@xsai/shared-chat'
import { describe, expect, it } from 'vitest'
import { installStrictToolSchemaMatchers } from './strict-tool-schema'
installStrictToolSchemaMatchers()
function createTool(parameters: unknown): Tool {
return {
type: 'function',
function: {
name: 'test_tool',
description: 'Test tool.',
parameters,
},
} as Tool
}
describe('strict tool schema matchers', () => {
/**
* @example
* expect(tool).toSatisfyStrictToolSchema()
*/
it('accepts a strict provider-safe tool schema', () => {
const tool = createTool({
type: 'object',
properties: {
mode: {
type: ['string', 'null'],
},
},
required: ['mode'],
additionalProperties: false,
})
expect(tool).toSatisfyStrictToolSchema()
})
/**
* @example
* expect(() => expect(tool).toSatisfyStrictToolSchema()).toThrow(/mode/)
*/
it('reports missing required keys with schema paths', () => {
const tool = createTool({
type: 'object',
properties: {
mode: {
type: ['string', 'null'],
},
},
required: [],
additionalProperties: false,
})
expect(() => expect(tool).toSatisfyStrictToolSchema()).toThrow(/test_tool\.parameters.*mode/)
})
/**
* @example
* expect([tool]).toSatisfyStrictToolSchemas()
*/
it('checks a list of tools', () => {
const tool = createTool({
type: 'object',
properties: {},
required: [],
additionalProperties: false,
})
expect([tool]).toSatisfyStrictToolSchemas()
})
})
@@ -0,0 +1,144 @@
import type { Tool } from '@xsai/shared-chat'
import type { JsonSchema } from 'xsschema'
import { expect } from 'vitest'
interface StrictToolSchemaIssue {
path: string
message: string
}
declare module 'vitest' {
interface Assertion<T = any> {
toSatisfyStrictToolSchema: () => T
toSatisfyStrictToolSchemas: () => T
}
interface AsymmetricMatchersContaining {
toSatisfyStrictToolSchema: () => void
toSatisfyStrictToolSchemas: () => void
}
}
function isSchemaRecord(value: unknown): value is JsonSchema {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function sorted(values: string[]): string[] {
return [...values].sort((left, right) => left.localeCompare(right))
}
function collectSchemaIssues(schema: unknown, path: string, issues: StrictToolSchemaIssue[]): void {
if (!isSchemaRecord(schema)) {
return
}
if (schema.properties) {
const propertyKeys = Object.keys(schema.properties)
const required = Array.isArray(schema.required) ? schema.required.filter((value): value is string => typeof value === 'string') : []
if (!Array.isArray(schema.required)) {
issues.push({
path,
message: '`required` must be supplied when `properties` is present.',
})
}
else if (sorted(required).join('\0') !== sorted(propertyKeys).join('\0')) {
const missing = propertyKeys.filter(key => !required.includes(key))
const extra = required.filter(key => !propertyKeys.includes(key))
issues.push({
path,
message: [
'`required` must include every key in `properties`.',
missing.length ? `Missing: ${missing.join(', ')}.` : '',
extra.length ? `Extra: ${extra.join(', ')}.` : '',
].filter(Boolean).join(' '),
})
}
if (schema.additionalProperties !== false) {
issues.push({
path,
message: '`additionalProperties` must be false when `properties` is present.',
})
}
for (const [key, value] of Object.entries(schema.properties)) {
collectSchemaIssues(value, `${path}.${key}`, issues)
}
}
if (Array.isArray(schema.items)) {
schema.items.forEach((item, index) => collectSchemaIssues(item, `${path}.items[${index}]`, issues))
}
else if (schema.items) {
collectSchemaIssues(schema.items, `${path}.items`, issues)
}
for (const unionKey of ['anyOf', 'oneOf', 'allOf'] as const) {
const schemas = schema[unionKey]
if (Array.isArray(schemas)) {
schemas.forEach((item, index) => collectSchemaIssues(item, `${path}.${unionKey}[${index}]`, issues))
}
}
}
/**
* Collects strict provider schema issues from one xsAI tool.
*
* Use when:
* - Vitest checks need diagnostics instead of throwing immediately
* - A provider rejects schemas that omit `required` keys or allow extra object properties
*
* Expects:
* - `tool.function.parameters` contains the provider-facing JSON Schema
*
* Returns:
* - A list of path-qualified issues; empty means the schema satisfies the local strict rules
*/
export function collectStrictToolSchemaIssues(tool: Tool): StrictToolSchemaIssue[] {
const issues: StrictToolSchemaIssue[] = []
collectSchemaIssues(tool.function.parameters, `${tool.function.name}.parameters`, issues)
return issues
}
function formatIssues(issues: StrictToolSchemaIssue[]): string {
return issues.map(issue => `- ${issue.path}: ${issue.message}`).join('\n')
}
/**
* Installs Vitest matchers for strict provider-facing tool schema checks.
*
* Use when:
* - A test file wants `expect(tool).toSatisfyStrictToolSchema()`
* - A test file wants `expect(tools).toSatisfyStrictToolSchemas()`
*
* Expects:
* - Called before the matcher is used in the current Vitest worker
*
* Returns:
* - Registers matchers on Vitest's `expect` object
*/
export function installStrictToolSchemaMatchers(): void {
expect.extend({
toSatisfyStrictToolSchema(received: Tool) {
const issues = collectStrictToolSchemaIssues(received)
return {
pass: issues.length === 0,
message: () => issues.length
? `Expected tool schema to satisfy strict provider rules:\n${formatIssues(issues)}`
: 'Expected tool schema not to satisfy strict provider rules.',
}
},
toSatisfyStrictToolSchemas(received: Tool[]) {
const issues = received.flatMap(tool => collectStrictToolSchemaIssues(tool))
return {
pass: issues.length === 0,
message: () => issues.length
? `Expected tool schemas to satisfy strict provider rules:\n${formatIssues(issues)}`
: 'Expected tool schemas not to satisfy strict provider rules.',
}
},
})
}
@@ -0,0 +1,113 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool } from '@xsai/shared-chat'
import { describe, expect, it, vi } from 'vitest'
import { streamFrom } from './llm-service'
const { streamTextMock } = vi.hoisted(() => ({
streamTextMock: vi.fn(),
}))
vi.mock('@xsai/stream-text', () => ({
streamText: streamTextMock,
}))
vi.mock('@xsai/shared-chat', async (importOriginal) => {
const actual = await importOriginal<typeof import('@xsai/shared-chat')>()
return {
...actual,
stepCountAtLeast: vi.fn(),
}
})
const provider = {
chat: () => ({
baseURL: 'https://example.com/',
}),
} as unknown as ChatProvider
function createMockStreamResult(steps: Promise<unknown[]> = Promise.resolve([])) {
return {
steps,
messages: Promise.resolve([]),
usage: Promise.resolve(undefined),
totalUsage: Promise.resolve(undefined),
}
}
describe('streamFrom tool error capture', () => {
/**
* @example
* await streamFrom({ model, chatProvider, messages, options: { captureToolErrors: true } })
*/
it('keeps captureToolErrors internal while forwarding failed tool calls as tool-error events', async () => {
let resolveSteps: ((steps: unknown[]) => void) | undefined
const events: unknown[] = []
const failingTool = {
type: 'function',
function: {
name: 'play_chess',
description: 'Start chess.',
parameters: { type: 'object', properties: {} },
},
execute: vi.fn(() => {
throw new Error('Focus mode does not accept game-state mutation inputs.')
}),
} satisfies Tool
streamTextMock.mockImplementationOnce((options: {
captureToolErrors?: boolean
onEvent: (event: unknown) => Promise<void>
tools?: Tool[]
}) => {
const steps = new Promise<unknown[]>((resolve) => {
resolveSteps = resolve
})
queueMicrotask(async () => {
const result = await options.tools?.[0]?.execute({}, {
messages: [],
toolCallId: 'call-1',
})
await options.onEvent({
type: 'tool-result',
args: {},
result,
toolCallId: 'call-1',
toolName: 'play_chess',
})
await options.onEvent({ type: 'finish', finishReason: 'stop' })
resolveSteps?.([])
})
return createMockStreamResult(steps)
})
await streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'play chess' }] as Message[],
options: {
captureToolErrors: true,
tools: [failingTool],
onStreamEvent: (event) => {
events.push(event)
},
},
})
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({
type: 'tool-error',
isError: true,
toolCallId: 'call-1',
toolName: 'play_chess',
result: expect.stringContaining('Focus mode does not accept game-state mutation inputs.'),
}))
})
})
+73 -6
View File
@@ -1,8 +1,9 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
import type { Message, Tool } from '@xsai/shared-chat'
import type { StreamFromOptions, StreamOptions } from '../types/llm'
import { errorMessageFrom } from '@moeru/std'
import { stepCountAtLeast } from '@xsai/shared-chat'
import { streamText } from '@xsai/stream-text'
@@ -46,6 +47,65 @@ 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}": ${errorMessageFrom(error) ?? String(error)}`
}
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)
}
catch (error) {
if (isAbortError(error))
throw error
const result = createCapturedToolErrorResult(tool.function.name, error)
capturedToolErrorByCallId.set(executeOptions.toolCallId, result)
return result
}
},
}))
}
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,
}
}
export async function streamFrom({
model,
chatProvider,
@@ -63,6 +123,10 @@ 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
@@ -81,7 +145,8 @@ export async function streamFrom({
const onEvent = async (event: unknown) => {
try {
await options?.onStreamEvent?.(event as any)
const streamEvent = resolveCapturedToolErrorEvent(event, capturedToolErrorByCallId)
await options?.onStreamEvent?.(streamEvent as any)
if (event && (event as any).type === 'finish') {
const finishReason = (event as any).finishReason
const waitingForToolRound = finishReason === 'tool_calls' || finishReason === 'tool-calls'
@@ -104,10 +169,12 @@ export async function streamFrom({
messages: sanitized,
headers: options?.headers,
stopWhen: stepCountAtLeast(10),
tools,
// NOTICE: Some OpenAI-compatible gateways reject the wire-level
// `capture_tool_errors` parameter with 400 unknown_parameter.
// Keep this unset here so the request remains provider-compatible.
// 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,
onEvent,
})
+1
View File
@@ -16,6 +16,7 @@ export interface StreamOptions {
toolsCompatibility?: Map<string, boolean>
supportsTools?: boolean
waitForTools?: boolean
captureToolErrors?: boolean
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: '@proj-airi/core-agent',
include: ['src/**/*.test.ts'],
},
})
@@ -7,6 +7,14 @@ import { ref } from 'vue'
import { useChatOrchestratorStore } from './chat'
vi.hoisted(() => {
;(globalThis as any).window = {
location: {
origin: 'http://localhost',
},
}
})
const llmStreamMock = vi.fn()
const trackFirstMessageMock = vi.fn()
const ingestContextMessageMock = vi.fn()
@@ -147,6 +155,18 @@ vi.mock('./modules/consciousness', () => ({
}),
}))
vi.mock('./modules/airi-card', () => ({
useAiriCardStore: () => ({
activeCard: undefined,
}),
}))
vi.mock('./modules/artistry-autonomous', () => ({
useAutonomousArtistryStore: () => ({
runArtistTask: vi.fn(),
}),
}))
const provider = {
chat: () => ({ baseURL: 'https://example.com/' }),
} as unknown as ChatProvider
@@ -195,6 +215,7 @@ describe('chat orchestrator contract', () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, messages: Message[], options: any) => {
composedMessages = messages
expect(options.waitForTools).toBe(true)
expect(options.captureToolErrors).toBe(true)
await options.onStreamEvent({ type: 'text-delta', text: 'hello' })
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
+1
View File
@@ -438,6 +438,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
// NOTICE: xsai stream may emit `finish` before tool steps continue, so keep waiting until
// the final non-tool finish to avoid ending the chat turn with no assistant reply.
waitForTools: true,
captureToolErrors: true,
onStreamEvent: async (event: StreamEvent) => {
switch (event.type) {
case 'tool-call':