From 28d7e66d8adce7329e8f0dc346a12a349319ea5d Mon Sep 17 00:00:00 2001 From: lesetong Date: Mon, 10 Aug 2026 00:48:36 +0800 Subject: [PATCH] fix: hoist $defs to the schema root for set_tasks Refs like "#/$defs/AddTask" resolve from the document root, but Type.Cyclic nested the $defs inside tasks.items, so the moonshot-flavored gateway reported "$defs not found for reference". Hoist all $defs entries to the parameter schema root and strip the synthetic $id that would change fragment resolution. Update the regression test to pin the layout. Bump version to 0.2.3. --- package.json | 2 +- src/tools.ts | 61 ++++++++++++++++++++++++++++++++++++--------- tests/tools.test.ts | 16 +++++++----- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 140b0b9..900cee8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-todo-herdr", - "version": "0.2.2", + "version": "0.2.3", "description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.", "type": "module", "main": "./index.ts", diff --git a/src/tools.ts b/src/tools.ts index 5d0d5b1..3e83a78 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -38,6 +38,41 @@ function normalizeLocalRefs(schema: T): T { return schema; } +/** + * TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but + * refs like "#/$defs/AddTask" resolve from the document root. Strict gateways + * (moonshot-flavored) reject a root-style ref when the definition is nested. + * Hoist every $defs entry to the schema root and drop the synthetic $id, which + * would otherwise change fragment resolution inside the definition. + */ +function hoistDefsToRoot(schema: T): T { + const defs = new Map(); + function walk(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) walk(item); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + const nested = obj["$defs"]; + if (nested && typeof nested === "object") { + for (const [name, def] of Object.entries(nested)) { + if (!defs.has(name)) defs.set(name, def); + } + delete obj["$defs"]; + } + for (const value of Object.values(obj)) walk(value); + } + walk(schema); + for (const def of defs.values()) { + if (def && typeof def === "object") { + delete (def as Record)["$id"]; + } + } + if (defs.size > 0) (schema as Record)["$defs"] = Object.fromEntries(defs); + return schema; +} + const AddTaskSchema = Type.Cyclic( { AddTask: Type.Object( @@ -61,18 +96,20 @@ const AddTaskSchema = Type.Cyclic( "AddTask", ); -export const SetTaskParams = normalizeLocalRefs( - Type.Object( - { - mode: SetTaskModeSchema, - tasks: Type.Array(AddTaskSchema, { - description: "Tasks to append or install. May be empty only in replace mode.", - }), - force: Type.Optional( - Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }), - ), - }, - { additionalProperties: false }, +export const SetTaskParams = hoistDefsToRoot( + normalizeLocalRefs( + Type.Object( + { + mode: SetTaskModeSchema, + tasks: Type.Array(AddTaskSchema, { + description: "Tasks to append or install. May be empty only in replace mode.", + }), + force: Type.Optional( + Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }), + ), + }, + { additionalProperties: false }, + ), ), ); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 33d1d49..23fc61f 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -31,14 +31,18 @@ test("registers five focused tools without the old add_task alias", () => { assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]); }); -test("set_tasks schema uses #/$defs/ refs accepted by strict providers", () => { +test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => { const { tools } = registeredTools(); - const itemSchema = tools.get("set_tasks").parameters.properties.tasks.items; - // Every local $ref must be a URI reference; bare names ("AddTask") are - // rejected by moonshot-flavored schema validators (Console Go upstream). + const params = tools.get("set_tasks").parameters; + const itemSchema = params.properties.tasks.items; + // $defs must live at the document root: root-style refs resolve from there, + // and moonshot-flavored validators reject nested definitions (Console Go). + assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root"); + assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items"); assert.equal(itemSchema.$ref, "#/$defs/AddTask"); - assert.equal(itemSchema.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask"); - const serialized = JSON.stringify(tools.get("set_tasks").parameters); + assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask"); + assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped"); + const serialized = JSON.stringify(params); assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain"); });