Browse Source

refactor: remove legacy edit mode

Shoubhit Dash 1 month ago
parent
commit
5a7f03a384

+ 48 - 30
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<string, any>, metadata: unknown) {
+    const meta = typeof metadata === "object" && metadata ? (metadata as Record<string, any>) : undefined
+    const filediff =
+      meta && typeof meta["filediff"] === "object" && meta["filediff"]
+        ? (meta["filediff"] as Record<string, any>)
+        : 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) {

+ 1 - 3
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -2065,9 +2065,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
       </Match>
       <Match when={true}>
         <InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
-          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"])}
         </InlineTool>
       </Match>
     </Switch>

+ 0 - 4
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()

+ 29 - 656
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,82 +23,41 @@ 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<typeof LegacyEditParams>
-type HashlineEditParams = z.infer<typeof HashlineEditParams>
 type EditParams = z.infer<typeof EditParams>
 
-function normalizeLineEndings(text: string): string {
-  return text.replaceAll("\r\n", "\n")
+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 isLegacyParams(params: EditParams): params is LegacyEditParams {
-  return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
+function normalizeLineEndings(text: string): string {
+  return text.replaceAll("\r\n", "\n")
 }
 
 async function withLocks(paths: string[], fn: () => Promise<void>) {
@@ -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<ReturnType<typeof LSP.diagnostics>> = {}
-  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<string, void, unknown>
-
-// 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.")
-}

+ 4 - 14
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:<content>`, 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.

+ 1 - 7
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) {

+ 1 - 1
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:<content>` when hashline mode is enabled
+- Output format uses hashline anchors: `N#ID:<content>`
 - 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

+ 26 - 10
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 {

+ 1 - 4
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")
 

+ 0 - 1
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:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
-- Legacy format can be restored with `experimental.hashline_edit: false`: `<line>: <content>` (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.

+ 0 - 9
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
         })

+ 22 - 2
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<string, unknown>)?.hashline_edit).toBeUndefined()
     },
   })
 })

+ 408 - 663
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")
+      },
     })
-
-    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(
+    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,
-              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")
+      },
     })
-
-    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(
+    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,
-                oldString: "old",
-                newString: "new",
+                type: "set_line",
+                line: hashlineRef(2, "b"),
+                text: "B",
               },
-              ctx,
-            ),
-          ).rejects.toThrow("not found")
-        },
-      })
+            ],
+          },
+          ctx,
+        )
+
+        expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
+        expect(result.metadata.edit_mode).toBe("hashline")
+      },
     })
+  })
 
-    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(
+  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: [
               {
-                filePath: filepath,
-                oldString: "same",
-                newString: "same",
+                type: "replace",
+                old_text: "foo",
+                new_text: "qux",
+                all: true,
               },
-              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")
+            ],
+          },
+          ctx,
+        )
 
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          FileTime.read(ctx.sessionID, filepath)
+        expect(await fs.readFile(filepath, "utf-8")).toBe("qux bar qux baz qux")
+      },
+    })
+  })
 
-          const edit = await EditTool.init()
-          await expect(
-            edit.execute(
+  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: [
               {
-                filePath: filepath,
-                oldString: "not in file",
-                newString: "replacement",
+                type: "replace_lines",
+                start_line: hashlineRef(2, "b"),
+                end_line: hashlineRef(3, "c"),
+                text: ["B", "C"],
               },
-              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",
+                type: "insert_before",
+                line: hashlineRef(2, "b"),
+                text: "x",
               },
-              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",
+                type: "insert_after",
+                line: hashlineRef(3, "c"),
+                text: "y",
               },
-              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,
-          )
+            ],
+          },
+          ctx,
+        )
 
-          expect(events).toContain("edited")
-          expect(events).toContain("updated")
-          unsubEdited()
-          unsubUpdated()
-        },
-      })
+        expect(await fs.readFile(filepath, "utf-8")).toBe("a\nx\nB\nC\ny\nd")
+      },
     })
   })
 
-  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(
+  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: [
               {
-                filePath: filepath,
-                oldString: "",
-                newString: "",
+                type: "prepend",
+                text: "start",
               },
-              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",
+                type: "append",
+                text: "end",
               },
-              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,
-          )
+            ],
+          },
+          ctx,
+        )
 
-          expect(result.metadata.filediff).toBeDefined()
-          expect(result.metadata.filediff.file).toBe(filepath)
-          expect(result.metadata.filediff.additions).toBeGreaterThan(0)
-        },
-      })
+        expect(await fs.readFile(filepath, "utf-8")).toBe("start\nend")
+      },
     })
   })
 
-  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)
-        },
-      })
+  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")
+      },
     })
-  })
-
-  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(
+    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,
-          )
+          ),
+        ).rejects.toThrow("directory")
+      },
+    })
+  })
 
-          const content = await fs.readFile(filepath, "utf-8")
-          expect(content).toBe("start\nend")
-        },
-      })
+  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: [
+              {
+                type: "replace_lines",
+                start_line: hashlineRef(2, "line2"),
+                end_line: hashlineRef(2, "line2"),
+                text: ["new line a", "new line b"],
+              },
+            ],
+          },
+          ctx,
+        )
+
+        expect(result.metadata.filediff).toBeDefined()
+        expect(result.metadata.filediff.file).toBe(filepath)
+        expect(result.metadata.filediff.additions).toBeGreaterThan(0)
+      },
+    })
+  })
 
-    test("rejects missing files for non-append/prepend edits", async () => {
-      await using tmp = await tmpdir({
-        config: {
-          experimental: {
-            hashline_edit: true,
+  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: [
+              {
+                type: "set_line",
+                line: hashlineRef(2, "b"),
+                text: "B",
+              },
+            ],
           },
+          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,
         },
-      })
-      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(
+      },
+      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: [
               {
-                filePath: filepath,
-                edits: [
-                  {
-                    type: "replace",
-                    old_text: "a",
-                    new_text: "b",
-                  },
-                ],
+                type: "set_line",
+                line: hashlineRef(2, "b"),
+                text: hashlineLine(2, "B"),
               },
-              ctx,
-            ),
-          ).rejects.toThrow("Missing file can only be created")
-        },
-      })
+            ],
+          },
+          ctx,
+        )
+
+        expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
+      },
     })
+  })
 
-    test("supports delete and rename flows", async () => {
-      await using tmp = await tmpdir({
-        config: {
-          experimental: {
-            hashline_edit: true,
+  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",
+              },
+            ],
           },
-        },
-        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()
+          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,
+        )
 
-          FileTime.read(ctx.sessionID, doomed)
-          await edit.execute(
-            {
-              filePath: doomed,
-              delete: true,
-              edits: [],
-            },
-            ctx,
-          )
-          await expect(fs.stat(doomed)).rejects.toThrow()
-        },
-      })
+        await expect(fs.stat(doomed)).rejects.toThrow()
+      },
     })
+  })
 
-    test("rejects hashline payload when experimental mode is disabled", async () => {
-      await using tmp = await tmpdir({
-        config: {
-          experimental: {
-            hashline_edit: false,
+  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",
+              },
+            ],
           },
-        },
-        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(
+          ctx,
+        )
+
+        FileTime.read(ctx.sessionID, filepath)
+        const second = edit.execute(
+          {
+            filePath: filepath,
+            edits: [
               {
-                filePath: filepath,
-                edits: [
-                  {
-                    type: "append",
-                    text: "b",
-                  },
-                ],
+                type: "set_line",
+                line: hashlineRef(1, "0"),
+                text: "2",
               },
-              ctx,
-            ),
-          ).rejects.toThrow("Hashline edit payload is disabled")
-        },
-      })
+            ],
+          },
+          ctx,
+        )
+
+        const results = await Promise.allSettled([first, second])
+        expect(results.some((result) => result.status === "fulfilled")).toBe(true)
+      },
     })
   })
 })

+ 1 - 36
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")
       },
     })

+ 0 - 23
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", () => {

+ 4 - 62
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")
-      },
-    })
-  })
 })

+ 2 - 2
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,
                   }}
                 />
               </div>