diff --git a/packages/cloud-agents/package.json b/packages/cloud-agents/package.json index 9413636e3..ad41b9240 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -95,6 +95,7 @@ "@types/ioredis-mock": "^8.2.6", "@types/node": "^24.10.13", "@types/jsdom": "21.1.7", + "ajv": "^8.20.0", "ioredis-mock": "^8.13.1", "vitest": "^4.1.1" } diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts index 89c9cc659..48ad0e8a2 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts @@ -9,6 +9,7 @@ import { FAST_AGENT_NATIVE_TOOL_NAMES, } from '@roomote/types'; import { z } from 'zod'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge'; @@ -16,8 +17,9 @@ import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge' * Guards the JSON schema OpenAI receives for every Fast native tool. * * OpenCode loads each generated tool module with its own zod 4, treats - * `args` as a record of field schemas (wrapping it in `z.object`), and ships - * `z.toJSONSchema` of that to the provider. A tool that declares `args` as a + * `args` as a record of field schemas (wrapping it in `z.object`), and + * normalizes `z.toJSONSchema` before sending it to the provider. A tool + * that declares `args` as a * bare schema instead of a record (a `z.union`, say) turns into a schema * carrying zod internals, which OpenAI rejects with * `invalid_function_parameters` on every request, taking down every Fast turn @@ -196,12 +198,22 @@ function toOpenCodeJsonSchema(zod: ZodV4, args: unknown) { )} ${nonZod.length === 1 ? 'is' : 'are'} not. OpenCode wraps args in z.object itself; a bare schema (z.union, z.object) as args ships its internals to the provider.`, ); } - return zod.z.toJSONSchema(zod.z.object(args as Record), { - io: 'input', - }); + const schema = zod.z.toJSONSchema( + zod.z.object(args as Record), + { + io: 'input', + }, + ); + // OpenCode v1.18.10 tool/registry.ts zodJsonSchema renames the dictionary + // without rewriting refs. Testing raw Zod output missed this boundary. + const { $defs, ...rest } = schema; + return JSON.parse( + JSON.stringify($defs ? { ...rest, definitions: $defs } : rest), + ); } describe('Fast native tool schemas as OpenAI receives them', () => { + const validator = new Ajv2020({ strict: false }); let workDir: string; let zod: ZodV4; let tools: LoadedTool[]; @@ -270,6 +282,7 @@ describe('Fast native tool schemas as OpenAI receives them', () => { let schema: unknown; try { schema = toOpenCodeJsonSchema(zod, tool.args ?? {}); + validator.compile(schema as object); } catch (error) { failures.push( `${tool.name}: ${error instanceof Error ? error.message : String(error)}`, @@ -315,23 +328,13 @@ describe('Fast native tool schemas as OpenAI receives them', () => { ); const schema = toOpenCodeJsonSchema(zod, callTool?.args ?? {}) as { properties?: Record; - $defs?: Record; }; const argsSchema = schema.properties?.args as - | { type?: string; additionalProperties?: { $ref?: string } } + | { type?: string; additionalProperties?: { anyOf?: unknown[] } } | undefined; - const valueSchemaName = argsSchema?.additionalProperties?.$ref?.replace( - '#/$defs/', - '', - ); - const valueSchema = valueSchemaName - ? (schema.$defs?.[valueSchemaName] as - | { anyOf?: Array<{ type?: string }> } - | undefined) - : undefined; expect(argsSchema?.type).toBe('object'); - expect(valueSchema?.anyOf).toEqual( + expect(argsSchema?.additionalProperties?.anyOf).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'string' }), expect.objectContaining({ type: 'object' }), @@ -340,6 +343,60 @@ describe('Fast native tool schemas as OpenAI receives them', () => { ); }); + it('detects dangling refs after OpenCode normalizes recursive Zod schemas', () => { + const args = { + args: zod.z.record(zod.z.string(), zod.z.json()).optional(), + }; + expect(() => + validator.compile( + zod.z.toJSONSchema(zod.z.object(args), { io: 'input' }), + ), + ).not.toThrow(); + expect(() => validator.compile(toOpenCodeJsonSchema(zod, args))).toThrow( + /can't resolve reference #\/\$defs\//, + ); + }); + + it('preserves nested JSON through serialized native schema validation and server parsing', () => { + const callTool = tools.find( + (tool) => tool.name === FAST_AGENT_NATIVE_TOOL_NAMES.callIntegrationTool, + )!; + const validate = validator.compile( + toOpenCodeJsonSchema(zod, callTool.args), + ); + const nativeSchema = zod.z.object(callTool.args as Record); + const serverSchema = z.object(CALL_INTEGRATION_TOOL_TOOL.inputSchema); + const base = { integrationId: 'example', toolName: 'nested_tool' }; + for (const args of [ + undefined, + {}, + { + text: 'value', + number: 1.5, + enabled: true, + nullable: null, + list: [ + null, + false, + 42, + 'text', + [], + {}, + { nested: [{ 'arbitrary/key': { values: [1, null] } }] }, + ], + object: { nested: { list: [[{ value: 'preserved' }]] } }, + }, + ]) { + const input = JSON.parse(JSON.stringify({ ...base, args })); + expect(validate(input), JSON.stringify(validate.errors)).toBe(true); + expect(nativeSchema.parse(input)).toEqual(input); + expect(serverSchema.parse(input)).toEqual(input); + } + for (const args of [null, 'text', [], 42, false]) { + expect(validate({ ...base, args })).toBe(false); + } + }); + it('preserves required Sentry organization scope through generated tool execution and server parsing', async () => { const callTool = tools.find( (tool) => tool.name === FAST_AGENT_NATIVE_TOOL_NAMES.callIntegrationTool, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index e4f1538c2..70d8cd030 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -460,7 +460,9 @@ export default { args: { integrationId: z.string().min(1).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.integrationId)}), toolName: z.string().min(1).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.toolName)}), - args: z.record(z.string(), z.json()).optional().describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), + // OpenCode renames $defs without rewriting refs. Keep JSON value types + // concrete but non-recursive; nested values are validated server-side. + args: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])).optional().describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), }, execute: (args, context) => invoke(${JSON.stringify(CALL_INTEGRATION_TOOL_TOOL.name)}, args, context), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e11e5dd4c..6b49a8c83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1207,6 +1207,9 @@ importers: '@types/node': specifier: ^24.10.13 version: 24.12.4 + ajv: + specifier: ^8.20.0 + version: 8.20.0 ioredis-mock: specifier: ^8.13.1 version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.10.1))(ioredis@5.10.1)