fix(stage-tamagotchi): stage widget tool was broken
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user