cba80c79c3
## 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
118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
adaptSchemaForStrict,
|
|
prepareSchemaForCCA,
|
|
type SchemaCompatibilityProvider,
|
|
type SchemaCompatibilityResult,
|
|
sanitizeSchemaForGoogle,
|
|
validateSchemaCompatibility,
|
|
validateStrictSchemaEnforcement,
|
|
} from "@oh-my-pi/pi-ai/utils/schema";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { createTools, HIDDEN_TOOLS, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
|
|
interface ToolSchemaEntry {
|
|
name: string;
|
|
schema: Record<string, unknown>;
|
|
}
|
|
|
|
function createTestSession(): ToolSession {
|
|
return {
|
|
cwd: "/tmp/test",
|
|
hasUI: true,
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
settings: Settings.isolated(),
|
|
};
|
|
}
|
|
|
|
function asSchemaObject(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
async function collectToolSchemas(): Promise<ToolSchemaEntry[]> {
|
|
const session = createTestSession();
|
|
const byToolName = new Map<string, Record<string, unknown>>();
|
|
|
|
for (const tool of await createTools(session)) {
|
|
const schema = asSchemaObject(tool.parameters);
|
|
if (!schema) {
|
|
continue;
|
|
}
|
|
byToolName.set(tool.name, schema);
|
|
}
|
|
|
|
for (const [name, factory] of Object.entries(HIDDEN_TOOLS)) {
|
|
const tool = await factory(session);
|
|
if (!tool) {
|
|
continue;
|
|
}
|
|
const schema = asSchemaObject(tool.parameters);
|
|
if (!schema) {
|
|
continue;
|
|
}
|
|
byToolName.set(name, schema);
|
|
}
|
|
|
|
return [...byToolName.entries()]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([name, schema]) => ({ name, schema }));
|
|
}
|
|
|
|
function formatCompatibilityIssues(
|
|
toolName: string,
|
|
provider: SchemaCompatibilityProvider,
|
|
result: SchemaCompatibilityResult,
|
|
): string {
|
|
if (result.compatible) {
|
|
return "";
|
|
}
|
|
const details = result.violations
|
|
.map(violation => ` - ${violation.rule} at ${violation.path}: ${violation.message}`)
|
|
.join("\n");
|
|
return `${toolName} (${provider}):\n${details}`;
|
|
}
|
|
|
|
describe("builtin tool schemas provider compatibility", () => {
|
|
it("keeps all builtin and hidden tool schemas valid after provider enforcement", async () => {
|
|
const toolSchemas = await collectToolSchemas();
|
|
const failures: string[] = [];
|
|
|
|
for (const { name, schema } of toolSchemas) {
|
|
const strictResult = adaptSchemaForStrict(schema, true);
|
|
const strictCompatibility = validateStrictSchemaEnforcement(schema, strictResult);
|
|
if (!strictCompatibility.compatible) {
|
|
failures.push(formatCompatibilityIssues(name, "openai-strict", strictCompatibility));
|
|
}
|
|
|
|
try {
|
|
const googleSchema = sanitizeSchemaForGoogle(schema);
|
|
const googleCompatibility = validateSchemaCompatibility(googleSchema, "google");
|
|
if (!googleCompatibility.compatible) {
|
|
failures.push(formatCompatibilityIssues(name, "google", googleCompatibility));
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${name} (google): sanitizeSchemaForGoogle threw: ${String(error)}`);
|
|
}
|
|
|
|
const cloudCodeAssistSchema = prepareSchemaForCCA(schema);
|
|
const cloudCodeAssistCompatibility = validateSchemaCompatibility(
|
|
cloudCodeAssistSchema,
|
|
"cloud-code-assist-claude",
|
|
);
|
|
if (!cloudCodeAssistCompatibility.compatible) {
|
|
failures.push(formatCompatibilityIssues(name, "cloud-code-assist-claude", cloudCodeAssistCompatibility));
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
throw new Error(`Provider compatibility failures:\n\n${failures.join("\n\n")}`);
|
|
}
|
|
|
|
expect(failures).toEqual([]);
|
|
});
|
|
});
|