diff --git a/dist/server.js b/dist/server.js index 2123b6a..4423a37 100644 --- a/dist/server.js +++ b/dist/server.js @@ -3004,6 +3004,13 @@ var planToolArgs = { revisit_evidence: z2.string().trim().min(1).max(2000).optional() }; var PlanToolSchema = z2.object(planToolArgs).strict(); +var planToolInputSchema = z2.toJSONSchema(PlanToolSchema, { + io: "input", + unrepresentable: "any" +}); +function planToolInput() { + return JSON.parse(JSON.stringify(planToolInputSchema)); +} async function planFromTool(args, context) { const input = PlanToolSchema.parse(args); return JSON.stringify({ @@ -4948,7 +4955,7 @@ function goalToolsV2(services) { { name: "update_goal_plan", description: services.locale === "zh-CN" ? "\u4FDD\u5B58\u76EE\u6807\u7684\u6574\u4F53\u8BA1\u5212\u3001\u9636\u6BB5\u3001\u4EFB\u52A1\u548C\u9A8C\u8BC1\u8BC1\u636E\u3002\u4FDD\u6301\u6574\u4F53\u76EE\u6807\u4E0D\u53D8\uFF1B\u4F7F\u7528 get_goal \u8FD4\u56DE\u7684\u76EE\u6807 ID \u548C\u8BA1\u5212\u7248\u672C\u3002" : "Persist the overall plan, phases, tasks, verification evidence and decisions. Preserve the goal scope; use the goal ID and planRevision from get_goal. Completed work cannot be silently reopened or removed.", - input: v2ObjectSchema(planToolArgs), + input: planToolInput(), options: { codemode: false }, execute: async (args, context) => ({ content: await planFromTool(args, context) }) }, diff --git a/src/server.ts b/src/server.ts index c0ac50e..2c53808 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1059,6 +1059,40 @@ const planToolArgs = { } const PlanToolSchema = z.object(planToolArgs).strict() +/** + * JSON Schema for the V2 update_goal_plan tool input. + * + * Passing the raw zod objects from planToolArgs as the V2 tool `input` leaks + * zod internals into the schema hosts send to providers: serialized artifacts + * like `def`, `checks`, `shape`, `optional`, and null-valued keywords such as + * `maxLength: null` or `format: null`. Providers that strictly validate tool + * schemas (for example Mistral mistral-large-4) reject those requests with + * `Invalid tool schema`, breaking every session that includes the goal tools. + * + * The V2 plugin API accepts JSON Schema rather than the zod objects used by + * the V1 tool API. `io: "input"` keeps the schema provider-facing: optional + * `revisit_evidence` stays out of `required`, and `decisions` keeps its + * `default` instead of being pinned as required output. Convert once here; + * runtime argument validation is unaffected because planFromTool still parses + * through PlanToolSchema. + */ +const planToolInputSchema = z.toJSONSchema(PlanToolSchema, { + io: "input", + unrepresentable: "any", +}) as ToolSchema.ValueSchema + +/** + * Deep copy of {@link planToolInputSchema} for each tool registration. + * + * Registrations share one module instance, so handing out the schema object + * itself would let any host (or test framework) that mutates a received tool + * input corrupt the schema for every other registration. The schema is pure + * JSON, so a JSON round-trip is a sufficient deep copy. + */ +function planToolInput(): ToolSchema.ValueSchema { + return JSON.parse(JSON.stringify(planToolInputSchema)) as ToolSchema.ValueSchema +} + async function planFromTool(args: unknown, context: ToolExecContext) { const input = PlanToolSchema.parse(args) return JSON.stringify( @@ -3356,7 +3390,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { { name: "update_goal_plan", description: services.locale === "zh-CN" ? "保存目标的整体计划、阶段、任务和验证证据。保持整体目标不变;使用 get_goal 返回的目标 ID 和计划版本。" : "Persist the overall plan, phases, tasks, verification evidence and decisions. Preserve the goal scope; use the goal ID and planRevision from get_goal. Completed work cannot be silently reopened or removed.", - input: v2ObjectSchema(planToolArgs), + input: planToolInput(), options: { codemode: false }, execute: async (args, context) => ({ content: await planFromTool(args, context) }), }, diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index b71f018..cf25eee 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -671,6 +671,112 @@ test("V2 plan tools publish structured ACP metadata and compaction retains the p expect(JSON.stringify(compaction)).toContain("compound") }) +test("V2 update_goal_plan input is provider-safe JSON Schema", async () => { + const mock = makeMockContext() + await setupPlugin(mock as never) + const input = goalTool(mock, "update_goal_plan").input as unknown + + const zodArtifactKeys = ["def", "checks", "shape", "optional", "catchall", "isFinite", "minValue", "maxValue", "isInt"] + const walk = (node: unknown, path: string, visit: (key: string, value: unknown, path: string) => void) => { + if (Array.isArray(node)) { + node.forEach((child, index) => walk(child, `${path}[${index}]`, visit)) + return + } + if (node === null || typeof node !== "object") return + for (const [key, value] of Object.entries(node as Record)) { + visit(key, value, `${path}.${key}`) + walk(value, `${path}.${key}`, visit) + } + } + + const invalid: string[] = [] + walk(input, "input", (key, value, path) => { + if (zodArtifactKeys.includes(key)) invalid.push(`zod artifact key "${key}" at ${path}`) + if (value === null) invalid.push(`null value for "${key}" at ${path}`) + }) + expect(invalid).toEqual([]) + + const root = input as { type?: string; properties?: Record; required?: string[]; additionalProperties?: boolean } + expect(root.type).toBe("object") + expect(root.additionalProperties).toBe(false) + expect(Object.keys(root.properties ?? {}).sort()).toEqual([ + "expected_revision", + "goal_id", + "plan", + "reason", + "revisit_evidence", + ]) + expect(root.required?.sort()).toEqual(["expected_revision", "goal_id", "plan", "reason"]) +}) + +test("V2 update_goal_plan registrations do not share a mutable input schema", async () => { + const first = makeMockContext({ auto_continue: false }) + await setupPlugin(first as never) + const second = makeMockContext({ auto_continue: false }) + await setupPlugin(second as never) + + // Simulate a host or test framework that mutates a received tool input. + const corrupted = (goalTool(first, "update_goal_plan").input as { properties?: Record }) + .properties + if (corrupted) for (const key of Object.keys(corrupted)) delete corrupted[key] + + const survivor = goalTool(second, "update_goal_plan").input as { properties?: Record } + expect(Object.keys(survivor.properties ?? {}).sort()).toEqual([ + "expected_revision", + "goal_id", + "plan", + "reason", + "revisit_evidence", + ]) +}) + +// Nested plan schema assertions ported from #68 (authored by @abeisleem, commits +// b88c9e2/e44a291) with thanks — they complement the wire-safety checks above by +// pinning the serialized nested plan structure. +test("V2 plan tool publishes JSON Schema with required fields and optional nested evidence", async () => { + const mock = makeMockContext({ auto_continue: false }) + await setupPlugin(mock as never) + // Inspect the serialized wire schema, not only the outer object wrapper. + const input = JSON.parse(JSON.stringify(goalTool(mock, "update_goal_plan").input)) + expect(input.required).toEqual(["goal_id", "expected_revision", "plan", "reason"]) + expect(input.additionalProperties).toBe(false) + expect(input.properties.goal_id).toMatchObject({ type: "string", minLength: 1 }) + expect(input.properties.expected_revision).toMatchObject({ type: "integer", minimum: 0 }) + expect(input.properties.reason).toMatchObject({ type: "string", minLength: 1, maxLength: 2000 }) + expect(input.properties.revisit_evidence).toMatchObject({ type: "string", minLength: 1, maxLength: 2000 }) + + const plan = input.properties.plan + expect(plan.required).toEqual(["summary", "completionCriteria", "phases"]) + expect(plan.additionalProperties).toBe(false) + expect(plan.properties.decisions).toMatchObject({ type: "array", default: [], maxItems: 32 }) + expect(plan.properties.completionCriteria).toMatchObject({ type: "array", minItems: 1, maxItems: 32 }) + const phases = plan.properties.phases + expect(phases).toMatchObject({ type: "array", minItems: 1, maxItems: 64 }) + const phase = phases.items + expect(phase.required).toEqual(["id", "objective", "status", "tasks"]) + expect(phase.additionalProperties).toBe(false) + const tasks = phase.properties.tasks + expect(tasks).toMatchObject({ type: "array", minItems: 1, maxItems: 128 }) + const task = tasks.items + expect(task.required).toEqual(["id", "description", "status"]) + expect(task.additionalProperties).toBe(false) + expect(task.properties.status).toEqual({ + type: "string", + enum: ["pending", "in_progress", "completed", "blocked"], + }) + for (const field of [ + phase.properties.verification, + phase.properties.blocker, + task.properties.evidence, + task.properties.blocker, + ]) { + expect(field.anyOf).toEqual([ + { type: "string", minLength: 1, maxLength: 2000 }, + { type: "null" }, + ]) + } +}) + test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transform", async () => { const mock = makeMockContext({ auto_continue: false }) const cleanup = await setupPlugin(mock as never)