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
This commit is contained in:
roboomp
2026-07-15 19:14:49 +00:00
parent d5cd24f39a
commit 508dbbbc5a
3 changed files with 77 additions and 6 deletions
+4
View File
@@ -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
+40
View File
@@ -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<string, string>([
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);
+33 -6
View File
@@ -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<string, unknown>;
const properties = sanitized.properties as Record<string, unknown>;
// 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<string, unknown>;
expect(normalizeSchemaForGoogle(schema)).toEqual(schema);
const properties = sanitized.properties as Record<string, unknown>;
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<string, unknown>;
expect(google.properties).toEqual(expectedProps);
const cca = normalizeSchemaForCCA(schema) as Record<string, unknown>;
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", () => {