From 0a314c84de89a18bde92e3927947f69b4fb00b83 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Fri, 11 Sep 2026 04:13:05 +0700 Subject: [PATCH] fix(translator): treat contentSchema and unevaluatedItems as schema slots (#13110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings. Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith. --- changelog.d/fixes/13110-schema-slot-keys.md | 1 + open-sse/translator/helpers/schemaCoercion.ts | 7 ++ .../translator/schema-slot-keys-drift.test.ts | 93 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 changelog.d/fixes/13110-schema-slot-keys.md create mode 100644 tests/unit/translator/schema-slot-keys-drift.test.ts diff --git a/changelog.d/fixes/13110-schema-slot-keys.md b/changelog.d/fixes/13110-schema-slot-keys.md new file mode 100644 index 0000000000..fb91255e94 --- /dev/null +++ b/changelog.d/fixes/13110-schema-slot-keys.md @@ -0,0 +1 @@ +- **fix(translator):** `contentSchema` and `unevaluatedItems` are now treated as subschema positions by the tool-schema sanitizer, so a truncation placeholder in either is replaced with a permissive schema instead of being forwarded as a string ([#13110](https://github.com/diegosouzapw/OmniRoute/pull/13110)) diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 32843703b9..08b2cbe7db 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -514,6 +514,13 @@ const SCHEMA_SLOT_KEYS = [ "else", "unevaluatedProperties", "additionalItems", + // draft 2020-12 applicators whose value is a schema too. Without them a + // placeholder in either position falls through to the scalar branch at the + // bottom of the walker and is forwarded as a string, which is the shape this + // sanitizer exists to remove. The opencode plugin's own walker + // (@omniroute/opencode-plugin-v2/src/shared/gemini.ts) lists both. + "contentSchema", + "unevaluatedItems", ]; function coerceIndexedObjectToArray(value: unknown): unknown[] | null { diff --git a/tests/unit/translator/schema-slot-keys-drift.test.ts b/tests/unit/translator/schema-slot-keys-drift.test.ts new file mode 100644 index 0000000000..8078b0cc4f --- /dev/null +++ b/tests/unit/translator/schema-slot-keys-drift.test.ts @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { stripInvalidSchemaConstructs } from "../../../open-sse/translator/helpers/schemaCoercion.ts"; + +// Every draft 2020-12 keyword whose value is a schema rather than an annotation. +// A placeholder in any of them has to become the permissive {}: forwarding the +// string is invalid JSON Schema and is the 400 this sanitizer exists to prevent. +const SCHEMA_SLOTS = [ + "items", + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedProperties", + "additionalItems", + "contentSchema", + "unevaluatedItems", +]; + +// Produced by logTruncation.ts once a schema is deeper than the log depth limit. +const PLACEHOLDERS = ["[MaxDepth]", "[Truncated]", "[Circular]", "[Object]", "[Array]"]; + +function strip(schema: unknown) { + return stripInvalidSchemaConstructs(schema) as Record; +} + +for (const key of SCHEMA_SLOTS) { + test(`a placeholder in ${key} becomes a permissive schema`, () => { + for (const placeholder of PLACEHOLDERS) { + const out = strip({ type: "object", [key]: placeholder }); + assert.deepEqual(out[key], {}, `${key} kept ${placeholder}`); + } + }); +} + +test("every slot is covered by the same rule, none left behind", () => { + // The point of the list above is that it is complete. If a slot is dropped + // from the walker, the loop above catches it; this catches the reverse -- a + // slot handled by the walker but missing from this list would make the loop + // silently smaller. + const surviving = SCHEMA_SLOTS.filter((key) => { + const out = strip({ [key]: "[MaxDepth]" }); + return typeof out[key] === "string"; + }); + assert.deepEqual(surviving, []); +}); + +test("a boolean schema is preserved, not widened", () => { + // `contentSchema: false` and `unevaluatedItems: false` are valid and + // restrictive; turning either into {} would invite the model to invent data. + for (const key of ["contentSchema", "unevaluatedItems"]) { + assert.equal(strip({ [key]: false })[key], false); + assert.equal(strip({ [key]: true })[key], true); + } +}); + +test("a nested subschema is still walked", () => { + const out = strip({ + contentSchema: { type: "object", properties: { a: { enum: "[MaxDepth]" } } }, + unevaluatedItems: { items: "[MaxDepth]" }, + }); + const content = out.contentSchema as Record>; + assert.deepEqual(content.properties.a, {}, "an invalid enum is dropped, leaving {}"); + assert.deepEqual(out.unevaluatedItems, { items: {} }); +}); + +test("a string that is not a placeholder is left alone", () => { + // Only the placeholder shape is coerced. Anything else stays exactly as it + // arrived, so a schema this sanitizer does not understand is forwarded rather + // than rewritten. + for (const key of ["contentSchema", "unevaluatedItems"]) { + assert.equal(strip({ [key]: "text/plain" })[key], "text/plain"); + } +}); + +test("a property named like a slot keyword is not treated as one", () => { + // Property names live in their own space: a tool whose parameter is called + // contentSchema must keep its description string. + const out = strip({ + type: "object", + properties: { contentSchema: "[MaxDepth]", unevaluatedItems: { type: "string" } }, + }); + const properties = out.properties as Record; + assert.deepEqual( + properties.contentSchema, + {}, + "a placeholder property value is still a schema slot" + ); + assert.deepEqual(properties.unevaluatedItems, { type: "string" }); +});