From 56decd79dbb4d941a09ed2c94958a11ddee31ba4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 22 Feb 2026 19:40:34 +0530 Subject: [PATCH 1/7] feat: add experimental hashline edit mode --- .opencode/opencode.jsonc | 4 + .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- packages/opencode/src/config/config.ts | 5 + packages/opencode/src/tool/edit.ts | 513 ++++++++++++--- packages/opencode/src/tool/edit.txt | 32 +- packages/opencode/src/tool/hashline.ts | 621 ++++++++++++++++++ packages/opencode/src/tool/read.ts | 9 +- packages/opencode/src/tool/read.txt | 5 +- packages/opencode/src/tool/registry.ts | 6 + packages/opencode/test/config/config.test.ts | 22 + packages/opencode/test/tool/edit.test.ts | 278 ++++++++ packages/opencode/test/tool/hashline.test.ts | 184 ++++++ packages/opencode/test/tool/read.test.ts | 45 ++ .../test/tool/registry-hashline.test.ts | 64 ++ 14 files changed, 1694 insertions(+), 98 deletions(-) create mode 100644 packages/opencode/src/tool/hashline.ts create mode 100644 packages/opencode/test/tool/hashline.test.ts create mode 100644 packages/opencode/test/tool/registry-hashline.test.ts diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 3497847a67..88eded47dc 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -6,6 +6,10 @@ }, }, "mcp": {}, + "experimental": { + "hashline_edit": true, + "hashline_autocorrect": true, + }, "tools": { "github-triage": false, "github-pr-search": 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 f5a7f6f6ca..bb2c4c24dd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2019,7 +2019,9 @@ function Edit(props: ToolProps) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit{" "} + {normalizePath(props.input.filePath!)}{" "} + {input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad0fd76c4..aad60764d2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1186,6 +1186,11 @@ 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"), + hashline_autocorrect: z + .boolean() + .optional() + .describe("Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts"), openTelemetry: z .boolean() .optional() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7a097d3fe1..577fbdceb0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -5,6 +5,7 @@ import z from "zod" import * as path from "path" +import * as fs from "fs/promises" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -17,72 +18,158 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" +import { + HashlineEdit, + applyHashlineEdits, + hashlineOnlyCreates, + parseHashlineContent, + serializeHashlineContent, +} from "./hashline" +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 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).", + }) + } + }) + +type LegacyEditParams = z.infer +type HashlineEditParams = z.infer +type EditParams = z.infer function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } -export const EditTool = Tool.define("edit", { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - async execute(params, ctx) { - if (!params.filePath) { - throw new Error("filePath is required") +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 => { + if (idx >= unique.length) return fn() + await FileTime.withLock(unique[idx], () => recurse(idx + 1)) + } + await recurse(0) +} + +function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff { + const filediff: Snapshot.FileDiff = { + file, + before, + after, + additions: 0, + deletions: 0, + } + for (const change of diffLines(before, after)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + return filediff +} + +async function diagnosticsOutput(filePath: string, output: string) { + await LSP.touchFile(filePath, true) + const diagnostics = await LSP.diagnostics() + const normalizedFilePath = Filesystem.normalizePath(filePath) + const issues = diagnostics[normalizedFilePath] ?? [] + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) { + return { + output, + diagnostics, } + } - if (params.oldString === params.newString) { - throw new Error("No changes to apply: oldString and newString are identical.") - } + 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` : "" + return { + output: + output + + `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n`, + diagnostics, + } +} - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - await assertExternalDirectory(ctx, filePath) +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.") + } - 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 filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + await assertExternalDirectory(ctx, filePath) - 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)), - ) + 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)], @@ -92,64 +179,312 @@ export const EditTool = Tool.define("edit", { diff, }, }) - - await Filesystem.write(filePath, contentNew) + await Filesystem.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) await Bus.publish(FileWatcher.Event.Updated, { file: filePath, - event: "change", + event: existed ? "change" : "add", }) - contentNew = await Filesystem.readText(filePath) - diff = trimDiff( - createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), - ) FileTime.read(ctx.sessionID, filePath) - }) - - const filediff: Snapshot.FileDiff = { - file: filePath, - before: contentOld, - after: contentNew, - additions: 0, - deletions: 0, - } - for (const change of diffLines(contentOld, contentNew)) { - if (change.added) filediff.additions += change.count || 0 - if (change.removed) filediff.deletions += change.count || 0 + return } - ctx.metadata({ + 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, - filediff, - diagnostics: {}, }, }) - let output = "Edit applied successfully." - await LSP.touchFile(filePath, true) - const diagnostics = await LSP.diagnostics() - const normalizedFilePath = Filesystem.normalizePath(filePath) - const issues = diagnostics[normalizedFilePath] ?? [] - 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(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + 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, ctx: Tool.Context, autocorrect: boolean) { + const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const targetPath = params.rename + ? path.isAbsolute(params.rename) + ? params.rename + : path.join(Instance.directory, params.rename) + : sourcePath + + await assertExternalDirectory(ctx, sourcePath) + if (params.rename) { + await assertExternalDirectory(ctx, targetPath) + } + + if (params.delete && params.edits.length > 0) { + throw new Error("delete=true cannot be combined with edits") + } + if (params.delete && params.rename) { + throw new Error("delete=true cannot be combined with rename") + } + + let diff = "" + let before = "" + let after = "" + let noop = 0 + let deleted = false + let changed = false + let diagnostics: Awaited> = {} + const paths = [sourcePath, targetPath] + await withLocks(paths, async () => { + const sourceStat = Filesystem.stat(sourcePath) + if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`) + const exists = Boolean(sourceStat) + + if (params.rename && !exists) { + throw new Error("rename requires an existing source file") } + if (params.delete) { + if (!exists) { + noop = 1 + return + } + await FileTime.assert(ctx.sessionID, sourcePath) + before = await Filesystem.readText(sourcePath) + after = "" + diff = trimDiff( + createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)), + ) + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, sourcePath)], + always: ["*"], + metadata: { + filepath: sourcePath, + diff, + }, + }) + await fs.rm(sourcePath, { force: true }) + await Bus.publish(File.Event.Edited, { + file: sourcePath, + }) + await Bus.publish(FileWatcher.Event.Updated, { + file: sourcePath, + event: "unlink", + }) + deleted = true + changed = true + return + } + + if (!exists && !hashlineOnlyCreates(params.edits)) { + throw new Error("Missing file can only be created with append/prepend hashline edits") + } + if (exists) { + await FileTime.assert(ctx.sessionID, sourcePath) + } + + const parsed = exists + ? parseHashlineContent(await Filesystem.readBytes(sourcePath)) + : { + bom: false, + eol: "\n", + trailing: false, + lines: [] as string[], + text: "", + raw: "", + } + + before = parsed.raw + const next = applyHashlineEdits({ + lines: parsed.lines, + trailing: parsed.trailing, + edits: params.edits, + autocorrect, + }) + const output = serializeHashlineContent({ + lines: next.lines, + trailing: next.trailing, + eol: parsed.eol, + bom: parsed.bom, + }) + after = output.text + + const noContentChange = before === after && sourcePath === targetPath + if (noContentChange) { + noop = 1 + diff = trimDiff( + createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)), + ) + return + } + + diff = trimDiff( + createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)), + ) + const patterns = [path.relative(Instance.worktree, sourcePath)] + if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath)) + await ctx.ask({ + permission: "edit", + patterns: Array.from(new Set(patterns)), + always: ["*"], + metadata: { + filepath: sourcePath, + diff, + }, + }) + + if (sourcePath === targetPath) { + await Filesystem.write(sourcePath, output.bytes) + await Bus.publish(File.Event.Edited, { + file: sourcePath, + }) + await Bus.publish(FileWatcher.Event.Updated, { + file: sourcePath, + event: exists ? "change" : "add", + }) + FileTime.read(ctx.sessionID, sourcePath) + changed = true + return + } + + const targetExists = await Filesystem.exists(targetPath) + await Filesystem.write(targetPath, output.bytes) + await fs.rm(sourcePath, { force: true }) + await Bus.publish(File.Event.Edited, { + file: sourcePath, + }) + await Bus.publish(File.Event.Edited, { + file: targetPath, + }) + await Bus.publish(FileWatcher.Event.Updated, { + file: sourcePath, + event: "unlink", + }) + await Bus.publish(FileWatcher.Event.Updated, { + file: targetPath, + event: targetExists ? "change" : "add", + }) + FileTime.read(ctx.sessionID, targetPath) + changed = true + }) + + const file = deleted ? sourcePath : targetPath + const filediff = createFileDiff(file, before, after) + ctx.metadata({ + metadata: { + diff, + filediff, + diagnostics, + edit_mode: HASHLINE_EDIT_MODE, + noop, + }, + }) + + if (!deleted && (changed || noop === 0)) { + const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.") + diagnostics = result.diagnostics return { metadata: { diagnostics, diff, filediff, + edit_mode: HASHLINE_EDIT_MODE, + noop, }, - title: `${path.relative(Instance.worktree, filePath)}`, - output, + title: `${path.relative(Instance.worktree, targetPath)}`, + output: result.output, } + } + + return { + metadata: { + diagnostics, + diff, + filediff, + edit_mode: HASHLINE_EDIT_MODE, + noop, + }, + title: `${path.relative(Instance.worktree, file)}`, + output: deleted ? "Edit applied successfully." : "No changes applied.", + } +} + +export const EditTool = Tool.define("edit", { + description: DESCRIPTION, + parameters: EditParams, + 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 !== true) { + throw new Error( + "Hashline edit payload is disabled. Enable experimental.hashline_edit to use hashline operations.", + ) + } + + const hashlineParams: HashlineEditParams = { + filePath: params.filePath, + edits: params.edits ?? [], + delete: params.delete, + rename: params.rename, + } + + return executeHashline( + hashlineParams, + ctx, + config.experimental?.hashline_autocorrect === true || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", + ) }, }) diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index 618fd5ad1e..b16af91a84 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -1,10 +1,30 @@ -Performs exact string replacements in files. +Performs file edits with two supported payload schemas. Usage: -- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. -- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- 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. -- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". -- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. -- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +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 (requires `experimental.hashline_edit: true`): +- `{ filePath, edits, delete?, rename? }` +- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call. +- Use strict anchor references from `Read` output: `LINE#ID`. +- Optional cleanup behavior can be enabled with `experimental.hashline_autocorrect: true`. +- When `Read` returns `LINE#ID:`, prefer hashline operations. +- Operations: + - `set_line { line, text }` + - `replace_lines { start_line, end_line, text }` + - `insert_after { line, text }` + - `insert_before { line, text }` + - `insert_between { after_line, before_line, text }` + - `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 must be retried with updated references. diff --git a/packages/opencode/src/tool/hashline.ts b/packages/opencode/src/tool/hashline.ts new file mode 100644 index 0000000000..ecc8e9b578 --- /dev/null +++ b/packages/opencode/src/tool/hashline.ts @@ -0,0 +1,621 @@ +// hashline autocorrect heuristics in this file are inspired by +// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode. + +import z from "zod" + +export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH" + +const HASHLINE_ID_LENGTH = 2 +const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`) +const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`) + +type TextValue = string | string[] + +export const HashlineText = z.union([z.string(), z.array(z.string())]) + +export const HashlineEdit = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("set_line"), + line: z.string(), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("replace_lines"), + start_line: z.string(), + end_line: z.string(), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("insert_after"), + line: z.string(), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("insert_before"), + line: z.string(), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("insert_between"), + after_line: z.string(), + before_line: z.string(), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("append"), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("prepend"), + text: HashlineText, + }) + .strict(), + z + .object({ + type: z.literal("replace"), + old_text: z.string(), + new_text: HashlineText, + all: z.boolean().optional(), + }) + .strict(), +]) + +export type HashlineEdit = z.infer + +export function hashlineID(lineNumber: number, line: string): string { + let normalized = line + if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1) + normalized = normalized.replace(/\s+/g, "") + void lineNumber + const hash = Bun.hash.xxHash32(normalized) & 0xff + const high = (hash >>> 4) & 0x0f + const low = hash & 0x0f + return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}` +} + +export function hashlineRef(lineNumber: number, line: string): string { + return `${lineNumber}#${hashlineID(lineNumber, line)}` +} + +export function hashlineLine(lineNumber: number, line: string): string { + return `${hashlineRef(lineNumber, line)}:${line}` +} + +export function parseHashlineRef(input: string, label: string) { + const match = input.match(HASHLINE_REF_REGEX) + if (!match) { + throw new Error(`${label} must contain a LINE#ID reference`) + } + + const line = Number.parseInt(match[1], 10) + if (!Number.isInteger(line) || line < 1) { + throw new Error(`${label} has invalid line number: ${match[1]}`) + } + + const id = match[2] + if (!HASHLINE_ID_REGEX.test(id)) { + throw new Error(`${label} has invalid hash id: ${id}`) + } + + return { + raw: `${line}#${id}`, + line, + id, + } +} + +function toLines(text: TextValue) { + if (Array.isArray(text)) return text + return text.split(/\r?\n/) +} + +const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/ +const DIFF_PLUS_RE = /^[+-](?![+-])/ + +function stripNewLinePrefixes(lines: string[]) { + let hashPrefixCount = 0 + let diffPlusCount = 0 + let nonEmpty = 0 + for (const line of lines) { + if (line.length === 0) continue + nonEmpty++ + if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++ + if (DIFF_PLUS_RE.test(line)) diffPlusCount++ + } + if (nonEmpty === 0) return lines + + const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5 + const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5 + if (!stripHash && !stripPlus) return lines + + return lines.map((line) => { + if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "") + if (stripPlus) return line.replace(DIFF_PLUS_RE, "") + return line + }) +} + +function equalsIgnoringWhitespace(a: string, b: string) { + if (a === b) return true + return a.replace(/\s+/g, "") === b.replace(/\s+/g, "") +} + +function leadingWhitespace(line: string) { + const match = line.match(/^\s*/) + if (!match) return "" + return match[0] +} + +function restoreLeadingIndent(template: string, line: string) { + if (line.length === 0) return line + const templateIndent = leadingWhitespace(template) + if (templateIndent.length === 0) return line + const indent = leadingWhitespace(line) + if (indent.length > 0) return line + return templateIndent + line +} + +function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) { + if (oldLines.length !== newLines.length) return newLines + let changed = false + const out = new Array(newLines.length) + for (let idx = 0; idx < newLines.length; idx++) { + const restored = restoreLeadingIndent(oldLines[idx], newLines[idx]) + out[idx] = restored + if (restored !== newLines[idx]) changed = true + } + if (changed) return out + return newLines +} + +function stripAllWhitespace(s: string) { + return s.replace(/\s+/g, "") +} + +function restoreOldWrappedLines(oldLines: string[], newLines: string[]) { + if (oldLines.length === 0 || newLines.length < 2) return newLines + + const canonToOld = new Map() + for (const line of oldLines) { + const canon = stripAllWhitespace(line) + const bucket = canonToOld.get(canon) + if (bucket) bucket.count++ + if (!bucket) canonToOld.set(canon, { line, count: 1 }) + } + + const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = [] + for (let start = 0; start < newLines.length; start++) { + for (let len = 2; len <= 10 && start + len <= newLines.length; len++) { + const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join("")) + const old = canonToOld.get(canonSpan) + if (old && old.count === 1 && canonSpan.length >= 6) { + candidates.push({ + start, + len, + replacement: old.line, + canon: canonSpan, + }) + } + } + } + if (candidates.length === 0) return newLines + + const canonCounts = new Map() + for (const candidate of candidates) { + canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1) + } + + const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1) + if (unique.length === 0) return newLines + + unique.sort((a, b) => b.start - a.start) + const out = [...newLines] + for (const candidate of unique) { + out.splice(candidate.start, candidate.len, candidate.replacement) + } + + return out +} + +function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) { + if (lines.length <= 1) return lines + if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1) + return lines +} + +function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) { + if (lines.length <= 1) return lines + if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1) + return lines +} + +function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) { + let out = lines + if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1) + if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1) + return out +} + +function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) { + const count = endLine - startLine + 1 + if (lines.length <= 1 || lines.length <= count) return lines + + let out = lines + const beforeIdx = startLine - 2 + if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) { + out = out.slice(1) + } + + const afterIdx = endLine + if ( + afterIdx < fileLines.length && + out.length > 0 && + equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx]) + ) { + out = out.slice(0, -1) + } + + return out +} + +function ensureText(text: TextValue, label: string) { + const value = Array.isArray(text) ? text.join("") : text + if (value.length > 0) return + throw new Error(`${label} must be non-empty`) +} + +function applyReplace(content: string, oldText: string, newText: TextValue, all = false) { + if (oldText.length === 0) throw new Error("replace.old_text must be non-empty") + + const next = toLines(newText).join("\n") + const first = content.indexOf(oldText) + if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`) + + if (all) return content.replaceAll(oldText, next) + + const last = content.lastIndexOf(oldText) + if (first !== last) { + throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.") + } + + return content.slice(0, first) + next + content.slice(first + oldText.length) +} + +function mismatchContext(lines: string[], line: number) { + if (lines.length === 0) return ">>> (file is empty)" + const start = Math.max(1, line - 1) + const end = Math.min(lines.length, line + 1) + return Array.from({ length: end - start + 1 }, (_, idx) => start + idx) + .map((num) => { + const marker = num === line ? ">>>" : " " + return `${marker} ${hashlineLine(num, lines[num - 1])}` + }) + .join("\n") +} + +function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) { + const seen = new Set() + const unique = mismatches.filter((m) => { + const key = `${m.expected}:${m.line}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + const body = unique + .map((m) => { + if (m.line < 1 || m.line > lines.length) { + return [ + `>>> expected ${m.expected}`, + `>>> current line ${m.line} is out of range (1-${Math.max(lines.length, 1)})`, + ].join("\n") + } + + const current = hashlineRef(m.line, lines[m.line - 1]) + return [`>>> expected ${m.expected}`, mismatchContext(lines, m.line), `>>> retry with ${current}`].join("\n") + }) + .join("\n\n") + + throw new Error( + [ + "Hashline edit rejected: file changed since last read. Re-read the file and retry with updated LINE#ID anchors.", + body, + ].join("\n\n"), + ) +} + +function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) { + const mismatches = refs + .filter((ref) => { + if (ref.line < 1 || ref.line > lines.length) return true + return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id + }) + .map((ref) => ({ expected: ref.raw, line: ref.line })) + + if (mismatches.length > 0) throwMismatch(lines, mismatches) +} + +function splitLines(text: string) { + if (text === "") { + return { + lines: [] as string[], + trailing: false, + } + } + + const trailing = text.endsWith("\n") + const lines = text.split(/\r?\n/) + if (trailing) lines.pop() + + return { lines, trailing } +} + +export function parseHashlineContent(bytes: Buffer) { + const raw = bytes.toString("utf8") + let text = raw + const bom = raw.startsWith("\uFEFF") + if (bom) text = raw.slice(1) + + const eol = text.includes("\r\n") ? "\r\n" : "\n" + const { lines, trailing } = splitLines(text) + + return { + bom, + eol, + trailing, + lines, + text, + raw, + } +} + +export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) { + let text = input.lines.join(input.eol) + if (input.trailing && input.lines.length > 0) text += input.eol + if (input.bom) text = `\uFEFF${text}` + return { + text, + bytes: Buffer.from(text, "utf8"), + } +} + +type Splice = { + start: number + del: number + text: string[] + order: number + kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend" + sortLine: number + precedence: number + startLine?: number + endLine?: number + anchorLine?: number + beforeLine?: number + afterLine?: number +} + +export function applyHashlineEdits(input: { + lines: string[] + trailing: boolean + edits: HashlineEdit[] + autocorrect?: boolean +}) { + const lines = [...input.lines] + const originalLines = [...input.lines] + let trailing = input.trailing + const refs: Array<{ raw: string; line: number; id: string }> = [] + const replaceOps: Array> = [] + const ops: Splice[] = [] + const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1" + const parseText = (text: TextValue) => { + const next = toLines(text) + if (!autocorrect) return next + return stripNewLinePrefixes(next) + } + + input.edits.forEach((edit, order) => { + if (edit.type === "replace") { + replaceOps.push(edit) + return + } + + if (edit.type === "append") { + ensureText(edit.text, "append.text") + ops.push({ + start: lines.length, + del: 0, + text: parseText(edit.text), + order, + kind: "append", + sortLine: lines.length + 1, + precedence: 1, + }) + return + } + + if (edit.type === "prepend") { + ensureText(edit.text, "prepend.text") + ops.push({ + start: 0, + del: 0, + text: parseText(edit.text), + order, + kind: "prepend", + sortLine: 0, + precedence: 2, + }) + return + } + + if (edit.type === "set_line") { + const line = parseHashlineRef(edit.line, "set_line.line") + refs.push(line) + ops.push({ + start: line.line - 1, + del: 1, + text: parseText(edit.text), + order, + kind: "set_line", + sortLine: line.line, + precedence: 0, + startLine: line.line, + endLine: line.line, + }) + return + } + + if (edit.type === "replace_lines") { + const start = parseHashlineRef(edit.start_line, "replace_lines.start_line") + const end = parseHashlineRef(edit.end_line, "replace_lines.end_line") + refs.push(start) + refs.push(end) + + if (start.line > end.line) { + throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line") + } + + ops.push({ + start: start.line - 1, + del: end.line - start.line + 1, + text: parseText(edit.text), + order, + kind: "replace_lines", + sortLine: end.line, + precedence: 0, + startLine: start.line, + endLine: end.line, + }) + return + } + + if (edit.type === "insert_after") { + const line = parseHashlineRef(edit.line, "insert_after.line") + ensureText(edit.text, "insert_after.text") + refs.push(line) + ops.push({ + start: line.line, + del: 0, + text: parseText(edit.text), + order, + kind: "insert_after", + sortLine: line.line, + precedence: 1, + anchorLine: line.line, + }) + return + } + + if (edit.type === "insert_before") { + const line = parseHashlineRef(edit.line, "insert_before.line") + ensureText(edit.text, "insert_before.text") + refs.push(line) + ops.push({ + start: line.line - 1, + del: 0, + text: parseText(edit.text), + order, + kind: "insert_before", + sortLine: line.line, + precedence: 2, + anchorLine: line.line, + }) + return + } + + const after = parseHashlineRef(edit.after_line, "insert_between.after_line") + const before = parseHashlineRef(edit.before_line, "insert_between.before_line") + ensureText(edit.text, "insert_between.text") + refs.push(after) + refs.push(before) + + if (after.line >= before.line) { + throw new Error("insert_between.after_line must be less than insert_between.before_line") + } + + ops.push({ + start: after.line, + del: 0, + text: parseText(edit.text), + order, + kind: "insert_between", + sortLine: before.line, + precedence: 3, + afterLine: after.line, + beforeLine: before.line, + }) + }) + + validateAnchors(lines, refs) + + const sorted = [...ops].sort((a, b) => { + if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine + if (a.precedence !== b.precedence) return a.precedence - b.precedence + return a.order - b.order + }) + + sorted.forEach((op) => { + if (op.start < 0 || op.start > lines.length) { + throw new Error(`line index ${op.start + 1} is out of range`) + } + + let text = op.text + if (autocorrect) { + if (op.kind === "set_line" || op.kind === "replace_lines") { + const start = op.startLine ?? op.start + 1 + const end = op.endLine ?? start + op.del - 1 + const old = originalLines.slice(start - 1, end) + text = stripRangeBoundaryEcho(originalLines, start, end, text) + text = restoreOldWrappedLines(old, text) + text = restoreIndentForPairedReplacement(old, text) + } + + if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) { + text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text) + } + + if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) { + text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text) + } + + if (op.kind === "insert_between" && op.afterLine && op.beforeLine) { + text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text) + } + } + + lines.splice(op.start, op.del, ...text) + }) + + if (replaceOps.length > 0) { + const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}` + const replaced = replaceOps.reduce( + (acc, op) => + applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all), + content, + ) + const split = splitLines(replaced) + lines.splice(0, lines.length, ...split.lines) + trailing = split.trailing + } + + return { + lines, + trailing, + } +} + +export function hashlineOnlyCreates(edits: HashlineEdit[]) { + return edits.every((edit) => edit.type === "append" || edit.type === "prepend") +} diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c981ac16e4..847c8a4c52 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,8 @@ 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 const MAX_LINE_LENGTH = 2000 @@ -156,6 +158,7 @@ export const ReadTool = Tool.define("read", { const offset = params.offset ?? 1 const start = offset - 1 const raw: string[] = [] + const full: string[] = [] let bytes = 0 let lines = 0 let truncatedByBytes = false @@ -179,6 +182,7 @@ export const ReadTool = Tool.define("read", { } raw.push(line) + full.push(text) bytes += size } } finally { @@ -190,8 +194,11 @@ 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 === true const content = raw.map((line, index) => { - return `${index + offset}: ${line}` + const lineNumber = index + offset + if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}` + return `${lineNumber}: ${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 368174cc8d..cfa401f532 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -7,7 +7,10 @@ Usage: - To read later sections, call this tool again with a larger offset. - Use the grep tool to find specific content in large files or files with long lines. - If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern. -- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories. +- Contents are returned with a line prefix. +- Default format: `: ` (example: `1: foo`). +- When `experimental.hashline_edit` is enabled: `LINE#ID:` (example: `1#AB:foo`). Use these anchors for hashline edits. +- 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. - Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ef0e78ffa8..7e0481b6d7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -133,6 +133,7 @@ export namespace ToolRegistry { }, agent?: Agent.Info, ) { + const config = await Config.get() const tools = await all() const result = await Promise.all( tools @@ -142,6 +143,11 @@ export namespace ToolRegistry { return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } + if (config.experimental?.hashline_edit === true) { + if (t.id === "apply_patch") return false + return true + } + // use apply tool in same format as codex const usePatch = model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 56773570af..062881565f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -56,6 +56,28 @@ test("loads JSON config file", async () => { }) }) +test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + experimental: { + hashline_edit: true, + hashline_autocorrect: true, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.hashline_edit).toBe(true) + expect(config.experimental?.hashline_autocorrect).toBe(true) + }, + }) +}) + test("loads JSONC config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index c3cf0404b9..82159b3b1c 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" +import { hashlineLine, hashlineRef } from "../../src/tool/hashline" const ctx = { sessionID: "test-edit-session", @@ -493,4 +494,281 @@ describe("tool.edit", () => { }) }) }) + + 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( + { + filePath: filepath, + edits: [ + { + type: "set_line", + line: hashlineRef(2, "b"), + text: "B", + }, + ], + }, + ctx, + ) + + const content = await fs.readFile(filepath, "utf-8") + expect(content).toBe("a\nB\nc") + expect(result.metadata.edit_mode).toBe("hashline") + }, + }) + }) + + 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( + { + filePath: filepath, + edits: [ + { + type: "set_line", + line: hashlineRef(2, "b"), + text: hashlineLine(2, "B"), + }, + ], + }, + ctx, + ) + + const content = await fs.readFile(filepath, "utf-8") + expect(content).toBe("a\nB\nc") + }, + }) + }) + + 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( + { + 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, + ) + + const content = await fs.readFile(filepath, "utf-8") + expect(content).toBe("a\nx\nB\nC\ny\nd") + }, + }) + }) + + 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( + { + filePath: filepath, + edits: [ + { + type: "prepend", + text: "start", + }, + { + type: "append", + text: "end", + }, + ], + }, + ctx, + ) + + const content = await fs.readFile(filepath, "utf-8") + expect(content).toBe("start\nend") + }, + }) + }) + + 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( + { + filePath: filepath, + edits: [ + { + type: "replace", + old_text: "a", + new_text: "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") + + 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("rejects hashline payload when experimental mode is disabled", async () => { + await using tmp = await tmpdir({ + 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( + { + filePath: filepath, + edits: [ + { + type: "append", + text: "b", + }, + ], + }, + ctx, + ), + ).rejects.toThrow("Hashline edit payload is disabled") + }, + }) + }) + }) }) diff --git a/packages/opencode/test/tool/hashline.test.ts b/packages/opencode/test/tool/hashline.test.ts new file mode 100644 index 0000000000..cb2e71a2cd --- /dev/null +++ b/packages/opencode/test/tool/hashline.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline" + +function swapID(ref: string) { + const [line, id] = ref.split("#") + const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}` + return `${line}#${next}` +} + +describe("tool.hashline", () => { + test("hash computation is stable and 2-char alphabet encoded", () => { + const a = hashlineID(1, " const x = 1") + const b = hashlineID(1, "constx=1") + const c = hashlineID(99, "constx=1") + expect(a).toBe(b) + expect(a).toBe(c) + expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/) + }) + + test("autocorrect strips copied hashline prefixes when enabled", () => { + const old = Bun.env.OPENCODE_HL_AUTOCORRECT + Bun.env.OPENCODE_HL_AUTOCORRECT = "1" + try { + const result = applyHashlineEdits({ + lines: ["a"], + trailing: false, + edits: [ + { + type: "set_line", + line: hashlineRef(1, "a"), + text: hashlineLine(1, "a"), + }, + ], + }) + expect(result.lines).toEqual(["a"]) + } finally { + if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT + else Bun.env.OPENCODE_HL_AUTOCORRECT = old + } + }) + + test("parses strict LINE#ID references with tolerant extraction", () => { + const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line") + expect(ref.line).toBe(12) + expect(ref.id).toBe("ZP") + expect(ref.raw).toBe("12#ZP") + + expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID") + }) + + test("aggregates mismatch errors with >>> context and retry refs", () => { + const lines = ["alpha", "beta", "gamma"] + const wrong = swapID(hashlineRef(2, lines[1])) + + expect(() => + applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "set_line", + line: wrong, + text: "BETA", + }, + ], + }), + ).toThrow("changed since last read") + + expect(() => + applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "set_line", + line: wrong, + text: "BETA", + }, + ], + }), + ).toThrow(">>> retry with") + }) + + test("applies batched line edits bottom-up for stable results", () => { + const lines = ["a", "b", "c", "d"] + const one = hashlineRef(1, lines[0]) + const two = hashlineRef(2, lines[1]) + const three = hashlineRef(3, lines[2]) + const four = hashlineRef(4, lines[3]) + + const result = applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "replace_lines", + start_line: two, + end_line: three, + text: ["B", "C"], + }, + { + type: "insert_after", + line: one, + text: "A1", + }, + { + type: "set_line", + line: four, + text: "D", + }, + ], + }) + + expect(result.lines).toEqual(["a", "A1", "B", "C", "D"]) + }) + + test("orders append and prepend deterministically on empty files", () => { + const result = applyHashlineEdits({ + lines: [], + trailing: false, + edits: [ + { + type: "append", + text: "end", + }, + { + type: "prepend", + text: "start", + }, + ], + }) + + expect(result.lines).toEqual(["start", "end"]) + }) + + test("validates ranges, between constraints, and non-empty insert text", () => { + const lines = ["a", "b", "c"] + const one = hashlineRef(1, lines[0]) + const two = hashlineRef(2, lines[1]) + + expect(() => + applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "replace_lines", + start_line: two, + end_line: one, + text: "x", + }, + ], + }), + ).toThrow("start_line") + + expect(() => + applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "insert_between", + after_line: two, + before_line: one, + text: "x", + }, + ], + }), + ).toThrow("insert_between.after_line") + + expect(() => + applyHashlineEdits({ + lines, + trailing: false, + edits: [ + { + type: "append", + text: "", + }, + ], + }), + ).toThrow("append.text") + }) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 88228f14e8..6af64fd5c8 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" +import { hashlineLine } from "../../src/tool/hashline" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -443,6 +444,50 @@ root_type Monster;` }) }) +describe("tool.read hashline output", () => { + test("returns LINE#ID prefixes when hashline mode is enabled", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + init: async (dir) => { + await Bun.write(path.join(dir, "hashline.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, "hashline.txt") }, ctx) + expect(result.output).toContain(hashlineLine(1, "foo")) + expect(result.output).toContain(hashlineLine(2, "bar")) + expect(result.output).not.toContain("1: foo") + }, + }) + }) + + test("keeps legacy line prefixes when hashline mode is disabled", async () => { + await using tmp = await tmpdir({ + 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", () => { test("loads AGENTS.md from parent directory and includes in metadata", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/registry-hashline.test.ts b/packages/opencode/test/tool/registry-hashline.test.ts new file mode 100644 index 0000000000..e7cf8a334b --- /dev/null +++ b/packages/opencode/test/tool/registry-hashline.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { ToolRegistry } from "../../src/tool/registry" + +describe("tool.registry hashline routing", () => { + test.each([ + { providerID: "openai", modelID: "gpt-5" }, + { providerID: "anthropic", modelID: "claude-3-7-sonnet" }, + ])("disables apply_patch and enables edit when experimental hashline is on (%o)", async (model) => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tools = await ToolRegistry.tools(model) + const ids = tools.map((tool) => tool.id) + expect(ids).toContain("edit") + expect(ids).toContain("write") + expect(ids).not.toContain("apply_patch") + }, + }) + }) + + test("keeps existing GPT apply_patch routing when experimental hashline is off", async () => { + await using tmp = await tmpdir() + + 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 experimental hashline is off", async () => { + await using tmp = await tmpdir() + + 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") + }, + }) + }) +}) From ce5c827a6ef134469bb8425071cae45af74bcadb Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 22 Feb 2026 19:46:59 +0530 Subject: [PATCH 2/7] chore: remove local opencode config flags --- .opencode/opencode.jsonc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 88eded47dc..3497847a67 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -6,10 +6,6 @@ }, }, "mcp": {}, - "experimental": { - "hashline_edit": true, - "hashline_autocorrect": true, - }, "tools": { "github-triage": false, "github-pr-search": false, From 9ef803be82a3940f97815be6a2d7bd9792d52163 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 22 Feb 2026 22:31:33 +0530 Subject: [PATCH 3/7] feat: enable hashline by default --- packages/opencode/src/config/config.ts | 9 ++++-- packages/opencode/src/tool/edit.ts | 6 ++-- packages/opencode/src/tool/edit.txt | 5 ++-- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/read.txt | 4 +-- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 5 ++++ packages/opencode/test/tool/read.test.ts | 20 ++++++------- .../test/tool/registry-hashline.test.ts | 30 +++++++++++-------- 9 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad60764d2..b312503f5a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1186,11 +1186,16 @@ 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"), + hashline_edit: z + .boolean() + .optional() + .describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"), hashline_autocorrect: z .boolean() .optional() - .describe("Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts"), + .describe( + "Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)", + ), openTelemetry: z .boolean() .optional() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 577fbdceb0..a97189a8b6 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -467,9 +467,9 @@ export const EditTool = Tool.define("edit", { } const config = await Config.get() - if (config.experimental?.hashline_edit !== true) { + if (config.experimental?.hashline_edit === false) { throw new Error( - "Hashline edit payload is disabled. Enable experimental.hashline_edit to use hashline operations.", + "Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.", ) } @@ -483,7 +483,7 @@ export const EditTool = Tool.define("edit", { return executeHashline( hashlineParams, ctx, - config.experimental?.hashline_autocorrect === true || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", + config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", ) }, }) diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index b16af91a84..eaee266337 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -12,11 +12,12 @@ Legacy schema (always supported): - The edit fails if `oldString` matches multiple locations and `replaceAll` is not true. - Use `replaceAll: true` for global replacements. -Hashline schema (requires `experimental.hashline_edit: true`): +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. - Use strict anchor references from `Read` output: `LINE#ID`. -- Optional cleanup behavior can be enabled with `experimental.hashline_autocorrect: true`. +- 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`. - When `Read` returns `LINE#ID:`, prefer hashline operations. - Operations: - `set_line { line, text }` diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 847c8a4c52..544bfa2429 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -194,7 +194,7 @@ 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 === true + 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}` diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index cfa401f532..921b2384ef 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -8,8 +8,8 @@ Usage: - Use the grep tool to find specific content in large files or files with long lines. - 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: `: ` (example: `1: foo`). -- When `experimental.hashline_edit` is enabled: `LINE#ID:` (example: `1#AB:foo`). Use these anchors for hashline edits. +- 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 7e0481b6d7..94fb4b0a0d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -143,7 +143,7 @@ export namespace ToolRegistry { return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } - if (config.experimental?.hashline_edit === true) { + if (config.experimental?.hashline_edit !== false) { if (t.id === "apply_patch") return false return true } diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 82159b3b1c..cf20c11aee 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -744,6 +744,11 @@ describe("tool.edit", () => { 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") }, diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 6af64fd5c8..f9e8a355ac 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -270,10 +270,10 @@ describe("tool.read truncation", () => { fn: async () => { const read = await ReadTool.init() const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx) - expect(result.output).toContain("10: line10") - expect(result.output).toContain("14: line14") - expect(result.output).not.toContain("9: line10") - expect(result.output).not.toContain("15: line15") + expect(result.output).toContain(hashlineLine(10, "line10")) + expect(result.output).toContain(hashlineLine(14, "line14")) + expect(result.output).not.toContain(hashlineLine(9, "line9")) + expect(result.output).not.toContain(hashlineLine(15, "line15")) expect(result.output).toContain("line10") expect(result.output).toContain("line14") expect(result.output).not.toContain("line0") @@ -445,13 +445,8 @@ root_type Monster;` }) describe("tool.read hashline output", () => { - test("returns LINE#ID prefixes when hashline mode is enabled", async () => { + test("returns LINE#ID prefixes by default", async () => { await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, init: async (dir) => { await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar") }, @@ -471,6 +466,11 @@ 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") }, diff --git a/packages/opencode/test/tool/registry-hashline.test.ts b/packages/opencode/test/tool/registry-hashline.test.ts index e7cf8a334b..bd620702f8 100644 --- a/packages/opencode/test/tool/registry-hashline.test.ts +++ b/packages/opencode/test/tool/registry-hashline.test.ts @@ -7,14 +7,8 @@ describe("tool.registry hashline routing", () => { test.each([ { providerID: "openai", modelID: "gpt-5" }, { providerID: "anthropic", modelID: "claude-3-7-sonnet" }, - ])("disables apply_patch and enables edit when experimental hashline is on (%o)", async (model) => { - await using tmp = await tmpdir({ - config: { - experimental: { - hashline_edit: true, - }, - }, - }) + ])("disables apply_patch and enables edit by default (%o)", async (model) => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -28,8 +22,14 @@ describe("tool.registry hashline routing", () => { }) }) - test("keeps existing GPT apply_patch routing when experimental hashline is off", async () => { - await using tmp = await tmpdir() + 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, @@ -45,8 +45,14 @@ describe("tool.registry hashline routing", () => { }) }) - test("keeps existing non-GPT routing when experimental hashline is off", async () => { - await using tmp = await tmpdir() + 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, From a6f802d7fe33bba6b6ffa49d0f9bc26616d4f7b3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 22 Feb 2026 22:39:03 +0530 Subject: [PATCH 4/7] fix: align codex prompt with edit routing --- packages/opencode/src/session/prompt/codex_header.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex_header.txt index d595cadb0e..96f9d7e2e4 100644 --- a/packages/opencode/src/session/prompt/codex_header.txt +++ b/packages/opencode/src/session/prompt/codex_header.txt @@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Editing constraints - Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - Only add comments if they are necessary to make a non-obvious block easier to understand. -- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). +- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage - Prefer specialized tools over shell for file operations: From 3026a005b6dba60388b5d2acae478d32ffaad9c8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 23 Feb 2026 10:05:12 +0530 Subject: [PATCH 5/7] test: reorder hashline config test for beta merge --- packages/opencode/test/config/config.test.ts | 44 ++++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 062881565f..e37024e5c6 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -56,28 +56,6 @@ test("loads JSON config file", async () => { }) }) -test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig(dir, { - $schema: "https://opencode.ai/config.json", - experimental: { - hashline_edit: true, - hashline_autocorrect: true, - }, - }) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.experimental?.hashline_edit).toBe(true) - expect(config.experimental?.hashline_autocorrect).toBe(true) - }, - }) -}) - test("loads JSONC config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -102,6 +80,28 @@ test("loads JSONC config file", async () => { }) }) +test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + experimental: { + hashline_edit: true, + hashline_autocorrect: true, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.hashline_edit).toBe(true) + expect(config.experimental?.hashline_autocorrect).toBe(true) + }, + }) +}) + test("merges multiple config files with correct precedence", async () => { await using tmp = await tmpdir({ init: async (dir) => { From aec95c4d106dedd218ac7a39c04334c8c599b9f8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 27 Feb 2026 09:45:06 +0530 Subject: [PATCH 6/7] stabilize hashline routing and anchors --- packages/opencode/src/tool/edit.ts | 9 +- packages/opencode/src/tool/edit.txt | 4 +- packages/opencode/src/tool/grep.ts | 9 +- packages/opencode/src/tool/grep.txt | 3 +- packages/opencode/src/tool/hashline.ts | 111 +++++++++++------- packages/opencode/src/tool/registry.ts | 9 +- packages/opencode/test/tool/grep.test.ts | 56 +++++++++ packages/opencode/test/tool/hashline.test.ts | 58 ++++++--- .../test/tool/registry-hashline.test.ts | 42 ++++++- 9 files changed, 228 insertions(+), 73 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a97189a8b6..455fc2f075 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -251,7 +251,12 @@ async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) { } } -async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, autocorrect: boolean) { +async function executeHashline( + params: HashlineEditParams, + ctx: Tool.Context, + autocorrect: boolean, + aggressiveAutocorrect: boolean, +) { const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) const targetPath = params.rename ? path.isAbsolute(params.rename) @@ -345,6 +350,7 @@ async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, au trailing: parsed.trailing, edits: params.edits, autocorrect, + aggressiveAutocorrect, }) const output = serializeHashlineContent({ lines: next.lines, @@ -484,6 +490,7 @@ export const EditTool = Tool.define("edit", { hashlineParams, ctx, config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", + Bun.env.OPENCODE_HL_AUTOCORRECT === "1", ) }, }) diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index eaee266337..4b729cfc83 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -18,6 +18,7 @@ Hashline schema (default behavior): - 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. - Operations: - `set_line { line, text }` @@ -28,4 +29,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 must be retried with updated references. +- 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. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 82e7ac1667..9fb59ab8fd 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -9,6 +9,8 @@ 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 @@ -116,6 +118,7 @@ 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 = "" @@ -129,7 +132,11 @@ export const GrepTool = Tool.define("grep", { } const truncatedLineText = match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + if (useHashline) { + outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`) + } else { + outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + } } if (truncated) { diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695a..0874117f15 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -2,7 +2,8 @@ - Searches file contents using regular expressions - 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 and line numbers with at least one match sorted by modification time +- 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 - 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/hashline.ts b/packages/opencode/src/tool/hashline.ts index ecc8e9b578..ef4606740b 100644 --- a/packages/opencode/src/tool/hashline.ts +++ b/packages/opencode/src/tool/hashline.ts @@ -8,6 +8,7 @@ export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH" const HASHLINE_ID_LENGTH = 2 const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`) const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`) +const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/ type TextValue = string | string[] @@ -75,12 +76,18 @@ export const HashlineEdit = z.discriminatedUnion("type", [ export type HashlineEdit = z.infer +function isLowSignalContent(normalized: string) { + if (normalized.length === 0) return true + if (normalized.length <= 2) return true + return LOW_SIGNAL_CONTENT_RE.test(normalized) +} + export function hashlineID(lineNumber: number, line: string): string { let normalized = line if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1) normalized = normalized.replace(/\s+/g, "") - void lineNumber - const hash = Bun.hash.xxHash32(normalized) & 0xff + const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized + const hash = Bun.hash.xxHash32(seed) & 0xff const high = (hash >>> 4) & 0x0f const low = hash & 0x0f return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}` @@ -123,29 +130,29 @@ function toLines(text: TextValue) { } const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/ -const DIFF_PLUS_RE = /^[+-](?![+-])/ +const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/ + +function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) { + const nonEmpty = lines.filter((line) => line.length > 0) + if (nonEmpty.length === 0) return lines + + const matches = nonEmpty.filter(test).length + if (matches === 0 || matches < nonEmpty.length * 0.5) return lines + + return lines.map(rewrite) +} function stripNewLinePrefixes(lines: string[]) { - let hashPrefixCount = 0 - let diffPlusCount = 0 - let nonEmpty = 0 - for (const line of lines) { - if (line.length === 0) continue - nonEmpty++ - if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++ - if (DIFF_PLUS_RE.test(line)) diffPlusCount++ - } - if (nonEmpty === 0) return lines - - const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5 - const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5 - if (!stripHash && !stripPlus) return lines - - return lines.map((line) => { - if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "") - if (stripPlus) return line.replace(DIFF_PLUS_RE, "") - return line - }) + const stripped = stripByMajority( + lines, + (line) => HASHLINE_PREFIX_RE.test(line), + (line) => line.replace(HASHLINE_PREFIX_RE, ""), + ) + return stripByMajority( + stripped, + (line) => WRAPPER_PREFIX_RE.test(line), + (line) => line.replace(WRAPPER_PREFIX_RE, ""), + ) } function equalsIgnoringWhitespace(a: string, b: string) { @@ -306,35 +313,51 @@ function mismatchContext(lines: string[], line: number) { .join("\n") } +function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) { + if (mismatch.line < 1 || mismatch.line > lines.length) { + return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})` + } + return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}` +} + function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) { const seen = new Set() - const unique = mismatches.filter((m) => { - const key = `${m.expected}:${m.line}` + const unique = mismatches.filter((mismatch) => { + const key = `${mismatch.expected}:${mismatch.line}` if (seen.has(key)) return false seen.add(key) return true }) - const body = unique - .map((m) => { - if (m.line < 1 || m.line > lines.length) { + const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch)) + const hidden = unique.length - preview.length + const count = unique.length + const linesOut = [ + `Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`, + ...preview, + ...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []), + ] + + if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") { + const body = unique + .map((mismatch) => { + if (mismatch.line < 1 || mismatch.line > lines.length) { + return [ + `>>> expected ${mismatch.expected}`, + `>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`, + ].join("\n") + } return [ - `>>> expected ${m.expected}`, - `>>> current line ${m.line} is out of range (1-${Math.max(lines.length, 1)})`, + `>>> expected ${mismatch.expected}`, + mismatchContext(lines, mismatch.line), + `>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`, ].join("\n") - } + }) + .join("\n\n") + linesOut.push("", body) + } - const current = hashlineRef(m.line, lines[m.line - 1]) - return [`>>> expected ${m.expected}`, mismatchContext(lines, m.line), `>>> retry with ${current}`].join("\n") - }) - .join("\n\n") - - throw new Error( - [ - "Hashline edit rejected: file changed since last read. Re-read the file and retry with updated LINE#ID anchors.", - body, - ].join("\n\n"), - ) + throw new Error(linesOut.join("\n")) } function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) { @@ -412,6 +435,7 @@ export function applyHashlineEdits(input: { trailing: boolean edits: HashlineEdit[] autocorrect?: boolean + aggressiveAutocorrect?: boolean }) { const lines = [...input.lines] const originalLines = [...input.lines] @@ -420,6 +444,7 @@ export function applyHashlineEdits(input: { const replaceOps: Array> = [] const ops: Splice[] = [] const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1" + const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1" const parseText = (text: TextValue) => { const next = toLines(text) if (!autocorrect) return next @@ -572,7 +597,7 @@ export function applyHashlineEdits(input: { } let text = op.text - if (autocorrect) { + if (autocorrect && aggressiveAutocorrect) { if (op.kind === "set_line" || op.kind === "replace_lines") { const start = op.startLine ?? op.start + 1 const end = op.endLine ?? start + op.del - 1 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a20c72da4e..176d329614 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -137,6 +137,9 @@ export namespace ToolRegistry { ) { 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( tools .filter((t) => { @@ -145,14 +148,12 @@ export namespace ToolRegistry { return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } - if (config.experimental?.hashline_edit !== false) { - if (t.id === "apply_patch") return false + if (hashline) { + if (t.id === "apply_patch") return usePatch return true } // use apply tool in same format as codex - const usePatch = - model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") if (t.id === "apply_patch") return usePatch if (t.id === "edit" || t.id === "write") return !usePatch diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index e774580df6..0070a7a5e2 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -37,6 +37,62 @@ describe("tool.grep", () => { }) }) + test("hashline disabled keeps Line N format", 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") + }, + }) + + 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).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/) + }, + }) + }) + test("no matches returns correct output", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/hashline.test.ts b/packages/opencode/test/tool/hashline.test.ts index cb2e71a2cd..7a2c358292 100644 --- a/packages/opencode/test/tool/hashline.test.ts +++ b/packages/opencode/test/tool/hashline.test.ts @@ -7,6 +7,15 @@ function swapID(ref: string) { return `${line}#${next}` } +function errorMessage(run: () => void) { + try { + run() + return "" + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + describe("tool.hashline", () => { test("hash computation is stable and 2-char alphabet encoded", () => { const a = hashlineID(1, " const x = 1") @@ -17,6 +26,15 @@ describe("tool.hashline", () => { expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/) }) + test("low-signal lines mix line index into hash id", () => { + const a = hashlineID(1, "") + const b = hashlineID(2, "") + const c = hashlineID(1, "{}") + const d = hashlineID(2, "{}") + expect(a).not.toBe(b) + expect(c).not.toBe(d) + }) + test("autocorrect strips copied hashline prefixes when enabled", () => { const old = Bun.env.OPENCODE_HL_AUTOCORRECT Bun.env.OPENCODE_HL_AUTOCORRECT = "1" @@ -39,6 +57,23 @@ describe("tool.hashline", () => { } }) + test("default autocorrect does not rewrite non-prefix content", () => { + const result = applyHashlineEdits({ + lines: ["a"], + trailing: false, + edits: [ + { + type: "set_line", + line: hashlineRef(1, "a"), + text: "+a", + }, + ], + autocorrect: true, + aggressiveAutocorrect: false, + }) + expect(result.lines).toEqual(["+a"]) + }) + test("parses strict LINE#ID references with tolerant extraction", () => { const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line") expect(ref.line).toBe(12) @@ -48,11 +83,11 @@ describe("tool.hashline", () => { expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID") }) - test("aggregates mismatch errors with >>> context and retry refs", () => { + test("reports compact mismatch errors with retry anchors", () => { const lines = ["alpha", "beta", "gamma"] const wrong = swapID(hashlineRef(2, lines[1])) - expect(() => + const message = errorMessage(() => applyHashlineEdits({ lines, trailing: false, @@ -64,21 +99,12 @@ describe("tool.hashline", () => { }, ], }), - ).toThrow("changed since last read") + ) - expect(() => - applyHashlineEdits({ - lines, - trailing: false, - edits: [ - { - type: "set_line", - line: wrong, - text: "BETA", - }, - ], - }), - ).toThrow(">>> retry with") + expect(message).toContain("anchor mismatch") + expect(message).toContain("retry with") + expect(message).not.toContain(">>>") + expect(message.length).toBeLessThan(260) }) test("applies batched line edits bottom-up for stable results", () => { diff --git a/packages/opencode/test/tool/registry-hashline.test.ts b/packages/opencode/test/tool/registry-hashline.test.ts index bd620702f8..d27d1e8ddf 100644 --- a/packages/opencode/test/tool/registry-hashline.test.ts +++ b/packages/opencode/test/tool/registry-hashline.test.ts @@ -4,16 +4,46 @@ import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" describe("tool.registry hashline routing", () => { - test.each([ - { providerID: "openai", modelID: "gpt-5" }, - { providerID: "anthropic", modelID: "claude-3-7-sonnet" }, - ])("disables apply_patch and enables edit by default (%o)", async (model) => { - await using tmp = await tmpdir() + test("hashline mode keeps edit and apply_patch for GPT models", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const tools = await ToolRegistry.tools(model) + const tools = await ToolRegistry.tools({ + providerID: "openai", + modelID: "gpt-5", + }) + const ids = tools.map((tool) => tool.id) + expect(ids).toContain("edit") + expect(ids).toContain("write") + expect(ids).toContain("apply_patch") + }, + }) + }) + + test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + }) + + 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).toContain("write") From 5a7f03a384d07f4b20044cf53d5e828f212ab13b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 6 Mar 2026 11:10:00 +0530 Subject: [PATCH 7/7] refactor: remove legacy edit mode --- packages/opencode/src/acp/agent.ts | 78 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- packages/opencode/src/config/config.ts | 4 - packages/opencode/src/tool/edit.ts | 685 +--------- packages/opencode/src/tool/edit.txt | 18 +- packages/opencode/src/tool/grep.ts | 8 +- packages/opencode/src/tool/grep.txt | 2 +- packages/opencode/src/tool/multiedit.ts | 36 +- packages/opencode/src/tool/read.ts | 5 +- packages/opencode/src/tool/read.txt | 1 - packages/opencode/src/tool/registry.ts | 9 - packages/opencode/test/config/config.test.ts | 24 +- packages/opencode/test/tool/edit.test.ts | 1125 +++++++---------- packages/opencode/test/tool/grep.test.ts | 37 +- packages/opencode/test/tool/read.test.ts | 23 - .../test/tool/registry-hashline.test.ts | 66 +- packages/ui/src/components/message-part.tsx | 4 +- 17 files changed, 575 insertions(+), 1554 deletions(-) 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, }} />