fix(stage-tamagotchi): stage widget tool was broken

This commit is contained in:
Neko Ayaka
2026-04-24 04:12:26 +08:00
parent a7941f27dd
commit 3cc9c87b0f
2 changed files with 371 additions and 24 deletions
@@ -1,11 +1,300 @@
import type { Tool } from '@xsai/shared-chat'
import type { JsonSchema } from 'xsschema'
import type { WidgetInvokers } from './widgets'
import { describe, expect, it, vi } from 'vitest'
import { execFile as execFileCallback } from 'node:child_process'
import { promisify } from 'node:util'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../../../widgets/extension-ui/host'
import { executeWidgetAction, normalizeComponentProps } from './widgets'
import { executeWidgetAction, normalizeComponentProps, widgetsTools } from './widgets'
const execFile = promisify(execFileCallback)
const aihubmixApiKey = process.env.AIHUBMIX_API_KEY?.trim() || ''
const hasAihubmixApiKey = Boolean(aihubmixApiKey)
const aihubmixBaseUrl = normalizeBaseUrl(process.env.AIHUBMIX_BASE_URL)
const configuredAihubmixModel = process.env.AIHUBMIX_MODEL?.trim()
interface AihubmixModelListResponse {
data?: Array<{
id?: string
}>
}
interface AihubmixErrorResponse {
error?: {
message?: string
type?: string
param?: string
code?: string
}
}
interface CurlJsonResponse<T> {
body: T
status: number
}
function getObjectSchema(schema?: JsonSchema) {
if (!schema)
return undefined
if (schema.type === 'object' || (Array.isArray(schema.type) && schema.type.includes('object')))
return schema
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]
return candidates.find((candidate): candidate is JsonSchema => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate) && candidate.type === 'object'))
}
/**
* Normalizes the configured AIHubMix base URL to a trailing-slash form.
*
* Before:
* - `https://aihubmix.com/v1`
*
* After:
* - `https://aihubmix.com/v1/`
*/
function normalizeBaseUrl(value: string | undefined): string {
let normalized = value?.trim() || 'https://aihubmix.com/v1/'
if (!normalized.endsWith('/'))
normalized += '/'
return normalized
}
/**
* Executes one `curl` JSON request and returns both the body and HTTP status.
*
* Use when:
* - The local Node TLS stack fails against the provider but HTTPS requests succeed via `curl`
* - An env-backed integration test still needs a reproducible provider response
*
* Expects:
* - `curl` is installed in the local environment
* - The endpoint returns JSON on both success and error paths
*
* Returns:
* - Parsed JSON body plus the HTTP status code
*/
async function runCurlJson<T>(options: {
body?: string
headers?: string[]
method?: 'GET' | 'POST'
url: string
}): Promise<CurlJsonResponse<T>> {
// NOTICE:
// Node `fetch` reaches AIHubMix from this repo environment with `ECONNRESET` before TLS
// negotiation completes, while `curl` succeeds against the same host and credentials.
// This test uses `curl` through `execFile` so we can still reproduce the provider-side
// schema validation error inside Vitest without introducing shell interpolation or
// hand-managed temporary files.
const args = [
'--silent',
'--show-error',
'--write-out',
'\n%{http_code}',
'--url',
options.url,
]
for (const header of options.headers ?? []) {
args.push('--header', header)
}
if (options.method) {
args.push('--request', options.method)
}
if (options.body) {
args.push('--data-raw', options.body)
}
const result = await execFile('curl', args, {
maxBuffer: 1024 * 1024 * 4,
})
const output = result.stdout.trimEnd()
const lastNewlineIndex = output.lastIndexOf('\n')
if (lastNewlineIndex < 0)
throw new Error('curl did not emit an HTTP status line.')
const rawBody = output.slice(0, lastNewlineIndex)
const rawStatus = output.slice(lastNewlineIndex + 1)
const status = Number.parseInt(rawStatus, 10)
if (!Number.isFinite(status))
throw new Error(`curl emitted an invalid HTTP status: ${rawStatus}`)
return {
body: JSON.parse(rawBody) as T,
status,
}
}
/**
* Picks one likely chat-capable model for the local provider repro.
*
* Use when:
* - The env file does not pin `AIHUBMIX_MODEL`
* - A live schema repro still needs a concrete chat model id
*
* Expects:
* - `/models` returns provider model ids
*
* Returns:
* - A concrete chat model id to use with `/chat/completions`
*/
async function resolveAihubmixModel(): Promise<string> {
if (configuredAihubmixModel)
return configuredAihubmixModel
const response = await runCurlJson<AihubmixModelListResponse>({
url: new URL('models', aihubmixBaseUrl).toString(),
headers: [
`Authorization: Bearer ${aihubmixApiKey}`,
],
})
expect(response.status).toBe(200)
const modelIds = (response.body.data ?? [])
.map(entry => entry.id?.trim())
.filter((value): value is string => Boolean(value))
const preferredModel = [
'gpt-4o-mini',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gpt-4o',
].find(candidate => modelIds.includes(candidate))
if (preferredModel)
return preferredModel
const fallbackModel = modelIds.find(model =>
['embed', 'embedding', 'tts', 'whisper', 'rerank'].every(fragment => !model.toLowerCase().includes(fragment)),
)
if (!fallbackModel)
throw new Error('Unable to resolve an AIHubMix chat model. Set AIHUBMIX_MODEL in .env.local.')
return fallbackModel
}
/**
* Builds the exact `stage_widgets` tool schema AIRI sends to the provider.
*
* Use when:
* - The integration test needs to prove the live provider sees the same tool schema as AIRI
*
* Expects:
* - `widgetsTools()` resolves in the Vitest Node runtime
*
* Returns:
* - The `stage_widgets` tool definition
*/
async function getStageWidgetsTool(): Promise<Tool> {
const tools = await widgetsTools()
const stageWidgets = tools.find(tool => tool.function.name === 'stage_widgets')
if (!stageWidgets)
throw new Error('Unable to resolve the stage_widgets tool definition.')
return stageWidgets
}
describe('widgets tool helpers', () => {
describe('provider-facing schema reproduction', () => {
it('uses a provider-safe windowSize schema for strict tool validation', async () => {
const stageWidgetsTool = await getStageWidgetsTool()
const schema = stageWidgetsTool.function.parameters as JsonSchema
const windowSize = getObjectSchema(schema.properties?.windowSize as JsonSchema | undefined)
// ROOT CAUSE:
//
// OpenAI-compatible providers that enforce strict tool schemas require object
// schemas to list every property key in `required`, even when the caller thinks
// some nested fields are optional.
//
// The fixed provider-facing schema keeps the root `windowSize` field required and
// 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(windowSize).toBeDefined()
expect(windowSize?.additionalProperties).toBe(false)
expect(Object.keys(windowSize?.properties ?? {})).toEqual([
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight',
])
expect(schema.required).toContain('windowSize')
expect(windowSize?.required).toEqual([
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight',
])
expect(windowSize?.required).toEqual(Object.keys(windowSize?.properties ?? {}))
})
describe('live AIHubMix repro', () => {
if (!hasAihubmixApiKey) {
it.skip('aIHUBMIX_API_KEY must be set in apps/stage-tamagotchi/.env.local to run this test', () => {})
return
}
let model: string
let stageWidgetsTool: Tool
beforeAll(async () => {
stageWidgetsTool = await getStageWidgetsTool()
model = await resolveAihubmixModel()
})
it('accepts the provider-facing schema after windowSize constraints are made provider-safe', async () => {
// ROOT CAUSE:
//
// `stage_widgets.windowSize` is emitted as a strict object with optional nested keys.
// Some OpenAI-compatible validators reject that shape and require every nested property
// to appear in `required`, even though the schema is valid Draft-07 JSON Schema.
//
// This test proves whether AIHubMix currently rejects the tool with the same provider-
// side validation error reported in the bug report.
const response = await runCurlJson<AihubmixErrorResponse>({
method: 'POST',
url: new URL('chat/completions', aihubmixBaseUrl).toString(),
headers: [
`Authorization: Bearer ${aihubmixApiKey}`,
'Content-Type: application/json',
],
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content: 'Open the widgets window.',
},
],
tools: [stageWidgetsTool],
tool_choice: 'auto',
temperature: 0,
}),
})
const payload = response.body
expect(response.status).toBe(200)
expect(payload.error).toBeUndefined()
}, 15000)
})
})
describe('normalizeComponentProps', () => {
it('parses JSON strings into objects', () => {
const result = normalizeComponentProps('{"city":"Tokyo","temp":15}')
@@ -1,11 +1,11 @@
import type { Tool } from '@xsai/shared-chat'
import type { JsonSchema } from 'xsschema'
import type { WidgetWindowSize } from '../../../../shared/eventa'
import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { tool } from '@xsai/tool'
import { z } from 'zod'
import { rawTool } from '@xsai/tool'
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
@@ -85,24 +85,82 @@ function resolveInvokers(override?: WidgetInvokers): WidgetInvokers {
return cachedInvokers
}
const widgetWindowSizeParams = z.object({
width: z.number().positive(),
height: z.number().positive(),
minWidth: z.number().positive().optional(),
minHeight: z.number().positive().optional(),
maxWidth: z.number().positive().optional(),
maxHeight: z.number().positive().optional(),
}).strict()
const nullablePositiveNumberSchema = {
type: ['number', 'null'],
exclusiveMinimum: 0,
} satisfies JsonSchema
const widgetParams = z.object({
action: z.enum(['spawn', 'update', 'remove', 'clear', 'open']).describe('Choose one: spawn, update, remove, clear, open'),
id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'),
componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'),
componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'),
size: z.enum(['s', 'm', 'l']),
windowSize: widgetWindowSizeParams.optional().describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'),
ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'),
}).strict()
const widgetWindowSizeParams = {
description: 'Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}',
type: ['object', 'null'],
properties: {
width: {
type: 'number',
exclusiveMinimum: 0,
},
height: {
type: 'number',
exclusiveMinimum: 0,
},
minWidth: nullablePositiveNumberSchema,
minHeight: nullablePositiveNumberSchema,
maxWidth: nullablePositiveNumberSchema,
maxHeight: nullablePositiveNumberSchema,
},
required: [
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight',
],
additionalProperties: false,
} satisfies JsonSchema
const widgetParams = {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['spawn', 'update', 'remove', 'clear', 'open'],
description: 'Choose one: spawn, update, remove, clear, open',
},
id: {
type: 'string',
description: 'Widget id; required for update/remove, optional for spawn/open',
},
componentName: {
type: 'string',
description: 'Widget component to render, e.g. weather (required for spawn)',
},
componentProps: {
type: 'string',
description: 'Widget props as JSON string (e.g. {"city":"Tokyo"})',
},
size: {
type: 'string',
enum: ['s', 'm', 'l'],
},
windowSize: widgetWindowSizeParams,
ttlSeconds: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
description: 'Auto-close timer in seconds (spawn only)',
},
},
required: [
'action',
'id',
'componentName',
'componentProps',
'size',
'windowSize',
'ttlSeconds',
],
additionalProperties: false,
} satisfies JsonSchema
export function normalizeComponentProps(raw?: string | Record<string, any>) {
if (raw === undefined || raw === null)
@@ -209,8 +267,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
}
}
const tools: Promise<Tool>[] = [
tool({
const tools: Tool[] = [
rawTool({
name: 'stage_widgets',
description: 'Manage overlay widgets in the Stage desktop app (spawn, update, remove, clear, or open the widgets window).',
execute: params => executeWidgetAction(params as WidgetActionInput),
@@ -218,4 +276,4 @@ const tools: Promise<Tool>[] = [
}),
]
export const widgetsTools = async () => Promise.all(tools)
export const widgetsTools = async () => tools