diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d518dd12a1..eff7389bba 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,21 +347,13 @@ export namespace ACP { ] if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) + const diff = editDiff(part.state.input, part.state.metadata) + if (diff) { + content.push({ + type: "diff", + ...diff, + }) + } } if (part.tool === "todowrite") { @@ -862,21 +854,13 @@ export namespace ACP { ] if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) + const diff = editDiff(part.state.input, part.state.metadata) + if (diff) { + content.push({ + type: "diff", + ...diff, + }) + } } if (part.tool === "todowrite") { @@ -1630,6 +1614,40 @@ export namespace ACP { } } + function editDiff(input: Record, metadata: unknown) { + const meta = typeof metadata === "object" && metadata ? (metadata as Record) : undefined + const filediff = + meta && typeof meta["filediff"] === "object" && meta["filediff"] + ? (meta["filediff"] as Record) + : undefined + const path = + typeof filediff?.["file"] === "string" + ? filediff["file"] + : typeof input["filePath"] === "string" + ? input["filePath"] + : "" + const oldText = + typeof filediff?.["before"] === "string" + ? filediff["before"] + : typeof input["oldString"] === "string" + ? input["oldString"] + : "" + const newText = + typeof filediff?.["after"] === "string" + ? filediff["after"] + : typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + if (!path && !oldText && !newText) return + return { + path, + oldText, + newText, + } + } + function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { const result = applyPatch(fileOriginal, unifiedDiff) if (result === false) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 033f4bab81..c0e6cf7602 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2065,9 +2065,7 @@ function Edit(props: ToolProps) { - Edit{" "} - {normalizePath(props.input.filePath!)}{" "} - {input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })} + Edit {normalizePath(props.input.filePath!)} {input(props.input, ["edits", "filePath"])} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf7eb27b08..c170b30586 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1151,10 +1151,6 @@ export namespace Config { .object({ disable_paste_summary: z.boolean().optional(), batch_tool: z.boolean().optional().describe("Enable the batch tool"), - hashline_edit: z - .boolean() - .optional() - .describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"), hashline_autocorrect: z .boolean() .optional() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 455fc2f075..f087a8eb37 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -1,14 +1,9 @@ -// the approaches in this edit tool are sourced from -// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts -// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts -// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts - import z from "zod" import * as path from "path" import * as fs from "fs/promises" +import { createTwoFilesPatch, diffLines } from "diff" import { Tool } from "./tool" import { LSP } from "../lsp" -import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" import { FileWatcher } from "../file/watcher" @@ -28,84 +23,43 @@ import { import { Config } from "../config/config" const MAX_DIAGNOSTICS_PER_FILE = 20 -const LEGACY_EDIT_MODE = "legacy" const HASHLINE_EDIT_MODE = "hashline" - -const LegacyEditParams = 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)"), -}) - -const HashlineEditParams = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z.array(HashlineEdit).default([]), - delete: z.boolean().optional(), - rename: z.string().optional(), -}) +const LEGACY_KEYS = ["oldString", "newString", "replaceAll"] as const const EditParams = z .object({ filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().optional().describe("The text to replace"), - newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), edits: z.array(HashlineEdit).optional(), delete: z.boolean().optional(), rename: z.string().optional(), }) .strict() .superRefine((value, ctx) => { - const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined - const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined - - if (legacy && hashline) { - ctx.addIssue({ - code: "custom", - message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.", - }) - return - } - - if (!legacy && !hashline) { - ctx.addIssue({ - code: "custom", - message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).", - }) - return - } - - if (legacy) { - if (value.oldString === undefined || value.newString === undefined) { - ctx.addIssue({ - code: "custom", - message: "Legacy payload requires both oldString and newString.", - }) - } - return - } - - if (value.edits === undefined) { - ctx.addIssue({ - code: "custom", - message: "Hashline payload requires edits (use [] when only delete is intended).", - }) - } + if (value.edits !== undefined) return + ctx.addIssue({ + code: "custom", + message: "Hashline payload requires edits (use [] when only delete or rename is intended).", + }) }) -type LegacyEditParams = z.infer -type HashlineEditParams = z.infer type EditParams = z.infer +function formatValidationError(error: z.ZodError) { + const legacy = error.issues.some((issue) => { + if (issue.code !== "unrecognized_keys") return false + if (!("keys" in issue) || !Array.isArray(issue.keys)) return false + return issue.keys.some((key) => LEGACY_KEYS.includes(key as (typeof LEGACY_KEYS)[number])) + }) + if (legacy) { + return "Legacy edit payload has been removed. Use hashline fields: { filePath, edits, delete?, rename? }." + } + return `Invalid parameters for tool 'edit':\n${error.issues.map((issue) => `- ${issue.message}`).join("\n")}` +} + function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } -function isLegacyParams(params: EditParams): params is LegacyEditParams { - return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined -} - async function withLocks(paths: string[], fn: () => Promise) { const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b)) const recurse = async (idx: number): Promise => { @@ -154,105 +108,8 @@ async function diagnosticsOutput(filePath: string, output: string) { } } -async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) { - if (params.oldString === params.newString) { - throw new Error("No changes to apply: oldString and newString are identical.") - } - - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - await assertExternalDirectory(ctx, filePath) - - let diff = "" - let contentOld = "" - let contentNew = "" - await FileTime.withLock(filePath, async () => { - if (params.oldString === "") { - const existed = await Filesystem.exists(filePath) - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - await ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - }, - }) - await Filesystem.write(filePath, params.newString) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - await Bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: existed ? "change" : "add", - }) - FileTime.read(ctx.sessionID, filePath) - return - } - - const stats = Filesystem.stat(filePath) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - await FileTime.assert(ctx.sessionID, filePath) - contentOld = await Filesystem.readText(filePath) - contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) - - diff = trimDiff( - createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), - ) - await ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - }, - }) - - await Filesystem.write(filePath, contentNew) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - await Bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: "change", - }) - contentNew = await Filesystem.readText(filePath) - diff = trimDiff( - createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), - ) - FileTime.read(ctx.sessionID, filePath) - }) - - const filediff = createFileDiff(filePath, contentOld, contentNew) - - ctx.metadata({ - metadata: { - diff, - filediff, - diagnostics: {}, - edit_mode: LEGACY_EDIT_MODE, - }, - }) - - const result = await diagnosticsOutput(filePath, "Edit applied successfully.") - - return { - metadata: { - diagnostics: result.diagnostics, - diff, - filediff, - edit_mode: LEGACY_EDIT_MODE, - }, - title: `${path.relative(Instance.worktree, filePath)}`, - output: result.output, - } -} - async function executeHashline( - params: HashlineEditParams, + params: EditParams, ctx: Tool.Context, autocorrect: boolean, aggressiveAutocorrect: boolean, @@ -263,13 +120,14 @@ async function executeHashline( ? params.rename : path.join(Instance.directory, params.rename) : sourcePath + const edits = params.edits ?? [] await assertExternalDirectory(ctx, sourcePath) if (params.rename) { await assertExternalDirectory(ctx, targetPath) } - if (params.delete && params.edits.length > 0) { + if (params.delete && edits.length > 0) { throw new Error("delete=true cannot be combined with edits") } if (params.delete && params.rename) { @@ -283,8 +141,7 @@ async function executeHashline( let deleted = false let changed = false let diagnostics: Awaited> = {} - const paths = [sourcePath, targetPath] - await withLocks(paths, async () => { + await withLocks([sourcePath, targetPath], async () => { const sourceStat = Filesystem.stat(sourcePath) if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`) const exists = Boolean(sourceStat) @@ -300,10 +157,7 @@ async function executeHashline( } await FileTime.assert(ctx.sessionID, sourcePath) before = await Filesystem.readText(sourcePath) - after = "" - diff = trimDiff( - createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)), - ) + diff = trimDiff(createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), "")) await ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, sourcePath)], @@ -326,7 +180,7 @@ async function executeHashline( return } - if (!exists && !hashlineOnlyCreates(params.edits)) { + if (!exists && !hashlineOnlyCreates(edits)) { throw new Error("Missing file can only be created with append/prepend hashline edits") } if (exists) { @@ -348,7 +202,7 @@ async function executeHashline( const next = applyHashlineEdits({ lines: parsed.lines, trailing: parsed.trailing, - edits: params.edits, + edits, autocorrect, aggressiveAutocorrect, }) @@ -360,8 +214,7 @@ async function executeHashline( }) after = output.text - const noContentChange = before === after && sourcePath === targetPath - if (noContentChange) { + if (before === after && sourcePath === targetPath) { noop = 1 diff = trimDiff( createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)), @@ -463,31 +316,15 @@ async function executeHashline( export const EditTool = Tool.define("edit", { description: DESCRIPTION, parameters: EditParams, + formatValidationError, async execute(params, ctx) { if (!params.filePath) { throw new Error("filePath is required") } - if (isLegacyParams(params)) { - return executeLegacy(params, ctx) - } - const config = await Config.get() - if (config.experimental?.hashline_edit === false) { - throw new Error( - "Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.", - ) - } - - const hashlineParams: HashlineEditParams = { - filePath: params.filePath, - edits: params.edits ?? [], - delete: params.delete, - rename: params.rename, - } - return executeHashline( - hashlineParams, + params, ctx, config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", Bun.env.OPENCODE_HL_AUTOCORRECT === "1", @@ -495,431 +332,6 @@ export const EditTool = Tool.define("edit", { }, }) -export type Replacer = (content: string, find: string) => Generator - -// Similarity thresholds for block anchor fallback matching -const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0 -const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 - -/** - * Levenshtein distance algorithm implementation - */ -function levenshtein(a: string, b: string): number { - // Handle empty strings - if (a === "" || b === "") { - return Math.max(a.length, b.length) - } - const matrix = Array.from({ length: a.length + 1 }, (_, i) => - Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), - ) - - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1 - matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost) - } - } - return matrix[a.length][b.length] -} - -export const SimpleReplacer: Replacer = function* (_content, find) { - yield find -} - -export const LineTrimmedReplacer: Replacer = function* (content, find) { - const originalLines = content.split("\n") - const searchLines = find.split("\n") - - if (searchLines[searchLines.length - 1] === "") { - searchLines.pop() - } - - for (let i = 0; i <= originalLines.length - searchLines.length; i++) { - let matches = true - - for (let j = 0; j < searchLines.length; j++) { - const originalTrimmed = originalLines[i + j].trim() - const searchTrimmed = searchLines[j].trim() - - if (originalTrimmed !== searchTrimmed) { - matches = false - break - } - } - - if (matches) { - let matchStartIndex = 0 - for (let k = 0; k < i; k++) { - matchStartIndex += originalLines[k].length + 1 - } - - let matchEndIndex = matchStartIndex - for (let k = 0; k < searchLines.length; k++) { - matchEndIndex += originalLines[i + k].length - if (k < searchLines.length - 1) { - matchEndIndex += 1 // Add newline character except for the last line - } - } - - yield content.substring(matchStartIndex, matchEndIndex) - } - } -} - -export const BlockAnchorReplacer: Replacer = function* (content, find) { - const originalLines = content.split("\n") - const searchLines = find.split("\n") - - if (searchLines.length < 3) { - return - } - - if (searchLines[searchLines.length - 1] === "") { - searchLines.pop() - } - - const firstLineSearch = searchLines[0].trim() - const lastLineSearch = searchLines[searchLines.length - 1].trim() - const searchBlockSize = searchLines.length - - // Collect all candidate positions where both anchors match - const candidates: Array<{ startLine: number; endLine: number }> = [] - for (let i = 0; i < originalLines.length; i++) { - if (originalLines[i].trim() !== firstLineSearch) { - continue - } - - // Look for the matching last line after this first line - for (let j = i + 2; j < originalLines.length; j++) { - if (originalLines[j].trim() === lastLineSearch) { - candidates.push({ startLine: i, endLine: j }) - break // Only match the first occurrence of the last line - } - } - } - - // Return immediately if no candidates - if (candidates.length === 0) { - return - } - - // Handle single candidate scenario (using relaxed threshold) - if (candidates.length === 1) { - const { startLine, endLine } = candidates[0] - const actualBlockSize = endLine - startLine + 1 - - let similarity = 0 - let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only - - if (linesToCheck > 0) { - for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { - const originalLine = originalLines[startLine + j].trim() - const searchLine = searchLines[j].trim() - const maxLen = Math.max(originalLine.length, searchLine.length) - if (maxLen === 0) { - continue - } - const distance = levenshtein(originalLine, searchLine) - similarity += (1 - distance / maxLen) / linesToCheck - - // Exit early when threshold is reached - if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { - break - } - } - } else { - // No middle lines to compare, just accept based on anchors - similarity = 1.0 - } - - if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { - let matchStartIndex = 0 - for (let k = 0; k < startLine; k++) { - matchStartIndex += originalLines[k].length + 1 - } - let matchEndIndex = matchStartIndex - for (let k = startLine; k <= endLine; k++) { - matchEndIndex += originalLines[k].length - if (k < endLine) { - matchEndIndex += 1 // Add newline character except for the last line - } - } - yield content.substring(matchStartIndex, matchEndIndex) - } - return - } - - // Calculate similarity for multiple candidates - let bestMatch: { startLine: number; endLine: number } | null = null - let maxSimilarity = -1 - - for (const candidate of candidates) { - const { startLine, endLine } = candidate - const actualBlockSize = endLine - startLine + 1 - - let similarity = 0 - let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only - - if (linesToCheck > 0) { - for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { - const originalLine = originalLines[startLine + j].trim() - const searchLine = searchLines[j].trim() - const maxLen = Math.max(originalLine.length, searchLine.length) - if (maxLen === 0) { - continue - } - const distance = levenshtein(originalLine, searchLine) - similarity += 1 - distance / maxLen - } - similarity /= linesToCheck // Average similarity - } else { - // No middle lines to compare, just accept based on anchors - similarity = 1.0 - } - - if (similarity > maxSimilarity) { - maxSimilarity = similarity - bestMatch = candidate - } - } - - // Threshold judgment - if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { - const { startLine, endLine } = bestMatch - let matchStartIndex = 0 - for (let k = 0; k < startLine; k++) { - matchStartIndex += originalLines[k].length + 1 - } - let matchEndIndex = matchStartIndex - for (let k = startLine; k <= endLine; k++) { - matchEndIndex += originalLines[k].length - if (k < endLine) { - matchEndIndex += 1 - } - } - yield content.substring(matchStartIndex, matchEndIndex) - } -} - -export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { - const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim() - const normalizedFind = normalizeWhitespace(find) - - // Handle single line matches - const lines = content.split("\n") - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (normalizeWhitespace(line) === normalizedFind) { - yield line - } else { - // Only check for substring matches if the full line doesn't match - const normalizedLine = normalizeWhitespace(line) - if (normalizedLine.includes(normalizedFind)) { - // Find the actual substring in the original line that matches - const words = find.trim().split(/\s+/) - if (words.length > 0) { - const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") - try { - const regex = new RegExp(pattern) - const match = line.match(regex) - if (match) { - yield match[0] - } - } catch (e) { - // Invalid regex pattern, skip - } - } - } - } - } - - // Handle multi-line matches - const findLines = find.split("\n") - if (findLines.length > 1) { - for (let i = 0; i <= lines.length - findLines.length; i++) { - const block = lines.slice(i, i + findLines.length) - if (normalizeWhitespace(block.join("\n")) === normalizedFind) { - yield block.join("\n") - } - } - } -} - -export const IndentationFlexibleReplacer: Replacer = function* (content, find) { - const removeIndentation = (text: string) => { - const lines = text.split("\n") - const nonEmptyLines = lines.filter((line) => line.trim().length > 0) - if (nonEmptyLines.length === 0) return text - - const minIndent = Math.min( - ...nonEmptyLines.map((line) => { - const match = line.match(/^(\s*)/) - return match ? match[1].length : 0 - }), - ) - - return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n") - } - - const normalizedFind = removeIndentation(find) - const contentLines = content.split("\n") - const findLines = find.split("\n") - - for (let i = 0; i <= contentLines.length - findLines.length; i++) { - const block = contentLines.slice(i, i + findLines.length).join("\n") - if (removeIndentation(block) === normalizedFind) { - yield block - } - } -} - -export const EscapeNormalizedReplacer: Replacer = function* (content, find) { - const unescapeString = (str: string): string => { - return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => { - switch (capturedChar) { - case "n": - return "\n" - case "t": - return "\t" - case "r": - return "\r" - case "'": - return "'" - case '"': - return '"' - case "`": - return "`" - case "\\": - return "\\" - case "\n": - return "\n" - case "$": - return "$" - default: - return match - } - }) - } - - const unescapedFind = unescapeString(find) - - // Try direct match with unescaped find string - if (content.includes(unescapedFind)) { - yield unescapedFind - } - - // Also try finding escaped versions in content that match unescaped find - const lines = content.split("\n") - const findLines = unescapedFind.split("\n") - - for (let i = 0; i <= lines.length - findLines.length; i++) { - const block = lines.slice(i, i + findLines.length).join("\n") - const unescapedBlock = unescapeString(block) - - if (unescapedBlock === unescapedFind) { - yield block - } - } -} - -export const MultiOccurrenceReplacer: Replacer = function* (content, find) { - // This replacer yields all exact matches, allowing the replace function - // to handle multiple occurrences based on replaceAll parameter - let startIndex = 0 - - while (true) { - const index = content.indexOf(find, startIndex) - if (index === -1) break - - yield find - startIndex = index + find.length - } -} - -export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { - const trimmedFind = find.trim() - - if (trimmedFind === find) { - // Already trimmed, no point in trying - return - } - - // Try to find the trimmed version - if (content.includes(trimmedFind)) { - yield trimmedFind - } - - // Also try finding blocks where trimmed content matches - const lines = content.split("\n") - const findLines = find.split("\n") - - for (let i = 0; i <= lines.length - findLines.length; i++) { - const block = lines.slice(i, i + findLines.length).join("\n") - - if (block.trim() === trimmedFind) { - yield block - } - } -} - -export const ContextAwareReplacer: Replacer = function* (content, find) { - const findLines = find.split("\n") - if (findLines.length < 3) { - // Need at least 3 lines to have meaningful context - return - } - - // Remove trailing empty line if present - if (findLines[findLines.length - 1] === "") { - findLines.pop() - } - - const contentLines = content.split("\n") - - // Extract first and last lines as context anchors - const firstLine = findLines[0].trim() - const lastLine = findLines[findLines.length - 1].trim() - - // Find blocks that start and end with the context anchors - for (let i = 0; i < contentLines.length; i++) { - if (contentLines[i].trim() !== firstLine) continue - - // Look for the matching last line - for (let j = i + 2; j < contentLines.length; j++) { - if (contentLines[j].trim() === lastLine) { - // Found a potential context block - const blockLines = contentLines.slice(i, j + 1) - const block = blockLines.join("\n") - - // Check if the middle content has reasonable similarity - // (simple heuristic: at least 50% of non-empty lines should match when trimmed) - if (blockLines.length === findLines.length) { - let matchingLines = 0 - let totalNonEmptyLines = 0 - - for (let k = 1; k < blockLines.length - 1; k++) { - const blockLine = blockLines[k].trim() - const findLine = findLines[k].trim() - - if (blockLine.length > 0 || findLine.length > 0) { - totalNonEmptyLines++ - if (blockLine === findLine) { - matchingLines++ - } - } - } - - if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { - yield block - break // Only match the first occurrence - } - } - break - } - } - } -} - export function trimDiff(diff: string): string { const lines = diff.split("\n") const contentLines = lines.filter( @@ -955,42 +367,3 @@ export function trimDiff(diff: string): string { return trimmedLines.join("\n") } - -export function replace(content: string, oldString: string, newString: string, replaceAll = false): string { - if (oldString === newString) { - throw new Error("No changes to apply: oldString and newString are identical.") - } - - let notFound = true - - for (const replacer of [ - SimpleReplacer, - LineTrimmedReplacer, - BlockAnchorReplacer, - WhitespaceNormalizedReplacer, - IndentationFlexibleReplacer, - EscapeNormalizedReplacer, - TrimmedBoundaryReplacer, - ContextAwareReplacer, - MultiOccurrenceReplacer, - ]) { - for (const search of replacer(content, oldString)) { - const index = content.indexOf(search) - if (index === -1) continue - notFound = false - if (replaceAll) { - return content.replaceAll(search, newString) - } - const lastIndex = content.lastIndexOf(search) - if (index !== lastIndex) continue - return content.substring(0, index) + newString + content.substring(index + search.length) - } - } - - if (notFound) { - throw new Error( - "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.", - ) - } - throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.") -} diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index 4b729cfc83..e5b2e25fa4 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -1,22 +1,12 @@ -Performs file edits with two supported payload schemas. +Performs file edits with hashline anchors. Usage: - You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - -Legacy schema (always supported): -- `{ filePath, oldString, newString, replaceAll? }` -- Exact replacement only. -- The edit fails if `oldString` is not found. -- The edit fails if `oldString` matches multiple locations and `replaceAll` is not true. -- Use `replaceAll: true` for global replacements. - -Hashline schema (default behavior): - `{ filePath, edits, delete?, rename? }` -- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call. +- `edits` is required; use `[]` when only delete or rename is intended. - Use strict anchor references from `Read` output: `LINE#ID`. -- Hashline mode can be turned off with `experimental.hashline_edit: false`. - Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`. - Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics. - When `Read` returns `LINE#ID:`, prefer hashline operations. @@ -29,5 +19,5 @@ Hashline schema (default behavior): - `append { text }` - `prepend { text }` - `replace { old_text, new_text, all? }` -- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors. -- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads. +- Provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors. +- GPT-family models can use `apply_patch` as fallback when needed. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 9fb59ab8fd..4f9733784c 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -9,7 +9,6 @@ import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" -import { Config } from "../config/config" import { hashlineRef } from "./hashline" const MAX_LINE_LENGTH = 2000 @@ -118,7 +117,6 @@ export const GrepTool = Tool.define("grep", { } const totalMatches = matches.length - const useHashline = (await Config.get()).experimental?.hashline_edit !== false const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] let currentFile = "" @@ -132,11 +130,7 @@ export const GrepTool = Tool.define("grep", { } const truncatedLineText = match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText - if (useHashline) { - outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`) - } else { - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) - } + outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`) } if (truncated) { diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index 0874117f15..05dde1d68a 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -3,7 +3,7 @@ - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") - Returns file paths with matching lines sorted by modification time -- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:` when hashline mode is enabled +- Output format uses hashline anchors: `N#ID:` - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7f562f4737..5c72d15634 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" import { EditTool } from "./edit" +import { WriteTool } from "./write" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" @@ -22,17 +23,32 @@ export const MultiEditTool = Tool.define("multiedit", { }), async execute(params, ctx) { const tool = await EditTool.init() + const write = await WriteTool.init() const results = [] - for (const [, edit] of params.edits.entries()) { - const result = await tool.execute( - { - filePath: params.filePath, - oldString: edit.oldString, - newString: edit.newString, - replaceAll: edit.replaceAll, - }, - ctx, - ) + for (const edit of params.edits) { + const result = + edit.oldString === "" + ? await write.execute( + { + filePath: params.filePath, + content: edit.newString, + }, + ctx, + ) + : await tool.execute( + { + filePath: params.filePath, + edits: [ + { + type: "replace", + old_text: edit.oldString, + new_text: edit.newString, + all: edit.replaceAll, + }, + ], + }, + ctx, + ) results.push(result) } return { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 544bfa2429..28c6fc3c18 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,7 +11,6 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { Filesystem } from "../util/filesystem" -import { Config } from "../config/config" import { hashlineRef } from "./hashline" const DEFAULT_READ_LIMIT = 2000 @@ -194,11 +193,9 @@ export const ReadTool = Tool.define("read", { throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`) } - const useHashline = (await Config.get()).experimental?.hashline_edit !== false const content = raw.map((line, index) => { const lineNumber = index + offset - if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}` - return `${lineNumber}: ${line}` + return `${hashlineRef(lineNumber, full[index])}:${line}` }) const preview = raw.slice(0, 20).join("\n") diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index 921b2384ef..eb9baa1949 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -9,7 +9,6 @@ Usage: - If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern. - Contents are returned with a line prefix. - Default format: `LINE#ID:` (example: `1#AB:foo`). Use these anchors for hashline edits. -- Legacy format can be restored with `experimental.hashline_edit: false`: `: ` (example: `1: foo`). - For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories. - Any line longer than 2000 characters is truncated. - Call this tool in parallel when you know there are multiple files you want to read. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 176d329614..e10470a9c6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -135,9 +135,7 @@ export namespace ToolRegistry { }, agent?: Agent.Info, ) { - const config = await Config.get() const tools = await all() - const hashline = config.experimental?.hashline_edit !== false const usePatch = model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") const result = await Promise.all( @@ -148,14 +146,7 @@ export namespace ToolRegistry { return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } - if (hashline) { - if (t.id === "apply_patch") return usePatch - return true - } - - // use apply tool in same format as codex if (t.id === "apply_patch") return usePatch - if (t.id === "edit" || t.id === "write") return !usePatch return true }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6b7ec5187d..3eadc3ad2d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -102,7 +102,27 @@ test("loads JSONC config file", async () => { }) }) -test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => { +test("parses experimental.hashline_autocorrect", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + experimental: { + hashline_autocorrect: true, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.hashline_autocorrect).toBe(true) + }, + }) +}) + +test("ignores removed experimental.hashline_edit", async () => { await using tmp = await tmpdir({ init: async (dir) => { await writeConfig(dir, { @@ -118,8 +138,8 @@ test("parses experimental.hashline_edit and experimental.hashline_autocorrect", directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.experimental?.hashline_edit).toBe(true) expect(config.experimental?.hashline_autocorrect).toBe(true) + expect((config.experimental as Record)?.hashline_edit).toBeUndefined() }, }) }) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index cf20c11aee..c0d17191fe 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -19,761 +19,506 @@ const ctx = { } describe("tool.edit", () => { - describe("creating new files", () => { - test("creates new file when oldString is empty", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "newfile.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "", - newString: "new content", - }, - ctx, - ) - - expect(result.metadata.diff).toContain("new content") - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content") - }, - }) + test("rejects legacy payloads", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8") + }, }) + const filepath = path.join(tmp.path, "file.txt") - test("creates new file with nested directories", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await edit.execute( + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + await expect( + edit.execute( { filePath: filepath, - oldString: "", - newString: "nested file", - }, + oldString: "b", + newString: "B", + } as any, ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("nested file") - }, - }) - }) - - test("emits add event for new files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "new.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { Bus } = await import("../../src/bus") - const { File } = await import("../../src/file") - const { FileWatcher } = await import("../../src/file/watcher") - - const events: string[] = [] - const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited")) - const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) - - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "", - newString: "content", - }, - ctx, - ) - - expect(events).toContain("edited") - expect(events).toContain("updated") - unsubEdited() - unsubUpdated() - }, - }) + ), + ).rejects.toThrow("Legacy edit payload has been removed") + }, }) }) - describe("editing existing files", () => { - test("replaces text in existing file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.txt") - await fs.writeFile(filepath, "old content here", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "old content", - newString: "new content", - }, - ctx, - ) - - expect(result.output).toContain("Edit applied successfully") - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content here") - }, - }) + test("replaces a single line", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8") + }, }) + const filepath = path.join(tmp.path, "file.txt") - test("throws error when file does not exist", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nonexistent.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - await expect( - edit.execute( + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + const result = await edit.execute( + { + filePath: filepath, + edits: [ { - filePath: filepath, - oldString: "old", - newString: "new", + type: "set_line", + line: hashlineRef(2, "b"), + text: "B", }, - ctx, - ), - ).rejects.toThrow("not found") - }, - }) - }) - - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: filepath, - oldString: "same", - newString: "same", - }, - ctx, - ), - ).rejects.toThrow("identical") - }, - }) - }) - - test("throws error when oldString not found in file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "actual content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: filepath, - oldString: "not in file", - newString: "replacement", - }, - ctx, - ), - ).rejects.toThrow() - }, - }) - }) - - test("throws error when file was not read first (FileTime)", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: filepath, - oldString: "content", - newString: "modified", - }, - ctx, - ), - ).rejects.toThrow("You must read file") - }, - }) - }) - - test("throws error when file has been modified since read", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Read first - FileTime.read(ctx.sessionID, filepath) - - // Wait a bit to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Simulate external modification - await fs.writeFile(filepath, "modified externally", "utf-8") - - // Try to edit with the new content - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: filepath, - oldString: "modified externally", - newString: "edited", - }, - ctx, - ), - ).rejects.toThrow("modified since it was last read") - }, - }) - }) - - test("replaces all occurrences with replaceAll option", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "foo", - newString: "qux", - replaceAll: true, - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("qux bar qux baz qux") - }, - }) - }) - - test("emits change event for existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const { Bus } = await import("../../src/bus") - const { File } = await import("../../src/file") - const { FileWatcher } = await import("../../src/file/watcher") - - const events: string[] = [] - const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited")) - const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) - - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "original", - newString: "modified", - }, - ctx, - ) - - expect(events).toContain("edited") - expect(events).toContain("updated") - unsubEdited() - unsubUpdated() - }, - }) - }) - }) - - describe("edge cases", () => { - test("handles multiline replacements", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line 2\nextra line", - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\nnew line 2\nextra line\nline3") - }, - }) - }) - - test("handles CRLF line endings", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\r\nnew\r\nline3") - }, - }) - }) - - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "", - }, - ctx, - ), - ).rejects.toThrow("identical") - }, - }) - }) - - test("throws error when path is directory", async () => { - await using tmp = await tmpdir() - const dirpath = path.join(tmp.path, "adir") - await fs.mkdir(dirpath) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, dirpath) - - const edit = await EditTool.init() - await expect( - edit.execute( - { - filePath: dirpath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ).rejects.toThrow("directory") - }, - }) - }) - - test("tracks file diff statistics", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line a\nnew line b", - }, - ctx, - ) - - expect(result.metadata.filediff).toBeDefined() - expect(result.metadata.filediff.file).toBe(filepath) - expect(result.metadata.filediff.additions).toBeGreaterThan(0) - }, - }) - }) - }) - - describe("concurrent editing", () => { - test("serializes concurrent edits to same file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "0", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - - const edit = await EditTool.init() - - // Two concurrent edits - const promise1 = edit.execute( - { - filePath: filepath, - oldString: "0", - newString: "1", - }, - ctx, - ) - - // Need to read again since FileTime tracks per-session - FileTime.read(ctx.sessionID, filepath) - - const promise2 = edit.execute( - { - filePath: filepath, - oldString: "0", - newString: "2", - }, - ctx, - ) - - // Both should complete without error (though one might fail due to content mismatch) - const results = await Promise.allSettled([promise1, promise2]) - expect(results.some((r) => r.status === "fulfilled")).toBe(true) - }, - }) - }) - }) - - describe("hashline payload", () => { - test("replaces a single line in hashline mode", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, + ], }, - }, - init: async (dir) => { - await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8") - }, - }) - const filepath = path.join(tmp.path, "file.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - const edit = await EditTool.init() - const result = await edit.execute( + ctx, + ) + + expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc") + expect(result.metadata.edit_mode).toBe("hashline") + }, + }) + }) + + test("supports replace operations with all=true", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "foo bar foo baz foo", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + await edit.execute( + { + filePath: filepath, + edits: [ + { + type: "replace", + old_text: "foo", + new_text: "qux", + all: true, + }, + ], + }, + ctx, + ) + + expect(await fs.readFile(filepath, "utf-8")).toBe("qux bar qux baz qux") + }, + }) + }) + + test("supports range replacement and insert modes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + await edit.execute( + { + filePath: filepath, + edits: [ + { + type: "replace_lines", + start_line: hashlineRef(2, "b"), + end_line: hashlineRef(3, "c"), + text: ["B", "C"], + }, + { + type: "insert_before", + line: hashlineRef(2, "b"), + text: "x", + }, + { + type: "insert_after", + line: hashlineRef(3, "c"), + text: "y", + }, + ], + }, + ctx, + ) + + expect(await fs.readFile(filepath, "utf-8")).toBe("a\nx\nB\nC\ny\nd") + }, + }) + }) + + test("creates missing files from append and prepend operations", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "created.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + await edit.execute( + { + filePath: filepath, + edits: [ + { + type: "prepend", + text: "start", + }, + { + type: "append", + text: "end", + }, + ], + }, + ctx, + ) + + expect(await fs.readFile(filepath, "utf-8")).toBe("start\nend") + }, + }) + }) + + test("requires a prior read for existing files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "content", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + await expect( + edit.execute( { filePath: filepath, edits: [ { type: "set_line", - line: hashlineRef(2, "b"), - text: "B", + line: hashlineRef(1, "content"), + text: "changed", }, ], }, ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("a\nB\nc") - expect(result.metadata.edit_mode).toBe("hashline") - }, - }) + ), + ).rejects.toThrow("You must read file") + }, }) + }) - test("applies hashline autocorrect prefixes through config", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - hashline_autocorrect: true, - }, - }, - init: async (dir) => { - await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8") - }, - }) - const filepath = path.join(tmp.path, "file.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - const edit = await EditTool.init() - await edit.execute( + test("rejects files modified since read", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "original", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + await new Promise((resolve) => setTimeout(resolve, 100)) + await fs.writeFile(filepath, "external", "utf-8") + + const edit = await EditTool.init() + await expect( + edit.execute( { filePath: filepath, edits: [ { type: "set_line", - line: hashlineRef(2, "b"), - text: hashlineLine(2, "B"), + line: hashlineRef(1, "original"), + text: "changed", }, ], }, ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("a\nB\nc") - }, - }) + ), + ).rejects.toThrow("modified since it was last read") + }, }) + }) - test("supports range replacement and insert modes", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - init: async (dir) => { - await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8") - }, - }) - const filepath = path.join(tmp.path, "file.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(ctx.sessionID, filepath) - const edit = await EditTool.init() - await edit.execute( + test("rejects missing files for non-append and non-prepend edits", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "missing.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + await expect( + edit.execute( { filePath: filepath, edits: [ { - type: "replace_lines", - start_line: hashlineRef(2, "b"), - end_line: hashlineRef(3, "c"), - text: ["B", "C"], - }, - { - type: "insert_before", - line: hashlineRef(2, "b"), - text: "x", - }, - { - type: "insert_after", - line: hashlineRef(3, "c"), - text: "y", + type: "replace", + old_text: "a", + new_text: "b", }, ], }, ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("a\nx\nB\nC\ny\nd") - }, - }) + ), + ).rejects.toThrow("Missing file can only be created") + }, }) + }) - test("creates missing files from append/prepend operations", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - }) - const filepath = path.join(tmp.path, "created.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await edit.execute( + test("rejects directories", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "adir")) + }, + }) + const filepath = path.join(tmp.path, "adir") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + await expect( + edit.execute( { filePath: filepath, edits: [ - { - type: "prepend", - text: "start", - }, { type: "append", - text: "end", + text: "x", }, ], }, ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("start\nend") - }, - }) + ), + ).rejects.toThrow("directory") + }, }) + }) - test("rejects missing files for non-append/prepend edits", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - }) - const filepath = path.join(tmp.path, "missing.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await expect( - edit.execute( + test("tracks file diff statistics", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "line1\nline2\nline3", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + const result = await edit.execute( + { + filePath: filepath, + edits: [ { - filePath: filepath, - edits: [ - { - type: "replace", - old_text: "a", - new_text: "b", - }, - ], + type: "replace_lines", + start_line: hashlineRef(2, "line2"), + end_line: hashlineRef(2, "line2"), + text: ["new line a", "new line b"], }, - ctx, - ), - ).rejects.toThrow("Missing file can only be created") - }, - }) - }) - - test("supports delete and rename flows", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, + ], }, - }, - init: async (dir) => { - await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8") - await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8") - }, - }) - const source = path.join(tmp.path, "src.txt") - const target = path.join(tmp.path, "renamed.txt") - const doomed = path.join(tmp.path, "delete.txt") + ctx, + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - - FileTime.read(ctx.sessionID, source) - await edit.execute( - { - filePath: source, - rename: target, - edits: [ - { - type: "set_line", - line: hashlineRef(2, "b"), - text: "B", - }, - ], - }, - ctx, - ) - - expect(await fs.readFile(target, "utf-8")).toBe("a\nB") - await expect(fs.stat(source)).rejects.toThrow() - - FileTime.read(ctx.sessionID, doomed) - await edit.execute( - { - filePath: doomed, - delete: true, - edits: [], - }, - ctx, - ) - await expect(fs.stat(doomed)).rejects.toThrow() - }, - }) + expect(result.metadata.filediff).toBeDefined() + expect(result.metadata.filediff.file).toBe(filepath) + expect(result.metadata.filediff.additions).toBeGreaterThan(0) + }, }) + }) - test("rejects hashline payload when experimental mode is disabled", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: false, - }, - }, - init: async (dir) => { - await fs.writeFile(path.join(dir, "file.txt"), "a", "utf-8") - }, - }) - const filepath = path.join(tmp.path, "file.txt") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await EditTool.init() - await expect( - edit.execute( + test("emits change events for existing files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "a\nb", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + + const { Bus } = await import("../../src/bus") + const { File } = await import("../../src/file") + const { FileWatcher } = await import("../../src/file/watcher") + + const events: string[] = [] + const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited")) + const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) + + const edit = await EditTool.init() + await edit.execute( + { + filePath: filepath, + edits: [ { - filePath: filepath, - edits: [ - { - type: "append", - text: "b", - }, - ], + type: "set_line", + line: hashlineRef(2, "b"), + text: "B", }, - ctx, - ), - ).rejects.toThrow("Hashline edit payload is disabled") + ], + }, + ctx, + ) + + expect(events).toContain("edited") + expect(events).toContain("updated") + unsubEdited() + unsubUpdated() + }, + }) + }) + + test("applies hashline autocorrect prefixes through config", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_autocorrect: true, }, - }) + }, + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + await edit.execute( + { + filePath: filepath, + edits: [ + { + type: "set_line", + line: hashlineRef(2, "b"), + text: hashlineLine(2, "B"), + }, + ], + }, + ctx, + ) + + expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc") + }, + }) + }) + + test("supports delete and rename flows", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8") + await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8") + }, + }) + const source = path.join(tmp.path, "src.txt") + const target = path.join(tmp.path, "renamed.txt") + const doomed = path.join(tmp.path, "delete.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + + FileTime.read(ctx.sessionID, source) + await edit.execute( + { + filePath: source, + rename: target, + edits: [ + { + type: "set_line", + line: hashlineRef(2, "b"), + text: "B", + }, + ], + }, + ctx, + ) + + expect(await fs.readFile(target, "utf-8")).toBe("a\nB") + await expect(fs.stat(source)).rejects.toThrow() + + FileTime.read(ctx.sessionID, doomed) + await edit.execute( + { + filePath: doomed, + delete: true, + edits: [], + }, + ctx, + ) + + await expect(fs.stat(doomed)).rejects.toThrow() + }, + }) + }) + + test("serializes concurrent edits to the same file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "file.txt"), "0", "utf-8") + }, + }) + const filepath = path.join(tmp.path, "file.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read(ctx.sessionID, filepath) + const edit = await EditTool.init() + + const first = edit.execute( + { + filePath: filepath, + edits: [ + { + type: "set_line", + line: hashlineRef(1, "0"), + text: "1", + }, + ], + }, + ctx, + ) + + FileTime.read(ctx.sessionID, filepath) + const second = edit.execute( + { + filePath: filepath, + edits: [ + { + type: "set_line", + line: hashlineRef(1, "0"), + text: "2", + }, + ], + }, + ctx, + ) + + const results = await Promise.allSettled([first, second]) + expect(results.some((result) => result.status === "fulfilled")).toBe(true) + }, }) }) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 0070a7a5e2..87ddbe14a6 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -37,41 +37,8 @@ describe("tool.grep", () => { }) }) - test("hashline disabled keeps Line N format", async () => { + test("emits hashline anchors by default", async () => { await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: false, - }, - }, - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( - { - pattern: "alpha", - path: tmp.path, - }, - ctx, - ) - expect(result.output).toContain("Line 1: alpha") - }, - }) - }) - - test("hashline enabled emits N#ID anchor format", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, init: async (dir) => { await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta") }, @@ -117,10 +84,8 @@ describe("tool.grep", () => { }) test("handles CRLF line endings in output", async () => { - // This test verifies the regex split handles both \n and \r\n await using tmp = await tmpdir({ init: async (dir) => { - // Create a test file with content await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") }, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 5064c36c50..bc200f333c 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -463,29 +463,6 @@ describe("tool.read hashline output", () => { }, }) }) - - test("keeps legacy line prefixes when hashline mode is disabled", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: false, - }, - }, - init: async (dir) => { - await Bun.write(path.join(dir, "legacy.txt"), "foo\nbar") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const read = await ReadTool.init() - const result = await read.execute({ filePath: path.join(tmp.path, "legacy.txt") }, ctx) - expect(result.output).toContain("1: foo") - expect(result.output).toContain("2: bar") - }, - }) - }) }) describe("tool.read loaded instructions", () => { diff --git a/packages/opencode/test/tool/registry-hashline.test.ts b/packages/opencode/test/tool/registry-hashline.test.ts index d27d1e8ddf..3acb39744e 100644 --- a/packages/opencode/test/tool/registry-hashline.test.ts +++ b/packages/opencode/test/tool/registry-hashline.test.ts @@ -4,14 +4,8 @@ import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" describe("tool.registry hashline routing", () => { - test("hashline mode keeps edit and apply_patch for GPT models", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - }) + test("keeps edit and apply_patch for GPT models", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -28,14 +22,8 @@ describe("tool.registry hashline routing", () => { }) }) - test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - }) + test("keeps edit and removes apply_patch for non-GPT models", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -51,50 +39,4 @@ describe("tool.registry hashline routing", () => { }, }) }) - - test("keeps existing GPT apply_patch routing when hashline is explicitly disabled", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: false, - }, - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const tools = await ToolRegistry.tools({ - providerID: "openai", - modelID: "gpt-5", - }) - const ids = tools.map((tool) => tool.id) - expect(ids).toContain("apply_patch") - expect(ids).not.toContain("edit") - }, - }) - }) - - test("keeps existing non-GPT routing when hashline is explicitly disabled", async () => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: false, - }, - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const tools = await ToolRegistry.tools({ - providerID: "anthropic", - modelID: "claude-3-7-sonnet", - }) - const ids = tools.map((tool) => tool.id) - expect(ids).toContain("edit") - expect(ids).not.toContain("apply_patch") - }, - }) - }) }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index fbeb8bda28..f64cc7c0e9 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1751,11 +1751,11 @@ ToolRegistry.register({ mode="diff" before={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + contents: props.metadata?.filediff?.before ?? props.input.oldString, }} after={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + contents: props.metadata?.filediff?.after ?? props.input.newString, }} />