From 5e592189f1f4fd5104d5aba9cec2472b80befac3 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:32:38 +0100 Subject: [PATCH] test(ai): BigInt and Undefined tool arguments convert as TypeBox converted them pi-ai's validation probes pinned Type.BigInt() and Type.Undefined() parameters rejecting every JSON argument, a documented limit of the switch to pi-schema (TypeBox converted to them). pi-schema now validates and converts both types, so "42" and 42 become 42n and null becomes undefined, as with TypeBox. The probes expect that, and pi-ai's CHANGELOG says so; Type.Void() still rejects every JSON argument. Also notes in pi-schema's code where it differs from TypeBox on purpose: validate() is not Value.Parse, multipleOf answers false where TypeBox throws, recursive() writes other JSON than Type.Cyclic, and Static types optional and rest tuple elements as plain ones. --- packages/ai/CHANGELOG.md | 1 + packages/ai/test/validation-probes.test.ts | 22 ++++++---------------- packages/schema/src/builders.ts | 4 ++++ packages/schema/src/checks.ts | 3 +++ packages/schema/src/typebox-static.ts | 4 +++- packages/schema/src/validator.ts | 9 ++++++++- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 369cb68f5..dc2654166 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Fixed tool arguments for `Type.BigInt()` and `Type.Undefined()` parameters being rejected: they convert as TypeBox converted them (`"42"` and `42` to `42n`, `null` to `undefined`), now that pi-schema supports both types. `Type.Void()` still rejects every JSON argument. - Fixed OpenCode Go Claude Haiku 5.5 failing with a 400 (`Invalid signature in thinking block ... bound to a different conversation`) after the tool list, system prompt, or earlier history changed, which left the session failing on every retry. OpenCode Go and Zen Claude 5.5 models, Opus 5, and Fable 5.1 now use per-message effort and send `thinking.block_binding: drop_block`, like the Anthropic provider ([#10954](https://github.com/earendil-works/pi/issues/10954)) - Fixed Anthropic non-strict tool schemas dropping root `$defs` and `definitions`, which left property `$ref`s pointing at nothing. Claude then produced wrong arguments for such tools (common in MCP servers), for example a JSON string instead of an object, and some Anthropic-compatible gateways rejected the request outright ([#9557](https://github.com/earendil-works/pi/issues/9557)) - Fixed OpenRouter rejecting requests with a 400 (`This endpoint's maximum context length is ...`) for models whose output limit is close to the context window, such as DeepSeek flash. OpenRouter counts the input with its own tokenizer, which can be several percent higher than the provider's count, and pi left only 4,096 tokens spare. pi then treated the 400 as a context overflow and compacted the session, even at around 20% of the window. The default output limit now leaves 15% of the estimated input unused, and near a full window at most 32,768 output tokens are requested ([#10497](https://github.com/earendil-works/pi/issues/10497)) diff --git a/packages/ai/test/validation-probes.test.ts b/packages/ai/test/validation-probes.test.ts index 0ddfe8d12..1a3edd0ba 100644 --- a/packages/ai/test/validation-probes.test.ts +++ b/packages/ai/test/validation-probes.test.ts @@ -1,7 +1,7 @@ // The reviewers' probes of the schema switch (round 1) as regression tests: TypeBox-built and plain JSON // (MCP-shaped) tool schemas with arguments through validateToolArguments. Each case's outcome is "ok ", "REJECT " or "THROW ". Cases marked as TypeBox-only record the documented -// differences from TypeBox (see the CHANGELOG): Refine and BigInt. +// differences from TypeBox (see the CHANGELOG): Refine. BigInt and Undefined convert as TypeBox converted them. // biome-ignore-all lint/suspicious/noTemplateCurlyInString: TypeBox template literal syntax. // biome-ignore-all lint/suspicious/noThenProperty: JSON Schema's `then` keyword. @@ -465,8 +465,8 @@ const expected: Record = { 'codec {"c":12}': 'ok {"c":"12"}', "undefined-optional {}": "ok {}", 'undefined-optional {"u":null}': "ok {}", - 'bigint {"n":1}': "REJECT - n: expected bigint, got integer", - 'bigint {"n":"12"}': "REJECT - n: expected bigint, got string", + 'bigint {"n":1}': 'ok {"n":"1n"}', + 'bigint {"n":"12"}': 'ok {"n":"12n"}', 'refine {"n":5}': 'ok {"n":5}', 'refine {"n":-5}': 'ok {"n":-5}', 'template-literal {"t":"id-12"}': 'ok {"t":"id-12"}', @@ -631,19 +631,9 @@ describe("reviewer probes (Sol)", () => { $defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } }, }; const solCases: Array<[string, object, unknown, string]> = [ - // TypeBox-only: Type.BigInt and Type.Undefined have no JSON form; TypeBox converted to them. - [ - "BigInt from a JSON string", - Type.Object({ n: Type.BigInt() }), - { n: "42" }, - "REJECT - n: expected bigint, got string", - ], - [ - "Undefined from JSON null", - Type.Object({ n: Type.Undefined() }), - { n: null }, - "REJECT - n: expected undefined, got null", - ], + // Type.BigInt and Type.Undefined have no JSON form: arguments convert to them, as TypeBox converted them. + ["BigInt from a JSON string", Type.Object({ n: Type.BigInt() }), { n: "42" }, 'ok {"n":"42n"}'], + ["Undefined from JSON null", Type.Object({ n: Type.Undefined() }), { n: null }, "ok {}"], // TypeBox-only: its additionalProperties also converted declared properties; pi-schema converts only undeclared ones. [ "additionalProperties on declared properties", diff --git a/packages/schema/src/builders.ts b/packages/schema/src/builders.ts index dda3a52ad..9705ead9c 100644 --- a/packages/schema/src/builders.ts +++ b/packages/schema/src/builders.ts @@ -169,6 +169,10 @@ export function unsafe(schema: Schema = {}): Schema { * returns. TypeScript cannot infer a recursive type from the builders, so `T` is given: * `recursive("Tree", (tree) => obj({ name: str(), children: arr(tree) }))`. `id` becomes the schema's `$id` * and the target of the reference, so it must be unique among the recursive schemas within one schema. + * + * The counterpart of TypeBox's `Type.Cyclic` + `Type.Ref`, with other JSON: an `$id` on the schema instead of `$defs` + * and a root `$ref`. It validates the same values. The JSON differs only where a schema is published, and its type is + * written once instead of inferred, which a recursive schema (a file tree) needs only once. */ export function recursive(id: string, build: (self: Schema) => Schema): Schema { return { ...build({ $ref: id }), $id: id }; diff --git a/packages/schema/src/checks.ts b/packages/schema/src/checks.ts index 4b48ed6d6..d119e1fb0 100644 --- a/packages/schema/src/checks.ts +++ b/packages/schema/src/checks.ts @@ -305,6 +305,9 @@ export function numberCheck( /** * Whether `v` is a multiple of `d`, exactly for decimals: 0.0075 / 0.0001 is 74.99999999999999 in floating point. With * a bigint on either side, exact integer arithmetic; a bigint is no multiple of 0 or of a fraction. + * + * Deliberately unlike TypeBox, which throws there (`BigInt(0.5)`, division by zero): a check answers for every value + * and never throws, and no real schema pairs a bigint with a fractional or zero `multipleOf`. */ export function isMultiple(v: Bound, d: Bound): boolean { if (typeof v === "bigint" || typeof d === "bigint") { diff --git a/packages/schema/src/typebox-static.ts b/packages/schema/src/typebox-static.ts index 4028c5fa2..4567559ab 100644 --- a/packages/schema/src/typebox-static.ts +++ b/packages/schema/src/typebox-static.ts @@ -1,6 +1,8 @@ // The static types of TypeBox-built schemas, read from the type shapes TypeBox 1.x declares (`'~kind'`, `'~optional'`, // `'~readonly'`, `'~unsafe'`) without importing TypeBox, so `Static` also types tool arguments of TypeBox schemas. -// Only the common kinds are mapped; every other kind (Intersect, Cyclic, Ref, ...) is `unknown`. +// Only the common kinds are mapped; every other kind (Intersect, Cyclic, Ref, ...) is `unknown`. Tuples are plain +// tuples: an element marked `Type.Optional`, `Type.Rest` or `Type.Readonly` is typed by its schema alone (`[string]` +// where TypeBox gives `[string?]`), unlike TypeBox; tuples of tool and API schemas are fixed pairs, typed exactly. import type { Static } from "./builders.ts"; diff --git a/packages/schema/src/validator.ts b/packages/schema/src/validator.ts index 451931887..41f25849e 100644 --- a/packages/schema/src/validator.ts +++ b/packages/schema/src/validator.ts @@ -53,7 +53,14 @@ export class Validator { return this.explainer.errors(value); } - /** `value` itself when it is valid, else its errors: check() and errors() in one result, without coercion. */ + /** + * `value` itself when it is valid, else its errors: check() and errors() in one result, without coercion. + * + * Not TypeBox's `Value.Parse`, which also clones, fills defaults, converts and strips undeclared properties before + * it checks. This only checks: the strict replacement where `Value.Parse` is used to validate (its other steps + * change nothing for a valid value of a schema without defaults or extra properties). Use `parse()` with + * `coerce: true` for conversion. + */ validate(value: unknown): ParseResult { return this.check(value) ? { ok: true, value } : { ok: false, errors: this.errors(value) }; }