Files
oh-my-pi/packages/ai/test/schema-compatibility.test.ts
T
can1357 cba80c79c3 fix(schema): harden all provider schema normalizers with cycle detection, fixpoint iteration, and correctness fixes
## New: schema compatibility validation API

Add `validateSchemaCompatibility(schema, provider)` in
`packages/ai/src/utils/schema/compatibility.ts` that performs a static
audit of a JSON Schema against three provider targets:

- `openai-strict`: checks forbidden keys, required/properties symmetry,
  additionalProperties constraint, and that every node declares a type,
  combinator, or $ref
- `google`: checks unsupported keyword set and array-valued type
- `cloud-code-assist-claude`: checks forbidden keywords, array type,
  null type, nullable keyword, and combiner presence; also validates via
  AJV 2020 draft

Add `validateStrictSchemaEnforcement(original, result)` to assert the
fail-open contract: when strict enforcement succeeds the output must pass
openai-strict validation; when it fails the output must be the original
schema object (same reference).

Export both functions and their types from `./utils/schema/index.ts`.

## New: shared constants in fields.ts

Extract `COMBINATOR_KEYS` (`anyOf`, `allOf`, `oneOf`) and add
`CCA_UNSUPPORTED_SCHEMA_FIELDS` as exported constants, eliminating the
local duplicate in `strict-mode.ts` and providing a canonical field set
for Cloud Code Assist (much narrower than the Google set — CCA supports
validation keywords like `additionalProperties`, `minLength`,
`pattern`, etc.).

## Fix: cycle detection in all recursive schema traversals

All recursive walkers now carry a `WeakSet<object>` guard. Previously any
schema with a reference cycle (or a schema object that appears at two
nodes in the tree) would cause an infinite loop or a stack overflow:

- `sanitizeSchemaForStrictMode` / `enforceStrictSchema`
- `normalizeSchemaForCloudCodeAssistClaude`
- `normalizeNullablePropertiesForCloudCodeAssist`
- `stripResidualCombiners`
- `sanitizeSchemaImpl` (Google sanitizer)
- `hasResidualCloudCodeAssistIncompatibilities`

`hasResidualCloudCodeAssistIncompatibilities` previously returned `true`
for already-visited nodes, producing false positives that forced the CCA
fallback schema on valid (but multiply-referenced) schemas. It now
correctly returns `false`.

## Fix: stripResidualCombiners iterates to fixpoint

The previous single-pass approach missed chained combiner reductions
where one collapsed variant exposed another reducible combiner. The
rewriter now loops until no further reduction occurs.

## Fix: mergeObjectCombinerVariants required-field computation

The merged object schema now takes the intersection of all variants'
`required` arrays, then unions in own-level required properties that
exist in the merged schema. Previously the `required` field was silently
dropped from the flattened schema, making all properties effectively
optional.

## Fix: sanitizeSchemaForGoogle improvements

- Type inference for const-collapsed enums: type is derived from all
  variants (must unanimously agree), falling back to inference from enum
  values; mixed null/non-null infers the non-null scalar type and sets
  `nullable: true`
- Const→enum deduplication now uses deep structural equality instead of
  `Object.is`
- Recursion spreads the full options object so new fields (`unsupportedFields`,
  `seen`) are not silently dropped when descending into sub-schemas
- Array-valued `type` is filtered to strings before processing
- Removed incorrect stripping of `additionalProperties: false` (the
  field is valid and should be preserved)
- Parameterized `unsupportedFields` in `SanitizeSchemaOptions` enables
  code reuse between the Google and CCA sanitizers

## Fix: sanitizeSchemaForStrictMode / enforceStrictSchema

- `nullable: true` is now stripped during sanitization and expanded into
  `anyOf: [schema, {type: "null"}]` in the enforcer output, matching
  what OpenAI strict mode requires
- Type inference: `type: "array"` is inferred when `items` is present;
  a scalar type is inferred from uniform `enum` values
- Const→enum merge uses deep equality to avoid duplicate entries when
  both `const` and `enum` exist with the same value
- `additionalProperties` is now dropped unconditionally in sanitization
  (previously only object-valued `additionalProperties` was recursed;
  non-object values were passed through)
- `enforceStrictSchema` recurses into `$defs` and `definitions` blocks
- `enforceStrictSchema` handles tuple-style `items` arrays
- `enforceStrictSchema` skips double-wrapping: optional properties
  already expressed as `anyOf: [..., {type: "null"}]` are not wrapped again
- `tryEnforceStrictSchema` now caches results in a `WeakMap` keyed on
  the input schema object to avoid redundant work on repeated calls

