diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d34c5e552..9302c222e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Removed + +- Removed `replaceText` edit operation from hashline mode (substring-based text replacement) +- Removed autocorrect heuristics that attempted to detect and fix line merges and formatting rewrites in hashline edits ## [12.19.0] - 2026-02-22 ### Added diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 65cf5e34b..650490619 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -21,159 +21,6 @@ export type HashlineEdit = | { op: "append"; after?: LineTag; content: string[] } | { op: "prepend"; before?: LineTag; content: string[] } | { op: "insert"; after: LineTag; before: LineTag; content: string[] }; -export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean }; -export type EditSpec = HashlineEdit | ReplaceTextEdit; - -/** - * Compare two strings ignoring all whitespace differences. - * - * Returns true when the non-whitespace characters are identical — meaning - * the only differences are in spaces, tabs, or other whitespace. - */ -function equalsIgnoringWhitespace(a: string, b: string): boolean { - // Fast path: identical strings - if (a === b) return true; - // Compare with all whitespace removed - return a.replace(/\s+/g, "") === b.replace(/\s+/g, ""); -} - -function stripAllWhitespace(s: string): string { - return s.replace(/\s+/g, ""); -} - -function stripTrailingContinuationTokens(s: string): string { - // Heuristic: models often merge a continuation line into the prior line - // while also changing the trailing operator (e.g. `&&` → `||`). - // Strip common trailing continuation tokens so we can still detect merges. - return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, ""); -} - -function stripMergeOperatorChars(s: string): string { - // Used for merge detection when the model changes a logical operator like - // `||` → `??` while also merging adjacent lines. - return s.replace(/[|&?]/g, ""); -} - -function leadingWhitespace(s: string): string { - const match = s.match(/^\s*/); - return match ? match[0] : ""; -} - -function restoreLeadingIndent(templateLine: string, line: string): string { - if (line.length === 0) return line; - const templateIndent = leadingWhitespace(templateLine); - if (templateIndent.length === 0) return line; - const indent = leadingWhitespace(line); - if (indent.length > 0) return line; - return templateIndent + line; -} - -function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] { - if (oldLines.length !== newLines.length) return newLines; - let changed = false; - const out = new Array(newLines.length); - for (let i = 0; i < newLines.length; i++) { - const restored = restoreLeadingIndent(oldLines[i], newLines[i]); - out[i] = restored; - if (restored !== newLines[i]) changed = true; - } - return changed ? out : newLines; -} - -/** - * Undo pure formatting rewrites where the model reflows a single logical line - * into multiple lines (or similar), but the token stream is identical. - */ -function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] { - if (oldLines.length === 0 || newLines.length < 2) return newLines; - - const canonToOld = new Map(); - for (const line of oldLines) { - const canon = stripAllWhitespace(line); - const bucket = canonToOld.get(canon); - if (bucket) bucket.count++; - else canonToOld.set(canon, { line, count: 1 }); - } - - const candidates: { start: number; len: number; replacement: string; canon: string }[] = []; - for (let start = 0; start < newLines.length; start++) { - for (let len = 2; len <= 10 && start + len <= newLines.length; len++) { - const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join("")); - const old = canonToOld.get(canonSpan); - if (old && old.count === 1 && canonSpan.length >= 6) { - candidates.push({ start, len, replacement: old.line, canon: canonSpan }); - } - } - } - if (candidates.length === 0) return newLines; - - // Keep only spans whose canonical match is unique in the new output. - const canonCounts = new Map(); - for (const c of candidates) { - canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1); - } - const uniqueCandidates = candidates.filter(c => (canonCounts.get(c.canon) ?? 0) === 1); - if (uniqueCandidates.length === 0) return newLines; - - // Apply replacements back-to-front so indices remain stable. - uniqueCandidates.sort((a, b) => b.start - a.start); - const out = [...newLines]; - for (const c of uniqueCandidates) { - out.splice(c.start, c.len, c.replacement); - } - return out; -} - -function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] { - if (dstLines.length <= 1) return dstLines; - if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) { - return dstLines.slice(1); - } - return dstLines; -} - -function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] { - if (dstLines.length <= 1) return dstLines; - if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) { - return dstLines.slice(0, -1); - } - return dstLines; -} - -function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] { - let out = dstLines; - if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) { - out = out.slice(1); - } - if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) { - out = out.slice(0, -1); - } - return out; -} - -function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] { - // Only strip when the model replaced with multiple lines and grew the edit. - // This avoids turning a single-line replacement into a deletion. - const count = endLine - startLine + 1; - if (dstLines.length <= 1 || dstLines.length <= count) return dstLines; - - let out = dstLines; - const beforeIdx = startLine - 2; - if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) { - out = out.slice(1); - } - - const afterIdx = endLine; - if ( - afterIdx < fileLines.length && - out.length > 0 && - equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx]) - ) { - out = out.slice(0, -1); - } - - return out; -} const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"; @@ -594,38 +441,6 @@ export function applyHashlineEdits( let firstChangedLine: number | undefined; const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = []; - const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1"; - - function collectExplicitlyTouchedLines(): Set { - const touched = new Set(); - for (const edit of edits) { - switch (edit.op) { - case "set": - touched.add(edit.tag.line); - break; - case "replace": - for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln); - break; - case "append": - if (edit.after) { - touched.add(edit.after.line); - } - break; - case "prepend": - if (edit.before) { - touched.add(edit.before.line); - } - break; - case "insert": - touched.add(edit.after.line); - touched.add(edit.before.line); - break; - } - } - return touched; - } - - const explicitlyTouchedLines = collectExplicitlyTouchedLines(); // Pre-validate: collect all hash mismatches before mutating const mismatches: HashMismatch[] = []; function validateRef(ref: { line: number; hash: string }): boolean { @@ -765,35 +580,8 @@ export function applyHashlineEdits( for (const { edit, idx } of annotated) { switch (edit.op) { case "set": { - const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null; - if (merged) { - const origLines = originalFileLines.slice( - merged.startLine - 1, - merged.startLine - 1 + merged.deleteCount, - ); - let nextLines = merged.newLines; - nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines); - - if (origLines.every((line, i) => line === nextLines[i])) { - noopEdits.push({ - editIndex: idx, - loc: `${edit.tag.line}#${edit.tag.hash}`, - currentContent: origLines.join("\n"), - }); - break; - } - fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines); - trackFirstChanged(merged.startLine); - break; - } - - const count = 1; const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line); - let stripped = autocorrect - ? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content) - : edit.content; - stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped; - const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; + const newLines = edit.content; if (origLines.every((line, i) => line === newLines[i])) { noopEdits.push({ editIndex: idx, @@ -802,36 +590,19 @@ export function applyHashlineEdits( }); break; } - fileLines.splice(edit.tag.line - 1, count, ...newLines); + fileLines.splice(edit.tag.line - 1, 1, ...newLines); trackFirstChanged(edit.tag.line); break; } case "replace": { const count = edit.last.line - edit.first.line + 1; - const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count); - let stripped = autocorrect - ? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content) - : edit.content; - stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped; - const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; - if (autocorrect && origLines.every((line, i) => line === newLines[i])) { - noopEdits.push({ - editIndex: idx, - loc: `${edit.first.line}#${edit.first.hash}`, - currentContent: origLines.join("\n"), - }); - break; - } + const newLines = edit.content; fileLines.splice(edit.first.line - 1, count, ...newLines); trackFirstChanged(edit.first.line); break; } case "append": { - const inserted = edit.after - ? autocorrect - ? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content) - : edit.content - : edit.content; + const inserted = edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, @@ -855,11 +626,7 @@ export function applyHashlineEdits( break; } case "prepend": { - const inserted = edit.before - ? autocorrect - ? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content) - : edit.content - : edit.content; + const inserted = edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, @@ -884,7 +651,7 @@ export function applyHashlineEdits( case "insert": { const afterLine = originalFileLines[edit.after.line - 1]; const beforeLine = originalFileLines[edit.before.line - 1]; - const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content; + const inserted = edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, @@ -911,51 +678,4 @@ export function applyHashlineEdits( firstChangedLine = line; } } - - function maybeExpandSingleLineMerge( - line: number, - content: string[], - ): { startLine: number; deleteCount: number; newLines: string[] } | null { - if (content.length !== 1) return null; - if (line < 1 || line > fileLines.length) return null; - - const newLine = content[0]; - const newCanon = stripAllWhitespace(newLine); - const newCanonForMergeOps = stripMergeOperatorChars(newCanon); - if (newCanon.length === 0) return null; - - const orig = fileLines[line - 1]; - const origCanon = stripAllWhitespace(orig); - const origCanonForMatch = stripTrailingContinuationTokens(origCanon); - const origCanonForMergeOps = stripMergeOperatorChars(origCanon); - const origLooksLikeContinuation = origCanonForMatch.length < origCanon.length; - if (origCanon.length === 0) return null; - const nextIdx = line; - const prevIdx = line - 2; - // Case A: dst absorbed the next continuation line. - if (origLooksLikeContinuation && nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) { - const next = fileLines[nextIdx]; - const nextCanon = stripAllWhitespace(next); - const a = newCanon.indexOf(origCanonForMatch); - const b = newCanon.indexOf(nextCanon); - if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) { - return { startLine: line, deleteCount: 2, newLines: [newLine] }; - } - } - // Case B: dst absorbed the previous declaration/continuation line. - if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) { - const prev = fileLines[prevIdx]; - const prevCanon = stripAllWhitespace(prev); - const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon); - const prevLooksLikeContinuation = prevCanonForMatch.length < prevCanon.length; - if (!prevLooksLikeContinuation) return null; - const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch)); - const b = newCanonForMergeOps.indexOf(origCanonForMergeOps); - if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) { - return { startLine: line - 1, deleteCount: 2, newLines: [newLine] }; - } - } - - return null; - } } diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 51af17024..dffb6cc77 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -34,14 +34,7 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard" import { applyPatch } from "./applicator"; import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; import { findMatch } from "./fuzzy"; -import { - applyHashlineEdits, - computeLineHash, - type HashlineEdit, - type LineTag, - parseTag, - type ReplaceTextEdit, -} from "./hashline"; +import { applyHashlineEdits, computeLineHash, type HashlineEdit, type LineTag, parseTag } from "./hashline"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { buildNormativeUpdateInput } from "./normative"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; @@ -201,13 +194,6 @@ export function hashlineParseContent(edit: string | string[] | null): string[] { if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1); return lines; } - -function hashlineParseContentString(edit: string | string[] | null): string { - if (edit === null) return ""; - if (Array.isArray(edit)) return edit.join("\n"); - return edit; -} - const hashlineTargetEditSchema = Type.Object( { op: Type.Literal("set"), @@ -255,25 +241,12 @@ const hashlineInsertEditSchema = Type.Object( { additionalProperties: false }, ); -const hashlineReplaceTextEditSchema = Type.Object( - { - op: Type.Literal("replaceText"), - old_text: Type.String({ description: "Text to find", minLength: 1 }), - new_text: hashlineReplaceContentFormat("Replacement"), - all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })), - }, - { additionalProperties: false }, -); - -const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1"; - const hashlineEditSpecSchema = Type.Union([ hashlineTargetEditSchema, hashlineRangeEditSchema, hashlineAppendEditSchema, hashlinePrependEditSchema, hashlineInsertEditSchema, - ...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []), ]); const hashlineEditSchema = Type.Object( @@ -486,7 +459,7 @@ export class EditTool implements AgentTool { case "patch": return renderPromptTemplate(patchDescription); case "hashline": - return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED }); + return renderPromptTemplate(hashlineDescription); default: return renderPromptTemplate(replaceDescription); } @@ -581,7 +554,6 @@ export class EditTool implements AgentTool { } const anchorEdits: HashlineEdit[] = []; - const replaceEdits: ReplaceTextEdit[] = []; for (const edit of edits) { switch (edit.op) { case "set": { @@ -643,16 +615,6 @@ export class EditTool implements AgentTool { } break; } - case "replaceText": { - const { old_text, new_text, all } = edit; - replaceEdits.push({ - op: "replaceText", - old_text: old_text, - new_text: hashlineParseContentString(new_text), - all: all ?? false, - }); - break; - } default: throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`); } @@ -668,19 +630,6 @@ export class EditTool implements AgentTool { const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits); normalizedContent = anchorResult.content; - // Apply content-replace edits (substr-style fuzzy replace) - for (const r of replaceEdits) { - if (r.old_text.length === 0) { - throw new Error("old_text must not be empty."); - } - const rep = replaceText(normalizedContent, r.old_text, r.new_text, { - fuzzy: this.#allowFuzzy, - all: r.all ?? false, - threshold: this.#fuzzyThreshold, - }); - normalizedContent = rep.content; - } - const result = { content: normalizedContent, firstChangedLine: anchorResult.firstChangedLine, diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 332249877..b73dbdd4c 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -22,10 +22,6 @@ Apply precise file edits using `LINE#ID` tags, anchoring to the file content. - `{ op: "prepend", before: "N#ID", content: […] }` or `{ op: "prepend", content: […] }` (no `before` = insert at beginning of file) - `{ op: "append", after: "N#ID", content: […] }` or `{ op: "append", content: […] }` (no `after` = insert at end of file) - `{ op: "insert", after: "N#ID", before: "N#ID", content: […] }` (between adjacent anchors; safest for blocks) -{{#if allowReplaceText}} -- **Content replace** - - `{ op: "replaceText", old_text: "…", new_text: "…", all?: boolean }` -{{/if}} - **File-level controls** - `{ delete: true, edits: [] }` deletes the file (cannot be combined with `rename`). - `{ rename: "new/path.ts", edits: […] }` writes result to new path and removes old path. @@ -180,18 +176,6 @@ content: ["function validate(data: unknown): boolean {", " return data != null The trailing `""` in `content` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits. -{{#if allowReplaceText}} - -``` -op: "replaceText" -old_text: "x = 42" -new_text: "x = 99" -``` - -Use only when line anchors aren't available. `old_text` must match exactly one location in the file (or set `"all": true` for all occurrences). - -{{/if}} - ``` path: "src/deprecated/legacy.ts" diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 56e1cb7d9..1c5bf084e 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -661,37 +661,6 @@ describe("edit tool CRLF handling", () => { ).rejects.toThrow(/Found 2 occurrences/); }); - it("should apply hashline replace (substr-style) when edit variant is hashline", async () => { - const originalEditVariant = Bun.env.PI_EDIT_VARIANT; - const originalHashlineReplace = Bun.env.PI_HL_REPLACETXT; - Bun.env.PI_EDIT_VARIANT = "hashline"; - Bun.env.PI_HL_REPLACETXT = "1"; - - const hashDir = path.join(os.tmpdir(), `coding-agent-hashline-replace-${Snowflake.next()}`); - fs.mkdirSync(hashDir, { recursive: true }); - const testFile = path.join(hashDir, "app.txt"); - fs.writeFileSync(testFile, "x = 42\ny = 10\n"); - - try { - const session = createTestToolSession(hashDir); - const hashlineEditTool = new EditTool(session); - const result = await hashlineEditTool.execute("hashline-replace-1", { - path: testFile, - edits: [{ op: "replaceText", old_text: "x = 42", new_text: "x = 99" }], - }); - - expect(getTextOutput(result)).toContain("Updated"); - const content = await Bun.file(testFile).text(); - expect(content).toBe("x = 99\ny = 10\n"); - } finally { - fs.rmSync(hashDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) delete Bun.env.PI_EDIT_VARIANT; - else Bun.env.PI_EDIT_VARIANT = originalEditVariant; - if (originalHashlineReplace === undefined) delete Bun.env.PI_HL_REPLACETXT; - else Bun.env.PI_HL_REPLACETXT = originalHashlineReplace; - } - }); - it("should delete file in hashline mode with delete:true", async () => { const originalEditVariant = Bun.env.PI_EDIT_VARIANT; Bun.env.PI_EDIT_VARIANT = "hashline";