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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user