diff --git a/packages/ai/src/utils/schema/fields.ts b/packages/ai/src/utils/schema/fields.ts index 41e9aacf1..b25006248 100644 --- a/packages/ai/src/utils/schema/fields.ts +++ b/packages/ai/src/utils/schema/fields.ts @@ -154,6 +154,22 @@ export const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record = buildAllCcaTypeSpecificKeys(); + +function buildAllCcaTypeSpecificKeys(): Record { + const all: Record = {}; + for (const typeKeys of Object.values(CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS)) { + for (const key in typeKeys) { + all[key] = true; + } + } + return all; +} + /** * Cloud Code Assist shared schema keys allowed on any type. * Used alongside CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS for CCA combiner collapsing. diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index e10a46270..0d2961f26 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -11,6 +11,7 @@ import { dereferenceJsonSchema } from "./dereference"; import { upgradeJsonSchemaTo202012 } from "./draft"; import { areJsonValuesEqual, mergePropertySchemas } from "./equality"; import { + ALL_CCA_TYPE_SPECIFIC_KEYS, CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS, COMBINATOR_KEYS, @@ -501,11 +502,26 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) { return schema; } - const nextSchema = copySchemaWithout(schema, combiner); const nonNullTypes = variantTypes.filter(t => t !== "null"); - nextSchema.type = nonNullTypes[0] ?? variantTypes[0]; - const chosenTypeAllowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[nextSchema.type as string] ?? {}; + const chosenType: string = nonNullTypes[0] ?? variantTypes[0]; + nextSchema.type = chosenType; + const chosenTypeAllowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[chosenType] ?? {}; + + // Strip sibling keys that were copied from the parent and belong to a + // different type (e.g. `items` sibling on a now-string-typed schema). + for (const key in nextSchema) { + if (!Object.hasOwn(nextSchema, key)) continue; + if (key === "type") continue; + if ( + Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) && + !Object.hasOwn(chosenTypeAllowedKeys, key) && + !Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key) + ) { + delete nextSchema[key]; + } + } + for (const key in mergedVariantFields) { if (!Object.hasOwn(mergedVariantFields, key)) continue; // Drop type-specific keys that don't belong to the chosen type diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index b2c23fbc4..22efcb2cd 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -979,6 +979,21 @@ describe("normalizeSchemaForCCA", () => { description: "pr number, url, or branch", }); }); + + it("strips sibling type-specific keys copied from parent when mixed-type collapse picks opposing type", () => { + // Edge case: parent has a sibling `items` outside the anyOf, + // and the chosen type is string. The sibling must be stripped. + const normalized = normalizeSchemaForCCA({ + anyOf: [{ type: "string" }, { type: "array", items: { type: "number" } }], + items: { type: "string" }, + description: "pr number, url, or branch", + }); + + expect(normalized).toEqual({ + type: "string", + description: "pr number, url, or branch", + }); + }); }); // ---------------------------------------------------------------------------