fix(stage-ui,stage-tamagotchi,core-agent): tool error should be handled
This commit is contained in:
@@ -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.',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user