## Fix: mergeCompatibleEnumSchemas deep equality

Uses `areJsonValuesEqual` instead of `Object.is` when deduplicating
enum members, so structurally equal objects are not duplicated.

## New: test coverage

- `packages/ai/test/schema-normalization.test.ts`: comprehensive unit
  tests for strict mode, Google, and Cloud Code Assist normalization
- `packages/ai/test/schema-compatibility.test.ts`: unit tests for all
  three provider targets in the new compatibility validator
- `packages/coding-agent/test/tools/provider-schema-compatibility.test.ts`:
  integration test that instantiates every builtin and hidden tool, runs
  their parameter schemas through all three provider pipelines, and
  asserts zero compatibility violations
2026-02-28 18:41:10 +01:00

137 lines
3.8 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
adaptSchemaForStrict,
prepareSchemaForCCA,
type SchemaCompatibilityResult,
sanitizeSchemaForGoogle,
validateSchemaCompatibility,
validateStrictSchemaEnforcement,
} from "@oh-my-pi/pi-ai/utils/schema";
function hasRule(result: SchemaCompatibilityResult, rule: string): boolean {
return result.violations.some(violation => violation.rule === rule);
}
describe("schema compatibility validation", () => {
it("validates strict-mode schemas after enforcement", () => {
const strictResult = adaptSchemaForStrict(
{
type: "object",
properties: {
requiredText: { type: "string" },
optionalCount: { type: "number" },
},
required: ["requiredText"],
},
true,
);
expect(strictResult.strict).toBe(true);
const compatibility = validateSchemaCompatibility(strictResult.schema, "openai-strict");
expect(compatibility.compatible).toBe(true);
expect(compatibility.violations).toEqual([]);
});
it("validates strict-mode fail-open contract when enforcement falls back", () => {
const originalSchema = {
type: "object",
properties: {
broken: {
type: "array",
items: {},
},
},
required: ["broken"],
} as Record<string, unknown>;
const strictResult = adaptSchemaForStrict(originalSchema, true);
expect(strictResult.strict).toBe(false);
const compatibility = validateStrictSchemaEnforcement(originalSchema, strictResult);
expect(compatibility.compatible).toBe(true);
expect(compatibility.violations).toEqual([]);
});
it("reports strict-mode violations for incompatible schemas", () => {
const compatibility = validateSchemaCompatibility(
{
type: "object",
properties: {
name: { type: "string" },
},
required: [],
},
"openai-strict",
);
expect(compatibility.compatible).toBe(false);
expect(hasRule(compatibility, "strict-object-required")).toBe(true);
});
it("validates Google-compatible schemas after sanitization", () => {
const sanitized = sanitizeSchemaForGoogle({
type: "object",
additionalProperties: false,
properties: {
pattern: { type: "string", description: "property name that matches schema keyword" },
status: { type: ["string", "null"], const: "active" },
},
required: ["status"],
});
const compatibility = validateSchemaCompatibility(sanitized, "google");
expect(compatibility.compatible).toBe(true);
expect(compatibility.violations).toEqual([]);
});
it("reports Google violations for unsupported schema features", () => {
const compatibility = validateSchemaCompatibility(
{
type: ["string", "null"],
additionalProperties: false,
},
"google",
);
expect(compatibility.compatible).toBe(false);
expect(hasRule(compatibility, "google-type-array")).toBe(true);
expect(hasRule(compatibility, "google-forbidden-key")).toBe(true);
});
it("validates Cloud Code Assist Claude schemas after normalization", () => {
const prepared = prepareSchemaForCCA({
type: "object",
properties: {
mode: { anyOf: [{ const: "fast" }, { const: "safe" }, { type: "null" }] },
},
required: ["mode"],
});
const compatibility = validateSchemaCompatibility(prepared, "cloud-code-assist-claude");
expect(compatibility.compatible).toBe(true);
expect(compatibility.violations).toEqual([]);
});
it("reports Cloud Code Assist residual incompatibilities", () => {
const compatibility = validateSchemaCompatibility(
{
type: "object",
properties: {
value: {
nullable: true,
anyOf: [{ type: "string" }, { type: "null" }],
},
},
required: ["value"],
},
"cloud-code-assist-claude",
);
expect(compatibility.compatible).toBe(false);
expect(hasRule(compatibility, "cca-nullable-key")).toBe(true);
expect(hasRule(compatibility, "cca-combiner")).toBe(true);
expect(hasRule(compatibility, "cca-null-type")).toBe(true);
});
});