diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index b62979144..15b47a2fd 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+
### Added
- Added subcommands to `/copy` command: `code` (copy last code block), `all` (copy all code blocks), `cmd` (copy last bash/python command), and `last` (copy full message)
@@ -15,6 +16,7 @@
### Changed
+- Updated hashline tool documentation with explicit guidance on `replace` operation semantics, clarifying that `lines` must not extend past `end` to avoid unintended line duplication
- Improved diagnostic message formatting to group errors by file path with indented details for better readability
- Modified eager todo prelude to use hidden custom message type instead of visible developer message, preventing duplicate prompt text in session history
- Updated eager todo prompt to remove dynamic user request injection, simplifying the template and preventing request repetition in displayed messages
@@ -28,6 +30,8 @@
### Fixed
+- Fixed hashline line normalization to trim trailing whitespace and strip carriage returns instead of removing all whitespace, preserving intentional spacing in code
+- Fixed noop detection in hashline replace operations to check array length equality before comparing lines, preventing false noop classification when single-line replacements expand to multiple lines
- Fixed path resolution to accept bare directory names without trailing slashes in comma/space-separated path lists (e.g., `apps packages phases`)
- Per-role `modelRoles` thinking selectors now propagate through commit/title helper model selection, legacy commit analysis, and agentic commit sessions while preserving default thinking inheritance when no role override is configured
diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts
index 175e7a67a..df77f8476 100644
--- a/packages/coding-agent/src/patch/hashline.ts
+++ b/packages/coding-agent/src/patch/hashline.ts
@@ -33,16 +33,13 @@ const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
/**
* Compute a short hexadecimal hash of a single line.
*
- * Uses xxHash32 on a whitespace-normalized line, truncated to 2 chars from
+ * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, truncated to 2 chars from
* {@link NIBBLE_STR}. For lines containing no alphanumeric characters (only
* punctuation/symbols/whitespace), the line number is mixed in to reduce hash collisions.
* The line input should not include a trailing newline.
*/
export function computeLineHash(idx: number, line: string): string {
- if (line.endsWith("\r")) {
- line = line.slice(0, -1);
- }
- line = line.replace(/\s+/g, "");
+ line = line.replace(/\r/g, "").trimEnd();
let seed = 0;
if (!RE_SIGNIFICANT.test(line)) {
@@ -626,7 +623,7 @@ export function applyHashlineEdits(
if (!edit.end) {
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
const newLines = edit.lines;
- if (origLines.every((line, i) => line === newLines[i])) {
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${edit.pos.line}#${edit.pos.hash}`,
diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md
index 6478213d6..4af1b9428 100644
--- a/packages/coding-agent/src/prompts/tools/hashline.md
+++ b/packages/coding-agent/src/prompts/tools/hashline.md
@@ -30,6 +30,8 @@ Before choosing the payload, answer these questions in order:
- `[""]` — blank line
- `null` or `[]` — delete if replace, no-op if append or prepend
+`replace` substitutes **exactly** the `pos..end` range (inclusive). Everything before `pos` and after `end` is preserved untouched. If `lines` contains content that already exists after `end`, those lines **will be duplicated** in the output. Keep `lines` to exactly what belongs inside the consumed range.
+
Ops are applied bottom-up. Tags **MUST** be referenced from the most recent `read` output.
@@ -117,6 +119,21 @@ Blank out a line without removing it:
```
+
+Remove just the TODO comment (line 10) from `beta()` without touching anything else:
+```
+{
+ path: "util.ts",
+ edits: [{
+ op: "replace",
+ pos: {{hlineref 10 "\t// TODO: remove after migration"}},
+ lines: null
+ }]
+}
+```
+Do **not** widen the range and re-emit surrounding lines when a single-line delete suffices.
+
+
Replace the catch body with smarter error handling. Shape (a): `pos` is the first body line, `end` is the last body line. The catch header (line 14) and its closer (line 17) are outside the range and stay untouched.
```
@@ -271,5 +288,6 @@ Good — prepend before the next declaration so the new sibling is anchored on a
- When changing existing code near a block tail or closing delimiter, default to `replace` over the owned span instead of inserting around the boundary.
- When adding a sibling declaration, default to `prepend` on the next sibling declaration instead of `append` on the previous block's closing brace.
- **Block boundaries travel together.** For a block `{ header / body / closer }`, there are exactly two valid replace shapes: (a) replace only the body — `pos`=first body line, `end`=last body line, leave the header and closer untouched; or (b) replace the whole block — `pos`=header, `end`=closer, re-emit all three in `lines`. Never split them: do not set `end` to the closer while omitting it from `lines` (deletes it), and do not emit the closer in `lines` without including it in `end` (duplicates it). This applies to every block terminator: `}`, `continue`, `break`, `return`, `throw`.
+- **`lines` must not extend past `end`.** `lines` replaces exactly `pos..end`. Content after `end` survives. If you include lines in `lines` that exist after `end`, they will appear twice. Either extend `end` to cover all lines you are re-emitting, or remove the extra lines from `lines`.
- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use a real tab character.
\ No newline at end of file
diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts
index 943e2f3b9..0e4a08f00 100644
--- a/packages/coding-agent/test/core/hashline.test.ts
+++ b/packages/coding-agent/test/core/hashline.test.ts
@@ -699,6 +699,15 @@ describe("applyHashlineEdits — multiple edits", () => {
expect(result.lines).toBe("one\nTWO_THREE\nfour\nfive\nSIX");
});
+ it("single-line replace expanding to multiple lines is not a noop", () => {
+ const content = "aaa\n\nccc";
+ const blankHash = computeLineHash(2, "");
+ const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }];
+ const result = applyHashlineEdits(content, edits);
+ expect(result.lines).toBe("aaa\n\ninserted\n\nccc");
+ expect(result.firstChangedLine).toBe(2);
+ });
+
it("empty edits array is a no-op", () => {
const content = "aaa\nbbb";
const result = applyHashlineEdits(content, []);