From 1cce2ee96b0c60a350b65658ce9555c3903db18b Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 4 May 2026 05:34:57 +0200 Subject: [PATCH] feat(coding-agent/edit): added opt-in setting for pure-insert hashline duplicate dropping - Added a new `edit.hashlineAutoDropPureInsertDuplicates` boolean setting with default `false` for hashline edits. - Threaded the setting through tool execution contexts so hashline previews and execution honor the configured option. - Changed pure-insert duplicate boundary absorption to run only when enabled and added tests for default-disabled and enabled behavior. --- packages/coding-agent/CHANGELOG.md | 3 +- .../src/config/settings-schema.ts | 9 ++ .../coding-agent/src/edit/modes/hashline.ts | 95 +++++++++++-------- packages/coding-agent/src/edit/streaming.ts | 7 +- .../src/modes/components/tool-execution.ts | 4 + .../src/modes/controllers/event-controller.ts | 2 + .../src/modes/utils/ui-helpers.ts | 1 + .../coding-agent/test/core/hashline.test.ts | 50 ++++++++-- 8 files changed, 121 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 917e7ec7b..284d81dfc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Added `buildWorkspaceTree` and `WorkspaceTree` exports so callers can precompute and pass a workspace context to prompt generation - Added `workspaceTree` support to `buildSystemPrompt` options to reuse a prebuilt directory snapshot - Added `read.summarize.enabled`, `read.summarize.minBodyLines`, and `read.summarize.minCommentLines` settings to control whether `read` returns structural summaries and how many multiline body/comment lines are collapsed +- Added `edit.hashlineAutoDropPureInsertDuplicates` setting to opt into dropping 2+ pure-insert hashline payload lines that duplicate adjacent file context; default is `false`. ### Changed @@ -22,7 +23,7 @@ - Changed default `read` output for parseable code files without an explicit selector to return a structural summary instead of full verbatim lines, while still supporting full output for `:raw` and explicit ranges - Changed truncation/pagination hints in read, archive, and SQLite outputs to use colon syntax (`Use :`) when continuing reads - Changed the read tool UI preview title to include summary elision counts when a summary is returned -- Extended hashline edit tool's auto-absorb mechanism to pure inserts (`+ ANCHOR`, `< ANCHOR`, `+ BOF`, `+ EOF`, `< BOF`, `< EOF`): leading payload lines that duplicate file lines immediately above the insertion point, or trailing payload lines that duplicate file lines immediately below it, are dropped automatically (matching the existing 2-line-minimum threshold used for `= A..B` boundary absorption). A warning is surfaced when this happens. +- Changed hashline pure-insert duplicate auto-drop to be opt-in through `edit.hashlineAutoDropPureInsertDuplicates` instead of always enabled. ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 01d7bb58a..729b89f95 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1419,6 +1419,15 @@ export const SETTINGS_SCHEMA = { }, }, + "edit.hashlineAutoDropPureInsertDuplicates": { + type: "boolean", + default: false, + ui: { + tab: "editing", + label: "Hashline Duplicate Insert Drop", + description: "Drop 2+ pure-insert payload lines that duplicate adjacent file context", + }, + }, "edit.blockAutoGenerated": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/edit/modes/hashline.ts b/packages/coding-agent/src/edit/modes/hashline.ts index bb86fe63f..b7577abb3 100644 --- a/packages/coding-agent/src/edit/modes/hashline.ts +++ b/packages/coding-agent/src/edit/modes/hashline.ts @@ -103,6 +103,9 @@ export interface CompactHashlineDiffOptions { /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */ maxUnchangedRun?: number; } +export interface HashlineApplyOptions { + autoDropPureInsertDuplicates?: boolean; +} export interface SplitHashlineOptions { cwd?: string; @@ -1146,6 +1149,7 @@ function absorbReplacementBoundaryDuplicates( edits: HashlineEdit[], fileLines: string[], warnings: string[], + options: HashlineApplyOptions, ): HashlineEdit[] { let nextSyntheticIndex = edits.length; const absorbed: HashlineEdit[] = []; @@ -1160,43 +1164,45 @@ function absorbReplacementBoundaryDuplicates( for (let index = 0; index < edits.length; index++) { const group = findReplacementGroup(edits, index); if (!group) { - const pureInsert = findPureInsertGroup(edits, index); - if (pureInsert) { - const result = tryAbsorbPureInsertGroup(pureInsert, fileLines); - if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) { - if (result.leadingFileRange) { - const { start, end } = result.leadingFileRange; - const key = `pure-insert-leading:${start}..${end}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` + - `(file lines ${start}..${end} already match the payload's leading lines).`, - ); + if (options.autoDropPureInsertDuplicates) { + const pureInsert = findPureInsertGroup(edits, index); + if (pureInsert) { + const result = tryAbsorbPureInsertGroup(pureInsert, fileLines); + if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) { + if (result.leadingFileRange) { + const { start, end } = result.leadingFileRange; + const key = `pure-insert-leading:${start}..${end}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` + + `(file lines ${start}..${end} already match the payload's leading lines).`, + ); + } } - } - if (result.trailingFileRange) { - const { start, end } = result.trailingFileRange; - const key = `pure-insert-trailing:${start}..${end}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` + - `(file lines ${start}..${end} already match the payload's trailing lines).`, - ); + if (result.trailingFileRange) { + const { start, end } = result.trailingFileRange; + const key = `pure-insert-trailing:${start}..${end}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` + + `(file lines ${start}..${end} already match the payload's trailing lines).`, + ); + } } + for (const text of result.keptPayload) { + absorbed.push({ + kind: "insert", + cursor: cloneCursor(pureInsert.cursor), + text, + lineNum: pureInsert.sourceLineNum, + index: nextSyntheticIndex++, + }); + } + index = pureInsert.endIndex; + continue; } - for (const text of result.keptPayload) { - absorbed.push({ - kind: "insert", - cursor: cloneCursor(pureInsert.cursor), - text, - lineNum: pureInsert.sourceLineNum, - index: nextSyntheticIndex++, - }); - } - index = pureInsert.endIndex; - continue; } } absorbed.push(edits[index]); @@ -1272,7 +1278,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map 0) throw new HashlineMismatchError(mismatches, fileLines); - const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings); + const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options); // Normalize after_anchor inserts to before_anchor of the next line, or EOF // when the anchor is the final line. This keeps the bucketing logic below @@ -1510,6 +1520,7 @@ async function readHashlineFileText(file: { text(): Promise }, pathText: export async function computeHashlineDiff( input: { input: string; path?: string }, cwd: string, + options: HashlineApplyOptions = {}, ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> { try { const sections = splitHashlineInputs(input.input, { cwd, path: input.path }); @@ -1522,7 +1533,7 @@ export async function computeHashlineDiff( const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path); const { text: content } = stripBom(rawContent); const normalized = normalizeToLF(content); - const result = applyHashlineEdits(normalized, parseHashline(section.diff)); + const result = applyHashlineEdits(normalized, parseHashline(section.diff), options); if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` }; return generateDiffString(normalized, result.lines); } catch (err) { @@ -1560,6 +1571,12 @@ function formatNoChangeDiagnostic(pathText: string): string { return `Edits to ${pathText} resulted in no changes being made.`; } +function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions { + return { + autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"), + }; +} + function getTextContent(result: AgentToolResult): string { return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); } @@ -1586,7 +1603,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & const { text } = stripBom(source.rawContent); const normalized = normalizeToLF(text); - const result = applyHashlineEdits(normalized, edits); + const result = applyHashlineEdits(normalized, edits, getHashlineApplyOptions(session)); if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath)); } @@ -1614,7 +1631,7 @@ async function executeHashlineSection( const { bom, text } = stripBom(source.rawContent); const originalEnding = detectLineEnding(text); const originalNormalized = normalizeToLF(text); - const result = applyHashlineEdits(originalNormalized, edits); + const result = applyHashlineEdits(originalNormalized, edits, getHashlineApplyOptions(session)); if (originalNormalized === result.lines) { return { diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 996641028..41039e4df 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -32,6 +32,7 @@ export interface StreamingDiffContext { signal: AbortSignal; fuzzyThreshold?: number; allowFuzzy?: boolean; + hashlineAutoDropPureInsertDuplicates?: boolean; } export interface EditStreamingStrategy { @@ -222,7 +223,11 @@ const hashlineStrategy: EditStreamingStrategy = { async computeDiffPreview(args, ctx) { if (typeof args.input !== "string" || args.input.length === 0) return null; ctx.signal.throwIfAborted(); - const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd); + const result = await computeHashlineDiff( + { input: args.input, path: args.path }, + ctx.cwd, + { autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates }, + ); ctx.signal.throwIfAborted(); if ("error" in result && !args.path) return [{ path: "", error: result.error }]; return [toPerFilePreview(args.path ?? "", result)]; diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index d387dd607..c1f273bed 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -67,6 +67,7 @@ export interface ToolExecutionOptions { showImages?: boolean; // default: true (only used if terminal supports images) editFuzzyThreshold?: number; editAllowFuzzy?: boolean; + hashlineAutoDropPureInsertDuplicates?: boolean; } export interface ToolExecutionHandle { @@ -100,6 +101,7 @@ export class ToolExecutionComponent extends Container { #showImages: boolean; #editFuzzyThreshold: number | undefined; #editAllowFuzzy: boolean | undefined; + #hashlineAutoDropPureInsertDuplicates: boolean | undefined; #isPartial = true; #tool?: AgentTool; #ui: TUI; @@ -147,6 +149,7 @@ export class ToolExecutionComponent extends Container { this.#showImages = options.showImages ?? true; this.#editFuzzyThreshold = options.editFuzzyThreshold; this.#editAllowFuzzy = options.editAllowFuzzy; + this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates; this.#tool = tool; this.#ui = ui; this.#cwd = cwd; @@ -248,6 +251,7 @@ export class ToolExecutionComponent extends Container { signal: controller.signal, fuzzyThreshold: this.#editFuzzyThreshold, allowFuzzy: this.#editAllowFuzzy, + hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates, }); if (controller.signal.aborted) return; if (previews) { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 482ad34af..fd45fea16 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -280,6 +280,7 @@ export class EventController { showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), + hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"), }, tool, this.ctx.ui, @@ -383,6 +384,7 @@ export class EventController { showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), + hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"), }, tool, this.ctx.ui, diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index ee33596d7..5b6cd1dd2 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -336,6 +336,7 @@ export class UiHelpers { showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), + hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"), }, tool, this.ctx.ui, diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 3acd38f65..0910d5acd 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -49,6 +49,10 @@ function applyDiff(content: string, diff: string): string { return applyHashlineEdits(content, parseHashline(diff)).lines; } +function applyDiffWithPureInsertAutoDrop(content: string, diff: string): string { + return applyHashlineEdits(content, parseHashline(diff), { autoDropPureInsertDuplicates: true }).lines; +} + async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-")); try { @@ -58,9 +62,13 @@ async function withTempDir(fn: (tempDir: string) => Promise): Promise { await Bun.write(targetPath, content); @@ -169,12 +177,18 @@ describe("hashline parser — block op syntax", () => { ); }); + it("does not auto-drop pure-insert duplicate boundaries by default", () => { + const source = ["aaa", "bbb", "ccc"].join("\n"); + const diff = [`+ ${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); + expect(applyDiff(source, diff)).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc"); + }); + it("auto-absorbs duplicated leading payload of a pure `+ ANCHOR` insert", () => { // `+ 2 ~aaa ~bbb ~NEW`: payload echoes the two file lines AT/ABOVE the // insertion point (aaa, bbb), then adds NEW. The leading echo is absorbed. const source = ["aaa", "bbb", "ccc"].join("\n"); const diff = [`+ ${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nNEW\nccc"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc"); }); it("auto-absorbs context-wrap echo (leading-above + trailing-below) on `+ ANCHOR`", () => { @@ -183,7 +197,7 @@ describe("hashline parser — block op syntax", () => { // only NEW inserted after bbb. const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); const diff = [`+ ${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); }); it("auto-absorbs duplicated trailing payload of a pure `< ANCHOR` insert", () => { @@ -191,21 +205,21 @@ describe("hashline parser — block op syntax", () => { // line after it. Drop the trailing duplicates. const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); const diff = [`< ${tag(3, "ccc")}`, pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); }); it("auto-absorbs duplicated leading payload at EOF insert", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); // `+ EOF` payload echoes the last two file lines, then adds NEW. const diff = ["+ EOF", pl("bbb"), pl("ccc"), pl("NEW")].join("\n"); - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nccc\nNEW"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nNEW"); }); it("auto-absorbs duplicated trailing payload at BOF insert", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); // `< BOF` payload prepends NEW but trails with the first two file lines. const diff = ["< BOF", pl("NEW"), pl("aaa"), pl("bbb")].join("\n"); - expect(applyDiff(source, diff)).toBe("NEW\naaa\nbbb\nccc"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc"); }); it("does not auto-absorb a single duplicated boundary line in a pure insert", () => { @@ -213,13 +227,13 @@ describe("hashline parser — block op syntax", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); const diff = [`+ ${tag(2, "bbb")}`, pl("bbb"), pl("NEW")].join("\n"); // Only "bbb" matches above; that's a 1-line dup, not absorbed. - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); }); it("surfaces a warning when pure-insert duplicates are auto-dropped", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); const diff = [`+ ${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); - const result = applyHashlineEdits(source, parseHashline(diff)); + const result = applyHashlineEdits(source, parseHashline(diff), { autoDropPureInsertDuplicates: true }); expect(result.lines).toBe("aaa\nbbb\nNEW\nccc"); expect(result.warnings).toBeDefined(); expect(result.warnings).toEqual( @@ -382,6 +396,24 @@ describe("hashline executor", () => { }); }); + it("honors the pure-insert duplicate auto-drop setting", async () => { + await withTempDir(async tempDir => { + const filePath = path.join(tempDir, "a.ts"); + const source = ["aaa", "bbb", "ccc"].join("\n"); + const input = `@a.ts\n+ ${tag(2, "bbb")}\n${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`; + + await Bun.write(filePath, source); + await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); + expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc"); + + await Bun.write(filePath, source); + const enabled = Settings.isolated({ "edit.hashlineAutoDropPureInsertDuplicates": true }); + const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, enabled)); + expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nNEW\nccc"); + expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("Auto-dropped"); + }); + }); + it("preflights every section before writing multi-file edits", async () => { await withTempDir(async tempDir => { const aPath = path.join(tempDir, "a.ts");