bc39ffa265
- Introduce `@oh-my-pi/omptype` as a new ArkType-compatible schema validation package featuring a lazy JIT runtime, JSON Schema emission, and compatibility adapters. - Replace `arktype` across workspace packages and test utilities with `@oh-my-pi/omptype`. - Add benchmark suites, tests, and documentation for the new validation engine and adapters. - Update workspace build, test runner, and release configurations to include the new package.
630 lines
24 KiB
TypeScript
630 lines
24 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { type } from "@oh-my-pi/omptype";
|
|
import { normalizeAnthropicToolSchema } from "@oh-my-pi/pi-ai/providers/anthropic";
|
|
import type { Tool } from "@oh-my-pi/pi-ai/types";
|
|
import {
|
|
adaptSchemaForStrict,
|
|
decontaminateZodInstance,
|
|
isZodSchema,
|
|
normalizeEmptySchemas,
|
|
normalizeSchemaForCCA,
|
|
normalizeSchemaForGoogle,
|
|
stripSchemaDescriptions,
|
|
stripToolDescriptions,
|
|
toolWireSchema,
|
|
zodToWireSchema,
|
|
} from "@oh-my-pi/pi-ai/utils/schema";
|
|
import { z } from "zod/v4";
|
|
|
|
describe("isZodSchema", () => {
|
|
it("accepts a live Zod instance", () => {
|
|
expect(isZodSchema(z.object({ a: z.string() }))).toBe(true);
|
|
expect(isZodSchema(z.string())).toBe(true);
|
|
expect(isZodSchema(z.enum({ a: "a", b: "b" }))).toBe(true);
|
|
});
|
|
|
|
// Regression: issue #1101. Before tightening, `isZodSchema` returned true
|
|
// for `JSON.parse(JSON.stringify(zodSchema))` because the `_zod` property
|
|
// (and its object value) survived the round-trip — even though every Zod
|
|
// method had been stripped along with the prototype. The relaxed predicate
|
|
// fed garbage into `z.toJSONSchema` and (when callers bypassed conversion)
|
|
// shipped the raw Zod internals to Anthropic's strict validator.
|
|
it("rejects a JSON-roundtripped Zod schema (prototype lost)", () => {
|
|
const impostor = JSON.parse(JSON.stringify(z.object({ a: z.string() })));
|
|
expect(isZodSchema(impostor)).toBe(false);
|
|
});
|
|
|
|
it("rejects the raw gitnexus_impact.direction payload from issue #1101", () => {
|
|
const impostor = {
|
|
def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } },
|
|
type: "enum",
|
|
enum: { upstream: "upstream", downstream: "downstream" },
|
|
options: ["upstream", "downstream"],
|
|
};
|
|
expect(isZodSchema(impostor)).toBe(false);
|
|
});
|
|
|
|
it("rejects plain JSON Schema objects", () => {
|
|
expect(isZodSchema({ type: "object", properties: {} })).toBe(false);
|
|
expect(isZodSchema({ type: "string" })).toBe(false);
|
|
});
|
|
|
|
it("rejects non-objects", () => {
|
|
expect(isZodSchema(null)).toBe(false);
|
|
expect(isZodSchema(undefined)).toBe(false);
|
|
expect(isZodSchema("string")).toBe(false);
|
|
expect(isZodSchema(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// zodToWireSchema — empty-schema normalization (issue #1179)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("zodToWireSchema — empty-schema normalization", () => {
|
|
it("converts z.unknown() additionalProperties from {} to true (z.record case)", () => {
|
|
// Grammar-constrained samplers treat {} as "emit empty object" rather than
|
|
// "any JSON value". Normalizing to `true` lets models emit strings.
|
|
const schema = z.object({ extra: z.record(z.string(), z.unknown()) });
|
|
const wire = zodToWireSchema(schema);
|
|
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra.additionalProperties).toBe(true);
|
|
});
|
|
|
|
it("converts z.unknown() items from {} to true (z.array case)", () => {
|
|
const schema = z.object({ items: z.array(z.unknown()) });
|
|
const wire = zodToWireSchema(schema);
|
|
const items = (wire.properties as Record<string, unknown>).items as Record<string, unknown>;
|
|
expect(items.items).toBe(true);
|
|
});
|
|
|
|
it("converts z.unknown() property schemas from {} to true", () => {
|
|
const schema = z.object({ meta: z.unknown() });
|
|
const wire = zodToWireSchema(schema);
|
|
const meta = (wire.properties as Record<string, unknown>).meta;
|
|
expect(meta).toBe(true);
|
|
});
|
|
|
|
it("does not touch non-empty schemas or boolean values", () => {
|
|
const schema = z.object({ name: z.string() });
|
|
const wire = zodToWireSchema(schema);
|
|
const name = (wire.properties as Record<string, unknown>).name as Record<string, unknown>;
|
|
expect(name.type).toBe("string");
|
|
expect(name.additionalProperties).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("zodToWireSchema — nullable scalar normalization", () => {
|
|
it("rewrites nullable scalar anyOf to a type array while preserving metadata", () => {
|
|
const schema = z.object({ skip: z.number().nullable().describe("matches to skip") });
|
|
const wire = zodToWireSchema(schema);
|
|
const skip = (wire.properties as Record<string, unknown>).skip as Record<string, unknown>;
|
|
expect(skip).toEqual({
|
|
type: ["number", "null"],
|
|
description: "matches to skip",
|
|
});
|
|
});
|
|
|
|
it("preserves null semantics when rewriting nullable scalar enum anyOf", () => {
|
|
const wire = toolWireSchema({
|
|
name: "mcp__sentry_search_docs",
|
|
description: "",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
guide: {
|
|
anyOf: [{ type: "string", enum: ["javascript", "python"], description: "guide" }, { type: "null" }],
|
|
},
|
|
},
|
|
required: ["guide"],
|
|
},
|
|
async execute() {},
|
|
} as unknown as Tool);
|
|
const guide = (wire.properties as Record<string, unknown>).guide as Record<string, unknown>;
|
|
|
|
expect(guide).toEqual({
|
|
type: ["string", "null"],
|
|
enum: ["javascript", "python", null],
|
|
description: "guide",
|
|
});
|
|
});
|
|
|
|
it("keeps nullable integers free of Zod safe-integer bounds", () => {
|
|
const schema = z.object({ count: z.number().int().nullable() });
|
|
const wire = zodToWireSchema(schema);
|
|
const count = (wire.properties as Record<string, unknown>).count as Record<string, unknown>;
|
|
expect(count).toEqual({ type: ["integer", "null"] });
|
|
});
|
|
|
|
it("leaves mixed nullable unions as anyOf", () => {
|
|
const schema = z.object({ value: z.union([z.string(), z.number()]).nullable() });
|
|
const wire = zodToWireSchema(schema);
|
|
const value = (wire.properties as Record<string, unknown>).value as Record<string, unknown>;
|
|
expect(value.type).toBeUndefined();
|
|
expect(Array.isArray(value.anyOf)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// normalizeEmptySchemas — provider-agnostic post-pipeline normalization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("normalizeEmptySchemas", () => {
|
|
it("normalizes {} in additionalProperties / items / property values / combiner branches", () => {
|
|
const schema: Record<string, unknown> = {
|
|
type: "object",
|
|
properties: { meta: {}, items: { type: "array", items: {} } },
|
|
additionalProperties: {},
|
|
anyOf: [{}, { type: "string" }],
|
|
};
|
|
normalizeEmptySchemas(schema);
|
|
expect(schema).toEqual({
|
|
type: "object",
|
|
properties: { meta: true, items: { type: "array", items: true } },
|
|
additionalProperties: true,
|
|
anyOf: [true, { type: "string" }],
|
|
});
|
|
});
|
|
|
|
it("leaves non-empty schemas and boolean values alone", () => {
|
|
const schema: Record<string, unknown> = {
|
|
type: "object",
|
|
additionalProperties: { type: "string" },
|
|
unevaluatedProperties: false,
|
|
};
|
|
normalizeEmptySchemas(schema);
|
|
expect(schema).toEqual({
|
|
type: "object",
|
|
additionalProperties: { type: "string" },
|
|
unevaluatedProperties: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// toolWireSchema — covers both Zod and TypeBox paths (issue #1179)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("toolWireSchema — empty-schema normalization across both paths", () => {
|
|
function zodTool(parameters: z.ZodType): Tool {
|
|
return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool;
|
|
}
|
|
function jsonTool(parameters: Record<string, unknown>): Tool {
|
|
return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool;
|
|
}
|
|
|
|
it("normalizes {} → true for Zod tools (z.record(z.string(), z.unknown()))", () => {
|
|
const wire = toolWireSchema(zodTool(z.object({ extra: z.record(z.string(), z.unknown()) })));
|
|
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra.additionalProperties).toBe(true);
|
|
});
|
|
|
|
it("normalizes {} → true for TypeBox / raw JSON Schema tools", () => {
|
|
const wire = toolWireSchema(
|
|
jsonTool({
|
|
type: "object",
|
|
properties: { extra: { type: "object", additionalProperties: {} } },
|
|
required: [],
|
|
}),
|
|
);
|
|
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra.additionalProperties).toBe(true);
|
|
});
|
|
|
|
it("normalizes nullable scalar anyOf for TypeBox / raw JSON Schema tools", () => {
|
|
const wire = toolWireSchema(
|
|
jsonTool({
|
|
type: "object",
|
|
properties: {
|
|
skip: {
|
|
anyOf: [{ type: "number", minimum: 0 }, { type: "null" }],
|
|
description: "matches to skip",
|
|
},
|
|
},
|
|
required: ["skip"],
|
|
}),
|
|
);
|
|
const skip = (wire.properties as Record<string, unknown>).skip as Record<string, unknown>;
|
|
expect(skip).toEqual({
|
|
type: ["number", "null"],
|
|
description: "matches to skip",
|
|
minimum: 0,
|
|
});
|
|
});
|
|
|
|
it("preserves raw JSON Schema required defaults and safe-integer bounds", () => {
|
|
const wire = toolWireSchema(
|
|
jsonTool({
|
|
type: "object",
|
|
properties: {
|
|
mode: { type: "string", default: "fast" },
|
|
limit: {
|
|
type: "integer",
|
|
minimum: Number.MIN_SAFE_INTEGER,
|
|
maximum: Number.MAX_SAFE_INTEGER,
|
|
},
|
|
},
|
|
required: ["mode", "limit"],
|
|
}),
|
|
);
|
|
expect(wire.required).toEqual(["mode", "limit"]);
|
|
const limit = (wire.properties as Record<string, unknown>).limit as Record<string, unknown>;
|
|
expect(limit.minimum).toBe(Number.MIN_SAFE_INTEGER);
|
|
expect(limit.maximum).toBe(Number.MAX_SAFE_INTEGER);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider downstream behavior with normalized `additionalProperties: true`
|
|
// (issue #1179 — verify Google and Anthropic don't break)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("provider normalizers on normalized open-record schemas", () => {
|
|
const wire = zodToWireSchema(
|
|
z.object({
|
|
action: z.enum(["apply", "discard"]),
|
|
extra: z.record(z.string(), z.unknown()).optional(),
|
|
}),
|
|
);
|
|
|
|
it("Anthropic preserves additionalProperties: true so strict-mode opt-out still fires", () => {
|
|
// `normalizeAnthropicStrictSchemaNode` rejects nodes where additionalProperties !== false.
|
|
// With normalization, the value is `true` (was `{}`); still !== false, so strict opts out.
|
|
const out = normalizeAnthropicToolSchema(wire) as Record<string, unknown>;
|
|
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra.additionalProperties).toBe(true);
|
|
});
|
|
|
|
it("Google strips additionalProperties entirely (UNSUPPORTED_SCHEMA_FIELDS)", () => {
|
|
// Pre-existing behavior — Google never sees the open-record marker either way.
|
|
// `additionalProperties: true` is removed just like `additionalProperties: {}` was.
|
|
const out = normalizeSchemaForGoogle(wire) as Record<string, unknown>;
|
|
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra).not.toHaveProperty("additionalProperties");
|
|
});
|
|
|
|
it("CCA (Claude on Cloud Code Assist) strips additionalProperties entirely", () => {
|
|
const out = normalizeSchemaForCCA(wire) as Record<string, unknown>;
|
|
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
|
|
expect(extra).not.toHaveProperty("additionalProperties");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// decontaminateZodInstance — nullable wrapping of non-scalar inner schemas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("decontaminateZodInstance — nullable union", () => {
|
|
it("z.union([z.string(), z.number()]).nullable() produces a null-tolerant schema", () => {
|
|
// Round-trip strips Zod methods; decontaminateZodInstance must then inject null.
|
|
const roundTripped = JSON.parse(JSON.stringify(z.union([z.string(), z.number()]).nullable()));
|
|
const out = decontaminateZodInstance(roundTripped) as Record<string, unknown>;
|
|
// The union inner schema surfaces as an anyOf shape (no scalar `type`), so
|
|
// nullable wrapping must produce { anyOf: [..., { type: "null" }] }.
|
|
const toleratesNull =
|
|
(Array.isArray(out.type) && (out.type as string[]).includes("null")) ||
|
|
(Array.isArray(out.anyOf) &&
|
|
(out.anyOf as unknown[]).some(
|
|
b => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "null",
|
|
));
|
|
expect(toleratesNull).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// arkToWireSchema — `T | undefined` value-union pruning (Codex strict-mode break)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("arkToWireSchema — undefined-union branch pruning", () => {
|
|
function arkTool(parameters: unknown): Tool {
|
|
return { name: "t", description: "d", parameters } as Tool;
|
|
}
|
|
/** True when any `anyOf`/`oneOf`/`allOf` carries a `true` or `{}` branch. */
|
|
function hasPermissiveBranch(node: unknown): boolean {
|
|
if (Array.isArray(node)) return node.some(hasPermissiveBranch);
|
|
if (!node || typeof node !== "object") return false;
|
|
const obj = node as Record<string, unknown>;
|
|
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
const arr = obj[key];
|
|
if (
|
|
Array.isArray(arr) &&
|
|
arr.some(
|
|
b =>
|
|
b === true ||
|
|
(b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length === 0),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return Object.values(obj).some(hasPermissiveBranch);
|
|
}
|
|
|
|
it("inlines `T | undefined` to the concrete type and keeps the key required", () => {
|
|
const wire = toolWireSchema(arkTool(type({ id: "string | undefined", assignment: "string" })));
|
|
const props = wire.properties as Record<string, unknown>;
|
|
expect(props.id).toEqual({ type: "string" });
|
|
// ArkType validates `string | undefined` as required-present (an absent key is
|
|
// rejected at runtime), so the wire must keep the key required for consistency.
|
|
expect(wire.required).toEqual(expect.arrayContaining(["id", "assignment"]));
|
|
expect(hasPermissiveBranch(wire)).toBe(false);
|
|
});
|
|
|
|
it("prunes the undefined branch inside nested array items", () => {
|
|
const wire = toolWireSchema(
|
|
arkTool(type({ tasks: type({ id: "string | undefined", assignment: "string" }).array() })),
|
|
);
|
|
const tasks = (wire.properties as Record<string, unknown>).tasks as Record<string, unknown>;
|
|
const itemProps = (tasks.items as Record<string, unknown>).properties as Record<string, unknown>;
|
|
expect(itemProps.id).toEqual({ type: "string" });
|
|
expect(hasPermissiveBranch(wire)).toBe(false);
|
|
});
|
|
|
|
it("collapses `(string | undefined)[]` element unions to a typed item", () => {
|
|
const wire = toolWireSchema(arkTool(type({ xs: "(string | undefined)[]" })));
|
|
const xs = (wire.properties as Record<string, unknown>).xs as Record<string, unknown>;
|
|
expect(xs.items).toEqual({ type: "string" });
|
|
expect(hasPermissiveBranch(wire)).toBe(false);
|
|
});
|
|
|
|
it("stays strict-mode-representable end to end (Codex acceptance)", () => {
|
|
const wire = toolWireSchema(
|
|
arkTool(
|
|
type({
|
|
agent: "string",
|
|
context: "string",
|
|
tasks: type({ id: "string | undefined", assignment: "string" }).array(),
|
|
}),
|
|
),
|
|
);
|
|
const adapted = adaptSchemaForStrict(wire, true);
|
|
expect(adapted.strict).toBe(true);
|
|
expect(hasPermissiveBranch(adapted.schema)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// arkToWireSchema — authored property order (guards the @ark/schema patch)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("arkToWireSchema — authored property order", () => {
|
|
function arkTool(parameters: unknown): Tool {
|
|
return { name: "t", description: "d", parameters } as Tool;
|
|
}
|
|
|
|
it("preserves declaration order rather than alphabetizing keys", () => {
|
|
// Without the @ark/schema patch, ArkType canonicalizes keys by hash
|
|
// (alphabetical): `zebra, content, path`. Streaming renderers and prompt
|
|
// caching depend on the authored order being preserved on the wire.
|
|
const wire = toolWireSchema(arkTool(type({ path: "string", content: "string", zebra: "string" })));
|
|
expect(Object.keys(wire.properties as Record<string, unknown>)).toEqual(["path", "content", "zebra"]);
|
|
});
|
|
|
|
it("emits required props before optional props, each in declaration order", () => {
|
|
const wire = toolWireSchema(
|
|
arkTool(type({ pattern: "string", "paths?": "string", i: "boolean", "skip?": "number" })),
|
|
);
|
|
expect(Object.keys(wire.properties as Record<string, unknown>)).toEqual(["pattern", "i", "paths", "skip"]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// const-union collapse — ArkType's described literal unions / generic anyOf
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("const-union collapse", () => {
|
|
function tool(parameters: unknown): Tool {
|
|
return { name: "t", description: "d", parameters } as Tool;
|
|
}
|
|
|
|
it("collapses a described ArkType enumerated union into one typed enum with a single description", () => {
|
|
const wire = toolWireSchema(tool(type({ size: type.enumerated("a", "b", "c").describe("label") })));
|
|
const size = (wire.properties as Record<string, unknown>).size as Record<string, unknown>;
|
|
expect(size.anyOf).toBeUndefined();
|
|
expect(size.type).toBe("string");
|
|
expect(size.description).toBe("label");
|
|
expect(size.enum).toEqual(expect.arrayContaining(["a", "b", "c"]));
|
|
expect((size.enum as unknown[]).length).toBe(3);
|
|
// "label" appears exactly once — not duplicated onto every value branch.
|
|
expect(JSON.stringify(size).match(/"label"/g)?.length).toBe(1);
|
|
});
|
|
|
|
it("emits an exact { type, enum, description } shape, lifting a shared branch description", () => {
|
|
const param = {
|
|
type: "object",
|
|
properties: {
|
|
x: {
|
|
anyOf: [
|
|
{ const: "a", description: "lbl" },
|
|
{ const: "b", description: "lbl" },
|
|
],
|
|
},
|
|
},
|
|
required: ["x"],
|
|
additionalProperties: false,
|
|
};
|
|
const wire = toolWireSchema(tool(param));
|
|
const x = (wire.properties as Record<string, unknown>).x as Record<string, unknown>;
|
|
expect(x).toEqual({ type: "string", enum: ["a", "b"], description: "lbl" });
|
|
});
|
|
|
|
it("leaves a non-const anyOf with distinct per-branch descriptions untouched", () => {
|
|
const param = {
|
|
type: "object",
|
|
properties: {
|
|
x: {
|
|
anyOf: [
|
|
{ type: "string", description: "a string" },
|
|
{ type: "number", description: "a number" },
|
|
],
|
|
},
|
|
},
|
|
required: ["x"],
|
|
additionalProperties: false,
|
|
};
|
|
const wire = toolWireSchema(tool(param));
|
|
const x = (wire.properties as Record<string, unknown>).x as Record<string, unknown>;
|
|
expect(x.enum).toBeUndefined();
|
|
expect(x.anyOf).toEqual([
|
|
{ type: "string", description: "a string" },
|
|
{ type: "number", description: "a number" },
|
|
]);
|
|
});
|
|
|
|
it("preserves a const union whose branches carry distinct per-variant descriptions", () => {
|
|
const param = {
|
|
type: "object",
|
|
properties: {
|
|
x: {
|
|
anyOf: [
|
|
{ const: "a", description: "first" },
|
|
{ const: "b", description: "second" },
|
|
],
|
|
},
|
|
},
|
|
required: ["x"],
|
|
additionalProperties: false,
|
|
};
|
|
const wire = toolWireSchema(tool(param));
|
|
const x = (wire.properties as Record<string, unknown>).x as Record<string, unknown>;
|
|
expect(x.enum).toBeUndefined();
|
|
expect(x.anyOf).toEqual([
|
|
{ const: "a", description: "first" },
|
|
{ const: "b", description: "second" },
|
|
]);
|
|
});
|
|
|
|
it("keeps the anyOf when a shared branch description disagrees with the union root's description", () => {
|
|
const param = {
|
|
type: "object",
|
|
properties: {
|
|
x: {
|
|
description: "parent",
|
|
anyOf: [
|
|
{ const: "a", description: "branch" },
|
|
{ const: "b", description: "branch" },
|
|
],
|
|
},
|
|
},
|
|
required: ["x"],
|
|
additionalProperties: false,
|
|
};
|
|
const wire = toolWireSchema(tool(param));
|
|
const x = (wire.properties as Record<string, unknown>).x as Record<string, unknown>;
|
|
expect(x.enum).toBeUndefined();
|
|
expect(x.description).toBe("parent");
|
|
expect(Array.isArray(x.anyOf)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("stripSchemaDescriptions", () => {
|
|
it("removes annotations through nested schema keywords while preserving structure", () => {
|
|
const schema = {
|
|
type: "object",
|
|
description: "object annotation",
|
|
properties: {
|
|
path: { type: "string", description: "the path" },
|
|
choice: {
|
|
anyOf: [
|
|
{ type: "string", description: "string variant" },
|
|
{ type: "number", description: "number variant" },
|
|
],
|
|
},
|
|
},
|
|
dependentSchemas: {
|
|
path: { type: "object", description: "dependent annotation" },
|
|
},
|
|
required: ["path"],
|
|
};
|
|
const stripped = stripSchemaDescriptions(schema);
|
|
expect(JSON.stringify(stripped)).not.toContain("annotation");
|
|
expect(JSON.stringify(stripped)).not.toContain("variant");
|
|
expect(JSON.stringify(stripped)).not.toContain("the path");
|
|
// Structure survives: types, property names, required, union arity.
|
|
const props = stripped.properties as Record<string, { type?: string; anyOf?: unknown[] }>;
|
|
expect(props.path.type).toBe("string");
|
|
expect(props.choice.anyOf).toHaveLength(2);
|
|
expect(stripped.required).toEqual(["path"]);
|
|
});
|
|
|
|
it("keeps a property literally named `description` (only its own annotation is dropped)", () => {
|
|
const schema = {
|
|
type: "object",
|
|
properties: {
|
|
description: { type: "string", description: "a field that is named description" },
|
|
},
|
|
};
|
|
const stripped = stripSchemaDescriptions(schema);
|
|
const prop = (stripped.properties as Record<string, { type: string; description?: string }>).description;
|
|
expect(prop).toBeDefined();
|
|
expect(prop.type).toBe("string");
|
|
expect(prop.description).toBeUndefined();
|
|
});
|
|
|
|
it("never descends into data-bearing keywords (default/const/examples)", () => {
|
|
const schema = {
|
|
type: "object",
|
|
properties: {
|
|
mode: {
|
|
type: "string",
|
|
description: "the mode",
|
|
default: { description: "data, keep me" },
|
|
examples: [{ description: "example data" }],
|
|
},
|
|
},
|
|
};
|
|
const stripped = stripSchemaDescriptions(schema);
|
|
const mode = (stripped.properties as Record<string, Record<string, unknown>>).mode;
|
|
expect(mode.description).toBeUndefined();
|
|
expect(mode.default).toEqual({ description: "data, keep me" });
|
|
expect(mode.examples).toEqual([{ description: "example data" }]);
|
|
});
|
|
|
|
it("does not mutate the input schema", () => {
|
|
const schema = {
|
|
type: "object",
|
|
description: "keep",
|
|
properties: { a: { type: "string", description: "keep a" } },
|
|
};
|
|
stripSchemaDescriptions(schema);
|
|
expect(schema.description).toBe("keep");
|
|
expect(schema.properties.a.description).toBe("keep a");
|
|
});
|
|
|
|
it("memoizes the result on the input via a hidden stamp", () => {
|
|
const schema = { type: "object", properties: { a: { type: "string", description: "x" } } };
|
|
const first = stripSchemaDescriptions(schema);
|
|
const second = stripSchemaDescriptions(schema);
|
|
expect(second).toBe(first);
|
|
});
|
|
});
|
|
|
|
describe("stripToolDescriptions", () => {
|
|
const tool: Tool = {
|
|
name: "demo",
|
|
description: "top-level tool description",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "where to read" },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
};
|
|
|
|
it("empties the top-level description and strips nested schema descriptions", () => {
|
|
const [stripped] = stripToolDescriptions([tool]);
|
|
expect(stripped.description).toBe("");
|
|
expect(JSON.stringify(stripped.parameters)).not.toContain("where to read");
|
|
expect((stripped.parameters as { properties: Record<string, { type: string }> }).properties.path.type).toBe(
|
|
"string",
|
|
);
|
|
});
|
|
|
|
it("leaves the original tool and the cached wire schema intact", () => {
|
|
stripToolDescriptions([tool]);
|
|
expect(tool.description).toBe("top-level tool description");
|
|
expect(JSON.stringify(toolWireSchema(tool))).toContain("where to read");
|
|
});
|
|
});
|