Просмотр исходного кода

test(tool): pin every tool's parameters schema before migration

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).
Kit Langton 1 день назад
Родитель
Сommit
d6abb56f58

+ 4 - 4
packages/opencode/src/tool/apply_patch.ts

@@ -15,7 +15,7 @@ import DESCRIPTION from "./apply_patch.txt"
 import { File } from "../file"
 import { Format } from "../format"
 
-const PatchParams = z.object({
+export const Parameters = z.object({
   patchText: z.string().describe("The full patch text that describes all changes to be made"),
 })
 
@@ -27,7 +27,7 @@ export const ApplyPatchTool = Tool.define(
     const format = yield* Format.Service
     const bus = yield* Bus.Service
 
-    const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
+    const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
       if (!params.patchText) {
         return yield* Effect.fail(new Error("patchText is required"))
       }
@@ -287,8 +287,8 @@ export const ApplyPatchTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: PatchParams,
-      execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 1 - 1
packages/opencode/src/tool/bash.ts

@@ -50,7 +50,7 @@ const FILES = new Set([
 const FLAGS = new Set(["-destination", "-literalpath", "-path"])
 const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
 
-const Parameters = z.object({
+export const Parameters = z.object({
   command: z.string().describe("The command to execute"),
   timeout: z.number().describe("Optional timeout in milliseconds").optional(),
   workdir: z

+ 17 - 15
packages/opencode/src/tool/codesearch.ts

@@ -5,6 +5,22 @@ import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./codesearch.txt"
 
+export const Parameters = z.object({
+  query: z
+    .string()
+    .describe(
+      "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
+    ),
+  tokensNum: z
+    .number()
+    .min(1000)
+    .max(50000)
+    .default(5000)
+    .describe(
+      "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
+    ),
+})
+
 export const CodeSearchTool = Tool.define(
   "codesearch",
   Effect.gen(function* () {
@@ -12,21 +28,7 @@ export const CodeSearchTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        query: z
-          .string()
-          .describe(
-            "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
-          ),
-        tokensNum: z
-          .number()
-          .min(1000)
-          .max(50000)
-          .default(5000)
-          .describe(
-            "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
-          ),
-      }),
+      parameters: Parameters,
       execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
         Effect.gen(function* () {
           yield* ctx.ask({

+ 1 - 1
packages/opencode/src/tool/edit.ts

@@ -32,7 +32,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
   return text.replaceAll("\n", "\r\n")
 }
 
-const Parameters = z.object({
+export const Parameters = z.object({
   filePath: z.string().describe("The absolute path to the file to modify"),
   oldString: z.string().describe("The text to replace"),
   newString: z.string().describe("The text to replace it with (must be different from oldString)"),

+ 11 - 9
packages/opencode/src/tool/glob.ts

@@ -9,6 +9,16 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import * as Tool from "./tool"
 
+export const Parameters = z.object({
+  pattern: z.string().describe("The glob pattern to match files against"),
+  path: z
+    .string()
+    .optional()
+    .describe(
+      `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
+    ),
+})
+
 export const GlobTool = Tool.define(
   "glob",
   Effect.gen(function* () {
@@ -17,15 +27,7 @@ export const GlobTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        pattern: z.string().describe("The glob pattern to match files against"),
-        path: z
-          .string()
-          .optional()
-          .describe(
-            `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
-          ),
-      }),
+      parameters: Parameters,
       execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
         Effect.gen(function* () {
           const ins = yield* InstanceState.context

+ 7 - 5
packages/opencode/src/tool/grep.ts

@@ -10,6 +10,12 @@ import * as Tool from "./tool"
 
 const MAX_LINE_LENGTH = 2000
 
+export const Parameters = z.object({
+  pattern: z.string().describe("The regex pattern to search for in file contents"),
+  path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
+  include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
+})
+
 export const GrepTool = Tool.define(
   "grep",
   Effect.gen(function* () {
@@ -18,11 +24,7 @@ export const GrepTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        pattern: z.string().describe("The regex pattern to search for in file contents"),
-        path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
-        include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
-      }),
+      parameters: Parameters,
       execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
         Effect.gen(function* () {
           const empty = {

+ 6 - 4
packages/opencode/src/tool/invalid.ts

@@ -2,14 +2,16 @@ import z from "zod"
 import { Effect } from "effect"
 import * as Tool from "./tool"
 
+export const Parameters = z.object({
+  tool: z.string(),
+  error: z.string(),
+})
+
 export const InvalidTool = Tool.define(
   "invalid",
   Effect.succeed({
     description: "Do not use",
-    parameters: z.object({
-      tool: z.string(),
-      error: z.string(),
-    }),
+    parameters: Parameters,
     execute: (params: { tool: string; error: string }) =>
       Effect.succeed({
         title: "Invalid Tool",

+ 8 - 6
packages/opencode/src/tool/lsp.ts

@@ -21,6 +21,13 @@ const operations = [
   "outgoingCalls",
 ] as const
 
+export const Parameters = z.object({
+  operation: z.enum(operations).describe("The LSP operation to perform"),
+  filePath: z.string().describe("The absolute or relative path to the file"),
+  line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
+  character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
+})
+
 export const LspTool = Tool.define(
   "lsp",
   Effect.gen(function* () {
@@ -29,12 +36,7 @@ export const LspTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        operation: z.enum(operations).describe("The LSP operation to perform"),
-        filePath: z.string().describe("The absolute or relative path to the file"),
-        line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
-        character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
-      }),
+      parameters: Parameters,
       execute: (
         args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
         ctx: Tool.Context,

+ 15 - 13
packages/opencode/src/tool/multiedit.ts

@@ -6,6 +6,20 @@ import DESCRIPTION from "./multiedit.txt"
 import path from "path"
 import { Instance } from "../project/instance"
 
+export const Parameters = z.object({
+  filePath: z.string().describe("The absolute path to the file to modify"),
+  edits: z
+    .array(
+      z.object({
+        filePath: z.string().describe("The absolute path to the file to modify"),
+        oldString: z.string().describe("The text to replace"),
+        newString: z.string().describe("The text to replace it with (must be different from oldString)"),
+        replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
+      }),
+    )
+    .describe("Array of edit operations to perform sequentially on the file"),
+})
+
 export const MultiEditTool = Tool.define(
   "multiedit",
   Effect.gen(function* () {
@@ -14,19 +28,7 @@ export const MultiEditTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        filePath: z.string().describe("The absolute path to the file to modify"),
-        edits: z
-          .array(
-            z.object({
-              filePath: z.string().describe("The absolute path to the file to modify"),
-              oldString: z.string().describe("The text to replace"),
-              newString: z.string().describe("The text to replace it with (must be different from oldString)"),
-              replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
-            }),
-          )
-          .describe("Array of edit operations to perform sequentially on the file"),
-      }),
+      parameters: Parameters,
       execute: (
         params: {
           filePath: string

+ 3 - 1
packages/opencode/src/tool/plan.ts

@@ -17,6 +17,8 @@ function getLastModel(sessionID: SessionID) {
   return undefined
 }
 
+export const Parameters = z.object({})
+
 export const PlanExitTool = Tool.define(
   "plan_exit",
   Effect.gen(function* () {
@@ -26,7 +28,7 @@ export const PlanExitTool = Tool.define(
 
     return {
       description: EXIT_DESCRIPTION,
-      parameters: z.object({}),
+      parameters: Parameters,
       execute: (_params: {}, ctx: Tool.Context) =>
         Effect.gen(function* () {
           const info = yield* session.get(ctx.sessionID)

+ 4 - 4
packages/opencode/src/tool/question.ts

@@ -4,7 +4,7 @@ import * as Tool from "./tool"
 import { Question } from "../question"
 import DESCRIPTION from "./question.txt"
 
-const parameters = z.object({
+export const Parameters = z.object({
   questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
 })
 
@@ -12,15 +12,15 @@ type Metadata = {
   answers: ReadonlyArray<Question.Answer>
 }
 
-export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
+export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Service>(
   "question",
   Effect.gen(function* () {
     const question = yield* Question.Service
 
     return {
       description: DESCRIPTION,
-      parameters,
-      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
         Effect.gen(function* () {
           const answers = yield* question.ask({
             sessionID: ctx.sessionID,

+ 4 - 4
packages/opencode/src/tool/read.ts

@@ -18,7 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
 const MAX_BYTES = 50 * 1024
 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
 
-const parameters = z.object({
+export const Parameters = z.object({
   filePath: z.string().describe("The absolute path to the file or directory to read"),
   offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
   limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
@@ -77,7 +77,7 @@ export const ReadTool = Tool.define(
       yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
     })
 
-    const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
+    const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
       if (params.offset !== undefined && params.offset < 1) {
         return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
       }
@@ -212,8 +212,8 @@ export const ReadTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters,
-      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 1 - 1
packages/opencode/src/tool/skill.ts

@@ -8,7 +8,7 @@ import { Ripgrep } from "../file/ripgrep"
 import { Skill } from "../skill"
 import * as Tool from "./tool"
 
-const Parameters = z.object({
+export const Parameters = z.object({
   name: z.string().describe("The name of the skill from available_skills"),
 })
 

+ 4 - 4
packages/opencode/src/tool/task.ts

@@ -17,7 +17,7 @@ export interface TaskPromptOps {
 
 const id = "task"
 
-const parameters = z.object({
+export const Parameters = z.object({
   description: z.string().describe("A short (3-5 words) description of the task"),
   prompt: z.string().describe("The task for the agent to perform"),
   subagent_type: z.string().describe("The type of specialized agent to use for this task"),
@@ -37,7 +37,7 @@ export const TaskTool = Tool.define(
     const config = yield* Config.Service
     const sessions = yield* Session.Service
 
-    const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
+    const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
       const cfg = yield* config.get()
 
       if (!ctx.extra?.bypassAgentCheck) {
@@ -168,8 +168,8 @@ export const TaskTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters,
-      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 5 - 5
packages/opencode/src/tool/todo.ts

@@ -4,7 +4,7 @@ import * as Tool from "./tool"
 import DESCRIPTION_WRITE from "./todowrite.txt"
 import { Todo } from "../session/todo"
 
-const parameters = z.object({
+export const Parameters = z.object({
   todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
 })
 
@@ -12,15 +12,15 @@ type Metadata = {
   todos: Todo.Info[]
 }
 
-export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
+export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
   "todowrite",
   Effect.gen(function* () {
     const todo = yield* Todo.Service
 
     return {
       description: DESCRIPTION_WRITE,
-      parameters,
-      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
         Effect.gen(function* () {
           yield* ctx.ask({
             permission: "todowrite",
@@ -42,6 +42,6 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
             },
           }
         }),
-    } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
+    } satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
   }),
 )

+ 3 - 3
packages/opencode/src/tool/webfetch.ts

@@ -9,7 +9,7 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
 const MAX_TIMEOUT = 120 * 1000 // 2 minutes
 
-const parameters = z.object({
+export const Parameters = z.object({
   url: z.string().describe("The URL to fetch content from"),
   format: z
     .enum(["text", "markdown", "html"])
@@ -26,8 +26,8 @@ export const WebFetchTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters,
-      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
+      parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
           if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
             throw new Error("URL must start with http:// or https://")

+ 1 - 1
packages/opencode/src/tool/websearch.ts

@@ -5,7 +5,7 @@ import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./websearch.txt"
 
-const Parameters = z.object({
+export const Parameters = z.object({
   query: z.string().describe("Websearch query"),
   numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
   livecrawl: z

+ 6 - 4
packages/opencode/src/tool/write.ts

@@ -16,6 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 
 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
 
+export const Parameters = z.object({
+  content: z.string().describe("The content to write to the file"),
+  filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
+})
+
 export const WriteTool = Tool.define(
   "write",
   Effect.gen(function* () {
@@ -26,10 +31,7 @@ export const WriteTool = Tool.define(
 
     return {
       description: DESCRIPTION,
-      parameters: z.object({
-        content: z.string().describe("The content to write to the file"),
-        filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
-      }),
+      parameters: Parameters,
       execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
         Effect.gen(function* () {
           const filepath = path.isAbsolute(params.filePath)

+ 541 - 0
packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap

@@ -0,0 +1,541 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "patchText": {
+      "description": "The full patch text that describes all changes to be made",
+      "type": "string",
+    },
+  },
+  "required": [
+    "patchText",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "command": {
+      "description": "The command to execute",
+      "type": "string",
+    },
+    "description": {
+      "description": 
+"Clear, concise description of what this command does in 5-10 words. Examples:
+Input: ls
+Output: Lists files in current directory
+
+Input: git status
+Output: Shows working tree status
+
+Input: npm install
+Output: Installs package dependencies
+
+Input: mkdir foo
+Output: Creates directory 'foo'"
+,
+      "type": "string",
+    },
+    "timeout": {
+      "description": "Optional timeout in milliseconds",
+      "type": "number",
+    },
+    "workdir": {
+      "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
+      "type": "string",
+    },
+  },
+  "required": [
+    "command",
+    "description",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "query": {
+      "description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
+      "type": "string",
+    },
+    "tokensNum": {
+      "default": 5000,
+      "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
+      "maximum": 50000,
+      "minimum": 1000,
+      "type": "number",
+    },
+  },
+  "required": [
+    "query",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) edit 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "filePath": {
+      "description": "The absolute path to the file to modify",
+      "type": "string",
+    },
+    "newString": {
+      "description": "The text to replace it with (must be different from oldString)",
+      "type": "string",
+    },
+    "oldString": {
+      "description": "The text to replace",
+      "type": "string",
+    },
+    "replaceAll": {
+      "description": "Replace all occurrences of oldString (default false)",
+      "type": "boolean",
+    },
+  },
+  "required": [
+    "filePath",
+    "oldString",
+    "newString",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) glob 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "path": {
+      "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
+      "type": "string",
+    },
+    "pattern": {
+      "description": "The glob pattern to match files against",
+      "type": "string",
+    },
+  },
+  "required": [
+    "pattern",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "include": {
+      "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
+      "type": "string",
+    },
+    "path": {
+      "description": "The directory to search in. Defaults to the current working directory.",
+      "type": "string",
+    },
+    "pattern": {
+      "description": "The regex pattern to search for in file contents",
+      "type": "string",
+    },
+  },
+  "required": [
+    "pattern",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) invalid 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "error": {
+      "type": "string",
+    },
+    "tool": {
+      "type": "string",
+    },
+  },
+  "required": [
+    "tool",
+    "error",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "character": {
+      "description": "The character offset (1-based, as shown in editors)",
+      "maximum": 9007199254740991,
+      "minimum": 1,
+      "type": "integer",
+    },
+    "filePath": {
+      "description": "The absolute or relative path to the file",
+      "type": "string",
+    },
+    "line": {
+      "description": "The line number (1-based, as shown in editors)",
+      "maximum": 9007199254740991,
+      "minimum": 1,
+      "type": "integer",
+    },
+    "operation": {
+      "description": "The LSP operation to perform",
+      "enum": [
+        "goToDefinition",
+        "findReferences",
+        "hover",
+        "documentSymbol",
+        "workspaceSymbol",
+        "goToImplementation",
+        "prepareCallHierarchy",
+        "incomingCalls",
+        "outgoingCalls",
+      ],
+      "type": "string",
+    },
+  },
+  "required": [
+    "operation",
+    "filePath",
+    "line",
+    "character",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) multiedit 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "edits": {
+      "description": "Array of edit operations to perform sequentially on the file",
+      "items": {
+        "properties": {
+          "filePath": {
+            "description": "The absolute path to the file to modify",
+            "type": "string",
+          },
+          "newString": {
+            "description": "The text to replace it with (must be different from oldString)",
+            "type": "string",
+          },
+          "oldString": {
+            "description": "The text to replace",
+            "type": "string",
+          },
+          "replaceAll": {
+            "description": "Replace all occurrences of oldString (default false)",
+            "type": "boolean",
+          },
+        },
+        "required": [
+          "filePath",
+          "oldString",
+          "newString",
+        ],
+        "type": "object",
+      },
+      "type": "array",
+    },
+    "filePath": {
+      "description": "The absolute path to the file to modify",
+      "type": "string",
+    },
+  },
+  "required": [
+    "filePath",
+    "edits",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {},
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) question 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "questions": {
+      "description": "Questions to ask",
+      "items": {
+        "properties": {
+          "header": {
+            "description": "Very short label (max 30 chars)",
+            "type": "string",
+          },
+          "multiple": {
+            "description": "Allow selecting multiple choices",
+            "type": "boolean",
+          },
+          "options": {
+            "description": "Available choices",
+            "items": {
+              "properties": {
+                "description": {
+                  "description": "Explanation of choice",
+                  "type": "string",
+                },
+                "label": {
+                  "description": "Display text (1-5 words, concise)",
+                  "type": "string",
+                },
+              },
+              "ref": "QuestionOption",
+              "required": [
+                "label",
+                "description",
+              ],
+              "type": "object",
+            },
+            "type": "array",
+          },
+          "question": {
+            "description": "Complete question",
+            "type": "string",
+          },
+        },
+        "ref": "QuestionPrompt",
+        "required": [
+          "question",
+          "header",
+          "options",
+        ],
+        "type": "object",
+      },
+      "type": "array",
+    },
+  },
+  "required": [
+    "questions",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) read 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "filePath": {
+      "description": "The absolute path to the file or directory to read",
+      "type": "string",
+    },
+    "limit": {
+      "description": "The maximum number of lines to read (defaults to 2000)",
+      "type": "number",
+    },
+    "offset": {
+      "description": "The line number to start reading from (1-indexed)",
+      "type": "number",
+    },
+  },
+  "required": [
+    "filePath",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) skill 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "name": {
+      "description": "The name of the skill from available_skills",
+      "type": "string",
+    },
+  },
+  "required": [
+    "name",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) task 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "command": {
+      "description": "The command that triggered this task",
+      "type": "string",
+    },
+    "description": {
+      "description": "A short (3-5 words) description of the task",
+      "type": "string",
+    },
+    "prompt": {
+      "description": "The task for the agent to perform",
+      "type": "string",
+    },
+    "subagent_type": {
+      "description": "The type of specialized agent to use for this task",
+      "type": "string",
+    },
+    "task_id": {
+      "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
+      "type": "string",
+    },
+  },
+  "required": [
+    "description",
+    "prompt",
+    "subagent_type",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) todo 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "todos": {
+      "description": "The updated todo list",
+      "items": {
+        "properties": {
+          "content": {
+            "description": "Brief description of the task",
+            "type": "string",
+          },
+          "priority": {
+            "description": "Priority level of the task: high, medium, low",
+            "type": "string",
+          },
+          "status": {
+            "description": "Current status of the task: pending, in_progress, completed, cancelled",
+            "type": "string",
+          },
+        },
+        "required": [
+          "content",
+          "status",
+          "priority",
+        ],
+        "type": "object",
+      },
+      "type": "array",
+    },
+  },
+  "required": [
+    "todos",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "format": {
+      "default": "markdown",
+      "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
+      "enum": [
+        "text",
+        "markdown",
+        "html",
+      ],
+      "type": "string",
+    },
+    "timeout": {
+      "description": "Optional timeout in seconds (max 120)",
+      "type": "number",
+    },
+    "url": {
+      "description": "The URL to fetch content from",
+      "type": "string",
+    },
+  },
+  "required": [
+    "url",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "contextMaxCharacters": {
+      "description": "Maximum characters for context string optimized for LLMs (default: 10000)",
+      "type": "number",
+    },
+    "livecrawl": {
+      "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
+      "enum": [
+        "fallback",
+        "preferred",
+      ],
+      "type": "string",
+    },
+    "numResults": {
+      "description": "Number of search results to return (default: 8)",
+      "type": "number",
+    },
+    "query": {
+      "description": "Websearch query",
+      "type": "string",
+    },
+    "type": {
+      "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
+      "enum": [
+        "auto",
+        "fast",
+        "deep",
+      ],
+      "type": "string",
+    },
+  },
+  "required": [
+    "query",
+  ],
+  "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) write 1`] = `
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "properties": {
+    "content": {
+      "description": "The content to write to the file",
+      "type": "string",
+    },
+    "filePath": {
+      "description": "The absolute path to the file to write (must be absolute, not relative)",
+      "type": "string",
+    },
+  },
+  "required": [
+    "content",
+    "filePath",
+  ],
+  "type": "object",
+}
+`;

+ 272 - 0
packages/opencode/test/tool/parameters.test.ts

@@ -0,0 +1,272 @@
+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)
+    })
+  })
+})