diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7da7dd255c..3f52d9ac1a 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/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, ctx: Tool.Context) { + const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, 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, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6260b22216..a3a56820e9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/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 diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index ac9961e250..b0d6fab625 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/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({ diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index f535183d4c..20b9db25df 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/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)"), diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 673bb9cc8f..208bb6913c 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/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 diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index caa75edad5..5fc0c02246 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/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 = { diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index aca3618b6d..e080d820d6 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/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", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 263bfe81d2..fe25f661af 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/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, diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 004d3c870d..16a4c86767 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/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 diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index fd7276e09c..4afd4088ba 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/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) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index e5bb33aa69..fed69784d0 100644 --- a/packages/opencode/src/tool/question.ts +++ b/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 } -export const QuestionTool = Tool.define( +export const QuestionTool = Tool.define( "question", Effect.gen(function* () { const question = yield* Question.Service return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const answers = yield* question.ask({ sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 18c668ca07..23524ee5eb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/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, ctx: Tool.Context) { + const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, 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, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 58a66ee744..8b9c411a31 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/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"), }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 3da0664f3d..c77a15bcb9 100644 --- a/packages/opencode/src/tool/task.ts +++ b/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, ctx: Tool.Context) { + const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, 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, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 5090f17a7c..db041d4ee1 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/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( +export const TodoWriteTool = Tool.define( "todowrite", Effect.gen(function* () { const todo = yield* Todo.Service return { description: DESCRIPTION_WRITE, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "todowrite", @@ -42,6 +42,6 @@ export const TodoWriteTool = Tool.define + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 6498b871f8..ba80330516 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/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, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, 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://") diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 34cefd031f..047df79a3d 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/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 diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 741091b21d..843e4e7e5f 100644 --- a/packages/opencode/src/tool/write.ts +++ b/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) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap new file mode 100644 index 0000000000..ea3f3262eb --- /dev/null +++ b/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", +} +`; diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts new file mode 100644 index 0000000000..2f2c384bbf --- /dev/null +++ b/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) + }) + }) +})