From 677329427f32468c74b17f3ec47eeca4e05bec65 Mon Sep 17 00:00:00 2001 From: Neko Date: Tue, 18 Aug 2026 20:28:55 +0800 Subject: [PATCH] fix(plugin-sdk-tamagotchi): allow nullable enum tool parameters (#2314) --- .../plugin-sdk-tamagotchi/src/index.test.ts | 34 ++++++++++++++++++- .../plugin-sdk-tamagotchi/src/tools/index.ts | 6 +++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/plugin-sdk-tamagotchi/src/index.test.ts b/packages/plugin-sdk-tamagotchi/src/index.test.ts index 66039bc47..6c15615f6 100644 --- a/packages/plugin-sdk-tamagotchi/src/index.test.ts +++ b/packages/plugin-sdk-tamagotchi/src/index.test.ts @@ -10,7 +10,7 @@ import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' import type { ToolKitRuntime } from './tools' import { DisposableStore } from '@proj-airi/plugin-sdk' -import { object, optional, string } from 'valibot' +import { object, optional, picklist, string } from 'valibot' import { describe, expect, it, vi } from 'vitest' import { @@ -731,6 +731,38 @@ describe('plugin-sdk-tamagotchi', () => { expect(parameters.properties.opening.type).toEqual(['string', 'null']) }) + // ROOT CAUSE: + // + // The normalizer added null to an optional enum but kept type: "string". + // OpenAI rejected the schema because null does not satisfy the string type. + // The fix must allow null in both type and enum. + it('serializes optional enum tool fields as nullable enum properties', async () => { + const registerTool = vi.fn() + const tools = toolKit.createClient(createToolRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + register: registerTool, + registerToolsetPrompt: vi.fn(), + })) + + await tools.registerTool({ + id: 'play_chess', + title: 'placeholder-title', + description: 'placeholder-description', + inputSchema: object({ + airiSide: optional(picklist(['white', 'black'])), + }), + execute: async () => ({ ok: true }), + }) + + const parameters = registerTool.mock.calls[0]?.[0].tool.parameters + + expect(parameters.required).toEqual(['airiSide']) + expect(parameters.properties.airiSide.type).toEqual(['string', 'null']) + expect(parameters.properties.airiSide.enum).toEqual(['white', 'black', null]) + }) + /** * @example * expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ moduleId: 'chess:board' })) diff --git a/packages/plugin-sdk-tamagotchi/src/tools/index.ts b/packages/plugin-sdk-tamagotchi/src/tools/index.ts index 758569724..bf3f9076b 100644 --- a/packages/plugin-sdk-tamagotchi/src/tools/index.ts +++ b/packages/plugin-sdk-tamagotchi/src/tools/index.ts @@ -171,7 +171,6 @@ function withNullableValue(schema: JsonSchema): JsonSchema { if (Array.isArray(next.enum)) { next.enum = next.enum.includes(null) ? next.enum : [...next.enum, null] - return next } if (Array.isArray(next.type)) { @@ -184,6 +183,11 @@ function withNullableValue(schema: JsonSchema): JsonSchema { return next } + // An enum without a type already accepts every enum value, including null. + if (Array.isArray(next.enum)) { + return next + } + next.anyOf = [...(next.anyOf ?? []), { type: 'null' }] return next }