refactor: remove legacy edit mode
parent
9215af4465
commit
5a7f03a384
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
// the approaches in this edit tool are sourced from
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
||||
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
|
|
@ -28,84 +23,43 @@ import {
|
|||
import { Config } from "../config/config"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const LEGACY_EDIT_MODE = "legacy"
|
||||
const HASHLINE_EDIT_MODE = "hashline"
|
||||
|
||||
const LegacyEditParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
})
|
||||
|
||||
const HashlineEditParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z.array(HashlineEdit).default([]),
|
||||
delete: z.boolean().optional(),
|
||||
rename: z.string().optional(),
|
||||
})
|
||||
const LEGACY_KEYS = ["oldString", "newString", "replaceAll"] as const
|
||||
|
||||
const EditParams = z
|
||||
.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().optional().describe("The text to replace"),
|
||||
newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
edits: z.array(HashlineEdit).optional(),
|
||||
delete: z.boolean().optional(),
|
||||
rename: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined
|
||||
const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined
|
||||
|
||||
if (legacy && hashline) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!legacy && !hashline) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
if (value.oldString === undefined || value.newString === undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Legacy payload requires both oldString and newString.",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (value.edits === undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hashline payload requires edits (use [] when only delete is intended).",
|
||||
})
|
||||
}
|
||||
if (value.edits !== undefined) return
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hashline payload requires edits (use [] when only delete or rename is intended).",
|
||||
})
|
||||
})
|
||||
|
||||
type LegacyEditParams = z.infer<typeof LegacyEditParams>
|
||||
type HashlineEditParams = z.infer<typeof HashlineEditParams>
|
||||
type EditParams = z.infer<typeof EditParams>
|
||||
|
||||
function formatValidationError(error: z.ZodError) {
|
||||
const legacy = error.issues.some((issue) => {
|
||||
if (issue.code !== "unrecognized_keys") return false
|
||||
if (!("keys" in issue) || !Array.isArray(issue.keys)) return false
|
||||
return issue.keys.some((key) => LEGACY_KEYS.includes(key as (typeof LEGACY_KEYS)[number]))
|
||||
})
|
||||
if (legacy) {
|
||||
return "Legacy edit payload has been removed. Use hashline fields: { filePath, edits, delete?, rename? }."
|
||||
}
|
||||
return `Invalid parameters for tool 'edit':\n${error.issues.map((issue) => `- ${issue.message}`).join("\n")}`
|
||||
}
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
}
|
||||
|
||||
function isLegacyParams(params: EditParams): params is LegacyEditParams {
|
||||
return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
|
||||
}
|
||||
|
||||
async function withLocks(paths: string[], fn: () => Promise<void>) {
|
||||
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
|
||||
const recurse = async (idx: number): 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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,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")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue