From 3f52e26a73eb3b5031bfc77d00475d7651ca3416 Mon Sep 17 00:00:00 2001 From: lycaon Date: Fri, 10 Jul 2026 22:23:25 -0600 Subject: [PATCH] fix(ai): preserve CCA schemas with annotation conflicts --- packages/ai/CHANGELOG.md | 3 +++ packages/ai/src/utils/schema/normalize.ts | 17 +++++++++++++++-- packages/ai/test/schema-normalization.test.ts | 11 +++++++++++ .../tools/provider-schema-compatibility.test.ts | 16 ++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d9ab9b9f7..368e47ab2 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,9 @@ - Fixed an issue in the Responses API where empty tool results were incorrectly serialized with a "(see attached image)" placeholder, causing models to look for non-existent attachments. - Fixed OpenAI Responses server non-streaming envelopes to always include the required "incomplete_details" field, using null for completed responses. +### Fixed + +- Preserved Cloud Code Assist tool schemas when mixed-type unions carry branch-local validation descriptions. ## [16.4.2] - 2026-07-10 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index db621700c..e651a1b07 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -547,7 +547,11 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" const existingValue = mergedVariantFields[key]; if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) { - return schema; + if (key !== "description") return schema; + // Descriptions are annotations, so merge branch-local spill text instead of + // treating it as a structural incompatibility. + mergedVariantFields[key] = mergeSchemaDescriptions(existingValue, variantValue); + continue; } mergedVariantFields[key] = variantValue; } @@ -588,7 +592,9 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" const value = mergedVariantFields[key]; const existingValue = nextSchema[key]; if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) { - return schema; + if (key !== "description") return schema; + nextSchema[key] = mergeSchemaDescriptions(existingValue, value); + continue; } if (existingValue === undefined) { nextSchema[key] = value; @@ -597,6 +603,13 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" return nextSchema; } +function mergeSchemaDescriptions(existing: unknown, incoming: unknown): string { + if (typeof existing !== "string") return typeof incoming === "string" ? incoming : ""; + if (typeof incoming !== "string" || incoming.length === 0 || existing === incoming) return existing; + if (existing.length === 0) return incoming; + return `${existing}\n\n${incoming}`; +} + function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject { const variantsRaw = schema[combiner]; if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema; diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 2cbcc5097..d3b031666 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -1021,6 +1021,17 @@ describe("normalizeSchemaForCCA", () => { }); }); + it("keeps mixed unions when branch validation spill differs from the parent description", () => { + const normalized = normalizeSchemaForCCA({ + anyOf: [{ type: "string" }, { type: "array", minItems: 1, items: { type: "string" } }], + description: "Optional result type", + }) as Record; + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional result type\n\n{minItems: 1}"); + }); + 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. diff --git a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts index 4ca29e8cf..60e099649 100644 --- a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts +++ b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts @@ -138,6 +138,22 @@ describe("builtin tool schemas provider compatibility", () => { expect(failures).toEqual([]); }); + it("preserves the yield result schema for Cloud Code Assist", async () => { + const toolSchemas = await collectToolSchemas(); + const yieldEntry = toolSchemas.find(tool => tool.name === "yield"); + expect(yieldEntry).toBeDefined(); + if (!yieldEntry) return; + + const normalized = asSchemaObject(normalizeSchemaForCCA(yieldEntry.schema)); + const properties = asSchemaObject(normalized?.properties); + const typeSchema = asSchemaObject(properties?.type); + + expect(normalized?.type).toBe("object"); + expect(properties?.result).toBeDefined(); + expect(typeSchema?.type).toBe("string"); + expect(typeSchema?.anyOf).toBeUndefined(); + }); + it("asserts that browser tool schema root has 'type: \"object\"' for Codex and OpenAI Responses compatibility", async () => { const toolSchemas = await collectToolSchemas(); const browserEntry = toolSchemas.find(tool => tool.name === "browser");