Просмотр исходного кода

stabilize hashline routing and anchors

Shoubhit Dash 5 месяцев назад
Родитель
Сommit
aec95c4d10

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

+ 3 - 1
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:<content>`, 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.

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

+ 2 - 1
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:<content>` 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

+ 67 - 42
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<typeof HashlineEdit>
 
+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 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
+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 stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
-  const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
-  if (!stripHash && !stripPlus) return lines
+  const matches = nonEmpty.filter(test).length
+  if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
 
-  return lines.map((line) => {
-    if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
-    if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
-    return line
-  })
+  return lines.map(rewrite)
+}
+
+function stripNewLinePrefixes(lines: string[]) {
+  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<string>()
-  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")
-      }
-
-      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")
+      })
+      .join("\n\n")
+    linesOut.push("", body)
+  }
 
-  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<Extract<HashlineEdit, { type: "replace" }>> = []
   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

+ 5 - 4
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
 

+ 56 - 0
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) => {

+ 42 - 16
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", () => {

+ 36 - 6
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({
+          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(model)
+        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")