fix(stage-ui,stage-ui-spine): make optional tool params work with OpenAI strict mode (#3072)

This commit is contained in:
Rishabh Balabomma
2026-10-22 21:22:02 +08:00
committed by GitHub
parent 8111aa828e
commit d630935ff8
3 changed files with 51 additions and 5 deletions
@@ -60,8 +60,10 @@ export const tools = [
},
parameters: z.object({
name: z.string().describe('Spine animation name (e.g. "idle", "walk", "celebrate"). Case-insensitive partial match accepted.'),
loop: z.boolean().optional().describe('Whether the animation should loop. Defaults to true.'),
oneShot: z.boolean().optional().describe('Play once on the emotion track instead of replacing the idle loop.'),
// Required-nullable instead of `.optional()`: OpenAI strict mode wants every
// property in `required`, or the whole request fails.
loop: z.union([z.boolean(), z.null()]).describe('Whether the animation should loop, or null for the default (true, or false for a one-shot).'),
oneShot: z.union([z.boolean(), z.null()]).describe('Play once on the emotion track instead of replacing the idle loop, or null for false.'),
}),
}),
@@ -153,6 +153,47 @@ describe('home assistant tools', () => {
}, {})
})
it('lists every property as required, so OpenAI strict mode accepts the tools', async () => {
const mounted = await tools(createClient())
for (const entry of mounted.values()) {
const parameters = entry.function.parameters as {
properties?: Record<string, unknown>
required?: string[]
}
expect(parameters.required?.slice().sort()).toEqual(Object.keys(parameters.properties ?? {}).sort())
}
})
it('treats a null domain and null data as not given', async () => {
const callService = vi.fn(async () => [])
const client = createClient({
callService,
listEntities: vi.fn(async () => [
{ entityId: 'light.kitchen', state: 'on', attributes: {} },
{ entityId: 'climate.hall', state: 'heat', attributes: {} },
]),
})
const mounted = await tools(client)
const listed = JSON.parse(await execute(mounted.get('home_assistant_list_entities'), { domain: null }) as string)
expect(listed.total).toBe(2)
await execute(mounted.get('home_assistant_call_service'), {
domain: 'light',
service: 'turn_on',
entity_id: 'light.kitchen',
data: null,
})
expect(callService).toHaveBeenCalledWith({
domain: 'light',
service: 'turn_on',
entityId: 'light.kitchen',
data: undefined,
}, {})
})
it('requires a target entity, so no call reaches a whole domain', async () => {
const mounted = await tools(createClient())
@@ -46,7 +46,7 @@ function toSummary(entity: HomeAssistantEntity) {
* The payload crosses into the main process and lands in a request body, so a
* nested or non-object value is a caller mistake rather than something to send.
*/
function parseServiceData(raw: string | undefined) {
function parseServiceData(raw: string | null | undefined) {
if (!raw?.trim())
return undefined
@@ -183,7 +183,9 @@ export async function createHomeAssistantTools(
})
},
parameters: z.object({
domain: z.string().optional().describe('Entity domain to filter by, for example "light" or "climate".'),
// Optional inputs are required-nullable, not `.optional()`. OpenAI strict
// mode wants every property in `required`, or the whole request fails.
domain: z.union([z.string(), z.null()]).describe('Entity domain to filter by, for example "light" or "climate", or null for every domain.'),
}).strict(),
}),
tool({
@@ -267,7 +269,8 @@ export async function createHomeAssistantTools(
entity_id: z.string().describe('Target entity id, for example "light.living_room". Always pass one.'),
// NOTICE: `data` is z.string() (JSON) because z.record() emits `propertyNames`,
// which OpenAI rejects. The same reason appears beside builtIn_mcpCallTool.
data: z.string().optional().describe('Extra service fields as a JSON object string, for example {"brightness": 200}.'),
// Required-nullable for OpenAI strict mode, as with `domain` above.
data: z.union([z.string(), z.null()]).describe('Extra service fields as a JSON object string, for example {"brightness": 200}, or null for none.'),
}).strict(),
}),
])