diff --git a/packages/coding-agent/src/edit/line-hash.ts b/packages/coding-agent/src/edit/line-hash.ts index 27d7f655d..1dac50110 100644 --- a/packages/coding-agent/src/edit/line-hash.ts +++ b/packages/coding-agent/src/edit/line-hash.ts @@ -3,26 +3,74 @@ * circular dependencies (prompt-templates → hashline → tools → edit). */ -/** 16-char nibble alphabet (no digits); shared with chunk checksum suffixes. */ -export const HASHLINE_NIBBLE_ALPHABET = "ZPMQVRWSNKTXJBYH"; +/** + * 40 common English BPE bigrams. Each entry tokenizes as a single token in + * modern BPE vocabularies (cl100k / o200k / Claude family), so a hashline anchor + * built from one bigram is exactly 1 token. + * + * Order is stable forever — changing it would invalidate every saved + * `LINE#ID` reference in transcripts and prompts. + */ +export const HASHLINE_BIGRAMS = [ + "th", + "he", + "in", + "er", + "an", + "re", + "on", + "at", + "en", + "nd", + "ti", + "es", + "or", + "te", + "of", + "ed", + "is", + "it", + "al", + "ar", + "st", + "to", + "nt", + "ng", + "se", + "ha", + "as", + "ou", + "io", + "le", + "ve", + "co", + "me", + "de", + "hi", + "ri", + "ro", + "ic", + "ne", + "ea", +] as const; -const NIBBLE_STR = HASHLINE_NIBBLE_ALPHABET; +export const HASHLINE_BIGRAMS_COUNT = HASHLINE_BIGRAMS.length; -const DICT = Array.from({ length: 256 }, (_, i) => { - const h = i >>> 4; - const l = i & 0x0f; - return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`; -}); +/** + * Regex source matching exactly one bigram from {@link HASHLINE_BIGRAMS}. + * Used by hashline parsers — keep in sync with the alphabet array above. + */ +export const HASHLINE_BIGRAM_RE_SRC = `(?:${HASHLINE_BIGRAMS.join("|")})`; const RE_SIGNIFICANT = /[\p{L}\p{N}]/u; /** - * Compute a short hexadecimal hash of a single line. + * Compute a short BPE-bigram hash of a single line. * - * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, truncated to 2 chars from - * {@link NIBBLE_STR}. For lines containing no alphanumeric characters (only - * punctuation/symbols/whitespace), the line number is mixed in to reduce hash collisions. - * The line input should not include a trailing newline. + * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, mapped into + * {@link HASHLINE_BIGRAMS} via modulo. For lines containing no alphanumeric + * characters (only punctuation/symbols/whitespace), the line number is mixed in + * to reduce hash collisions. The line input should not include a trailing newline. */ export function computeLineHash(idx: number, line: string): string { line = line.replace(/\r/g, "").trimEnd(); @@ -31,7 +79,7 @@ export function computeLineHash(idx: number, line: string): string { if (!RE_SIGNIFICANT.test(line)) { seed = idx; } - return DICT[Bun.hash.xxHash32(line, seed) & 0xff]; + return HASHLINE_BIGRAMS[Bun.hash.xxHash32(line, seed) % HASHLINE_BIGRAMS_COUNT]; } /** @@ -53,7 +101,7 @@ export function formatLineHash(line: number, lines: string): string { * @example * ``` * formatHashLines("function hi() {\n return;\n}") - * // "1#HH:function hi() {\n2#HH: return;\n3#HH:}" + * // "1#th:function hi() {\n2#er: return;\n3#in:}" * ``` */ export function formatHashLines(text: string, startLine = 1): string { diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index dc12caa6c..2364a1dd6 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -25,6 +25,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; import { outputMeta } from "../../tools/output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; import { generateUnifiedDiffString } from "../diff"; +import { HASHLINE_BIGRAMS } from "../line-hash"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; import type { EditToolDetails, LspBatchRequest } from "../renderer"; @@ -372,11 +373,13 @@ export async function describeChunkedGrepMatch(params: { }; } -const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH"; +const CHUNK_CHECKSUM_BIGRAMS = new Set(HASHLINE_BIGRAMS); type NativeChunkRegion = "head" | "body"; function isChunkChecksumToken(value: string): boolean { - return value.length === 4 && Array.from(value).every(ch => CHUNK_CHECKSUM_ALPHABET.includes(ch.toUpperCase())); + if (value.length !== 4) return false; + const lower = value.toLowerCase(); + return CHUNK_CHECKSUM_BIGRAMS.has(lower.slice(0, 2)) && CHUNK_CHECKSUM_BIGRAMS.has(lower.slice(2, 4)); } function parseChunkEditSelector(selector: string | undefined): { @@ -406,11 +409,11 @@ function parseChunkEditSelector(selector: string | undefined): { if (hashIndex >= 0) { const suffix = selectorPart.slice(hashIndex + 1).trim(); if (isChunkChecksumToken(suffix)) { - crc = suffix.toUpperCase(); + crc = suffix.toLowerCase(); selectorPart = selectorPart.slice(0, hashIndex).trimEnd(); } } else if (isChunkChecksumToken(selectorPart)) { - crc = selectorPart.toUpperCase(); + crc = selectorPart.toLowerCase(); selectorPart = ""; } @@ -550,7 +553,7 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget { export const chunkToolEditSchema = Type.Object( { path: Type.String({ - description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.", + description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#thth~', 'src/app.ts:class_Bar'.", }), write: Type.Optional( Type.Union([Type.String(), Type.Null()], { @@ -577,6 +580,7 @@ export const chunkToolEditSchema = Type.Object( ); export const chunkEditParamsSchema = Type.Object( { + path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })), edits: Type.Array(chunkToolEditSchema, { description: "Chunk edits", minItems: 1, diff --git a/packages/coding-agent/src/edit/modes/hashline.ts b/packages/coding-agent/src/edit/modes/hashline.ts index 9f7aae121..62568ad71 100644 --- a/packages/coding-agent/src/edit/modes/hashline.ts +++ b/packages/coding-agent/src/edit/modes/hashline.ts @@ -2,18 +2,16 @@ * Hashline edit mode — a line-addressable edit format using text hashes. * * Each line in a file is identified by its 1-indexed line number and a short - * hexadecimal hash derived from the normalized line text (xxHash32, truncated to 2 - * hex chars). + * BPE-bigram hash derived from the normalized line text (xxHash32 mod 40, + * mapped through HASHLINE_BIGRAMS). * The combined `LINE#ID` reference acts as both an address and a staleness check: * if the file has changed since the caller last read it, hash mismatches are caught * before any mutation occurs. * * Displayed format: `LINENUM#HASH:TEXT` - * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`) + * Reference format: `"LINENUM#HASH"` (e.g. `"5#th"`) */ -import * as fs from "node:fs/promises"; -import * as nodePath from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; @@ -21,16 +19,12 @@ import type { BunFile } from "bun"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; import { assertEditableFileContent } from "../../tools/auto-generated-guard"; -import { - invalidateFsScanAfterDelete, - invalidateFsScanAfterRename, - invalidateFsScanAfterWrite, -} from "../../tools/fs-cache-invalidation"; +import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd } from "../../tools/path-utils"; import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; import { generateDiffString } from "../diff"; -import { computeLineHash, formatLineHash } from "../line-hash"; +import { computeLineHash, formatLineHash, HASHLINE_BIGRAM_RE_SRC } from "../line-hash"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; import type { EditToolDetails, LspBatchRequest } from "../renderer"; @@ -49,8 +43,12 @@ export type HashlineEdit = | { op: "append_file"; lines: string[] } | { op: "prepend_file"; lines: string[] }; -const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/; -const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/; +const HASHLINE_PREFIX_RE = new RegExp( + `^\\s*(?:>>>|>>)?\\s*(?:\\+?\\s*(?:\\d+\\s*#\\s*|#\\s*)|\\+)\\s*${HASHLINE_BIGRAM_RE_SRC}:`, +); +const HASHLINE_PREFIX_PLUS_RE = new RegExp( + `^\\s*(?:>>>|>>)?\\s*\\+\\s*(?:\\d+\\s*#\\s*|#\\s*)?${HASHLINE_BIGRAM_RE_SRC}:`, +); const DIFF_PLUS_RE = /^[+](?![+])/; const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/; @@ -153,17 +151,16 @@ const locSchema = Type.Union( export const hashlineEditSchema = Type.Object( { - path: Type.String({ description: "File path" }), + path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })), loc: Type.Optional(locSchema), content: Type.Optional(linesSchema), - delete: Type.Optional(Type.Boolean({ description: "Delete the file" })), - move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })), }, { additionalProperties: false }, ); export const hashlineEditParamsSchema = Type.Object( { + path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })), edits: Type.Array(hashlineEditSchema, { description: "edits" }), }, { additionalProperties: false }, @@ -197,7 +194,7 @@ export function isHashlineParams(params: unknown): params is HashlineParams { if (params.edits.length === 0) return true; const first = params.edits[0]; if (typeof first !== "object" || first === null) return false; - return "loc" in first || "delete" in first || "move" in first; + return "loc" in first; } function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] { @@ -484,20 +481,20 @@ export async function* streamHashLinesFromLines( } /** - * Parse a line reference string like `"5#abcd"` into structured form. + * Parse a line reference string like `"5#th"` into structured form. * - * @throws Error if the format is invalid (not `NUMBER#HEXHASH`) + * @throws Error if the format is invalid (not `NUMBER#BIGRAM`) */ export function parseTag(ref: string): { line: number; hash: string } { // This regex captures: // 1. optional leading ">+" and whitespace // 2. line number (1+ digits) // 3. "#" with optional surrounding spaces - // 4. hash (2 hex chars) + // 4. hash (one BPE bigram from HASHLINE_BIGRAMS) // 5. optional trailing display suffix (":..." or " ...") - const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/); + const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)\\s*#\\s*(${HASHLINE_BIGRAM_RE_SRC})`)); if (!match) { - throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`); + throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#th").`); } const line = Number.parseInt(match[1], 10); if (line < 1) { @@ -1170,7 +1167,7 @@ export function buildCompactHashlineDiffPreview( } export async function computeHashlineDiff( - input: { path: string; edits: HashlineEditInput[]; move?: string }, + input: { path: string; edits: HashlineEditInput[] }, cwd: string, ): Promise< | { @@ -1181,28 +1178,19 @@ export async function computeHashlineDiff( error: string; } > { - const { path, edits, move } = input; + const { path, edits } = input; try { const absolutePath = resolveToCwd(path, cwd); - const movePath = move ? resolveToCwd(move, cwd) : undefined; - const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0; const resolvedEdits = resolveHashlineEditsForDiff(edits); const file = Bun.file(absolutePath); - if (movePath === absolutePath) { - return { error: "move path is the same as source path" }; - } - if (isMoveOnly) { - return { diff: "", firstChangedLine: undefined }; - } - const rawContent = await readHashlineFileText(file, path); const { text: content } = stripBom(rawContent); const normalizedContent = normalizeToLF(content); const result = applyHashlineEdits(normalizedContent, resolvedEdits); - if (normalizedContent === result.lines && !move) { + if (normalizedContent === result.lines) { return { error: `No changes would be made to ${path}. The edits produce identical content.` }; } @@ -1229,63 +1217,18 @@ export async function executeHashlineSingle( ): Promise> { const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options; - // Extract file-level ops from edits - const deleteFile = edits.some(e => e.delete); - const move = edits.find(e => e.move)?.move; - // Filter to content edits only (those with loc) const contentEdits = edits.filter(e => e.loc != null); - enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move }); + enforcePlanModeWrite(session, path, { op: "update" }); if (path.endsWith(".ipynb") && contentEdits.length > 0) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } const absolutePath = resolvePlanPath(session, path); - const resolvedMove = move ? resolvePlanPath(session, move) : undefined; - if (resolvedMove === absolutePath) { - throw new Error("move path is the same as source path"); - } const sourceFile = Bun.file(absolutePath); const sourceExists = await sourceFile.exists(); - const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0; - - if (deleteFile) { - if (sourceExists) { - await sourceFile.unlink(); - } - invalidateFsScanAfterDelete(absolutePath); - return { - content: [{ type: "text", text: `Deleted ${path}` }], - details: { - diff: "", - op: "delete", - meta: outputMeta().get(), - }, - }; - } - - if (isMoveOnly && resolvedMove) { - if (!sourceExists) { - throw new Error(`File not found: ${path}`); - } - const parentDir = nodePath.dirname(resolvedMove); - if (parentDir && parentDir !== ".") { - await fs.mkdir(parentDir, { recursive: true }); - } - await fs.rename(absolutePath, resolvedMove); - invalidateFsScanAfterRename(absolutePath, resolvedMove); - return { - content: [{ type: "text", text: `Moved ${path} to ${move}` }], - details: { - diff: "", - op: "update", - move, - meta: outputMeta().get(), - }, - }; - } if (!sourceExists) { const lines: string[] = []; @@ -1329,7 +1272,7 @@ export async function executeHashlineSingle( warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; - if (originalNormalized === result.text && !move) { + if (originalNormalized === result.text) { let diagnostic = `No changes made to ${path}. The edits produced identical content.`; if (result.noopEdits && result.noopEdits.length > 0) { const details = result.noopEdits @@ -1349,24 +1292,23 @@ export async function executeHashlineSingle( throw new Error(diagnostic); } - const writePath = resolvedMove ?? absolutePath; const finalContent = bom + restoreLineEndings(result.text, originalEnding); - const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst => - dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined, + const diagnostics = await writethrough( + absolutePath, + finalContent, + signal, + Bun.file(absolutePath), + batchRequest, + dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined), ); - if (resolvedMove && resolvedMove !== absolutePath) { - await sourceFile.unlink(); - invalidateFsScanAfterRename(absolutePath, resolvedMove); - } else { - invalidateFsScanAfterWrite(absolutePath); - } + invalidateFsScanAfterWrite(absolutePath); const diffResult = generateDiffString(originalNormalized, result.text); const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); - const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; + const resultText = `Updated ${path}`; const preview = buildCompactHashlineDiffPreview(diffResult.diff); const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; @@ -1384,7 +1326,6 @@ export async function executeHashlineSingle( firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, diagnostics, op: "update", - move, meta, }, }; diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 4ba69596e..5ca211a2a 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -8,7 +8,7 @@ Call format: `{"edits": [{"path": "file:chunk#ID~", "write": "new body"}, …]}` - **MUST** inspect first with `read`. Never invent chunk paths or IDs. Copy them from the latest `read` output or edit response. -- `path` format: `file:selector` — e.g. `src/app.ts:fn_foo#ABCD~`. Append `~` for body, `^` for head, or nothing for the whole chunk. Include `#ID` for `write`/`delete`. +- `path` format: `file:selector` — e.g. `src/app.ts:fn_foo#thth~`. Append `~` for body, `^` for head, or nothing for the whole chunk. Include `#ID` for `write`/`delete`. - If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing. {{#if chunkAutoIndent}} - Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `~` of a method, write the body starting at column 0: @@ -75,32 +75,32 @@ Each edit entry has `path` (`file:selector`) plus **exactly one** operation fiel Given this `read` output for `counter.rs`: ``` - | counter.rs·62L·rust·#ZRPW + | counter.rs·62L·rust·#anth | -@imp#MNHH +@imp#erhe 1 |use std::fmt; | -@struct_Counte#QTSX +@struct_Counte#onat 3^|/// A simple counter that tracks a value and its history. 4^|#[derive(Debug, Clone)] 5^|pub struct Counter { --@struct_Counte.field_value#MQTW +-@struct_Counte.field_value#enth 6 | /// The current value. 7 | value: i32, --@struct_Counte.field_max#HJMQ +-@struct_Counte.field_max#seti 8 | /// Maximum allowed value. 9 | max: i32, 10 |} | -@impl_Counte#VNPP +@impl_Counte#reha 12^|impl Counter { --@impl_Counte.fn_new#RWZV +-@impl_Counte.fn_new#ndas 13^| /// Creates a new counter starting at zero. 14^| pub fn new(max: i32) -> Self { 15 | Self { value: 0, max } 16 | } 17 | --@impl_Counte.fn_increm#MNHV +-@impl_Counte.fn_increm#ouer 18^| /// Increments the counter by one, clamping at max. 19^| pub fn increment(&mut self) { 20 | if self.value < self.max { @@ -108,7 +108,7 @@ Given this `read` output for `counter.rs`: 22 | } 23 | } 24 | --@impl_Counte.fn_decrem#TTWB +-@impl_Counte.fn_decrem#arve 25^| /// Decrements the counter by one, clamping at zero. 26^| pub fn decrement(&mut self) { 27 | if self.value > 0 { @@ -116,167 +116,43 @@ Given this `read` output for `counter.rs`: 29 | } 30 | } 31 | --@impl_Counte.fn_get#PTNT +-@impl_Counte.fn_get#arco 32^| /// Returns the current value. 33^| pub fn get(&self) -> i32 { 34 | self.value 35 | } 36 |} | -@impl_Displa#BNJH +@impl_Displa#meha 38^|impl fmt::Display for Counter { --@impl_Displa.fn_fmt#NKRN +-@impl_Displa.fn_fmt#deri 39^| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 40 | write!(f, "Counter({}/{})", self.value, self.max) 41 | } 42 |} - | -@mod_tests#YWXM -44^|#[cfg(test)] -45^|mod tests { --@mod_tests.chunk#VSMY -46 | use super::*; -47 | --@mod_tests.fn_test_i#YXQZ -48^| #[test] -49^| fn test_increment() { -50 | let mut c = Counter::new(10); -51 | c.increment(); -52 | assert_eq!(c.get(), 1); -53 | } -54 | --@mod_tests.fn_test_d#XPBQ -55^| #[test] -56^| fn test_decrement_at_zero() { -57 | let mut c = Counter::new(10); -58 | c.decrement(); -59 | assert_eq!(c.get(), 0); -60 | } -61 |} ``` +Lines marked `^` between the line number and `|` are **head** lines (doc comments, attributes, signature). Lines without `^` are **body** lines. `~` replaces body lines only; `^` replaces head lines only. -**Understanding `^` markers in `read` output:** Lines marked with `^` between the line number and `|` (e.g. ` 3^|`) are **head** lines — doc comments, attributes, and the signature. Lines without `^` (e.g. ` 7 |`) are **body** lines. `~` replaces body lines only, keeping head lines intact. - -**Put body** (`~` — the common case): -``` -{ "path": "counter.rs:impl_Counte.fn_increm#MNHV~", "write": "self.value = (self.value + 1).min(self.max);\n" } -``` -Result — only the body (non-`^` lines) changes; the doc comment, signature, and closing `}` are all preserved: -``` - /// Increments the counter by one, clamping at max. - pub fn increment(&mut self) { - self.value = (self.value + 1).min(self.max); - } -``` - -**Write whole chunk** (rewrite signature + doc comment + body): -``` -{ "path": "counter.rs:impl_Counte.fn_increm#MNHV", "write": "/// Increments by the given step, clamping at max.\npub fn increment(&mut self, step: i32) {\n\tself.value = (self.value + step).min(self.max);\n}\n" } -``` -Result — **everything** including the doc comment and signature is rewritten. You must include them; omitting them deletes them: -``` - /// Increments by the given step, clamping at max. - pub fn increment(&mut self, step: i32) { - self.value = (self.value + step).min(self.max); - } -``` - -**Write head** (`^` — attributes, doc comments, signature): -``` -{ "path": "counter.rs:impl_Counte.fn_get#PTNT^", "write": "/// Returns the current counter value.\n#[inline]\npub fn get(&self) -> i32 {\n" } -``` -Result — the head (all `^` lines + opening brace) changes, body untouched: -``` - /// Returns the current counter value. - #[inline] - pub fn get(&self) -> i32 { - self.value - } -``` - -**Insert before a chunk** (`prepend`): -``` -{ "path": "counter.rs:impl_Counte.fn_get", "insert": { "loc": "prepend", "body": "/// Resets the counter to zero.\npub fn reset(&mut self) {\n\tself.value = 0;\n}\n\n" } } -``` -Result — a new method is inserted before `fn get`: -``` - /// Resets the counter to zero. - pub fn reset(&mut self) { - self.value = 0; - } - - /// Returns the current value. - pub fn get(&self) -> i32 { -``` - -**Insert after a chunk** (`append`): -``` -{ "path": "counter.rs:struct_Counte", "insert": { "loc": "append", "body": "\nimpl Default for Counter {\n\tfn default() -> Self {\n\t\tSelf { value: 0, max: 100 }\n\t}\n}\n" } } -``` -Result — a new impl block appears after the struct: -``` -} - -impl Default for Counter { - fn default() -> Self { - Self { value: 0, max: 100 } - } -} - -impl Counter { -``` - -**Insert at start of container body** (`~` + `prepend`): -``` -{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "prepend", "body": "/// Creates a counter starting at the given value.\npub fn with_value(value: i32, max: i32) -> Self {\n\tSelf { value: value.min(max), max }\n}\n\n" } } -``` -Result — a new method is added at the top of the impl body, before existing methods: -``` -impl Counter { - /// Creates a counter starting at the given value. - pub fn with_value(value: i32, max: i32) -> Self { - Self { value: value.min(max), max } - } - - /// Creates a new counter starting at zero. - pub fn new(max: i32) -> Self { -``` - -**Insert at end of container body** (`~` + `append`): -``` -{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "append", "body": "\n/// Returns true if the counter is at its maximum.\npub fn is_maxed(&self) -> bool {\n\tself.value >= self.max\n}\n" } } -``` -Result — a new method is added at the end of the impl body, before the closing `}`: -``` - pub fn get(&self) -> i32 { - self.value - } - - /// Returns true if the counter is at its maximum. - pub fn is_maxed(&self) -> bool { - self.value >= self.max - } -} -``` - -**Delete a chunk**: -``` -{ "path": "counter.rs:impl_Counte.fn_decrem#TTWB", "delete": true } -``` -Result — the method (including its doc comment and signature) is removed. -- Indentation rules (important): -{{#if chunkAutoIndent}} - - Use `\t` for each indent level. The tool converts tabs to the file's actual style (2-space, 4-space, etc.). -{{else}} - - Match the file's real indentation characters in your snippet. The tool preserves your literal tabs/spaces after adding the target region's base indent. -{{/if}} - - Do NOT include the chunk's base indentation — only indent relative to the region's opening level. - - For `write`, the tool strips common leading whitespace shared by all non-empty lines, then adds the target region's base indent. If lines have mixed relative indentation, write them at column 0 so the common-margin cleanup cannot change the structure. - - For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Multiple sibling lines: `"print(a)\nprint(b)\nprint(c)\n"` — all at column 0, the tool adds the function's base indent. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in. Python example — to replace `~` of `def divide(a, b):`, write: `"if b == 0:\n\treturn None\nreturn a / b\n"` — the `if` and `return a / b` are at column 0, `return None` is one `\t` in. - - For `^`: write at column 0 relative to the head region, just like `~`. A class member's head uses `"/// doc\n#[attr]\npub fn start() {"` — do not include the class/member base indentation. -{{#if chunkAutoIndent}} - - For a top-level item: start at zero indent. Write `"fn foo() {\n\treturn 1;\n}\n"`. -{{else}} - - For a top-level item: start at zero indent. Write `"fn foo() {\n return 1;\n}\n"`. -{{/if}} +# Put body (`~` — the common case) +`{ "path": "counter.rs:impl_Counte.fn_increm#ouer~", "write": "self.value = (self.value + 1).min(self.max);\n" }` +Only body changes; doc comment, signature, and closing `}` are preserved. +# Write whole chunk (rewrite signature + doc + body) +`{ "path": "counter.rs:impl_Counte.fn_increm#ouer", "write": "/// Increments by the given step, clamping at max.\npub fn increment(&mut self, step: i32) {\n\tself.value = (self.value + step).min(self.max);\n}\n" }` +Everything is rewritten. Omitting the doc comment or signature deletes them. +# Write head (`^` — attributes, doc comments, signature) +`{ "path": "counter.rs:impl_Counte.fn_get#arco^", "write": "/// Returns the current counter value.\n#[inline]\npub fn get(&self) → i32 {\n" }` +Head changes (all `^` lines + opening brace); body untouched. +# Insert before a chunk (`prepend`) +`{ "path": "counter.rs:impl_Counte.fn_get", "insert": { "loc": "prepend", "body": "/// Resets the counter to zero.\npub fn reset(&mut self) {\n\tself.value = 0;\n}\n\n" } }` +# Insert after a chunk (`append`) +`{ "path": "counter.rs:struct_Counte", "insert": { "loc": "append", "body": "\nimpl Default for Counter {\n\tfn default() → Self {\n\t\tSelf { value: 0, max: 100 }\n\t}\n}\n" } }` +# Insert at start of container body (`~` + `prepend`) +`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "prepend", "body": "/// Creates a counter starting at the given value.\npub fn with_value(value: i32, max: i32) → Self {\n\tSelf { value: value.min(max), max }\n}\n\n" } }` +Lands at the top of the impl body, before existing methods. +# Insert at end of container body (`~` + `append`) +`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "append", "body": "\n/// Returns true if the counter is at its maximum.\npub fn is_maxed(&self) → bool {\n\tself.value ≥ self.max\n}\n" } }` +Lands at the end of the impl body, before the closing `}`. +# Delete a chunk +`{ "path": "counter.rs:impl_Counte.fn_decrem#arve", "delete": true }` +Removes the method including its doc comment and signature. diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index a34cb2825..24f80c5be 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -9,14 +9,14 @@ Searches files using powerful regex matching. {{#if IS_HASHLINE_MODE}} -- Text output is CID prefixed: `LINE#ID:content` +- Text output is anchor-prefixed: `123#th:content` {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed {{/if}} {{/if}} {{#if IS_CHUNK_MODE}} -- Text output is chunk-path-prefixed: `path:selector>LINE|content` +- Text output is chunk-path-prefixed: `path:sel>123#th|content` {{/if}} diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index b43c88232..5d97192c1 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,22 +1,21 @@ -Applies precise file edits using `LINE#ID` anchors from `read` output. +Applies precise file edits using anchor-prefixed line references (e.g. `123#th`) from `read` output. Read the file first. Copy anchors exactly from the latest `read` output. After any successful edit, re-read before editing that file again. **Top level** - `edits` — array of edit entries +- `path` (optional) — default file path used when an entry omits its own `path`. Lets you share the path across many edits in one request. -**Edit entry**: `{ path, loc, content }` or `{ path, delete: true }` or `{ path, move: "new/path" }` -- `path` — file path +**Edit entry**: `{ path?, loc, content }` +- `path` — file path (omit to fall back to the request-level `path`) - `loc` — where to apply the edit (see below) - `content` — replacement/inserted lines (array of strings preferred, `null` to delete) -- `delete` — delete the file -- `move` — move/rename the file **`loc` values** - `"append"` / `"prepend"` — insert at end/start of file -- `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line -- `{ range: { pos: "N#ID", end: "N#ID" } }` — replace inclusive range `pos..end` with new content (set `pos == end` for single-line replace) +- `{ append: "123#th" }` / `{ prepend: "123#th" }` — insert after/before anchored line +- `{ range: { pos: "123#th", end: "123#th" } }` — replace inclusive range `pos..end` with new content (set `pos == end` for single-line replace) @@ -43,87 +42,21 @@ All examples below reference the same file: {{hline 18 "}"}} ``` - +# Replace a block body Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. - -``` -{ - edits: [{ - path: "a.ts", - loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } }, - content: [ - "\t\tif (isEnoent(err)) return null;", - "\t\tthrow err;" - ] - }] -} -``` - - - -Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. - -``` -{ - edits: [{ - path: "a.ts", - loc: { range: { pos: {{href 6 "\tlog();"}}, end: {{href 7 "}"}} } }, - content: [ - "\tvalidate();", - "\tlog();", - "}" - ] - }] -} -``` - -**Wrong**: `end: {{href 6 "\tlog();"}}` with the same content — line 7 (`}`) survives AND content emits `}`, producing two closing braces. - - - +`{edits:[{path:"a.ts",loc:{range:{pos:{{href 15 "\t\tconsole.error(err);"}},end:{{href 16 "\t\treturn null;"}}}},content:["\t\tif (isEnoent(err)) return null;","\t\tthrow err;"]}]}` +# Replace whole block including closing brace +Replace `alpha`'s entire body including the closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. +`{edits:[{path:"a.ts",loc:{range:{pos:{{href 6 "\tlog();"}},end:{{href 7 "}"}}}},content:["\tvalidate();","\tlog();","}"]}]}` +**Wrong**: `end: {{href 6 "\tlog();"}}` — line 7 (`}`) survives AND content emits `}`, producing two closing braces. +# Replace one line Single-line replace uses `pos == end`. - -``` -{ - edits: [{ - path: "a.ts", - loc: { range: { pos: {{href 2 "const timeout = 5000;"}}, end: {{href 2 "const timeout = 5000;"}} } }, - content: ["const timeout = 30_000;"] - }] -} -``` - - - -``` -{ - edits: [{ - path: "a.ts", - loc: { range: { pos: {{href 10 "\t// TODO: remove after migration"}}, end: {{href 11 "\tlegacy();"}} } }, - content: null - }] -} -``` - - - +`{edits:[{path:"a.ts",loc:{range:{pos:{{href 2 "const timeout = 5000;"}},end:{{href 2 "const timeout = 5000;"}}}},content:["const timeout = 30_000;"]}]}` +# Delete a range +`{edits:[{path:"a.ts",loc:{range:{pos:{{href 10 "\t// TODO: remove after migration"}},end:{{href 11 "\tlegacy();"}}}},content:null}]}` +# Insert before a sibling When adding a sibling declaration, prefer `prepend` on the next declaration. - -``` -{ - edits: [{ - path: "a.ts", - loc: { prepend: {{href 9 "function beta() {"}} }, - content: [ - "function gamma() {", - "\tvalidate();", - "}", - "" - ] - }] -} -``` - +`{edits:[{path:"a.ts",loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}` diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index a50a4a960..0488fbd7c 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -2,7 +2,6 @@ Reads files using syntax-aware chunks. Also inspects directories, archives, SQLi The chunk-aware `read` variant returns AST-scoped chunks with current checksum IDs for structural editing, and otherwise behaves like `open` for non-code content. - - You **MUST** parallelize calls when exploring related files - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content. @@ -17,7 +16,7 @@ The chunk-aware `read` variant returns AST-scoped chunks with current checksum I |---|---| |*(omitted)*|Read full file as chunks (up to {{DEFAULT_LIMIT}} lines)| |`class_Foo`|Read a specific chunk| -|`class_Foo.fn_bar#ABCD~`|Read a chunk region (body `~` / head `^`) by ID| +|`class_Foo.fn_bar#thth~`|Read a chunk region (body `~` / head `^`) by ID| |`?`|List all chunk paths with IDs| |`L50`|Read from line 50 onward (shorthand for L50 to EOF)| |`L50-L120`|Read lines 50 through 120| @@ -27,7 +26,7 @@ The chunk-aware `read` variant returns AST-scoped chunks with current checksum I Max {{DEFAULT_MAX_LINES}} lines per call. # Chunks -Each anchor `@full.chunk.path#CCCC` (with `-` prefixes for nesting depth) in the output identifies a chunk. Use `full.chunk.path#CCCC` as-is to read truncated chunks. +Each anchor `@full.chunk.path#thth` (with `-` prefixes for nesting depth) in the output identifies a chunk. Use `full.chunk.path#thth` as-is to read truncated chunks. If you need a canonical target list, run `read(path="file", sel="?")`. That listing shows chunk paths with IDs and is the safest structural discovery mode. Summary lines in this listing are orientation hints; follow a selector with `read(path="file", sel="chunk#ID")` or use `raw` when you need exact source. Line numbers in the gutter are absolute file line numbers. diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 483599e52..73c6738d6 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -18,13 +18,13 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil |`L50`|Read from line 50 onward (shorthand for L50 to EOF)| |`L50-L120`|Read lines 50 through 120| |`L20-L20`|Read exactly one line| -|`raw`|Raw content without transformations (for URLs: untouched HTML)| +|`raw`|Skip line-numbering / hashline / chunking; return file content as plain text. For URLs: untouched HTML.| Max {{DEFAULT_MAX_LINES}} lines per call. # Filesystem {{#if IS_HASHLINE_MODE}} -- Reading from FS returns lines prefixed with anchors: `41#ZZ:def alpha():` +- Reading from FS returns lines prefixed with anchors: `41#th:def alpha():` {{else}} {{#if IS_LINE_NUMBER_MODE}} - Reading from FS returns lines prefixed with line numbers: `41:def alpha():` diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index dd21214fb..51fec2c56 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -370,7 +370,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: const readSchema = Type.Object({ path: Type.String({ description: "Path or URL to read" }), - sel: Type.Optional(Type.String({ description: "Selector: chunk path, L10-L50, or raw" })), + sel: Type.Optional(Type.String({ description: "Selector" })), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 20 })), }); @@ -1247,8 +1247,9 @@ export class ReadTool implements AgentTool { firstLineExceedsLimit, }; - const shouldAddHashLines = displayMode.hashLines; - const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; + const isRawMode = parsed.kind === "raw"; + const shouldAddHashLines = !isRawMode && displayMode.hashLines; + const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers; const formatText = (text: string, startNum: number): string => { return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers); }; diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index ee3e59b19..76b9116b3 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -4,6 +4,9 @@ import { buildCompactHashlineDiffPreview, computeLineHash, formatHashLines, + HASHLINE_BIGRAM_RE_SRC, + HASHLINE_BIGRAMS, + HASHLINE_BIGRAMS_COUNT, HashlineMismatchError, hashlineParseText, parseTag, @@ -22,6 +25,13 @@ function makeTag(line: number, content: string): Anchor { }; } +/** Returns a valid bigram that's guaranteed NOT to equal the real hash of `(line, content)`. */ +function staleBigramFor(line: number, content: string): string { + const real = computeLineHash(line, content); + const idx = HASHLINE_BIGRAMS.indexOf(real); + return HASHLINE_BIGRAMS[(idx + 1) % HASHLINE_BIGRAMS_COUNT]; +} + // ═══════════════════════════════════════════════════════════════════════════ // computeLineHash // ═══════════════════════════════════════════════════════════════════════════ @@ -29,7 +39,7 @@ function makeTag(line: number, content: string): Anchor { describe("computeLineHash", () => { it("returns 2-4 character alphanumeric hash string", () => { const hash = computeLineHash(1, "hello"); - expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); + expect(hash).toMatch(new RegExp(`^${HASHLINE_BIGRAM_RE_SRC}$`)); }); it("same content at same line produces same hash", () => { @@ -46,7 +56,7 @@ describe("computeLineHash", () => { it("empty line produces valid hash", () => { const hash = computeLineHash(1, ""); - expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); + expect(hash).toMatch(new RegExp(`^${HASHLINE_BIGRAM_RE_SRC}$`)); }); it("uses line number for symbol-only lines", () => { @@ -93,7 +103,7 @@ describe("formatHashLines", () => { const result = formatHashLines("foo\n\nbar"); const lines = result.split("\n"); expect(lines).toHaveLength(3); - expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/); + expect(lines[1]).toMatch(new RegExp(`^2#${HASHLINE_BIGRAM_RE_SRC}:$`)); }); it("round-trips with computeLineHash", () => { @@ -102,7 +112,7 @@ describe("formatHashLines", () => { const lines = formatted.split("\n"); for (let i = 0; i < lines.length; i++) { - const match = lines[i].match(/^(\d+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/); + const match = lines[i].match(new RegExp(`^(\\d+)#(${HASHLINE_BIGRAM_RE_SRC}):(.*)$`)); expect(match).not.toBeNull(); const lineNum = Number.parseInt(match![1], 10); const hash = match![2]; @@ -171,8 +181,8 @@ describe("streamHashLinesFrom*", () => { describe("parseTag", () => { it("parses valid reference", () => { - const ref = parseTag("5#QQ"); - expect(ref).toEqual({ line: 5, hash: "QQ" }); + const ref = parseTag("5#th"); + expect(ref).toEqual({ line: 5, hash: "th" }); }); it("rejects single-character hash", () => { @@ -180,8 +190,8 @@ describe("parseTag", () => { }); it("parses long hash by taking strict 2-char prefix", () => { - const ref = parseTag("100#QQQQ"); - expect(ref).toEqual({ line: 100, hash: "QQ" }); + const ref = parseTag("100#thQQ"); + expect(ref).toEqual({ line: 100, hash: "th" }); }); it("rejects missing separator", () => { @@ -197,7 +207,7 @@ describe("parseTag", () => { }); it("rejects line number 0", () => { - expect(() => parseTag("0#QQ")).toThrow(/Line number must be >= 1/); + expect(() => parseTag("0#th")).toThrow(/Line number must be >= 1/); }); it("rejects empty string", () => { @@ -541,7 +551,7 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.lines).toBe("if (ok) {\n runSafe();\n}\n}\nafter();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("Possible boundary duplication"); - expect(result.warnings?.[0]).toContain("set `end` to 3#RZ"); + expect(result.warnings?.[0]).toContain("set `end` to 3#en"); }); it("preserves duplicated trailing content when replacement re-emits the next line", () => { @@ -558,7 +568,7 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.lines).toBe("start\n newCall();\nnextCall();\nnextCall();\nafter();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("Possible boundary duplication"); - expect(result.warnings?.[0]).toContain("set `end` to 3#HR"); + expect(result.warnings?.[0]).toContain("set `end` to 3#te"); }); it("preserves duplicated leading content when replacement re-emits the previous line", () => { @@ -724,13 +734,17 @@ describe("applyHashlineEdits — errors", () => { it("rejects stale hash", () => { const content = "aaa\nbbb\nccc"; // Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00) - const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_line", pos: parseTag(`2#${staleBigramFor(2, "bbb")}`), lines: ["BBB"] }, + ]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); it("stale hash error shows >>> markers with correct hashes", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }]; + const edits: HashlineEdit[] = [ + { op: "replace_line", pos: parseTag(`2#${staleBigramFor(2, "bbb")}`), lines: ["BBB"] }, + ]; try { applyHashlineEdits(content, edits); @@ -754,8 +768,8 @@ describe("applyHashlineEdits — errors", () => { const content = "aaa\nbbb\nccc\nddd\neee"; // Use hashes that don't match any line (avoid 00 — ccc hashes to 00) const edits: HashlineEdit[] = [ - { op: "replace_line", pos: parseTag("2#ZZ"), lines: ["BBB"] }, - { op: "replace_line", pos: parseTag("4#ZZ"), lines: ["DDD"] }, + { op: "replace_line", pos: parseTag(`2#${staleBigramFor(2, "bbb")}`), lines: ["BBB"] }, + { op: "replace_line", pos: parseTag(`4#${staleBigramFor(4, "ddd")}`), lines: ["DDD"] }, ]; try { @@ -797,7 +811,7 @@ describe("applyHashlineEdits — errors", () => { it("rejects out-of-range line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("10#ZZ"), lines: ["X"] }]; + const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag(`10#${HASHLINE_BIGRAMS[0]}`), lines: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); @@ -904,27 +918,27 @@ describe("stripNewLinePrefixes", () => { }); it("strips hashline prefixes when all non-empty lines carry them", () => { - const lines = ["1#WQ:foo", "2#TZ:bar", "3#HX:baz"]; + const lines = ["1#th:foo", "2#er:bar", "3#in:baz"]; expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]); }); it("strips plus hashline prefixes when all non-empty lines carry them", () => { - const lines = ["+WQ:foo", "+TZ:bar", "+HX:baz"]; + const lines = ["+th:foo", "+er:bar", "+in:baz"]; expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]); }); it("strips plus hashline prefixes in mixed +/ - change style", () => { - const lines = ["-**Storage location TBD:**", "+MW:**Storage location TBD:**"]; + const lines = ["-**Storage location TBD:**", "+ti:**Storage location TBD:**"]; expect(stripNewLinePrefixes(lines)).toEqual(["-**Storage location TBD:**", "**Storage location TBD:**"]); }); it("does NOT strip hashline prefixes when any non-empty line is plain content", () => { - const lines = ["1#WQ:foo", "bar", "3#HX:baz"]; - expect(stripNewLinePrefixes(lines)).toEqual(["1#WQ:foo", "bar", "3#HX:baz"]); + const lines = ["1#th:foo", "bar", "3#in:baz"]; + expect(stripNewLinePrefixes(lines)).toEqual(["1#th:foo", "bar", "3#in:baz"]); }); it("strips hash-only prefixes when all non-empty lines carry them", () => { - const lines = ["#WQ:", "#TZ:{{/*", "#HX:OC deployment container livenessProbe template"]; + const lines = ["#th:", "#er:{{/*", "#in:OC deployment container livenessProbe template"]; expect(stripNewLinePrefixes(lines)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); @@ -946,9 +960,9 @@ describe("stripNewLinePrefixes", () => { it("strips hashline prefixes when truncation marker is present (anchor corruption bug)", () => { const lines = [ - "1#BQ:---", - "2#XS:title: example", - "3#BQ:---", + "1#an:---", + "2#re:title: example", + "3#an:---", "", "[Showing lines 1-300 of 332. Use sel=L301 to continue]", ]; @@ -959,14 +973,14 @@ describe("stripNewLinePrefixes", () => { }); it("strips hashline prefixes when generic read truncation notice is present", () => { - const lines = ["1#BQ:line one", "2#XS:line two", "", "[42 more lines in file. Use sel=L3 to continue]"]; + const lines = ["1#an:line one", "2#re:line two", "", "[42 more lines in file. Use sel=L3 to continue]"]; const result = stripNewLinePrefixes(lines); expect(result[0]).toBe("line one"); expect(result[1]).toBe("line two"); }); it("strips nested hashline prefixes (already-corrupted content re-read)", () => { - const lines = ["1#NX:1#BQ:---", "2#TY:2#XS:title: example", "3#JZ:3#BQ:---"]; + const lines = ["1#at:1#an:---", "2#en:2#re:title: example", "3#nd:3#an:---"]; const result = stripNewLinePrefixes(lines); expect(result[0]).toBe("---"); expect(result[1]).toBe("title: example"); @@ -980,7 +994,7 @@ describe("stripNewLinePrefixes", () => { describe("stripHashlinePrefixes", () => { it("strips when all non-empty lines have hashline prefixes", () => { - const lines = ["1#BQ:---", "2#XS:title", "", "4#VV:content"]; + const lines = ["1#an:---", "2#re:title", "", "4#on:content"]; expect(stripHashlinePrefixes(lines)).toEqual(["---", "title", "", "content"]); }); @@ -991,9 +1005,9 @@ describe("stripHashlinePrefixes", () => { it("strips hashline prefixes even when truncation marker is present (anchor corruption bug)", () => { const lines = [ - "1#BQ:---", - "2#XS:title: example", - "3#BQ:---", + "1#an:---", + "2#re:title: example", + "3#an:---", "", "[Showing lines 1-300 of 332. Use sel=L301 to continue]", ]; @@ -1004,7 +1018,7 @@ describe("stripHashlinePrefixes", () => { }); it("strips nested hashline prefixes from already-corrupted content", () => { - const lines = ["1#NX:1#BQ:---", "2#TY:2#XS:title"]; + const lines = ["1#at:1#an:---", "2#en:2#re:title"]; const result = stripHashlinePrefixes(lines); expect(result[0]).toBe("---"); expect(result[1]).toBe("title"); @@ -1026,12 +1040,12 @@ describe("hashlineParseContent", () => { }); it("strips hashline prefixes from array input when all non-empty lines are prefixed", () => { - const input = ["259#WQ:", "260#TZ:{{/*", "261#HX:OC deployment container livenessProbe template"]; + const input = ["259#th:", "260#er:{{/*", "261#in:OC deployment container livenessProbe template"]; expect(hashlineParseText(input)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); it("strips hash-only prefixes from array input when all non-empty lines are prefixed", () => { - const input = ["#WQ:", "#TZ:{{/*", "#HX:OC deployment container livenessProbe template"]; + const input = ["#th:", "#er:{{/*", "#in:OC deployment container livenessProbe template"]; expect(hashlineParseText(input)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); @@ -1082,8 +1096,9 @@ describe("hashlineParseContent", () => { }); it("preserves comment lines starting with '# Word:' through hashlineParseText", () => { - // Regression: HASHLINE_PREFIX_RE matched '# Note:', '# TODO:', etc. because the - // hash ID segment was [0-9a-zA-Z]{1,16} instead of [ZPMQVRWSNKTXJBYH]{2}. + // Regression: HASHLINE_PREFIX_RE used to match '# Note:', '# TODO:', etc. because the + // hash ID segment was overly permissive ([0-9a-zA-Z]{1,16}). It now matches only the + // 40-bigram alphabet from HASHLINE_BIGRAMS, so accidental colon-suffixed words don't strip. expect(hashlineParseText([" # Note: Using version 1.24.x"])).toEqual([" # Note: Using version 1.24.x"]); expect(hashlineParseText(["# TODO: remove this"])).toEqual(["# TODO: remove this"]); expect(hashlineParseText(["# step: install deps"])).toEqual(["# step: install deps"]); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index f08e3e164..69fd6adfb 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -380,23 +380,6 @@ describe("computeHashlineDiff", () => { expect(result.diff).toContain("second"); } }); - - test("allows move-only operation when content is unchanged", async () => { - const sourcePath = path.join(tempDir, "source.txt"); - await Bun.write(sourcePath, "unchanged content\n"); - - const result = await computeHashlineDiff( - { path: sourcePath, edits: [], move: path.join(tempDir, "moved", "target.txt") }, - tempDir, - ); - - expect("error" in result).toBe(false); - if ("diff" in result) { - expect(result.diff).toBe(""); - expect(result.firstChangedLine).toBeUndefined(); - } - }); - test("returns a handled error when the source path is a local URL", async () => { const result = await computeHashlineDiff({ path: "local://PLAN.md", edits: [] }, tempDir); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 7fb9e4f00..354e5198d 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1598,88 +1598,6 @@ describe("edit tool CRLF handling", () => { ).rejects.toThrow(/Found 2 occurrences/); }); - it("should delete file in hashline mode with delete:true", async () => { - const originalEditVariant = Bun.env.PI_EDIT_VARIANT; - Bun.env.PI_EDIT_VARIANT = "hashline"; - - const hashDir = path.join(os.tmpdir(), `coding-agent-hashline-delete-${Snowflake.next()}`); - fs.mkdirSync(hashDir, { recursive: true }); - const testFile = path.join(hashDir, "delete-me.txt"); - fs.writeFileSync(testFile, "to be deleted\n"); - - try { - const session = createTestToolSession(hashDir); - const hashlineEditTool = new EditTool(session); - const result = await hashlineEditTool.execute("hashline-delete-1", { - edits: [{ path: testFile, delete: true }], - } as any); - - expect(getTextOutput(result)).toContain("Deleted"); - expect(fs.existsSync(testFile)).toBe(false); - } finally { - fs.rmSync(hashDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) delete Bun.env.PI_EDIT_VARIANT; - else Bun.env.PI_EDIT_VARIANT = originalEditVariant; - } - }); - - it("should rename file in hashline mode with rename", async () => { - const originalEditVariant = Bun.env.PI_EDIT_VARIANT; - Bun.env.PI_EDIT_VARIANT = "hashline"; - - const hashDir = path.join(os.tmpdir(), `coding-agent-hashline-rename-${Snowflake.next()}`); - fs.mkdirSync(hashDir, { recursive: true }); - const sourceFile = path.join(hashDir, "source.txt"); - const targetFile = path.join(hashDir, "moved", "target.txt"); - fs.writeFileSync(sourceFile, "unchanged content\n"); - - try { - const session = createTestToolSession(hashDir); - const hashlineEditTool = new EditTool(session); - const result = await hashlineEditTool.execute("hashline-rename-1", { - edits: [{ path: sourceFile, move: targetFile }], - } as any); - - expect(getTextOutput(result)).toContain("Moved"); - expect(fs.existsSync(sourceFile)).toBe(false); - expect(fs.existsSync(targetFile)).toBe(true); - expect(await Bun.file(targetFile).text()).toBe("unchanged content\n"); - } finally { - fs.rmSync(hashDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) delete Bun.env.PI_EDIT_VARIANT; - else Bun.env.PI_EDIT_VARIANT = originalEditVariant; - } - }); - - it("should preserve binary bytes when moving in hashline mode", async () => { - const originalEditVariant = Bun.env.PI_EDIT_VARIANT; - Bun.env.PI_EDIT_VARIANT = "hashline"; - - const hashDir = path.join(os.tmpdir(), `coding-agent-hashline-binary-move-${Snowflake.next()}`); - fs.mkdirSync(hashDir, { recursive: true }); - const sourceFile = path.join(hashDir, "image.bin"); - const targetFile = path.join(hashDir, "moved", "image.bin"); - const originalBytes = Buffer.from([0, 255, 13, 10, 137, 80, 78, 71, 0, 1, 2, 3, 127]); - fs.writeFileSync(sourceFile, originalBytes); - - try { - const session = createTestToolSession(hashDir); - const hashlineEditTool = new EditTool(session); - const result = await hashlineEditTool.execute("hashline-rename-binary", { - edits: [{ path: sourceFile, move: targetFile }], - } as any); - - expect(getTextOutput(result)).toContain("Moved"); - expect(fs.existsSync(sourceFile)).toBe(false); - expect(fs.existsSync(targetFile)).toBe(true); - expect(Array.from(fs.readFileSync(targetFile))).toEqual(Array.from(originalBytes)); - } finally { - fs.rmSync(hashDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) delete Bun.env.PI_EDIT_VARIANT; - else Bun.env.PI_EDIT_VARIANT = originalEditVariant; - } - }); - // TODO: CRLF preservation broken by LSP formatting - fix later it.skip("should preserve UTF-8 BOM after edit", async () => { const testFile = path.join(testDir, "bom-test.txt");