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.',
}
},
})
}