From 269f7de48323b85a3f94f82d63b7a74648ed6d84 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 11 Feb 2026 06:47:09 +0100 Subject: [PATCH] feat(coding-agent): redesigned hashline edit API with discriminated unions and improved hash algorithm - Redesigned hashline edit API to use discriminated union variants (replaceLine, replaceLines, insertAfter, insertBefore, substr) instead of nested src/dst structure. - Changed hash algorithm from xxHash64 hex to xxHash32 base36 encoding and increased hash length from 2 to 3 characters. - Added substr edit variant to match and replace content by unique substring when line hashes are unavailable. - Implemented line relocation heuristics to resolve stale line references when hash uniquely identifies a moved line. - Moved substr needle search from edit application phase to pre-validation phase for earlier error detection. - Migrated hashline edit type definitions from manual TypeScript interfaces to schema-derived types using Static pattern. --- packages/coding-agent/CHANGELOG.md | 12 +- packages/coding-agent/src/patch/diff.ts | 3 +- packages/coding-agent/src/patch/hashline.ts | 170 +++++++++------ packages/coding-agent/src/patch/index.ts | 79 +++---- packages/coding-agent/src/patch/shared.ts | 69 ++++--- packages/coding-agent/src/patch/types.ts | 30 +-- .../src/prompts/tools/hashline.md | 57 +++--- .../coding-agent/test/core/hashline.test.ts | 193 ++++++++++-------- packages/react-edit-benchmark/report.ts | 18 ++ packages/react-edit-benchmark/runner.ts | 54 +++++ 10 files changed, 408 insertions(+), 277 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5509e0e14..e945a2049 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,23 +1,25 @@ # Changelog ## [Unreleased] + ### Added -- Added `substr` source specification kind to match and replace lines by unique substring without requiring line-hash references - Added `{{hashline}}` Handlebars helper to compute accurate `LINE:HASH` references for prompt examples and documentation +- Added `substr` edit variant to match and replace by unique substring when line-hash references are unavailable ### Changed +- Changed hashline edit format from `src`/`dst` object structure to direct operation schemas (`replaceLine`, `replaceLines`, `insertAfter`, `insertBefore`, `substr`) +- Changed hash algorithm from 2-character hexadecimal to 3-character base-36 alphanumeric for improved readability and collision resistance +- Improved hash mismatch handling to automatically relocate stale line references when the hash uniquely identifies a moved line +- Changed `HashlineEdit` from `src`/`dst` format to direct operation schemas: `replaceLine`, `replaceLines`, `insertAfter`, `insertBefore`, `substr` - Changed hash algorithm from hexadecimal (base-16) to base-36 alphanumeric for shorter, more readable line references - Increased maximum wrapped-line restoration from 6 to 10 lines to handle longer reflowed statements - Updated prompt examples to use `{{hashline}}` Handlebars helper for generating correct line references in tool instructions -### Removed - -- Removed `substring` source specification kind from hashline edits—use line-hash references instead - ### Fixed +- Fixed substr operation to resolve line numbers during validation phase rather than mutation phase, preventing ambiguity errors from being raised too late - Fixed multi-edit application to use original file state for all anchor references, preventing incorrect line numbers when earlier edits change file length ## [11.10.4] - 2026-02-10 diff --git a/packages/coding-agent/src/patch/diff.ts b/packages/coding-agent/src/patch/diff.ts index b14027437..cdebbdb63 100644 --- a/packages/coding-agent/src/patch/diff.ts +++ b/packages/coding-agent/src/patch/diff.ts @@ -9,8 +9,9 @@ import { resolveToCwd } from "../tools/path-utils"; import { previewPatch } from "./applicator"; import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; import { applyHashlineEdits } from "./hashline"; +import type { HashlineEdit } from "./index"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; -import type { DiffError, DiffResult, HashlineEdit, PatchInput } from "./types"; +import type { DiffError, DiffResult, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 0f9fc52d3..3e5c5c3db 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -2,49 +2,57 @@ * Hashline edit mode — a line-addressable edit format using content hashes. * * Each line in a file is identified by its 1-indexed line number and a short - * hex hash derived from the normalized line content (xxHash64, truncated to 2 - * hex chars). + * base36 hash derived from the normalized line content (xxHash32, truncated to 4 + * base36 chars). * The combined `LINE:HASH` 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| CONTENT` - * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3"`) + * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`) */ -import type { HashlineEdit, HashMismatch, SrcSpec } from "./types"; +import type { HashlineEdit } from "./index"; +import type { HashMismatch } from "./types"; -/** Parsed representation of a {@link SrcSpec} with resolved line references. */ -type ParsedRefs = SrcSpec<{ line: number; hash: string }>; +type ParsedRefs = + | { kind: "single"; ref: { line: number; hash: string } } + | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } } + | { kind: "insertAfter"; after: { line: number; hash: string } } + | { kind: "insertBefore"; before: { line: number; hash: string } } + | { kind: "substr"; needle: string; resolvedLine?: number }; -/** - * Convert a structured {@link SrcSpec} into parsed line references. - * - * Calls {@link parseLineRef} on each string ref field to produce `{ line, hash }`. - * Collapses same-line ranges into single-line specs (models sometimes emit - * `347:aa..347:bb` which hashline mode doesn't support as sub-line addressing). - */ -function parseSrcSpec(src: SrcSpec): ParsedRefs { - if (!("kind" in src)) { - return { kind: "substr", needle: src.needle }; +function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } { + if ("replaceLine" in edit) { + return { + spec: { kind: "single", ref: parseLineRef(edit.replaceLine.loc) }, + dst: edit.replaceLine.content, + }; } - switch (src.kind) { - case "single": - return { kind: "single", ref: parseLineRef(src.ref) }; - case "range": { - const start = parseLineRef(src.start); - const end = parseLineRef(src.end); - // Same-line range collapse: treat as single-line edit - if (start.line === end.line) { - return { kind: "single", ref: start }; - } - return { kind: "range", start, end }; - } - case "insertAfter": - return { kind: "insertAfter", after: parseLineRef(src.after) }; - case "insertBefore": - return { kind: "insertBefore", before: parseLineRef(src.before) }; + if ("replaceLines" in edit) { + const start = parseLineRef(edit.replaceLines.start); + const end = parseLineRef(edit.replaceLines.end); + return { + spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end }, + dst: edit.replaceLines.content, + }; } + if ("insertAfter" in edit) { + return { + spec: { kind: "insertAfter", after: parseLineRef(edit.insertAfter.loc) }, + dst: edit.insertAfter.content, + }; + } + if ("substr" in edit) { + return { + spec: { kind: "substr", needle: edit.substr.needle }, + dst: edit.substr.content, + }; + } + return { + spec: { kind: "insertBefore", before: parseLineRef(edit.insertBefore.loc) }, + dst: edit.insertBefore.content, + }; } /** Split dst into lines; empty string means delete (no lines). */ function splitDstLines(dst: string): string[] { @@ -294,17 +302,17 @@ function stripNewLinePrefixes(lines: string[]): string[] { }); } -const HASH_LEN = 2; +const HASH_LEN = 3; const RADIX = 36; const HASH_MOD = RADIX ** HASH_LEN; const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0")); /** - * Compute a short hex hash of a single line. + * Compute a short base36 hash of a single line. * * Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN} - * hex characters. The `idx` parameter is accepted for compatibility with older + * base36 characters. The `idx` parameter is accepted for compatibility with older * call sites, but is not currently mixed into the hash. * The line input should not include a trailing newline. */ @@ -671,9 +679,9 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: /** * Apply an array of hashline edits to file content. * - * Each edit's `src` is a structured {@link SrcSpec} identifying the target - * lines. Line references are resolved via {@link parseLineRef} and hashes - * validated before any mutation. + * Each edit operation identifies target lines directly (`replaceLine`, `replaceLines`, + * `insertAfter`, `insertBefore`). Line references are resolved via {@link parseLineRef} + * and hashes validated before any mutation. * * Edits are sorted bottom-up (highest effective line first) so earlier * splices don't invalidate later line numbers. @@ -693,10 +701,36 @@ export function applyHashlineEdits( let firstChangedLine: number | undefined; // Parse src specs and dst lines up front - const parsed = edits.map(e => ({ - spec: parseSrcSpec(e.src), - dstLines: stripNewLinePrefixes(splitDstLines(e.dst)), - })); + const parsed = edits.map(edit => { + const parsedEdit = parseHashlineEdit(edit); + return { + spec: parsedEdit.spec, + dstLines: stripNewLinePrefixes(splitDstLines(parsedEdit.dst)), + }; + }); + + // Resolve substr specs to line numbers + for (const p of parsed) { + if (p.spec.kind !== "substr") continue; + const indices: number[] = []; + for (let i = 0; i < fileLines.length; i++) { + if (fileLines[i].includes(p.spec.needle)) indices.push(i); + } + if (indices.length === 0) { + throw new Error(`Substr needle not found in file: "${p.spec.needle}"`); + } + if (indices.length > 1) { + const previews = indices + .slice(0, 5) + .map(i => `${i + 1}: ${fileLines[i]}`) + .join("\n"); + const more = indices.length > 5 ? `\n... (${indices.length - 5} more)` : ""; + throw new Error( + `Substr needle is ambiguous (found ${indices.length} matches): "${p.spec.needle}"\n${previews}${more}`, + ); + } + (p.spec as { resolvedLine?: number }).resolvedLine = indices[0] + 1; + } const explicitlyTouchedLines = new Set(); for (const { spec } of parsed) { @@ -714,13 +748,26 @@ export function applyHashlineEdits( explicitlyTouchedLines.add(spec.before.line); break; case "substr": + explicitlyTouchedLines.add(spec.resolvedLine!); break; } } // Pre-validate: collect all hash mismatches before mutating const mismatches: HashMismatch[] = []; - + const uniqueLineByHash = new Map(); + const seenDuplicateHashes = new Set(); + for (let i = 0; i < fileLines.length; i++) { + const lineNo = i + 1; + const hash = computeLineHash(lineNo, fileLines[i]); + if (seenDuplicateHashes.has(hash)) continue; + if (uniqueLineByHash.has(hash)) { + uniqueLineByHash.delete(hash); + seenDuplicateHashes.add(hash); + continue; + } + uniqueLineByHash.set(hash, lineNo); + } for (const { spec, dstLines } of parsed) { const refsToValidate: { line: number; hash: string }[] = []; switch (spec.kind) { @@ -747,7 +794,7 @@ export function applyHashlineEdits( break; case "substr": if (dstLines.length !== 1) { - throw new Error(`Substr src requires single-line dst (got ${dstLines.length} lines)`); + throw new Error(`Substr edit requires single-line replacement (got ${dstLines.length} lines)`); } break; } @@ -757,9 +804,16 @@ export function applyHashlineEdits( 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.toLowerCase()) { - mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); + if (actualHash === ref.hash.toLowerCase()) { + continue; } + + const relocated = uniqueLineByHash.get(ref.hash.toLowerCase()); + if (relocated !== undefined) { + ref.line = relocated; + continue; + } + mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); } } @@ -789,7 +843,7 @@ export function applyHashlineEdits( precedence = 2; break; case "substr": - sortLine = 0; + sortLine = p.spec.resolvedLine ?? 0; precedence = 3; break; } @@ -870,29 +924,11 @@ export function applyHashlineEdits( break; } case "substr": { - const indices: number[] = []; - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].includes(spec.needle)) indices.push(i); - } - if (indices.length === 0) { - throw new Error(`Substr src not found in file: "${spec.needle}"`); - } - if (indices.length > 1) { - const previews = indices - .slice(0, 5) - .map(i => `${i + 1}: ${fileLines[i]}`) - .join("\n"); - const more = indices.length > 5 ? `\n... (${indices.length - 5} more)` : ""; - throw new Error( - `Substr src is ambiguous (found ${indices.length} matches): "${spec.needle}"\n${previews}${more}`, - ); - } - - const lineIdx = indices[0]; + const lineIdx = spec.resolvedLine! - 1; const original = fileLines[lineIdx]; const replaced = original.replace(spec.needle, dstLines[0]); fileLines.splice(lineIdx, 1, replaced); - trackFirstChanged(lineIdx + 1); + trackFirstChanged(spec.resolvedLine!); break; } } diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 05bffdc19..284b14df7 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -11,7 +11,7 @@ import * as fs from "node:fs/promises"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; -import { Type } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import { createLspWritethrough, @@ -34,7 +34,7 @@ import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from ". import { buildNormativeUpdateInput } from "./normative"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; // Internal imports -import type { FileSystem, HashlineEdit, Operation, PatchInput } from "./types"; +import type { FileSystem, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -88,15 +88,12 @@ export type { FileSystem, FuzzyMatch as EditMatch, FuzzyMatch, - HashlineEdit, - HashlineInput, HashMismatch, MatchOutcome as EditMatchOutcome, MatchOutcome, Operation, PatchInput, SequenceSearchResult, - SrcSpec, } from "./types"; // Types // Legacy aliases for backwards compatibility @@ -124,44 +121,56 @@ const patchEditSchema = Type.Object({ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); -export type ReplaceParams = { path: string; old_text: string; new_text: string; all?: boolean }; -export type PatchParams = { path: string; op?: string; rename?: string; diff?: string }; -export type HashlineParams = { path: string; edits: HashlineEdit[] }; +export type ReplaceParams = Static; +export type PatchParams = Static; -const srcSpecSchema = Type.Union([ - Type.Object({ - kind: Type.Literal("single"), - ref: Type.String({ description: 'Line reference "LINE:HASH"' }), +const hashlineReplaceLineSchema = Type.Object({ + replaceLine: Type.Object({ + loc: Type.String({ description: 'Line reference "LINE:HASH"' }), + content: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }), }), - Type.Object({ - kind: Type.Literal("range"), - start: Type.String({ description: 'Start line ref "LINE:HASH"' }), - end: Type.String({ description: 'End line ref "LINE:HASH"' }), - }), - Type.Object({ - kind: Type.Literal("insertAfter"), - after: Type.String({ description: 'Insert after this line "LINE:HASH"' }), - }), - Type.Object({ - kind: Type.Literal("insertBefore"), - before: Type.String({ description: 'Insert before this line "LINE:HASH"' }), - }), - Type.Object({ - type: Type.Literal("substr"), - needle: Type.String({ description: "Unique substring to match" }), - }), -]); - -const hashlineEditItemSchema = Type.Object({ - src: srcSpecSchema, - dst: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }), }); +const hashlineReplaceLinesSchema = Type.Object({ + replaceLines: Type.Object({ + start: Type.String({ description: 'Start line ref "LINE:HASH"' }), + end: Type.String({ description: 'End line ref "LINE:HASH"' }), + content: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }), + }), +}); +const hashlineInsertAfterSchema = Type.Object({ + insertAfter: Type.Object({ + loc: Type.String({ description: 'Insert after this line "LINE:HASH"' }), + content: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }), + }), +}); +const hashlineInsertBeforeSchema = Type.Object({ + insertBefore: Type.Object({ + loc: Type.String({ description: 'Insert before this line "LINE:HASH"' }), + content: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }), + }), +}); +const hashlineSubstrSchema = Type.Object({ + substr: Type.Object({ + needle: Type.String({ description: "Unique substring to find in the target line" }), + content: Type.String({ description: "Replacement for the needle (single-line only)" }), + }), +}); +const hashlineEditItemSchema = Type.Union([ + hashlineReplaceLineSchema, + hashlineReplaceLinesSchema, + hashlineInsertAfterSchema, + hashlineInsertBeforeSchema, + hashlineSubstrSchema, +]); const hashlineEditSchema = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }), }); +export type HashlineEdit = Static; +export type HashlineParams = Static; + // ═══════════════════════════════════════════════════════════════════════════ // LSP FileSystem for patch mode // ═══════════════════════════════════════════════════════════════════════════ @@ -408,9 +417,7 @@ export class EditTool implements AgentTool { const { bom, text: content } = stripBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); - const result = applyHashlineEdits(normalizedContent, edits); - if (normalizedContent === result.content) { throw new Error(`No changes made to ${path}. The edits produced identical content.`); } diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 062597f68..d3ac7568c 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -21,7 +21,7 @@ import { } from "../tools/render-utils"; import type { RenderCallOptions } from "../tools/renderers"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; -import type { DiffError, DiffResult, HashlineEdit, Operation } from "./types"; +import type { DiffError, DiffResult, Operation } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // LSP Batching @@ -82,9 +82,16 @@ interface EditRenderArgs { */ previewDiff?: string; // Hashline mode fields - edits?: HashlineEdit[]; + edits?: HashlineEditPreview[]; } +type HashlineEditPreview = + | { replaceLine: { loc: string; content: string } } + | { replaceLines: { start: string; end: string; content: string } } + | { insertAfter: { loc: string; content: string } } + | { insertBefore: { loc: string; content: string } } + | { substr: { needle: string; content: string } }; + /** Extended context for edit tool rendering */ export interface EditRenderContext { /** Pre-computed diff preview (computed before tool executes) */ @@ -115,32 +122,26 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe return text; } -function formatStreamingHashlineEdits(edits: HashlineEdit[], uiTheme: Theme, ui: ToolUIKit): string { +function formatStreamingHashlineEdits(edits: HashlineEditPreview[], uiTheme: Theme, ui: ToolUIKit): string { const MAX_EDITS = 4; const MAX_DST_LINES = 8; - let text = "\n\n"; text += uiTheme.fg("dim", `[${edits.length} hashline edit${edits.length === 1 ? "" : "s"}]`); text += "\n"; - let shownEdits = 0; let shownDstLines = 0; - for (const edit of edits) { shownEdits++; if (shownEdits > MAX_EDITS) break; - - text += uiTheme.fg("toolOutput", ui.truncate(replaceTabs(formatHashlineSrc(edit.src)), 120)); + const formatted = formatHashlineEdit(edit); + text += uiTheme.fg("toolOutput", ui.truncate(replaceTabs(formatted.srcLabel), 120)); text += "\n"; - - if (edit.dst === "") { + if (formatted.dst === "") { text += uiTheme.fg("dim", ui.truncate(" (delete)", 120)); text += "\n"; continue; } - - const dstLines = edit.dst.split("\n"); - for (const dstLine of dstLines) { + for (const dstLine of formatted.dst.split("\n")) { shownDstLines++; if (shownDstLines > MAX_DST_LINES) break; text += uiTheme.fg("toolOutput", ui.truncate(replaceTabs(`+ ${dstLine}`), 120)); @@ -148,7 +149,6 @@ function formatStreamingHashlineEdits(edits: HashlineEdit[], uiTheme: Theme, ui: } if (shownDstLines > MAX_DST_LINES) break; } - if (edits.length > MAX_EDITS) { text += uiTheme.fg("dim", `… (${edits.length - MAX_EDITS} more edits)`); } @@ -157,24 +157,37 @@ function formatStreamingHashlineEdits(edits: HashlineEdit[], uiTheme: Theme, ui: } return text.trimEnd(); - - function formatHashlineSrc(src: HashlineEdit["src"]): string { - if (!("kind" in src)) { - return `• substr ${src.needle}`; + function formatHashlineEdit(edit: HashlineEditPreview): { srcLabel: string; dst: string } { + if ("replaceLine" in edit) { + return { + srcLabel: `• replaceLine ${edit.replaceLine.loc}`, + dst: edit.replaceLine.content, + }; } - switch (src.kind) { - case "single": - return `• single ${src.ref}`; - case "range": - return `• range ${src.start}..${src.end}`; - case "insertAfter": - return `• insertAfter ${src.after}..`; - case "insertBefore": - return `• insertBefore ..${src.before}`; + if ("replaceLines" in edit) { + return { + srcLabel: `• replaceLines ${edit.replaceLines.start}..${edit.replaceLines.end}`, + dst: edit.replaceLines.content, + }; } + if ("insertAfter" in edit) { + return { + srcLabel: `• insertAfter ${edit.insertAfter.loc}..`, + dst: edit.insertAfter.content, + }; + } + if ("substr" in edit) { + return { + srcLabel: `• substr "${edit.substr.needle}"`, + dst: edit.substr.content, + }; + } + return { + srcLabel: `• insertBefore ..${edit.insertBefore.loc}`, + dst: edit.insertBefore.content, + }; } } - function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string { const icon = uiTheme.getLangIcon(language); if (lineCount !== null) { diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index 75d227642..d612c3ff7 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -195,32 +195,10 @@ export interface DiffError { // Hashline Types // ═══════════════════════════════════════════════════════════════════════════ -/** A single line reference in `"LINE:HASH"` format (e.g. `"5:a3f2"`). */ -export type LineRef = string; - -/** Structured source specification for a hashline edit operation. */ -export type SrcSpec = - | { kind: "single"; ref: L } - | { kind: "range"; start: L; end: L } - | { kind: "insertAfter"; after: L } - | { kind: "insertBefore"; before: L } - | { type: "substr"; needle: string }; - -/** A single edit operation in hashline mode. */ -export interface HashlineEdit { - /** Structured source specification identifying which lines to target. */ - src: SrcSpec; - /** Replacement content (`\n`-separated) — `""` for delete */ - dst: string; -} - -/** Input for a hashline edit operation */ -export interface HashlineInput { - /** File path (relative or absolute) */ - path: string; - /** Array of edit operations */ - edits: HashlineEdit[]; -} +/** + * Hashline edit operation/input types are schema-derived in `patch/index.ts` + * via `Static` and `Static`. + */ /** A single hash mismatch found during validation */ export interface HashMismatch { diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 9e6387a56..edbc112f5 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -4,62 +4,55 @@ Line-addressed edits using hash-verified line references. Read file with hashes - Copy `LINE:HASH` refs verbatim from read output — never fabricate or guess hashes -- `dst` contains plain content lines only — no `LINE:HASH|` prefix, no diff `+` markers -- On hash mismatch: the error shows correct `LINE:HASH` refs with `>>>` markers — use those directly -- Edit only the lines that need to change. Do not reformat, restyle, adjust whitespace, change brace spacing, or break/join lines you were not asked to modify. +- `content` contains plain replacement lines only — no `LINE:HASH|` prefix, no diff `+` markers +- On hash mismatch: use the updated `LINE:HASH` refs shown by `>>>` directly; only `read` again if you need additional lines/context +- If you already edited a file in this turn, re-read that file before the next edit to it +- For code-change requests, respond with tool calls, not prose +- Edit only requested lines. Do not reformat unrelated code. **Workflow:** -1. Read target file (output includes `LINE:HASH| content` on every line) -2. Identify lines to change by their `LINE:HASH` prefix -3. Submit edit with `src` (line reference) and `dst` (replacement content) -**Operations:** -- **Replace single**: `src: { kind: "single", ref: "{{hashline 5 'old_value = True'}}" }` — replaces line 5 -- **Replace range**: `src: { kind: "range", start: "{{hashline 5 'old_value = True'}}", end: "{{hashline 9 'return result'}}" }` — replaces lines 5-9 (fewer dst lines = net deletion) -- **Delete range**: same as replace range with `dst: ""` -- **Insert after**: `src: { kind: "insertAfter", after: "{{hashline 5 'old_value = True'}}" }` — inserts after line 5 -- **Insert before**: `src: { kind: "insertBefore", before: "{{hashline 5 'old_value = True'}}" }` — inserts before line 5 +1. Read target file (`read` with `hashes: true`) +2. Collect the exact `LINE:HASH` refs you need +3. Submit one `edit` call with all known operations for that file +4. If another change on same file is needed later: re-read first, then edit -Multiple edits in one call are applied bottom-up (safe for non-overlapping edits). -**`dst` rules — get this right:** -- Write only the new content — no `LINE:HASH| ` prefixes, no `+` diff markers. -- Preserve the original indentation of surrounding code. -- Do not echo anchor/boundary lines into `dst` (for insertAfter, write only the new lines — not the anchor line followed by new lines). +**Edit variants:** +- `{ replaceLine: { loc: "LINE:HASH", content: "..." } }` +- `{ replaceLines: { start: "LINE:HASH", end: "LINE:HASH", content: "..." } }` +- `{ insertAfter: { loc: "LINE:HASH", content: "..." } }` +- `{ insertBefore: { loc: "LINE:HASH", content: "..." } }` +- `{ substr: { needle: "unique substring", content: "..." } }` — use when line hashes unavailable; needle must match exactly one line + +`content: ""` means delete (for `replaceLine`/`replaceLines`). - `path`: File path -- `edits`: Array of edit operations - - `src`: Source spec — one of: - - `{ kind: "single", ref: "LINE:HASH" }` - - `{ kind: "range", start: "LINE:HASH", end: "LINE:HASH" }` - - `{ kind: "insertAfter", after: "LINE:HASH" }` - - `{ kind: "insertBefore", before: "LINE:HASH" }` - - `{ type: "substr", needle: "text" }` - - `dst`: Replacement content (`\n`-separated for multi-line, `""` for delete) +- `edits`: Array of edit operations (one of the variants above) -edit {"path":"src/app.py","edits":[{"src":{"kind":"single","ref":"{{hashline 2 'x = 42'}}"},"dst":" x = 99"}]} +edit {"path":"src/app.py","edits":[{"replaceLine":{"loc":"{{hashline 2 'x = 42'}}","content":" x = 99"}}]} -edit {"path":"src/app.py","edits":[{"src":{"kind":"range","start":"{{hashline 5 'old_value = True'}}","end":"{{hashline 8 'return result'}}"},"dst":" combined = True"}]} +edit {"path":"src/app.py","edits":[{"replaceLines":{"start":"{{hashline 5 'old_value = True'}}","end":"{{hashline 8 'return result'}}","content":" combined = True"}}]} -edit {"path":"src/app.py","edits":[{"src":{"kind":"range","start":"{{hashline 5 'old_value = True'}}","end":"{{hashline 6 'unused = None'}}"},"dst":""}]} +edit {"path":"src/app.py","edits":[{"replaceLines":{"start":"{{hashline 5 'old_value = True'}}","end":"{{hashline 6 'unused = None'}}","content":""}}]} -edit {"path":"src/app.py","edits":[{"src":{"kind":"insertAfter","after":"{{hashline 3 'def hello'}}"},"dst":" # new comment"}]} +edit {"path":"src/app.py","edits":[{"insertAfter":{"loc":"{{hashline 3 'def hello'}}","content":" # new comment"}}]} -edit {"path":"src/app.py","edits":[{"src":{"kind":"insertBefore","before":"{{hashline 3 'def hello'}}"},"dst":" # new comment"}]} +edit {"path":"src/app.py","edits":[{"insertBefore":{"loc":"{{hashline 3 'def hello'}}","content":" # new comment"}}]} -edit {"path":"src/app.py","edits":[{"src":{"kind":"single","ref":"{{hashline 10 'return True'}}"},"dst":" return False"},{"src":{"kind":"single","ref":"{{hashline 3 'def hello'}}"},"dst":" x = 42"}]} - \ No newline at end of file +edit {"path":"src/app.py","edits":[{"replaceLine":{"loc":"{{hashline 10 'return True'}}","content":" return False"}},{"replaceLine":{"loc":"{{hashline 3 'def hello'}}","content":" x = 42"}}]} + diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index ffd9902ad..d8ba5a86d 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -232,7 +232,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(2, "bbb") }, dst: "BBB" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(2, "bbb"), content: "BBB" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); @@ -242,7 +242,7 @@ describe("applyHashlineEdits — replace", () => { test("range replace (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { src: { kind: "range", start: makeRef(2, "bbb"), end: makeRef(3, "ccc") }, dst: "ONE" }, + { replaceLines: { start: makeRef(2, "bbb"), end: makeRef(3, "ccc"), content: "ONE" } }, ]; const result = applyHashlineEdits(content, edits); @@ -252,7 +252,7 @@ describe("applyHashlineEdits — replace", () => { test("range replace (same count)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { src: { kind: "range", start: makeRef(2, "bbb"), end: makeRef(3, "ccc") }, dst: "XXX\nYYY" }, + { replaceLines: { start: makeRef(2, "bbb"), end: makeRef(3, "ccc"), content: "XXX\nYYY" } }, ]; const result = applyHashlineEdits(content, edits); @@ -262,7 +262,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces first line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(1, "first") }, dst: "FIRST" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(1, "first"), content: "FIRST" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("FIRST\nsecond\nthird"); @@ -271,7 +271,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces last line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(3, "third") }, dst: "THIRD" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(3, "third"), content: "THIRD" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("first\nsecond\nTHIRD"); @@ -290,7 +290,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(2, "bbb") }, dst: "" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(2, "bbb"), content: "" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nccc"); @@ -300,7 +300,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes range of lines", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { src: { kind: "range", start: makeRef(2, "bbb"), end: makeRef(3, "ccc") }, dst: "" }, + { replaceLines: { start: makeRef(2, "bbb"), end: makeRef(3, "ccc"), content: "" } }, ]; const result = applyHashlineEdits(content, edits); @@ -309,7 +309,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes first line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(1, "aaa") }, dst: "" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(1, "aaa"), content: "" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("bbb\nccc"); @@ -317,7 +317,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes last line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: makeRef(3, "ccc") }, dst: "" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: makeRef(3, "ccc"), content: "" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb"); @@ -335,7 +335,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts after a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "insertAfter", after: makeRef(1, "aaa") }, dst: "NEW" }]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(1, "aaa"), content: "NEW" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); @@ -344,7 +344,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts multiple lines", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "insertAfter", after: makeRef(1, "aaa") }, dst: "x\ny\nz" }]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(1, "aaa"), content: "x\ny\nz" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); @@ -352,7 +352,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts after last line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "insertAfter", after: makeRef(2, "bbb") }, dst: "NEW" }]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(2, "bbb"), content: "NEW" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW"); @@ -360,14 +360,14 @@ describe("applyHashlineEdits — insert", () => { test("insert with empty dst throws", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "insertAfter", after: makeRef(1, "aaa") }, dst: "" }]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(1, "aaa"), content: "" } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); test("inserts before a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "insertBefore", before: makeRef(2, "bbb") }, dst: "NEW" }]; + const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(2, "bbb"), content: "NEW" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); @@ -376,13 +376,14 @@ describe("applyHashlineEdits — insert", () => { test("inserts before first line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "insertBefore", before: makeRef(1, "aaa") }, dst: "TOP" }]; + const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(1, "aaa"), content: "TOP" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("TOP\naaa\nbbb"); expect(result.firstChangedLine).toBe(1); }); }); + // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — heuristics // ═══════════════════════════════════════════════════════════════════════════ @@ -394,12 +395,7 @@ describe("applyHashlineEdits — heuristics", () => { test("strips insert-after anchor echo", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [ - { - src: { kind: "insertAfter", after: makeRef(2, "bbb") }, - dst: "bbb\nNEW", - }, - ]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(2, "bbb"), content: "bbb\nNEW" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW\nccc"); @@ -407,12 +403,7 @@ describe("applyHashlineEdits — heuristics", () => { test("strips insert-before anchor echo", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [ - { - src: { kind: "insertBefore", before: makeRef(2, "bbb") }, - dst: "NEW\nbbb", - }, - ]; + const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(2, "bbb"), content: "NEW\nbbb" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); @@ -433,9 +424,12 @@ describe("applyHashlineEdits — heuristics", () => { const end = 6; const edits: HashlineEdit[] = [ { - src: { kind: "range", start: makeRef(start, "if (cond) {"), end: makeRef(end, "}") }, - // Echoes line after the range ("after();") and also reformats the import line. - dst: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"].join("\n"), + replaceLines: { + start: makeRef(start, "if (cond) {"), + end: makeRef(end, "}"), + // Echoes line after the range ("after();") and also reformats the import line. + content: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"].join("\n"), + }, }, ]; @@ -450,12 +444,11 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - src: { - kind: "range", + replaceLines: { start: makeRef(1, "import { foo } from 'x';"), end: makeRef(2, "import { bar } from 'y';"), + content: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"].join("\n"), }, - dst: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"].join("\n"), }, ]; @@ -473,19 +466,21 @@ describe("applyHashlineEdits — heuristics", () => { const content = ["before();", longLine, "after();"].join("\n"); const edits: HashlineEdit[] = [ { - src: { kind: "single", ref: makeRef(2, longLine) }, - dst: [ - "const", - "options", - "=", - "veryLongIdentifier", - "+", - "anotherLongIdentifier", - "+", - "thirdLongIdentifier", - "+", - "fourthLongIdentifier;", - ].join("\n"), + replaceLine: { + loc: makeRef(2, longLine), + content: [ + "const", + "options", + "=", + "veryLongIdentifier", + "+", + "anotherLongIdentifier", + "+", + "thirdLongIdentifier", + "+", + "fourthLongIdentifier;", + ].join("\n"), + }, }, ]; @@ -500,9 +495,11 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - src: { kind: "single", ref: makeRef(1, " typeof HOOK === 'undefined' &&") }, - // Model merged both lines into one and dropped indentation. - dst: "typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'", + replaceLine: { + loc: makeRef(1, " typeof HOOK === 'undefined' &&"), + // Model merged both lines into one and dropped indentation. + content: "typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'", + }, }, ]; @@ -521,9 +518,11 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - src: { kind: "single", ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;") }, - // Model absorbed the declaration line and dropped indentation. - dst: "const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;", + replaceLine: { + loc: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"), + // Model absorbed the declaration line and dropped indentation. + content: "const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;", + }, }, ]; @@ -541,8 +540,10 @@ describe("applyHashlineEdits — heuristics", () => { const srcHash = computeLineHash(2, "bbb"); const edits: HashlineEdit[] = [ { - src: { kind: "single", ref: `2:${srcHash}export function foo(a, b) {}` }, // comma in trailing content - dst: "BBB", + replaceLine: { + loc: `2:${srcHash}export function foo(a, b) {}`, // comma in trailing content + content: "BBB", + }, }, ]; @@ -550,12 +551,14 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe("aaa\nBBB\nccc"); }); - test("supports substr src when it matches exactly one line", () => { + test("substr replaces needle with content when it matches exactly one line", () => { const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc"; const edits: HashlineEdit[] = [ { - src: { type: "substr", needle: "devtools–unsupported-bridge-protocol" }, - dst: "devtools-unsupported-bridge-protocol", + substr: { + needle: "devtools–unsupported-bridge-protocol", + content: "devtools-unsupported-bridge-protocol", + }, }, ]; @@ -566,7 +569,7 @@ describe("applyHashlineEdits — heuristics", () => { test("treats same-line ranges as single-line replacements", () => { const content = "aaa\nbbb\nccc"; const good = makeRef(2, "bbb"); - const edits: HashlineEdit[] = [{ src: { kind: "range", start: good, end: "2:00" }, dst: "BBB" }]; + const edits: HashlineEdit[] = [{ replaceLines: { start: good, end: "2:00", content: "BBB" } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); }); @@ -576,8 +579,10 @@ describe("applyHashlineEdits — heuristics", () => { // dst is byte-identical to original (en-dash), so this would normally be a no-op. const edits: HashlineEdit[] = [ { - src: { kind: "single", ref: makeRef(2, "devtools–unsupported-bridge-protocol") }, - dst: "devtools–unsupported-bridge-protocol", + replaceLine: { + loc: makeRef(2, "devtools–unsupported-bridge-protocol"), + content: "devtools–unsupported-bridge-protocol", + }, }, ]; @@ -598,8 +603,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { src: { kind: "single", ref: makeRef(2, "bbb") }, dst: "BBB" }, - { src: { kind: "single", ref: makeRef(4, "ddd") }, dst: "DDD" }, + { replaceLine: { loc: makeRef(2, "bbb"), content: "BBB" } }, + { replaceLine: { loc: makeRef(4, "ddd"), content: "DDD" } }, ]; const result = applyHashlineEdits(content, edits); @@ -610,8 +615,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { src: { kind: "single", ref: makeRef(2, "bbb") }, dst: "BBB" }, - { src: { kind: "single", ref: makeRef(4, "ddd") }, dst: "" }, + { replaceLine: { loc: makeRef(2, "bbb"), content: "BBB" } }, + { replaceLine: { loc: makeRef(4, "ddd"), content: "" } }, ]; const result = applyHashlineEdits(content, edits); @@ -621,8 +626,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + insert in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { src: { kind: "single", ref: makeRef(3, "ccc") }, dst: "CCC" }, - { src: { kind: "insertAfter", after: makeRef(1, "aaa") }, dst: "INSERTED" }, + { replaceLine: { loc: makeRef(3, "ccc"), content: "CCC" } }, + { insertAfter: { loc: makeRef(1, "aaa"), content: "INSERTED" } }, ]; const result = applyHashlineEdits(content, edits); @@ -632,8 +637,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies non-overlapping edits against original anchors when line counts change", () => { const content = "one\ntwo\nthree\nfour\nfive\nsix"; const edits: HashlineEdit[] = [ - { src: { kind: "range", start: makeRef(2, "two"), end: makeRef(3, "three") }, dst: "TWO_THREE" }, - { src: { kind: "single", ref: makeRef(6, "six") }, dst: "SIX" }, + { replaceLines: { start: makeRef(2, "two"), end: makeRef(3, "three"), content: "TWO_THREE" } }, + { replaceLine: { loc: makeRef(6, "six"), content: "SIX" } }, ]; const result = applyHashlineEdits(content, edits); @@ -659,14 +664,14 @@ describe("applyHashlineEdits — errors", () => { test("rejects stale hash", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: "2:00" }, dst: "BBB" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: "2:00", content: "BBB" } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); test("stale hash error shows >>> markers with correct hashes", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: "2:00" }, dst: "BBB" }]; + const edits: HashlineEdit[] = [{ replaceLine: { loc: "2:00", content: "BBB" } }]; try { applyHashlineEdits(content, edits); @@ -689,8 +694,8 @@ describe("applyHashlineEdits — errors", () => { test("stale hash error collects all mismatches", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { src: { kind: "single", ref: "2:00" }, dst: "BBB" }, - { src: { kind: "single", ref: "4:00" }, dst: "DDD" }, + { replaceLine: { loc: "2:00", content: "BBB" } }, + { replaceLine: { loc: "4:00", content: "DDD" } }, ]; try { @@ -708,24 +713,48 @@ describe("applyHashlineEdits — errors", () => { } }); - test("rejects out-of-range line", () => { - const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "single", ref: "10:aa" }, dst: "X" }]; + test("relocates stale line refs when hash uniquely identifies a moved line", () => { + const content = "aaa\nbbb\nccc"; + const staleButUnique = `2:${computeLineHash(1, "ccc")}`; + const edits: HashlineEdit[] = [{ replaceLine: { loc: staleButUnique, content: "CCC" } }]; - expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nbbb\nCCC"); }); - test("rejects substr src when not found", () => { - const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { type: "substr", needle: "garbage" }, dst: "X" }]; + test("does not relocate when expected hash is non-unique", () => { + const content = "dup\nmid\ndup"; + const staleDuplicate = `2:${computeLineHash(1, "dup")}`; + const edits: HashlineEdit[] = [{ replaceLine: { loc: staleDuplicate, content: "DUP" } }]; - expect(() => applyHashlineEdits(content, edits)).toThrow(/Substr src not found/); + expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); + }); + + test("rejects substr when needle not found", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ substr: { needle: "garbage", content: "X" } }]; + + expect(() => applyHashlineEdits(content, edits)).toThrow(/Substr needle not found/); + }); + + test("rejects substr when needle matches multiple lines", () => { + const content = "aaa\ndup\nmid\ndup"; + const edits: HashlineEdit[] = [{ substr: { needle: "dup", content: "X" } }]; + + expect(() => applyHashlineEdits(content, edits)).toThrow(/Substr needle is ambiguous/); + }); + + test("rejects out-of-range line", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ replaceLine: { loc: "10:aa", content: "X" } }]; + + expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); test("rejects range with start > end", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { src: { kind: "range", start: makeRef(5, "eee"), end: makeRef(2, "bbb") }, dst: "X" }, + { replaceLines: { start: makeRef(5, "eee"), end: makeRef(2, "bbb"), content: "X" } }, ]; expect(() => applyHashlineEdits(content, edits)).toThrow(); @@ -733,7 +762,7 @@ describe("applyHashlineEdits — errors", () => { test("rejects insert-after with empty dst", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: { kind: "insertAfter", after: makeRef(1, "aaa") }, dst: "" }]; + const edits: HashlineEdit[] = [{ insertAfter: { loc: makeRef(1, "aaa"), content: "" } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); diff --git a/packages/react-edit-benchmark/report.ts b/packages/react-edit-benchmark/report.ts index 9bac458f1..10a985260 100644 --- a/packages/react-edit-benchmark/report.ts +++ b/packages/react-edit-benchmark/report.ts @@ -164,6 +164,24 @@ export function generateReport(result: BenchmarkResult): string { lines.push(`| **Avg Indent Score** | — | **${formatScore(summary.avgIndentScore)}** |`); lines.push(""); + if (summary.hashlineEditSubtypes) { + const total = Object.values(summary.hashlineEditSubtypes).reduce((a, b) => a + b, 0); + if (total > 0) { + lines.push("### Hashline Edit Subtypes"); + lines.push(""); + lines.push("| Operation | Count | % |"); + lines.push("|-----------|-------|---|"); + const order = ["replaceLine", "replaceLines", "insertAfter", "insertBefore", "substr"]; + for (const key of order) { + const count = summary.hashlineEditSubtypes[key] ?? 0; + const pct = formatPercent(count / total); + lines.push(`| ${key} | ${count} | ${pct} |`); + } + lines.push(`| **Total** | **${total}** | 100% |`); + lines.push(""); + } + } + lines.push("## Task Results"); lines.push(""); lines.push("| Task | File | Success | Edit Hit | R/E/W | Tokens (In/Out) | Time | Indent |"); diff --git a/packages/react-edit-benchmark/runner.ts b/packages/react-edit-benchmark/runner.ts index 55e5804b7..90ba45faf 100644 --- a/packages/react-edit-benchmark/runner.ts +++ b/packages/react-edit-benchmark/runner.ts @@ -50,6 +50,25 @@ function getEditPathFromArgs(args: unknown): string | null { return typeof pathValue === "string" && pathValue.length > 0 ? pathValue : null; } +const HASHLINE_SUBTYPES = ["replaceLine", "replaceLines", "insertAfter", "insertBefore", "substr"] as const; + +function countHashlineEditSubtypes(args: unknown): Record { + const counts: Record = Object.fromEntries(HASHLINE_SUBTYPES.map((k) => [k, 0])); + if (!args || typeof args !== "object") return counts; + const edits = (args as { edits?: unknown[] }).edits; + if (!Array.isArray(edits)) return counts; + for (const edit of edits) { + if (!edit || typeof edit !== "object") continue; + for (const key of HASHLINE_SUBTYPES) { + if (key in edit) { + counts[key]++; + break; + } + } + } + return counts; +} + async function collectOriginalFileContents(cwd: string, files: string[]): Promise> { const originals = new Map(); for (const file of files) { @@ -296,6 +315,8 @@ export interface TaskRunResult { diff?: string; toolCalls: ToolCallStats; editFailures: EditFailure[]; + /** Hashline edit subtype counts (replaceLine, replaceLines, etc.) — only when editVariant is hashline */ + hashlineEditSubtypes?: Record; } export interface ProgressEvent { @@ -333,6 +354,8 @@ export interface BenchmarkSummary { totalToolCalls: ToolCallStats; avgToolCallsPerRun: ToolCallStats; editSuccessRate: number; + /** Hashline edit subtype totals — only when editVariant is hashline */ + hashlineEditSubtypes?: Record; } export interface BenchmarkResult { @@ -412,6 +435,9 @@ async function runSingleTask( editFailures: 0, totalInputChars: 0, }; + const hashlineSubtypes: Record = Object.fromEntries( + HASHLINE_SUBTYPES.map((k) => [k, 0]), + ); const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`); const logEvent = async (event: unknown) => { @@ -504,6 +530,12 @@ async function runSingleTask( if (e.toolName === "edit" && e.toolCallId && pendingEdits.has(e.toolCallId)) { const args = pendingEdits.get(e.toolCallId) ?? null; pendingEdits.delete(e.toolCallId); + if (config.editVariant === "hashline" && args) { + const counts = countHashlineEditSubtypes(args); + for (const key of HASHLINE_SUBTYPES) { + hashlineSubtypes[key] += counts[key]; + } + } if (e.isError) { toolStats.editFailures++; const error = await appendNoChangeMutationHint(extractToolErrorMessage(e.result), args, cwd, originalFiles); @@ -583,6 +615,7 @@ async function runSingleTask( diff, toolCalls: toolStats, editFailures, + hashlineEditSubtypes: config.editVariant === "hashline" ? hashlineSubtypes : undefined, }; } @@ -614,6 +647,9 @@ async function runBatchedTask( editFailures: 0, totalInputChars: 0, }; + const hashlineSubtypes: Record = Object.fromEntries( + HASHLINE_SUBTYPES.map((k) => [k, 0]), + ); const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`); const logEvent = async (event: unknown) => { @@ -677,6 +713,12 @@ async function runBatchedTask( if (e.toolName === "edit" && e.toolCallId && pendingEdits.has(e.toolCallId)) { const args = pendingEdits.get(e.toolCallId) ?? null; pendingEdits.delete(e.toolCallId); + if (config.editVariant === "hashline" && args) { + const counts = countHashlineEditSubtypes(args); + for (const key of HASHLINE_SUBTYPES) { + hashlineSubtypes[key] += counts[key]; + } + } if (e.isError) { toolStats.editFailures++; const toolError = await appendNoChangeMutationHint( @@ -755,6 +797,7 @@ async function runBatchedTask( diff, toolCalls: toolStats, editFailures, + hashlineEditSubtypes: config.editVariant === "hashline" ? hashlineSubtypes : undefined, }; } @@ -1164,6 +1207,16 @@ export async function runBenchmark( const editSuccessRate = totalToolCalls.edit > 0 ? totalToolCalls.editSuccesses / totalToolCalls.edit : 1; + const hashlineEditSubtypes: Record | undefined = + config.editVariant === "hashline" + ? Object.fromEntries( + HASHLINE_SUBTYPES.map((key) => [ + key, + allRuns.reduce((sum, r) => sum + (r.hashlineEditSubtypes?.[key] ?? 0), 0), + ]), + ) + : undefined; + const summary: BenchmarkSummary = { totalTasks: tasks.length, totalRuns, @@ -1190,6 +1243,7 @@ export async function runBenchmark( totalInputChars: totalToolCalls.totalInputChars / totalRuns, }, editSuccessRate, + hashlineEditSubtypes, }; return {