d6abb56f58
Pre-migration safety net for the upcoming tool-by-tool zod\u2192Schema conversion. Every tool's parameters schema now has: 1. A JSON Schema snapshot (`z.toJSONSchema` with `io: "input"`) \u2014 this captures exactly what the LLM sees at tool registration time, so any drift caused by a future migration fails the snapshot. 2. Parse-accept/parse-reject assertions per tool pinning the user-visible behavioural contract (required fields, refinement bounds, enum membership, default values). To make the snapshots possible without standing up each tool's full Effect runtime, every tool file now exports its parameters schema as `Parameters` at module scope: - 9 tools already had a module-level const \u2014 just added `export`, and standardised the name to `Parameters` (uppercase) where it was previously `parameters`. - 9 tools had their schema inline inside `Tool.define` \u2014 hoisted to module scope under the same `Parameters` name and wired back through. Zero behaviour change: Tool.define still sees the same schema, runtime validation path is identical, SDK (types.gen.ts + openapi.json) is byte-identical, and the full 2054-test suite passes. 18 JSON Schema snapshots and 43 explicit parse/reject assertions for the 18 built-in tools (apply_patch, bash, codesearch, edit, glob, grep, invalid, lsp, multiedit, plan, question, read, skill, task, todo, webfetch, websearch, write).
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import z from "zod"
|
|
|
|
// Each tool exports its parameters schema at module scope so this test can
|
|
// import them without running the tool's Effect-based init. The JSON Schema
|
|
// snapshot captures what the LLM sees; the parse assertions pin down the
|
|
// accepts/rejects contract. Both must survive any future migration (e.g. from
|
|
// zod to Effect Schema via the effect-zod walker) byte-for-byte.
|
|
|
|
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
|
import { Parameters as Bash } from "../../src/tool/bash"
|
|
import { Parameters as CodeSearch } from "../../src/tool/codesearch"
|
|
import { Parameters as Edit } from "../../src/tool/edit"
|
|
import { Parameters as Glob } from "../../src/tool/glob"
|
|
import { Parameters as Grep } from "../../src/tool/grep"
|
|
import { Parameters as Invalid } from "../../src/tool/invalid"
|
|
import { Parameters as Lsp } from "../../src/tool/lsp"
|
|
import { Parameters as MultiEdit } from "../../src/tool/multiedit"
|
|
import { Parameters as Plan } from "../../src/tool/plan"
|
|
import { Parameters as Question } from "../../src/tool/question"
|
|
import { Parameters as Read } from "../../src/tool/read"
|
|
import { Parameters as Skill } from "../../src/tool/skill"
|
|
import { Parameters as Task } from "../../src/tool/task"
|
|
import { Parameters as Todo } from "../../src/tool/todo"
|
|
import { Parameters as WebFetch } from "../../src/tool/webfetch"
|
|
import { Parameters as WebSearch } from "../../src/tool/websearch"
|
|
import { Parameters as Write } from "../../src/tool/write"
|
|
|
|
// Helper: the JSON Schema the LLM sees at tool registration time
|
|
// (session/prompt.ts runs `z.toJSONSchema(tool.parameters)` with the AI SDK's
|
|
// default `io` mode). Snapshots pin the exact wire shape.
|
|
const toJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { io: "input" })
|
|
|
|
describe("tool parameters", () => {
|
|
describe("JSON Schema (wire shape)", () => {
|
|
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
|
|
test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
|
|
test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot())
|
|
test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
|
|
test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
|
|
test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
|
|
test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
|
|
test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
|
|
test("multiedit", () => expect(toJsonSchema(MultiEdit)).toMatchSnapshot())
|
|
test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
|
|
test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
|
|
test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
|
|
test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
|
|
test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
|
|
test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
|
|
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
|
|
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
|
|
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
|
|
})
|
|
|
|
describe("apply_patch", () => {
|
|
test("accepts patchText", () => {
|
|
expect(ApplyPatch.parse({ patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
|
|
patchText: "*** Begin Patch\n*** End Patch",
|
|
})
|
|
})
|
|
test("rejects missing patchText", () => {
|
|
expect(ApplyPatch.safeParse({}).success).toBe(false)
|
|
})
|
|
test("rejects non-string patchText", () => {
|
|
expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("bash", () => {
|
|
test("accepts minimum: command + description", () => {
|
|
expect(Bash.parse({ command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
|
})
|
|
test("accepts optional timeout + workdir", () => {
|
|
const parsed = Bash.parse({ command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
|
expect(parsed.timeout).toBe(5000)
|
|
expect(parsed.workdir).toBe("/tmp")
|
|
})
|
|
test("rejects missing description (required by zod)", () => {
|
|
expect(Bash.safeParse({ command: "ls" }).success).toBe(false)
|
|
})
|
|
test("rejects missing command", () => {
|
|
expect(Bash.safeParse({ description: "list" }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("codesearch", () => {
|
|
test("accepts query; tokensNum defaults to 5000", () => {
|
|
expect(CodeSearch.parse({ query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
|
|
})
|
|
test("accepts override tokensNum", () => {
|
|
expect(CodeSearch.parse({ query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
|
|
})
|
|
test("rejects tokensNum under 1000", () => {
|
|
expect(CodeSearch.safeParse({ query: "x", tokensNum: 500 }).success).toBe(false)
|
|
})
|
|
test("rejects tokensNum over 50000", () => {
|
|
expect(CodeSearch.safeParse({ query: "x", tokensNum: 60000 }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("edit", () => {
|
|
test("accepts all four fields", () => {
|
|
expect(Edit.parse({ filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
|
|
filePath: "/a",
|
|
oldString: "x",
|
|
newString: "y",
|
|
replaceAll: true,
|
|
})
|
|
})
|
|
test("replaceAll is optional", () => {
|
|
const parsed = Edit.parse({ filePath: "/a", oldString: "x", newString: "y" })
|
|
expect(parsed.replaceAll).toBeUndefined()
|
|
})
|
|
test("rejects missing filePath", () => {
|
|
expect(Edit.safeParse({ oldString: "x", newString: "y" }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("glob", () => {
|
|
test("accepts pattern-only", () => {
|
|
expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
|
|
})
|
|
test("accepts optional path", () => {
|
|
expect(Glob.parse({ pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
|
|
})
|
|
test("rejects missing pattern", () => {
|
|
expect(Glob.safeParse({}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("grep", () => {
|
|
test("accepts pattern-only", () => {
|
|
expect(Grep.parse({ pattern: "TODO" })).toEqual({ pattern: "TODO" })
|
|
})
|
|
test("accepts optional path + include", () => {
|
|
const parsed = Grep.parse({ pattern: "TODO", path: "/tmp", include: "*.ts" })
|
|
expect(parsed.path).toBe("/tmp")
|
|
expect(parsed.include).toBe("*.ts")
|
|
})
|
|
test("rejects missing pattern", () => {
|
|
expect(Grep.safeParse({}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("invalid", () => {
|
|
test("accepts tool + error", () => {
|
|
expect(Invalid.parse({ tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
|
|
})
|
|
test("rejects missing fields", () => {
|
|
expect(Invalid.safeParse({ tool: "foo" }).success).toBe(false)
|
|
expect(Invalid.safeParse({ error: "bar" }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("lsp", () => {
|
|
test("accepts all fields", () => {
|
|
const parsed = Lsp.parse({ operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
|
|
expect(parsed.operation).toBe("hover")
|
|
})
|
|
test("rejects line < 1", () => {
|
|
expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 0, character: 1 }).success).toBe(false)
|
|
})
|
|
test("rejects character < 1", () => {
|
|
expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 1, character: 0 }).success).toBe(false)
|
|
})
|
|
test("rejects unknown operation", () => {
|
|
expect(Lsp.safeParse({ operation: "bogus", filePath: "/a.ts", line: 1, character: 1 }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("multiedit", () => {
|
|
test("accepts empty edits array", () => {
|
|
expect(MultiEdit.parse({ filePath: "/a", edits: [] }).edits).toEqual([])
|
|
})
|
|
test("accepts an edit entry", () => {
|
|
const parsed = MultiEdit.parse({
|
|
filePath: "/a",
|
|
edits: [{ filePath: "/a", oldString: "x", newString: "y" }],
|
|
})
|
|
expect(parsed.edits.length).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe("plan", () => {
|
|
test("accepts empty object", () => {
|
|
expect(Plan.parse({})).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe("question", () => {
|
|
test("accepts questions array", () => {
|
|
const parsed = Question.parse({
|
|
questions: [
|
|
{
|
|
question: "pick one",
|
|
header: "Header",
|
|
custom: false,
|
|
options: [{ label: "a", description: "desc" }],
|
|
},
|
|
],
|
|
})
|
|
expect(parsed.questions.length).toBe(1)
|
|
})
|
|
test("rejects missing questions", () => {
|
|
expect(Question.safeParse({}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("read", () => {
|
|
test("accepts filePath-only", () => {
|
|
expect(Read.parse({ filePath: "/a" }).filePath).toBe("/a")
|
|
})
|
|
test("accepts optional offset + limit", () => {
|
|
const parsed = Read.parse({ filePath: "/a", offset: 10, limit: 100 })
|
|
expect(parsed.offset).toBe(10)
|
|
expect(parsed.limit).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe("skill", () => {
|
|
test("accepts name", () => {
|
|
expect(Skill.parse({ name: "foo" }).name).toBe("foo")
|
|
})
|
|
test("rejects missing name", () => {
|
|
expect(Skill.safeParse({}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("task", () => {
|
|
test("accepts description + prompt + subagent_type", () => {
|
|
const parsed = Task.parse({ description: "d", prompt: "p", subagent_type: "general" })
|
|
expect(parsed.subagent_type).toBe("general")
|
|
})
|
|
test("rejects missing prompt", () => {
|
|
expect(Task.safeParse({ description: "d", subagent_type: "general" }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("todo", () => {
|
|
test("accepts todos array", () => {
|
|
const parsed = Todo.parse({
|
|
todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
|
|
})
|
|
expect(parsed.todos.length).toBe(1)
|
|
})
|
|
test("rejects missing todos", () => {
|
|
expect(Todo.safeParse({}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("webfetch", () => {
|
|
test("accepts url-only", () => {
|
|
expect(WebFetch.parse({ url: "https://example.com" }).url).toBe("https://example.com")
|
|
})
|
|
})
|
|
|
|
describe("websearch", () => {
|
|
test("accepts query", () => {
|
|
expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode")
|
|
})
|
|
})
|
|
|
|
describe("write", () => {
|
|
test("accepts content + filePath", () => {
|
|
expect(Write.parse({ content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
|
|
})
|
|
test("rejects missing filePath", () => {
|
|
expect(Write.safeParse({ content: "hi" }).success).toBe(false)
|
|
})
|
|
})
|
|
})
|