From 3cc9c87b0fb673a8fcb5701f418d5691ba2485b7 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 24 Apr 2026 04:12:05 +0800 Subject: [PATCH] fix(stage-tamagotchi): stage widget tool was broken --- .../stores/tools/builtin/widgets.test.ts | 293 +++++++++++++++++- .../renderer/stores/tools/builtin/widgets.ts | 102 ++++-- 2 files changed, 371 insertions(+), 24 deletions(-) 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 b095a90ac..d0b23e6b7 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 @@ -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 { + 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(options: { + body?: string + headers?: string[] + method?: 'GET' | 'POST' + url: string +}): Promise> { + // 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 { + if (configuredAihubmixModel) + return configuredAihubmixModel + + const response = await runCurlJson({ + 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 { + 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({ + 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}') diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts index 3b4b7e944..89bc1d6e2 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts @@ -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) { if (raw === undefined || raw === null) @@ -209,8 +267,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv } } -const tools: Promise[] = [ - 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[] = [ }), ] -export const widgetsTools = async () => Promise.all(tools) +export const widgetsTools = async () => tools