diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts index 4132e16f5..111ea4943 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts @@ -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>>({ @@ -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 () => { diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts index 0a848b55e..d1d70930c 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts @@ -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 + 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) { + 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('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((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 }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts index c5a4669a8..e99abf33b 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts @@ -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' }, diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts index 880b13648..307e40143 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts @@ -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({ + 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) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts index d0b23e6b7..71910a74a 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts @@ -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([ diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts new file mode 100644 index 000000000..6fb66f860 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts @@ -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() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts new file mode 100644 index 000000000..feb8e354d --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts @@ -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 { + 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.', + } + }, + }) +} diff --git a/packages/core-agent/src/runtime/llm-service.test.ts b/packages/core-agent/src/runtime/llm-service.test.ts new file mode 100644 index 000000000..0ccad508c --- /dev/null +++ b/packages/core-agent/src/runtime/llm-service.test.ts @@ -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() + return { + ...actual, + stepCountAtLeast: vi.fn(), + } +}) + +const provider = { + chat: () => ({ + baseURL: 'https://example.com/', + }), +} as unknown as ChatProvider + +function createMockStreamResult(steps: Promise = 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 + tools?: Tool[] + }) => { + const steps = new Promise((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.'), + })) + }) +}) diff --git a/packages/core-agent/src/runtime/llm-service.ts b/packages/core-agent/src/runtime/llm-service.ts index 4f288610f..992fb8221 100644 --- a/packages/core-agent/src/runtime/llm-service.ts +++ b/packages/core-agent/src/runtime/llm-service.ts @@ -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, +): 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, +) { + 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() + const streamTools = options?.captureToolErrors && tools != null + ? withCapturedToolErrors(tools, capturedToolErrorByCallId) + : tools return new Promise((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, }) diff --git a/packages/core-agent/src/types/llm.ts b/packages/core-agent/src/types/llm.ts index 0df07eb5f..e61dccb04 100644 --- a/packages/core-agent/src/types/llm.ts +++ b/packages/core-agent/src/types/llm.ts @@ -16,6 +16,7 @@ export interface StreamOptions { toolsCompatibility?: Map supportsTools?: boolean waitForTools?: boolean + captureToolErrors?: boolean tools?: Tool[] | (() => Promise) } diff --git a/packages/core-agent/vitest.config.ts b/packages/core-agent/vitest.config.ts new file mode 100644 index 000000000..464fcb4d5 --- /dev/null +++ b/packages/core-agent/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: '@proj-airi/core-agent', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts index f8abb03ee..6326ddec9 100644 --- a/packages/stage-ui/src/stores/chat.contract.test.ts +++ b/packages/stage-ui/src/stores/chat.contract.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' }) diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts index fb955d724..b559e3154 100644 --- a/packages/stage-ui/src/stores/chat.ts +++ b/packages/stage-ui/src/stores/chat.ts @@ -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':