From 01a15912c2644f75e8d9251dec990b38a50d9686 Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Tue, 20 Jan 2026 13:17:39 -0800 Subject: [PATCH] feat: select/replace tools --- packages/opencode/src/cli/cmd/debug/agent.ts | 3 + packages/opencode/src/session/prompt.ts | 8 + packages/opencode/src/tool/registry.ts | 4 + .../opencode/src/tool/replace-selection.ts | 257 ++++++++++++++++++ .../opencode/src/tool/replace-selection.txt | 8 + packages/opencode/src/tool/select-text.ts | 146 ++++++++++ packages/opencode/src/tool/select-text.txt | 11 + packages/opencode/src/tool/selection-utils.ts | 158 +++++++++++ packages/opencode/src/tool/tool.ts | 1 + .../opencode/test/tool/apply_patch.test.ts | 1 + packages/opencode/test/tool/bash.test.ts | 1 + .../test/tool/external-directory.test.ts | 1 + packages/opencode/test/tool/grep.test.ts | 1 + packages/opencode/test/tool/question.test.ts | 1 + packages/opencode/test/tool/read.test.ts | 1 + .../opencode/test/tool/select-text.test.ts | 211 ++++++++++++++ 16 files changed, 813 insertions(+) create mode 100644 packages/opencode/src/tool/replace-selection.ts create mode 100644 packages/opencode/src/tool/replace-selection.txt create mode 100644 packages/opencode/src/tool/select-text.ts create mode 100644 packages/opencode/src/tool/select-text.txt create mode 100644 packages/opencode/src/tool/selection-utils.ts create mode 100644 packages/opencode/test/tool/select-text.test.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index d1236ff40bc..8b5b03538c1 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -162,5 +162,8 @@ async function createToolContext(agent: Agent.Info) { } } }, + async getConversation() { + return await Session.messages({ sessionID: session.id }) + }, } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9325583acf7..326fc1be329 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -403,6 +403,9 @@ export namespace SessionPrompt { ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), }) }, + async getConversation() { + return await Session.messages({ sessionID }) + }, } const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { executionError = error @@ -684,6 +687,9 @@ export namespace SessionPrompt { ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), }) }, + async getConversation() { + return await Session.messages({ sessionID: input.session.id }) + }, }) for (const item of await ToolRegistry.tools( @@ -1020,6 +1026,7 @@ export namespace SessionPrompt { extra: { bypassCwdCheck: true, model }, metadata: async () => {}, ask: async () => {}, + getConversation: async () => await Session.messages({ sessionID: input.sessionID }), } const result = await t.execute(args, readCtx) pieces.push({ @@ -1081,6 +1088,7 @@ export namespace SessionPrompt { extra: { bypassCwdCheck: true }, metadata: async () => {}, ask: async () => {}, + getConversation: async () => await Session.messages({ sessionID: input.sessionID }), } const result = await ListTool.init().then((t) => t.execute(args, listCtx)) return [ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index dad9914a289..5f54a678fbe 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,6 +11,8 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { SelectTextTool } from "./select-text" +import { ReplaceSelectionTool } from "./replace-selection" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" @@ -102,6 +104,8 @@ export namespace ToolRegistry { GrepTool, EditTool, WriteTool, + SelectTextTool, + ReplaceSelectionTool, TaskTool, WebFetchTool, TodoWriteTool, diff --git a/packages/opencode/src/tool/replace-selection.ts b/packages/opencode/src/tool/replace-selection.ts new file mode 100644 index 00000000000..efa6005524b --- /dev/null +++ b/packages/opencode/src/tool/replace-selection.ts @@ -0,0 +1,257 @@ +import z from "zod" +import * as path from "path" +import { Tool } from "./tool" +import { createTwoFilesPatch, diffLines } from "diff" +import DESCRIPTION from "./replace-selection.txt" +import { Instance } from "../project/instance" +import { assertExternalDirectory } from "./external-directory" +import { SelectionUtils } from "./selection-utils" +import { FileTime } from "../file/time" +import { Bus } from "../bus" +import { File } from "../file" +import { LSP } from "../lsp" +import { Snapshot } from "@/snapshot" +import { Filesystem } from "../util/filesystem" + +const MAX_DIAGNOSTICS_PER_FILE = 20 + +export const ReplaceSelectionTool = Tool.define("replace-selection", { + description: DESCRIPTION, + parameters: z.object({ + replace: z.string().describe("The new content to replace the selection with"), + }), + async execute(params, ctx) { + const conversation = await ctx.getConversation() + let lastSelectPart = null + let foundReplaceAfterSelect = false + + for (let i = conversation.length - 1; i >= 0; i--) { + const msg = conversation[i] + if (msg.info.role !== "assistant") continue + + for (const part of msg.parts) { + if (part.type === "tool" && part.state.status === "completed") { + if (!foundReplaceAfterSelect && part.tool === "replace-selection") { + foundReplaceAfterSelect = true + continue + } + if (part.tool === "select-text" && !foundReplaceAfterSelect) { + lastSelectPart = part + break + } + } + } + if (lastSelectPart) break + } + + if (!lastSelectPart) { + throw new Error("No valid selection found. You must call select-text before replace-selection.") + } + + const { filePath, searchStart, searchEnd } = lastSelectPart.state.input + + let filepath = filePath + if (!path.isAbsolute(filepath)) { + filepath = path.join(Instance.worktree, filepath) + } + + await assertExternalDirectory(ctx, filepath) + + const file = Bun.file(filepath) + if (!(await file.exists())) { + const dir = path.dirname(filepath) + const base = path.basename(filepath) + + let entries: string[] = [] + try { + const glob = new Bun.Glob("*") + entries = await Array.fromAsync(glob.scan({ cwd: dir })) + } catch { + entries = [] + } + + const suggestions = entries + .filter( + (entry: string) => + entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()), + ) + .slice(0, 3) + + const suggestionsMessage = + suggestions.length > 0 + ? `\n\nDid you mean one of these?\n${suggestions.map((e) => path.join(dir, e)).join("\n")}` + : "" + + throw new Error(`File not found: ${filepath}${suggestionsMessage}`) + } + + const isBinary = await isBinaryFile(filepath, file) + if (isBinary) { + throw new Error(`Cannot edit binary file: ${filepath}`) + } + + let contentOld = "" + let contentNew = "" + + await FileTime.withLock(filepath, async () => { + await FileTime.assert(ctx.sessionID, filepath) + + contentOld = await file.text() + const [startIndexMatchesNewLine, startIndex, endIndex] = SelectionUtils.findStartEndIndices( + contentOld, + searchStart, + searchEnd ?? "", + ) + + const selectedText = contentOld.substring(startIndex, endIndex) + if (selectedText.length / contentOld.length > 0.8) { + throw new Error( + "Selection is too large for replacement. The selected text is more than 80% of the file content.", + ) + } + + let finalStartIndex = startIndex + const isTabOrSpace = (char: string) => char === " " || char === "\t" + + if (!startIndexMatchesNewLine && params.replace.length > 0 && isTabOrSpace(params.replace[0])) { + let needsAutoIndent = true + let check = startIndex - 1 + while (check >= 0) { + if (contentOld[check] === "\n") { + break + } + if (contentOld[check] !== " " && contentOld[check] !== "\t") { + needsAutoIndent = false + break + } + check-- + } + if (needsAutoIndent) { + finalStartIndex = check + 1 + } + } + + contentNew = contentOld.substring(0, finalStartIndex) + params.replace + contentOld.substring(endIndex) + + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + before: selectedText, + after: params.replace, + }, + }) + + await file.write(contentNew) + await Bus.publish(File.Event.Edited, { + file: filepath, + }) + + contentNew = await file.text() + FileTime.read(ctx.sessionID, filepath) + }) + + const filediff: Snapshot.FileDiff = { + file: filepath, + before: contentOld, + after: contentNew, + additions: 0, + deletions: 0, + } + const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew) + + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + + ctx.metadata({ + metadata: { + diff, + filediff, + }, + }) + + let output = "Selection replaced successfully." + await LSP.touchFile(filepath, true) + const diagnostics = await LSP.diagnostics() + const normalizedPath = Filesystem.normalizePath(filepath) + const issues = diagnostics[normalizedPath] ?? [] + const errors = issues.filter((item) => item.severity === 1) + + if (errors.length > 0) { + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\n\nLSP errors detected in this file, please fix:\n\n${limited + .map((d) => d.message) + .join("\n")}${suffix}\n` + } + + return { + metadata: { + diff, + filediff, + }, + title: path.relative(Instance.worktree, filepath), + output, + } + }, +}) + +async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + default: + break + } + + const stat = await file.stat() + const fileSize = stat.size ?? 0 + if (fileSize === 0) return false + + const bufferSize = Math.min(4096, fileSize) + const buffer = await file.arrayBuffer() + if (buffer.byteLength === 0) return false + const bytes = new Uint8Array(buffer.slice(0, bufferSize)) + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + return nonPrintableCount / bytes.length > 0.3 +} diff --git a/packages/opencode/src/tool/replace-selection.txt b/packages/opencode/src/tool/replace-selection.txt new file mode 100644 index 00000000000..04bc577afd0 --- /dev/null +++ b/packages/opencode/src/tool/replace-selection.txt @@ -0,0 +1,8 @@ +Replace text previously selected by the select-text tool. Providing full lines, including the whitespace prefixes and trailing newline, to the replace parameter is recommended to ensure proper formatting when used with replace-selection. + +Usage: +- This tool requires that select-text has been called first to establish the selection +- The replace parameter should contain the new content to replace the selection with +- Providing full lines with whitespace prefixes (tabs/spaces) and trailing newlines is recommended to ensure proper formatting +- Only one selection can be active at a time, and this tool replaces that selection +- After replacement, the selection is cleared and a new select-text call is needed for additional replacements diff --git a/packages/opencode/src/tool/select-text.ts b/packages/opencode/src/tool/select-text.ts new file mode 100644 index 00000000000..69787297059 --- /dev/null +++ b/packages/opencode/src/tool/select-text.ts @@ -0,0 +1,146 @@ +import z from "zod" +import * as path from "path" +import * as fs from "fs" +import { Tool } from "./tool" +import DESCRIPTION from "./select-text.txt" +import { Instance } from "../project/instance" +import { assertExternalDirectory } from "./external-directory" +import { SelectionUtils } from "./selection-utils" + +export const SelectTextTool = Tool.define("select-text", { + description: DESCRIPTION, + parameters: z.object({ + filePath: z.string().describe("The path to the file to search in"), + searchStart: z + .string() + .describe("The exact start string to find and select. Selection is inclusive of searchStart."), + searchEnd: z + .string() + .optional() + .describe( + "Optional: Exact string to find and select up to. If not provided, only the text containing searchStart is selected. Selection behavior of searchEnd must be specified. For example, you may want it to be true to match a closing bracket of the searchStart code block. Or false, to match, but not include the start of the next code block.", + ), + }), + async execute(params, ctx) { + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.join(Instance.worktree, filepath) + } + const title = path.relative(Instance.worktree, filepath) + + await assertExternalDirectory(ctx, filepath, { + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + }) + + await ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) + + const file = Bun.file(filepath) + if (!(await file.exists())) { + const dir = path.dirname(filepath) + const base = path.basename(filepath) + + const dirEntries = fs.readdirSync(dir) + const suggestions = dirEntries + .filter( + (entry) => + entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()), + ) + .map((entry) => path.join(dir, entry)) + .slice(0, 3) + + const suggestionsMessage = + suggestions.length > 0 ? `\n\nDid you mean one of these?\n${suggestions.join("\n")}` : "" + + throw new Error(`File not found: ${filepath}${suggestionsMessage}`) + } + + const isBinary = await isBinaryFile(filepath, file) + if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) + + const content = await file.text() + const [startIndexMatchesNewLine, startIndex, endIndex] = SelectionUtils.findStartEndIndices( + content, + params.searchStart, + params.searchEnd ?? "", + ) + const selectedText = content.substring(startIndex, endIndex) + + if (selectedText.length / content.length > 0.8) { + throw new Error("Selection is too large. The selected text is more than 80% of the file content.") + } + + const message = startIndexMatchesNewLine + ? `The following text was selected for replacement in "${title}":\n\n${selectedText}` + : `WARNING: The searchStart parameter did not exactly match leading whitespace, however a unique match was found. You MUST try to match whitespace when using the select-text tool. The following text was selected for replacement in "${title}":\n\n${selectedText}` + + return { + title, + output: message, + metadata: { + filePath: params.filePath, + searchStart: params.searchStart, + searchEnd: params.searchEnd, + }, + } + }, +}) + +async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + default: + break + } + + const stat = await file.stat() + const fileSize = stat.size + if (fileSize === 0) return false + + const bufferSize = Math.min(4096, fileSize) + const buffer = await file.arrayBuffer() + if (buffer.byteLength === 0) return false + const bytes = new Uint8Array(buffer.slice(0, bufferSize)) + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + return nonPrintableCount / bytes.length > 0.3 +} diff --git a/packages/opencode/src/tool/select-text.txt b/packages/opencode/src/tool/select-text.txt new file mode 100644 index 00000000000..82f0cb81a6d --- /dev/null +++ b/packages/opencode/src/tool/select-text.txt @@ -0,0 +1,11 @@ +Select text in the workspace in preparation for replacement with replace-selection. Uses a searchStart and optionally searchEnd. Only one selection is active at a time in the entire workspace. The select-text will select full lines of code around the searchStart and searchEnd. The next call to replace-selection tool will replace the selected text with the new content. + +Usage: +- The filename parameter is the name of the file to search in +- The searchStart parameter is the exact start string to find and select. Selection is inclusive of searchStart +- The searchEnd parameter is optional. If not provided, only the text containing searchStart is selected +- When using searchEnd, you must specify the selection behavior. For example: + - Use true to match a closing bracket of the searchStart code block + - Use false to match, but not include the start of the next code block +- Only one selection can be active at a time across the entire workspace +- The selection process will expand to include full lines of code around the search strings diff --git a/packages/opencode/src/tool/selection-utils.ts b/packages/opencode/src/tool/selection-utils.ts new file mode 100644 index 00000000000..8014f5a3b07 --- /dev/null +++ b/packages/opencode/src/tool/selection-utils.ts @@ -0,0 +1,158 @@ + +export class SelectionUtils { + static codeAround(contents: string, startIndex: number, endIndex: number, contextLines: number) { + // Validate inputs + if (startIndex < 0 || endIndex < 0 || startIndex > contents.length || endIndex > contents.length || startIndex > endIndex) { + throw new Error('Invalid start or end index'); + } + + if (contextLines < 0) { + throw new Error('Context lines must be non-negative'); + } + + // Split content into lines + const lines = contents.split('\n'); + + // Find line numbers for start and end indices + let currentPos = 0; + let startLine = 0; + let endLine = 0; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for the newline character + + if (currentPos <= startIndex && startIndex < currentPos + lineLength) { + startLine = i; + } + + if (currentPos <= endIndex && endIndex < currentPos + lineLength) { + endLine = i; + break; + } + + currentPos += lineLength; + } + + // Calculate the range of lines to include with context + const startRange = Math.max(0, startLine - contextLines); + const endRange = Math.min(lines.length - 1, endLine + contextLines); + + // Extract the contextual lines + const contextualLines = lines.slice(startRange, endRange + 1); + + // Join the lines back together + return contextualLines.join('\n'); + } + + // Helper method to find all occurrences of a pattern in content + static findAllOccurrences(content: string, pattern: string, requireNewLine = true, requireEol = true): number[] { + const indices: number[] = []; + for (let i = 0; i <= content.length - pattern.length; i++) { + if (content.substring(i, i + pattern.length) === pattern) { + if (requireNewLine) { + if (i !== 0 && content[i - 1] !== '\n') { + continue; + } + } + if (requireEol) { + if (i + pattern.length < content.length && content[i + pattern.length] !== '\n') { + continue; + } + } + + indices.push(i); + } + } + return indices; + } + + static multiplePatterns(patternName: string, pattern: string, indices: number[], content: string): string { + const codeArounds = indices.slice(0, 4).map(i => this.codeAround(content, i, i + pattern.length, 3)).join("\n\n------------\n\n"); + return `Error: Multiple occurrences of ${patternName} pattern found. Please ensure the pattern is unique by matching longer patterns or multiple lines. Matches found include (max 4):\n\n${codeArounds}`; + } + + // try to find a string uniquely in content, first by exact match, then by fuzzy match ignoring leading whitespace + static fuzzyFind(content: string, pattern: string, patternName: string): [boolean, number] { + for (const eol of [true, false]) { + let indices = this.findAllOccurrences(content, pattern, true, eol); + if (indices.length === 1) { + return [true, indices[0]]; + } + else if (indices.length) { + throw new Error(SelectionUtils.multiplePatterns(patternName, pattern, indices, content)); + } + } + + for (const eol of [true, false]) { + let indices = this.findAllOccurrences(content, pattern, false, eol); + if (indices.length === 1) { + return [false, indices[0]]; + } + else if (indices.length) { + throw new Error(SelectionUtils.multiplePatterns(patternName, pattern, indices, content)); + } + } + + throw new Error(`Error: ${patternName} not found.`); + } + + static getLeadingIndent(content: string, index: number): string|undefined { + let indent = ''; + for (let i = index - 1; i >= 0; i--) { + const char = content[i]; + if (char === '\n') { + return indent; + } + if (char === ' ' || char === '\t') { + indent = char + indent; + } + else { + // non whitespace, so this index is not indented. + return; + } + } + + return indent; + } + + static findStartEndIndices(content: string, searchStart: string, searchEnd: string): [boolean, number, number] { + const [startIndexMatchesNewLine, startIndex] = this.fuzzyFind(content, searchStart, 'searchStart'); + if (!searchEnd) { + return [startIndexMatchesNewLine, startIndex, startIndex + searchStart.length]; + } + + const leadingIndent = this.getLeadingIndent(content, startIndex); + // when a leading indent is found for searchStart, try to apply it to searchEnd to for a more precise search. + if (leadingIndent) { + const indentedSearchEnd = leadingIndent + searchEnd; + + for (const eol of [true, false]) { + const indices = this.findAllOccurrences(content.substring(startIndex + searchStart.length), indentedSearchEnd, true, eol) + .map(i => i + startIndex + searchStart.length); + + if (indices.length === 1) { + return [startIndexMatchesNewLine, startIndex, indices[0] + indentedSearchEnd.length]; + } + else if (indices.length) { + throw new Error(SelectionUtils.multiplePatterns('searchEnd', searchEnd, indices, content)); + } + } + } + + for (const newline of [true, false]) { + for (const eol of [true, false]) { + const indices = this.findAllOccurrences(content.substring(startIndex + searchStart.length), searchEnd, newline, eol) + .map(i => i + startIndex + searchStart.length); + + if (indices.length === 1) { + return [startIndexMatchesNewLine, startIndex, indices[0] + searchEnd.length]; + } + else if (indices.length) { + throw new Error(SelectionUtils.multiplePatterns('searchEnd', searchEnd, indices, content)); + } + } + } + + throw new Error(`Error: searchEnd not found.`); + } +} diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af41..2ff2262603d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -22,6 +22,7 @@ export namespace Tool { extra?: { [key: string]: any } metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise + getConversation(): Promise } export interface Info { id: string diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 6445c6845b5..595e0569d12 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -12,6 +12,7 @@ const baseCtx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + getConversation: async () => [], } type AskInput = { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193e9..9787c56cfca 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -14,6 +14,7 @@ const ctx = { abort: AbortSignal.any([]), metadata: () => {}, ask: async () => {}, + getConversation: async () => [], } const projectRoot = path.join(__dirname, "../..") diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index b21f6a9715c..96cd261c64e 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -12,6 +12,7 @@ const baseCtx: Omit = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + getConversation: async () => [], } describe("tool.assertExternalDirectory", () => { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a79d931575c..1cbf4aea085 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -12,6 +12,7 @@ const ctx = { abort: AbortSignal.any([]), metadata: () => {}, ask: async () => {}, + getConversation: async () => [], } const projectRoot = path.join(__dirname, "../..") diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index fa95e9612b6..dc8e18b9e5d 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -11,6 +11,7 @@ const ctx = { abort: AbortSignal.any([]), metadata: () => {}, ask: async () => {}, + getConversation: async () => [], } describe("tool.question", () => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7250bd2fd1e..9663a40931c 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -16,6 +16,7 @@ const ctx = { abort: AbortSignal.any([]), metadata: () => {}, ask: async () => {}, + getConversation: async () => [], } describe("tool.read external_directory permission", () => { diff --git a/packages/opencode/test/tool/select-text.test.ts b/packages/opencode/test/tool/select-text.test.ts new file mode 100644 index 00000000000..fe35c46ed80 --- /dev/null +++ b/packages/opencode/test/tool/select-text.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { SelectTextTool } from "../../src/tool/select-text" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, + getConversation: async () => [], + extra: {}, +} + +describe("tool.select-text", () => { + test("selects text with searchStart only", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "test.ts"), + `function foo() { + return 42 +} + +function bar() { + return 43 +}`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const result = await selectText.execute( + { filePath: path.join(tmp.path, "test.ts"), searchStart: "return 42" }, + ctx, + ) + expect(result.output).toContain("return 42") + expect(result.title).toContain("test.ts") + expect(result.metadata.filePath).toBe(path.join(tmp.path, "test.ts")) + }, + }) + }) + + test("selects text with searchStart and searchEnd", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "test.ts"), + `function foo() { + return 42 +} + +more content here + +and even more`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const result = await selectText.execute( + { + filePath: path.join(tmp.path, "test.ts"), + searchStart: "function foo() {", + searchEnd: "}", + }, + ctx, + ) + expect(result.output).toContain("function foo() {") + expect(result.output).toContain("return 42") + expect(result.output).toContain("}") + }, + }) + }) + + test("throws error when file not found", async () => { + await using tmp = await tmpdir({ + init: async () => {}, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const error = await selectText + .execute({ filePath: path.join(tmp.path, "nonexistent.txt"), searchStart: "test" }, ctx) + .catch((e) => e) + expect(error).toBeDefined() + expect(error.message).toContain("File not found") + }, + }) + }) + + test("throws error for binary files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00]) + await Bun.write(path.join(dir, "test.exe"), exe) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const error = await selectText + .execute({ filePath: path.join(tmp.path, "test.exe"), searchStart: "test" }, ctx) + .catch((e) => e) + expect(error.message).toContain("Cannot read binary file") + }, + }) + }) + + test("throws error when selection is too large", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const content = "x".repeat(85) + "\n" + "y".repeat(10) + await Bun.write(path.join(dir, "large.txt"), content) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const error = await selectText + .execute({ filePath: path.join(tmp.path, "large.txt"), searchStart: "x".repeat(85) }, ctx) + .catch((e) => e) + expect(error).toBeInstanceOf(Error) + expect(error.message).toContain("Selection is too large") + }, + }) + }) + + test("handles files with newlines at end of searchStart", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.ts"), "function foo() {\n return 42\n}\n") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const result = await selectText.execute( + { + filePath: path.join(tmp.path, "test.ts"), + searchStart: "function foo() {\n", + }, + ctx, + ) + expect(result.output).toContain("function foo() {") + expect(result.output).not.toContain("WARNING") + }, + }) + }) + + test("warns when leading whitespace does not match", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "test.ts"), + `function foo() { + return 42 +}`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const result = await selectText.execute( + { + filePath: path.join(tmp.path, "test.ts"), + searchStart: "return 42", + }, + ctx, + ) + expect(result.output).toContain("return 42") + expect(result.output).toContain("WARNING") + expect(result.output).toContain("whitespace") + }, + }) + }) + + test("allows absolute path inside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const selectText = await SelectTextTool.init() + const result = await selectText.execute( + { filePath: path.join(tmp.path, "test.txt"), searchStart: "hello" }, + ctx, + ) + expect(result.output).toContain("hello") + expect(result.title).toContain("test.txt") + }, + }) + }) +})