Skip to content

Commit 3cc9c87

Browse files
committed
fix(stage-tamagotchi): stage widget tool was broken
1 parent a7941f2 commit 3cc9c87

2 files changed

Lines changed: 372 additions & 25 deletions

File tree

apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts

Lines changed: 291 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,300 @@
1+
import type { Tool } from '@xsai/shared-chat'
2+
import type { JsonSchema } from 'xsschema'
3+
14
import type { WidgetInvokers } from './widgets'
25

3-
import { describe, expect, it, vi } from 'vitest'
6+
import { execFile as execFileCallback } from 'node:child_process'
7+
import { promisify } from 'node:util'
8+
9+
import { beforeAll, describe, expect, it, vi } from 'vitest'
410

511
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../../../widgets/extension-ui/host'
6-
import { executeWidgetAction, normalizeComponentProps } from './widgets'
12+
import { executeWidgetAction, normalizeComponentProps, widgetsTools } from './widgets'
13+
14+
const execFile = promisify(execFileCallback)
15+
const aihubmixApiKey = process.env.AIHUBMIX_API_KEY?.trim() || ''
16+
const hasAihubmixApiKey = Boolean(aihubmixApiKey)
17+
const aihubmixBaseUrl = normalizeBaseUrl(process.env.AIHUBMIX_BASE_URL)
18+
const configuredAihubmixModel = process.env.AIHUBMIX_MODEL?.trim()
19+
20+
interface AihubmixModelListResponse {
21+
data?: Array<{
22+
id?: string
23+
}>
24+
}
25+
26+
interface AihubmixErrorResponse {
27+
error?: {
28+
message?: string
29+
type?: string
30+
param?: string
31+
code?: string
32+
}
33+
}
34+
35+
interface CurlJsonResponse<T> {
36+
body: T
37+
status: number
38+
}
39+
40+
function getObjectSchema(schema?: JsonSchema) {
41+
if (!schema)
42+
return undefined
43+
44+
if (schema.type === 'object' || (Array.isArray(schema.type) && schema.type.includes('object')))
45+
return schema
46+
47+
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]
48+
return candidates.find((candidate): candidate is JsonSchema => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate) && candidate.type === 'object'))
49+
}
50+
51+
/**
52+
* Normalizes the configured AIHubMix base URL to a trailing-slash form.
53+
*
54+
* Before:
55+
* - `https://aihubmix.com/v1`
56+
*
57+
* After:
58+
* - `https://aihubmix.com/v1/`
59+
*/
60+
function normalizeBaseUrl(value: string | undefined): string {
61+
let normalized = value?.trim() || 'https://aihubmix.com/v1/'
62+
if (!normalized.endsWith('/'))
63+
normalized += '/'
64+
return normalized
65+
}
66+
67+
/**
68+
* Executes one `curl` JSON request and returns both the body and HTTP status.
69+
*
70+
* Use when:
71+
* - The local Node TLS stack fails against the provider but HTTPS requests succeed via `curl`
72+
* - An env-backed integration test still needs a reproducible provider response
73+
*
74+
* Expects:
75+
* - `curl` is installed in the local environment
76+
* - The endpoint returns JSON on both success and error paths
77+
*
78+
* Returns:
79+
* - Parsed JSON body plus the HTTP status code
80+
*/
81+
async function runCurlJson<T>(options: {
82+
body?: string
83+
headers?: string[]
84+
method?: 'GET' | 'POST'
85+
url: string
86+
}): Promise<CurlJsonResponse<T>> {
87+
// NOTICE:
88+
// Node `fetch` reaches AIHubMix from this repo environment with `ECONNRESET` before TLS
89+
// negotiation completes, while `curl` succeeds against the same host and credentials.
90+
// This test uses `curl` through `execFile` so we can still reproduce the provider-side
91+
// schema validation error inside Vitest without introducing shell interpolation or
92+
// hand-managed temporary files.
93+
const args = [
94+
'--silent',
95+
'--show-error',
96+
'--write-out',
97+
'\n%{http_code}',
98+
'--url',
99+
options.url,
100+
]
101+
102+
for (const header of options.headers ?? []) {
103+
args.push('--header', header)
104+
}
105+
106+
if (options.method) {
107+
args.push('--request', options.method)
108+
}
109+
110+
if (options.body) {
111+
args.push('--data-raw', options.body)
112+
}
113+
114+
const result = await execFile('curl', args, {
115+
maxBuffer: 1024 * 1024 * 4,
116+
})
117+
const output = result.stdout.trimEnd()
118+
const lastNewlineIndex = output.lastIndexOf('\n')
119+
120+
if (lastNewlineIndex < 0)
121+
throw new Error('curl did not emit an HTTP status line.')
122+
123+
const rawBody = output.slice(0, lastNewlineIndex)
124+
const rawStatus = output.slice(lastNewlineIndex + 1)
125+
const status = Number.parseInt(rawStatus, 10)
126+
127+
if (!Number.isFinite(status))
128+
throw new Error(`curl emitted an invalid HTTP status: ${rawStatus}`)
129+
130+
return {
131+
body: JSON.parse(rawBody) as T,
132+
status,
133+
}
134+
}
135+
136+
/**
137+
* Picks one likely chat-capable model for the local provider repro.
138+
*
139+
* Use when:
140+
* - The env file does not pin `AIHUBMIX_MODEL`
141+
* - A live schema repro still needs a concrete chat model id
142+
*
143+
* Expects:
144+
* - `/models` returns provider model ids
145+
*
146+
* Returns:
147+
* - A concrete chat model id to use with `/chat/completions`
148+
*/
149+
async function resolveAihubmixModel(): Promise<string> {
150+
if (configuredAihubmixModel)
151+
return configuredAihubmixModel
152+
153+
const response = await runCurlJson<AihubmixModelListResponse>({
154+
url: new URL('models', aihubmixBaseUrl).toString(),
155+
headers: [
156+
`Authorization: Bearer ${aihubmixApiKey}`,
157+
],
158+
})
159+
expect(response.status).toBe(200)
160+
161+
const modelIds = (response.body.data ?? [])
162+
.map(entry => entry.id?.trim())
163+
.filter((value): value is string => Boolean(value))
164+
165+
const preferredModel = [
166+
'gpt-4o-mini',
167+
'gpt-4.1-mini',
168+
'gpt-4.1-nano',
169+
'gpt-4o',
170+
].find(candidate => modelIds.includes(candidate))
171+
172+
if (preferredModel)
173+
return preferredModel
174+
175+
const fallbackModel = modelIds.find(model =>
176+
['embed', 'embedding', 'tts', 'whisper', 'rerank'].every(fragment => !model.toLowerCase().includes(fragment)),
177+
)
178+
179+
if (!fallbackModel)
180+
throw new Error('Unable to resolve an AIHubMix chat model. Set AIHUBMIX_MODEL in .env.local.')
181+
182+
return fallbackModel
183+
}
184+
185+
/**
186+
* Builds the exact `stage_widgets` tool schema AIRI sends to the provider.
187+
*
188+
* Use when:
189+
* - The integration test needs to prove the live provider sees the same tool schema as AIRI
190+
*
191+
* Expects:
192+
* - `widgetsTools()` resolves in the Vitest Node runtime
193+
*
194+
* Returns:
195+
* - The `stage_widgets` tool definition
196+
*/
197+
async function getStageWidgetsTool(): Promise<Tool> {
198+
const tools = await widgetsTools()
199+
const stageWidgets = tools.find(tool => tool.function.name === 'stage_widgets')
200+
201+
if (!stageWidgets)
202+
throw new Error('Unable to resolve the stage_widgets tool definition.')
203+
204+
return stageWidgets
205+
}
7206

