diff --git a/.omp/skills/system-prompts/SKILL.md b/.omp/skills/system-prompts/SKILL.md index 0391bc87d..57dad37de 100644 --- a/.omp/skills/system-prompts/SKILL.md +++ b/.omp/skills/system-prompts/SKILL.md @@ -379,7 +379,6 @@ Output final answer after ####. When using: 3-5 diverse examples covering edge cases. ```xml - Input: X Output: Y @@ -389,7 +388,6 @@ Output: Y Input: X' Output: Y' - ``` ### Long Context Handling diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 702743880..3d7a24f8f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Removed automatic line relocation when hash references become stale; edits with mismatched line hashes now fail with an error instead of silently relocating to matching lines elsewhere in the file @@ -17,11 +16,18 @@ ### Changed +- Updated hashline reference format from `LINE:HASH` to `LINE#ID` throughout the codebase for improved clarity +- Renamed hashline edit operations: `set_line` → `set`, `replace_lines` → `set_range`, `insert_after` → `insert` with support for `before` and `between` anchors +- Changed hashline edit `body` field from string to array of strings for clearer multiline handling +- Updated handlebars helpers: renamed `hashline` to `hlineref` and added `hlinefull` for formatted line output +- Improved insert operation to support `before`, `after`, and `between` (both anchors) positioning modes +- Made autocorrect heuristics (boundary echo stripping, indent restoration) conditional on `PI_HL_AUTOCORRECT` environment variable - Updated SSH host discovery to load from managed omp config paths (.omp/ssh.json and ~/.omp/agent/ssh.json) in addition to legacy root-level ssh.json and .ssh.json files - Improved terminal output handling in interactive bash sessions to ensure all queued writes complete before returning results ### Fixed +- Fixed insert-between operation to properly validate adjacent anchor lines and strip boundary echoes from both sides - Fixed terminal output handling to properly queue and serialize writes, preventing dropped or corrupted output in interactive bash sessions ## [12.12.1] - 2026-02-19 diff --git a/packages/coding-agent/src/commit/prompts/analysis-system.md b/packages/coding-agent/src/commit/prompts/analysis-system.md index 9048bec9e..9acaab365 100644 --- a/packages/coding-agent/src/commit/prompts/analysis-system.md +++ b/packages/coding-agent/src/commit/prompts/analysis-system.md @@ -76,7 +76,6 @@ Call create_conventional_analysis with: } - { "type": "feat", @@ -146,5 +145,4 @@ Call create_conventional_analysis with: "details": [], "issue_refs": [] } - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index d6110d574..e179c2385 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -230,13 +230,28 @@ handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => jtdToT handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value)); /** - * {{hashline lineNum "content"}} — compute a real hashline ref for prompt examples. - * Returns `"lineNum:hash"` using the actual hash algorithm. + * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples. + * Returns `"lineNum#hash"` using the actual hash algorithm. */ -handlebars.registerHelper("hashline", (lineNum: unknown, content: unknown): string => { +function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } { const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10); - const str = typeof content === "string" ? content : String(content ?? ""); - return `${num}:${computeLineHash(num, str)}`; + const text = typeof content === "string" ? content : String(content ?? ""); + const ref = `${num}#${computeLineHash(num, text)}`; + return { num, text, ref }; +} + +handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => { + const { ref } = formatHashlineRef(lineNum, content); + return ref; +}); + +/** + * {{hlinefull lineNum "content"}} — format a full read-style line with prefix. + * Returns `"lineNum#hash|content"`. + */ +handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => { + const { ref, text } = formatHashlineRef(lineNum, content); + return `${ref}|${text}`; }); export function renderPromptTemplate(template: string, context: TemplateContext = {}): string { diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 482783891..ff17e3226 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -287,7 +287,7 @@ export const SETTINGS_SCHEMA = { ui: { tab: "config", label: "Read hash lines", - description: "Include line hashes in read output for hashline edit mode (LINE:HASH|content)", + description: "Include line hashes in read output for hashline edit mode (LINE#ID|content)", }, }, showHardwareCursor: { diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 19cf0baaf..abb8bf6c4 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -4,12 +4,12 @@ * Each line in a file is identified by its 1-indexed line number and a short * 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: + * 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|CONTENT` - * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`) + * Displayed format: `LINENUM#HASH|CONTENT` + * Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`) */ import type { HashlineEdit } from "./index"; @@ -18,45 +18,84 @@ import type { HashMismatch } from "./types"; 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: "insertAfter"; after: { line: number; hash: string } } + | { kind: "insertBefore"; before: { line: number; hash: string } } + | { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } } + | { kind: "insertAtEof" }; -function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } { - if ("set_line" in edit) { +function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } { + if ("set" in edit) { return { - spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) }, - dst: edit.set_line.new_text, + spec: { kind: "single", ref: parseLineRef(edit.set.ref) }, + dstLines: edit.set.body, }; } - if ("replace_lines" in edit) { - const r = edit.replace_lines as Record; - const start = parseLineRef(r.start_anchor); - if (!r.end_anchor) { + if ("set_range" in edit) { + const r = edit.set_range as Record; + const start = parseLineRef(r.beg as string); + if (!r.end) { return { spec: { kind: "single", ref: start }, - dst: r.new_text ?? "", + dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")), }; } - const end = parseLineRef(r.end_anchor); + const end = parseLineRef(r.end as string); return { spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end }, - dst: r.new_text ?? "", + dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")), }; } + if ("insert" in edit) { + const r = edit.insert as Record; + const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? "")); + const hasAfterField = "after" in r; + const hasBeforeField = "before" in r; + const afterRef = r.after; + const beforeRef = r.before; + if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) { + throw new Error('insert.after must be a non-empty "LINE#ID" string when provided'); + } + if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) { + throw new Error('insert.before must be a non-empty "LINE#ID" string when provided'); + } + const hasAfter = hasAfterField && typeof afterRef === "string"; + const hasBefore = hasBeforeField && typeof beforeRef === "string"; + if (hasAfter && hasBefore) { + return { + spec: { + kind: "insertBetween", + after: parseLineRef(afterRef), + before: parseLineRef(beforeRef), + }, + dstLines, + }; + } + if (hasAfter) { + return { + spec: { kind: "insertAfter", after: parseLineRef(afterRef) }, + dstLines, + }; + } + if (hasBefore) { + return { + spec: { kind: "insertBefore", before: parseLineRef(beforeRef) }, + dstLines, + }; + } + return { spec: { kind: "insertAtEof" }, dstLines }; + } if ("replace" in edit) { throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits"); } - return { - spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) }, - dst: edit.insert_after.text ?? (edit.insert_after as Record).content ?? "", - }; + throw new Error("Unknown hashline edit operation"); } /** Split dst into lines; empty string means delete (no lines). */ function splitDstLines(dst: string): string[] { return dst === "" ? [] : dst.split("\n"); } -/** Pattern matching hashline display format: `LINE:HASH|CONTENT` */ -const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+:[0-9a-zA-Z]{1,16}\|/; +/** Pattern matching hashline display format: `LINE#ID|CONTENT` */ +const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/; /** Pattern matching a unified-diff `+` prefix (but not `++`) */ const DIFF_PLUS_RE = /^\+(?!\+)/; @@ -179,6 +218,25 @@ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): str return dstLines; } +function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] { + if (dstLines.length <= 1) return dstLines; + if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) { + return dstLines.slice(0, -1); + } + return dstLines; +} + +function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] { + let out = dstLines; + if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) { + out = out.slice(1); + } + if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) { + out = out.slice(0, -1); + } + return out; +} + function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] { // Only strip when the model replaced with multiple lines and grew the edit. // This avoids turning a single-line replacement into a deletion. @@ -206,7 +264,7 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: /** * Strip hashline display prefixes and diff `+` markers from replacement lines. * - * Models frequently copy the `LINE:HASH ` prefix from read output into their + * Models frequently copy the `LINE#ID ` prefix from read output into their * replacement content, or include unified-diff `+` prefixes. Both corrupt the * output file. This strips them heuristically before application. */ @@ -262,7 +320,7 @@ export function computeLineHash(idx: number, line: string): string { /** * Format file content with hashline prefixes for display. * - * Each line becomes `LINENUM:HASH|CONTENT` where LINENUM is 1-indexed. + * Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed. * * @param content - Raw file content string * @param startLine - First line number (1-indexed, defaults to 1) @@ -271,7 +329,7 @@ export function computeLineHash(idx: number, line: string): string { * @example * ``` * formatHashLines("function hi() {\n return;\n}") - * // "1:HH|function hi() {\n2:HH| return;\n3:HH|}" + * // "1#HH|function hi() {\n2#HH| return;\n3#HH|}" * ``` */ export function formatHashLines(content: string, startLine = 1): string { @@ -280,7 +338,7 @@ export function formatHashLines(content: string, startLine = 1): string { .map((line, i) => { const num = startLine + i; const hash = computeLineHash(num, line); - return `${num}:${hash}|${line}`; + return `${num}#${hash}|${line}`; }) .join("\n"); } @@ -352,7 +410,7 @@ export async function* streamHashLinesFromUtf8( }; const pushLine = (line: string): string[] => { - const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`; + const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`; lineNum++; const chunksToYield: string[] = []; @@ -446,7 +504,7 @@ export async function* streamHashLinesFromLines( const pushLine = (line: string): string[] => { sawAnyLine = true; - const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`; + const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`; lineNum++; const chunksToYield: string[] = []; @@ -498,24 +556,24 @@ export async function* streamHashLinesFromLines( } /** - * Parse a line reference string like `"5:abcd"` into structured form. + * Parse a line reference string like `"5#abcd"` into structured form. * - * @throws Error if the format is invalid (not `NUMBER:HEXHASH`) + * @throws Error if the format is invalid (not `NUMBER#HEXHASH`) */ export function parseLineRef(ref: string): { line: number; hash: string } { - // Strip display-format suffix: "5:ab|some content" → "5:ab", or legacy "5:ab some content" → "5:ab" + // Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab" // Models often copy the full display format from read output. const cleaned = ref .replace(/\|.*$/, "") .replace(/ {2}.*$/, "") .replace(/^>+\s*/, "") .trim(); - const normalized = cleaned.replace(/\s*:\s*/, ":"); - const strictMatch = normalized.match(/^(\d+):([0-9a-zA-Z]{1,16})$/); - const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+):([0-9a-zA-Z]{${HASH_LEN}})`)); + const normalized = cleaned.replace(/\s*#\s*/, "#"); + const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/); + const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`)); const match = strictMatch ?? prefixMatch; if (!match) { - throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`); + throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`); } const line = Number.parseInt(match[1], 10); if (line < 1) { @@ -535,7 +593,7 @@ const MISMATCH_CONTEXT = 2; * Error thrown when one or more hashline references have stale hashes. * * Displays grep-style output with `>>>` markers on mismatched lines, - * showing the correct `LINE:HASH` so the caller can fix all refs at once. + * showing the correct `LINE#ID` so the caller can fix all refs at once. */ export class HashlineMismatchError extends Error { readonly remaps: ReadonlyMap; @@ -548,7 +606,7 @@ export class HashlineMismatchError extends Error { const remaps = new Map(); for (const m of mismatches) { const actual = computeLineHash(m.line, fileLines[m.line - 1]); - remaps.set(`${m.line}:${m.expected}`, `${m.line}:${actual}`); + remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`); } this.remaps = remaps; } @@ -573,7 +631,7 @@ export class HashlineMismatchError extends Error { const lines: string[] = []; lines.push( - `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`, + `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`, ); lines.push(""); @@ -587,7 +645,7 @@ export class HashlineMismatchError extends Error { const content = fileLines[lineNum - 1]; const hash = computeLineHash(lineNum, content); - const prefix = `${lineNum}:${hash}`; + const prefix = `${lineNum}#${hash}`; if (mismatchSet.has(lineNum)) { lines.push(`>>> ${prefix}|${content}`); @@ -624,8 +682,8 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: /** * Apply an array of hashline edits to file content. * - * Each edit operation identifies target lines directly (`set_line`, `replace_lines`, - * `insert_after`). Line references are resolved via {@link parseLineRef} + * Each edit operation identifies target lines directly (`set`, `set_range`, + * `insert`). Line references are resolved via {@link parseLineRef} * and hashes validated before any mutation. * * Edits are sorted bottom-up (highest effective line first) so earlier @@ -651,12 +709,14 @@ export function applyHashlineEdits( let firstChangedLine: number | undefined; const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = []; + const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1"; + // Parse src specs and dst lines up front const parsed = edits.map(edit => { const parsedEdit = parseHashlineEdit(edit); return { spec: parsedEdit.spec, - dstLines: stripNewLinePrefixes(splitDstLines(parsedEdit.dst)), + dstLines: stripNewLinePrefixes(parsedEdit.dstLines), }; }); @@ -673,6 +733,15 @@ export function applyHashlineEdits( case "insertAfter": touched.add(spec.after.line); break; + case "insertBefore": + touched.add(spec.before.line); + break; + case "insertBetween": + touched.add(spec.after.line); + touched.add(spec.before.line); + break; + case "insertAtEof": + break; } } return touched; @@ -700,11 +769,38 @@ export function applyHashlineEdits( } case "insertAfter": { if (dstLines.length === 0) { - throw new Error('Insert-after edit (src "N:HH..") requires non-empty dst'); + throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst'); } if (!validateRef(spec.after)) continue; break; } + case "insertBefore": { + if (dstLines.length === 0) { + throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst'); + } + if (!validateRef(spec.before)) continue; + break; + } + case "insertBetween": { + if (dstLines.length === 0) { + throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst'); + } + if (spec.before.line !== spec.after.line + 1) { + throw new Error( + `insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`, + ); + } + const afterValid = validateRef(spec.after); + const beforeValid = validateRef(spec.before); + if (!afterValid || !beforeValid) continue; + break; + } + case "insertAtEof": { + if (dstLines.length === 0) { + throw new Error("Insert-at-EOF edit requires non-empty dst"); + } + break; + } case "range": { if (spec.start.line > spec.end.line) { throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`); @@ -736,6 +832,15 @@ export function applyHashlineEdits( case "insertAfter": lineKey = `i:${p.spec.after.line}`; break; + case "insertBefore": + lineKey = `ib:${p.spec.before.line}`; + break; + case "insertBetween": + lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`; + break; + case "insertAtEof": + lineKey = "ieof"; + break; } const dstKey = `${lineKey}|${p.dstLines.join("\n")}`; if (seenEditKeys.has(dstKey)) { @@ -767,6 +872,18 @@ export function applyHashlineEdits( sortLine = p.spec.after.line; precedence = 1; break; + case "insertBefore": + sortLine = p.spec.before.line; + precedence = 2; + break; + case "insertBetween": + sortLine = p.spec.before.line; + precedence = 3; + break; + case "insertAtEof": + sortLine = fileLines.length + 1; + precedence = 4; + break; } return { ...p, idx, sortLine, precedence }; }); @@ -777,7 +894,7 @@ export function applyHashlineEdits( for (const { spec, dstLines, idx } of annotated) { switch (spec.kind) { case "single": { - const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines); + const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null; if (merged) { const origLines = originalFileLines.slice( merged.startLine - 1, @@ -794,7 +911,7 @@ export function applyHashlineEdits( if (origLines.join("\n") === nextLines.join("\n")) { noopEdits.push({ editIndex: idx, - loc: `${spec.ref.line}:${spec.ref.hash}`, + loc: `${spec.ref.line}#${spec.ref.hash}`, currentContent: origLines.join("\n"), }); break; @@ -806,16 +923,22 @@ export function applyHashlineEdits( const count = 1; const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line); - let stripped = stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines); - stripped = restoreOldWrappedLines(origLines, stripped); - let newLines = restoreIndentForPairedReplacement(origLines, stripped); - if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) { + let stripped = autocorrect + ? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines) + : dstLines; + stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped; + let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; + if ( + autocorrect && + origLines.join("\n") === newLines.join("\n") && + origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l)) + ) { newLines = normalizeConfusableHyphensInLines(newLines); } if (origLines.join("\n") === newLines.join("\n")) { noopEdits.push({ editIndex: idx, - loc: `${spec.ref.line}:${spec.ref.hash}`, + loc: `${spec.ref.line}#${spec.ref.hash}`, currentContent: origLines.join("\n"), }); break; @@ -827,16 +950,22 @@ export function applyHashlineEdits( case "range": { const count = spec.end.line - spec.start.line + 1; const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count); - let stripped = stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines); - stripped = restoreOldWrappedLines(origLines, stripped); - let newLines = restoreIndentForPairedReplacement(origLines, stripped); - if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) { + let stripped = autocorrect + ? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines) + : dstLines; + stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped; + let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; + if ( + autocorrect && + origLines.join("\n") === newLines.join("\n") && + origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l)) + ) { newLines = normalizeConfusableHyphensInLines(newLines); } if (origLines.join("\n") === newLines.join("\n")) { noopEdits.push({ editIndex: idx, - loc: `${spec.start.line}:${spec.start.hash}`, + loc: `${spec.start.line}#${spec.start.hash}`, currentContent: origLines.join("\n"), }); break; @@ -847,11 +976,11 @@ export function applyHashlineEdits( } case "insertAfter": { const anchorLine = originalFileLines[spec.after.line - 1]; - const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines); + const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, - loc: `${spec.after.line}:${spec.after.hash}`, + loc: `${spec.after.line}#${spec.after.hash}`, currentContent: originalFileLines[spec.after.line - 1], }); break; @@ -860,23 +989,53 @@ export function applyHashlineEdits( trackFirstChanged(spec.after.line + 1); break; } + case "insertBefore": { + const anchorLine = originalFileLines[spec.before.line - 1]; + const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines; + if (inserted.length === 0) { + noopEdits.push({ + editIndex: idx, + loc: `${spec.before.line}#${spec.before.hash}`, + currentContent: originalFileLines[spec.before.line - 1], + }); + break; + } + fileLines.splice(spec.before.line - 1, 0, ...inserted); + trackFirstChanged(spec.before.line); + break; + } + case "insertBetween": { + const afterLine = originalFileLines[spec.after.line - 1]; + const beforeLine = originalFileLines[spec.before.line - 1]; + const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines; + if (inserted.length === 0) { + noopEdits.push({ + editIndex: idx, + loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`, + currentContent: `${afterLine}\n${beforeLine}`, + }); + break; + } + fileLines.splice(spec.before.line - 1, 0, ...inserted); + trackFirstChanged(spec.before.line); + break; + } + case "insertAtEof": { + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...dstLines); + trackFirstChanged(1); + break; + } + fileLines.splice(fileLines.length, 0, ...dstLines); + trackFirstChanged(fileLines.length - dstLines.length + 1); + break; + } } } - const warnings: string[] = []; - let diffLineCount = Math.abs(fileLines.length - originalFileLines.length); - for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) { - if (fileLines[i] !== originalFileLines[i]) diffLineCount++; - } - if (diffLineCount > edits.length * 4) { - warnings.push( - `Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`, - ); - } return { content: fileLines.join("\n"), firstChangedLine, - ...(warnings.length > 0 ? { warnings } : {}), ...(noopEdits.length > 0 ? { noopEdits } : {}), }; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 8c0f798c9..cb0e47f74 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -129,35 +129,38 @@ const patchEditSchema = Type.Object({ export type ReplaceParams = Static; export type PatchParams = Static; -const hashlineSingleSchema = Type.Object( +const hashlineSetSchema = Type.Object( { - set_line: Type.Object({ - anchor: Type.String({ description: 'Line reference "LINE:HASH"' }), - new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }), + set: Type.Object({ + ref: Type.String({ description: 'Line reference "LINE#ID"' }), + body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }), }), }, { additionalProperties: true }, ); -const hashlineRangeSchema = Type.Object( +const hashlineSetRangeSchema = Type.Object( { - replace_lines: Type.Object({ - start_anchor: Type.String({ description: 'Start line ref "LINE:HASH"' }), - end_anchor: Type.String({ description: 'End line ref "LINE:HASH"' }), - new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }), - }), - }, - { additionalProperties: true }, -); -const hashlineInsertAfterSchema = Type.Object( - { - insert_after: Type.Object({ - anchor: Type.String({ description: 'Insert after this line "LINE:HASH"' }), - text: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }), + set_range: Type.Object({ + beg: Type.String({ description: 'Start line ref "LINE#ID"' }), + end: Type.String({ description: 'End line ref "LINE#ID"' }), + body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }), }), }, { additionalProperties: true }, ); +const hashlineInsertSchema = Type.Union([ + Type.Object( + { + insert: Type.Object({ + before: Type.Optional(Type.String({ minLength: 1, description: 'Insert before this line "LINE#ID"' })), + after: Type.Optional(Type.String({ minLength: 1, description: 'Insert after this line "LINE#ID"' })), + body: Type.Array(Type.String(), { description: "Lines to insert; must be non-empty" }), + }), + }, + { additionalProperties: true }, + ), +]); const hashlineReplaceSchema = Type.Object( { replace: Type.Object({ @@ -169,9 +172,9 @@ const hashlineReplaceSchema = Type.Object( { additionalProperties: true }, ); const hashlineEditItemSchema = Type.Union([ - hashlineSingleSchema, - hashlineRangeSchema, - hashlineInsertAfterSchema, + hashlineSetSchema, + hashlineSetRangeSchema, + hashlineInsertSchema, hashlineReplaceSchema, ]); const hashlineEditSchema = Type.Object( @@ -426,30 +429,23 @@ export class EditTool implements AgentTool { if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) { throw new Error( `edits[${i}] contains 'old_text'/'new_text' at top level (replace mode). ` + - `Use {replace: {old_text, new_text}} for hashline content replace, or {set_line}, {replace_lines}, {insert_after}.`, + `Use {replace: {old_text, new_text}} for hashline content replace, or {set}, {set_range}, {insert}.`, ); } if ("diff" in edit) { throw new Error( `edits[${i}] contains 'diff' field from patch mode. ` + - `Hashline edits use: {set_line}, {replace_lines}, {insert_after}, or {replace}.`, + `Hashline edits use: {set}, {set_range}, {insert}, or {replace}.`, ); } - if ( - !("set_line" in edit) && - !("replace_lines" in edit) && - !("insert_after" in edit) && - !("replace" in edit) - ) { + if (!("set" in edit) && !("set_range" in edit) && !("insert" in edit) && !("replace" in edit)) { throw new Error( - `edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`, + `edits[${i}] must contain exactly one of: 'set', 'set_range', 'insert', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`, ); } } - const anchorEdits = edits.filter( - (e): e is HashlineEdit => "set_line" in e || "replace_lines" in e || "insert_after" in e, - ); + const anchorEdits = edits.filter((e): e is HashlineEdit => "set" in e || "set_range" in e || "insert" in e); const replaceEdits = edits.filter( (e): e is { replace: { old_text: string; new_text: string; all?: boolean } } => "replace" in e, ); @@ -467,7 +463,7 @@ export class EditTool implements AgentTool { const originalNormalized = normalizeToLF(content); let normalizedContent = originalNormalized; - // Apply anchor-based edits first (set_line, replace_lines, insert_after) + // Apply anchor-based edits first (set, set_range, insert) const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits); normalizedContent = anchorResult.content; @@ -508,17 +504,19 @@ export class EditTool implements AgentTool { const targetLines: string[] = []; for (const edit of edits) { const refs: string[] = []; - if ("set_line" in edit) refs.push(edit.set_line.anchor); - else if ("replace_lines" in edit) - refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor); - else if ("insert_after" in edit) refs.push(edit.insert_after.anchor); + if ("set" in edit) refs.push(edit.set.ref); + else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end); + else if ("insert" in edit) { + if (edit.insert.after) refs.push(edit.insert.after); + if (edit.insert.before) refs.push(edit.insert.before); + } for (const ref of refs) { try { const parsed = parseLineRef(ref); if (parsed.line >= 1 && parsed.line <= lines.length) { const lineContent = lines[parsed.line - 1]; const hash = computeLineHash(parsed.line, lineContent); - targetLines.push(`${parsed.line}:${hash}|${lineContent}`); + targetLines.push(`${parsed.line}#${hash}|${lineContent}`); } } catch { /* skip malformed refs */ diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 309135a88..4256b2d25 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -86,9 +86,9 @@ interface EditRenderArgs { } type HashlineEditPreview = - | { set_line: { anchor: string; new_text: string } } - | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } } - | { insert_after: { anchor: string; text: string } } + | { set: { ref: string; body: string[] } } + | { set_range: { beg: string; end: string; body: string[] } } + | { insert: { before?: string; after?: string; body: string[] } } | { replace: { old_text: string; new_text: string; all?: boolean } }; /** Extended context for edit tool rendering */ @@ -168,20 +168,28 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool dst: "", }; } - if ("set_line" in editRecord) { - const setLine = asRecord(editRecord.set_line); + if ("set" in editRecord) { + const setLine = asRecord(editRecord.set); return { - srcLabel: `• set_line ${typeof setLine?.anchor === "string" ? setLine.anchor : "…"}`, - dst: typeof setLine?.new_text === "string" ? setLine.new_text : "", + srcLabel: `• set ${typeof setLine?.ref === "string" ? setLine.ref : "…"}`, + dst: Array.isArray(setLine?.body) + ? (setLine.body as string[]).join("\n") + : typeof setLine?.body === "string" + ? setLine.body + : "", }; } - if ("replace_lines" in editRecord) { - const replaceLines = asRecord(editRecord.replace_lines); - const start = typeof replaceLines?.start_anchor === "string" ? replaceLines.start_anchor : "…"; - const end = typeof replaceLines?.end_anchor === "string" ? replaceLines.end_anchor : "…"; + if ("set_range" in editRecord) { + const setRange = asRecord(editRecord.set_range); + const start = typeof setRange?.beg === "string" ? setRange.beg : "…"; + const end = typeof setRange?.end === "string" ? setRange.end : "…"; return { - srcLabel: `• replace_lines ${start}..${end}`, - dst: typeof replaceLines?.new_text === "string" ? replaceLines.new_text : "", + srcLabel: `• set_range ${start}..${end}`, + dst: Array.isArray(setRange?.body) + ? (setRange.body as string[]).join("\n") + : typeof setRange?.body === "string" + ? setRange.body + : "", }; } if ("replace" in editRecord) { @@ -192,17 +200,23 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool dst: typeof replace?.new_text === "string" ? replace.new_text : "", }; } - if ("insert_after" in editRecord) { - const insertAfter = asRecord(editRecord.insert_after); - const anchor = typeof insertAfter?.anchor === "string" ? insertAfter.anchor : "…"; - const text = - typeof insertAfter?.text === "string" - ? insertAfter.text - : typeof insertAfter?.content === "string" - ? insertAfter.content - : ""; + if ("insert" in editRecord) { + const insertOp = asRecord(editRecord.insert); + const after = typeof insertOp?.after === "string" ? insertOp.after : undefined; + const before = typeof insertOp?.before === "string" ? insertOp.before : undefined; + const body = insertOp?.body; + const text = Array.isArray(body) + ? (body as string[]).join("\n") + : typeof body === "string" + ? body + : typeof insertOp?.text === "string" + ? insertOp.text + : typeof insertOp?.content === "string" + ? (insertOp.content as string) + : ""; + const refs = [after, before].filter(Boolean).join("..") || "…"; return { - srcLabel: `• insert_after ${anchor}..`, + srcLabel: `• insert ${refs}`, dst: text, }; } diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index 0d25c43a6..98fddf1ff 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -11,14 +11,18 @@ Powerful search tool built on ripgrep. -Results are always content mode. {{#if IS_HASHLINE_MODE}}Lines are hashline-prefixed as `LINE:HASH|content`.{{else}}{{#if IS_LINE_NUMBER_MODE}}Lines are line-number-prefixed.{{else}}Lines are plain text (no prefixes).{{/if}}{{/if}} -Truncated at 100 matches by default (configurable via `limit`). +- Results are always content mode. +{{#if IS_HASHLINE_MODE}} +- Text output is CID prefixed: `LINE#ID|content` +{{else}} +{{#if IS_LINE_NUMBER_MODE}} +- Text output is line-number-prefixed +{{/if}} +{{/if}} -- ALWAYS use Grep for search tasks—NEVER invoke `grep` or `rg` via Bash. Has correct permissions and access. - - - -- Open-ended searches requiring multiple rounds—use Task tool with explore subagent instead - \ No newline at end of file +- ALWAYS use Grep when searching for content. +- NEVER invoke `grep` or `rg` via Bash. +- If the search is open-ended, requiring multiple rounds, use Task tool with explore subagent instead + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index b89f55d95..1f410cb98 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,85 +1,130 @@ # Edit (Hash Anchored) -Line-addressed edits using hash-verified line references. Read files in hashline mode, collect exact `LINE:HASH` references, and submit edits that change only the targeted token or expression. -**CRITICAL: Copy `LINE:HASH` refs verbatim from read output. Use only the anchor prefix (e.g., `{{hashline 42 "const x = 1"}}`), never the trailing source text after `|`.** +Apply precise file edits using `LINE#ID` anchors from `read` output. +**CRITICAL:** anchors are `LINE#ID` only. Copy verbatim from the prefix (example: `{{hlineref 42 "const x = 1"}}`). Never include `|content`. -1. Read the target file (`read`) to obtain `LINE:HASH` references -2. Collect the exact `LINE:HASH` refs for lines you will change -3. Direction-lock each mutation: identify the exact current token/expression → the intended replacement -4. Submit one `edit` call containing all operations for that file -5. If another edit is needed on the same file: re-read first, then edit (hashes change after every edit) -6. Respond with tool calls only — no prose +1. `read` the target range to capture current `LINE#ID` anchors. +2. Pick the smallest operation per change site (`set`/`set_range`/`insert`/`replace`). +3. Direction-lock every edit: exact current text -> intended text. +4. Submit one `edit` call per file containing all operations. +5. If another edit is needed in that file, re-read first (hashes changed). +6. Output tool calls only; no prose. -Four edit variants are available: -- **`set_line`**: Replace a single line - `{ set_line: { anchor: "LINE:HASH", new_text: "..." } }` - `new_text: ""` keeps the line but makes it blank. -- **`replace_lines`**: Replace a contiguous range (use for deletions with `new_text: ""`) - `{ replace_lines: { start_anchor: "LINE:HASH", end_anchor: "LINE:HASH", new_text: "..." } }` -- **`insert_after`**: Add new content after an anchor line - `{ insert_after: { anchor: "LINE:HASH", text: "..." } }` -- **`replace`**: Substring-style fuzzy match (when line refs are unavailable) - `{ replace: { old_text: "...", new_text: "...", all?: boolean } }` -**Atomicity:** All edits in one call validate against the file as last read. Line numbers and hashes refer to the original state, not post-edit state. The applicator sorts and applies bottom-up automatically. +- **`set`** (single line replace/delete) + - `{ set: { ref: "LINE#ID", body: ["..."] } }` + - `body: []` deletes the line; `body: [""]` keeps a blank line. +- **`set_range`** (contiguous multi-line replace/delete) + - `{ set_range: { beg: "LINE#ID", end: "LINE#ID", body: ["..."] } }` + - Use for swaps, block rewrites, or deleting a full span (`body: []`). +- **`insert`** (new content) + - `{ insert: { before: "LINE#ID", body: ["..."] } }` + - `{ insert: { after: "LINE#ID", body: ["..."] } }` + - `{ insert: { after: "LINE#ID", before: "LINE#ID", body: ["..."] } }` (between adjacent anchors; safest for blocks) + - `{ insert: { body: ["..."] } }` (append EOF only when intentional) +- **`replace`** (fuzzy text fallback when anchors unavailable) + - `{ replace: { old_text: "...", new_text: "...", all?: boolean } }` +**Atomicity:** all ops validate against the same pre-edit file snapshot; refs are interpreted against last `read`; applicator applies bottom-up. -1. **Scope each operation minimally.** One logical change site per operation. Use separate `set_line` ops for non-adjacent lines instead of a wide `replace_lines` that spans unchanged code. -2. **Preserve original formatting exactly.** Copy each line's whitespace, braces, semicolons, trailing commas, and style — then change only the targeted token/expression. Keep `import { foo }` as-is; keep indentation and line breaks as-is. -3. **Use `insert_after` for additions.** When adding a field, argument, or import near existing lines, prefer `insert_after` over replacing a neighboring line. -4. **Ensure `new_text` differs from current content.** Identical content is rejected as a no-op. -5. **Edit only requested lines.** Leave unrelated code untouched. -6. **Lock mutation direction.** Replace the exact currently-present token with the intended target. For swaps between two locations, use two `set_line` ops in one call. +1. **Minimize scope:** one logical mutation site per operation. +2. **Preserve formatting:** keep indentation, punctuation, line breaks, trailing commas, brace style. +3. **Prefer insertion over neighbor rewrites:** anchor on structural boundaries (`}`, `]`, `},`) not interior property lines. +4. **No no-ops:** replacement body must differ from current content. +5. **Touch only requested code:** avoid incidental edits. +6. **Use exact current tokens:** never "rewrite approximately"; mutate the token that exists now. +7. **For swaps/moves:** prefer one `set_range` over multiple conflicting `set`s. + +- One wrong line -> `set` +- Adjacent block changed -> `set_range` +- Missing line/block -> `insert` +- Cannot trust line anchors (generated/unknown offsets) -> `replace` (last resort) + + + +- Copy anchor IDs exactly from `read` or error output. +- Never handcraft hashes. +- For inserts, prefer `after+before` dual anchors when both boundaries are known. +- Re-read after each successful edit call before issuing another on same file. + + -**Hash mismatch (`>>>` error):** -→ Copy the updated `LINE:HASH` refs from the error output verbatim and retry with the same intended mutation. -→ Re-read only if you need lines not shown in the error. -→ If mismatch repeats after applying updated refs, stop and re-read the relevant region. -**No-op error ("identical content"):** -→ Stop. Re-read the file — you are targeting the wrong line or your replacement is not different. -→ After 2 consecutive no-op errors on the same line, re-read the entire function/block. +**Hash mismatch (`>>>`)** +- Retry with the updated anchors shown in error output. +- Re-read only if required anchors are missing from error snippet. +- If mismatch repeats, stop and re-read the exact block. +**No-op / identical content** +- Re-read immediately; target is stale or replacement equals current text. +- After two no-ops on same area, re-read the full function/block before retry. - -set_line: { anchor: "{{hashline 2 " x"}}", new_text: " x = 99" } + +Read: +{{hlinefull 41 " return record != null && record.status === 'fulfilled';"}} +Edit: +set: { ref: "{{hlineref 41 " return record != null && record.status === 'fulfilled';"}}", body: [" return record != null && record?.status === 'fulfilled';"] } - -replace_lines: { start_anchor: "{{hashline 5 "old start line"}}", end_anchor: "{{hashline 8 "old end line"}}", new_text: " combined = True" } + +Read: +{{hlinefull 15 "export function useX(...): boolean {"}} +{{hlinefull 16 " useEffect(() => {"}} +Edit: +insert: { before: "{{hlineref 16 " useEffect(() => {"}}", body: [" const [isVisible, setIsVisible] = useState(true);"] } - -replace_lines: { start_anchor: "{{hashline 5 "line to delete A"}}", end_anchor: "{{hashline 6 "line to delete B"}}", new_text: "" } + +Read: +{{hlinefull 120 " doFirst();"}} +{{hlinefull 121 " doThird();"}} +Edit: +insert: { after: "{{hlineref 120 " doFirst();"}}", before: "{{hlineref 121 " doThird();"}}", body: [" doSecond();"] } - -insert_after: { anchor: "{{hashline 3 "anchor line content"}}", text: " # new comment" } + +Read: +{{hlinefull 190 " thenable.then(resolve, ignoreReject);"}} +{{hlinefull 191 " chunkCache.set(chunkId, thenable);"}} +Edit: +set_range: { beg: "{{hlineref 190 " thenable.then(resolve, ignoreReject);"}}", end: "{{hlineref 191 " chunkCache.set(chunkId, thenable);"}}", body: [" chunkCache.set(chunkId, thenable);", " thenable.then(resolve, ignoreReject);"] } - -set_line: { anchor: "{{hashline 10 "old line 10"}}", new_text: " return False" } -set_line: { anchor: "{{hashline 3 "old line 3"}}", new_text: " x = 42" } + +Read: +{{hlinefull 188 ""}} +{{hlinefull 189 " // If we don't find a Fiber on the comment..."}} +Edit: +insert: { after: "{{hlineref 188 ""}}", body: [" if (targetFiber) {", " targetInst = targetFiber;", " }"] } - + +Bad: +insert: { after: "195#d3", body: [" { id: \"nanogpt\", available: true },"] } +Good: +insert: { after: "196#f6", before: "197#fc", body: [" { id: \"nanogpt\", available: true },"] } + + + +insert: { body: ["// end marker"] } + + + replace: { old_text: "x = 42", new_text: "x = 99" } -Before submitting, verify: -- [ ] Payload shape: `{"path": string, "edits": [operation, ...]}` with non-empty `edits` array -- [ ] Each operation has exactly one variant key: `set_line` | `replace_lines` | `insert_after` | `replace` -- [ ] Each anchor is copied exactly from the `LINE:HASH` prefix (no spaces, no trailing source text) -- [ ] `new_text`/`text` contains plain replacement lines only — no `LINE:HASH` prefixes, no diff `+` markers -- [ ] Each replacement differs from the current line content -- [ ] Each operation targets one logical change site with minimal scope -- [ ] Formatting of replaced lines matches the original exactly, except for the targeted change +- [ ] Payload shape is `{ "path": string, "edits": [operation, ...] }` and `edits` is non-empty +- [ ] Every operation has exactly one variant key: `set` | `set_range` | `insert` | `replace` +- [ ] Every anchor is copied exactly as `LINE#ID` (no spaces, no `|content`) +- [ ] `body` lines are raw content only (no diff markers, no anchor prefixes) +- [ ] Every replacement is meaningfully different from current content +- [ ] Scope is minimal and formatting is preserved except targeted token changes -**REMINDER: Copy `LINE:HASH` refs verbatim. Anchors are `LINE:HASH` only — never `LINE:HASH|content`. Preserve exact formatting. Change only the targeted token.** \ No newline at end of file +**Final reminder:** anchors are immutable references to the last read snapshot. Re-read when state changes, then edit. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 3baff956e..3788b9b83 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -6,12 +6,10 @@ Reads files from local filesystem or internal URLs. - Reads up to {{DEFAULT_MAX_LINES}} lines default - Use `offset` and `limit` for large files {{#if IS_HASHLINE_MODE}} -- Text output is hashline-prefixed: `LINE:HASH|content` +- Text output is CID prefixed: `LINE#ID|content` {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed -{{else}} -- Text output is plain (no line prefixes) {{/if}} {{/if}} - Supports images (PNG, JPG) and PDFs diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 9398124ac..4f2340bc6 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -209,7 +209,7 @@ export class GrepTool implements AgentTool { const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => { if (useHashLines) { - const ref = `${lineNumber}:${computeLineHash(lineNumber, line)}`; + const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`; return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`; } const padded = lineNumber.toString().padStart(lineWidth, " "); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 81c434367..6d696b20a 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -772,7 +772,7 @@ export class ReadTool implements AgentTool { const prependHashLines = (text: string, startNum: number): string => { const textLines = text.split("\n"); return textLines - .map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`) + .map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`) .join("\n"); }; const formatText = (text: string, startNum: number): string => { @@ -929,7 +929,7 @@ export class ReadTool implements AgentTool { }; const prependHashLines = (text: string, startNum: number): string => { const textLines = text.split("\n"); - return textLines.map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`).join("\n"); + return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`).join("\n"); }; const formatText = (text: string, startNum: number): string => { if (shouldAddHashLines) return prependHashLines(text, startNum); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 3b9671f11..efc0b38f2 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { applyHashlineEdits, computeLineHash, @@ -47,30 +47,30 @@ describe("formatHashLines", () => { test("formats single line", () => { const result = formatHashLines("hello"); const hash = computeLineHash(1, "hello"); - expect(result).toBe(`1:${hash}|hello`); + expect(result).toBe(`1#${hash}|hello`); }); test("formats multiple lines with 1-indexed numbers", () => { const result = formatHashLines("foo\nbar\nbaz"); const lines = result.split("\n"); expect(lines).toHaveLength(3); - expect(lines[0]).toStartWith("1:"); - expect(lines[1]).toStartWith("2:"); - expect(lines[2]).toStartWith("3:"); + expect(lines[0]).toStartWith("1#"); + expect(lines[1]).toStartWith("2#"); + expect(lines[2]).toStartWith("3#"); }); test("respects custom startLine", () => { const result = formatHashLines("foo\nbar", 10); const lines = result.split("\n"); - expect(lines[0]).toStartWith("10:"); - expect(lines[1]).toStartWith("11:"); + expect(lines[0]).toStartWith("10#"); + expect(lines[1]).toStartWith("11#"); }); test("handles empty lines in content", () => { const result = formatHashLines("foo\n\nbar"); const lines = result.split("\n"); expect(lines).toHaveLength(3); - expect(lines[1]).toMatch(/^2:[0-9a-z]{2,4}\|$/); + expect(lines[1]).toMatch(/^2#[0-9a-z]{2,4}\|$/); }); test("round-trips with computeLineHash", () => { @@ -79,7 +79,7 @@ describe("formatHashLines", () => { const lines = formatted.split("\n"); for (let i = 0; i < lines.length; i++) { - const match = lines[i].match(/^(\d+):([0-9a-z]+)\|(.*)$/); + const match = lines[i].match(/^(\d+)#([0-9a-z]+)\|(.*)$/); expect(match).not.toBeNull(); const lineNum = Number.parseInt(match![1], 10); const hash = match![2]; @@ -148,34 +148,34 @@ describe("streamHashLinesFrom*", () => { describe("parseLineRef", () => { test("parses valid reference", () => { - const ref = parseLineRef("5:abcd"); + const ref = parseLineRef("5#abcd"); expect(ref).toEqual({ line: 5, hash: "abcd" }); }); test("parses single-digit hash", () => { - const ref = parseLineRef("1:a"); + const ref = parseLineRef("1#a"); expect(ref).toEqual({ line: 1, hash: "a" }); }); test("parses long hash", () => { - const ref = parseLineRef("100:abcdef0123456789"); + const ref = parseLineRef("100#abcdef0123456789"); expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" }); }); - test("rejects missing colon", () => { + test("rejects missing separator", () => { expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/); }); test("rejects non-numeric line", () => { - expect(() => parseLineRef("abc:1234")).toThrow(/Invalid line reference/); + expect(() => parseLineRef("abc#1234")).toThrow(/Invalid line reference/); }); test("rejects non-alphanumeric hash", () => { - expect(() => parseLineRef("5:$$$$")).toThrow(/Invalid line reference/); + expect(() => parseLineRef("5#$$$$")).toThrow(/Invalid line reference/); }); test("rejects line number 0", () => { - expect(() => parseLineRef("0:abcd")).toThrow(/Line number must be >= 1/); + expect(() => parseLineRef("0#abcd")).toThrow(/Line number must be >= 1/); }); test("rejects empty string", () => { @@ -183,7 +183,7 @@ describe("parseLineRef", () => { }); test("rejects empty hash", () => { - expect(() => parseLineRef("5:")).toThrow(/Invalid line reference/); + expect(() => parseLineRef("5#")).toThrow(/Invalid line reference/); }); }); @@ -227,12 +227,12 @@ describe("validateLineRef", () => { describe("applyHashlineEdits — replace", () => { function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("replaces single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); @@ -241,9 +241,7 @@ describe("applyHashlineEdits — replace", () => { test("range replace (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [ - { replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "ONE" } }, - ]; + const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["ONE"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nONE\nddd"); @@ -252,7 +250,7 @@ describe("applyHashlineEdits — replace", () => { test("range replace (same count)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "XXX\nYYY" } }, + { set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["XXX", "YYY"] } }, ]; const result = applyHashlineEdits(content, edits); @@ -262,7 +260,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces first line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "first"), new_text: "FIRST" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "first"), body: ["FIRST"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("FIRST\nsecond\nthird"); @@ -271,7 +269,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces last line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "third"), new_text: "THIRD" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "third"), body: ["THIRD"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("first\nsecond\nTHIRD"); @@ -285,12 +283,12 @@ describe("applyHashlineEdits — replace", () => { describe("applyHashlineEdits — delete", () => { function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("deletes single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: [] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nccc"); @@ -299,9 +297,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes range of lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [ - { replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "" } }, - ]; + const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: [] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nddd"); @@ -309,7 +305,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes first line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "aaa"), new_text: "" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "aaa"), body: [] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("bbb\nccc"); @@ -317,7 +313,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes last line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "ccc"), new_text: "" } }]; + const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "ccc"), body: [] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb"); @@ -330,12 +326,12 @@ describe("applyHashlineEdits — delete", () => { describe("applyHashlineEdits — insert", () => { function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("inserts after a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "NEW" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["NEW"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); @@ -344,7 +340,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts multiple lines", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "x\ny\nz" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["x", "y", "z"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); @@ -352,7 +348,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts after last line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "NEW" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["NEW"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW"); @@ -360,10 +356,154 @@ describe("applyHashlineEdits — insert", () => { test("insert with empty dst throws", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); + + test("inserts at EOF without anchors", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }]; + + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nbbb\nNEW"); + expect(result.firstChangedLine).toBe(3); + }); + + test("inserts at EOF into empty file without anchors", () => { + const content = ""; + const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }]; + + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("NEW"); + expect(result.firstChangedLine).toBe(1); + }); + + test("insert at EOF with empty dst throws", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { body: [] } }]; + + expect(() => applyHashlineEdits(content, edits)).toThrow(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// applyHashlineEdits — insert (before) +// ═══════════════════════════════════════════════════════════════════════════ + +describe("applyHashlineEdits — insert (before)", () => { + function makeRef(lineNum: number, content: string): string { + return `${lineNum}#${computeLineHash(lineNum, content)}`; + } + + test("inserts before a line", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW"] } }]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); + expect(result.firstChangedLine).toBe(2); + }); + + test("inserts multiple lines before", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["x", "y", "z"] } }]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); + }); + + test("inserts before first line", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: ["NEW"] } }]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("NEW\naaa\nbbb"); + }); + + test("insert with before and empty text throws", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }]; + expect(() => applyHashlineEdits(content, edits)).toThrow(); + }); + + test("strips anchor echo from end of inserted text (autocorrect)", () => { + Bun.env.PI_HL_AUTOCORRECT = "1"; + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW", "bbb"] } }]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); + }); + Bun.env.PI_HL_AUTOCORRECT = undefined; + + test("insert before and insert after at same line produce correct order", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } }, + { insert: { after: makeRef(2, "bbb"), body: ["AFTER"] } }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc"); + }); + + test("insert before with set at same line", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } }, + { set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nBEFORE\nBBB\nccc"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// applyHashlineEdits — insert (between) +// ═══════════════════════════════════════════════════════════════════════════ + +describe("applyHashlineEdits — insert (between)", () => { + function makeRef(lineNum: number, content: string): string { + return `${lineNum}#${computeLineHash(lineNum, content)}`; + } + + test("inserts between adjacent anchors", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: ["NEW"] } }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); + expect(result.firstChangedLine).toBe(2); + }); + + test("inserts multiple lines between anchors", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { + insert: { + after: makeRef(1, "aaa"), + before: makeRef(2, "bbb"), + body: ["x", "y", "z"], + }, + }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nx\ny\nz\nbbb\nccc"); + }); + + test("strips boundary echo from both sides (autocorrect)", () => { + Bun.env.PI_HL_AUTOCORRECT = "1"; + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { + insert: { + after: makeRef(1, "aaa"), + before: makeRef(2, "bbb"), + body: ["aaa", "NEW", "bbb"], + }, + }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); + }); + Bun.env.PI_HL_AUTOCORRECT = undefined; }); // ═══════════════════════════════════════════════════════════════════════════ @@ -371,13 +511,22 @@ describe("applyHashlineEdits — insert", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — heuristics", () => { + let origEnv: string | undefined; + beforeAll(() => { + origEnv = Bun.env.PI_HL_AUTOCORRECT; + Bun.env.PI_HL_AUTOCORRECT = "1"; + }); + afterAll(() => { + Bun.env.PI_HL_AUTOCORRECT = origEnv; + }); + function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("strips insert-after anchor echo", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "bbb\nNEW" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["bbb", "NEW"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW\nccc"); @@ -398,11 +547,11 @@ describe("applyHashlineEdits — heuristics", () => { const end = 6; const edits: HashlineEdit[] = [ { - replace_lines: { - start_anchor: makeRef(start, "if (cond) {"), - end_anchor: makeRef(end, "}"), + set_range: { + beg: makeRef(start, "if (cond) {"), + end: makeRef(end, "}"), // Echoes line after the range ("after();") and also reformats the import line. - new_text: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"].join("\n"), + body: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"], }, }, ]; @@ -417,10 +566,10 @@ describe("applyHashlineEdits — heuristics", () => { const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n"); const edits: HashlineEdit[] = [ { - replace_lines: { - start_anchor: makeRef(1, "import { foo } from 'x';"), - end_anchor: makeRef(2, "import { bar } from 'y';"), - new_text: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"].join("\n"), + set_range: { + beg: makeRef(1, "import { foo } from 'x';"), + end: makeRef(2, "import { bar } from 'y';"), + body: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"], }, }, ]; @@ -439,9 +588,9 @@ describe("applyHashlineEdits — heuristics", () => { const content = ["before();", longLine, "after();"].join("\n"); const edits: HashlineEdit[] = [ { - set_line: { - anchor: makeRef(2, longLine), - new_text: [ + set: { + ref: makeRef(2, longLine), + body: [ "const", "options", "=", @@ -452,7 +601,7 @@ describe("applyHashlineEdits — heuristics", () => { "thirdLongIdentifier", "+", "fourthLongIdentifier;", - ].join("\n"), + ], }, }, ]; @@ -468,10 +617,10 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - set_line: { - anchor: makeRef(1, " typeof HOOK === 'undefined' &&"), + set: { + ref: makeRef(1, " typeof HOOK === 'undefined' &&"), // Model merged both lines into one and dropped indentation. - new_text: "typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'", + body: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"], }, }, ]; @@ -491,11 +640,10 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - set_line: { - anchor: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"), + set: { + ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"), // Model absorbed the declaration line and dropped indentation. - new_text: - "const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;", + body: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"], }, }, ]; @@ -509,14 +657,14 @@ describe("applyHashlineEdits — heuristics", () => { ); }); - test("accepts polluted src that starts with LINE:HASH but includes trailing content", () => { + test("accepts polluted src that starts with LINE#ID but includes trailing content", () => { const content = "aaa\nbbb\nccc"; const srcHash = computeLineHash(2, "bbb"); const edits: HashlineEdit[] = [ { - set_line: { - anchor: `2:${srcHash}export function foo(a, b) {}`, // comma in trailing content - new_text: "BBB", + set: { + ref: `2#${srcHash}export function foo(a, b) {}`, // comma in trailing content + body: ["BBB"], }, }, ]; @@ -528,7 +676,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[] = [{ replace_lines: { start_anchor: good, end_anchor: good, new_text: "BBB" } }]; + const edits: HashlineEdit[] = [{ set_range: { beg: good, end: good, body: ["BBB"] } }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); }); @@ -538,9 +686,9 @@ describe("applyHashlineEdits — heuristics", () => { // dst is byte-identical to original (en-dash), so this would normally be a no-op. const edits: HashlineEdit[] = [ { - set_line: { - anchor: makeRef(2, "devtools–unsupported-bridge-protocol"), - new_text: "devtools–unsupported-bridge-protocol", + set: { + ref: makeRef(2, "devtools–unsupported-bridge-protocol"), + body: ["devtools–unsupported-bridge-protocol"], }, }, ]; @@ -556,14 +704,14 @@ describe("applyHashlineEdits — heuristics", () => { describe("applyHashlineEdits — multiple edits", () => { function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } }, - { set_line: { anchor: makeRef(4, "ddd"), new_text: "DDD" } }, + { set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }, + { set: { ref: makeRef(4, "ddd"), body: ["DDD"] } }, ]; const result = applyHashlineEdits(content, edits); @@ -574,8 +722,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } }, - { set_line: { anchor: makeRef(4, "ddd"), new_text: "" } }, + { set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }, + { set: { ref: makeRef(4, "ddd"), body: [] } }, ]; const result = applyHashlineEdits(content, edits); @@ -585,8 +733,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + insert in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { set_line: { anchor: makeRef(3, "ccc"), new_text: "CCC" } }, - { insert_after: { anchor: makeRef(1, "aaa"), text: "INSERTED" } }, + { set: { ref: makeRef(3, "ccc"), body: ["CCC"] } }, + { insert: { after: makeRef(1, "aaa"), body: ["INSERTED"] } }, ]; const result = applyHashlineEdits(content, edits); @@ -597,13 +745,13 @@ describe("applyHashlineEdits — multiple edits", () => { const content = "one\ntwo\nthree\nfour\nfive\nsix"; const edits: HashlineEdit[] = [ { - replace_lines: { - start_anchor: makeRef(2, "two"), - end_anchor: makeRef(3, "three"), - new_text: "TWO_THREE", + set_range: { + beg: makeRef(2, "two"), + end: makeRef(3, "three"), + body: ["TWO_THREE"], }, }, - { set_line: { anchor: makeRef(6, "six"), new_text: "SIX" } }, + { set: { ref: makeRef(6, "six"), body: ["SIX"] } }, ]; const result = applyHashlineEdits(content, edits); @@ -624,19 +772,19 @@ describe("applyHashlineEdits — multiple edits", () => { describe("applyHashlineEdits — errors", () => { function makeRef(lineNum: number, content: string): string { - return `${lineNum}:${computeLineHash(lineNum, content)}`; + return `${lineNum}#${computeLineHash(lineNum, content)}`; } test("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[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }]; + const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["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[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }]; + const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }]; try { applyHashlineEdits(content, edits); @@ -648,10 +796,10 @@ describe("applyHashlineEdits — errors", () => { expect(msg).toContain(">>>"); // Should show the correct hash for line 2 const correctHash = computeLineHash(2, "bbb"); - expect(msg).toContain(`2:${correctHash}|bbb`); + expect(msg).toContain(`2#${correctHash}|bbb`); // Context lines should NOT have >>> markers const lines = msg.split("\n"); - const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes(":")); + const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes("#")); expect(contextLines.length).toBeGreaterThan(0); } }); @@ -659,10 +807,7 @@ describe("applyHashlineEdits — errors", () => { test("stale hash error collects all mismatches", () => { const content = "aaa\nbbb\nccc\nddd\neee"; // Use hashes that don't match any line (avoid 00 — ccc hashes to 00) - const edits: HashlineEdit[] = [ - { set_line: { anchor: "2:zz", new_text: "BBB" } }, - { set_line: { anchor: "4:zz", new_text: "DDD" } }, - ]; + const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }, { set: { ref: "4#zz", body: ["DDD"] } }]; try { applyHashlineEdits(content, edits); @@ -681,8 +826,8 @@ describe("applyHashlineEdits — errors", () => { test("does not relocate stale line refs even when hash uniquely matches another line", () => { const content = "aaa\nbbb\nccc"; - const staleButUnique = `2:${computeLineHash(1, "ccc")}`; - const edits: HashlineEdit[] = [{ set_line: { anchor: staleButUnique, new_text: "CCC" } }]; + const staleButUnique = `2#${computeLineHash(1, "ccc")}`; + const edits: HashlineEdit[] = [{ set: { ref: staleButUnique, body: ["CCC"] } }]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); @@ -695,35 +840,53 @@ describe("applyHashlineEdits — errors", () => { test("does not relocate when expected hash is non-unique", () => { const content = "dup\nmid\ndup"; - const staleDuplicate = `2:${computeLineHash(1, "dup")}`; - const edits: HashlineEdit[] = [{ set_line: { anchor: staleDuplicate, new_text: "DUP" } }]; + const staleDuplicate = `2#${computeLineHash(1, "dup")}`; + const edits: HashlineEdit[] = [{ set: { ref: staleDuplicate, body: ["DUP"] } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); test("rejects out-of-range line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ set_line: { anchor: "10:aa", new_text: "X" } }]; + const edits: HashlineEdit[] = [{ set: { ref: "10#aa", body: ["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[] = [ - { replace_lines: { start_anchor: makeRef(5, "eee"), end_anchor: makeRef(2, "bbb"), new_text: "X" } }, - ]; + const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(5, "eee"), end: makeRef(2, "bbb"), body: ["X"] } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("rejects insert-after with empty dst", () => { + test("rejects insert with after and empty text", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }]; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); + test("rejects insert with before and empty text", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }]; + expect(() => applyHashlineEdits(content, edits)).toThrow(); + }); + + test("rejects insert with both anchors and empty text", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: [] } }]; + expect(() => applyHashlineEdits(content, edits)).toThrow(); + }); + + test("rejects insert with non-adjacent anchors", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [ + { insert: { after: makeRef(1, "aaa"), before: makeRef(3, "ccc"), body: ["NEW"] } }, + ]; + expect(() => applyHashlineEdits(content, edits)).toThrow(/adjacent anchors/); + }); + test("rejects replace edit (handled separately in EditTool)", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ replace: { old_text: "aaa", new_text: "AAA" } }]; diff --git a/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T13-42-11.md b/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T13-42-11.md new file mode 100644 index 000000000..34e4534ed --- /dev/null +++ b/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T13-42-11.md @@ -0,0 +1,1213 @@ +# Edit Benchmark Report + +## Configuration + +| Setting | Value | +|---------|-------| +| Date | 2026-02-19T13:39:15.043Z | +| Model | p-anthropic/p-anthropic/claude-haiku-4-5 | +| Thinking Level | low | +| Runs per task | 1 | +| Edit Variant | hashline | +| Edit Fuzzy | auto | +| Edit Fuzzy Threshold | auto | +| Guided Mode | no | +| Max Attempts | 1 | +| No-op Retry Limit | 2 | +| Mutation Scope Window | 20 | +| Require Edit Tool | no | +| Require Read Tool | no | +| No-Edit Baseline | no | + +## Summary + +| Metric | Value | +|--------|-------| +| Total Tasks | 80 | +| Total Runs | 80 | +| Successful Runs | 60 | +| **Task Success Rate** | **75.0% (60/80)** | +| Verified Rate | 75.0% (60/80) | +| Edit Tool Usage Rate | 100.0% (80/80) | +| **Edit Success Rate** | **90.6%** | +| Timeout Runs | 0 | +| Mutation Intent Match Rate | 76.3% | +| Patch Failure Rate | 9.4% (9/96) | +| Tasks All Passing | 60 | +| Tasks Flaky/Failing | 20 | + +### Tool Calls + +| Tool | Total | Avg/Run | +|------|-------|---------| +| Read | 199 | 2.5 | +| Edit | 96 | 1.2 | +| Write | 0 | 0.0 | +| **Tool Input Chars** | 27,200 | 340 | + +### Tokens & Time + +| Metric | Total | Avg/Run | +|--------|-------|---------| +| Input Tokens | 1,910 | 24 | +| Output Tokens | 113,328 | 1,417 | +| Total Tokens | 3,871,668 | 48,396 | +| Duration | 1304.5s | 16.3s | +| **Avg Indent Score** | — | **0.00** | + +### Hashline Edit Subtypes + +| Operation | Count | % | +|-----------|-------|---| +| set | 83 | 79.0% | +| set_range | 12 | 11.4% | +| insert | 8 | 7.6% | +| replace | 2 | 1.9% | +| **Total** | **105** | 100% | + +## Task Results + +| Task | File | Success | Edit Hit | R/E/W | Tokens (In/Out) | Time | Indent | +|------|------|---------|----------|-------|-----------------|------|--------| +| Access Remove Optional Chain 001 | fallbackEvalContext.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/464 | 6.8s | 0.00 | +| Access Remove Optional Chain 003 | hookNamesCache.js | 0/1 ❌ | 100.0% | 3/1/0 | 23/830 | 10.9s | 0.00 | +| Access Remove Optional Chain 005 | registerDevToolsEventLogger.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/1,012 | 11.1s | 0.00 | +| Access Remove Optional Chain 007 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/992 | 15.2s | 0.00 | +| Call Swap Call Args 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/421 | 8.1s | 0.00 | +| Call Swap Call Args 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/568 | 8.2s | 0.00 | +| Call Swap Call Args 005 | ReactNoopPersistent.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/513 | 6.9s | 0.00 | +| Call Swap Call Args 007 | parseSourceAndMetadata.js | 1/1 ✅ | 100.0% | 3/1/0 | 25/728 | 9.3s | 0.00 | +| Duplicate Duplicate Line Flip 001 | isCustomElement.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/460 | 7.8s | 0.00 | +| Duplicate Duplicate Line Flip 003 | ReactFiberDevToolsHook.js | 0/1 ❌ | 100.0% | 4/1/0 | 33/17,737 | 154.8s | 0.00 | +| Duplicate Duplicate Line Flip 005 | shallowEqual.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/441 | 6.8s | 0.00 | +| Duplicate Duplicate Line Flip 007 | ReactDOMEventReplaying.js | 0/1 ❌ | 100.0% | 1/1/0 | 16/17,742 | 140.5s | 0.00 | +| Identifier Identifier Multi Edit 001 | Button.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/582 | 6.3s | 0.00 | +| Identifier Identifier Multi Edit 003 | ReactFlightDOMClientBrowser.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/871 | 12.1s | 0.00 | +| Identifier Identifier Multi Edit 005 | githubAPI.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/451 | 7.4s | 0.00 | +| Identifier Identifier Multi Edit 007 | ReactFiberComponentStack.js | 1/1 ✅ | 50.0% | 2/2/0 | 28/885 | 9.1s | 0.00 | +| Import Swap Named Imports 001 | ListApp.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/633 | 7.5s | 0.00 | +| Import Swap Named Imports 003 | index.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/484 | 6.8s | 0.00 | +| Import Swap Named Imports 005 | SuspenseScrubber.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/555 | 7.5s | 0.00 | +| Import Swap Named Imports 007 | InspectedElement.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/2,503 | 22.0s | 0.00 | +| Literal Flip Boolean 001 | ReactDOMLegacyServerImpl.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/744 | 10.3s | 0.00 | +| Literal Flip Boolean 003 | ReactProfilerTimer.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/3,114 | 31.7s | 0.00 | +| Literal Flip Boolean 005 | OpenInEditorButton.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/858 | 9.8s | 0.00 | +| Literal Flip Boolean 007 | Element.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/568 | 11.1s | 0.00 | +| Literal Off By One 001 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/492 | 5.8s | 0.00 | +| Literal Off By One 003 | ReactFlightClientConfigBundlerTurbopack.js | 1/1 ✅ | 100.0% | 3/1/0 | 24/841 | 13.4s | 0.00 | +| Literal Off By One 005 | ContextMenu.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/472 | 6.6s | 0.00 | +| Literal Off By One 007 | index.js | 1/1 ✅ | 100.0% | 3/1/0 | 27/793 | 12.3s | 0.00 | +| Operator Remove Negation 001 | prepareInjection.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/534 | 9.6s | 0.00 | +| Operator Remove Negation 003 | ReactDOMSelection.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/1,307 | 14.7s | 0.00 | +| Operator Remove Negation 005 | ReactDOMContainer.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/1,334 | 14.1s | 0.00 | +| Operator Remove Negation 007 | SelectEventPlugin.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/740 | 10.9s | 0.00 | +| Operator Swap Arithmetic 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/495 | 6.4s | 0.00 | +| Operator Swap Arithmetic 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/937 | 12.8s | 0.00 | +| Operator Swap Arithmetic 005 | ReactFiberConfigWithNoResources.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/526 | 8.3s | 0.00 | +| Operator Swap Arithmetic 007 | useCanvasInteraction.js | 0/1 ❌ | 100.0% | 3/1/0 | 25/556 | 10.0s | 0.00 | +| Operator Swap Comparison 001 | useSmartTooltip.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/1,135 | 13.6s | 0.00 | +| Operator Swap Comparison 003 | ReactFlightDOMServerBrowser.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/873 | 10.4s | 0.00 | +| Operator Swap Comparison 005 | Rectangle.js | 1/1 ✅ | 50.0% | 2/2/0 | 28/949 | 13.1s | 0.00 | +| Operator Swap Comparison 007 | ReactFiberTreeReflection.js | 1/1 ✅ | 100.0% | 3/1/0 | 24/1,201 | 15.4s | 0.00 | +| Operator Swap Equality 001 | ReactNoopFlightClient.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/480 | 8.3s | 0.00 | +| Operator Swap Equality 003 | astUtils.js | 1/1 ✅ | 100.0% | 3/1/0 | 24/1,442 | 16.5s | 0.00 | +| Operator Swap Equality 005 | ReactDOMContainer.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/632 | 9.1s | 0.00 | +| Operator Swap Equality 007 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/1,319 | 15.3s | 0.00 | +| Operator Swap Increment Decrement 001 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/430 | 7.4s | 0.00 | +| Operator Swap Increment Decrement 003 | ReactFlightClientConfigBundlerESM.js | 1/1 ✅ | 100.0% | 3/1/0 | 25/697 | 12.7s | 0.00 | +| Operator Swap Increment Decrement 005 | ReactFiberViewTransitionComponent.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/687 | 8.9s | 0.00 | +| Operator Swap Increment Decrement 007 | ReactFiberConcurrentUpdates.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/575 | 8.6s | 0.00 | +| Operator Swap Logical 001 | ErrorView.js | 0/1 ❌ | 80.0% | 4/5/0 | 58/4,558 | 79.3s | 0.00 | +| Operator Swap Logical 003 | DevTools.js | 1/1 ✅ | 100.0% | 3/1/0 | 25/572 | 9.1s | 0.00 | +| Operator Swap Logical 005 | UseEffectEvent.js | 1/1 ✅ | 100.0% | 3/1/0 | 27/967 | 13.4s | 0.00 | +| Operator Swap Logical 007 | getHookNameForLocation.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/723 | 9.3s | 0.00 | +| Operator Swap Nullish 001 | ElementBadges.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/533 | 7.5s | 0.00 | +| Operator Swap Nullish 003 | ReactComponentStackFrame.js | 1/1 ✅ | 100.0% | 3/1/0 | 25/667 | 9.2s | 0.00 | +| Operator Swap Nullish 005 | ReactLogo.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/613 | 9.1s | 0.00 | +| Operator Swap Nullish 007 | SuspenseBreadcrumbs.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/1,387 | 14.4s | 0.00 | +| Regex Swap Regex Quantifier 001 | SimpleValues.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/477 | 6.2s | 0.00 | +| Regex Swap Regex Quantifier 003 | utils.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/1,227 | 14.3s | 0.00 | +| Regex Swap Regex Quantifier 005 | formatWithStyles.js | 1/1 ✅ | 40.0% | 5/5/0 | 66/1,785 | 19.7s | 0.00 | +| Regex Swap Regex Quantifier 007 | RunReactCompiler.ts | 0/1 ❌ | 100.0% | 4/1/0 | 28/8,987 | 86.8s | 0.00 | +| Structural Delete Statement 001 | useExtensionComponentsPanelVisibility.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/565 | 8.4s | 0.00 | +| Structural Delete Statement 003 | useCanvasInteraction.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/721 | 11.8s | 0.00 | +| Structural Delete Statement 005 | StackTraceView.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/1,139 | 16.3s | 0.00 | +| Structural Delete Statement 007 | ReactDOMFizzStaticBrowser.js | 1/1 ✅ | 100.0% | 4/2/0 | 34/1,306 | 14.8s | 0.00 | +| Structural Remove Early Return 001 | ReactFlightAsyncDispatcher.js | 0/1 ❌ | 50.0% | 3/2/0 | 32/889 | 12.4s | 0.00 | +| Structural Remove Early Return 003 | ReactDOMComponentTree.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/729 | 11.2s | 0.00 | +| Structural Remove Early Return 005 | TabBar.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/596 | 10.6s | 0.00 | +| Structural Remove Early Return 007 | CommitTreeBuilder.js | 0/1 ❌ | 50.0% | 4/2/0 | 35/2,060 | 20.6s | 0.00 | +| Structural Swap Adjacent Lines 001 | reactPolling.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/528 | 7.1s | 0.00 | +| Structural Swap Adjacent Lines 003 | OwnersStack.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/583 | 9.4s | 0.00 | +| Structural Swap Adjacent Lines 005 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/644 | 9.4s | 0.00 | +| Structural Swap Adjacent Lines 007 | ReactFlightClientConfigBundlerWebpack.js | 0/1 ❌ | 100.0% | 2/1/0 | 21/734 | 9.2s | 0.00 | +| Structural Swap If Else 001 | utils.js | 0/1 ❌ | 100.0% | 4/3/0 | 35/2,111 | 21.7s | 0.00 | +| Structural Swap If Else 003 | ReactDOMFloat.js | 0/1 ❌ | 100.0% | 3/1/0 | 21/1,016 | 14.1s | 0.00 | +| Structural Swap If Else 005 | ReactClientConsoleConfigPlain.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/734 | 10.8s | 0.00 | +| Structural Swap If Else 007 | index.js | 1/1 ✅ | 50.0% | 3/2/0 | 34/859 | 12.2s | 0.00 | +| Unicode Unicode Hyphen 001 | formatProdErrorMessage.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/508 | 8.1s | 0.00 | +| Unicode Unicode Hyphen 003 | SourceMapConsumer.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/518 | 9.2s | 0.00 | +| Unicode Unicode Hyphen 005 | babel.config.js | 1/1 ✅ | 100.0% | 2/1/0 | 21/470 | 6.6s | 0.00 | +| Unicode Unicode Hyphen 007 | ReactInternalTestUtils.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/519 | 10.1s | 0.00 | + +## Category Summary + +| Category | Runs | Verified | Edit Used | Success | Min/Avg/Max Difficulty | +|----------|------|----------|-----------|---------|------------------------| +| access | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | 3 / 7.5 / 10 | +| call | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 2 / 7.8 / 14 | +| duplicate | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | 8 / 11.3 / 17 | +| identifier | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 4 / 6.8 / 9 | +| import | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | 2 / 3.8 / 5 | +| literal | 8 | 100.0% (8/8) | 100.0% (8/8) | 100.0% (8/8) | 3 / 6.9 / 10 | +| operator | 28 | 82.1% (23/28) | 100.0% (28/28) | 82.1% (23/28) | 3 / 6.7 / 10 | +| regex | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | 2 / 5.8 / 10 | +| structural | 16 | 43.8% (7/16) | 100.0% (16/16) | 43.8% (7/16) | 4 / 8.3 / 13 | +| unicode | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 2 / 4.8 / 8 | + +## Mutation Summary + +| Mutation | Category | Runs | Verified | Edit Used | Success | +|----------|----------|------|----------|-----------|---------| +| delete-statement | structural | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| duplicate-line-flip | duplicate | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| flip-boolean | literal | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| identifier-multi-edit | identifier | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| off-by-one | literal | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| remove-early-return | structural | 4 | 0.0% (0/4) | 100.0% (4/4) | 0.0% (0/4) | +| remove-negation | operator | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| remove-optional-chain | access | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| swap-adjacent-lines | structural | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| swap-arithmetic | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| swap-call-args | call | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| swap-comparison | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| swap-equality | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| swap-if-else | structural | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| swap-increment-decrement | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| swap-logical | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| swap-named-imports | import | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| swap-nullish | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| swap-regex-quantifier | regex | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| unicode-hyphen | unicode | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | + +## Difficulty Summary + +| Difficulty Score | Runs | Verified | Edit Used | Success | +|------------------|------|----------|-----------|---------| +| 0-2 | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | +| 3-5 | 24 | 75.0% (18/24) | 100.0% (24/24) | 75.0% (18/24) | +| 6-8 | 27 | 74.1% (20/27) | 100.0% (27/27) | 74.1% (20/27) | +| 9+ | 25 | 72.0% (18/25) | 100.0% (25/25) | 72.0% (18/25) | + +## Edit Tool Errors + +Failures where the edit tool returned an error or failed to apply the patch. + +### Identifier Identifier Multi Edit 007 (ReactFiberComponentStack.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFiberComponentStack.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "4f#6f". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactFiberComponentStack.js", + "edits": [ + { + "set": { + "ref": "4f#6f", + "body": [ + " if (debugInfo) {" + ] + } + }, + { + "set": { + "ref": "6f#8f", + "body": [ + " for (let i = debugInfo.length - 1; i >= 0; i--) {" + ] + } + }, + { + "set": { + "ref": "8f#f8", + "body": [ + " const entry = debugInfo[i];" + ] + } + } + ] +} +``` + +### Operator Swap Comparison 005 (Rectangle.js) + +#### Run 1 + +##### Attempt 1 + +- Path: Rectangle.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "9e#a4". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "Rectangle.js", + "edits": [ + { + "set": { + "ref": "9e#a4", + "body": [ + " if (tr < 0) {" + ] + } + } + ] +} +``` + +### Operator Swap Logical 001 (ErrorView.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ErrorView.js +- Operation: — + +**Tool error** + +``` +1 line has changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines). + + 34#1a|
+ 35#6f|
Uncaught Error: {errorMessage && ''}
+>>> 36#e4| {dismissError === null && ( + 37#89| +``` + +### Structural Delete Statement 005 (StackTraceView.js) — 0/1 + +**All runs failed with same error:** File mismatch for StackTraceView.js + +**Sample agent response (run 1):** +``` +Restored missing `ignored` computation in StackTraceView.js. Not run (not requested). +``` + +**Diff (expected vs actual):** +```diff +@@ -49 @@ + ); + const [functionName, url, line, column] = + symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite; ++ + const ignored = symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false; + // TODO: Make an option to be able to toggle the display of ignore listed rows. + // Ideally this UI should be higher than a single Stack Trace so that there's not +``` + +### Structural Remove Early Return 001 (ReactFlightAsyncDispatcher.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactFlightAsyncDispatcher.js + +**Diff (expected vs actual):** +```diff +@@ -17 @@ + if (request) { + return getCache(request); + } ++ + return new Map(); + } + +``` + +### Structural Remove Early Return 003 (ReactDOMComponentTree.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMComponentTree.js + +**Sample agent response (run 1):** +``` +Fix applied in `ReactDOMComponentTree.js` with the missing guard clause restored. + +Tests not run (not requested). +``` + +**Diff (expected vs actual):** +```diff +@@ -188 @@ + if (targetFiber) { + return targetFiber; + } ++ + // If we don't find a Fiber on the comment, it might be because + // we haven't gotten to hydrate it yet. There might still be a + // parent boundary that hasn't above this one so we need to find +``` + +### Structural Remove Early Return 005 (TabBar.js) — 0/1 + +**All runs failed with same error:** File mismatch for TabBar.js + +**Sample agent response (run 1):** +``` +Done. +``` + +**Diff (expected vs actual):** +```diff +@@ -90 @@ + + {tabs.map((tab) => { + if (tab === null) { +- return
; ++ return null; + } + } +- + const { icon, id, label, title } = tab; + + let button = ( +``` + +### Structural Remove Early Return 007 (CommitTreeBuilder.js) — 0/1 + +**All runs failed with same error:** File mismatch for CommitTreeBuilder.js + +**Sample agent response (run 1):** +``` +Fixed by restoring the early return when the requested commit tree is already cached. No tests run. +``` + +**Diff (expected vs actual):** +```diff +@@ -63 @@ + } + + const commitTrees = ((rootToCommitTreeMap.get(rootID): any): Array); +- if (commitIndex < commitTrees.length) { +- return commitTrees[commitIndex]; +- } + + const { profilingData } = profilerStore; + if (profilingData === null) { +@@ -84 @@ + ); + } + ++ if (commitTrees.length > commitIndex) { ++ return commitTrees[commitIndex]; ++ } ++ + let commitTree: CommitTree = ((null: any): CommitTree); + for (let index = commitTrees.length; index <= commitIndex; index++) { + // Commits are generated sequentially and cached. +``` + +### Structural Swap Adjacent Lines 007 (ReactFlightClientConfigBundlerWebpack.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactFlightClientConfigBundlerWebpack.js + +**Sample agent response (run 1):** +``` +Change applied. No tests run. +``` + +**Diff (expected vs actual):** +```diff +@@ -184 @@ + const entry = chunkCache.get(chunkId); + if (entry === undefined) { + const thenable = loadChunk(chunkId, chunkFilename); +- promises.push(thenable); +- // $FlowFixMe[method-unbinding] + const resolve = chunkCache.set.bind(chunkCache, chunkId, null); + const resolve = chunkCache.set.bind(chunkCache, chunkId, null); +- thenable.then(resolve, ignoreReject); ++ // $FlowFixMe[method-unbinding] ++ promises.push(thenable); + chunkCache.set(chunkId, thenable); + chunkCache.set(chunkId, thenable); ++ thenable.then(resolve, ignoreReject); + } else if (entry !== null) { + promises.push(entry); + } +``` + +### Structural Swap If Else 001 (utils.js) — 0/1 + +**All runs failed with same error:** File mismatch for utils.js + +**Diff (expected vs actual):** +```diff +@@ -23 @@ + if (sourceURL.startsWith('file:///')) { + filePath = new URL(sourceURL).pathname; + } else if (sourceURL.includes('://')) { +- // $FlowFixMe[cannot-resolve-name] ++ filePath = sourceURL; ++ } else { + if (!__IS_INTERNAL_VERSION__) { + if (!__IS_INTERNAL_VERSION__) { +- // In this case, we can't really determine the path to a file, disable a button +- return { url: null, shouldDisableButton: true }; +- } else { + const endOfSourceMapURLPattern = '.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf(endOfSourceMapURLPattern); + const endOfSourceMapURLPattern = '.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf(endOfSourceMapURLPattern); +- + if (endOfSourceMapURLIndex === -1) { + if (endOfSourceMapURLIndex === -1) { +- return { url: null, shouldDisableButton: true }; ++ return { ++ url: null, ++ shouldDisableButton: true, ++ }; + } else { + filePath = sourceURL.slice( + endOfSourceMapURLIndex + endOfSourceMapURLPattern.length, +@@ -39 @@ + sourceURL.length, + ); + } ++ } else { ++ return { ++ url: null, ++ shouldDisableButton: true, ++ }; + } + } +- } else { +- filePath = sourceURL; + } + + const lineNumberAsString = String(line); +``` + +### Structural Swap If Else 003 (ReactDOMFloat.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMFloat.js + +**Sample agent response (run 1):** +``` +Fix applied in `ReactDOMFloat.js`. Swapped the `if (encountered)` and `else` branches in the `preinitModule` dev warning logic. +``` + +**Diff (expected vs actual):** +```diff +@@ -257 @@ + case 'script': { + break; + } +- +- // We have an invalid as type and need to warn + default: { + const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); + console.error( +``` diff --git a/packages/react-edit-benchmark/src/report.ts b/packages/react-edit-benchmark/src/report.ts index 25ea4a79c..8403a47a7 100644 --- a/packages/react-edit-benchmark/src/report.ts +++ b/packages/react-edit-benchmark/src/report.ts @@ -158,13 +158,13 @@ export function generateReport(result: BenchmarkResult): string { lines.push(""); if (summary.hashlineEditSubtypes) { - const total = Object.values(summary.hashlineEditSubtypes).reduce((a, b) => a + b, 0); + const order = ["set", "set_range", "insert"] as const; + const total = order.reduce((sum, key) => sum + (summary.hashlineEditSubtypes?.[key] ?? 0), 0); if (total > 0) { lines.push("### Hashline Edit Subtypes"); lines.push(""); lines.push("| Operation | Count | % |"); lines.push("|-----------|-------|---|"); - const order = ["set_line", "replace_lines", "insert_after", "replace"]; for (const key of order) { const count = summary.hashlineEditSubtypes[key] ?? 0; const pct = formatPercent(count / total); diff --git a/packages/react-edit-benchmark/src/runner.ts b/packages/react-edit-benchmark/src/runner.ts index 1889232aa..39fdd51dd 100644 --- a/packages/react-edit-benchmark/src/runner.ts +++ b/packages/react-edit-benchmark/src/runner.ts @@ -57,7 +57,7 @@ function getEditPathFromArgs(args: unknown): string | null { return typeof pathValue === "string" && pathValue.length > 0 ? pathValue : null; } -const HASHLINE_SUBTYPES = ["set_line", "replace_lines", "insert_after", "replace"] as const; +const HASHLINE_SUBTYPES = ["set", "set_range", "insert"] as const; function countHashlineEditSubtypes(args: unknown): Record { const counts: Record = Object.fromEntries(HASHLINE_SUBTYPES.map(k => [k, 0])); @@ -106,7 +106,7 @@ function buildMutationPreviewAgainstOriginal(original: string, current: string): if (change.removed) { for (const line of lines) { const hash = computeLineHash(lineNum, line); - preview.push(`${lineNum}:${hash} -${line}`); + preview.push(`${lineNum}#${hash}|-${line}`); lineNum += 1; } continue; @@ -114,7 +114,7 @@ function buildMutationPreviewAgainstOriginal(original: string, current: string): for (const line of lines) { const hash = computeLineHash(lineNum, line); - preview.push(`${lineNum}:${hash} +${line}`); + preview.push(`${lineNum}#${hash}|+${line}`); } } @@ -304,9 +304,9 @@ async function evaluateMutationIntent( } type GuidedHashlineEdit = - | { set_line: { anchor: string; new_text: string } } - | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } } - | { insert_after: { anchor: string; text: string } }; + | { set: { ref: string; body: string[] } } + | { set_range: { beg: string; end: string; body: string[] } } + | { insert: { after: string; body: string[] } }; function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashlineEdit[] { const changes = diffLines(actual, expected); @@ -328,38 +328,38 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl if (pendingAdded.length === 0) return; if (insertLine === 1) { const firstLine = actualLines[0] ?? ""; - const firstRef = `1:${computeLineHash(1, firstLine)}`; + const firstRef = `1#${computeLineHash(1, firstLine)}`; edits.push({ - set_line: { anchor: firstRef, new_text: `${pendingAdded.join("\n")}\n${firstLine}` }, + set: { ref: firstRef, body: [...pendingAdded, firstLine] }, }); } else if (insertLine <= actualLines.length) { const afterLine = actualLines[insertLine - 2] ?? ""; - const afterRef = `${insertLine - 1}:${computeLineHash(insertLine - 1, afterLine)}`; + const afterRef = `${insertLine - 1}#${computeLineHash(insertLine - 1, afterLine)}`; edits.push({ - insert_after: { anchor: afterRef, text: pendingAdded.join("\n") }, + insert: { after: afterRef, body: [...pendingAdded] }, }); } else if (insertLine === actualLines.length + 1 && actualLines.length > 0) { const afterLine = actualLines[actualLines.length - 1] ?? ""; - const afterRef = `${actualLines.length}:${computeLineHash(actualLines.length, afterLine)}`; + const afterRef = `${actualLines.length}#${computeLineHash(actualLines.length, afterLine)}`; edits.push({ - insert_after: { anchor: afterRef, text: pendingAdded.join("\n") }, + insert: { after: afterRef, body: [...pendingAdded] }, }); } } else { const startLine = pendingStart; const endLine = pendingStart + pendingRemoved.length - 1; const startContent = actualLines[startLine - 1] ?? ""; - const startRef = `${startLine}:${computeLineHash(startLine, startContent)}`; + const startRef = `${startLine}#${computeLineHash(startLine, startContent)}`; if (startLine === endLine) { - edits.push({ set_line: { anchor: startRef, new_text: pendingAdded.join("\n") } }); + edits.push({ set: { ref: startRef, body: [...pendingAdded] } }); } else { const endContent = actualLines[endLine - 1] ?? ""; - const endRef = `${endLine}:${computeLineHash(endLine, endContent)}`; + const endRef = `${endLine}#${computeLineHash(endLine, endContent)}`; edits.push({ - replace_lines: { - start_anchor: startRef, - end_anchor: endRef, - new_text: pendingAdded.join("\n"), + set_range: { + beg: startRef, + end: endRef, + body: [...pendingAdded], }, }); } diff --git a/packages/react-edit-benchmark/src/verify.ts b/packages/react-edit-benchmark/src/verify.ts index ec124457a..9b25173d2 100644 --- a/packages/react-edit-benchmark/src/verify.ts +++ b/packages/react-edit-benchmark/src/verify.ts @@ -148,8 +148,11 @@ export async function verifyExpectedFileSubset( expectedNormalized, actualNormalized, ); - const expectedFormatted = await formatContent(expectedPath, expectedNormalized); - const actualFormatted = await formatContent(actualPath, actualNormalizedWithPreservedWhitespace); + const expectedFormatted = await formatContent(expectedPath, normalizeBlankLines(expectedNormalized)); + const actualFormatted = await formatContent( + actualPath, + normalizeBlankLines(actualNormalizedWithPreservedWhitespace), + ); const formattedEquivalent = expectedFormatted.formatted === actualFormatted.formatted; // Indent score: distance between agent's raw output and formatted output @@ -251,6 +254,11 @@ function normalizeLineEndings(value: string): string { return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } +/** Collapse runs of 2+ blank lines into a single blank line. */ +function normalizeBlankLines(text: string): string { + return text.replace(/\n{3,}/g, "\n\n"); +} + function restoreWhitespaceOnlyLineDiffs(expected: string, actual: string): string { const expectedLines = expected.split("\n"); const actualLines = actual.split("\n");