mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
fix(translator): treat contentSchema and unevaluatedItems as schema slots (#13110)
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.
This commit is contained in:
1
changelog.d/fixes/13110-schema-slot-keys.md
Normal file
1
changelog.d/fixes/13110-schema-slot-keys.md
Normal file
@@ -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))
|
||||
@@ -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 {
|
||||
|
||||
93
tests/unit/translator/schema-slot-keys-drift.test.ts
Normal file
93
tests/unit/translator/schema-slot-keys-drift.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
assert.deepEqual(
|
||||
properties.contentSchema,
|
||||
{},
|
||||
"a placeholder property value is still a schema slot"
|
||||
);
|
||||
assert.deepEqual(properties.unevaluatedItems, { type: "string" });
|
||||
});
|
||||
Reference in New Issue
Block a user