8207
describe('widgets tool helpers', () => {
208+
describe('provider-facing schema reproduction', () => {
209+
it('uses a provider-safe windowSize schema for strict tool validation', async () => {
210+
const stageWidgetsTool = await getStageWidgetsTool()
211+
const schema = stageWidgetsTool.function.parameters as JsonSchema
212+
const windowSize = getObjectSchema(schema.properties?.windowSize as JsonSchema | undefined)
213+
214+
// ROOT CAUSE:
215+
//
216+
// OpenAI-compatible providers that enforce strict tool schemas require object
217+
// schemas to list every property key in `required`, even when the caller thinks
218+
// some nested fields are optional.
219+
//
220+
// The fixed provider-facing schema keeps the root `windowSize` field required and
221+
// nullable, then requires every nested key while allowing optional constraints to
222+
// be expressed as `number | null`. That preserves the runtime behavior while
223+
// satisfying strict tool validators that compare `required` against `properties`.
224+
expect(windowSize).toBeDefined()
225+
expect(windowSize?.additionalProperties).toBe(false)
226+
expect(Object.keys(windowSize?.properties ?? {})).toEqual([
227+
'width',
228+
'height',
229+
'minWidth',
230+
'minHeight',
231+
'maxWidth',
232+
'maxHeight',
233+
])
234+
expect(schema.required).toContain('windowSize')
235+
expect(windowSize?.required).toEqual([
236+
'width',
237+
'height',
238+
'minWidth',
239+
'minHeight',
240+
'maxWidth',
241+
'maxHeight',
242+
])
243+
expect(windowSize?.required).toEqual(Object.keys(windowSize?.properties ?? {}))
244+
})
245+
246+
describe('live AIHubMix repro', () => {
247+
if (!hasAihubmixApiKey) {
248+
it.skip('aIHUBMIX_API_KEY must be set in apps/stage-tamagotchi/.env.local to run this test', () => {})
249+
return
250+
}
251+
252+
let model: string
253+
let stageWidgetsTool: Tool
254+
255+
beforeAll(async () => {
256+
stageWidgetsTool = await getStageWidgetsTool()
257+
model = await resolveAihubmixModel()
258+
})
259+
260+
it('accepts the provider-facing schema after windowSize constraints are made provider-safe', async () => {
261+
// ROOT CAUSE:
262+
//
263+
// `stage_widgets.windowSize` is emitted as a strict object with optional nested keys.
264+
// Some OpenAI-compatible validators reject that shape and require every nested property
265+
// to appear in `required`, even though the schema is valid Draft-07 JSON Schema.
266+
//
267+
// This test proves whether AIHubMix currently rejects the tool with the same provider-
268+
// side validation error reported in the bug report.
269+
const response = await runCurlJson<AihubmixErrorResponse>({
270+
method: 'POST',
271+
url: new URL('chat/completions', aihubmixBaseUrl).toString(),
272+
headers: [
273+
`Authorization: Bearer ${aihubmixApiKey}`,
274+
'Content-Type: application/json',
275+
],
276+
body: JSON.stringify({
277+
model,
278+
messages: [
279+
{
280+
role: 'user',
281+
content: 'Open the widgets window.',
282+
},
283+
],
284+
tools: [stageWidgetsTool],
285+
tool_choice: 'auto',
286+
temperature: 0,
287+
}),
288+
})
289+
290+
const payload = response.body
291+
292+
expect(response.status).toBe(200)
293+
expect(payload.error).toBeUndefined()
294+
}, 15000)
295+
})
296+
})
297+
9298
describe('normalizeComponentProps', () => {
10299
it('parses JSON strings into objects', () => {
11300
const result = normalizeComponentProps('{"city":"Tokyo","temp":15}')

0 commit comments

Comments
 (0)