From 508dbbbc5a89c4246bd7fbec216bf339cf01853f Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 15 Jul 2026 19:14:49 +0000 Subject: [PATCH] fix(schema): coerce boolean subschemas for google/cca transport Boolean JSON Schema subschemas (`true`/`false`, draft 6+) in MCP tool inputs passed through normalizeSchemaForGoogle/normalizeSchemaForCCA untouched. The Cloud Code Assist / Gemini protobuf Schema type has no representation for a bare boolean, so requests bounced with a 400 INVALID_ARGUMENT before reaching the model. Coerce booleans to their object equivalents (`true` -> `{}`, `false` -> `{ not: {} }`) at the single normalizeSchemaNode choke point, but only in genuine subschema slots (root, combiner branches, subschema-valued keywords, property values). Keyword-slot booleans (`nullable`, `enum` entries, `additionalProperties`) stay untouched so Moonshot/MCP open-record markers survive. Fixes #5604 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/utils/schema/normalize.ts | 40 +++++++++++++++++++ packages/ai/test/schema-normalization.test.ts | 39 +++++++++++++++--- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d3095bf01..06232f8a8 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed boolean JSON Schema subschemas (`true`/`false`) in MCP tool inputs triggering `400 INVALID_ARGUMENT` on the Google/Cloud Code Assist (Antigravity) transport by coercing them to their object equivalents (`true` → `{}`, `false` → `{ not: {} }`) before sending ([#5604](https://github.com/can1357/oh-my-pi/issues/5604)). + ## [17.0.0] - 2026-07-15 ### Changed diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index e651a1b07..60d5494c9 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -56,6 +56,13 @@ export interface NormalizeSchemaOptions { interface NormalizeSchemaWalkOptions extends NormalizeSchemaOptions { insideProperties: boolean; + /** + * True when the value currently being walked occupies a JSON Schema + * *subschema* slot (root, combiner branch, `items`, a property value, …). + * Only then is a bare `true`/`false` a boolean subschema to coerce; in a + * keyword slot (`nullable`, `enum` entries, `additionalProperties`) it stays. + */ + booleanIsSubschema: boolean; } interface ResidualIncompatibilityChecks { @@ -75,6 +82,26 @@ const SNAKE_TO_CAMEL_RENAMES = new Map([ const JSON_SCHEMA_COMBINERS = ["anyOf", "oneOf"] as const; const CCA_FORBIDDEN_COMBINERS = new Set(["anyOf", "oneOf", "allOf"]); +/** + * Keywords whose value is a single subschema (draft 2020-12). A bare `true` / + * `false` in one of these slots is a boolean subschema to coerce (issue #5604). + */ +const SUBSCHEMA_VALUE_KEYS = new Set([ + "items", + "additionalItems", + "unevaluatedItems", + "not", + "if", + "then", + "else", + "contains", + "propertyNames", + "contentSchema", +]); + +/** Keywords whose value is an array of subschemas. */ +const SUBSCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]); + const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = { type: "object", properties: {}, @@ -236,6 +263,15 @@ function normalizeSchemaNode(value: unknown, options: NormalizeSchemaWalkOptions exit(value); } } + if (typeof value === "boolean") { + // A bare boolean is a JSON Schema subschema only in a subschema slot. + // The Google/CCA protobuf Schema wire has no representation for it + // (issue #5604): `true` accepts anything -> `{}`, `false` accepts nothing + // -> `{ not: {} }`. In a keyword slot (`nullable`, `enum` entry, …) a + // boolean is a plain value and is left untouched. + if (!options.booleanIsSubschema) return value; + return value ? {} : { not: {} }; + } if (!isJsonObject(value)) { return value; } @@ -306,6 +342,8 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa result[key] = normalizeSchemaNode(entry, { ...options, insideProperties: !options.insideProperties && key === "properties", + booleanIsSubschema: + options.insideProperties || SUBSCHEMA_VALUE_KEYS.has(key) || SUBSCHEMA_ARRAY_KEYS.has(key), }); } applyDescriptionSpill(result, spill, options); @@ -328,6 +366,7 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa result[key] = normalizeSchemaNode(entry, { ...options, insideProperties: !options.insideProperties && key === "properties", + booleanIsSubschema: options.insideProperties || SUBSCHEMA_VALUE_KEYS.has(key) || SUBSCHEMA_ARRAY_KEYS.has(key), }); } @@ -895,6 +934,7 @@ export function normalizeSchema(value: unknown, options: NormalizeSchemaOptions) let normalized = normalizeSchemaNode(dereferenced, { ...options, insideProperties: false, + booleanIsSubschema: true, }); if (options.stripResidualCombinersFixpoint) { normalized = stripResidualCombiners(normalized); diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index d3b031666..fed1f1c17 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -252,7 +252,7 @@ describe("normalizeSchemaForGoogle", () => { expect(sanitized.enum).toEqual([null]); }); - it("preserves a property schema literally named additionalProperties inside properties", () => { + it("coerces a boolean subschema literally named additionalProperties inside properties", () => { const sanitized = normalizeSchemaForGoogle({ type: "object", properties: { @@ -262,20 +262,47 @@ describe("normalizeSchemaForGoogle", () => { }) as Record; const properties = sanitized.properties as Record; + // The key survives (it is a property, not the stripped keyword), but its + // boolean subschema value coerces to the object form (issue #5604). expect(Object.hasOwn(properties, "additionalProperties")).toBe(true); - expect(properties.additionalProperties).toBe(false); + expect(properties.additionalProperties).toEqual({ not: {} }); }); - it("preserves boolean schemas for a single property literally named additionalProperties", () => { - const schema = { + it("coerces a boolean subschema for a single property literally named additionalProperties", () => { + const sanitized = normalizeSchemaForGoogle({ type: "object", properties: { additionalProperties: false, }, required: ["additionalProperties"], - } as const; + }) as Record; - expect(normalizeSchemaForGoogle(schema)).toEqual(schema); + const properties = sanitized.properties as Record; + expect(properties.additionalProperties).toEqual({ not: {} }); + expect(sanitized.required).toEqual(["additionalProperties"]); + }); + + it("coerces boolean subschemas to object equivalents on the Google/CCA wire (issue #5604)", () => { + const schema = { + type: "object", + properties: { + propertyValue: true, + attributeValue: false, + }, + }; + const expectedProps = { propertyValue: {}, attributeValue: { not: {} } }; + + const google = normalizeSchemaForGoogle(schema) as Record; + expect(google.properties).toEqual(expectedProps); + const cca = normalizeSchemaForCCA(schema) as Record; + expect(cca.properties).toEqual(expectedProps); + + // Root-level and array-branch booleans are covered by the same choke point. + expect(normalizeSchemaForGoogle(true)).toEqual({}); + expect(normalizeSchemaForGoogle(false)).toEqual({ not: {} }); + expect(normalizeSchemaForGoogle({ anyOf: [true, { type: "string" }] })).toEqual({ + anyOf: [{}, { type: "string" }], + }); }); it("inlines local $ref / $defs entries for Google compatibility", () => {