From 30793c1655a2f3ad47f43a3b3598afec2b0daef6 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 26 May 2026 12:34:01 +0200 Subject: [PATCH] refactor: restructured hashline to use file-level hash validation with colon separators - Replaced per-line hash anchors with file-level hash validation in hashline format, changing anchor syntax from LINE+HASH to bare LINE numbers. - Simplified hashline line separator from pipe (|) to colon (:) and replaced replace operator (->) with colon, added delete operator (!) for explicit line deletion. - Implemented file-read snapshot caching with multi-snapshot ring buffer per path and file-hash-based recovery to detect and recover from stale edits. - Refactored hashline grammar, parser, and execution to support file-level hash binding, anchor-scoped validation, and structural bracket warnings for delete operations. - Updated documentation and test fixtures to reflect new hashline syntax with file hashes, colon separators, and delete operator throughout. --- docs/tools/ast-grep.md | 4 +- docs/tools/edit.md | 133 +++-- docs/tools/read.md | 6 +- docs/tools/search.md | 8 +- docs/tools/write.md | 2 +- packages/agent/src/harmony-leak.ts | 2 +- packages/coding-agent/CHANGELOG.md | 26 +- .../src/config/prompt-templates.ts | 12 +- .../src/config/settings-schema.ts | 3 +- .../coding-agent/src/edit/file-read-cache.ts | 93 +++- packages/coding-agent/src/edit/index.ts | 2 +- packages/coding-agent/src/edit/renderer.ts | 12 +- packages/coding-agent/src/edit/streaming.ts | 80 +-- packages/coding-agent/src/hashline/anchors.ts | 121 +++-- packages/coding-agent/src/hashline/apply.ts | 60 ++- .../coding-agent/src/hashline/constants.ts | 3 - .../coding-agent/src/hashline/diff-preview.ts | 9 +- packages/coding-agent/src/hashline/diff.ts | 34 +- packages/coding-agent/src/hashline/execute.ts | 117 ++++- .../coding-agent/src/hashline/executor.ts | 239 +++++++++ .../coding-agent/src/hashline/grammar.lark | 19 +- packages/coding-agent/src/hashline/hash.ts | 175 +++---- packages/coding-agent/src/hashline/index.ts | 3 +- packages/coding-agent/src/hashline/input.ts | 87 ++-- packages/coding-agent/src/hashline/parser.ts | 251 ---------- .../coding-agent/src/hashline/prefixes.ts | 32 +- .../coding-agent/src/hashline/recovery.ts | 134 +++-- packages/coding-agent/src/hashline/stream.ts | 4 +- .../coding-agent/src/hashline/tokenizer.ts | 467 +++++++++++++++++ packages/coding-agent/src/hashline/types.ts | 14 +- .../src/prompts/tools/ast-edit.md | 2 +- .../src/prompts/tools/ast-grep.md | 2 +- .../src/prompts/tools/hashline.md | 137 ++--- .../coding-agent/src/prompts/tools/read.md | 8 +- .../coding-agent/src/prompts/tools/search.md | 2 +- packages/coding-agent/src/tools/ast-edit.ts | 49 +- packages/coding-agent/src/tools/ast-grep.ts | 49 +- .../src/tools/match-line-format.ts | 12 +- packages/coding-agent/src/tools/read.ts | 175 +++++-- packages/coding-agent/src/tools/search.ts | 47 +- packages/coding-agent/src/tools/write.ts | 6 +- .../coding-agent/src/utils/file-mentions.ts | 4 +- .../coding-agent/test/core/hashline.test.ts | 447 +++++++++++------ packages/coding-agent/test/edit-diff.test.ts | 7 +- .../test/prompt-templates.test.ts | 8 +- .../test/read-multi-range.test.ts | 2 +- .../coding-agent/test/read-summary.test.ts | 21 +- packages/coding-agent/test/tools.test.ts | 15 +- .../coding-agent/test/tools/ast-edit.test.ts | 13 +- .../coding-agent/test/tools/ast-grep.test.ts | 5 +- .../test/tools/conflict-integration.test.ts | 2 +- .../test/tools/search-internal-urls.test.ts | 15 +- .../test/tools/search-path-lists.test.ts | 42 +- .../typescript-edit-benchmark/src/index.ts | 8 +- .../typescript-edit-benchmark/src/report.ts | 94 ++-- .../typescript-edit-benchmark/src/runner.ts | 474 ++++++++++-------- .../test/runner.test.ts | 96 +++- 57 files changed, 2520 insertions(+), 1374 deletions(-) create mode 100644 packages/coding-agent/src/hashline/executor.ts delete mode 100644 packages/coding-agent/src/hashline/parser.ts create mode 100644 packages/coding-agent/src/hashline/tokenizer.ts diff --git a/docs/tools/ast-grep.md b/docs/tools/ast-grep.md index 97282d6ef..9bda347e8 100644 --- a/docs/tools/ast-grep.md +++ b/docs/tools/ast-grep.md @@ -10,7 +10,7 @@ - `crates/pi-natives/src/language/mod.rs` — language aliases and extension inference - `packages/coding-agent/src/tools/path-utils.ts` — path/glob parsing and multi-path resolution - `packages/coding-agent/src/tools/render-utils.ts` — parse-error dedupe and display caps - - `packages/coding-agent/src/tools/match-line-format.ts` — anchor-prefixed match rendering + - `packages/coding-agent/src/tools/match-line-format.ts` — hashline match rendering - `packages/coding-agent/src/utils/file-display-mode.ts` — hashline vs line-number output mode - `packages/natives/native/index.d.ts` — JS-visible native binding contract @@ -36,7 +36,7 @@ Pattern grammar and language support exposed to the model: - Single-shot tool result. - Model-facing `content` is one text block: - grouped by file for directory/multi-file searches, - - match lines rendered as `*LINE+HASH|text` in hashline mode or `*LINE|text` otherwise, + - match lines rendered under `¶PATH#HASH` as `*LINE:text` in hashline mode or `*LINE|text` otherwise, - continuation lines for multi-line matches rendered with a leading space, - optional `meta: NAME=value` lines when ast-grep captured metavariables. - If no matches are found, text is `No matches found` or `No matches found. Parse issues mean the query may be mis-scoped; narrow paths before concluding absence.` plus formatted parse issues. diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 27285a3ba..29a11e4c3 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -13,7 +13,7 @@ - `packages/coding-agent/src/hashline/apply.ts` — validates anchors and applies edits - `packages/coding-agent/src/hashline/anchors.ts` — stale-anchor mismatch formatting - `packages/coding-agent/src/hashline/recovery.ts` — cache-based stale-anchor recovery - - `packages/coding-agent/src/hashline/hash.ts` — computes `LINEhh|` anchors shared with `read`/`search` + - `packages/coding-agent/src/hashline/hash.ts` — computes 4-hex file hashes and `LINE:TEXT` display lines shared with `read`/`search` - `packages/coding-agent/src/edit/file-read-cache.ts` — per-session read snapshot cache - `packages/coding-agent/src/tools/read.ts` — emits anchored lines and records read snapshots - `packages/coding-agent/src/tools/search.ts` — records sparse snapshots from matches/context @@ -26,21 +26,25 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `input` | `string` | Yes | One or more edit sections. First non-blank line must be `¶PATH` unless the caller supplies the legacy fallback `path` outside the model schema and the body already looks like hashline ops (`packages/coding-agent/src/hashline/input.ts`). Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. | +| `input` | `string` | Yes | One or more edit sections. Anchored sections must start with `¶PATH#HASH`; unbound `¶PATH` is allowed only for new-file / `BOF` / `EOF` boundary inserts. Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. | Patch language inside `input`: -- Section header: `¶PATH` -- Insert after: `ANCHOR↓` -- Insert before: `ANCHOR↑` -- Replace/delete range: `A-B→` -- Single-line replace/delete sugar: `A→` means `A-A→` -- `A-B→` with no payload deletes the range. To keep a blank line, include one explicit empty payload line. -- Inline payload: content after `↓`/`↑`/`→` on the same line is the first payload line; subsequent lines append to it +- Section header: `¶PATH#HASH` for anchored edits, `¶PATH` for BOF/EOF-only inserts +- Insert after: `LINE↓` +- Insert before: `LINE↑` +- Replace range: `A-B:` +- Single-line replace sugar: `A:` means `A-A:` +- Delete range: `A-B!` +- Single-line delete sugar: `A!` means `A-A!` +- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent payload lines. Bare `A↑` / `A↓` insert one blank line; bare `A:` / `A-B:` (no payload) replaces the line/range with a single blank line. Use `A!` / `A-B!` to delete entirely. +- `!` deletes and forbids payload. +- Inline payload: content after `↓`/`↑`/`:` on the same line is the first payload line; subsequent lines append to it. Read lines like `84:content` are already valid single-line replacements. - Special anchors: `BOF`, `EOF` -- Anchor token: `<2-char-hash>`, for example `41th` +- Anchor token: bare line number, for example `41` +- File binding: 4-hex hash in the section header, for example `¶src/a.ts#1a2b` -Anchors come from `read`/`search` output. `read` formats lines as `LINEhh|TEXT` via `formatHashLine` / `formatHashLines` in `packages/coding-agent/src/hashline/hash.ts`; copy only the token left of `|` into op lines. +Anchors come from `read`/`search` output. `read` emits a `¶PATH#HASH` header and lines as `LINE:TEXT`; copy the header into the edit section and copy only the line number into op lines. Other edit modes exist (`replace`, `patch`, `apply_patch`) and are selected outside the tool payload by `resolveEditMode()` in `packages/coding-agent/src/utils/edit-mode.ts`. Their schemas are different; this document covers the default hashline mode. @@ -67,8 +71,8 @@ Warnings: - While the model is still typing arguments, the TUI can compute a diff preview with `packages/coding-agent/src/edit/streaming.ts`; that preview is not a deferred action and does not block execution. ## Flow -1. `EditTool.execute()` in `packages/coding-agent/src/edit/index.ts` resolves the active mode. Default is `hashline`; `customFormat` exposes `packages/coding-agent/src/hashline/grammar.lark` with `$HFMT$` / `$HOP_INSERT_BEFORE$` / `$HOP_INSERT_AFTER$` / `$HOP_REPLACE$` / `$HOP_CHARS$` / `$HFILE$` placeholders filled from `packages/coding-agent/src/hashline/hash.ts`. -2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH` sections with `splitHashlineInputs()`. +1. `EditTool.execute()` in `packages/coding-agent/src/edit/index.ts` resolves the active mode. Default is `hashline`; `customFormat` exposes `packages/coding-agent/src/hashline/grammar.lark` with `$HFILE_HASH$` / `$HOP_INSERT_BEFORE$` / `$HOP_INSERT_AFTER$` / `$HOP_REPLACE$` / `$HOP_CHARS$` / `$HFILE$` placeholders filled from `packages/coding-agent/src/hashline/hash.ts`. +2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH#HASH` / `¶PATH` sections with `splitHashlineInputs()`. 3. If multiple sections target the same path, `mergeSamePathSections()` concatenates them before execution so every op still refers to the original file snapshot. 4. Multi-section calls run a preflight pass (`preflightHashlineSection()`): parse ops, enforce plan-mode write rules, load the current file, reject anchor-scoped edits against missing files, reject auto-generated files, apply edits in memory, and fail if the result is a no-op. This prevents partial batches. 5. `parseHashlineWithWarnings()` in `packages/coding-agent/src/hashline/parser.ts` tokenizes the diff body: @@ -76,11 +80,11 @@ Warnings: - stops at `*** End Patch` - stops at `*** Abort` and emits `ABORT_WARNING` - turns `↓` / `↑` payload runs into one `insert` edit per payload line - - turns `A-B→` with payload into inserts before `A`, then deletes for `A-B` - - turns `A-B→` with no payload into one `delete` edit per line in the range; a blank-in-place edit requires one explicit empty payload line -6. `applyHashlineEdits()` in `packages/coding-agent/src/hashline/apply.ts` validates every referenced anchor before mutating anything. Each anchor hash is recomputed from current file content with `computeLineHash()`. -7. If any anchor hash differs, `applyHashlineEdits()` throws `HashlineMismatchError`. `execute.ts` catches only that class and calls `tryRecoverHashlineWithCache()`. -8. Recovery replays the edits against the most recent cached read/search snapshot for that path (`packages/coding-agent/src/edit/file-read-cache.ts`), then 3-way merges the result onto current disk content using `Diff.applyPatch(..., { fuzzFactor: 3 })` in `packages/coding-agent/src/hashline/recovery.ts`. On success the edit proceeds with a warning; on failure the original mismatch error is re-thrown. + - turns `A-B:` with payload into inserts before `A`, then deletes for `A-B` + - turns `A-B!` into one `delete` edit per line in the range; payload is forbidden +6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write. +7. `applyHashlineEdits()` validates only line bounds, then applies the already hash-bound line-number edits. +8. Recovery replays the edits against the cached snapshot for the section hash (`packages/coding-agent/src/edit/file-read-cache.ts`), then 3-way merges the result onto current disk content using `Diff.applyPatch(..., { fuzzFactor: 0 })` in `packages/coding-agent/src/hashline/recovery.ts`. On success the edit proceeds with a warning; on failure a `HashlineMismatchError` is surfaced. 9. Before splicing lines, `absorbReplacementBoundaryDuplicates()` normalizes some malformed-but-recoverable ranges: - duplicate prefix/suffix lines adjacent to a replacement can be absorbed by widening the delete range - pure inserts can auto-drop duplicated leading/trailing payload lines when `edit.hashlineAutoDropPureInsertDuplicates` is enabled @@ -102,29 +106,36 @@ Warnings: Hashline op examples: ```text -¶src/a.ts -4fb↓ +¶src/a.ts#1a2b +4↓ const added = true; ``` ```text -¶src/a.ts -4fb↑ +¶src/a.ts#1a2b +4↑ const addedBefore = true; ``` ```text -¶src/a.ts -4fb-6qx→ +¶src/a.ts#1a2b +4-6: +const replacement = true; ``` ```text -¶src/a.ts -4fb-5dm→ +¶src/a.ts#1a2b +4-5: const clean = (name || DEF).trim(); return clean.length === 0 ? DEF : clean.toUpperCase(); ``` +```text +¶src/a.ts#1a2b +4: +const clean = (name || DEF).trim(); +``` + BOF/EOF examples: ```text @@ -142,16 +153,30 @@ export const done = true; Delete / blank examples: ```text -¶src/a.ts -4fb→ +¶src/a.ts#1a2b +4! ``` ```text -¶src/a.ts -4fb→ +¶src/a.ts#1a2b +4: -EOF↓ -export const done = true; +``` + +```text +¶src/a.ts#1a2b +4-6! +``` + +Multi-file example: + +```text +¶src/a.ts#1a2b +4: +const enabled = true; + +¶src/b.ts#3c4d +20! ``` ## Side Effects @@ -172,50 +197,50 @@ export const done = true; ## Limits & Caps - Default mode is `hashline` (`DEFAULT_EDIT_MODE`) in `packages/coding-agent/src/utils/edit-mode.ts`. -- Anchor hashes are always 2 lowercase letters from a stable 647-entry bigram table (`HL_BIGRAMS_COUNT`) in `packages/coding-agent/src/hashline/hash.ts`. +- File hashes are 4 lowercase hex chars from `computeFileHash()` in `packages/coding-agent/src/hashline/hash.ts`. - The visible mismatch report shows 2 lines of context on each side (`MISMATCH_CONTEXT`) in `packages/coding-agent/src/hashline/constants.ts`. -- Stale-anchor recovery uses `fuzzFactor: 3` (`HASHLINE_RECOVERY_FUZZ_FACTOR`) in `packages/coding-agent/src/hashline/recovery.ts`. +- Stale-anchor recovery uses `fuzzFactor: 0` (`HASHLINE_RECOVERY_FUZZ_FACTOR`) in `packages/coding-agent/src/hashline/recovery.ts`. - The per-session read cache keeps at most 30 paths (`MAX_PATHS_PER_SESSION`) in `packages/coding-agent/src/edit/file-read-cache.ts`. - Hashline streaming chunk defaults are 200 lines or 64 KiB per chunk (`packages/coding-agent/src/hashline/types.ts`, consumed by `packages/coding-agent/src/hashline/stream.ts`). -- `HL_OP_INSERT_BEFORE` is `↑`, `HL_OP_INSERT_AFTER` is `↓`, `HL_OP_REPLACE` is `→`, `HL_OP_CHARS` is `↑↓→`, `HL_FILE_PREFIX` is `¶`, and `HL_BODY_SEP` is `|` (`packages/coding-agent/src/hashline/hash.ts`). +- `HL_OP_INSERT_BEFORE` is `↑`, `HL_OP_INSERT_AFTER` is `↓`, `HL_OP_REPLACE` is `:`, `HL_OP_DELETE` is `!`, `HL_OP_CHARS` is `↑↓:!`, `HL_FILE_PREFIX` is `¶`, `HL_FILE_HASH_SEP` is `#`, and `HL_LINE_BODY_SEP` is `:` (`packages/coding-agent/src/hashline/hash.ts`). ## Errors - Missing section header: - - `input must begin with "¶PATH" on the first non-blank line; got: ... Example: "¶src/foo.ts" then edit ops.` + - `input must begin with "¶PATH#HASH" on the first non-blank line for anchored edits; got: ...` - Empty header: - `Input header "¶" is empty; provide a file path.` +- Missing hash for anchored edit: + - `Missing hashline file hash for anchored edit to ; use ¶#hash from your latest read.` +- Line-hash anchors in edit ops: + - `line N: edit ops use bare line numbers. Copy the ¶PATH#hash header, then use anchors like 42, 42-45, BOF, or EOF.` - Bad anchor token: - - `line N: expected a full anchor such as "119sr"; got "...".` + - `line N: expected a line number such as "119"; got "...".` - Bad range syntax: - - `line N: range must be ANCHOR or ANCHOR-ANCHOR (one dash, no spaces); got ...` + - `line N: range must be LINE or LINE-LINE (one dash, no spaces); got ...` - `line N: range A-B ends before it starts.` - - `line N: range A-B uses two different hashes for the same line.` -- Missing payload for `↓` / `↑`: - - `line N: ↑ and ↓ operations require at least one verbatim payload line.` +- Payload forbidden for `!`: + - `line N: ! deletes only. Payload is forbidden after !; use : to replace.` - Stray payload line: - - `line N: payload line has no preceding ↑, ↓, or → operation.` + - `line N: payload line has no preceding ↑, ↓, :, or ! operation.` - Unknown op: - - `line N: unrecognized op. Use ANCHOR↑ (insert before), ANCHOR↓ (insert after), or A-B→ (replace/delete).` -- Delete vs blank: - - `A-B→` with no payload deletes. To blank in place, include one explicit empty payload line before the next op/header/EOF. + - `line N: unrecognized op. Use LINE↑ (insert before), LINE↓ (insert after), LINE: / A-B: (replace), or LINE! / A-B! (delete).` - Missing file for anchor-scoped edits: - `File not found: ` - Out-of-range anchor: - `Line N does not exist (file has M lines)` -- Stale anchors throw `HashlineMismatchError`. The error message contains re-read guidance and reprints nearby current file lines as `LINEhh|TEXT`; mismatched lines are marked `*`. `displayMessage` renders the same information in a code-frame style. +- Stale file hash throws `HashlineMismatchError`. The error contains both hashes, re-read guidance, and nearby current file lines as `*LINE:TEXT` / ` LINE:TEXT`. - No-op edit: - `Edits to resulted in no changes being made.` -- Recovery failure is silent internally: if cache-based merge cannot prove a valid result, the original mismatch error is surfaced unchanged. +- Recovery failure is silent internally: if cache-based merge cannot prove a valid result, the mismatch error is surfaced unchanged. ## Notes -- `read` and `search` are the authoritative source of anchors. The edit parser does not want the trailing `|TEXT`; copy only the `LINEhh` token. +- `read` and `search` are the authoritative source of section hashes. Copy `¶PATH#HASH`; op lines use bare line numbers and do not want the trailing `:TEXT`. - Multi-op patches are parsed against the original file snapshot. Do not renumber later anchors after earlier ops; `applyHashlineEdits()` buckets and applies them bottom-up. -- `A-B→` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`; with no payload, it only deletes `A-B`. To blank in place, include one explicit empty payload line. Stale-anchor checking still happens on the original range lines. -- Interior lines of a multi-line range use hash `**` (`RANGE_INTERIOR_HASH`) and are not individually verified; only the first and last anchor hashes are checked. -- `computeLineHash()` trims trailing whitespace before hashing. Anchors survive line-ending changes and trailing-space-only changes, but not substantive line edits. -- For punctuation-only lines, the hash mixes in the line number; identical `}` lines on different lines intentionally get different anchors. -- `splitHashlineInputs()` normalizes absolute `¶PATH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH`. -- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH`-based, not Codex `*** Update File:` hunks. +- Failed hand-edits often come from sequentially shifting later anchors inside the same patch. Treat every op as using the line numbers from the original section header. +- `A-B:` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`. `A-B!` is the direct delete form. Bare `A:` / `A-B:` (no payload) replaces with a single blank line; bare `↑` / `↓` insert a blank line. +- `computeFileHash()` normalizes CR characters and trailing whitespace before hashing. The section survives line-ending and trailing-space-only changes, but not substantive file edits. +- `splitHashlineInputs()` normalizes absolute `¶PATH#HASH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH#HASH` for anchored edits. +- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH#HASH`-based, not Codex `*** Update File:` hunks. - `*** Abort` terminates parsing early and returns `ABORT_WARNING`; ops parsed before the marker still apply. - File-read cache invalidation is conflict-based, not write-through invalidation. If `read` later records content for a line that disagrees with the cached snapshot, the entire snapshot for that path is replaced with the newly observed lines (`packages/coding-agent/src/edit/file-read-cache.ts`). - There is no resolve-style apply/discard phase for hashline edits. The only preview path is the transient TUI diff preview in `packages/coding-agent/src/edit/streaming.ts`. diff --git a/docs/tools/read.md b/docs/tools/read.md index f99e75edb..19559390d 100644 --- a/docs/tools/read.md +++ b/docs/tools/read.md @@ -101,11 +101,11 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts - Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`. - Explicit ranges expand by `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` on the constrained sides only. - Non-raw output uses `resolveFileDisplayMode()`: - - hashline anchors when edit mode is hashline, read is not raw, source is mutable, edit tool exists, and `readHashLines !== false` + - hashline numbered output when edit mode is hashline, read is not raw, source is mutable, edit tool exists, and `readHashLines !== false` - otherwise optional line numbers when `readLineNumbers === true` - raw mode suppresses both -- Prefix format in hashline mode is `lineNumber + 2-char line hash + "|"`, e.g. `41th|def alpha():`, from `formatHashLine()` in `packages/coding-agent/src/hashline/hash.ts`. -- Those anchors are what the `edit`/hashline path consumes later; immutable sources and `:raw` intentionally suppress them. +- Prefix format in hashline mode is a `¶PATH#HASH` header followed by `LINE:TEXT`, e.g. `¶src/foo.ts#1a2b` and `41:def alpha():`, from `computeFileHash()` / `formatNumberedLine()` in `packages/coding-agent/src/hashline/hash.ts`. +- The `edit`/hashline path consumes that header plus bare line numbers later; immutable sources and `:raw` intentionally suppress them. ### Directory listings - `#readDirectory()` calls `buildDirectoryTree()` with: diff --git a/docs/tools/search.md b/docs/tools/search.md index 6fbe4badd..f481bf9d8 100644 --- a/docs/tools/search.md +++ b/docs/tools/search.md @@ -29,8 +29,8 @@ ## Outputs The tool returns a single text block in `content[0].text` plus structured `details`. -- Match lines are formatted by `formatMatchLine()` as `*|` for matches and ` |` for context. - - Hashline mode: `*5th|content`, ` 9x}|content`. +- Match lines are formatted by `formatMatchLine()` as `*LINE:content` for matches and ` LINE:content` for context under a `¶PATH#HASH` header in hashline mode. + - Hashline mode: `¶src/login.ts#3c4d`, `*5:content`, ` 9:content`. - Plain mode: `*5|content`, ` 9|content`. - Directory results are grouped by file, with `# ` headings and blank lines between groups. - `details` may include: @@ -54,7 +54,7 @@ The tool returns a single text block in `content[0].text` plus structured `detai 3. Internal URLs are resolved through `session.internalRouter`: - glob metacharacters (`*`, `?`, `[`, `{`) are rejected for internal URLs; - URLs without `resource.sourcePath` fail; - - immutable sources are tracked so output can suppress editable hashline anchors per file. + - immutable sources are tracked so output can suppress editable hashline numbered output per file. 4. For multi-path calls, `partitionExistingPaths()` skips only ENOENT entries. If every entry is missing, the tool errors. 5. Path resolution branches: - one entry: `parseSearchPath()` splits `basePath` and optional glob; @@ -140,4 +140,4 @@ The tool returns a single text block in `content[0].text` plus structured `detai - `hidden:true` is hard-coded in `search.ts`; there is no model-facing flag to exclude dotfiles. - `gitignore:false` only affects native directory traversal. It does not disable the tool's own path normalization or explicit-file handling. - When `paths` resolves to multiple exact files, `search.ts` does not apply the native `500` match cap and reports `totalMatches` internally as the post-skip length for that branch. -- The anchor suffix in hashline mode comes from `computeLineHash()` in `packages/coding-agent/src/hashline/hash.ts`; `search` itself only formats it. +- The section hash in hashline mode comes from `computeFileHash()` in `packages/coding-agent/src/hashline/hash.ts`; `search` emits bare line numbers beneath it. diff --git a/docs/tools/write.md b/docs/tools/write.md index 18b5aa97b..5b4047ba0 100644 --- a/docs/tools/write.md +++ b/docs/tools/write.md @@ -49,7 +49,7 @@ Single-shot result. - Archive writes return empty `details`. ## Flow -1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips `LINE+ID|` hashline prefixes from `content` when the session is in hashline display mode. +1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips pasted `¶PATH#HASH` headers and `LINE:` hashline prefixes from `content` when the session is in hashline display mode. 2. It calls `#resolveArchiveWritePath()` first. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/tools/archive-reader.ts`, checks candidate archive files on disk, and falls back to the longest matching archive suffix even when the archive file does not exist yet. 3. Archive writes call `enforcePlanModeWrite(..., { op: exists ? "update" : "create" })`, then `#writeArchiveEntry()`. - The parent directory of the archive file is created with `fs.mkdir(..., { recursive: true })`. diff --git a/packages/agent/src/harmony-leak.ts b/packages/agent/src/harmony-leak.ts index db743a8f4..545c8e16e 100644 --- a/packages/agent/src/harmony-leak.ts +++ b/packages/agent/src/harmony-leak.ts @@ -38,7 +38,7 @@ const SCRIPT_CLASS = const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u"); // Recovery registry. Each entry's parser must recognize the configured -// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface +// sentinel (per-tool, see eval/parse.ts and hashline/executor.ts) and surface // a warning to the model so it knows to re-issue any remaining work. // `accepts` gates on input shape: tools whose contaminated input doesn't // match the parser's expected DSL fall through to abort-and-retry. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3297862ae..110d8e1f1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,7 +3,6 @@ ## [Unreleased] ### Breaking Changes - - The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode ### Added @@ -28,7 +27,32 @@ ### Removed +- Removed the `installH2Fetch()` activation from CLI startup; HTTPS fetches now use Bun's default transport - Removed the `vim` edit mode along with the `VimTool` module, prompt, and supporting buffer/engine/renderer stack +- Removed per-line hash anchors (2-letter bigram hashes) from hashline format +- Removed `RANGE_INTERIOR_HASH` constant; multi-line ranges no longer use `**` filler +- Removed `HashMismatch` type and hash mismatch error reporting; replaced with file-level validation +### Added + +- Added file-hash computation and validation for hashline sections to detect stale edits +- Added file-read snapshot caching with multi-snapshot ring per path for recovery from agent's own writes +- Added delete operation (`!`) support to hashline grammar for explicit line deletion +- Added structural bracket/brace balance warnings when deleting lines with unclosed constructs + +### Changed + +- Bare `A:` / `A-B:` (no payload, no inline body) now replaces the line/range with a single blank line, symmetric with bare `A↑` / `A↓` inserting a blank line; previously rejected as ambiguous +- Simplified hashline anchor format from `LINE+HASH` to bare `LINE` numbers in edit operations +- Updated hashline file headers to include 4-hex file hash: `¶PATH#HASH` format for anchored edits +- Changed hashline line separator from `|` to `:` in editable output (e.g., `42:content` instead of `42ab|content`) +- Removed per-line hash validation; file-level hash now validates entire section integrity +- Updated read/search output to emit file-hash headers (`¶PATH#HASH`) followed by numbered lines for hashline mode +- Modified hashline grammar to accept optional file hash in headers and removed hash requirements from line anchors +- Changed hashline diff preview format to use `LINE:content` instead of `LINE+HASH|content` +- Updated prompt documentation to reflect new `¶PATH#HASH` header and bare line-number syntax + +### Removed + - Removed per-line hash anchors (2-letter bigram hashes) from hashline format - Removed `RANGE_INTERIOR_HASH` constant; multi-line ranges no longer use `**` filler - Removed `HashMismatch` type and hash mismatch error reporting; replaced with file-level validation diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 5f833e787..7daa327de 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -8,7 +8,7 @@ import { parseFrontmatter, prompt, } from "@oh-my-pi/pi-utils"; -import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; +import { HL_LINE_BODY_SEP } from "../hashline/hash"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; @@ -34,7 +34,7 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10); const raw = typeof content === "string" ? content : String(content ?? ""); const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - const ref = `${num}${computeLineHash(num, text)}`; + const ref = `${num}`; return { num, text, ref }; } @@ -124,11 +124,11 @@ function resolveHashlineRef(state: HashlineHelperState, args: unknown[]): string } /** - * {{href lineNum "content"}} — compute a real hashline ref for prompt examples. + * {{href lineNum "content"}} — compute a hashline line ref for prompt examples. * {{href lineNum}} — quote the ref remembered by the earlier {{hline lineNum "..."}} * {{href}} — quote the ref from the previous {{hline}} call. * {{href "[" "]"}} — wrap the previous {{hline}} ref with pre/post chars. - * Returns `"lineNumBIGRAM"` (e.g., `"42nd"`), or `"[42nd]"` when pre/post are supplied. + * Returns `"lineNum"` (e.g., `"42"`), or `"[42]"` when pre/post are supplied. */ prompt.registerHelper("href", function (this: unknown, ...args: unknown[]): string { const { positional, options } = splitHelperArgs(args); @@ -143,7 +143,7 @@ prompt.registerHelper("hrefr", function (this: unknown, ...args: unknown[]): str /** * {{hline lineNum "content"}} — format a full read-style line with prefix. - * Returns `"lineNumBIGRAM|content"` (pipe between anchor and content). + * Returns `"lineNum:content"` (colon between line number and content). */ prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): string { const { positional, options } = splitHelperArgs(args); @@ -151,7 +151,7 @@ prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): str const { num, ref, text } = formatHashlineRef(lineNum, content); const state = getHashlineHelperState(this, options); rememberHashlineRef(state, num, ref); - return `${ref}${HL_BODY_SEP}${text}`; + return `${ref}${HL_LINE_BODY_SEP}${text}`; }); const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c0740b2d2..25b96b91e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1603,7 +1603,8 @@ export const SETTINGS_SCHEMA = { ui: { tab: "editing", label: "Hash Lines", - description: "Include line hashes in read output for hashline edit mode (LINE+ID|content)", + description: + "Include file-hash headers and line numbers in read output for hashline edit mode (¶PATH#hash plus LINE:content)", }, }, diff --git a/packages/coding-agent/src/edit/file-read-cache.ts b/packages/coding-agent/src/edit/file-read-cache.ts index 33065e52a..54a2f1897 100644 --- a/packages/coding-agent/src/edit/file-read-cache.ts +++ b/packages/coding-agent/src/edit/file-read-cache.ts @@ -11,68 +11,101 @@ * Scoped per `ToolSession`: the cache lives on the session object itself, so * different sessions never share snapshots and entries get reclaimed when * the session goes out of scope. Each session keeps a small LRU window of - * paths; the cache always reflects what *this* session most recently saw, - * so it stays correct by construction even when this session writes the - * file itself — the next read after the write refreshes the entry. + * paths; each path keeps a short ring of recent snapshots so follow-up edits + * can recover from the agent's own prior writes as well as stale reads. */ import { LRUCache } from "lru-cache/raw"; import type { ToolSession } from "../tools"; const MAX_PATHS_PER_SESSION = 30; +const MAX_SNAPSHOTS_PER_PATH = 4; export interface FileReadSnapshot { /** 1-indexed line number → exact line content as observed by `read`/`search`. */ lines: Map; + /** Full normalized text when the read path observed the whole file. */ + fullText?: string; + /** 4-hex hash of `fullText`, or a sparse snapshot hash supplied by search. */ + fileHash?: string; recordedAt: number; } +interface FileReadSnapshotMetadata { + fullText?: string; + fileHash?: string; +} + export class FileReadCache { - #snapshots = new LRUCache({ max: MAX_PATHS_PER_SESSION }); + #snapshots = new LRUCache({ max: MAX_PATHS_PER_SESSION }); /** Look up the most recent snapshot for `absPath`, or `null` if absent. */ get(absPath: string): FileReadSnapshot | null { - return this.#snapshots.get(absPath) ?? null; + return this.#snapshots.get(absPath)?.[0] ?? null; + } + + /** Look up the most recent snapshot for `absPath` whose file hash matches. */ + getByHash(absPath: string, fileHash: string): FileReadSnapshot | null { + const history = this.#snapshots.get(absPath); + return history?.find(snapshot => snapshot.fileHash === fileHash) ?? null; } /** Record a contiguous run of lines (e.g. from a `read` tool). `startLine` is 1-indexed. */ - recordContiguous(absPath: string, startLine: number, lines: readonly string[]): void { - if (lines.length === 0) return; + recordContiguous( + absPath: string, + startLine: number, + lines: readonly string[], + metadata: FileReadSnapshotMetadata = {}, + ): void { + if (lines.length === 0 && metadata.fullText === undefined) return; const entries: Array = lines.map((line, idx) => [startLine + idx, line] as const); - this.#record(absPath, entries); + this.#record(absPath, entries, metadata); } /** Record sparse `(lineNumber, content)` pairs (e.g. `search` matches plus context). */ - recordSparse(absPath: string, entries: Iterable): void { + recordSparse( + absPath: string, + entries: Iterable, + metadata: FileReadSnapshotMetadata = {}, + ): void { const arr = Array.from(entries); - if (arr.length === 0) return; - this.#record(absPath, arr); + if (arr.length === 0 && metadata.fullText === undefined) return; + this.#record(absPath, arr, metadata); } - /** Drop the snapshot for a single path. */ + /** Drop the snapshot history for a single path. */ invalidate(absPath: string): void { this.#snapshots.delete(absPath); } - /** Drop every snapshot. */ + /** Drop every snapshot history. */ clear(): void { this.#snapshots.clear(); } - #record(absPath: string, entries: ReadonlyArray): void { - const existing = this.#snapshots.get(absPath); - if (existing && hasConflict(existing.lines, entries)) { - // File content has changed since we last recorded. Drop the stale - // snapshot and start fresh with whatever we just observed. - this.#snapshots.set(absPath, { lines: new Map(entries), recordedAt: Date.now() }); - return; - } - if (existing) { - for (const [lineNum, content] of entries) existing.lines.set(lineNum, content); - existing.recordedAt = Date.now(); + #record( + absPath: string, + entries: ReadonlyArray, + metadata: FileReadSnapshotMetadata, + ): void { + const history = this.#snapshots.get(absPath) ?? []; + const head = history[0]; + const now = Date.now(); + if (head && !hasConflict(head.lines, entries) && !hasHashConflict(head, metadata)) { + for (const [lineNum, content] of entries) head.lines.set(lineNum, content); + if (metadata.fullText !== undefined) head.fullText = metadata.fullText; + if (metadata.fileHash !== undefined) head.fileHash = metadata.fileHash; + head.recordedAt = now; // `get` above already touched LRU recency for this key. return; } - this.#snapshots.set(absPath, { lines: new Map(entries), recordedAt: Date.now() }); + + const nextSnapshot: FileReadSnapshot = { + lines: new Map(entries), + ...metadata, + recordedAt: now, + }; + const dedupedHistory = history.filter(snapshot => !isSameSnapshotIdentity(snapshot, nextSnapshot)); + this.#snapshots.set(absPath, [nextSnapshot, ...dedupedHistory].slice(0, MAX_SNAPSHOTS_PER_PATH)); } } @@ -84,6 +117,16 @@ function hasConflict(existing: Map, incoming: ReadonlyArray { const STREAMING_FALLBACK_LINES = 12; const STREAMING_FALLBACK_WIDTH = 80; -function isHashlineHeaderLine(line: string): boolean { - return line.trimEnd().startsWith(HL_FILE_PREFIX); -} - -function parseHashlineHeaderPath(line: string): string { - const trimmed = line.trimEnd(); - let prefixEnd = 0; - while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++; - return trimmed.slice(prefixEnd).trim(); -} - -function isHashlineOpLine(line: string): boolean { - return isHashlineOpLineText(line); -} - -function isHashlineEnvelopeMarkerLine(line: string): boolean { - const trimmed = line.trimEnd(); - return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER; -} +// Streaming-preview classification reuses one tokenizer instance for the +// stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are +// cheap, but keeping a single module-level reference matches the rest of +// the hashline package. +const HASHLINE_TOKENIZER = new HashlineTokenizer(); function trimHashlineStreamingSyntax(lines: string[]): string[] { let index = lines.findIndex(line => line.trim().length > 0); if (index === -1) return []; - if (lines[index].trimEnd() === BEGIN_PATCH_MARKER) { + if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") { index++; while (index < lines.length && lines[index].trim().length === 0) index++; } - if (index < lines.length && isHashlineHeaderLine(lines[index])) { + if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") { index++; } - return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line)); + return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line)); } function renderHashlineInputFallback(input: string, uiTheme: Theme): string { @@ -380,32 +365,51 @@ function buildHashlineNaturalOrderPreviews( input: string, defaultPath: string | undefined, ): PerFileDiffPreview[] | null { - const lines = input.split("\n"); const groups = new Map(); let currentPath = defaultPath ?? ""; - const ensure = (path: string): string[] => { - let bucket = groups.get(path); + const ensure = (sectionPath: string): string[] => { + let bucket = groups.get(sectionPath); if (!bucket) { bucket = []; - groups.set(path, bucket); + groups.set(sectionPath, bucket); } return bucket; }; - for (const raw of lines) { - if (isHashlineEnvelopeMarkerLine(raw)) continue; - if (isHashlineHeaderLine(raw)) { - currentPath = parseHashlineHeaderPath(raw); - if (currentPath) ensure(currentPath); - continue; + + // Per-call instance: the streaming preview re-runs each tick with the + // cumulative input, and we need the line counter to start at 1. A + // dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free + // for stateless predicate use elsewhere in this module. + const streamer = new HashlineTokenizer(); + for (const token of streamer.tokenizeAll(input)) { + switch (token.kind) { + case "envelope-begin": + case "envelope-end": + case "abort": + case "op-insert": + case "op-replace": + case "op-delete": + continue; + case "header": + currentPath = token.path; + if (currentPath) ensure(currentPath); + continue; + case "blank": + if (!currentPath) continue; + ensure(currentPath).push("+"); + continue; + case "payload": + if (!currentPath) continue; + ensure(currentPath).push(`+${token.text}`); + continue; } - if (isHashlineOpLine(raw) || !currentPath) continue; - ensure(currentPath).push(`+${raw}`); } + if (groups.size === 0) return null; const previews: PerFileDiffPreview[] = []; - for (const [path, body] of groups) { + for (const [sectionPath, body] of groups) { if (body.length === 0) continue; - previews.push({ path, diff: body.join("\n") }); + previews.push({ path: sectionPath, diff: body.join("\n") }); } return previews.length > 0 ? previews : null; } diff --git a/packages/coding-agent/src/hashline/anchors.ts b/packages/coding-agent/src/hashline/anchors.ts index a0977e8e1..d7f482cff 100644 --- a/packages/coding-agent/src/hashline/anchors.ts +++ b/packages/coding-agent/src/hashline/anchors.ts @@ -1,113 +1,104 @@ -import { formatCodeFrameLine } from "../tools/render-utils"; import { MISMATCH_CONTEXT } from "./constants"; -import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash"; -import type { HashMismatch } from "./types"; +import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash"; -const HL_HASH_HINT_RE = /^[a-z]{2}$/i; -const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160"); -const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`); +const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/; export function formatFullAnchorRequirement(raw?: string): string { - const suffix = typeof raw === "string" ? raw.trim() : ""; - const hashOnlyHint = HL_HASH_HINT_RE.test(suffix) - ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` + - `Copy the full anchor exactly as shown (for example, "160${suffix}").` - : ""; const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`; return ( - `the full anchor exactly as shown by read/search output ` + - `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}` + `a bare line number from read/search output plus the section header file hash ` + + `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b and line "160")${received}` ); } -export function parseTag(ref: string): { line: number; hash: string } { - const match = ref.match(PARSE_TAG_RE); +export function parseTag(ref: string): { line: number } { + const match = ref.match(LINE_REF_RE); if (!match) { throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`); } const line = Number.parseInt(match[1], 10); if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`); - return { line, hash: match[2] }; + return { line }; } -function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] { +export interface HashlineMismatchDetails { + path?: string; + expectedFileHash: string; + actualFileHash: string; + fileLines: string[]; + anchorLines?: readonly number[]; +} + +function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] { const displayLines = new Set(); - for (const mismatch of mismatches) { - const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT); - const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT); + for (const line of anchorLines) { + if (line < 1 || line > fileLines.length) continue; + const lo = Math.max(1, line - MISMATCH_CONTEXT); + const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT); for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum); } return [...displayLines].sort((a, b) => a - b); } export class HashlineMismatchError extends Error { - readonly remaps: ReadonlyMap; + readonly path: string | undefined; + readonly expectedFileHash: string; + readonly actualFileHash: string; + readonly fileLines: string[]; + readonly anchorLines: readonly number[]; - constructor( - public readonly mismatches: HashMismatch[], - public readonly fileLines: string[], - ) { - super(HashlineMismatchError.formatMessage(mismatches, fileLines)); + constructor(details: HashlineMismatchDetails) { + super(HashlineMismatchError.formatMessage(details)); this.name = "HashlineMismatchError"; - - const remaps = new Map(); - for (const mismatch of mismatches) { - const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? ""); - remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`); - } - this.remaps = remaps; + this.path = details.path; + this.expectedFileHash = details.expectedFileHash; + this.actualFileHash = details.actualFileHash; + this.fileLines = details.fileLines; + this.anchorLines = details.anchorLines ?? []; } get displayMessage(): string { - return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines); + return HashlineMismatchError.formatDisplayMessage({ + path: this.path, + expectedFileHash: this.expectedFileHash, + actualFileHash: this.actualFileHash, + fileLines: this.fileLines, + anchorLines: this.anchorLines, + }); } - private static rejectionHeader(mismatches: HashMismatch[]): string[] { - const noun = mismatches.length > 1 ? "anchors do" : "anchor does"; + static rejectionHeader(details: HashlineMismatchDetails): string[] { + const pathText = details.path ? ` for ${details.path}` : ""; return [ - `Edit rejected: ${mismatches.length} ${noun} not match the current file (marked *).`, - "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.", + `Edit rejected${pathText}: file changed between read and edit.`, + `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}; re-read and try again.`, ]; } - static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string { - const mismatchSet = new Set(mismatches.map(m => m.line)); - const displayLines = getMismatchDisplayLines(mismatches, fileLines); - const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0); - - const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""]; - let previous = -1; - for (const lineNum of displayLines) { - if (previous !== -1 && lineNum > previous + 1) out.push("..."); - previous = lineNum; - const marker = mismatchSet.has(lineNum) ? "*" : " "; - out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width)); - } - return out.join("\n"); + static formatDisplayMessage(details: HashlineMismatchDetails): string { + return HashlineMismatchError.formatMessage(details); } - static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { - const mismatchSet = new Set(mismatches.map(m => m.line)); - const lines = HashlineMismatchError.rejectionHeader(mismatches); + static formatMessage(details: HashlineMismatchDetails): string { + const anchorSet = new Set(details.anchorLines ?? []); + const lines = HashlineMismatchError.rejectionHeader(details); + const displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines); + if (displayLines.length === 0) return lines.join("\n"); + lines.push(""); let previous = -1; - for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) { + for (const lineNum of displayLines) { if (previous !== -1 && lineNum > previous + 1) lines.push("..."); previous = lineNum; - const text = fileLines[lineNum - 1] ?? ""; - const hash = computeLineHash(lineNum, text); - const marker = mismatchSet.has(lineNum) ? "*" : " "; - lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`); + const text = details.fileLines[lineNum - 1] ?? ""; + const marker = anchorSet.has(lineNum) ? "*" : " "; + lines.push(`${marker}${formatNumberedLine(lineNum, text)}`); } return lines.join("\n"); } } -export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void { +export function validateLineRef(ref: { line: number }, fileLines: string[]): void { if (ref.line < 1 || ref.line > fileLines.length) { throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); } - const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? ""); - if (actualHash !== ref.hash) { - throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines); - } } diff --git a/packages/coding-agent/src/hashline/apply.ts b/packages/coding-agent/src/hashline/apply.ts index ea32131c3..7ea0282e8 100644 --- a/packages/coding-agent/src/hashline/apply.ts +++ b/packages/coding-agent/src/hashline/apply.ts @@ -1,8 +1,5 @@ -import { HashlineMismatchError } from "./anchors"; -import { RANGE_INTERIOR_HASH } from "./constants"; -import { computeLineHash } from "./hash"; -import { cloneCursor } from "./parser"; -import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types"; +import { cloneCursor } from "./tokenizer"; +import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit } from "./types"; export interface HashlineApplyResult { lines: string; @@ -43,26 +40,17 @@ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] { } /** - * Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`; - * there is no auto-rebase. Callers are expected to surface mismatches as - * `HashlineMismatchError` so the model re-reads and re-anchors. + * Verify every anchored edit points at an existing line. File-version binding is + * checked once per section via the header hash before this function runs. */ -function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] { - const mismatches: HashMismatch[] = []; +function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]): void { for (const edit of edits) { for (const anchor of getHashlineEditAnchors(edit)) { if (anchor.line < 1 || anchor.line > fileLines.length) { throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`); } - if (anchor.hash === RANGE_INTERIOR_HASH) continue; - - const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? ""); - if (actualHash === anchor.hash) continue; - - mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash }); } } - return mismatches; } function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void { @@ -287,15 +275,10 @@ function contiguousRange(start: number, count: number): number[] { return Array.from({ length: count }, (_, offset) => start + offset); } -function deleteEditForAutoAbsorbedLine( - line: number, - sourceLineNum: number, - index: number, - fileLines: string[], -): HashlineEdit { +function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): HashlineEdit { return { kind: "delete", - anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") }, + anchor: { line }, lineNum: sourceLineNum, index, }; @@ -314,7 +297,7 @@ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean { if (a.kind === "bof" || a.kind === "eof") return true; const aAnchor = (a as { anchor: Anchor }).anchor; const bAnchor = (b as { anchor: Anchor }).anchor; - return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash; + return aAnchor.line === bAnchor.line; } /** @@ -606,13 +589,13 @@ function absorbReplacementBoundaryDuplicates( } for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) { - absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); + absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++)); } for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) { absorbed.push(edits[groupIndex]); } for (const line of contiguousRange(endLine + 1, safeSuffixCount)) { - absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); + absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++)); } index = group.endIndex; @@ -653,8 +636,7 @@ export function applyHashlineEdits( if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; }; - const mismatches = validateHashlineAnchors(edits, fileLines); - if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines); + validateHashlineLineBounds(edits, fileLines); const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options); @@ -669,10 +651,9 @@ export function applyHashlineEdits( continue; } const nextLineNum = anchorLine + 1; - const nextContent = fileLines[nextLineNum - 1] ?? ""; edit.cursor = { kind: "before_anchor", - anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) }, + anchor: { line: nextLineNum }, }; } @@ -711,6 +692,23 @@ export function applyHashlineEdits( } if (beforeLines.length === 0 && !deleteLine) continue; + const replaceMode = beforeLines.length > 0; + if (deleteLine && !replaceMode) { + const balance = computeDelimiterBalance([currentLine]); + const trimmedCurrentLine = currentLine.trim(); + const touchesStructuralBoundary = + trimmedCurrentLine.startsWith(")") || + trimmedCurrentLine.startsWith("]") || + trimmedCurrentLine.startsWith("}") || + trimmedCurrentLine.endsWith("(") || + trimmedCurrentLine.endsWith("[") || + trimmedCurrentLine.endsWith("{"); + if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) { + warnings.push( + `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:' to keep the boundary intact.`, + ); + } + } const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine]; const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert")); if (!deleteLine) { diff --git a/packages/coding-agent/src/hashline/constants.ts b/packages/coding-agent/src/hashline/constants.ts index 0172a5290..99d28c094 100644 --- a/packages/coding-agent/src/hashline/constants.ts +++ b/packages/coding-agent/src/hashline/constants.ts @@ -1,9 +1,6 @@ /** Lines of context shown either side of a hash mismatch. */ export const MISMATCH_CONTEXT = 2; -/** Filler hash used for the interior of a multi-line range; not validated. */ -export const RANGE_INTERIOR_HASH = "**"; - /** Optional patch envelope start marker; silently consumed when present. */ export const BEGIN_PATCH_MARKER = "*** Begin Patch"; diff --git a/packages/coding-agent/src/hashline/diff-preview.ts b/packages/coding-agent/src/hashline/diff-preview.ts index 8b41950dc..624ebd39f 100644 --- a/packages/coding-agent/src/hashline/diff-preview.ts +++ b/packages/coding-agent/src/hashline/diff-preview.ts @@ -1,4 +1,3 @@ -import { computeLineHash, HL_BODY_SEP } from "./hash"; import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types"; export function buildCompactHashlineDiffPreview( @@ -11,7 +10,7 @@ export function buildCompactHashlineDiffPreview( // `generateDiffString` numbers `+` lines with the post-edit line number, // `-` lines with the pre-edit line number, and context lines with the - // pre-edit line number. To emit fresh anchors usable for follow-up edits, + // pre-edit line number. To emit fresh line numbers usable for follow-up edits, // we convert context-line numbers to post-edit positions by tracking the // running offset (added so far - removed so far) as we walk the diff. const formatted = lines.map(line => { @@ -28,13 +27,13 @@ export function buildCompactHashlineDiffPreview( switch (kind) { case "+": addedLines++; - return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`; + return `+${lineNumber}:${content}`; case "-": removedLines++; - return `-${lineNumber}--${HL_BODY_SEP}${content}`; + return `-${lineNumber}:${content}`; default: { const newLineNumber = lineNumber + addedLines - removedLines; - return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`; + return ` ${newLineNumber}:${content}`; } } }); diff --git a/packages/coding-agent/src/hashline/diff.ts b/packages/coding-agent/src/hashline/diff.ts index 36987e8b5..a6e97691d 100644 --- a/packages/coding-agent/src/hashline/diff.ts +++ b/packages/coding-agent/src/hashline/diff.ts @@ -3,9 +3,10 @@ import { normalizeToLF, stripBom } from "../edit/normalize"; import { readEditFileText } from "../edit/read-file"; import { resolveToCwd } from "../tools/path-utils"; import { applyHashlineEdits } from "./apply"; -import { type HashlineInputSection, splitHashlineInputs } from "./input"; -import { parseHashline } from "./parser"; -import type { HashlineApplyOptions } from "./types"; +import { parseHashline } from "./executor"; +import { computeFileHash } from "./hash"; +import { splitHashlineInputs } from "./input"; +import type { HashlineApplyOptions, HashlineEdit, HashlineInputSection } from "./types"; async function readHashlineFileText( _file: { text(): Promise }, @@ -20,6 +21,28 @@ async function readHashlineFileText( } } +function hasAnchorScopedEdit(edits: readonly HashlineEdit[]): boolean { + return edits.some(edit => { + if (edit.kind === "delete") return true; + return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; + }); +} + +function validateSectionHash( + section: HashlineInputSection, + text: string, + edits: readonly HashlineEdit[], +): string | null { + if (section.fileHash === undefined) { + return hasAnchorScopedEdit(edits) + ? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.` + : null; + } + const currentHash = computeFileHash(text); + if (currentHash === section.fileHash) return null; + return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`; +} + export async function computeHashlineSectionDiff( section: HashlineInputSection, cwd: string, @@ -30,7 +53,10 @@ export async function computeHashlineSectionDiff( const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path); const { text: content } = stripBom(rawContent); const normalized = normalizeToLF(content); - const result = applyHashlineEdits(normalized, parseHashline(section.diff), options); + const { edits } = parseHashline(section.diff); + const hashError = validateSectionHash(section, normalized, edits); + if (hashError) return { error: hashError }; + const result = applyHashlineEdits(normalized, edits, options); if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` }; return generateDiffString(normalized, result.lines); } catch (err) { diff --git a/packages/coding-agent/src/hashline/execute.ts b/packages/coding-agent/src/hashline/execute.ts index 1cf666712..92b20239f 100644 --- a/packages/coding-agent/src/hashline/execute.ts +++ b/packages/coding-agent/src/hashline/execute.ts @@ -13,13 +13,15 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard" import { HashlineMismatchError } from "./anchors"; import { applyHashlineEdits, type HashlineApplyResult } from "./apply"; import { buildCompactHashlineDiffPreview } from "./diff-preview"; -import { type HashlineInputSection, splitHashlineInputs } from "./input"; -import { parseHashlineWithWarnings } from "./parser"; +import { parseHashline } from "./executor"; +import { computeFileHash } from "./hash"; +import { splitHashlineInputs } from "./input"; import { tryRecoverHashlineWithCache } from "./recovery"; import type { ExecuteHashlineSingleOptions, HashlineApplyOptions, HashlineEdit, + HashlineInputSection, hashlineEditParamsSchema, } from "./types"; @@ -46,6 +48,27 @@ function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean { }); } +function collectAnchorLines(edits: HashlineEdit[]): number[] { + const lines = new Set(); + for (const edit of edits) { + if (edit.kind === "delete") { + lines.add(edit.anchor.line); + continue; + } + if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") { + lines.add(edit.cursor.anchor.line); + } + } + return [...lines].sort((a, b) => a - b); +} + +function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: HashlineEdit[]): void { + if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return; + throw new Error( + `Missing hashline file hash for anchored edit to ${sectionPath}; use \`¶${sectionPath}#hash\` from your latest read.`, + ); +} + function formatNoChangeDiagnostic(pathText: string): string { return `Edits to ${pathText} resulted in no changes being made.`; } @@ -65,36 +88,48 @@ function getEditDetails(result: AgentToolResult): EditToolDetai } /** - * Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`, - * consult the read-snapshot cache for the file and 3-way-merge the edits onto - * the current text. If recovery succeeds, return the merged result with a - * synthetic warning. Otherwise re-throw the original mismatch error. + * Apply hashline edits with file-hash stale recovery. The section hash gates + * line-number edits against the version shown to the model; if the live file + * drifted, snapshot recovery attempts a strict 3-way merge. */ function applyHashlineEditsWithRecovery( session: ToolSession, absolutePath: string, + pathText: string, text: string, + fileHash: string | undefined, edits: HashlineEdit[], options: HashlineApplyOptions, ): HashlineApplyResult { - try { - return applyHashlineEdits(text, edits, options); - } catch (err) { - if (!(err instanceof HashlineMismatchError)) throw err; - const recovered = tryRecoverHashlineWithCache({ - cache: getFileReadCache(session), - absolutePath, - currentText: text, - edits, - options, - }); - if (!recovered) throw err; + if (fileHash === undefined) return applyHashlineEdits(text, edits, options); + + const currentHash = computeFileHash(text); + if (currentHash === fileHash) return applyHashlineEdits(text, edits, options); + + const cache = getFileReadCache(session); + const recovered = tryRecoverHashlineWithCache({ + cache, + absolutePath, + currentText: text, + fileHash, + edits, + options, + }); + if (recovered) { return { lines: recovered.lines, firstChangedLine: recovered.firstChangedLine, warnings: recovered.warnings, }; } + + throw new HashlineMismatchError({ + path: pathText, + expectedFileHash: fileHash, + actualFileHash: currentHash, + fileLines: text.split("\n"), + anchorLines: collectAnchorLines(edits), + }); } /** @@ -103,10 +138,11 @@ function applyHashlineEditsWithRecovery( * any changes in a multi-section batch. */ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise { - const { session, path: sectionPath, diff } = options; + const { session, path: sectionPath, fileHash, diff } = options; const absolutePath = resolvePlanPath(session, sectionPath); - const { edits } = parseHashlineWithWarnings(diff); + const { edits } = parseHashline(diff); + assertSectionHashAllowed(sectionPath, fileHash, edits); enforcePlanModeWrite(session, sectionPath, { op: "update" }); const source = await readHashlineFile(absolutePath, sectionPath); @@ -118,7 +154,9 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & const result = applyHashlineEditsWithRecovery( session, absolutePath, + sectionPath, normalized, + source.exists ? fileHash : undefined, edits, getHashlineApplyOptions(session), ); @@ -131,6 +169,7 @@ async function executeHashlineSection( const { session, path: sourcePath, + fileHash, diff, signal, batchRequest, @@ -139,7 +178,8 @@ async function executeHashlineSection( } = options; const absolutePath = resolvePlanPath(session, sourcePath); - const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff); + const { edits, warnings: parseWarnings } = parseHashline(diff); + assertSectionHashAllowed(sourcePath, fileHash, edits); enforcePlanModeWrite(session, sourcePath, { op: "update" }); const source = await readHashlineFile(absolutePath, sourcePath); @@ -152,7 +192,9 @@ async function executeHashlineSection( const result = applyHashlineEditsWithRecovery( session, absolutePath, + sourcePath, originalNormalized, + source.exists ? fileHash : undefined, edits, getHashlineApplyOptions(session), ); @@ -182,7 +224,10 @@ async function executeHashlineSection( // of the file: the model just received it back as the diff/preview. Cache // it so a follow-up edit anchored against this state can still recover // if the file is touched out-of-band before the next edit lands. - getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n")); + getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), { + fullText: result.lines, + fileHash: computeFileHash(result.lines), + }); const diffResult = generateDiffString(originalNormalized, result.lines); const meta = outputMeta() @@ -257,11 +302,31 @@ export async function executeHashlineSingle( * Path order is preserved by first occurrence. */ function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] { - const byPath = new Map(); + const byPath = new Map(); for (const section of sections) { const existing = byPath.get(section.path); - if (existing) existing.push(section.diff); - else byPath.set(section.path, [section.diff]); + if (existing) { + if ( + existing.fileHash !== undefined && + section.fileHash !== undefined && + existing.fileHash !== section.fileHash + ) { + throw new Error( + `Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`, + ); + } + if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash; + existing.diffs.push(section.diff); + continue; + } + byPath.set(section.path, { + ...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}), + diffs: [section.diff], + }); } - return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") })); + return Array.from(byPath, ([path, entry]) => ({ + path, + ...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}), + diff: entry.diffs.join("\n"), + })); } diff --git a/packages/coding-agent/src/hashline/executor.ts b/packages/coding-agent/src/hashline/executor.ts new file mode 100644 index 000000000..8f7b85afa --- /dev/null +++ b/packages/coding-agent/src/hashline/executor.ts @@ -0,0 +1,239 @@ +import { ABORT_WARNING } from "./constants"; +import { HL_OP_CHARS, HL_OP_DELETE, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, HL_OP_REPLACE } from "./hash"; +import { + cloneCursor, + type HashlineToken, + HashlineTokenizer, + isDeleteOpWithPayload, + type ParsedRange, +} from "./tokenizer"; +import type { Anchor, HashlineCursor, HashlineEdit } from "./types"; + +function validateRangeOrder(range: ParsedRange, lineNum: number): void { + if (range.end.line < range.start.line) { + throw new Error(`line ${lineNum}: range ${range.start.line}-${range.end.line} ends before it starts.`); + } +} + +function expandRange(range: ParsedRange): Anchor[] { + const anchors: Anchor[] = []; + for (let line = range.start.line; line <= range.end.line; line++) { + anchors.push({ line }); + } + return anchors; +} + +type PendingOp = + | { kind: "insert"; cursor: HashlineCursor; lineNum: number } + | { kind: "replace"; range: ParsedRange; lineNum: number }; + +interface Pending { + op: PendingOp; + payload: string[]; + pendingBlanks: number; +} + +/** + * Token-driven state machine that turns a stream of {@link HashlineToken}s + * into the flat list of {@link HashlineEdit}s applied downstream by the + * apply/diff layers. + * + * The executor owns: + * - the running edit index (kept monotonic across pending flushes), + * - the pending-payload buffer (lines accumulated for the most recently + * opened insert/replace op), + * - all parse-time diagnostics (range order, "delete with payload", + * orphan payload, unrecognized op), + * - the {@link terminated} flag set by `envelope-end`/`abort`. + * + * Tokens are dispatched in the order they arrive; the matching tokenizer + * supplies the line numbers carried inside each token so diagnostics line + * up with the source. + */ +export class HashlineExecutor { + #edits: HashlineEdit[] = []; + #warnings: string[] = []; + #editIndex = 0; + #pending: Pending | undefined; + #terminated = false; + + /** True once an `envelope-end` or `abort` token has been observed. */ + get terminated(): boolean { + return this.#terminated; + } + + /** + * Consume one token. After `terminated` flips true subsequent feeds + * are silently ignored so callers can keep draining their tokenizer + * without explicit early-exit guards. + */ + feed(token: HashlineToken): void { + if (this.#terminated) return; + + switch (token.kind) { + case "envelope-begin": + return; + case "envelope-end": + this.#terminated = true; + return; + case "abort": + this.#warnings.push(ABORT_WARNING); + this.#terminated = true; + return; + case "header": + this.#flushPending(false); + return; + case "blank": + if (this.#pending) this.#pending.pendingBlanks++; + return; + case "payload": + this.#handlePayload(token.text, token.lineNum); + return; + case "op-delete": + this.#flushPending(false); + if (token.trailingPayload) { + throw new Error( + `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`, + ); + } + validateRangeOrder(token.range, token.lineNum); + for (const anchor of expandRange(token.range)) { + this.#edits.push({ kind: "delete", anchor, lineNum: token.lineNum, index: this.#editIndex++ }); + } + return; + case "op-insert": + this.#flushPending(false); + this.#pending = { + op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum }, + payload: token.inlineBody === undefined ? [] : [token.inlineBody], + pendingBlanks: 0, + }; + return; + case "op-replace": + this.#flushPending(false); + validateRangeOrder(token.range, token.lineNum); + this.#pending = { + op: { kind: "replace", range: token.range, lineNum: token.lineNum }, + payload: token.inlineBody === undefined ? [] : [token.inlineBody], + pendingBlanks: 0, + }; + return; + } + } + + /** + * Flush any open pending op (including its trailing blank lines, which + * are payload-significant) and return the accumulated edits and + * warnings. The executor is single-use; reset() is required for reuse. + */ + end(): { edits: HashlineEdit[]; warnings: string[] } { + this.#flushPending(true); + return { edits: this.#edits, warnings: this.#warnings }; + } + + /** Reset to a fresh state so the same instance can drive another parse. */ + reset(): void { + this.#edits = []; + this.#warnings = []; + this.#editIndex = 0; + this.#pending = undefined; + this.#terminated = false; + } + + #handlePayload(text: string, lineNum: number): void { + if (this.#pending) { + this.#flushPendingBlanks(); + this.#pending.payload.push(text); + return; + } + + // Whitespace-only payload outside any pending op is a visual + // separator (matches the legacy outer-loop isBlankLine skip); + // only fully-empty lines arrive as `blank` tokens. + if (text.trim().length === 0) return; + // Orphan payload outside any pending op: pick the most specific + // diagnostic so the model sees the actionable hint. + if (isDeleteOpWithPayload(text)) { + throw new Error( + `line ${lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`, + ); + } + + const firstChar = text[0]; + const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar); + if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") { + throw new Error( + `line ${lineNum}: unrecognized op. Use LINE${HL_OP_INSERT_BEFORE} (insert before), LINE${HL_OP_INSERT_AFTER} (insert after), LINE${HL_OP_REPLACE} / A-B${HL_OP_REPLACE} (replace), or LINE${HL_OP_DELETE} / A-B${HL_OP_DELETE} (delete). ` + + `Got ${JSON.stringify(text)}.`, + ); + } + + throw new Error( + `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` + + `Got ${JSON.stringify(text)}.`, + ); + } + + #flushPendingBlanks(): void { + if (!this.#pending) return; + for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push(""); + this.#pending.pendingBlanks = 0; + } + + #flushPending(includeTrailingBlanks: boolean): void { + const pending = this.#pending; + if (!pending) return; + if (includeTrailingBlanks) this.#flushPendingBlanks(); + + const { op, payload } = pending; + const linesToInsert = payload.length === 0 ? [""] : payload; + + if (op.kind === "insert") { + for (const text of linesToInsert) { + this.#edits.push({ + kind: "insert", + cursor: cloneCursor(op.cursor), + text, + lineNum: op.lineNum, + index: this.#editIndex++, + }); + } + } else { + for (const text of linesToInsert) { + this.#edits.push({ + kind: "insert", + cursor: { kind: "before_anchor", anchor: { ...op.range.start } }, + text, + lineNum: op.lineNum, + index: this.#editIndex++, + }); + } + for (const anchor of expandRange(op.range)) { + this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ }); + } + } + + this.#pending = undefined; + } +} + +/** + * Drive a full hashline diff through the tokenizer + executor pipeline and + * return the resulting edits plus any parse-time warnings. This is the + * convenience entry point most callers want; reach for {@link + * HashlineTokenizer}/{@link HashlineExecutor} directly only when you need + * streaming feeds, cross-section state, or custom token handling. + */ +export function parseHashline(diff: string): { edits: HashlineEdit[]; warnings: string[] } { + const tokenizer = new HashlineTokenizer(); + const executor = new HashlineExecutor(); + const drain = (tokens: HashlineToken[]): void => { + for (const token of tokens) { + if (executor.terminated) return; + executor.feed(token); + } + }; + drain(tokenizer.feed(diff)); + drain(tokenizer.end()); + return executor.end(); +} diff --git a/packages/coding-agent/src/hashline/grammar.lark b/packages/coding-agent/src/hashline/grammar.lark index 1d7c74ecd..417230a99 100644 --- a/packages/coding-agent/src/hashline/grammar.lark +++ b/packages/coding-agent/src/hashline/grammar.lark @@ -3,22 +3,21 @@ begin_patch: "*** Begin Patch" LF end_patch: "*** End Patch" LF? hunk: update_hunk -update_hunk: "$HFILE$" filename LF line_op* +update_hunk: "$HFILE$" filename ("#" file_hash)? LF line_op* -filename: /(.+)/ +filename: /([^\s#]+)/ +file_hash: /[0-9a-f]{4}/ -line_op: insert_before | insert_after | replace | blank -insert_before: anchor "$HOP_INSERT_BEFORE$" inline_body LF payload* - | anchor "$HOP_INSERT_BEFORE$" LF payload+ -insert_after: anchor "$HOP_INSERT_AFTER$" inline_body LF payload* - | anchor "$HOP_INSERT_AFTER$" LF payload+ +line_op: insert_before | insert_after | replace | delete +insert_before: anchor "$HOP_INSERT_BEFORE$" inline_body? LF payload* +insert_after: anchor "$HOP_INSERT_AFTER$" inline_body? LF payload* replace: range "$HOP_REPLACE$" inline_body? LF payload* +delete: range "$HOP_DELETE$" LF inline_body: /[^\n]+/ -payload: /[^$HFILE$\n][^\n]*/ LF | LF -blank: LF +payload: /(.*)/ LF anchor: LID | "EOF" | "BOF" range: LID ("-" LID)? -LID: /[1-9]\d*$HFMT$/ +LID: /[1-9]\d*/ %import common.LF diff --git a/packages/coding-agent/src/hashline/hash.ts b/packages/coding-agent/src/hashline/hash.ts index d86f83787..f75910026 100644 --- a/packages/coding-agent/src/hashline/hash.ts +++ b/packages/coding-agent/src/hashline/hash.ts @@ -3,70 +3,54 @@ * and prompt helpers. */ -import bigrams from "./bigrams.json" with { type: "json" }; +const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** - * 647 single-token BPE bigrams for hashline anchors. Every entry tokenizes as - * exactly one token in modern BPE vocabularies (cl100k / o200k / Claude family), - * so a hashline anchor built from one bigram is exactly 1 token. - * - * This is the complete set of 2-letter lowercase combinations that are single - * tokens — the 29 missing combinations are rare-letter pairs (q/x/z heavy) - * that no major BPE vocabulary merges into a single token. - * - * Order is stable forever — changing it would invalidate every saved - * `LINE+ID` reference in transcripts and prompts. - */ -export const HL_BIGRAMS: readonly string[] = bigrams; - -export const HL_BIGRAMS_COUNT = HL_BIGRAMS.length; - -/** - * Decoration prefix that may precede a `LINE+HASH` anchor in tool output: + * Decoration prefix that may precede a line number in tool output: * `>` (context line in grep), `+` (added line in diff), `-` (removed line), * `*` (match line). Any combination, in any order, surrounded by optional - * whitespace. Output formatters emit at most one decoration per anchor; the - * regex stays liberal because anchor-ref parsers accept whatever the model - * echoes back. + * whitespace. Output formatters emit at most one decoration per line; the + * parser stays liberal because it accepts whatever the model echoes back. */ export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>+\\-*]*\\s*`; -/** - * Capture-group regex source for a decorated `LINE+HASH` anchor. Group 1 - * captures the line number (digits only); group 2 captures the hash. The - * source is intentionally unanchored — anchoring with `^` (or composing into a - * larger pattern) is the caller's responsibility. - */ -export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)([a-z]{2})`; +/** Capture-group regex source for a decorated bare line-number anchor. */ +export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`; + +/** Bare positive line-number Lid (no decorations, no captures, no anchors). */ +export const HL_LINE_RE_RAW = `[1-9]\\d*`; + +/** Capture-group form of {@link HL_LINE_RE_RAW}. */ +export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`; + +/** Four-hex-character file hash carried by a hashline section header. */ +export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`; + +/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */ +export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`; + +/** Separator between a hashline file path and its file hash. */ +export const HL_FILE_HASH_SEP = "#"; + +/** Separator between a line number and displayed line content in hashline mode. */ +export const HL_LINE_BODY_SEP = ":"; + +/** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */ +export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP); /** - * Bare `LINE+HASH` Lid (no decorations, no captures, no anchors). Use for - * embedding inside larger patterns where the line+hash unit appears as a - * literal (e.g. range bounds, alternation arms, op-line heuristics). + * Representative file hashes for use in user-facing error messages and prompt + * examples. */ -export const HL_HASH_RE_RAW = `[1-9]\\d*[a-z]{2}`; - -/** - * Capture-group form of {@link HL_HASH_RE_RAW}: group 1 captures the - * line number, group 2 captures the hash. - */ -export const HL_HASH_CAPTURE_RE_RAW = `([1-9]\\d*)([a-z]{2})`; - -/** Width of a hash in display characters. */ -export const HL_HASH_WIDTH = 2; - -/** - * Representative hash suffixes for use in user-facing error messages and - * prompt examples. - */ -export const HL_HASH_EXAMPLES = ["sr", "ab", "th"] as const; +export const HL_FILE_HASH_EXAMPLES = ["1a2b", "3c4d", "9f3e"] as const; /** * Format a comma-separated list of example anchors with an optional line-number - * prefix, quoted for inclusion in error messages: `"160sr", "160ab", "160th"`. + * prefix, quoted for inclusion in error messages: `"160", "42", "7"`. */ export function describeAnchorExamples(linePrefix = ""): string { - return HL_HASH_EXAMPLES.map(e => `"${linePrefix}${e}"`).join(", "); + const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"]; + return examples.map(e => `"${e}"`).join(", "); } /** @@ -76,10 +60,13 @@ export function describeAnchorExamples(linePrefix = ""): string { */ export function resolveHashlineGrammarPlaceholders(grammar: string): string { return grammar - .replaceAll("$HFMT$", "[a-z]{2}") + .replaceAll("$HFMT$", "") + .replaceAll("$HFILE_HASH$", HL_FILE_HASH_RE_RAW) + .replaceAll("$HFILE_HASH_SEP$", HL_FILE_HASH_SEP) .replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE) .replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER) .replaceAll("$HOP_REPLACE$", HL_OP_REPLACE) + .replaceAll("$HOP_DELETE$", HL_OP_DELETE) .replaceAll("$HOP_CHARS$", HL_OP_CHARS) .replaceAll("$HFILE$", HL_FILE_PREFIX); } @@ -87,12 +74,10 @@ export function resolveHashlineGrammarPlaceholders(grammar: string): string { /** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */ export const resolveLarkLidPlaceholders = resolveHashlineGrammarPlaceholders; -const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - /** - * Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX}; * op lines have an `ANCHOR[INLINE_PAYLOAD]` shape, where SIGIL is one of - * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. + * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE}, + * or {@link HL_OP_DELETE}. * Multi-line payloads follow on subsequent lines as verbatim file content with no * per-line marker. * @@ -101,74 +86,46 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, */ export const HL_OP_INSERT_BEFORE = "↑"; export const HL_OP_INSERT_AFTER = "↓"; -export const HL_OP_REPLACE = "→"; +export const HL_OP_REPLACE = ":"; +export const HL_OP_DELETE = "!"; /** All hashline edit op sigils, concatenated for fast membership tests. */ -export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`; +export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`; /** Hashline edit file section header marker. */ export const HL_FILE_PREFIX = "¶"; -/** Stable separator for read/search/hashline display output. Intentionally not configurable. */ -export const HL_BODY_SEP = "|"; - -/** Regex-escaped form of {@link HL_BODY_SEP}, safe for embedding inside a regex. */ -export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP); - -/** - * Compute a 2-character hash of a single line via xxHash32 mod 647 over - * {@link HL_BIGRAMS}. The hash depends only on the line's content (after - * stripping CR and trailing whitespace); the `idx` parameter is accepted - * for call-site symmetry with line numbers but is intentionally unused so - * that anchors remain stable across line shifts caused by sibling edits. - * - * The line input should not include a trailing newline. - */ -export function computeLineHash(idx: number, line: string): string { - void idx; - line = line.replace(/\r/g, "").trimEnd(); - // Seed is fixed so the hash depends only on line content. Earlier we mixed - // in `idx` for blank/punctuation-only lines, but that meant any line shift - // (e.g. from a sibling edit in the same batch) invalidated anchors whose - // content had not changed. Identical blank lines are intentionally allowed - // to collide — the edit op's line number disambiguates them. - return HL_BIGRAMS[Bun.hash.xxHash32(line, 0) % HL_BIGRAMS_COUNT]; +function normalizeFileHashText(text: string): string { + return text + .replace(/\r/g, "") + .split("\n") + .map(line => line.trimEnd()) + .join("\n"); } /** - * Formats an anchor reference given a line number and its text. - * Returns `LINE+ID` (e.g., `42sr`) — no separator between - * number and hash. + * Compute the 4-hex-character hash carried by a hashline section header. + * The hash normalizes CR characters and trailing whitespace before hashing so + * platform line endings and display-trimmed lines do not invalidate anchors. */ -export function formatLineHash(line: number, lines: string): string { - return `${line}${computeLineHash(line, lines)}`; +export function computeFileHash(text: string): string { + const normalized = normalizeFileHashText(text); + const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff; + return low16.toString(16).padStart(4, "0"); } -/** - * Formats a single line with a hashline anchor. - * Returns `LINE+ID|TEXT` (e.g., `42sr|function hi() {`, `3ab|}`). - */ -export function formatHashLine(lineNumber: number, line: string): string { - return `${lineNumber}${computeLineHash(lineNumber, line)}${HL_BODY_SEP}${line}`; +/** Format a hashline section header for a file path and file hash. */ +export function formatHashlineHeader(filePath: string, fileHash: string): string { + return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`; } -/** - * Format file text with hashline prefixes for display. - * - * Each line becomes `LINE+ID|TEXT` where LINENUM is 1-indexed. - * No padding on line numbers; pipe separator between anchor and content. - * - * @param text - Raw file text string - * @param startLine - First line number (1-indexed, defaults to 1) - * @returns Formatted string with one hashline-prefixed line per input line - * - * @example - * ``` - * formatHashLines("function hi() {\n return;\n}") - * // "1bm|function hi() {\n2er| return;\n3ab|}" - * ``` - */ -export function formatHashLines(text: string, startLine = 1): string { +/** Formats a single numbered line as `LINE:TEXT`. */ +export function formatNumberedLine(lineNumber: number, line: string): string { + return `${lineNumber}${HL_LINE_BODY_SEP}${line}`; +} + +/** Format file text with hashline-mode line-number prefixes for display. */ +export function formatNumberedLines(text: string, startLine = 1): string { const lines = text.split("\n"); - return lines.map((line, i) => formatHashLine(startLine + i, line)).join("\n"); + return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n"); } diff --git a/packages/coding-agent/src/hashline/index.ts b/packages/coding-agent/src/hashline/index.ts index da6ecb341..1e3c264a0 100644 --- a/packages/coding-agent/src/hashline/index.ts +++ b/packages/coding-agent/src/hashline/index.ts @@ -4,10 +4,11 @@ export * from "./constants"; export * from "./diff"; export * from "./diff-preview"; export * from "./execute"; +export * from "./executor"; export * from "./hash"; export * from "./input"; -export * from "./parser"; export * from "./prefixes"; export * from "./recovery"; export * from "./stream"; +export * from "./tokenizer"; export * from "./types"; diff --git a/packages/coding-agent/src/hashline/input.ts b/packages/coding-agent/src/hashline/input.ts index 81faf6c52..7f71a6beb 100644 --- a/packages/coding-agent/src/hashline/input.ts +++ b/packages/coding-agent/src/hashline/input.ts @@ -1,13 +1,10 @@ import * as path from "node:path"; -import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants"; -import { HL_FILE_PREFIX } from "./hash"; -import { isHashlineOpLineText } from "./parser"; -import type { SplitHashlineOptions } from "./types"; +import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash"; +import { HashlineTokenizer } from "./tokenizer"; +import type { HashlineInputSection, SplitHashlineOptions } from "./types"; -export interface HashlineInputSection { - path: string; - diff: string; -} +// Pure classification — single shared tokenizer is safe. +const TOKENIZER = new HashlineTokenizer(); function unquoteHashlinePath(pathText: string): string { if (pathText.length < 2) return pathText; @@ -25,27 +22,30 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string { return isWithinCwd ? relative || "." : unquoted; } +/** + * Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do not + * begin with the `¶` prefix; throws the existing "Input header must be …" + * error when a `¶`-prefixed line fails the strict shape (so malformed paths + * surface immediately instead of being silently re-classified as payload). + */ function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null { const trimmed = line.trimEnd(); if (!trimmed.startsWith(HL_FILE_PREFIX)) return null; - // Strip a run of leading header markers so canonical `¶PATH` and - // runaway-prefix forms like `¶¶PATH` / `¶¶¶PATH` route to the same file. - let prefixEnd = 0; - while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++; - const rest = trimmed.slice(prefixEnd); - if (rest.trim().length === 0) { - throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`); + + const token = TOKENIZER.tokenize(trimmed); + if (token.kind !== "header") { + throw new Error( + `Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH with a 4-hex file hash; got ${JSON.stringify(trimmed)}.`, + ); } - const parsedPath = normalizeHashlinePath(rest, cwd); + + const parsedPath = normalizeHashlinePath(token.path, cwd); if (parsedPath.length === 0) { throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`); } - return { path: parsedPath, diff: "" }; -} - -function isPatchEnvelopeMarker(line: string): boolean { - const trimmed = line.trimEnd(); - return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER; + return token.fileHash !== undefined + ? { path: parsedPath, fileHash: token.fileHash, diff: "" } + : { path: parsedPath, diff: "" }; } function stripLeadingBlankLines(input: string): string { @@ -53,7 +53,7 @@ function stripLeadingBlankLines(input: string): string { const lines = stripped.split("\n"); while (lines.length > 0) { const head = lines[0].replace(/\r$/, ""); - if (head.trim().length === 0 || head.trimEnd() === BEGIN_PATCH_MARKER) { + if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") { lines.shift(); continue; } @@ -64,7 +64,7 @@ function stripLeadingBlankLines(input: string): string { export function containsRecognizableHashlineOperations(input: string): boolean { for (const line of input.split(/\r?\n/)) { - if (isHashlineOpLineText(line)) return true; + if (TOKENIZER.isOp(line)) return true; } return false; } @@ -82,7 +82,7 @@ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): s return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`; } -export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } { +export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): HashlineInputSection { const [section] = splitHashlineInputs(input, options); return section; } @@ -95,33 +95,42 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions if (parseHashlineHeaderLine(firstLine, options.cwd) === null) { const preview = JSON.stringify(firstLine.slice(0, 120)); throw new Error( - `input must begin with "${HL_FILE_PREFIX}PATH" on the first non-blank line; got: ${preview}. ` + - `Example: "${HL_FILE_PREFIX}src/foo.ts" then edit ops.`, + `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` + + `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b" then edit ops.`, ); } const sections: HashlineInputSection[] = []; - let currentPath = ""; + let current: HashlineInputSection | undefined; let currentLines: string[] = []; const flush = () => { - if (currentPath.length === 0) return; + if (!current) return; const hasOps = currentLines.some(line => line.trim().length > 0); - if (hasOps) sections.push({ path: currentPath, diff: currentLines.join("\n") }); + if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") }); currentLines = []; }; for (const line of lines) { - if (line.trimEnd() === END_PATCH_MARKER || line.trimEnd() === ABORT_MARKER) break; - if (isPatchEnvelopeMarker(line)) continue; - const header = parseHashlineHeaderLine(line, options.cwd); - if (header !== null) { - flush(); - currentPath = header.path; - currentLines = []; - } else { - currentLines.push(line); + const trimmed = line.trimEnd(); + const token = TOKENIZER.tokenize(line); + if (token.kind === "envelope-end" || token.kind === "abort") break; + if (token.kind === "envelope-begin") continue; + + // Route every `¶`-prefixed line through parseHashlineHeaderLine so + // malformed headers still raise the strict "Input header must be …" + // diagnostic (the tokenizer alone would silently classify them as + // payload). + if (trimmed.startsWith(HL_FILE_PREFIX)) { + const header = parseHashlineHeaderLine(line, options.cwd); + if (header !== null) { + flush(); + current = header; + currentLines = []; + continue; + } } + currentLines.push(line); } flush(); return sections; diff --git a/packages/coding-agent/src/hashline/parser.ts b/packages/coding-agent/src/hashline/parser.ts deleted file mode 100644 index eae14a6d7..000000000 --- a/packages/coding-agent/src/hashline/parser.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants"; -import { - describeAnchorExamples, - HL_FILE_PREFIX, - HL_HASH_CAPTURE_RE_RAW, - HL_HASH_RE_RAW, - HL_OP_CHARS, - HL_OP_INSERT_AFTER, - HL_OP_INSERT_BEFORE, - HL_OP_REPLACE, -} from "./hash"; -import type { Anchor, HashlineCursor, HashlineEdit } from "./types"; - -const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -const OP_CHARS_ESCAPED = regexEscape(HL_OP_CHARS); - -// Leniently accept anchors copied from read/search output: -// - optional leading line-marker decoration (`*`, `>`, `+`, `-`) -// - the required `LINE+HASH` -// - an optional trailing `|TEXT` body so users can paste a full -// `LINE+HASH|TEXT` line verbatim. -const LID_CAPTURE_RE = new RegExp(`^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:\\|.*)?\\s*$`); - -// Pre-op anchor part for insert ops: leading decoration, then a LID or -// BOF/EOF, then optional `|TEXT` paste decoration. The decoration MUST NOT -// contain any op sigil so the op-line regex below knows where the anchor part -// ends. Trailing `\s*` allows space between the anchor and the op sigil. -const INSERT_ANCHOR_PART_RE_RAW = `\\s*[>+\\-*]*\\s*(?:${HL_HASH_RE_RAW}|BOF|EOF)(?:\\|[^${OP_CHARS_ESCAPED}\\n]*)?\\s*`; - -// Pre-op range part for the replace op: optional decoration + LID, then an -// optional `-LID` end, then optional trailing `|TEXT` paste decoration. The -// `-` is the range separator; `|TEXT` between bounds is unsupported (TEXT may -// contain `-`), trailing decoration after the full range is still tolerated. -const RANGE_PART_RE_RAW = `\\s*[>+\\-*]*\\s*${HL_HASH_RE_RAW}(?:-${HL_HASH_RE_RAW})?(?:\\|[^${OP_CHARS_ESCAPED}\\n]*)?\\s*`; - -// Op lines place the operator AFTER the anchor/range. Group 1 captures the -// anchor (or range) part; group 2 captures the optional inline payload that -// follows the op sigil on the same line, with trailing whitespace eaten. -const INSERT_BEFORE_OP_RE = new RegExp(`^(${INSERT_ANCHOR_PART_RE_RAW})${regexEscape(HL_OP_INSERT_BEFORE)}(.*?)\\s*$`); -const INSERT_AFTER_OP_RE = new RegExp(`^(${INSERT_ANCHOR_PART_RE_RAW})${regexEscape(HL_OP_INSERT_AFTER)}(.*?)\\s*$`); -const REPLACE_OP_RE = new RegExp(`^(${RANGE_PART_RE_RAW})${regexEscape(HL_OP_REPLACE)}(.*?)\\s*$`); - -// Range parser: a bare `LINE+HASH` or `LINE+HASH-LINE+HASH` with optional -// leading decoration and optional trailing `|TEXT` paste decoration. Captures -// 1/2 = start line/hash, 3/4 = optional end line/hash. -const RANGE_PARSE_RE = new RegExp( - `^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:-${HL_HASH_CAPTURE_RE_RAW})?(?:\\|.*)?\\s*$`, -); - -function parseLid(raw: string, lineNum: number): Anchor { - const match = LID_CAPTURE_RE.exec(raw); - if (!match) { - throw new Error( - `line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` + - `got ${JSON.stringify(raw)}.`, - ); - } - return { line: Number.parseInt(match[1], 10), hash: match[2] }; -} - -interface ParsedRange { - start: Anchor; - end: Anchor; -} - -function parseRange(raw: string, lineNum: number): ParsedRange { - const match = RANGE_PARSE_RE.exec(raw); - if (!match) { - throw new Error( - `line ${lineNum}: range must be ANCHOR or ANCHOR-ANCHOR (one dash, no spaces); ` + - `got ${JSON.stringify(raw)}.`, - ); - } - const start: Anchor = { line: Number.parseInt(match[1], 10), hash: match[2] }; - const end: Anchor = match[3] !== undefined ? { line: Number.parseInt(match[3], 10), hash: match[4] } : { ...start }; - if (end.line < start.line) { - throw new Error( - `line ${lineNum}: range ${start.line}${start.hash}-${end.line}${end.hash} ends before it starts.`, - ); - } - if (end.line === start.line && end.hash !== start.hash) { - throw new Error( - `line ${lineNum}: range ${start.line}${start.hash}-${end.line}${end.hash} uses two different hashes for the same line.`, - ); - } - return { start, end }; -} - -function expandRange(range: ParsedRange): Anchor[] { - const anchors: Anchor[] = []; - for (let line = range.start.line; line <= range.end.line; line++) { - const hash = - line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH; - anchors.push({ line, hash }); - } - return anchors; -} - -// `BOF`/`EOF` with optional leading decoration and optional `|TEXT` trailing -// paste decoration. The token is recognized verbatim; any `|TEXT` is discarded. -const BOF_RE = /^\s*[>+\-*]*\s*BOF(?:\|[^\n]*)?\s*$/; -const EOF_RE = /^\s*[>+\-*]*\s*EOF(?:\|[^\n]*)?\s*$/; - -function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor { - if (BOF_RE.test(raw)) return { kind: "bof" }; - if (EOF_RE.test(raw)) return { kind: "eof" }; - const cursorKind = kind === "before" ? "before_anchor" : "after_anchor"; - return { kind: cursorKind, anchor: parseLid(raw, lineNum) }; -} - -function isEnvelopeOrAbortMarkerLine(line: string): boolean { - const trimmed = line.trimEnd(); - return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER; -} - -export function isHashlineOpLineText(line: string): boolean { - return INSERT_BEFORE_OP_RE.test(line) || INSERT_AFTER_OP_RE.test(line) || REPLACE_OP_RE.test(line); -} - -function isPayloadTerminatorLine(line: string): boolean { - if (line.startsWith(HL_FILE_PREFIX)) return true; - if (isHashlineOpLineText(line)) return true; - return isEnvelopeOrAbortMarkerLine(line); -} - -export function cloneCursor(cursor: HashlineCursor): HashlineCursor { - if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } }; - if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } }; - return cursor; -} - -function collectPayload( - lines: string[], - startIndex: number, - opLineNum: number, - requirePayload: boolean, -): { payload: string[]; nextIndex: number } { - const payload: string[] = []; - let index = startIndex; - while (index < lines.length) { - const line = lines[index]; - if (isPayloadTerminatorLine(line)) break; - payload.push(line); - index++; - } - if (payload.length === 0 && requirePayload) { - throw new Error( - `line ${opLineNum}: ${HL_OP_INSERT_BEFORE} and ${HL_OP_INSERT_AFTER} operations require at least one verbatim payload line.`, - ); - } - return { payload, nextIndex: index }; -} - -export function parseHashline(diff: string): HashlineEdit[] { - return parseHashlineWithWarnings(diff).edits; -} - -export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } { - const edits: HashlineEdit[] = []; - const warnings: string[] = []; - const lines = diff.split(/\r?\n/); - if (diff.endsWith("\n") && lines.at(-1) === "") lines.pop(); - let editIndex = 0; - - const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => { - edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ }); - }; - - for (let i = 0; i < lines.length; ) { - const lineNum = i + 1; - const line = lines[i]; - - if (line.trim().length === 0) { - i++; - continue; - } - if (line === END_PATCH_MARKER) { - break; - } - if (line === ABORT_MARKER) { - warnings.push(ABORT_WARNING); - break; - } - if (line === BEGIN_PATCH_MARKER) { - i++; - continue; - } - - const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line); - if (insertBeforeMatch) { - const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before"); - const inlineBody = insertBeforeMatch[2].length > 0 ? insertBeforeMatch[2] : undefined; - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined); - if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line); - if (insertAfterMatch) { - const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after"); - const inlineBody = insertAfterMatch[2].length > 0 ? insertAfterMatch[2] : undefined; - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined); - if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const replaceMatch = REPLACE_OP_RE.exec(line); - if (replaceMatch) { - const range = parseRange(replaceMatch[1], lineNum); - const inlineBody = replaceMatch[2].length > 0 ? replaceMatch[2] : undefined; - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); - const allPayload = inlineBody !== undefined ? [inlineBody, ...payload] : payload; - if (allPayload.length > 0) { - for (const text of allPayload) { - edits.push({ - kind: "insert", - cursor: { kind: "before_anchor", anchor: { ...range.start } }, - text, - lineNum, - index: editIndex++, - }); - } - } - for (const anchor of expandRange(range)) { - edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ }); - } - i = nextIndex; - continue; - } - - const firstChar = line[0]; - const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar); - if (startsWithOp || /^[-@«»\u2254\u00A7]/u.test(line)) { - throw new Error( - `line ${lineNum}: unrecognized op. Use ANCHOR${HL_OP_INSERT_BEFORE} (insert before), ANCHOR${HL_OP_INSERT_AFTER} (insert after), or A-B${HL_OP_REPLACE} (replace/delete). ` + - `Got ${JSON.stringify(line)}.`, - ); - } - - throw new Error( - `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, or ${HL_OP_REPLACE} operation. ` + - `Got ${JSON.stringify(line)}.`, - ); - } - - return { edits, warnings }; -} diff --git a/packages/coding-agent/src/hashline/prefixes.ts b/packages/coding-agent/src/hashline/prefixes.ts index e24e67b82..057870154 100644 --- a/packages/coding-agent/src/hashline/prefixes.ts +++ b/packages/coding-agent/src/hashline/prefixes.ts @@ -1,8 +1,6 @@ -import { HL_BODY_SEP_RE_RAW } from "./hash"; - -const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`; -const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); -const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); +const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/; +const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/; +const HL_HEADER_RE = /^\s*¶\S+#[0-9a-f]{4}\s*$/; const DIFF_PLUS_RE = /^[+](?![+])/; const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/; @@ -20,12 +18,14 @@ function stripLeadingHashlinePrefixes(line: string): string { // 5. Read-output prefix stripping // // When a model echoes back content from a `read` or `search` response, every -// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style -// echoes, a leading `+`. These helpers detect that and recover the raw text. +// line is prefixed with either a hashline-mode line number (`123:`) or, for +// diff-style echoes, a leading `+`. These helpers detect that and recover the +// raw text. // ─────────────────────────────────────────────────────────────────────────── type LinePrefixStats = { nonEmpty: number; + headerCount: number; hashPrefixCount: number; diffPlusHashPrefixCount: number; diffPlusCount: number; @@ -35,6 +35,7 @@ type LinePrefixStats = { function collectLinePrefixStats(lines: string[]): LinePrefixStats { const stats: LinePrefixStats = { nonEmpty: 0, + headerCount: 0, hashPrefixCount: 0, diffPlusHashPrefixCount: 0, diffPlusCount: 0, @@ -47,6 +48,11 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats { stats.truncationNoticeCount++; continue; } + if (HL_HEADER_RE.test(line)) { + stats.nonEmpty++; + stats.headerCount++; + continue; + } stats.nonEmpty++; if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++; if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++; @@ -59,7 +65,8 @@ export function stripNewLinePrefixes(lines: string[]): string[] { const stats = collectLinePrefixStats(lines); if (stats.nonEmpty === 0) return lines; - const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty; + const contentLineCount = stats.nonEmpty - stats.headerCount; + const stripHash = contentLineCount > 0 && stats.hashPrefixCount === contentLineCount; const stripPlus = !stripHash && stats.diffPlusHashPrefixCount === 0 && @@ -69,7 +76,7 @@ export function stripNewLinePrefixes(lines: string[]): string[] { if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines; return lines - .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)) + .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line))) .map(line => { if (stripHash) return stripLeadingHashlinePrefixes(line); if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); @@ -83,8 +90,11 @@ export function stripNewLinePrefixes(lines: string[]): string[] { export function stripHashlinePrefixes(lines: string[]): string[] { const stats = collectLinePrefixStats(lines); if (stats.nonEmpty === 0) return lines; - if (stats.hashPrefixCount !== stats.nonEmpty) return lines; - return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line)); + const contentLineCount = stats.nonEmpty - stats.headerCount; + if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines; + return lines + .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line)) + .map(line => stripLeadingHashlinePrefixes(line)); } /** diff --git a/packages/coding-agent/src/hashline/recovery.ts b/packages/coding-agent/src/hashline/recovery.ts index c5e147820..b1c818a9d 100644 --- a/packages/coding-agent/src/hashline/recovery.ts +++ b/packages/coding-agent/src/hashline/recovery.ts @@ -1,15 +1,15 @@ import * as Diff from "diff"; import { generateDiffString } from "../edit/diff"; -import type { FileReadCache } from "../edit/file-read-cache"; -import { HashlineMismatchError } from "./anchors"; +import type { FileReadCache, FileReadSnapshot } from "../edit/file-read-cache"; import { applyHashlineEdits, type HashlineApplyResult } from "./apply"; -import { computeLineHash } from "./hash"; -import type { Anchor, HashlineApplyOptions, HashlineEdit } from "./types"; +import { computeFileHash } from "./hash"; +import type { HashlineApplyOptions, HashlineEdit } from "./types"; export interface HashlineRecoveryArgs { cache: FileReadCache; absolutePath: string; currentText: string; + fileHash: string; edits: HashlineEdit[]; options: HashlineApplyOptions; } @@ -20,76 +20,28 @@ export interface HashlineRecoveryResult { warnings: string[]; } -// Anchors are line-precise; never let Diff.applyPatch slide a hunk onto a -// duplicate closer 100+ lines away. If the snapshot-based replay does not -// align by exact line number, refuse and let the model re-read. +// Section hashes are line-precise; never let Diff.applyPatch slide a hunk onto a +// duplicate closer 100+ lines away. If snapshot replay does not align exactly, +// refuse and let the model re-read. const HASHLINE_RECOVERY_FUZZ_FACTOR = 0; -const HASHLINE_RECOVERY_WARNING = - "Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit)."; - -/** Collect every line anchor an edit batch depends on. */ -function collectEditAnchors(edits: HashlineEdit[]): Anchor[] { - const anchors: Anchor[] = []; - for (const edit of edits) { - if (edit.kind === "delete") { - anchors.push(edit.anchor); - continue; - } - const cursor = edit.cursor; - if (cursor.kind === "before_anchor" || cursor.kind === "after_anchor") { - anchors.push(cursor.anchor); - } - } - return anchors; -} - -/** - * Attempt to recover from a `HashlineMismatchError` by replaying the edits - * against a cached pre-edit snapshot of the file and 3-way-merging the result - * onto the current on-disk content. Returns `null` when no recovery is - * possible — callers should propagate the original mismatch error in that - * case. - * - * Recovery is gated on a strict precondition: every line the model anchored - * MUST be present in the cached snapshot AND its content MUST hash to the - * model-supplied hash. This prevents 3-way merges from silently sliding onto - * the wrong site when only tangential parts of the file went stale. - */ -export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null { - const { cache, absolutePath, currentText, edits, options } = args; - const snapshot = cache.get(absolutePath); - if (!snapshot || snapshot.lines.size === 0) return null; - - // Precondition: the model's anchors must be vouched-for by the cache. If - // even one anchored line is missing from the snapshot, or its cached - // content hashes to a different value than the model supplied, refuse — - // any merge from here is a guess. - const anchors = collectEditAnchors(edits); - for (const anchor of anchors) { - const cachedLine = snapshot.lines.get(anchor.line); - if (cachedLine === undefined) return null; - if (computeLineHash(anchor.line, cachedLine) !== anchor.hash) return null; - } - - const overlaid = currentText.split("\n"); - let maxCachedLine = 0; - for (const lineNum of snapshot.lines.keys()) { - if (lineNum > maxCachedLine) maxCachedLine = lineNum; - } - while (overlaid.length < maxCachedLine) overlaid.push(""); - for (const [lineNum, content] of snapshot.lines) { - overlaid[lineNum - 1] = content; - } - const previousText = overlaid.join("\n"); - if (previousText === currentText) return null; +const HASHLINE_RECOVERY_EXTERNAL_WARNING = + "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit)."; +const HASHLINE_RECOVERY_SESSION_CHAIN_WARNING = + "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session)."; +function applyEditsToSnapshot( + previousText: string, + currentText: string, + edits: HashlineEdit[], + options: HashlineApplyOptions, + recoveryWarning: string, +): HashlineRecoveryResult | null { let applied: HashlineApplyResult; try { applied = applyHashlineEdits(previousText, edits, options); - } catch (err) { - if (err instanceof HashlineMismatchError) return null; - throw err; + } catch { + return null; } if (applied.lines === previousText) return null; @@ -98,11 +50,9 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin if (typeof merged !== "string" || merged === currentText) return null; const mergedDiff = generateDiffString(currentText, merged); - // Only surface the recovery warning when the merge actually changed - // something visible. A no-op merge (e.g. trailing-newline only) is noise. const hasNetChange = mergedDiff.firstChangedLine !== undefined; const recoveryWarnings = hasNetChange - ? [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])] + ? [recoveryWarning, ...(applied.warnings ?? [])] : [...(applied.warnings ?? [])]; return { @@ -111,3 +61,45 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin warnings: recoveryWarnings, }; } + +function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap): string { + const overlaid = currentText.split("\n"); + let maxCachedLine = 0; + for (const lineNum of snapshotLines.keys()) { + if (lineNum > maxCachedLine) maxCachedLine = lineNum; + } + while (overlaid.length < maxCachedLine) overlaid.push(""); + for (const [lineNum, content] of snapshotLines) { + overlaid[lineNum - 1] = content; + } + return overlaid.join("\n"); +} + +function isHeadSnapshot(head: FileReadSnapshot | null, snapshot: FileReadSnapshot): boolean { + return head === snapshot; +} + +function resolveRecoveryWarning(head: FileReadSnapshot | null, snapshot: FileReadSnapshot): string { + return isHeadSnapshot(head, snapshot) ? HASHLINE_RECOVERY_EXTERNAL_WARNING : HASHLINE_RECOVERY_SESSION_CHAIN_WARNING; +} + +/** + * Attempt to recover from a section file-hash mismatch by replaying the edits + * against a cached pre-edit snapshot of the file and 3-way-merging the result + * onto the current on-disk content. Returns `null` when no recovery is possible. + */ +export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null { + const { cache, absolutePath, currentText, fileHash, edits, options } = args; + const head = cache.get(absolutePath); + const snapshot = cache.getByHash(absolutePath, fileHash); + if (!snapshot || snapshot.lines.size === 0) return null; + + const recoveryWarning = resolveRecoveryWarning(head, snapshot); + if (snapshot.fullText !== undefined) { + return applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning); + } + + const overlayText = buildSparseOverlayText(currentText, snapshot.lines); + if (computeFileHash(overlayText) !== fileHash) return null; + return applyEditsToSnapshot(overlayText, currentText, edits, options, recoveryWarning); +} diff --git a/packages/coding-agent/src/hashline/stream.ts b/packages/coding-agent/src/hashline/stream.ts index 6e241430b..05b4bd2b4 100644 --- a/packages/coding-agent/src/hashline/stream.ts +++ b/packages/coding-agent/src/hashline/stream.ts @@ -1,4 +1,4 @@ -import { formatHashLine } from "./hash"; +import { formatNumberedLine } from "./hash"; import type { HashlineStreamOptions } from "./types"; interface ResolvedHashlineStreamOptions { @@ -34,7 +34,7 @@ function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): Has }; const pushLine = (line: string): string[] => { - const formatted = formatHashLine(lineNumber, line); + const formatted = formatNumberedLine(lineNumber, line); lineNumber++; const chunks: string[] = []; diff --git a/packages/coding-agent/src/hashline/tokenizer.ts b/packages/coding-agent/src/hashline/tokenizer.ts new file mode 100644 index 000000000..93d8d217e --- /dev/null +++ b/packages/coding-agent/src/hashline/tokenizer.ts @@ -0,0 +1,467 @@ +import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants"; +import { + describeAnchorExamples, + HL_FILE_HASH_SEP, + HL_FILE_PREFIX, + HL_OP_DELETE, + HL_OP_INSERT_AFTER, + HL_OP_INSERT_BEFORE, + HL_OP_REPLACE, +} from "./hash"; +import type { Anchor, HashlineCursor } from "./types"; + +const CHAR_LINE_FEED = 10; +const CHAR_CARRIAGE_RETURN = 13; +const CHAR_ZERO = 48; +const CHAR_NINE = 57; +const CHAR_HASH = 35; +const CHAR_TAB = 9; +const CHAR_SPACE = 32; +const CHAR_LOWER_A = 97; +const CHAR_LOWER_F = 102; +const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0); +const FILE_HASH_LENGTH = 4; + +function isDigitCode(code: number): boolean { + return code >= CHAR_ZERO && code <= CHAR_NINE; +} + +function isNonZeroDigitCode(code: number): boolean { + return code > CHAR_ZERO && code <= CHAR_NINE; +} + +function isDecorationCode(code: number): boolean { + return code === 42 || code === 43 || code === 45 || code === 62; +} + +function isHexDigitCode(code: number): boolean { + return isDigitCode(code) || (code >= CHAR_LOWER_A && code <= CHAR_LOWER_F); +} + +function skipWhitespace(line: string, index: number, end = line.length): number { + return end - line.slice(index, end).trimStart().length; +} + +function trimEndIndex(line: string): number { + return line.trimEnd().length; +} + +function isEmptyLine(line: string): boolean { + return line.length === 0; +} + +function markerLineEquals(line: string, marker: string): boolean { + return line.trimEnd() === marker; +} + +/** + * Split a hashline diff into individual lines without losing the trailing + * empty line that callers may rely on for explicit blank payloads. CRLF pairs + * are normalized to a single line break. + * + * This mirrors the line-splitting performed by {@link HashlineTokenizer}'s + * streaming drain loop and is kept for non-streaming callers that prefer + * a single-shot split. + */ +export function splitHashlineLines(text: string): string[] { + if (text.length === 0) return [""]; + + const lines: string[] = []; + let start = 0; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) !== CHAR_LINE_FEED) continue; + let end = index; + if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--; + lines.push(text.slice(start, end)); + start = index + 1; + } + + if (start < text.length) { + let end = text.length; + if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--; + lines.push(text.slice(start, end)); + } + return lines; +} + +export function cloneCursor(cursor: HashlineCursor): HashlineCursor { + if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } }; + if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } }; + return cursor; +} + +// Leniently accept anchors copied from read/search output: +// - optional leading line-marker decoration (`*`, `>`, `+`, `-`) +// - the required bare line number +function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number { + let index = skipWhitespace(line, 0, end); + while (index < end && isDecorationCode(line.charCodeAt(index))) index++; + return skipWhitespace(line, index, end); +} + +interface NumberScan { + line: number; + nextIndex: number; +} + +function scanLineNumber(line: string, index: number, end: number): NumberScan | null { + if (index >= end || !isNonZeroDigitCode(line.charCodeAt(index))) return null; + + let lineNumber = 0; + let nextIndex = index; + while (nextIndex < end) { + const code = line.charCodeAt(nextIndex); + if (!isDigitCode(code)) break; + lineNumber = lineNumber * 10 + (code - CHAR_ZERO); + nextIndex++; + } + return { line: lineNumber, nextIndex }; +} + +/** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */ +export function parseLid(raw: string, lineNum: number): Anchor { + const end = trimEndIndex(raw); + const numberStart = skipDecoratedAnchorPrefix(raw, end); + const number = scanLineNumber(raw, numberStart, end); + if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) { + throw new Error( + `line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` + + `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`, + ); + } + return { line: number.line }; +} + +export interface ParsedRange { + start: Anchor; + end: Anchor; +} + +interface RangeScan { + range: ParsedRange; + nextIndex: number; +} + +function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null { + const numberStart = skipDecoratedAnchorPrefix(line, end); + const start = scanLineNumber(line, numberStart, end); + if (start === null) return null; + + let nextIndex = start.nextIndex; + let rangeEnd = start.line; + if (nextIndex < end && line.charCodeAt(nextIndex) === 45) { + const endNumber = scanLineNumber(line, nextIndex + 1, end); + if (endNumber === null) return null; + rangeEnd = endNumber.line; + nextIndex = endNumber.nextIndex; + } + + return { + range: { start: { line: start.line }, end: { line: rangeEnd } }, + nextIndex: skipWhitespace(line, nextIndex, end), + }; +} + +function startsWithWord(line: string, index: number, end: number, word: string): boolean { + if (index + word.length > end) return false; + for (let offset = 0; offset < word.length; offset++) { + if (line.charCodeAt(index + offset) !== word.charCodeAt(offset)) return false; + } + return true; +} + +function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor { + const end = trimEndIndex(raw); + const targetStart = skipDecoratedAnchorPrefix(raw, end); + + if (startsWithWord(raw, targetStart, end, "BOF") && skipWhitespace(raw, targetStart + 3, end) === end) { + return { kind: "bof" }; + } + if (startsWithWord(raw, targetStart, end, "EOF") && skipWhitespace(raw, targetStart + 3, end) === end) { + return { kind: "eof" }; + } + + const cursorKind = kind === "before" ? "before_anchor" : "after_anchor"; + return { kind: cursorKind, anchor: parseLid(raw, lineNum) }; +} + +function scanInlineBody(line: string, index: number): string | undefined { + const end = trimEndIndex(line); + return index < end ? line.slice(index, end) : undefined; +} + +interface ParsedInsertOp { + kind: "insert"; + cursor: HashlineCursor; + inlineBody: string | undefined; +} + +interface ParsedReplaceOp { + kind: "replace"; + range: ParsedRange; + inlineBody: string | undefined; +} + +interface ParsedDeleteOp { + kind: "delete"; + range: ParsedRange; + trailingPayload: boolean; +} + +type ParsedOp = ParsedInsertOp | ParsedReplaceOp | ParsedDeleteOp; + +function tryParseInsertOp(line: string, sigil: string, kind: "before" | "after"): ParsedInsertOp | null { + const end = trimEndIndex(line); + const targetStart = skipDecoratedAnchorPrefix(line, end); + + let targetEnd: number; + if (startsWithWord(line, targetStart, end, "BOF") || startsWithWord(line, targetStart, end, "EOF")) { + targetEnd = targetStart + 3; + } else { + const anchor = scanLineNumber(line, targetStart, end); + if (anchor === null) return null; + targetEnd = anchor.nextIndex; + } + + const opIndex = skipWhitespace(line, targetEnd, end); + if (opIndex >= end || line[opIndex] !== sigil) return null; + + // parseInsertTarget can only throw on inputs that already passed the + // BOF/EOF/line-number scan above, but guard the throw anyway — the + // tokenizer contract forbids it and a future refactor of the prefix + // scan must not silently start raising here. + try { + return { + kind: "insert", + cursor: parseInsertTarget(line.slice(0, opIndex), 0, kind), + inlineBody: scanInlineBody(line, opIndex + sigil.length), + }; + } catch { + return null; + } +} + +function tryParseReplaceOp(line: string): ParsedReplaceOp | null { + const end = trimEndIndex(line); + const range = scanRange(line, end); + if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_REPLACE) return null; + return { + kind: "replace", + range: range.range, + inlineBody: scanInlineBody(line, range.nextIndex + HL_OP_REPLACE.length), + }; +} + +function tryParseDeleteOp(line: string): ParsedDeleteOp | null { + const end = trimEndIndex(line); + const range = scanRange(line, end); + if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_DELETE) return null; + const afterSigil = range.nextIndex + HL_OP_DELETE.length; + return { kind: "delete", range: range.range, trailingPayload: afterSigil !== end }; +} + +function tryParseOp(line: string): ParsedOp | null { + return ( + tryParseInsertOp(line, HL_OP_INSERT_BEFORE, "before") ?? + tryParseInsertOp(line, HL_OP_INSERT_AFTER, "after") ?? + tryParseReplaceOp(line) ?? + tryParseDeleteOp(line) + ); +} + +/** + * Strict header scan: `¶+` prefix, optional whitespace, path body that excludes + * whitespace, `#`, and `¶`, optional `#[0-9a-f]{4}` hash suffix, optional + * trailing whitespace. Returns `null` when any byte deviates from the shape. + */ +function tryParseHeader(line: string): { path: string; fileHash?: string } | null { + const end = trimEndIndex(line); + if (end === 0 || line.charCodeAt(0) !== CHAR_PILCROW) return null; + + let index = 0; + while (index < end && line.charCodeAt(index) === CHAR_PILCROW) index++; + index = skipWhitespace(line, index, end); + if (index >= end) return null; + + const pathStart = index; + while (index < end) { + const code = line.charCodeAt(index); + if (code === CHAR_HASH || code === CHAR_PILCROW || code === CHAR_SPACE || code === CHAR_TAB) break; + index++; + } + if (index === pathStart) return null; + const path = line.slice(pathStart, index); + + let fileHash: string | undefined; + if (index < end && line.charCodeAt(index) === CHAR_HASH) { + const hashStart = index + 1; + const hashEnd = hashStart + FILE_HASH_LENGTH; + if (hashEnd > end) return null; + for (let probe = hashStart; probe < hashEnd; probe++) { + if (!isHexDigitCode(line.charCodeAt(probe))) return null; + } + fileHash = line.slice(hashStart, hashEnd); + index = hashEnd; + } + + // Anything other than trailing whitespace disqualifies the header. + if (skipWhitespace(line, index, end) !== end) return null; + + return fileHash !== undefined ? { path, fileHash } : { path }; +} + +/** + * Returns true when the line scans as `LINE!payload` (delete sigil followed by + * additional content). The executor uses this for the dedicated "deletes only" + * diagnostic, separate from the standard "unrecognized op" path. + */ +export function isDeleteOpWithPayload(line: string): boolean { + const range = scanRange(line, line.length); + return ( + range !== null && + range.nextIndex < line.length && + line[range.nextIndex] === HL_OP_DELETE && + range.nextIndex + HL_OP_DELETE.length < line.length + ); +} + +interface TokenBase { + /** 1-indexed line number in the original input stream. */ + lineNum: number; +} + +export type HashlineToken = + | (TokenBase & { kind: "blank" }) + | (TokenBase & { kind: "envelope-begin" }) + | (TokenBase & { kind: "envelope-end" }) + | (TokenBase & { kind: "abort" }) + | (TokenBase & { kind: "header"; path: string; fileHash?: string }) + | (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined }) + | (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined }) + | (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean }) + | (TokenBase & { kind: "payload"; text: string }); + +function classifyLine(line: string, lineNum: number): HashlineToken { + if (isEmptyLine(line)) return { kind: "blank", lineNum }; + if (markerLineEquals(line, BEGIN_PATCH_MARKER)) return { kind: "envelope-begin", lineNum }; + if (markerLineEquals(line, END_PATCH_MARKER)) return { kind: "envelope-end", lineNum }; + if (markerLineEquals(line, ABORT_MARKER)) return { kind: "abort", lineNum }; + + if (line.charCodeAt(0) === CHAR_PILCROW) { + const header = tryParseHeader(line); + if (header !== null) { + return header.fileHash !== undefined + ? { kind: "header", lineNum, path: header.path, fileHash: header.fileHash } + : { kind: "header", lineNum, path: header.path }; + } + } + + const op = tryParseOp(line); + if (op !== null) { + if (op.kind === "insert") { + return { kind: "op-insert", lineNum, cursor: op.cursor, inlineBody: op.inlineBody }; + } + if (op.kind === "replace") { + return { kind: "op-replace", lineNum, range: op.range, inlineBody: op.inlineBody }; + } + return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload }; + } + + return { kind: "payload", lineNum, text: line }; +} + +/** + * Stateful, line-oriented classifier for hashline diff text. Use the streaming + * {@link feed}/{@link end} pair to ingest text in chunks (each completed line + * emits exactly one token; a trailing partial line stays buffered until the + * next chunk or {@link end}). Use the stateless {@link tokenize}/predicate + * methods for callers that already hold whole lines and only need + * classification without buffering. + */ +export class HashlineTokenizer { + #buffer = ""; + #nextLineNum = 1; + #closed = false; + + /** + * Ingest a chunk of input text. Each newline-terminated line in the + * combined buffer produces one token. A trailing partial line (no `\n` + * yet, possibly ending in a lone `\r`) stays buffered until the next + * `feed`/`end` call so CRLF pairs that straddle chunk boundaries are + * still normalized correctly. + */ + feed(chunk: string): HashlineToken[] { + if (this.#closed) throw new Error("HashlineTokenizer is closed; call reset() before reusing."); + if (chunk.length === 0) return []; + this.#buffer = this.#buffer ? this.#buffer + chunk : chunk; + return this.#drainCompleteLines(); + } + + /** + * Flush any buffered residual line (the last line of input when it lacks + * a trailing newline) and mark the tokenizer closed. Calling `end` a + * second time returns `[]`; reuse requires `reset`. + */ + end(): HashlineToken[] { + if (this.#closed) return []; + this.#closed = true; + const buf = this.#buffer; + this.#buffer = ""; + if (buf.length === 0) return []; + let stop = buf.length; + if (buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--; + const token = classifyLine(buf.slice(0, stop), this.#nextLineNum++); + return [token]; + } + + /** Discard any buffered text and reset the line counter to 1. */ + reset(): void { + this.#buffer = ""; + this.#nextLineNum = 1; + this.#closed = false; + } + + /** Convenience: feed an entire text and immediately flush. */ + tokenizeAll(text: string): HashlineToken[] { + this.reset(); + const first = this.feed(text); + const last = this.end(); + return last.length === 0 ? first : first.concat(last); + } + + /** Stateless one-shot classification. Does not touch the streaming buffer. */ + tokenize(line: string, lineNum = 0): HashlineToken { + return classifyLine(line, lineNum); + } + + isOp(line: string): boolean { + return tryParseOp(line) !== null; + } + + isHeader(line: string): boolean { + return tryParseHeader(line) !== null; + } + + isEnvelopeMarker(line: string): boolean { + return ( + markerLineEquals(line, BEGIN_PATCH_MARKER) || + markerLineEquals(line, END_PATCH_MARKER) || + markerLineEquals(line, ABORT_MARKER) + ); + } + + #drainCompleteLines(): HashlineToken[] { + const tokens: HashlineToken[] = []; + const buf = this.#buffer; + let start = 0; + for (let index = 0; index < buf.length; index++) { + if (buf.charCodeAt(index) !== CHAR_LINE_FEED) continue; + let stop = index; + if (stop > start && buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--; + tokens.push(classifyLine(buf.slice(start, stop), this.#nextLineNum++)); + start = index + 1; + } + this.#buffer = start < buf.length ? buf.slice(start) : ""; + return tokens; + } +} diff --git a/packages/coding-agent/src/hashline/types.ts b/packages/coding-agent/src/hashline/types.ts index 0747a5db2..1bd87f70a 100644 --- a/packages/coding-agent/src/hashline/types.ts +++ b/packages/coding-agent/src/hashline/types.ts @@ -3,16 +3,8 @@ import type { LspBatchRequest } from "../edit/renderer"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp"; import type { ToolSession } from "../tools"; -export interface HashMismatch { - line: number; - expected: string; - actual: string; -} - export type Anchor = { line: number; - hash: string; - contentHint?: string; }; export type HashlineCursor = @@ -25,6 +17,12 @@ export type HashlineEdit = | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number } | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }; +export interface HashlineInputSection { + path: string; + fileHash?: string; + diff: string; +} + /** `path` is accepted by the edit tool runtime; other extra keys are preserved. */ export const hashlineEditParamsSchema = z.object({ input: z.string(), path: z.string().optional() }).passthrough(); export type HashlineParams = z.infer; diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index 29090214a..1be7238f9 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep. -- Replacement summary, per-file replacement counts, and change diffs as `-LINE+ID|before` / `+LINE+ID|after` lines +- Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#1a2b`, `-12:before`, `+12:after` lines in hashline mode - Parse issues when files cannot be processed diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index 9cb49c440..c35809682 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep. - Grouped matches with file path, byte range, line/column ranges, metavariable captures -- Match lines are anchor-prefixed: `*LINE+ID|content` for the matched line and ` LINE+ID|content` (leading space) for surrounding context +- Match lines are numbered under a file-hash header in hashline mode: `¶src/foo.ts#1a2b`, `*42:content` for the matched line, ` 43:content` for context - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index f56d0a4c6..ef9efc57a 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,120 +1,140 @@ Your patch language is a compact, line-anchored edit format. -A patch contains one or more file sections. The first non-blank line of every edit section MUST be `¶PATH`. -Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`. -You MUST copy them verbatim from the latest output for the file you're editing. +A patch contains one or more file sections. The first non-blank line of an anchored edit section MUST be `¶PATH#HASH`, copied from the latest `read`/`search` output for that file. `HASH` is a 4-hex file hash. +Operations reference lines by bare line number, e.g. `5`, `123`. + +`¶PATH` without `#HASH` is allowed ONLY for new-file / `BOF` / `EOF` boundary inserts. Anchored line ops without a header hash are rejected. Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions. -¶PATH header: subsequent ops apply to PATH +¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH +¶PATH unbound header: only BOF/EOF boundary inserts Each op line is ONE of: -ANCHOR↑ insert ABOVE the anchored line (or BOF); payload may follow inline after `↑` and/or on subsequent lines -ANCHOR↓ insert BELOW the anchored line (or EOF); payload may follow inline after `↓` and/or on subsequent lines -A-B→ replace the inclusive range A..B with payload; delete the range if no payload follows -A→ shorthand for A-A→ +LINE↑ insert ABOVE the anchored line (or BOF); payload may follow inline after `↑` and/or on subsequent lines +LINE↓ insert BELOW the anchored line (or EOF); payload may follow inline after `↓` and/or on subsequent lines +A-B: replace the inclusive range A..B with payload +A: shorthand for A-A: +A-B! delete the inclusive range A..B; payload forbidden +A! shorthand for A-A! -- The arrow points to where the content lands relative to the anchor: `↑` above, `↓` below, `→` overwrite. +- The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes. - Payload text is verbatim — NEVER escape unicode. -- An op line is `ANCHOR[INLINE_PAYLOAD]`. Anything after the sigil on the same line is the first payload line; subsequent payload lines follow on the next lines. -- A payload run ends at the next op line, the next `¶PATH`, an envelope marker, or EOF. -- `A-B→` with no payload deletes the range. To keep a blank line, include one explicit empty payload line on the next line. +- Op line shape: `ANCHOR[INLINE_PAYLOAD]`. +- Payload ends at next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are treated as separators (dropped); blank lines between two content payload lines, or trailing at EOF, are preserved. +- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent lines. +- A bare `A↑` / `A↓` (no payload) inserts one blank line. A bare `A:` / `A-B:` (no payload, no inline body) replaces the line/range with a single blank line. +- `!` delete ops NEVER include payload. +- Blank a line with bare `A:`, or remove it entirely with `A!`. Insert a blank line with bare `A↑` / `A↓`. - **Payload is only what's NEW relative to your range:** - - `→` replaces inside; NEVER include lines outside. - - `↑`/`↓` adds at the anchor; NEVER repeat line A or neighbors. + - `:` replaces inside; NEVER include lines outside. + - `↑`/`↓` adds at anchor; NEVER repeat line A or neighbors. - Payload matching nearby content duplicates — drop it or widen. -- **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing. -- Then smallest op: add → `↑`/`↓`; delete/replace → `→`. +- **Pick a self-contained unit first.** Touching multiline construct? Widen to it. +- Then smallest op: add with `↑`/`↓`; replace with `:`; delete with `!`. When braces bound your edit, you SHOULD prefer these shapes: - **Whole block**: range spans `{` through matching `}`. -- **Signature only**: one-line `→` on the opener; body untouched. -- **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces. +- **Signature only**: one-line `:` on opener; body untouched. +- **Insert inside**: anchor on `{` or last interior line; NEVER repeat braces. - **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier. - **NEVER replay past your range.** Stop before B+1; extend B if it must go. - **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite. -- **Anchor only inside the visible region.** B+1 truncated? Re-`read` first. +- **Anchor only inside visible content.** B+1 truncated? Re-`read` first. +- **Use the section hash from latest output.** Missing/stale? Re-`read`. - **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range. - **Anchors reference the file as last read.** NEVER shift for prior ops. -- **One `↓`/`↑` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops. -- **NEVER fabricate anchor hashes.** Missing? Re-`read`. +- **One patch, one coordinate space.** Later ops still use original line numbers. +- **Read lines already look like replace ops.** `84:content` already means “make line 84 equal to content”. Do not echo a second context line before it. +- **One `↓`/`↑` op per block, NOT per line.** N lines = ONE op, N payloads. +- **NEVER fabricate file hashes.** Missing? Re-`read`. +- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error. If you misfired an earlier edit and reach for `A!` to clean up, re-read first — you'll get a warning if the deleted line was a structural boundary, but the warning only fires after the fact. -{{hline 1 "const TITLE = \"Mr\";"}} -{{hline 2 "export function greet(name) {"}} -{{hline 3 "\treturn ["}} -{{hline 4 "\t\tTITLE,"}} -{{hline 5 "\t\tname?.trim() || \"guest\","}} -{{hline 6 "\t].join(\" \");"}} +¶mod.ts#1a2b +{{hline 1 'const TITLE = "Mr";'}} +{{hline 2 'export function greet(name) {'}} +{{hline 3 ' return ['}} +{{hline 4 ' TITLE,'}} +{{hline 5 ' name?.trim() || "guest",'}} +{{hline 6 ' ].join(" ");'}} {{hline 7 "}"}} -# Replace one line (the payload must re-emit the original indentation) -¶mod.ts -{{hrefr 1}}→ +# Replace one line (payload must re-emit original indentation) +¶mod.ts#1a2b +{{hrefr 1}}: const TITLE = "Mrs"; -# Replace a full multiline statement (widen to a self-contained boundary) -¶mod.ts -{{hrefr 3}}-{{hrefr 6}}→ +# Replace a full multiline statement (widen to self-contained boundary) +¶mod.ts#1a2b +{{hrefr 3}}-{{hrefr 6}}: return [ "Mrs", name?.trim() || "guest", ].join(" "); +# Delete one line +¶mod.ts#1a2b +{{hrefr 5}}! + +# Blank a line +¶mod.ts#1a2b +{{hrefr 5}}: # Insert ABOVE/BELOW a line -¶mod.ts +¶mod.ts#1a2b {{hrefr 4}}↓ "Dr", {{hrefr 5}}↑ "Dr", -# Append to file +# Append to existing file; hash optional because EOF is a boundary insert ¶mod.ts EOF↓ export const done = true; -# Delete a line -¶mod.ts -{{hrefr 5}}→ - -# Blank a line (replace with LF: the empty payload is the blank line before `EOF↓`) -¶mod.ts -{{hrefr 5}}→ - -EOF↓ +# Create a file +¶new.ts +BOF↓ export const done = true; + +# Multi-file patch +¶src/a.ts#1a2b +12: +const enabled = true; +¶src/b.ts#3c4d +20! # WRONG — replaces 2 lines just to add one. -¶mod.ts -{{hrefr 1}}-{{hrefr 2}}→ +¶mod.ts#1a2b +{{hrefr 1}}-{{hrefr 2}}: const TITLE = "Mr"; const DEBUG = false; export function greet(name) { # RIGHT — same effect, one-line insert -¶mod.ts +¶mod.ts#1a2b {{hrefr 1}}↓ const DEBUG = false; # WRONG — replace from the middle of a larger statement (error-prone) -¶mod.ts -{{hrefr 4}}-{{hrefr 5}}→ +¶mod.ts#1a2b +{{hrefr 4}}-{{hrefr 5}}: "Dr", name?.trim() || "guest", # RIGHT — widen to the full statement -¶mod.ts -{{hrefr 3}}-{{hrefr 6}}→ +¶mod.ts#1a2b +{{hrefr 3}}-{{hrefr 6}}: return [ "Dr", name?.trim() || "guest", @@ -122,11 +142,12 @@ const DEBUG = false; -- Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body. -- NEVER write unified diff syntax. Headers are `¶PATH`; ops put `↑`/`↓`/`→` AFTER the anchor. -- `A-B→` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line. -- `A-B→` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates. -- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `→`. - - Before `A-B→`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. -- NEVER use this tool to reformat code (indentation, whitespace, line wrapping, style). Run the project's formatter instead. +- Copy the `¶PATH#HASH` header verbatim for anchored edits. +- Copy only line numbers into ops; NEVER include `:TEXT` body unless you are intentionally using `LINE:TEXT` as replace syntax. +- NEVER write unified diff syntax. Ops put `↑`/`↓`/`:`/`!` AFTER the anchor. +- `:` replaces; bare `A:` blanks the line. `↑` / `↓` insert; bare `A↑` / `A↓` insert one blank line. Use `A!` to delete entirely. +- `!` deletes and forbids payload. +- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `:`. + - Before `A-B:` or `A-B!`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. +- NEVER use this tool to reformat code. Run the project's formatter instead. diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 50d9e1cbe..b8b05fc3d 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -28,17 +28,17 @@ Append `:` to `path`. The bare path falls back to the default mode. - Reading a directory path returns a depth-limited dirent listing. {{#if IS_HL_MODE}} -- Reading a file with an explicit selector returns lines prefixed with `line+hash` anchors: `41th|def alpha():`. The 2-char hash is a content fingerprint that `edit` / `apply_patch` consume — copy it verbatim, NEVER fabricate. The pipe character after the hash is a separator, not part of the file content. +- Reading a file with an explicit selector emits a file-hash header and numbered lines: `¶src/foo.ts#1a2b` then `41:def alpha():`. Copy the `¶PATH#HASH` header for anchored edits; ops use bare line numbers. NEVER fabricate the hash. {{else}} {{#if IS_LINE_NUMBER_MODE}} - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`. {{/if}} {{/if}} -- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form: +- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer demonstrating the multi-range selector you can use to recover the elided bodies, e.g.: - `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]` + `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]` - If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content. + Re-issue **only the relevant range(s)** using the multi-range selector (e.g. `:5-16,120-200`). NEVER guess what's inside `..` / `…` — those markers carry no content. NEVER re-read the whole file or use `:raw` when targeted ranges suffice. # Documents & Notebooks diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index 429a6ed60..d96a26d3e 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -9,7 +9,7 @@ Searches files using powerful regex matching. {{#if IS_HL_MODE}} -- Text output is anchor-prefixed: `*5th|content` (match) or ` 9x}|content` (context, leading space). The 2-char suffix is a content fingerprint. The `|` before content is a separator, not part of the file content. +- Text output emits a file-hash header per matched file plus numbered lines: `¶src/login.ts#3c4d`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers. {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 560c678d7..1f4d6a4e6 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -6,7 +6,7 @@ import { Text } from "@oh-my-pi/pi-tui"; import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; +import { computeFileHash, formatHashlineHeader } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; @@ -257,12 +257,26 @@ export class AstEditTool implements AgentTool(); + if (useHashLines) { + for (const relativePath of fileList) { + const absolutePath = path.resolve(this.session.cwd, relativePath); + try { + const fullText = await Bun.file(absolutePath).text(); + const fileHash = computeFileHash(fullText); + hashContexts.set(relativePath, { fileHash }); + } catch { + // Best-effort: if a file disappears between ast-edit and rendering, emit plain line output. + } + } + } const outputLines: string[] = []; const displayLines: string[] = []; const renderChangesForFile = (relativePath: string): { model: string[]; display: string[] } => { const modelOut: string[] = []; const displayOut: string[] = []; const fileChanges = changesByFile.get(relativePath) ?? []; + const hashContext = hashContexts.get(relativePath); const lineNumberWidth = fileChanges.reduce( (width, change) => Math.max(width, String(change.startLine).length), 0, @@ -272,13 +286,9 @@ export class AstEditTool implements AgentTool { const rendered = renderChangesForFile(relativePath); const count = fileReplacementCounts.get(relativePath) ?? 0; + const hashContext = hashContexts.get(relativePath); + const hashSuffix = hashContext ? `#${hashContext.fileHash}` : ""; return { - headerSuffix: ` (${formatCount("replacement", count)})`, + headerSuffix: `${hashSuffix} (${formatCount("replacement", count)})`, modelLines: rendered.model, displayLines: rendered.display, + skip: rendered.model.length === 0, }; }); outputLines.push(...grouped.model); @@ -302,6 +315,15 @@ export class AstEditTool implements AgentTool 0) { + outputLines.push(""); + displayLines.push(""); + } + const hashContext = hashContexts.get(relativePath); + if (hashContext) { + outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash)); + } outputLines.push(...rendered.model); displayLines.push(...rendered.display); } @@ -499,11 +521,12 @@ export const astEditToolRenderer = { let contextDir = searchBase ?? ""; return group.map(line => { if (line.startsWith("## ")) { - // Strip ` (3 replacements)` suffix attached by formatGroupedFiles. + // Strip ` (3 replacements)` and `#hash` suffixes from formatGroupedFiles. const fileName = line .slice(3) .trimEnd() - .replace(/\s+\([^)]*\)\s*$/, ""); + .replace(/\s+\([^)]*\)\s*$/, "") + .replace(/#[0-9a-f]+$/, ""); const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; const styled = uiTheme.fg("dim", line); return absPath ? fileHyperlink(absPath, styled) : styled; @@ -514,14 +537,14 @@ export const astEditToolRenderer = { .trimEnd() .replace(/\s+\([^)]*\)\s*$/, ""); const isDirectory = raw.endsWith("/"); - const name = raw.replace(/\/$/, ""); + const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, ""); if (isDirectory) { if (searchBase) { contextDir = name === "." ? searchBase : path.join(searchBase, name); } return uiTheme.fg("accent", line); } - // Root-level file with optional suffix, e.g. `# foo.ts (3 replacements)`. + // Root-level file with optional `#hash` and ` (3 replacements)` suffixes. const absPath = searchBase && name ? path.join(searchBase, name) : undefined; const styled = uiTheme.fg("accent", line); return absPath ? fileHyperlink(absPath, styled) : styled; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 20203eca9..119a2defc 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -5,7 +5,9 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; +import { getFileReadCache } from "../edit/file-read-cache"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { computeFileHash, formatHashlineHeader } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; @@ -216,25 +218,43 @@ export class AstGrepTool implements AgentTool(); + if (useHashLines) { + for (const relativePath of fileList) { + const absolutePath = path.resolve(this.session.cwd, relativePath); + try { + const fullText = await Bun.file(absolutePath).text(); + const fileHash = computeFileHash(fullText); + hashContexts.set(relativePath, { absolutePath, fileHash }); + } catch { + // Best-effort: if a file disappears between ast-grep and rendering, emit plain line output. + } + } + } const outputLines: string[] = []; const displayLines: string[] = []; const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => { const modelOut: string[] = []; const displayOut: string[] = []; const fileMatches = matchesByFile.get(relativePath) ?? []; + const hashContext = hashContexts.get(relativePath); const lineNumberWidth = fileMatches.reduce((width, match) => { const lineCount = match.text.split("\n").length; const endLine = match.startLine + lineCount - 1; return Math.max(width, String(match.startLine).length, String(endLine).length); }, 0); + const cacheEntries: Array = []; for (const match of fileMatches) { const matchLines = match.text.split("\n"); for (let index = 0; index < matchLines.length; index++) { const lineNumber = match.startLine + index; const isMatch = index === 0; const line = matchLines[index] ?? ""; - modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines })); + modelOut.push( + formatMatchLine(lineNumber, line, isMatch, { useHashLines: hashContext !== undefined }), + ); displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth)); + cacheEntries.push([lineNumber, line] as const); } if (match.metaVariables && Object.keys(match.metaVariables).length > 0) { const serializedMeta = Object.entries(match.metaVariables) @@ -246,19 +266,39 @@ export class AstGrepTool implements AgentTool 0) { + getFileReadCache(this.session).recordSparse(hashContext.absolutePath, cacheEntries, { + fileHash: hashContext.fileHash, + }); + } return { model: modelOut, display: displayOut }; }; if (isDirectory) { const grouped = formatGroupedFiles(fileList, relativePath => { const rendered = renderMatchesForFile(relativePath); - return { modelLines: rendered.model, displayLines: rendered.display }; + const hashContext = hashContexts.get(relativePath); + return { + modelLines: rendered.model, + displayLines: rendered.display, + headerSuffix: hashContext ? `#${hashContext.fileHash}` : "", + skip: rendered.model.length === 0, + }; }); outputLines.push(...grouped.model); displayLines.push(...grouped.display); } else { for (const relativePath of fileList) { const rendered = renderMatchesForFile(relativePath); + if (rendered.model.length === 0) continue; + if (outputLines.length > 0) { + outputLines.push(""); + displayLines.push(""); + } + const hashContext = hashContexts.get(relativePath); + if (hashContext) { + outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash)); + } outputLines.push(...rendered.model); displayLines.push(...rendered.display); } @@ -385,7 +425,8 @@ export const astGrepToolRenderer = { const fileName = line .slice(3) .trimEnd() - .replace(/\s+\([^)]*\)\s*$/, ""); + .replace(/\s+\([^)]*\)\s*$/, "") + .replace(/#[0-9a-f]+$/, ""); const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; const styled = uiTheme.fg("dim", line); return absPath ? fileHyperlink(absPath, styled) : styled; @@ -396,7 +437,7 @@ export const astGrepToolRenderer = { .trimEnd() .replace(/\s+\([^)]*\)\s*$/, ""); const isDirectory = raw.endsWith("/"); - const name = raw.replace(/\/$/, ""); + const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, ""); if (isDirectory) { if (searchBase) { contextDir = name === "." ? searchBase : path.join(searchBase, name); diff --git a/packages/coding-agent/src/tools/match-line-format.ts b/packages/coding-agent/src/tools/match-line-format.ts index 301323ae9..e56c6d4ac 100644 --- a/packages/coding-agent/src/tools/match-line-format.ts +++ b/packages/coding-agent/src/tools/match-line-format.ts @@ -1,12 +1,10 @@ -import { computeLineHash } from "../hashline/hash"; - /** * Format a single line of match output for grep/ast-grep style results. * - * The anchor/content separator is always `|`. Matched lines are prefixed - * with `*`; context lines are prefixed with a single space so anchors - * align in column. In hashline mode the anchor is `LINE+ID` (no `#`); in - * plain mode it is just the line number. Line numbers are never padded. + * Matched lines are prefixed with `*`; context lines are prefixed with a single + * space so line numbers align in column. In hashline mode the line uses the + * editable `LINE:content` shape under a file-hash header; in plain mode it keeps + * the legacy `LINE|content` display-only shape. Line numbers are never padded. */ export function formatMatchLine( lineNumber: number, @@ -16,7 +14,7 @@ export function formatMatchLine( ): string { const marker = isMatch ? "*" : " "; if (options.useHashLines) { - return `${marker}${lineNumber}${computeLineHash(lineNumber, line)}|${line}`; + return `${marker}${lineNumber}:${line}`; } return `${marker}${lineNumber}|${line}`; } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 395160449..c4052b14f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,9 +9,10 @@ import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import { getFileReadCache } from "../edit/file-read-cache"; +import { normalizeToLF } from "../edit/normalize"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash"; +import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "../hashline/hash"; import { InternalUrlRouter } from "../internal-urls"; import { parseInternalUrl } from "../internal-urls/parse"; import type { InternalUrl } from "../internal-urls/types"; @@ -113,13 +114,50 @@ function prependLineNumbers(text: string, startNum: number): string { return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n"); } +interface HashlineHeaderContext { + header: string; + fileHash: string; + fullText: string; +} + +function buildHashlineHeaderContext(displayPath: string, fullText: string): HashlineHeaderContext { + const normalized = normalizeToLF(fullText); + const fileHash = computeFileHash(normalized); + return { + header: formatHashlineHeader(displayPath, fileHash), + fileHash, + fullText: normalized, + }; +} + +async function readHashlineHeaderContext(absolutePath: string, cwd: string): Promise { + const fullText = await Bun.file(absolutePath).text(); + return buildHashlineHeaderContext(formatPathRelativeToCwd(absolutePath, cwd), fullText); +} + +function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string { + return context ? `${context.header}\n${text}` : text; +} + +function recordHashlineSnapshot( + session: ToolSession, + absolutePath: string | undefined, + context: HashlineHeaderContext | undefined, +): void { + if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return; + getFileReadCache(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), { + fullText: context.fullText, + fileHash: context.fileHash, + }); +} + function formatTextWithMode( text: string, startNum: number, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): string { - if (shouldAddHashLines) return formatHashLines(text, startNum); + if (shouldAddHashLines) return formatNumberedLines(text, startNum); if (shouldAddLineNumbers) return prependLineNumbers(text, startNum); return text; } @@ -150,7 +188,7 @@ function formatSingleLine( shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): string { - if (shouldAddHashLines) return formatHashLine(line, text); + if (shouldAddHashLines) return formatNumberedLine(line, text); if (shouldAddLineNumbers) return `${line}|${text}`; return text; } @@ -165,9 +203,7 @@ function formatMergedBraceLine( ): { model: string; display: string } { const merged = `${headText.trimEnd()} .. ${tailText.trim()}`; if (shouldAddHashLines) { - const start = formatLineHash(startLine, headText); - const end = formatLineHash(endLine, tailText); - return { model: `${start}-${end}${HL_BODY_SEP}${merged}`, display: merged }; + return { model: `${startLine}-${endLine}:${merged}`, display: merged }; } if (shouldAddLineNumbers) { return { model: `${startLine}-${endLine}|${merged}`, display: merged }; @@ -180,17 +216,38 @@ function countTextLines(text: string): number { return text.split("\n").length; } +/** Inclusive line range describing one elided span in a structural summary. */ +interface ElidedRange { + start: number; + end: number; +} + +/** Sample ranges shown in the footer to demonstrate the multi-range syntax. */ +const FOOTER_RANGE_SAMPLES = 2; + /** * Footer appended to summarized reads telling the model how to recover the * elided body. Without this hint, agents either ignore the `...`/`{ .. }` - * markers or burn a turn guessing the right selector (see issue #1046). + * markers or burn a turn guessing the right selector (see issue #1046). The + * footer demonstrates the multi-range selector syntax with concrete sample + * ranges drawn from the actual elision so the model re-reads only what it + * needs instead of falling back to `:raw` or whole-file reads. */ -function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elidedLines: number): string { - if (elidedSpans <= 0) return ""; - const spanWord = elidedSpans === 1 ? "region" : "regions"; +function formatSummaryElisionFooter( + readPath: string, + elidedRanges: ReadonlyArray, + elidedLines: number, +): string { + if (elidedRanges.length === 0) return ""; const lineWord = elidedLines === 1 ? "line" : "lines"; - const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : ""; - return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`; + const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES); + const selector = elidedRanges + .slice(0, sampleCount) + .map(r => `${r.start}-${r.end}`) + .join(","); + const example = `${readPath}:${selector}`; + const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`; + return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`; } const READ_CHUNK_SIZE = 8 * 1024; @@ -844,9 +901,18 @@ export class ReadTool implements AgentTool { const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; + const hashContext = + shouldAddHashLines && options.sourcePath + ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text) + : undefined; + recordHashlineSnapshot(this.session, options.sourcePath, hashContext); + let emittedHashlineHeader = false; const formatText = (content: string, startNum: number): string => { details.displayContent = { text: content, startLine: startNum }; - return formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers); + const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); }; let outputText: string; @@ -862,7 +928,7 @@ export class ReadTool implements AgentTool { if (shouldAddHashLines) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, - )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`; + )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`; } else { outputText = formatText(snippet.text, startLineDisplay); } @@ -928,6 +994,12 @@ export class ReadTool implements AgentTool { const totalLines = allLines.length; const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; + const hashContext = + shouldAddHashLines && options.sourcePath + ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text) + : undefined; + recordHashlineSnapshot(this.session, options.sourcePath, hashContext); + let emittedHashlineHeader = false; const resultBuilder = toolResult(details); if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath); @@ -943,7 +1015,9 @@ export class ReadTool implements AgentTool { } const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines); const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n"); - parts.push(formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + const formatted = formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers); + parts.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted); + if (hashContext) emittedHashlineHeader = true; } const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : ""; @@ -1002,6 +1076,11 @@ export class ReadTool implements AgentTool { const shouldAddHashLines = !rawSelector && displayMode.hashLines; const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers; + const hashContext = shouldAddHashLines + ? await readHashlineHeaderContext(absolutePath, this.session.cwd) + : undefined; + recordHashlineSnapshot(this.session, absolutePath, hashContext); + let emittedHashlineHeader = false; const maxColumns = resolveOutputMaxColumns(this.session.settings); const blocks: string[] = []; @@ -1042,11 +1121,18 @@ export class ReadTool implements AgentTool { } if (collectedLines.length > 0) { - getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines); + getFileReadCache(this.session).recordContiguous( + absolutePath, + range.startLine, + collectedLines, + hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {}, + ); } const blockText = collectedLines.join("\n"); - blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + const formatted = formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers); + blocks.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted); + if (hashContext) emittedHashlineHeader = true; } let outputText = blocks.join("\n\n…\n\n"); @@ -1335,7 +1421,7 @@ export class ReadTool implements AgentTool { #renderSummary(summary: SummaryResult): { text: string; displayText: string; - elidedSpans: number; + elidedRanges: ElidedRange[]; elidedLines: number; } { const displayMode = resolveFileDisplayMode(this.session); @@ -1396,13 +1482,13 @@ export class ReadTool implements AgentTool { const modelParts: string[] = []; const displayParts: string[] = []; - let elidedSpans = 0; + const elidedRanges: ElidedRange[] = []; let elidedLines = 0; for (const unit of units) { if (unit.kind === "elided") { modelParts.push("..."); displayParts.push("..."); - elidedSpans++; + elidedRanges.push({ start: unit.startLine, end: unit.endLine }); elidedLines += unit.endLine - unit.startLine + 1; continue; } @@ -1417,7 +1503,9 @@ export class ReadTool implements AgentTool { ); modelParts.push(formatted.model); displayParts.push(formatted.display); - elidedSpans++; + // Suggest the full brace range so re-reading shows both braces + // plus the elided body in one shot. + elidedRanges.push({ start: unit.startLine, end: unit.endLine }); // Merged brace pair encloses (start+1)..(end-1) as elided. elidedLines += Math.max(0, unit.endLine - unit.startLine - 1); continue; @@ -1426,7 +1514,7 @@ export class ReadTool implements AgentTool { displayParts.push(unit.text); } - return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines }; + return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedRanges, elidedLines }; } async execute( @@ -1674,15 +1762,20 @@ export class ReadTool implements AgentTool { const renderedSummary = this.#renderSummary(summary); const footer = formatSummaryElisionFooter( localReadPath, - renderedSummary.elidedSpans, + renderedSummary.elidedRanges, renderedSummary.elidedLines, ); - const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text; + const summaryHashContext = displayMode.hashLines + ? await readHashlineHeaderContext(absolutePath, this.session.cwd) + : undefined; + recordHashlineSnapshot(this.session, absolutePath, summaryHashContext); + const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text; + const modelText = prependHashlineHeader(bodyText, summaryHashContext); details = { displayContent: { text: renderedSummary.displayText, startLine: 1 }, summary: { lines: countTextLines(renderedSummary.text), - elidedSpans: renderedSummary.elidedSpans, + elidedSpans: renderedSummary.elidedRanges.length, elidedLines: renderedSummary.elidedLines, }, }; @@ -1820,16 +1913,29 @@ export class ReadTool implements AgentTool { firstLineExceedsLimit, }; - if (collectedLines.length > 0 && !firstLineExceedsLimit) { - getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines); - } - const shouldAddHashLines = !rawSelector && displayMode.hashLines; const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers; + const hashContext = shouldAddHashLines + ? await readHashlineHeaderContext(absolutePath, this.session.cwd) + : undefined; + + if (collectedLines.length > 0 && !firstLineExceedsLimit) { + getFileReadCache(this.session).recordContiguous( + absolutePath, + startLineDisplay, + collectedLines, + hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {}, + ); + } + let capturedDisplayContent: { text: string; startLine: number } | undefined; + let emittedHashlineHeader = false; const formatText = (text: string, startNum: number): string => { capturedDisplayContent = { text, startLine: startNum }; - return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers); + const formatted = formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); }; let outputText: string; @@ -1841,7 +1947,7 @@ export class ReadTool implements AgentTool { if (shouldAddHashLines) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, - )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`; + )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`; } else { outputText = formatText(snippet.text, startLineDisplay); } @@ -1964,7 +2070,12 @@ export class ReadTool implements AgentTool { const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; const rawText = region.lines.join("\n"); - const formattedText = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers); + const hashContext = shouldAddHashLines + ? await readHashlineHeaderContext(entry.absolutePath, this.session.cwd) + : undefined; + recordHashlineSnapshot(this.session, entry.absolutePath, hashContext); + const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers); + const formattedText = prependHashlineHeader(formattedBody, hashContext); const details: ReadToolDetails = { resolvedPath: entry.absolutePath, diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index c93d398ea..377d35b1c 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -9,6 +9,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import { getFileReadCache } from "../edit/file-read-cache"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { computeFileHash, formatHashlineHeader } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; import searchDescription from "../prompts/tools/search.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; @@ -303,7 +304,6 @@ export class SearchTool implements AgentTool(); + if (baseDisplayMode.hashLines) { + for (const relativePath of fileList) { + if (archiveDisplaySet.has(relativePath)) continue; + const absoluteFilePath = path.resolve(this.session.cwd, relativePath); + if (immutableSourcePaths.has(absoluteFilePath)) continue; + try { + const fullText = await Bun.file(absoluteFilePath).text(); + const fileHash = computeFileHash(fullText); + hashContexts.set(relativePath, { absolutePath: absoluteFilePath, fileHash }); + } catch { + // Best-effort: if the file disappeared between grep and render, fall back to plain line output. + } + } + } const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => { const modelOut: string[] = []; const displayOut: string[] = []; const fileMatches = matchesByFile.get(relativePath) ?? []; - const absoluteFilePath = path.resolve(this.session.cwd, relativePath); - const useHashLines = immutableSourcePaths.has(absoluteFilePath) - ? immutableDisplayMode.hashLines - : baseDisplayMode.hashLines; + const hashContext = hashContexts.get(relativePath); + const useHashLines = hashContext !== undefined; const lineNumberWidth = fileMatches.reduce((width, match) => { let nextWidth = Math.max(width, String(match.lineNumber).length); for (const ctx of match.contextBefore ?? []) { @@ -533,17 +546,21 @@ export class SearchTool implements AgentTool 0 && !archiveDisplaySet.has(relativePath)) { - getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries); + if (cacheEntries.length > 0 && hashContext) { + getFileReadCache(this.session).recordSparse(hashContext.absolutePath, cacheEntries, { + fileHash: hashContext.fileHash, + }); } return { model: modelOut, display: displayOut }; }; if (isDirectory) { const grouped = formatGroupedFiles(fileList, relativePath => { const rendered = renderMatchesForFile(relativePath); + const hashContext = hashContexts.get(relativePath); return { modelLines: rendered.model, displayLines: rendered.display, + headerSuffix: hashContext ? `#${hashContext.fileHash}` : "", skip: rendered.model.length === 0, }; }); @@ -552,6 +569,15 @@ export class SearchTool implements AgentTool 0) { + outputLines.push(""); + displayLines.push(""); + } + const hashContext = hashContexts.get(relativePath); + if (hashContext) { + outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash)); + } outputLines.push(...rendered.model); displayLines.push(...rendered.display); } @@ -745,11 +771,12 @@ export const searchToolRenderer = { let contextDir = searchBase ?? ""; return group.map(line => { if (line.startsWith("## ")) { - // Strip optional ` (suffix)` like ` (3 replacements)` before resolving. + // Strip optional ` (suffix)` and `#hash` before resolving. const fileName = line .slice(3) .trimEnd() - .replace(/\s+\([^)]*\)\s*$/, ""); + .replace(/\s+\([^)]*\)\s*$/, "") + .replace(/#[0-9a-f]+$/, ""); const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; const styled = uiTheme.fg("dim", line); return absPath ? fileHyperlink(absPath, styled) : styled; @@ -760,7 +787,7 @@ export const searchToolRenderer = { .trimEnd() .replace(/\s+\([^)]*\)\s*$/, ""); const isDirectory = raw.endsWith("/"); - const name = raw.replace(/\/$/, ""); + const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, ""); if (isDirectory) { if (searchBase) { contextDir = name === "." ? searchBase : path.join(searchBase, name); diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 4e44358a4..f318dd6ed 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -74,8 +74,8 @@ export interface WriteToolDetails { /** * Strip hashline display prefixes from write content. * - * Only active when hashline edit mode is enabled — the model sees `LINE+ID|` - * prefixes in read output and sometimes copies them into write content. + * Only active when hashline edit mode is enabled — the model sees `¶PATH#HASH` + * headers plus `LINE:` prefixes in read output and sometimes copies them into write content. */ function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } { if (!resolveFileDisplayMode(session).hashLines) { @@ -658,7 +658,7 @@ export class WriteTool implements AgentTool> { return untilAborted(signal, async () => { - // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output + // Strip hashline display prefixes (¶PATH#HASH + LINE:) if the model copied them from read output const { text: cleanContent, stripped } = stripWriteContent(this.session, content); const internalRouter = InternalUrlRouter.instance(); if (internalRouter.canHandle(path)) { diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index 797d8dd04..b6335db94 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -12,7 +12,7 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; import { glob } from "@oh-my-pi/pi-natives"; import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils"; -import { formatHashLines } from "../hashline/hash"; +import { computeFileHash, formatHashlineHeader, formatNumberedLines } from "../hashline/hash"; import type { FileMentionMessage } from "../session/messages"; import { DEFAULT_MAX_BYTES, @@ -356,7 +356,7 @@ export async function generateFileMentionMessages( const content = await Bun.file(absolutePath).text(); let { output, lineCount } = buildTextOutput(content); if (options?.useHashLines) { - output = formatHashLines(output); + output = `${formatHashlineHeader(resolvedPath, computeFileHash(content))}\n${formatNumberedLines(output)}`; } files.push({ path: resolvedPath, content: output, lineCount }); } catch { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 0cdf84fbd..6ce45c086 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -6,18 +6,15 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config import { applyHashlineEdits, buildCompactHashlineDiffPreview, - computeLineHash, + computeFileHash, type ExecuteHashlineSingleOptions, executeHashlineSingle, FileReadCache, generateDiffString, getFileReadCache, HashlineMismatchError, - HL_BODY_SEP, - HL_BODY_SEP_RE_RAW, hashlineEditParamsSchema, parseHashline, - parseHashlineWithWarnings, splitHashlineInput, splitHashlineInputs, tryRecoverHashlineWithCache, @@ -30,28 +27,27 @@ beforeAll(async () => { }); const pl = (text: string): string => text; -const outputSep = HL_BODY_SEP; -const outputSepRe = HL_BODY_SEP_RE_RAW; +const outputSep = ":"; +const outputSepRe = ":"; -function tag(line: number, content: string): string { - return `${line}${computeLineHash(line, content)}`; +function tag(line: number, _content: string): string { + return `${line}`; +} + +function header(filePath: string, content: string): string { + return `¶${filePath}#${computeFileHash(content)}`; } function sameLineRange(anchor: string): string { return `${anchor}-${anchor}`; } -function mistag(line: number, content: string): string { - const hash = computeLineHash(line, content); - return `${line}${hash === "zz" ? "yy" : "zz"}`; -} - function applyDiff(content: string, diff: string): string { - return applyHashlineEdits(content, parseHashline(diff)).lines; + return applyHashlineEdits(content, parseHashline(diff).edits).lines; } function applyDiffWithPureInsertAutoDrop(content: string, diff: string): string { - return applyHashlineEdits(content, parseHashline(diff), { autoDropPureInsertDuplicates: true }).lines; + return applyHashlineEdits(content, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true }).lines; } async function withTempDir(fn: (tempDir: string) => Promise): Promise { @@ -110,35 +106,35 @@ describe("hashline parser — suffix-op syntax", () => { expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail"); }); - it("deletes one line or an inclusive range when `A-B→` has no payload", () => { - expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}→`)).toBe("aaa\nccc"); - expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}→`)).toBe("aaa"); + it("deletes one line or an inclusive range with `!`", () => { + expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}!`)).toBe("aaa\nccc"); + expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}!`)).toBe("aaa"); }); - it("blanks a line in place with an explicit empty payload line", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}→\n\n`; - expect(applyDiff(content, diff)).toBe("aaa\n\nccc"); + it("blanks a line in place with `A:` when given an explicit empty payload", () => { + const explicit = `${sameLineRange(tag(2, "bbb"))}:\n\n`; + expect(applyDiff(content, explicit)).toBe("aaa\n\nccc"); }); it("replaces one line or an inclusive range with payload lines", () => { - const single = [`${tag(2, "bbb")}→`, pl("BBB")].join("\n"); + const single = [`${tag(2, "bbb")}:`, pl("BBB")].join("\n"); expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc"); - const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}→`, pl("BBB"), pl("CCC")].join("\n"); + const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:`, pl("BBB"), pl("CCC")].join("\n"); expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC"); }); it("treats single-anchor replace sugar as equivalent to an explicit one-line range", () => { const anchor = tag(2, "bbb"); - expect(parseHashline(`${anchor}→\nBBB`)).toEqual(parseHashline(`${anchor}-${anchor}→\nBBB`)); - expect(applyDiff(content, `${anchor}→\nBBB`)).toBe(applyDiff(content, `${anchor}-${anchor}→\nBBB`)); + expect(parseHashline(`${anchor}:\nBBB`).edits).toEqual(parseHashline(`${anchor}-${anchor}:\nBBB`).edits); + expect(applyDiff(content, `${anchor}:\nBBB`)).toBe(applyDiff(content, `${anchor}-${anchor}:\nBBB`)); }); it("accepts an inline payload on the op line as the first/only payload line", () => { const anchor = tag(2, "bbb"); expect(applyDiff(content, `${anchor}↓NEW`)).toBe("aaa\nbbb\nNEW\nccc"); expect(applyDiff(content, `${anchor}↑NEW`)).toBe("aaa\nNEW\nbbb\nccc"); - expect(applyDiff(content, `${anchor}→NEW`)).toBe("aaa\nNEW\nccc"); + expect(applyDiff(content, `${anchor}:NEW`)).toBe("aaa\nNEW\nccc"); }); it("combines an inline payload with subsequent payload lines on insert ops", () => { @@ -149,7 +145,7 @@ describe("hashline parser — suffix-op syntax", () => { it("combines an inline payload with subsequent payload lines on the replace op", () => { const anchor = tag(2, "bbb"); - const diff = [`${anchor}→FIRST`, pl("SECOND")].join("\n"); + const diff = [`${anchor}:FIRST`, pl("SECOND")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nFIRST\nSECOND\nccc"); }); @@ -162,28 +158,28 @@ describe("hashline parser — suffix-op syntax", () => { it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => { const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(3, "old();"))}→`, pl("// one"), pl("// two"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(3, "old();"))}:`, pl("// one"), pl("// two"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["// one", "// two", "new();"].join("\n")); }); it("auto-absorbs duplicated multiline suffix boundaries during replacement", () => { const source = ["old();", "// one", "// two"].join("\n"); - const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("new();"), pl("// one"), pl("// two")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}:`, pl("new();"), pl("// one"), pl("// two")].join("\n"); expect(applyDiff(source, diff)).toBe(["new();", "// one", "// two"].join("\n")); }); it("auto-absorbs a duplicated single structural suffix during replacement", () => { const source = ["old();", "};"].join("\n"); - const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("new();"), pl("};")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}:`, pl("new();"), pl("};")].join("\n"); expect(applyDiff(source, diff)).toBe(["new();", "};"].join("\n")); }); it("auto-absorbs a duplicated single structural prefix during replacement", () => { const source = ["};", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(2, "old();"))}→`, pl("};"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(2, "old();"))}:`, pl("};"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["};", "new();"].join("\n")); }); @@ -193,14 +189,14 @@ describe("hashline parser — suffix-op syntax", () => { // `}` is a legitimate part of the new block, not a duplicate of the file's // existing `}`. The single-line structural absorb must NOT fire here. const source = ["old();", "}"].join("\n"); - const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("if ok {"), pl("}")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}:`, pl("if ok {"), pl("}")].join("\n"); expect(applyDiff(source, diff)).toBe(["if ok {", "}", "}"].join("\n")); }); it("does not auto-absorb a single duplicated boundary line", () => { const source = ["keep", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(2, "old();"))}→`, pl("keep"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(2, "old();"))}:`, pl("keep"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["keep", "keep", "new();"].join("\n")); }); @@ -211,7 +207,7 @@ describe("hashline parser — suffix-op syntax", () => { // steal that anchor and turn the insert into a replacement. const source = ["A", "B", "X", "Y", "Z"].join("\n"); const diff = [ - `${tag(1, "A")}-${tag(2, "B")}→`, + `${tag(1, "A")}-${tag(2, "B")}:`, pl("alpha"), pl("X"), pl("Y"), @@ -224,9 +220,9 @@ describe("hashline parser — suffix-op syntax", () => { it("surfaces a warning when boundary duplicates are auto-absorbed", () => { const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(3, "old();"))}→`, pl("// one"), pl("// two"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(3, "old();"))}:`, pl("// one"), pl("// two"), pl("new();")].join("\n"); - const result = applyHashlineEdits(source, parseHashline(diff)); + const result = applyHashlineEdits(source, parseHashline(diff).edits); expect(result.lines).toBe(["// one", "// two", "new();"].join("\n")); expect(result.warnings).toBeDefined(); expect(result.warnings).toEqual( @@ -326,7 +322,7 @@ describe("hashline parser — suffix-op syntax", () => { 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), { autoDropPureInsertDuplicates: true }); + const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true }); expect(result.lines).toBe("aaa\nbbb\nNEW\nccc"); expect(result.warnings).toBeDefined(); expect(result.warnings).toEqual( @@ -336,7 +332,7 @@ describe("hashline parser — suffix-op syntax", () => { it("preserves payload text exactly", () => { const diff = [ - `${sameLineRange(tag(2, "bbb"))}→`, + `${sameLineRange(tag(2, "bbb"))}:`, pl(""), pl("# not a header"), pl("+ not an op"), @@ -348,124 +344,126 @@ describe("hashline parser — suffix-op syntax", () => { it("treats blank lines inside a payload run as empty payload lines", () => { // Truly blank lines inside an active payload run are verbatim empty // payload lines as long as more payload follows. - const diff = [`${sameLineRange(tag(2, "bbb"))}→`, pl("first"), "", "", pl("after")].join("\n"); + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, pl("first"), "", "", pl("after")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc"); }); - it("treats blank lines before the next op as payload", () => { + it("drops blank lines between ops (separator, not payload)", () => { + // Blank lines immediately before a next op are visual separators, not + // payload. This prevents agents from silently inflating a payload and + // shifting downstream line numbers. const diff = [ - `${sameLineRange(tag(1, "aaa"))}→`, + `${sameLineRange(tag(1, "aaa"))}:`, pl("AAA"), "", "", - `${sameLineRange(tag(3, "ccc"))}→`, + `${sameLineRange(tag(3, "ccc"))}:`, pl("CCC"), ].join("\n"); - expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC"); + expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC"); }); - it("rejects missing payloads and orphan payload lines", () => { - expect(() => parseHashline(`${tag(1, "aaa")}↓`)).toThrow(/require at least one/); - expect(() => parseHashline(pl("orphan"))).toThrow(/payload line has no preceding/); + it("treats a bare insert op as inserting one empty line", () => { + // `LINE↑` / `LINE↓` with no payload default to one empty line (same as `LINE↑\n\n`). + const upAnchor = { line: 1 }; + expect(parseHashline(`${tag(1, "aaa")}↑`).edits).toEqual([ + { kind: "insert", cursor: { kind: "before_anchor", anchor: upAnchor }, text: "", lineNum: 1, index: 0 }, + ]); + expect(parseHashline(`${tag(1, "aaa")}↓`).edits).toEqual([ + { kind: "insert", cursor: { kind: "after_anchor", anchor: upAnchor }, text: "", lineNum: 1, index: 0 }, + ]); + }); + + it("rejects orphan payload lines with no preceding op", () => { + expect(() => parseHashline(pl("orphan")).edits).toThrow(/payload line has no preceding/); }); it("leniently treats a bare blank line after ↑ / ↓ as an empty payload", () => { - const hash = computeLineHash(5, "aaa"); - const anchor = { line: 5, hash }; - expect(parseHashline(`${tag(5, "aaa")}↑\n\n`)).toEqual([ + const anchor = { line: 5 }; + expect(parseHashline(`${tag(5, "aaa")}↑\n\n`).edits).toEqual([ { kind: "insert", cursor: { kind: "before_anchor", anchor }, text: "", lineNum: 1, index: 0 }, ]); - expect(parseHashline(`${tag(5, "aaa")}↓\n\n`)).toEqual([ + expect(parseHashline(`${tag(5, "aaa")}↓\n\n`).edits).toEqual([ { kind: "insert", cursor: { kind: "after_anchor", anchor }, text: "", lineNum: 1, index: 0 }, ]); }); - it("rejects op sigils written in prefix position (legacy syntax)", () => { - expect(() => parseHashline(`↑${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); - expect(() => parseHashline(`↓${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); - expect(() => parseHashline(`→${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); + it("rejects op sigils written in prefix position", () => { + expect(() => parseHashline(`↑${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/); + expect(() => parseHashline(`↓${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/); + expect(() => parseHashline(`:${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/); }); - it("rejects ranges with `..` separator (legacy syntax)", () => { + it("rejects ranges with `..` separator", () => { // `..` is no longer the range separator; the line is treated as orphan - // payload because `2yy..3yy→` does not match the new range pattern. - expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}→\nBBB`)).toThrow(/payload line has no preceding/); + // payload because `2..3:` does not match the new range pattern. + expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\nBBB`).edits).toThrow( + /payload line has no preceding/, + ); }); it("describes the new sigil shape on unknown-op lines", () => { - expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`)).toThrow(/Use ANCHOR↑.*ANCHOR↓.*A-B→/); + expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow( + /Use LINE↑.*LINE↓.*LINE: \/ A-B:.*LINE! \/ A-B!/, + ); }); - it("leniently tolerates a trailing `|TEXT` body on anchors copied verbatim from read output", () => { + it("treats `LINE:TEXT` copied from read output as a single-line replace", () => { const anchor = tag(2, "bbb"); - // Bare trailing `|`, full `|TEXT` body, and trailing decoration after a range. - expect(applyDiff(content, [`${anchor}|→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, [`${anchor}|bbb→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, [`${anchor}|bbb↑`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); - expect(applyDiff(content, [`${anchor}|bbb↓`, pl("X")].join("\n"))).toBe("aaa\nbbb\nX\nccc"); - // Trailing `|TEXT` after the full range is also tolerated. - expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}|ccc→`)).toBe("aaa"); + expect(applyDiff(content, `${anchor}:BBB`)).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}:BBB`)).toBe("aaa\nBBB"); }); it("leniently strips `*`/`>` line-marker decoration from anchors", () => { const anchor = tag(2, "bbb"); - expect(applyDiff(content, [`*${anchor}→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, [`*${anchor}:`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); expect(applyDiff(content, [`>${anchor}↑`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); }); - it("anchor paste decoration `|TEXT` before the op is cosmetic; real payload comes after", () => { - // `|bbb` between the anchor and the op is just paste decoration and is - // discarded. Payload must come inline after the op or on the next lines. - const anchor = tag(2, "bbb"); - const diff = [`${anchor}|bbb↓`, pl("X"), pl("Y")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbbb\nX\nY\nccc"); - // No inline payload after the op and no follow-up: error. - expect(() => parseHashline(`${anchor}|bbb↓`)).toThrow(/require at least one/); + it("rejects arrow replace syntax as an unrecognized payload line", () => { + expect(() => parseHashline(`2→\nBBB`).edits).toThrow(/payload line has no preceding/); + expect(() => parseHashline(`2-3→\nBBB`).edits).toThrow(/payload line has no preceding/); }); - it("treats `|TEXT` after BOF/EOF as cosmetic decoration", () => { - expect(applyDiff(content, `BOF|head↓HEAD`)).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, `EOF|tail↓TAIL`)).toBe("aaa\nbbb\nccc\nTAIL"); + it("treats `LINE:TEXT` as replace syntax even when TEXT contains ↑ / ↓", () => { + const anchor = tag(2, "bbb"); + expect(applyDiff(content, `${anchor}:bbb↓`)).toBe("aaa\nbbb↓\nccc"); + expect(applyDiff(content, `${anchor}:bbb↑\nX`)).toBe("aaa\nbbb↑\nX\nccc"); + }); + + it("uses inline payload for BOF/EOF inserts", () => { + expect(applyDiff(content, `BOF↓HEAD`)).toBe("HEAD\naaa\nbbb\nccc"); + expect(applyDiff(content, `EOF↓TAIL`)).toBe("aaa\nbbb\nccc\nTAIL"); + expect(() => parseHashline(`2!keep`).edits).toThrow( + /deletes only\. Payload is forbidden after !; use : to replace/, + ); }); }); -describe("hashline — stale anchors", () => { - it("throws HashlineMismatchError when a Lid hash no longer matches", () => { - const diff = [`${sameLineRange(mistag(2, "bbb"))}→`, pl("BBB")].join("\n"); - expect(() => applyDiff("aaa\nbbb\nccc", diff)).toThrow(HashlineMismatchError); +describe("hashline — file hash binding", () => { + it("rejects line-hash anchors as unrecognized payload lines", () => { + expect(() => parseHashline("2ab:\nBBB").edits).toThrow(/payload line has no preceding/); }); - it("rejects when an anchor's stored line shifted (no auto-rebase)", () => { - const stale = tag(2, "bbb"); - const diff = [`${sameLineRange(stale)}→`, pl("BBB")].join("\n"); - expect(() => applyDiff("aaa\nINSERTED\nbbb\nccc", diff)).toThrow(HashlineMismatchError); - }); - - it("rejects when the line hash matches a different nearby line", () => { - // Significant-content lines hash by content alone; identical content gives - // identical hashes, so an anchor pointing at a different line with the - // same hash must not be silently relocated. - const file = ["x = 1", "y = 2", "x = 1", "z = 3", "x = 1", "w = 4"].join("\n"); - const collidingHash = computeLineHash(1, "x = 1"); - // User points at line 4 (`z = 3`) with the colliding hash; without auto- - // rebase, this is a plain mismatch. - const diff = [`${sameLineRange(`4${collidingHash}`)}→`, pl("REPLACED")].join("\n"); - expect(() => applyDiff(file, diff)).toThrow(HashlineMismatchError); + it("applies line-number edits without per-anchor hash validation", () => { + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, pl("BBB")].join("\n"); + expect(applyDiff("aaa\nbbb\nccc", diff)).toBe("aaa\nBBB\nccc"); }); }); describe("splitHashlineInput — ¶ headers", () => { - it("extracts path and diff body from ¶path header", () => { - const input = [`¶src/foo.ts`, `${sameLineRange(tag(2, "bbb"))}→`, pl("BBB")].join("\n"); + it("extracts path, file hash, and diff body from ¶path#hash header", () => { + const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, pl("BBB")].join("\n"); expect(splitHashlineInput(input)).toEqual({ path: "src/foo.ts", - diff: `${sameLineRange(tag(2, "bbb"))}→\n${pl("BBB")}`, + fileHash: "1a2b", + diff: `${sameLineRange(tag(2, "bbb"))}:\n${pl("BBB")}`, }); }); - it("strips leading blank lines and unquotes matching path quotes", () => { - expect(splitHashlineInput(`\n¶"foo bar.ts"\nBOF↓\n${pl("x")}`)).toEqual({ - path: "foo bar.ts", + it("strips leading blank lines", () => { + expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${pl("x")}`)).toEqual({ + path: "foo.ts", diff: `BOF↓\n${pl("x")}`, }); }); @@ -525,7 +523,7 @@ describe("hashline executor", () => { 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`; + const input = `${header("a.ts", source)}\n${tag(2, "bbb")}↓\n${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`; await Bun.write(filePath, source); await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); @@ -545,17 +543,18 @@ describe("hashline executor", () => { const bPath = path.join(tempDir, "b.ts"); await Bun.write(aPath, "aaa\n"); await Bun.write(bPath, "bbb\n"); + const bHeader = "¶b.ts#0000"; const input = [ - "¶a.ts", - `${sameLineRange(tag(1, "aaa"))}→`, + header("a.ts", "aaa\n"), + `${sameLineRange(tag(1, "aaa"))}:`, pl("AAA"), - "¶b.ts", - `${sameLineRange(mistag(1, "bbb"))}→`, + bHeader, + `${sameLineRange(tag(1, "bbb"))}:`, pl("BBB"), ].join("\n"); await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow( - /anchor(s)? do(es)? not match the current file/, + /file changed between read and edit|file hashes to/, ); expect(await Bun.file(aPath).text()).toBe("aaa\n"); expect(await Bun.file(bPath).text()).toBe("bbb\n"); @@ -574,8 +573,8 @@ describe("hashline executor", () => { // A naive sequential apply reads the modified disk and fails anchor // validation outright. const input = [ - "¶a.ts", - `${sameLineRange(tag(2, "L2"))}→`, + header("a.ts", `${original}\n`), + `${sameLineRange(tag(2, "L2"))}:`, pl("L2a"), pl("L2b"), pl("L2c"), @@ -585,7 +584,7 @@ describe("hashline executor", () => { pl("L2g"), pl("L2h"), pl("L2i"), - "¶a.ts", + header("a.ts", `${original}\n`), `${tag(8, "L8")}↓`, pl("INSERTED"), ].join("\n"); @@ -630,45 +629,40 @@ describe("hashlineEditParamsSchema — extra-field tolerance", () => { }); }); -describe("buildCompactHashlineDiffPreview — anchors track post-edit line numbers", () => { - it("emits hashes against the new file's line numbers for context after a range expansion", () => { +describe("buildCompactHashlineDiffPreview — line numbers track post-edit positions", () => { + it("emits context lines against the new file's line numbers after a range expansion", () => { const before = ["a1", "a2", "a3", "a4", "a5", "a6", "a7"].join("\n"); const after = ["a1", "a2", "a3", "X", "Y", "Z", "a5", "a6", "a7"].join("\n"); const { diff } = generateDiffString(before, after); const preview = buildCompactHashlineDiffPreview(diff); - // Walk the preview and verify every ` LINE+HASH${outputSep}content` line matches what + // Walk the preview and verify every ` LINE:content` line matches what // the file now has at that line number. const newFileLines = after.split("\n"); for (const line of preview.preview.split("\n")) { if (!line.startsWith(" ")) continue; // Skip context-elision markers ("...") which carry no real file content. if (line.endsWith(`${outputSep}...`)) continue; - const match = new RegExp(`^\\s(\\d+)([a-z]{2})${outputSepRe}(.*)$`).exec(line); + const match = new RegExp(`^\\s(\\d+)${outputSepRe}(.*)$`).exec(line); expect(match).not.toBeNull(); if (!match) continue; const lineNum = Number(match[1]); - const hash = match[2]; - const content = match[3]; + const content = match[2]; expect(newFileLines[lineNum - 1]).toBe(content); - expect(computeLineHash(lineNum, content)).toBe(hash); } }); - it("emits + lines with hashes against new line numbers and - lines with the placeholder", () => { + it("emits + and - lines with bare line numbers", () => { const before = "alpha\nbeta\ngamma\n"; const after = "alpha\nDELTA\nEPSILON\ngamma\n"; const { diff } = generateDiffString(before, after); const preview = buildCompactHashlineDiffPreview(diff); const additions = preview.preview.split("\n").filter(line => line.startsWith("+")); - expect(additions).toEqual([ - `+2${computeLineHash(2, "DELTA")}${outputSep}DELTA`, - `+3${computeLineHash(3, "EPSILON")}${outputSep}EPSILON`, - ]); + expect(additions).toEqual([`+2${outputSep}DELTA`, `+3${outputSep}EPSILON`]); const removals = preview.preview.split("\n").filter(line => line.startsWith("-")); - expect(removals).toEqual([`-2--${outputSep}beta`]); + expect(removals).toEqual([`-2${outputSep}beta`]); }); }); @@ -677,11 +671,15 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); const v0Lines = ["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8"]; - await Bun.write(filePath, `${v0Lines.join("\n")}\n`); + const v0Text = `${v0Lines.join("\n")}\n`; + await Bun.write(filePath, v0Text); const session = makeHashlineSession(tempDir); // Simulate the read tool having shown V0 to the model in this session. - getFileReadCache(session).recordContiguous(filePath, 1, v0Lines); + getFileReadCache(session).recordContiguous(filePath, 1, v0Text.split("\n"), { + fullText: v0Text, + fileHash: computeFileHash(v0Text), + }); // External actor (linter, subagent, user) prepends 7 lines. Anchors // authored against V0 no longer match V1, so the model's edit cannot @@ -691,7 +689,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { await Bun.write(filePath, `${v1Lines.join("\n")}\n`); // Model authors anchor against V0 — line 2 is "L2" in V0. - const input = `¶a.ts\n${sameLineRange(tag(2, "L2"))}→\n${pl("L2-MODEL")}\n`; + const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "L2"))}:\n${pl("L2-MODEL")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)); const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n"); @@ -704,7 +702,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { expect(finalLines).toContain("L8"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; - expect(text).toMatch(/Recovered from stale anchors using a previous read snapshot/); + expect(text).toMatch(/Recovered from a stale file hash using a previous read snapshot/); }); }); @@ -712,17 +710,21 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); const v0Lines = Array.from({ length: 10 }, (_, idx) => `L${idx + 1}`); - await Bun.write(filePath, `${v0Lines.join("\n")}\n`); + const v0Text = `${v0Lines.join("\n")}\n`; + await Bun.write(filePath, v0Text); const session = makeHashlineSession(tempDir); - // Cache only covers the first three lines — but the edit targets line 6. - getFileReadCache(session).recordContiguous(filePath, 1, v0Lines.slice(0, 3)); + // Cache only covers the first three lines — enough to retain the file hash + // but not enough to synthesize the requested pre-edit snapshot. + getFileReadCache(session).recordContiguous(filePath, 1, v0Lines.slice(0, 3), { + fileHash: computeFileHash(v0Text), + }); const v1Lines = [...v0Lines]; v1Lines[5] = "L6-CHANGED"; await Bun.write(filePath, `${v1Lines.join("\n")}\n`); - const input = `¶a.ts\n${sameLineRange(tag(6, "L6"))}→\n${pl("L6-MODEL")}\n`; + const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(6, "L6"))}:\n${pl("L6-MODEL")}\n`; await expect( executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)), ).rejects.toThrow(HashlineMismatchError); @@ -734,18 +736,23 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { it("returns null from tryRecoverHashlineWithCache when applyPatch cannot land", () => { const cache = new FileReadCache(); const fakePath = "/tmp/__hashline-recovery-applypatch__.ts"; - cache.recordContiguous(fakePath, 1, ["alpha", "beta", "gamma", "delta", "epsilon"]); + const snapshotText = "alpha\nbeta\ngamma\ndelta\nepsilon"; + cache.recordContiguous(fakePath, 1, snapshotText.split("\n"), { + fullText: snapshotText, + fileHash: computeFileHash(snapshotText), + }); // Live file is completely different — patch context cannot match even // with fuzz tolerance. const currentText = "totally\nunrelated\ncontent\nhere\nnow\n"; - const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}→\n${pl("BETA-MODEL")}`); + const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}:\n${pl("BETA-MODEL")}`).edits; const recovered = tryRecoverHashlineWithCache({ cache, absolutePath: fakePath, currentText, edits, + fileHash: computeFileHash(snapshotText), options: {}, }); expect(recovered).toBeNull(); @@ -764,15 +771,19 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); const v0Lines = ["alpha", "beta", "gamma", "delta", "epsilon"]; - await Bun.write(filePath, `${v0Lines.join("\n")}\n`); + const v0Text = `${v0Lines.join("\n")}\n`; + await Bun.write(filePath, v0Text); const session = makeHashlineSession(tempDir); // Initial read populates the cache with V0. - getFileReadCache(session).recordContiguous(filePath, 1, v0Lines); + getFileReadCache(session).recordContiguous(filePath, 1, v0Text.split("\n"), { + fullText: v0Text, + fileHash: computeFileHash(v0Text), + }); - // First edit: change line 2 → BETA. After the write, the cache should + // First edit: change line 2 : BETA. After the write, the cache should // reflect V1 (post-edit), not V0. - const firstInput = `¶a.ts\n${sameLineRange(tag(2, "beta"))}→\n${pl("BETA")}\n`; + const firstInput = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "beta"))}:\n${pl("BETA")}\n`; await executeHashlineSingle(hashlineExecuteOptions(tempDir, firstInput, undefined, session)); const v1Lines = ["alpha", "BETA", "gamma", "delta", "epsilon"]; expect(await Bun.file(filePath).text()).toBe(`${v1Lines.join("\n")}\n`); @@ -788,7 +799,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { const v2Lines = ["H1", "H2", "H3", "H4", "H5", "H6", "H7", ...v1Lines]; await Bun.write(filePath, `${v2Lines.join("\n")}\n`); - const secondInput = `¶a.ts\n${sameLineRange(tag(3, "gamma"))}→\n${pl("GAMMA")}\n`; + const secondInput = `${header("a.ts", `${v1Lines.join("\n")}\n`)}\n${sameLineRange(tag(3, "gamma"))}:\n${pl("GAMMA")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, secondInput, undefined, session)); const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n"); @@ -797,9 +808,52 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { expect(finalLines).toContain("GAMMA"); expect(finalLines).not.toContain("gamma"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; - expect(text).toMatch(/Recovered from stale anchors using a previous read snapshot/); + expect(text).toMatch(/Recovered from a stale file hash using a previous read snapshot/); }); }); + it("recovers from an older in-session snapshot even if the current file advanced again", () => { + const cache = new FileReadCache(); + const fakePath = "/tmp/__hashline-cache-ring-recovery__.ts"; + const v0Text = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; + const v1Text = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; + const currentText = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nTRAILER\n"; + + cache.recordContiguous(fakePath, 1, v0Text.split("\n"), { + fullText: v0Text, + fileHash: computeFileHash(v0Text), + }); + cache.recordContiguous(fakePath, 1, v1Text.split("\n"), { + fullText: v1Text, + fileHash: computeFileHash(v1Text), + }); + + const recovered = tryRecoverHashlineWithCache({ + cache, + absolutePath: fakePath, + currentText, + fileHash: computeFileHash(v0Text), + edits: parseHashline(`10:\nL10-EDITED`).edits, + options: {}, + }); + + expect(recovered).not.toBeNull(); + expect(recovered?.lines).toContain("L10-EDITED"); + }); + + it("retains older file hashes in the per-path snapshot ring", () => { + const cache = new FileReadCache(); + const fakePath = "/tmp/__hashline-cache-ring__.ts"; + const versions = ["one\n", "two\n", "three\n"]; + for (const version of versions) { + cache.recordContiguous(fakePath, 1, version.split("\n"), { + fullText: version, + fileHash: computeFileHash(version), + }); + } + expect(cache.get(fakePath)?.fileHash).toBe(computeFileHash("three\n")); + expect(cache.getByHash(fakePath, computeFileHash("one\n"))?.fullText).toBe("one\n"); + expect(cache.getByHash(fakePath, computeFileHash("two\n"))?.fullText).toBe("two\n"); + }); it("drops a cached entry when newly recorded lines disagree on overlap", () => { const cache = new FileReadCache(); @@ -840,7 +894,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("parser breaks at *** Abort and surfaces a warning", () => { const diff = [`${tag(1, "alpha")}↓`, pl("HELLO"), sentinel, `${tag(99, "junk")}↓`, pl("never")].join("\n"); - const { edits, warnings } = parseHashlineWithWarnings(diff); + const { edits, warnings } = parseHashline(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); expect(warnings.length).toBeGreaterThan(0); @@ -850,7 +904,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("appended sentinel from harmony-leak truncation: ops above are preserved", () => { // Mirrors the exact shape harmony-leak emits inside a single section. const diff = `${tag(1, "alpha")}↓\n${pl("KEPT")}\n*** Abort\n`; - const { edits, warnings } = parseHashlineWithWarnings(diff); + const { edits, warnings } = parseHashline(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ text: "KEPT" }); expect(warnings.length).toBeGreaterThan(0); @@ -874,7 +928,110 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("clean input without sentinel produces no warning", () => { const diff = `${tag(1, "alpha")}↓\n${pl("PAYLOAD")}\n`; - const { warnings } = parseHashlineWithWarnings(diff); + const { warnings } = parseHashline(diff); expect(warnings).toEqual([]); }); }); + +describe("hashline parser — bare ':' replaces with a single blank line", () => { + it("bare A: replaces the line with a single blank line", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:\n`); + expect(applyDiff(text, diff)).toBe("line1\n\nline3\n"); + }); + + it("bare A-B: replaces the range with a single blank line", () => { + const text = "line1\nline2\nline3\nline4\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:\n`); + expect(applyDiff(text, diff)).toBe("line1\n\nline4\n"); + }); + + it("A: with inline body still works", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:replacement\n`); + expect(applyDiff(text, diff)).toBe("line1\nreplacement\nline3\n"); + }); + + it("A: with explicit blank payload line also replaces with blank", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:\n\n`); + expect(applyDiff(text, diff)).toBe("line1\n\nline3\n"); + }); + + it("bare A↑ still inserts a blank line above", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2↑\n`); + expect(applyDiff(text, diff)).toBe("line1\n\nline2\nline3\n"); + }); + + it("bare A↓ still inserts a blank line below", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2↓\n`); + expect(applyDiff(text, diff)).toBe("line1\nline2\n\nline3\n"); + }); +}); + +describe("hashline apply — brace-delete soft warning", () => { + it("deleting a line with unbalanced brace emits a warning", () => { + const text = "if (x) {\n doThing();\n} else {\n doOther();\n}\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n3!\n`); + const result = applyHashlineEdits(text, parseHashline(diff).edits); + expect(result.warnings).toBeDefined(); + expect(result.warnings![0]).toContain("structural bracket/brace boundary"); + expect(result.warnings![0]).toContain("} else {"); + }); + + it("deleting a balanced line emits no warning", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2!\n`); + const result = applyHashlineEdits(text, parseHashline(diff).edits); + expect(result.warnings).toBeUndefined(); + }); + + it("replace operation that includes a brace line does NOT warn", () => { + const text = "if (x) {\n body\n}\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n3:}\n`); + const result = applyHashlineEdits(text, parseHashline(diff).edits); + expect(result.warnings).toBeUndefined(); + }); +}); + +describe("hashline parser — blank line is a separator before next op", () => { + it("blank line between ops is NOT absorbed into previous payload", () => { + const text = "a\nb\nc\nd\ne\n"; + const ops = `${header("a.ts", text)}\n1:A\n\n3:C\n`; + const { diff } = splitHashlineInput(ops); + // Both replaces land on their target lines without inflating either payload. + expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); + }); + + it("multiple blank lines between ops are also dropped", () => { + const text = "a\nb\nc\nd\ne\n"; + const ops = `${header("a.ts", text)}\n1:A\n\n\n\n3:C\n`; + const { diff } = splitHashlineInput(ops); + expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); + }); + + it("blank-only payload before next op blanks the line", () => { + // Agent typed `2:` then a blank separator then `4:D`. Under bare-`A:` + // blank-replace semantics, `2:` blanks line 2 and `4:D` replaces line 4. + const text = "a\nb\nc\nd\ne\n"; + const ops = `${header("a.ts", text)}\n2:\n\n4:D\n`; + const { diff } = splitHashlineInput(ops); + expect(applyDiff(text, diff)).toBe("a\n\nc\nD\ne\n"); + }); + + it("blank line inside payload between two content lines is preserved", () => { + const text = "a\nb\nc\n"; + const ops = `${header("a.ts", text)}\n2:\nfirst\n\nsecond\n`; + const { diff } = splitHashlineInput(ops); + expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n"); + }); + + it("trailing blank in payload at EOF is preserved (explicit blank replace)", () => { + const text = "a\nb\nc\n"; + const ops = `${header("a.ts", text)}\n2:\n\n`; + const { diff } = splitHashlineInput(ops); + expect(applyDiff(text, diff)).toBe("a\n\nc\n"); + }); +}); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index d80ee724c..c752dfecd 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -5,10 +5,10 @@ import * as path from "node:path"; import { adjustIndentation, computeEditDiff, + computeFileHash, computeHashlineDiff, DEFAULT_FUZZY_THRESHOLD, findMatch, - formatLineHash, } from "@oh-my-pi/pi-coding-agent/edit"; describe("findMatch", () => { @@ -236,10 +236,9 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `1→` with the same line as payload is a true no-op: the edit + // `1:` with the same line as payload is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. - const anchor = formatLineHash(1, line); - const input = `¶${sourcePath}\n${anchor}→\n${line}\n`; + const input = `¶${sourcePath}#${computeFileHash(`${line}\n`)}\n1:\n${line}\n`; const result = await computeHashlineDiff({ input }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index af317fbbd..461bcbadb 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -252,9 +252,9 @@ describe("hashline prompt helpers", () => { '{{hline 2 "const timeout = 5000;"}}\nquoted={{href 2}}\nraw={{hrefr 2}}\nlast={{hrefr}}', ); const [line, quoted, raw, last] = result.split("\n"); - const ref = line.split("|", 1)[0]; + const ref = line.split(":", 1)[0]; - expect(line).toBe(`${ref}|const timeout = 5000;`); + expect(line).toBe(`${ref}:const timeout = 5000;`); expect(quoted).toBe(`quoted="${ref}"`); expect(raw).toBe(`raw=${ref}`); expect(last).toBe(`last=${ref}`); @@ -266,11 +266,11 @@ describe("hashline prompt helpers", () => { const ref = raw.slice("raw=".length); expect(quoted).toBe(`quoted="${ref}"`); - expect(ref).toMatch(/^5[a-z]{2}$/); + expect(ref).toBe("5"); }); test("href should not reuse hline state across prompt renders", () => { - expect(expandPrompt('{{hline 1 "const x = 1;"}}\n{{hrefr}}')).toMatch(/^1[a-z]{2}\|const x = 1;\n1[a-z]{2}$/); + expect(expandPrompt('{{hline 1 "const x = 1;"}}\n{{hrefr}}')).toBe("1:const x = 1;\n1"); expect(() => expandPrompt("{{hrefr}}")).toThrow("previous {{hline}}"); }); }); diff --git a/packages/coding-agent/test/read-multi-range.test.ts b/packages/coding-agent/test/read-multi-range.test.ts index 51caf2c79..61554edff 100644 --- a/packages/coding-agent/test/read-multi-range.test.ts +++ b/packages/coding-agent/test/read-multi-range.test.ts @@ -154,6 +154,6 @@ describe("read tool multi-range selector", () => { expect(text).toContain("bridge four"); expect(text).toContain("bridge five"); expect(text).not.toContain("bridge three"); - expect(text).not.toContain("disk"); + expect(text).not.toContain("disk one"); }); }); diff --git a/packages/coding-agent/test/read-summary.test.ts b/packages/coding-agent/test/read-summary.test.ts index 13d6384b4..eac4d2b0a 100644 --- a/packages/coding-agent/test/read-summary.test.ts +++ b/packages/coding-agent/test/read-summary.test.ts @@ -183,10 +183,10 @@ describe("read summary", () => { expect(text).toContain("name: Ada"); }); - it("renders brace-pair elisions as a single anchored line with `..`", async () => { + it("renders brace-pair elisions as a single numbered line with `..`", async () => { // Regression for the read-tool format request: collapse the head / - // elided / closing-brace sandwich into one anchored line of the form - // `LINE+ID-LINE+ID|head { .. }` instead of three separate lines. + // elided / closing-brace sandwich into one numbered line of the form + // `START-END:head { .. }` instead of three separate lines. const fixture = path.join(tmpDir, "merge.ts"); await fs.writeFile( fixture, @@ -200,8 +200,8 @@ describe("read summary", () => { expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { .. }"); // The plain `...` ellipsis line must NOT appear once the merge fires. expect(text).not.toContain("\n...\n"); - // The merged anchor must be a hash-line range (LINE+ID-LINE+ID|head). - expect(text).toMatch(/\b1[a-z]{2}-7[a-z]{2}\|export function stripNewLinePrefixes/); + // The merged line must use the numbered range shape. + expect(text).toMatch(/\b1-7:export function stripNewLinePrefixes/); expect(result.details?.summary?.elidedSpans).toBe(1); }); @@ -240,7 +240,7 @@ describe("read summary", () => { expect(text).not.toContain(" .. "); }); - it("appends an elision footer that names the path and `:raw` recovery selector", async () => { + it("appends an elision footer that names targeted recovery ranges", async () => { // Regression for issue #1046: summarized reads must tell the model how // to recover the elided body so it does not stall on `...` / `{ .. }` // markers and burn a turn guessing the selector. @@ -256,12 +256,13 @@ describe("read summary", () => { expect(result.details?.summary?.elidedSpans).toBe(2); expect(result.details?.summary?.elidedLines).toBeGreaterThan(0); - expect(text).toContain("elided regions"); - expect(text).toContain(`${fixture}:raw`); - expect(text).toContain(`${fixture}:1-9999`); + expect(text).toContain("lines elided"); + expect(text).toContain(`${fixture}:1-5,7-11`); + expect(text).not.toContain(`${fixture}:raw`); + expect(text).not.toContain(`${fixture}:1-9999`); // Footer must be the LAST block of output so the recovery hint sits // next to the structural summary it describes. - expect(text.trimEnd().endsWith("for verbatim content]")).toBe(true); + expect(text.trimEnd().endsWith("]")).toBe(true); }); it("does not append a footer when the file has no elision", async () => { diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index d28eb585e..1a4ed35a8 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1269,16 +1269,11 @@ function b() { it("should abort and recover for subsequent commands", async () => { const controller = new AbortController(); - const promise = bashTool.execute( - "test-call-10-abort", - { command: "printf 'started\\n'; sleep 60" }, - controller.signal, - update => { - if (update.content?.some(content => content.type === "text" && content.text.includes("started"))) { - controller.abort("test abort"); - } - }, - ); + const promise = bashTool.execute("test-call-10-abort", { command: "sleep 60" }, controller.signal); + // Give the native shell a beat to enter `sleep`; do not depend on chunk + // delivery timing, which is flaky on loaded CI runners. + await Bun.sleep(100); + controller.abort("test abort"); await expect(promise).rejects.toThrow(/abort|cancel|timed out/i); const result = await bashTool.execute("test-call-10-after-abort", { command: "echo ok" }); diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 4e1c05446..ba2b26639 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -60,7 +60,7 @@ describe("ast_edit tool schema", () => { expect(strict.strict).toBe(true); }); - it("renders +/- lines with aligned hashline prefixes", async () => { + it("renders +/- lines with numbered hashline prefixes", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-edit-render-")); try { const filePath = path.join(tempDir, "legacy.ts"); @@ -81,9 +81,9 @@ describe("ast_edit tool schema", () => { expect(removedLine).toBeDefined(); expect(addedLine).toBeDefined(); - expect(removedLine).toMatch(/^-\d+[a-z]{2}\|/); - expect(addedLine).toMatch(/^\+\d+[a-z]{2}\|/); - expect(removedLine?.split("|", 1)[0].length).toBe(addedLine?.split("|", 1)[0].length); + expect(removedLine).toMatch(/^-\d+:/); + expect(addedLine).toMatch(/^\+\d+:/); + expect(removedLine?.split(":", 1)[0].length).toBe(addedLine?.split(":", 1)[0].length); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -211,8 +211,9 @@ describe("ast_edit tool schema", () => { | { totalReplacements?: number; fileReplacements?: Array<{ path: string; count: number }> } | undefined; - expect(text).toContain("## root.ts (1 replacement)"); - expect(text).toContain("## child.ts (1 replacement)"); + // Tree-grouped output: `# packages/pkg-…/src/` then `## root.ts# (1 replacement)`. + expect(text).toMatch(/^## root\.ts#[0-9a-f]{4} \(\d+ replacement[s]?\)$/m); + expect(text).toMatch(/^## child\.ts#[0-9a-f]{4} \(\d+ replacement[s]?\)$/m); expect(text).not.toContain("ignore.js"); expect(text).not.toContain("outside.ts"); expect(details?.totalReplacements).toBe(2); diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts index 49b4cbc6e..47258ea73 100644 --- a/packages/coding-agent/test/tools/ast-grep.test.ts +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -100,8 +100,9 @@ describe("ast_grep parse errors", () => { const text = result.content.find(content => content.type === "text")?.text ?? ""; const details = result.details as { matchCount?: number; fileCount?: number } | undefined; - expect(text).toContain("## root.ts"); - expect(text).toContain("## child.ts"); + // Directory mode uses tree-grouped `# dir/` + `## name#hash` headers. + expect(text).toMatch(/## root\.ts#[0-9a-f]+/); + expect(text).toMatch(/## child\.ts#[0-9a-f]+/); expect(text).not.toContain("ignore.js"); expect(text).not.toContain("outside.ts"); expect(details?.matchCount).toBe(2); diff --git a/packages/coding-agent/test/tools/conflict-integration.test.ts b/packages/coding-agent/test/tools/conflict-integration.test.ts index a9ac07efa..d1b5b318e 100644 --- a/packages/coding-agent/test/tools/conflict-integration.test.ts +++ b/packages/coding-agent/test/tools/conflict-integration.test.ts @@ -511,7 +511,7 @@ describe("write resolves conflicts via conflict://N", () => { await read.execute("read-hashed", { path: "hashed.ts" }); const result = await write.execute("write-hashed", { path: "conflict://1", - content: "42xy|cleanline\n", + content: "¶hashed.ts#1a2b\n42:cleanline\n", }); expect(getText(result)).toContain("auto-stripped hashline display prefixes"); const after = await Bun.file(filePath).text(); diff --git a/packages/coding-agent/test/tools/search-internal-urls.test.ts b/packages/coding-agent/test/tools/search-internal-urls.test.ts index 53006e3e3..672f292f0 100644 --- a/packages/coding-agent/test/tools/search-internal-urls.test.ts +++ b/packages/coding-agent/test/tools/search-internal-urls.test.ts @@ -146,8 +146,9 @@ describe("SearchTool internal URL resolution", () => { const text = getResultText(result); expect(text).toContain("needle"); - // No hashline anchors (LINE+ID|content) for immutable sources - expect(text).not.toMatch(/^\*?\s*\d+[a-z]{2}\|/m); + // No hashline section headers or numbered editable lines for immutable sources. + expect(text).not.toMatch(/^¶.*#[0-9a-f]{4}$/m); + expect(text).not.toMatch(/^\*?\s*\d+:/m); }); it("resolves local:// URLs before file-name lookup", async () => { @@ -185,8 +186,9 @@ describe("SearchTool internal URL resolution", () => { const text = getResultText(result); expect(text).toContain("needle"); - // Hashline anchor (LINE+ID|content) is kept for mutable local:// sources - expect(text).toMatch(/^\*?\s*\d+[a-z]{2}\|/m); + // Mutable local:// sources keep a hashline section header plus numbered match lines. + expect(text).toMatch(/^¶.*#[0-9a-f]{4}$/m); + expect(text).toMatch(/^\*\d+:.*needle/m); }); it("keeps hashlines on mutable files when mixed with immutable artifact:// inputs", async () => { @@ -204,8 +206,9 @@ describe("SearchTool internal URL resolution", () => { const text = getResultText(result); expect(text).toContain("needle"); - // Mutable mixed.txt keeps hashlines somewhere in the output - expect(text).toMatch(/^\*?\s*\d+[a-z]{2}\|.*mixed needle/m); + // Mutable mixed.txt keeps hashlines somewhere in the output. + expect(text).toMatch(/^# mixed\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^\*\d+:.*mixed needle/m); }); it("throws on nonexistent artifact ID", async () => { diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index 346dd1868..5bae608a6 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -81,10 +81,10 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; - expect(text).toContain("# apps"); - expect(text).toContain("# packages"); - expect(text).toContain("# phases"); - expect(text).toContain("## grep.txt"); + expect(text).toMatch(/^# apps\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^# packages\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^# phases\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toContain("shared-needle"); expect(text).not.toContain("# other"); expect(details?.fileCount).toBe(3); expect(details?.scopePath).toBe("apps/, packages/, phases/"); @@ -141,8 +141,8 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; - expect(text).toContain("# apps"); - expect(text).toContain("## grep.txt"); + expect(text).toMatch(/^# apps\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toContain("shared-needle"); expect(text).not.toContain(tempDir); expect(details?.fileCount).toBe(1); expect(details?.scopePath).toBe("apps"); @@ -198,10 +198,9 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; - expect(text).toContain("# apps"); - expect(text).toContain("# packages"); - expect(text).toContain("# phases"); - expect(text).toContain("## ast.ts"); + expect(text).toMatch(/^# apps\/\n## ast\.ts#[0-9a-f]{4}/m); + expect(text).toMatch(/^# packages\/\n## ast\.ts#[0-9a-f]{4}/m); + expect(text).toMatch(/^# phases\/\n## ast\.ts#[0-9a-f]{4}/m); expect(text).not.toContain("# other"); expect(details?.fileCount).toBe(3); expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); @@ -227,10 +226,9 @@ describe("tool path arrays", () => { const text = getText(preview); const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined; - expect(text).toContain("# apps"); - expect(text).toContain("# packages"); - expect(text).toContain("# phases"); - expect(text).toContain("## ast.ts (1 replacement)"); + expect(text).toMatch(/^# apps\/\n## ast\.ts#[0-9a-f]{4} \(\d+ replacement/m); + expect(text).toMatch(/^# packages\/\n## ast\.ts#[0-9a-f]{4} \(\d+ replacement/m); + expect(text).toMatch(/^# phases\/\n## ast\.ts#[0-9a-f]{4} \(\d+ replacement/m); expect(text).not.toContain("# other"); expect(details?.totalReplacements).toBe(3); expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); @@ -330,9 +328,9 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; - expect(text).toContain("# apps"); - expect(text).toContain("# packages"); - expect(text).toContain("# phases"); + expect(text).toMatch(/^# apps\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^# packages\/\n## grep\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^# phases\/\n## grep\.txt#[0-9a-f]{4}/m); expect(text).not.toContain("# other"); expect(details?.fileCount).toBe(3); expect(details?.scopePath).toBe("apps, packages, phases"); @@ -357,8 +355,8 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; - expect(text).toContain("# alpha.txt"); - expect(text).toContain("# beta.txt"); + expect(text).toMatch(/^# alpha\.txt#[0-9a-f]{4}/m); + expect(text).toMatch(/^# beta\.txt#[0-9a-f]{4}/m); expect(text).toContain("exact-needle alpha"); expect(text).toContain("exact-needle beta"); expect(text).not.toContain("nested"); @@ -408,8 +406,8 @@ describe("tool path arrays", () => { }); const text = getText(result); - expect(text).toMatch(/ 1(?:[a-z]{2})?\|#if FLAG/); - expect(text).toMatch(/\*2(?:[a-z]{2})?\|needle/); - expect(text).toMatch(/ 3(?:[a-z]{2})?\|#endif/); + expect(text).toMatch(/ 1:#if FLAG/); + expect(text).toMatch(/\*2:needle/); + expect(text).toMatch(/ 3:#endif/); }); }); diff --git a/packages/typescript-edit-benchmark/src/index.ts b/packages/typescript-edit-benchmark/src/index.ts index f02f8054d..f2a2052ca 100755 --- a/packages/typescript-edit-benchmark/src/index.ts +++ b/packages/typescript-edit-benchmark/src/index.ts @@ -513,8 +513,12 @@ async function main(): Promise { console.log(""); console.log("Benchmark complete!"); - console.log(` Success rate: ${(result.summary.overallSuccessRate * 100).toFixed(1)}%`); - console.log(` Total tokens: ${result.summary.totalTokens.input} in / ${result.summary.totalTokens.output} out`); + console.log( + ` Task success rate (best of ${config.runsPerTask}): ${(result.summary.taskSuccessRate * 100).toFixed(1)}% (${result.summary.successfulTasks}/${result.summary.totalTasks})`, + ); + console.log( + ` Total tokens (best): ${result.summary.totalTokens.input} in / ${result.summary.totalTokens.output} out`, + ); if (result.summary.ghostRuns > 0) { console.log(` Ghost runs (0/0/0): ${result.summary.ghostRuns}`); } diff --git a/packages/typescript-edit-benchmark/src/report.ts b/packages/typescript-edit-benchmark/src/report.ts index d8350bc1b..9e0de939e 100644 --- a/packages/typescript-edit-benchmark/src/report.ts +++ b/packages/typescript-edit-benchmark/src/report.ts @@ -5,22 +5,28 @@ import { formatDuration, formatPercent, truncate } from "@oh-my-pi/pi-utils"; import { type BenchmarkResult, EDIT_FAILURE_CATEGORIES, type TaskResult } from "./runner"; -function getStatusEmoji(successRate: number, runsPerTask: number): string { - const passing = Math.round(successRate * runsPerTask); - if (passing === runsPerTask) return "✅"; - if (passing === 0) return "❌"; - return "⚠️"; +function formatBestStatus(task: TaskResult, runsPerTask: number): { status: string; label: string } { + const completed = task.runs.filter(run => !isCompletedGhost(run)).length; + const succeeded = task.runs.filter(run => run.success).length; + if (task.success) { + // best-of-N pass; flag flakiness when not every run succeeded. + const flaky = completed > 0 && succeeded < completed; + const status = flaky ? "⚠️" : "✅"; + const label = `PASS (${succeeded}/${completed || runsPerTask})`; + return { status, label }; + } + return { status: "❌", label: `FAIL (0/${completed || runsPerTask})` }; +} + +function isCompletedGhost(run: TaskResult["runs"][number]): boolean { + if (run.success) return false; + return run.tokens.total === 0 && run.toolCalls.read === 0 && run.toolCalls.edit === 0 && run.toolCalls.write === 0; } function formatNumber(n: number): string { return n.toLocaleString(); } -function formatPassRate(successRate: number, runsPerTask: number): string { - const passing = Math.round(successRate * runsPerTask); - return `${passing}/${runsPerTask}`; -} - function formatRate(numerator: number, denominator: number): string { if (denominator === 0) return "—"; const percent = (numerator / denominator) * 100; @@ -82,7 +88,6 @@ export function generateReport(result: BenchmarkResult): string { ); const verifiedRuns = nonGhostRuns.filter(run => run.verificationPassed).length; const editToolRuns = nonGhostRuns.filter(run => run.patchApplied).length; - const successRuns = nonGhostRuns.filter(run => run.success).length; const totalEditAttempts = nonGhostRuns.reduce((sum, run) => sum + run.toolCalls.edit, 0); const totalEditFailures = nonGhostRuns.reduce((sum, run) => sum + run.toolCalls.editFailures, 0); @@ -115,17 +120,21 @@ export function generateReport(result: BenchmarkResult): string { lines.push("## Summary"); lines.push(""); + lines.push( + "Primary metrics (tokens, duration, tool calls) are aggregated over the **best run** of each task. Diagnostic counts (ghost runs, timeouts, retries, failure categories) span every executed run.", + ); + lines.push(""); lines.push("| Metric | Value |"); lines.push("|--------|-------|"); lines.push(`| Total Tasks | ${summary.totalTasks} |`); lines.push(`| Total Runs | ${summary.totalRuns} |`); lines.push(`| Successful Runs | ${summary.successfulRuns} |`); - lines.push(`| **Task Success Rate** | **${formatRate(successRuns, summary.totalRuns)}** |`); + lines.push(`| **Task Success Rate** | **${formatRate(summary.successfulTasks, summary.totalTasks)}** |`); if (config.editVariant === "hashline") { lines.push( - `| **Autocorrect-Free Success Rate** | **${formatRate(summary.autocorrectFreeSuccessfulRuns, summary.totalRuns)}** |`, + `| **Autocorrect-Free Success Rate** | **${formatRate(summary.autocorrectFreeSuccessfulTasks, summary.totalTasks)}** |`, ); - lines.push(`| Autocorrected Runs | ${formatRate(summary.autocorrectedRuns, summary.totalRuns)} |`); + lines.push(`| Autocorrected Best Runs | ${formatRate(summary.autocorrectedBestRuns, summary.totalTasks)} |`); lines.push(`| Edit Autocorrect Rate | ${formatPercent(summary.editAutocorrectRate)} |`); } lines.push(`| Verified Rate | ${formatRate(verifiedRuns, summary.totalRuns)} |`); @@ -149,34 +158,36 @@ export function generateReport(result: BenchmarkResult): string { if (config.editVariant === "patch" || config.editVariant === "hashline") { lines.push(`| Patch Failure Rate | ${formatRate(totalEditFailures, totalEditAttempts)} |`); } - lines.push(`| Tasks All Passing | ${summary.tasksWithAllPassing} |`); - lines.push(`| Tasks Flaky/Failing | ${summary.tasksWithAnyFailing} |`); + lines.push(`| Tasks All Passing | ${summary.consistentlyPassingTasks} |`); + lines.push(`| Tasks Flaky/Failing | ${summary.totalTasks - summary.consistentlyPassingTasks} |`); lines.push(""); lines.push("### Tool Calls"); lines.push(""); - lines.push("| Tool | Total | Avg/Run |"); - lines.push("|------|-------|---------|"); - lines.push(`| Read | ${summary.totalToolCalls.read} | ${summary.avgToolCallsPerRun.read.toFixed(1)} |`); - lines.push(`| Edit | ${summary.totalToolCalls.edit} | ${summary.avgToolCallsPerRun.edit.toFixed(1)} |`); - lines.push(`| Write | ${summary.totalToolCalls.write} | ${summary.avgToolCallsPerRun.write.toFixed(1)} |`); + lines.push("| Tool | Total (best) | Avg/Task |"); + lines.push("|------|--------------|----------|"); + lines.push(`| Read | ${summary.totalToolCalls.read} | ${summary.avgToolCallsPerTask.read.toFixed(1)} |`); + lines.push(`| Edit | ${summary.totalToolCalls.edit} | ${summary.avgToolCallsPerTask.edit.toFixed(1)} |`); + lines.push(`| Write | ${summary.totalToolCalls.write} | ${summary.avgToolCallsPerTask.write.toFixed(1)} |`); lines.push( - `| **Tool Input Chars** | ${formatNumber(summary.totalToolCalls.totalInputChars)} | ${formatNumber(Math.round(summary.avgToolCallsPerRun.totalInputChars))} |`, + `| **Tool Input Chars** | ${formatNumber(summary.totalToolCalls.totalInputChars)} | ${formatNumber(Math.round(summary.avgToolCallsPerTask.totalInputChars))} |`, ); lines.push(""); lines.push("### Tokens & Time"); lines.push(""); - lines.push("| Metric | Total | Avg/Run |"); - lines.push("|--------|-------|---------|"); + lines.push("| Metric | Total (best) | Avg/Task |"); + lines.push("|--------|--------------|----------|"); lines.push( - `| Input Tokens | ${formatNumber(summary.totalTokens.input)} | ${formatNumber(summary.avgTokensPerRun.input)} |`, + `| Input Tokens | ${formatNumber(summary.totalTokens.input)} | ${formatNumber(summary.avgTokensPerTask.input)} |`, ); lines.push( - `| Output Tokens | ${formatNumber(summary.totalTokens.output)} | ${formatNumber(summary.avgTokensPerRun.output)} |`, + `| Output Tokens | ${formatNumber(summary.totalTokens.output)} | ${formatNumber(summary.avgTokensPerTask.output)} |`, ); lines.push( - `| Total Tokens | ${formatNumber(summary.totalTokens.total)} | ${formatNumber(summary.avgTokensPerRun.total)} |`, + `| Total Tokens | ${formatNumber(summary.totalTokens.total)} | ${formatNumber(summary.avgTokensPerTask.total)} |`, + ); + lines.push( + `| Duration | ${formatDuration(summary.totalDuration)} | ${formatDuration(summary.avgDurationPerTask)} |`, ); - lines.push(`| Duration | ${formatDuration(summary.totalDuration)} | ${formatDuration(summary.avgDurationPerRun)} |`); lines.push(`| **Avg Indent Score** | — | **${formatScore(summary.avgIndentScore)}** |`); lines.push(""); @@ -222,12 +233,11 @@ export function generateReport(result: BenchmarkResult): string { lines.push("|------|------|---------|----------|-------|-----------------|------|--------|"); for (const task of tasks) { - const status = getStatusEmoji(task.successRate, runsPerTask); - const passRate = formatPassRate(task.successRate, runsPerTask); + const { status, label } = formatBestStatus(task, runsPerTask); const editHitRate = formatPercent(task.editSuccessRate); - const toolCalls = `${task.avgToolCalls.read.toFixed(0)}/${task.avgToolCalls.edit.toFixed(0)}/${task.avgToolCalls.write.toFixed(0)}`; + const toolCalls = `${task.toolCalls.read.toFixed(0)}/${task.toolCalls.edit.toFixed(0)}/${task.toolCalls.write.toFixed(0)}`; lines.push( - `| ${escapeMarkdown(task.name)} | ${escapeMarkdown(formatFiles(task.files))} | ${passRate} ${status} | ${editHitRate} | ${toolCalls} | ${formatNumber(task.avgTokens.input)}/${formatNumber(task.avgTokens.output)} | ${formatDuration(task.avgDuration)} | ${formatScore(task.avgIndentScore)} |`, + `| ${escapeMarkdown(task.name)} | ${escapeMarkdown(formatFiles(task.files))} | ${label} ${status} | ${editHitRate} | ${toolCalls} | ${formatNumber(task.tokens.input)}/${formatNumber(task.tokens.output)} | ${formatDuration(task.duration)} | ${formatScore(task.indentScore)} |`, ); } lines.push(""); @@ -279,30 +289,38 @@ export function generateReport(result: BenchmarkResult): string { } } - const flakyTasks = tasks.filter(t => t.successRate > 0 && t.successRate < 1); + const flakyTasks = tasks.filter(task => { + if (!task.success) return false; + const nonGhost = task.runs.filter(run => !isCompletedGhost(run)); + return nonGhost.length > 0 && nonGhost.some(run => !run.success); + }); if (flakyTasks.length > 0) { - lines.push("## Flaky Tasks (partial passing)"); + lines.push("## Flaky Tasks (best passed; some runs failed)"); lines.push(""); for (const task of flakyTasks) { - const passing = Math.round(task.successRate * runsPerTask); - lines.push(`### ${task.name} (${formatFiles(task.files)}) — ${passing}/${runsPerTask}`); + const nonGhost = task.runs.filter(run => !isCompletedGhost(run)); + const passing = nonGhost.filter(run => run.success).length; + const denom = nonGhost.length || runsPerTask; + const bestNote = task.bestRunIndex >= 0 ? ` (best: run ${task.bestRunIndex + 1})` : ""; + lines.push(`### ${task.name} (${formatFiles(task.files)}) — ${passing}/${denom}${bestNote}`); lines.push(""); lines.push("| Run | Status | Error | Tokens (in/out) | Time |"); lines.push("|-----|--------|-------|-----------------|------|"); for (const run of task.runs) { + const marker = run.runIndex === task.bestRunIndex ? " ★" : ""; const status = run.success ? "✅" : "❌"; const error = run.error ? truncate(escapeMarkdown(run.error), 50) : "—"; lines.push( - `| ${run.runIndex + 1} | ${status} | ${error} | ${formatNumber(run.tokens.input)} / ${formatNumber(run.tokens.output)} | ${formatDuration(run.duration)} |`, + `| ${run.runIndex + 1}${marker} | ${status} | ${error} | ${formatNumber(run.tokens.input)} / ${formatNumber(run.tokens.output)} | ${formatDuration(run.duration)} |`, ); } lines.push(""); } } - const failedTasks = tasks.filter(t => t.successRate === 0); + const failedTasks = tasks.filter(task => !task.success); if (failedTasks.length > 0) { lines.push("## Failed Tasks (0% passing)"); lines.push(""); diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index 57256f1e4..f9e4a00b7 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -9,7 +9,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentMessage, ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; -import { computeLineHash, formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent"; +import { computeFileHash, formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent"; import { prompt } from "@oh-my-pi/pi-utils"; import { diffLines } from "diff"; import { formatDirectory } from "./formatter"; @@ -294,27 +294,30 @@ function buildMutationPreviewAgainstOriginal(original: string, current: string): const changes = diffLines(original, current); const preview: string[] = []; - let lineNum = 1; + let origLineNum = 1; + let newLineNum = 1; + // Hashline diff-preview format: `-LINE:TEXT` for removed (pre-edit line + // number), `+LINE:TEXT` for added (post-edit line number). No per-line hash. for (const change of changes) { const lines = splitLines(change.value); if (!change.added && !change.removed) { - lineNum += lines.length; + origLineNum += lines.length; + newLineNum += lines.length; continue; } if (change.removed) { for (const line of lines) { - const hash = computeLineHash(lineNum, line); - preview.push(`${lineNum}#${hash}|-${line}`); - lineNum += 1; + preview.push(`-${origLineNum}:${line}`); + origLineNum += 1; } continue; } for (const line of lines) { - const hash = computeLineHash(lineNum, line); - preview.push(`${lineNum}#${hash}|+${line}`); + preview.push(`+${newLineNum}:${line}`); + newLineNum += 1; } } @@ -524,69 +527,56 @@ async function evaluateMutationIntent( }; } -type GuidedHashlineEdit = - | { set: { ref: string; body: string[] } } - | { set_range: { beg: string; end: string; body: string[] } } - | { insert: { after: string; body: string[] } }; - -function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashlineEdit[] { +/** + * Build a textual hashline patch (with `¶path#hash` section header) that + * transforms `actual` into `expected`. Returns null when no changes are + * needed or the diff isn't expressible as straight insert/replace/delete ops. + */ +function buildGuidedHashlinePatch(file: string, actual: string, expected: string): string | null { const changes = diffLines(actual, expected); const actualLines = actual.split("\n"); + // File-trailing newline produces a phantom empty last entry that is not a + // real line; the hashline grammar's line numbers count real lines only. + const fileLineCount = + actualLines.length > 0 && actualLines[actualLines.length - 1] === "" + ? actualLines.length - 1 + : actualLines.length; + const ops: string[] = []; let line = 1; let pendingStart = 1; - let pendingRemoved: string[] = []; + let pendingRemoved = 0; let pendingAdded: string[] = []; - const edits: GuidedHashlineEdit[] = []; + + const formatPayload = (body: string[]): string => (body.length === 0 ? "" : `\n${body.join("\n")}`); const flush = () => { - if (pendingRemoved.length === 0 && pendingAdded.length === 0) { - return; - } + if (pendingRemoved === 0 && pendingAdded.length === 0) return; - if (pendingRemoved.length === 0) { - const insertLine = pendingStart; + if (pendingRemoved === 0) { + // Pure insertion at `pendingStart` (line numbers are 1-indexed and + // refer to the pre-edit file). if (pendingAdded.length === 0) return; - if (insertLine === 1) { - const firstLine = actualLines[0] ?? ""; - const firstRef = `1#${computeLineHash(1, firstLine)}`; - edits.push({ - set: { ref: firstRef, body: [...pendingAdded, firstLine] }, - }); - } else if (insertLine <= actualLines.length) { - const afterLine = actualLines[insertLine - 2] ?? ""; - const afterRef = `${insertLine - 1}#${computeLineHash(insertLine - 1, afterLine)}`; - edits.push({ - insert: { after: afterRef, body: [...pendingAdded] }, - }); - } else if (insertLine === actualLines.length + 1 && actualLines.length > 0) { - const afterLine = actualLines[actualLines.length - 1] ?? ""; - const afterRef = `${actualLines.length}#${computeLineHash(actualLines.length, afterLine)}`; - edits.push({ - insert: { after: afterRef, body: [...pendingAdded] }, - }); + if (pendingStart <= 1) { + ops.push(`BOF↓${formatPayload(pendingAdded)}`); + } else if (pendingStart > fileLineCount) { + ops.push(`EOF↓${formatPayload(pendingAdded)}`); + } else { + // Insert above `pendingStart` so the new content lands at that line. + ops.push(`${pendingStart}↑${formatPayload(pendingAdded)}`); } } else { const startLine = pendingStart; - const endLine = pendingStart + pendingRemoved.length - 1; - const startContent = actualLines[startLine - 1] ?? ""; - const startRef = `${startLine}#${computeLineHash(startLine, startContent)}`; - if (startLine === endLine) { - edits.push({ set: { ref: startRef, body: [...pendingAdded] } }); + const endLine = pendingStart + pendingRemoved - 1; + const anchor = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`; + if (pendingAdded.length === 0) { + ops.push(`${anchor}!`); } else { - const endContent = actualLines[endLine - 1] ?? ""; - const endRef = `${endLine}#${computeLineHash(endLine, endContent)}`; - edits.push({ - set_range: { - beg: startRef, - end: endRef, - body: [...pendingAdded], - }, - }); + ops.push(`${anchor}:${formatPayload(pendingAdded)}`); } } - pendingRemoved = []; + pendingRemoved = 0; pendingAdded = []; }; @@ -595,13 +585,14 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl if (!change.added && !change.removed) { flush(); line += lines.length; + pendingStart = line; continue; } - if (pendingRemoved.length === 0 && pendingAdded.length === 0) { + if (pendingRemoved === 0 && pendingAdded.length === 0) { pendingStart = line; } if (change.removed) { - pendingRemoved.push(...lines); + pendingRemoved += lines.length; line += lines.length; } if (change.added) { @@ -610,7 +601,9 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl } flush(); - return edits; + if (ops.length === 0) return null; + const header = `¶${file}#${computeFileHash(actual)}`; + return `${header}\n${ops.join("\n")}`; } async function buildGuidedContext( @@ -635,11 +628,13 @@ async function buildGuidedContext( .catch(() => null); if (actual === null || expected === null) return null; - const edits = buildGuidedHashlineEdits(actual, expected); - if (edits.length === 0) return null; - if (edits.length > 25) return null; + const patch = buildGuidedHashlinePatch(file, actual, expected); + if (patch === null) return null; + // Rough complexity guard: too many ops or too long → skip guidance. + const opCount = patch.split("\n").filter(l => /[↑↓→]/.test(l)).length; + if (opCount === 0 || opCount > 25) return null; - const args = { path: file, edits }; + const args = { path: file, input: patch }; const argsText = JSON.stringify(args, null, 2); if (argsText.length > 20_000) return null; const metaParts: string[] = []; @@ -836,46 +831,78 @@ export interface TaskResult { name: string; files: string[]; runs: TaskRunResult[]; - successRate: number; - avgTokens: TokenStats; - avgDuration: number; - avgIndentScore: number; - avgToolCalls: ToolCallStats; + /** Index into `runs` (ordered by runIndex) of the selected best run; -1 if no runs completed. */ + bestRunIndex: number; + /** True when the selected best run succeeded. */ + success: boolean; + /** Token usage of the best run. */ + tokens: TokenStats; + /** Duration (ms) of the best run. */ + duration: number; + /** Indent score of the best run, or 0 if unscored. */ + indentScore: number; + /** Tool call stats of the best run. */ + toolCalls: ToolCallStats; + /** Edit-tool success rate of the best run (defaults to 1 when no edit attempts). */ editSuccessRate: number; - autocorrectFreeSuccessRate: number; + /** True if the best run succeeded with zero autocorrects. */ + autocorrectFreeSuccess: boolean; + /** Fraction of completed (non-ghost) runs that succeeded — flakiness indicator. */ + flakeSuccessRate: number; } export interface BenchmarkSummary { totalTasks: number; + /** Total completed runs across all tasks (excludes ghost runs). */ totalRuns: number; + /** Successful runs across every executed run (any of N). Diagnostic. */ successfulRuns: number; - overallSuccessRate: number; - tasksWithAllPassing: number; - tasksWithAnyFailing: number; + /** Tasks whose best run succeeded (best-of-N). Primary headline metric. */ + successfulTasks: number; + /** successfulTasks / totalTasks. */ + taskSuccessRate: number; + /** Tasks where best succeeded but at least one of N failed (flakiness). */ + flakyTasks: number; + /** Tasks where every executed non-ghost run succeeded. */ + consistentlyPassingTasks: number; + /** Tokens summed over the best run of each task. */ totalTokens: TokenStats; - avgTokensPerRun: TokenStats; + /** Average tokens per task (sum of best runs / number of tasks). */ + avgTokensPerTask: TokenStats; + /** Duration summed over best runs. */ totalDuration: number; - avgDurationPerRun: number; + /** Average duration of the best run per task. */ + avgDurationPerTask: number; + /** Average indent score over best runs (only counts runs with a score). */ avgIndentScore: number; + /** Tool calls summed over best runs. */ totalToolCalls: ToolCallStats; - avgToolCallsPerRun: ToolCallStats; + /** Average tool calls per task (sum of best runs / number of tasks). */ + avgToolCallsPerTask: ToolCallStats; + /** Edit-tool success rate aggregated across best runs. */ editSuccessRate: number; - autocorrectFreeSuccessfulRuns: number; + /** Tasks where the best run succeeded without any autocorrects. */ + autocorrectFreeSuccessfulTasks: number; + /** autocorrectFreeSuccessfulTasks / totalTasks. */ autocorrectFreeSuccessRate: number; - autocorrectedRuns: number; + /** Best runs with any autocorrects. */ + autocorrectedBestRuns: number; + /** Autocorrect rate across best-run edit successes. */ editAutocorrectRate: number; + /** Diagnostic: runs (across all N) that timed out. */ timeoutRuns: number; - /** Total retry counts across all runs */ + /** Diagnostic: total retry counts across all runs. */ totalTimeoutRetries: number; totalZeroToolRetries: number; totalProviderFailureRetries: number; - /** Runs where the 0/0/0 ghost signature was detected (0 tokens, 0 tool calls) */ + /** Diagnostic: ghost runs (0 tokens, 0 tool calls) across all N. */ ghostRuns: number; - /** Runs excluded because provider/transport stalls exhausted retries (subset of ghostRuns when error matches). */ + /** Diagnostic: runs excluded because provider/transport stalls exhausted retries. */ transportFailureRuns: number; mutationIntentMatchRate?: number; + /** Edit failure categories across all runs. */ editFailureCategories: Record; - /** Hashline edit subtype totals — only when editVariant is hashline */ + /** Hashline edit subtype totals across all runs — only when editVariant is hashline. */ hashlineEditSubtypes?: Record; } @@ -1629,70 +1656,71 @@ function isGhostRun(r: TaskRunResult): boolean { return noProgress || isTransportFailure(r); } +const EMPTY_TOOL_CALL_STATS: ToolCallStats = { + read: 0, + edit: 0, + write: 0, + editSuccesses: 0, + editFailures: 0, + editWarnings: 0, + editAutocorrects: 0, + totalInputChars: 0, +}; + +/** + * Strict ordering used to pick the "best" run for a task: + * 1. Successful runs win over failed runs. + * 2. Then prefer non-ghost runs (real work over 0/0/0 stalls). + * 3. Then prefer the run with lower total token usage. + * 4. Then prefer the earlier runIndex for stability. + */ +function isBetterRun(a: TaskRunResult, b: TaskRunResult): boolean { + if (a.success !== b.success) return a.success; + const aGhost = isGhostRun(a); + const bGhost = isGhostRun(b); + if (aGhost !== bGhost) return !aGhost; + if (a.tokens.total !== b.tokens.total) return a.tokens.total < b.tokens.total; + return a.runIndex < b.runIndex; +} + +function pickBestRunIndex(orderedRuns: TaskRunResult[]): number { + if (orderedRuns.length === 0) return -1; + let bestIdx = 0; + for (let i = 1; i < orderedRuns.length; i++) { + if (isBetterRun(orderedRuns[i]!, orderedRuns[bestIdx]!)) bestIdx = i; + } + return bestIdx; +} + function summarizeTaskRuns(task: EditTask, runs: TaskRunResult[]): TaskResult { const orderedRuns = runs.slice().sort((a, b) => a.runIndex - b.runIndex); const nonGhostRuns = orderedRuns.filter(r => !isGhostRun(r)); - const effective = nonGhostRuns.length; - const successfulRuns = orderedRuns.filter(r => r.success).length; - const successRate = effective > 0 ? successfulRuns / effective : 0; + const successfulNonGhost = nonGhostRuns.filter(r => r.success).length; + const flakeSuccessRate = nonGhostRuns.length > 0 ? successfulNonGhost / nonGhostRuns.length : 0; + const bestIdx = pickBestRunIndex(orderedRuns); + const best = bestIdx === -1 ? undefined : orderedRuns[bestIdx]!; - const avgTokens: TokenStats = - effective > 0 - ? { - input: Math.round(nonGhostRuns.reduce((sum, r) => sum + r.tokens.input, 0) / effective), - output: Math.round(nonGhostRuns.reduce((sum, r) => sum + r.tokens.output, 0) / effective), - total: Math.round(nonGhostRuns.reduce((sum, r) => sum + r.tokens.total, 0) / effective), - } - : { input: 0, output: 0, total: 0 }; - - const avgDuration = effective > 0 ? Math.round(nonGhostRuns.reduce((sum, r) => sum + r.duration, 0) / effective) : 0; - const indentScores = orderedRuns - .map(run => run.indentScore) - .filter((score): score is number => typeof score === "number"); - const avgIndentScore = - indentScores.length > 0 ? indentScores.reduce((sum, score) => sum + score, 0) / indentScores.length : 0; - - const avgToolCalls: ToolCallStats = - effective > 0 - ? { - read: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.read, 0) / effective, - edit: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.edit, 0) / effective, - write: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.write, 0) / effective, - editSuccesses: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editSuccesses, 0) / effective, - editFailures: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editFailures, 0) / effective, - editWarnings: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editWarnings, 0) / effective, - editAutocorrects: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editAutocorrects, 0) / effective, - totalInputChars: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.totalInputChars, 0) / effective, - } - : { - read: 0, - edit: 0, - write: 0, - editSuccesses: 0, - editFailures: 0, - editWarnings: 0, - editAutocorrects: 0, - totalInputChars: 0, - }; - - const totalEditAttempts = nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.edit, 0); - const totalEditSuccesses = nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editSuccesses, 0); - const editSuccessRate = totalEditAttempts > 0 ? totalEditSuccesses / totalEditAttempts : 1; - const autocorrectFreeSuccesses = nonGhostRuns.filter(run => run.success && run.editAutocorrectCount === 0).length; - const autocorrectFreeSuccessRate = effective > 0 ? autocorrectFreeSuccesses / effective : 0; + const tokens: TokenStats = best ? { ...best.tokens } : { input: 0, output: 0, total: 0 }; + const duration = best?.duration ?? 0; + const indentScore = typeof best?.indentScore === "number" ? best.indentScore : 0; + const toolCalls: ToolCallStats = best ? { ...best.toolCalls } : { ...EMPTY_TOOL_CALL_STATS }; + const editSuccessRate = toolCalls.edit > 0 ? toolCalls.editSuccesses / toolCalls.edit : 1; + const autocorrectFreeSuccess = Boolean(best?.success) && (best?.editAutocorrectCount ?? 0) === 0; return { id: task.id, name: task.name, files: task.files, runs: orderedRuns, - successRate, - avgTokens, - avgDuration, - avgIndentScore, - avgToolCalls, + bestRunIndex: best?.runIndex ?? -1, + success: Boolean(best?.success), + tokens, + duration, + indentScore, + toolCalls, editSuccessRate, - autocorrectFreeSuccessRate, + autocorrectFreeSuccess, + flakeSuccessRate, }; } @@ -1754,45 +1782,14 @@ export function buildBenchmarkResult(params: { const endTime = params.endTime ?? new Date().toISOString(); + // Diagnostic aggregates run over *every* executed run (across all N) so the + // report still surfaces ghost/timeout/retry signals. const allRuns = taskResults.flatMap(t => t.runs); - const totalRuns = allRuns.length; const ghostRuns = allRuns.filter(r => isGhostRun(r)).length; const transportFailureRuns = allRuns.filter(r => isTransportFailure(r)).length; - const effectiveRuns = totalRuns - ghostRuns; const nonGhostRuns = allRuns.filter(r => !isGhostRun(r)); + const totalRuns = nonGhostRuns.length; const successfulRuns = allRuns.filter(r => r.success).length; - - const totalTokens: TokenStats = { - input: nonGhostRuns.reduce((sum, r) => sum + r.tokens.input, 0), - output: nonGhostRuns.reduce((sum, r) => sum + r.tokens.output, 0), - total: nonGhostRuns.reduce((sum, r) => sum + r.tokens.total, 0), - }; - - const totalDuration = nonGhostRuns.reduce((sum, r) => sum + r.duration, 0); - const indentScores = nonGhostRuns - .map(run => run.indentScore) - .filter((score): score is number => typeof score === "number"); - const avgIndentScore = - indentScores.length > 0 ? indentScores.reduce((sum, score) => sum + score, 0) / indentScores.length : 0; - - const totalToolCalls: ToolCallStats = { - read: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.read, 0), - edit: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.edit, 0), - write: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.write, 0), - editSuccesses: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editSuccesses, 0), - editFailures: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editFailures, 0), - editWarnings: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editWarnings, 0), - editAutocorrects: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.editAutocorrects, 0), - totalInputChars: nonGhostRuns.reduce((sum, r) => sum + r.toolCalls.totalInputChars, 0), - }; - - const editSuccessRate = totalToolCalls.edit > 0 ? totalToolCalls.editSuccesses / totalToolCalls.edit : 1; - const autocorrectFreeSuccessfulRuns = nonGhostRuns.filter( - run => run.success && run.editAutocorrectCount === 0, - ).length; - const autocorrectedRuns = nonGhostRuns.filter(run => run.editAutocorrectCount > 0).length; - const editAutocorrectRate = - totalToolCalls.editSuccesses > 0 ? totalToolCalls.editAutocorrects / totalToolCalls.editSuccesses : 0; const timeoutRuns = nonGhostRuns.filter( r => r.error?.includes("Timeout") || r.error?.includes("Timeout exhausted"), ).length; @@ -1802,13 +1799,7 @@ export function buildBenchmarkResult(params: { (sum, r) => sum + (r.retryStats?.providerFailureRetries ?? 0), 0, ); - const runsWithMutationIntent = nonGhostRuns.filter(r => typeof r.mutationIntentMatched === "boolean"); - const mutationIntentMatchRate = - runsWithMutationIntent.length > 0 - ? runsWithMutationIntent.filter(r => r.mutationIntentMatched).length / runsWithMutationIntent.length - : undefined; const editFailureCategories = countEditFailureCategories(nonGhostRuns); - const hashlineEditSubtypes: Record | undefined = params.config.editVariant === "hashline" ? Object.fromEntries( @@ -1816,38 +1807,91 @@ export function buildBenchmarkResult(params: { ) : undefined; - const denom = effectiveRuns || 1; + // Primary aggregates run over the *best* run of each completed task. + const bestRuns: TaskRunResult[] = []; + for (const task of taskResults) { + if (task.bestRunIndex < 0) continue; + const best = task.runs.find(r => r.runIndex === task.bestRunIndex); + if (best) bestRuns.push(best); + } + const tasksWithBestRun = bestRuns.length; + const totalTasks = params.tasks.length; + const denom = totalTasks || 1; + + const successfulTasks = taskResults.filter(t => t.success).length; + const consistentlyPassingTasks = taskResults.filter( + t => t.success && t.runs.filter(r => !isGhostRun(r)).every(r => r.success), + ).length; + const flakyTasks = taskResults.filter( + t => t.success && t.runs.filter(r => !isGhostRun(r)).some(r => !r.success), + ).length; + + const totalTokens: TokenStats = { + input: bestRuns.reduce((sum, r) => sum + r.tokens.input, 0), + output: bestRuns.reduce((sum, r) => sum + r.tokens.output, 0), + total: bestRuns.reduce((sum, r) => sum + r.tokens.total, 0), + }; + const totalDuration = bestRuns.reduce((sum, r) => sum + r.duration, 0); + const totalToolCalls: ToolCallStats = { + read: bestRuns.reduce((sum, r) => sum + r.toolCalls.read, 0), + edit: bestRuns.reduce((sum, r) => sum + r.toolCalls.edit, 0), + write: bestRuns.reduce((sum, r) => sum + r.toolCalls.write, 0), + editSuccesses: bestRuns.reduce((sum, r) => sum + r.toolCalls.editSuccesses, 0), + editFailures: bestRuns.reduce((sum, r) => sum + r.toolCalls.editFailures, 0), + editWarnings: bestRuns.reduce((sum, r) => sum + r.toolCalls.editWarnings, 0), + editAutocorrects: bestRuns.reduce((sum, r) => sum + r.toolCalls.editAutocorrects, 0), + totalInputChars: bestRuns.reduce((sum, r) => sum + r.toolCalls.totalInputChars, 0), + }; + const bestIndentScores = bestRuns + .map(r => r.indentScore) + .filter((score): score is number => typeof score === "number"); + const avgIndentScore = + bestIndentScores.length > 0 ? bestIndentScores.reduce((sum, s) => sum + s, 0) / bestIndentScores.length : 0; + + const editSuccessRate = totalToolCalls.edit > 0 ? totalToolCalls.editSuccesses / totalToolCalls.edit : 1; + const autocorrectFreeSuccessfulTasks = bestRuns.filter(r => r.success && r.editAutocorrectCount === 0).length; + const autocorrectedBestRuns = bestRuns.filter(r => r.editAutocorrectCount > 0).length; + const editAutocorrectRate = + totalToolCalls.editSuccesses > 0 ? totalToolCalls.editAutocorrects / totalToolCalls.editSuccesses : 0; + const bestWithMutationIntent = bestRuns.filter(r => typeof r.mutationIntentMatched === "boolean"); + const mutationIntentMatchRate = + bestWithMutationIntent.length > 0 + ? bestWithMutationIntent.filter(r => r.mutationIntentMatched).length / bestWithMutationIntent.length + : undefined; + + const taskDenom = tasksWithBestRun || 1; const summary: BenchmarkSummary = { - totalTasks: params.tasks.length, - totalRuns: effectiveRuns, + totalTasks, + totalRuns, successfulRuns, - overallSuccessRate: successfulRuns / denom, - tasksWithAllPassing: taskResults.filter(t => t.successRate === 1).length, - tasksWithAnyFailing: taskResults.filter(t => t.successRate < 1).length, + successfulTasks, + taskSuccessRate: successfulTasks / denom, + flakyTasks, + consistentlyPassingTasks, totalTokens, - avgTokensPerRun: { - input: Math.round(totalTokens.input / denom), - output: Math.round(totalTokens.output / denom), - total: Math.round(totalTokens.total / denom), + avgTokensPerTask: { + input: Math.round(totalTokens.input / taskDenom), + output: Math.round(totalTokens.output / taskDenom), + total: Math.round(totalTokens.total / taskDenom), }, totalDuration, - avgDurationPerRun: Math.round(totalDuration / denom), + avgDurationPerTask: Math.round(totalDuration / taskDenom), avgIndentScore, totalToolCalls, - avgToolCallsPerRun: { - read: totalToolCalls.read / denom, - edit: totalToolCalls.edit / denom, - write: totalToolCalls.write / denom, - editSuccesses: totalToolCalls.editSuccesses / denom, - editFailures: totalToolCalls.editFailures / denom, - editWarnings: totalToolCalls.editWarnings / denom, - editAutocorrects: totalToolCalls.editAutocorrects / denom, - totalInputChars: totalToolCalls.totalInputChars / denom, + avgToolCallsPerTask: { + read: totalToolCalls.read / taskDenom, + edit: totalToolCalls.edit / taskDenom, + write: totalToolCalls.write / taskDenom, + editSuccesses: totalToolCalls.editSuccesses / taskDenom, + editFailures: totalToolCalls.editFailures / taskDenom, + editWarnings: totalToolCalls.editWarnings / taskDenom, + editAutocorrects: totalToolCalls.editAutocorrects / taskDenom, + totalInputChars: totalToolCalls.totalInputChars / taskDenom, }, editSuccessRate, - autocorrectFreeSuccessfulRuns, - autocorrectFreeSuccessRate: autocorrectFreeSuccessfulRuns / denom, - autocorrectedRuns, + autocorrectFreeSuccessfulTasks, + autocorrectFreeSuccessRate: autocorrectFreeSuccessfulTasks / denom, + autocorrectedBestRuns, editAutocorrectRate, timeoutRuns, totalTimeoutRetries, @@ -1888,29 +1932,43 @@ export async function runBenchmark( : undefined; try { - const runItems: TaskRunItem[] = tasks.flatMap(task => - Array.from({ length: config.runsPerTask }, (_, runIndex) => ({ task, runIndex })), - ); - - const pending = shuffle(runItems); + const runsPerTask = Math.max(1, Math.floor(config.runsPerTask)); + const taskQueue = shuffle(tasks.slice()); const resultsByTask = new Map(); const concurrency = Math.max(1, Math.floor(config.taskConcurrency)); - const running: Promise[] = []; - const runNext = async (): Promise => { - const nextItem = pending.shift(); - if (!nextItem) return; - const { task, result } = await runConcurrentBenchmarkRun(nextItem, config, onProgress, shared); + const recordResult = (task: EditTask, result: TaskRunResult) => { const list = resultsByTask.get(task.id) ?? []; list.push(result); resultsByTask.set(task.id, list); onResultSnapshot?.(buildBenchmarkResult({ tasks, config, resultsByTask, startTime })); - await runNext(); }; - const slots = Math.min(concurrency, pending.length); + // Each worker takes one task at a time and launches all N runs for that + // task concurrently. The best run is chosen later via summarizeTaskRuns; + // taskConcurrency caps the number of in-flight tasks (not runs). + const runTaskAllRuns = async (task: EditTask): Promise => { + const items: TaskRunItem[] = Array.from({ length: runsPerTask }, (_, runIndex) => ({ task, runIndex })); + await Promise.all( + items.map(async item => { + const { result } = await runConcurrentBenchmarkRun(item, config, onProgress, shared); + recordResult(task, result); + }), + ); + }; + + const worker = async (): Promise => { + while (true) { + const task = taskQueue.shift(); + if (!task) return; + await runTaskAllRuns(task); + } + }; + + const slots = Math.min(concurrency, taskQueue.length); + const running: Promise[] = []; for (let i = 0; i < slots; i++) { - running.push(runNext()); + running.push(worker()); } await Promise.all(running); diff --git a/packages/typescript-edit-benchmark/test/runner.test.ts b/packages/typescript-edit-benchmark/test/runner.test.ts index e0acca42e..3acce9812 100644 --- a/packages/typescript-edit-benchmark/test/runner.test.ts +++ b/packages/typescript-edit-benchmark/test/runner.test.ts @@ -35,7 +35,7 @@ function createTask(id: string): EditTask { }; } -function createRun(runIndex: number, success: boolean): TaskRunResult { +function createRun(runIndex: number, success: boolean, overrides: Partial = {}): TaskRunResult { return { runIndex, success, @@ -56,6 +56,7 @@ function createRun(runIndex: number, success: boolean): TaskRunResult { editFailures: [], editWarnings: [], editAutocorrectCount: 0, + ...overrides, }; } @@ -177,6 +178,99 @@ describe("buildBenchmarkResult", () => { expect(report).toContain("| range-continuation | 1 | 100.0% |"); expect(report).toContain("- Category: range-continuation"); }); + + it("picks the successful run with the lowest tokens as the task best", () => { + const task = createTask("best"); + const losing = createRun(0, false, { tokens: { input: 5, output: 5, total: 10 } }); + const winning = createRun(1, true, { tokens: { input: 100, output: 50, total: 150 } }); + const expensive = createRun(2, true, { tokens: { input: 500, output: 250, total: 750 } }); + const result = buildBenchmarkResult({ + tasks: [task], + config: { + provider: "anthropic", + model: "claude", + runsPerTask: 3, + timeout: 1000, + taskConcurrency: 1, + }, + resultsByTask: new Map([[task.id, [losing, winning, expensive]]]), + startTime: "2026-04-28T00:00:00.000Z", + endTime: "2026-04-28T00:00:01.000Z", + }); + + const taskResult = result.tasks[0]!; + expect(taskResult.success).toBe(true); + expect(taskResult.bestRunIndex).toBe(1); + expect(taskResult.tokens.total).toBe(150); + expect(result.summary.successfulTasks).toBe(1); + expect(result.summary.successfulRuns).toBe(2); + expect(result.summary.totalTokens.total).toBe(150); + expect(result.summary.taskSuccessRate).toBe(1); + expect(result.summary.flakyTasks).toBe(1); + expect(result.summary.consistentlyPassingTasks).toBe(0); + }); + + it("falls back to the cheapest failure when no run succeeded", () => { + const task = createTask("none"); + const expensiveFail = createRun(0, false, { tokens: { input: 200, output: 100, total: 300 } }); + const cheapFail = createRun(1, false, { tokens: { input: 20, output: 10, total: 30 } }); + const result = buildBenchmarkResult({ + tasks: [task], + config: { + provider: "anthropic", + model: "claude", + runsPerTask: 2, + timeout: 1000, + taskConcurrency: 1, + }, + resultsByTask: new Map([[task.id, [expensiveFail, cheapFail]]]), + startTime: "2026-04-28T00:00:00.000Z", + endTime: "2026-04-28T00:00:01.000Z", + }); + + const taskResult = result.tasks[0]!; + expect(taskResult.success).toBe(false); + expect(taskResult.bestRunIndex).toBe(1); + expect(taskResult.tokens.total).toBe(30); + expect(result.summary.successfulTasks).toBe(0); + expect(result.summary.taskSuccessRate).toBe(0); + }); + + it("ignores ghost runs when picking the best non-successful run", () => { + const task = createTask("ghost"); + const ghostRun = createRun(0, false, { + tokens: { input: 0, output: 0, total: 0 }, + toolCalls: { + read: 0, + edit: 0, + write: 0, + editSuccesses: 0, + editFailures: 0, + editWarnings: 0, + editAutocorrects: 0, + totalInputChars: 0, + }, + }); + const realFailure = createRun(1, false, { tokens: { input: 40, output: 20, total: 60 } }); + const result = buildBenchmarkResult({ + tasks: [task], + config: { + provider: "anthropic", + model: "claude", + runsPerTask: 2, + timeout: 1000, + taskConcurrency: 1, + }, + resultsByTask: new Map([[task.id, [ghostRun, realFailure]]]), + startTime: "2026-04-28T00:00:00.000Z", + endTime: "2026-04-28T00:00:01.000Z", + }); + + const taskResult = result.tasks[0]!; + expect(taskResult.bestRunIndex).toBe(1); + expect(taskResult.tokens.total).toBe(60); + expect(result.summary.ghostRuns).toBe(1); + }); }); describe("writeConversationDump", () => {