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.
This commit is contained in:
can1357
2026-05-04 05:34:57 +02:00
parent 8283358d86
commit 1cce2ee96b
8 changed files with 121 additions and 50 deletions
+2 -1
View File
@@ -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 :<offset>`) 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
@@ -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,
@@ -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<number, IndexedEdit[
return byLine;
}
export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
export function applyHashlineEdits(
text: string,
edits: HashlineEdit[],
options: HashlineApplyOptions = {},
): HashlineApplyResult {
if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
const fileLines = text.split("\n");
@@ -1287,7 +1297,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
if (mismatches.length > 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<string> }, 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<EditToolDetails>): 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 {
+6 -1
View File
@@ -32,6 +32,7 @@ export interface StreamingDiffContext {
signal: AbortSignal;
fuzzyThreshold?: number;
allowFuzzy?: boolean;
hashlineAutoDropPureInsertDuplicates?: boolean;
}
export interface EditStreamingStrategy<Args = unknown> {
@@ -222,7 +223,11 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
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)];
@@ -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) {
@@ -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,
@@ -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,
@@ -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<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-"));
try {
@@ -58,9 +62,13 @@ async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void
}
}
function hashlineExecuteOptions(tempDir: string, input: string): ExecuteHashlineSingleOptions {
function hashlineExecuteOptions(
tempDir: string,
input: string,
settings = Settings.isolated(),
): ExecuteHashlineSingleOptions {
return {
session: { cwd: tempDir } as ToolSession,
session: { cwd: tempDir, settings } as ToolSession,
input,
writethrough: async (targetPath, content) => {
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");