/** * Regression tests for apply-patch behaviors. * * These tests verify that the edit/ module correctly implements features * that were identified as missing or regressed in other implementations. * Each test corresponds to a specific scenario from patchv2/TODO.md. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { applyPatch, findContextLine, seekSequence } from "@oh-my-pi/pi-coding-agent/edit"; import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; describe("regression: indentation adjustment for line-based replacements (2B)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-2b-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("line-based patch adjusts indentation when fuzzy matching at different indent level", async () => { const filePath = path.join(tempDir, "indent.ts"); // File has 4-space indentation await Bun.write( filePath, `class Example { constructor() { this.value = 1; this.name = "test"; } } `, ); // Patch uses 0 indentation - should be adjusted to match the 8-space indent in file await applyPatch( { path: "indent.ts", op: "update", diff: `@@ constructor() { -this.value = 1; -this.name = "test"; +this.value = 42; +this.name = "updated";`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain(" this.value = 42;"); expect(result).toContain(' this.name = "updated";'); }); test("multi-hunk patch adjusts indentation independently per hunk", async () => { const filePath = path.join(tempDir, "multi-indent.ts"); await Bun.write( filePath, `function outer() { function inner1() { return 1; } function inner2() { return 2; } } `, ); // Different indentation levels in file - each hunk should adjust independently await applyPatch( { path: "multi-indent.ts", op: "update", diff: `@@ function inner1() { -return 1; +return 10; @@ function inner2() { -return 2; +return 20;`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain(" return 10;"); // 4 spaces for inner1 expect(result).toContain(" return 20;"); // 6 spaces for inner2 }); }); describe("regression: ambiguity detection for context-less hunks (2C)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-2c-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("single-hunk simple diff rejects multiple occurrences", async () => { const filePath = path.join(tempDir, "dupe.txt"); await Bun.write(filePath, "foo\nbar\nfoo\nbaz\n"); await expect( applyPatch( { path: "dupe.txt", op: "update", diff: "-foo\n+FOO", }, { cwd: tempDir }, ), ).rejects.toThrow(/2 occurrences/); }); test("multi-hunk context-less diff rejects ambiguous patterns", async () => { const filePath = path.join(tempDir, "multi-dupe.txt"); // Each pattern appears twice await Bun.write(filePath, "aaa\nbbb\naaa\nccc\nbbb\nddd\n"); // First hunk for "aaa" is ambiguous (appears at lines 1 and 3) await expect( applyPatch( { path: "multi-dupe.txt", op: "update", diff: "@@\n-aaa\n+AAA\n@@\n-ccc\n+CCC", }, { cwd: tempDir }, ), ).rejects.toThrow(/2 occurrences/); }); test("context lines disambiguate otherwise ambiguous patterns", async () => { const filePath = path.join(tempDir, "context-disambig.txt"); await Bun.write(filePath, "header\nfoo\nbar\nmiddle\nfoo\nbaz\nfooter\n"); // Context line "middle" disambiguates which "foo" to change await applyPatch( { path: "context-disambig.txt", op: "update", diff: "@@\n middle\n-foo\n+FOO", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); }); }); describe("regression: context search uses line hints (2D)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-2d-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("unified diff line numbers help locate correct position", async () => { const filePath = path.join(tempDir, "hints.txt"); // File with repeated function definitions await Bun.write( filePath, `function process() { return 1; } function process() { return 2; } function process() { return 3; } `, ); // Use unified diff format with line hint to target the second process() await applyPatch( { path: "hints.txt", op: "update", diff: `@@ -5,3 +5,3 @@ function process() { function process() { - return 2; + return 200; }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); // First unchanged expect(result).toContain("return 200;"); // Second changed expect(result).toContain("return 3;"); // Third unchanged }); test("line hint overrides context-only search when appropriate", async () => { const filePath = path.join(tempDir, "hint-priority.txt"); await Bun.write( filePath, `# Section A def helper(): pass # Section B def helper(): pass `, ); // Line hint points to Section B's helper (line 6) await applyPatch( { path: "hint-priority.txt", op: "update", diff: `@@ -6,2 +6,2 @@ def helper(): def helper(): - pass + return True`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[2]).toBe(" pass"); // Section A unchanged expect(lines[6]).toBe(" return True"); // Section B changed }); }); describe("regression: insertion uses newStartLine fallback (2E)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("pure addition with context uses context to find insertion point", async () => { const filePath = path.join(tempDir, "insert.txt"); await Bun.write(filePath, "line1\nline2\nline3\n"); // Insert after line1 using context await applyPatch( { path: "insert.txt", op: "update", diff: `@@ line1 +inserted`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("line1\ninserted\nline2\nline3\n"); }); test("pure addition with line hint inserts at correct position", async () => { const filePath = path.join(tempDir, "insert-hint.txt"); await Bun.write(filePath, "aaa\nbbb\nccc\n"); // Use unified diff format line hints to insert at specific location await applyPatch( { path: "insert-hint.txt", op: "update", diff: `@@ -2,1 +2,2 @@ bbb +inserted after bbb`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\ninserted after bbb\nccc\n"); }); test("insertion at end of file works correctly", async () => { const filePath = path.join(tempDir, "append.txt"); await Bun.write(filePath, "first\nsecond\n"); await applyPatch( { path: "append.txt", op: "update", diff: `@@ +appended line *** End of File`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("first\nsecond\nappended line\n"); }); }); describe("regression: seekSequence character-based fallback (2F)", () => { test("seekSequence falls back to character-based matching when line-based fails", () => { // Lines with subtle differences that line-based fuzzy matching might miss const lines = [ "function calculateTotal(items) {", " let sum = 0;", " for (const item of items) {", " sum += item.price * item.quantity;", " }", " return sum;", "}", ]; // Pattern has minor differences: extra space, different quote style const pattern = [ " for (const item of items) {", // extra space before { " sum += item.price*item.quantity;", // no spaces around * ]; const result = seekSequence(lines, pattern, 0, false); expect(result.index).toBe(2); expect(result.confidence).toBeGreaterThan(0.9); }); test("seekSequence handles normalized unicode matching", () => { const lines = ['const message = "Hello – World";', "console.log(message);"]; // Pattern uses ASCII dash instead of en-dash const pattern = ['const message = "Hello - World";']; const result = seekSequence(lines, pattern, 0, false); expect(result.index).toBe(0); }); test("seekSequence finds pattern with whitespace differences", () => { const lines = [" function foo() {", " return 42;", " }"]; // Pattern has normalized whitespace const pattern = ["function foo() {", "return 42;"]; const result = seekSequence(lines, pattern, 0, false); expect(result.index).toBe(0); }); }); describe("regression: findContextLine progressive matching (2D related)", () => { test("finds exact context line", () => { const lines = ["function foo() {", " return 1;", "}"]; const result = findContextLine(lines, "function foo() {", 0); expect(result.index).toBe(0); expect(result.confidence).toBe(1.0); }); test("finds context line with whitespace differences", () => { const lines = [" function foo() {", " return 1;", "}"]; const result = findContextLine(lines, "function foo() {", 0); expect(result.index).toBe(0); expect(result.confidence).toBeGreaterThan(0.9); }); test("finds context line with unicode normalization", () => { const lines = ['const msg = "Hello – World";', "return msg;"]; // ASCII dash in pattern, en-dash in content const result = findContextLine(lines, 'const msg = "Hello - World";', 0); expect(result.index).toBe(0); }); test("finds context line as prefix match", () => { const lines = ["function calculateTotalWithTax(items, taxRate) {", " return 0;", "}"]; // Partial function name matches as prefix const result = findContextLine(lines, "function calculateTotalWithTax(items", 0); expect(result.index).toBe(0); expect(result.confidence).toBeGreaterThan(0.9); }); test("finds context line as substring match", () => { // Substring must be at least 6 chars and 30% of line length const lines = ["// comment: calculateTotal here", "function foo() {}"]; const result = findContextLine(lines, "calculateTotal", 0); expect(result.index).toBe(0); expect(result.confidence).toBeGreaterThan(0.9); }); test("falls back to fuzzy match for similar lines", () => { const lines = ["functoin calclateTotal(itms) {", " return 0;", "}"]; // Typos in content, correct in pattern const result = findContextLine(lines, "function calculateTotal(items) {", 0); expect(result.index).toBe(0); expect(result.confidence).toBeGreaterThan(0.8); }); }); // ═══════════════════════════════════════════════════════════════════════════ // Plan: Make `@@` Context Matching Robust - Expected Behaviors // These tests document expected behaviors from the plan. Some may fail if // the feature is not yet implemented. // ═══════════════════════════════════════════════════════════════════════════ describe("plan: partial line matching for @@ context", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `plan-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ context matches when actual line contains it as substring", async () => { const filePath = path.join(tempDir, "imports.ts"); // Actual line has more content than the @@ context await Bun.write( filePath, 'import { mkdirSync, unlinkSync } from "node:fs";\n\nfunction cleanup() {\n unlinkSync("temp");\n}\n', ); // @@ context is a partial match (substring of actual line) await applyPatch( { path: "imports.ts", op: "update", diff: `@@ import { mkdirSync, unlinkSync } function cleanup() { - unlinkSync("temp"); + rmSync("temp", { recursive: true });`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain('rmSync("temp", { recursive: true });'); }); test("@@ context matches function signature even with trailing content", async () => { const filePath = path.join(tempDir, "funcs.ts"); await Bun.write( filePath, `function processItems(items: Item[], options?: Options): Result { return items.map(i => i.value); } `, ); // @@ has partial function signature await applyPatch( { path: "funcs.ts", op: "update", diff: `@@ function processItems(items - return items.map(i => i.value); + return items.filter(i => i.valid).map(i => i.value);`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("filter(i => i.valid)"); }); }); describe("plan: unified diff format line numbers", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `plan-unified-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ -10,6 +10,7 @@ is parsed as line numbers not literal text", async () => { const filePath = path.join(tempDir, "lines.txt"); // Create file with 15 lines const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`); await Bun.write(filePath, `${lines.join("\n")}\n`); // Use unified diff format to target line 10 await applyPatch( { path: "lines.txt", op: "update", diff: `@@ -10,3 +10,3 @@ line 10 -line 11 +LINE ELEVEN line 12`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("LINE ELEVEN"); expect(result).toContain("line 10"); // unchanged expect(result).toContain("line 12"); // unchanged }); test("unified diff line numbers take precedence over context search", async () => { const filePath = path.join(tempDir, "repeat.txt"); // Same pattern appears at lines 3 and 8 await Bun.write( filePath, `header line 2 target line line 4 line 5 line 6 line 7 target line line 9 `, ); // Line hint says line 8, should change second "target line" await applyPatch( { path: "repeat.txt", op: "update", diff: `@@ -8,1 +8,1 @@ -target line +MODIFIED TARGET`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[2]).toBe("target line"); // First unchanged expect(lines[7]).toBe("MODIFIED TARGET"); // Second changed }); }); describe("plan: Codex-style wrapped patches", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `plan-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("strips *** Begin Patch / *** End Patch wrapper", async () => { const filePath = path.join(tempDir, "wrapped.txt"); await Bun.write(filePath, "old content\n"); // Full Codex-style wrapper - the diff inside should be extracted await applyPatch( { path: "wrapped.txt", op: "update", diff: `*** Begin Patch @@ -old content +new content *** End Patch`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("new content\n"); }); test("strips partial wrapper (only *** End Patch)", async () => { const filePath = path.join(tempDir, "partial.txt"); await Bun.write(filePath, "original\n"); // Only end marker present await applyPatch( { path: "partial.txt", op: "update", diff: `@@ -original +modified *** End Patch`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("modified\n"); }); test("strips bare *** terminator (model hallucination)", async () => { const filePath = path.join(tempDir, "bare-asterisk.txt"); await Bun.write(filePath, "line1\nline2\nline3\n"); // Model sometimes outputs just *** as end marker await applyPatch( { path: "bare-asterisk.txt", op: "update", diff: `@@ -line2 +LINE TWO ***`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("line1\nLINE TWO\nline3\n"); }); test("strips bare *** terminator in multi-hunk diff", async () => { const filePath = path.join(tempDir, "multi-hunk-asterisk.txt"); await Bun.write(filePath, "aaa\nbbb\nccc\nddd\n"); // Multiple hunks with *** terminator at end await applyPatch( { path: "multi-hunk-asterisk.txt", op: "update", diff: `@@ -aaa +AAA @@ -ccc +CCC ***`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("AAA\nbbb\nCCC\nddd\n"); }); test("strips bare *** at beginning of diff", async () => { const filePath = path.join(tempDir, "leading-asterisk.txt"); await Bun.write(filePath, "old\n"); await applyPatch( { path: "leading-asterisk.txt", op: "update", diff: `*** @@ -old +new`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("new\n"); }); test("strips unified diff metadata lines", async () => { const filePath = path.join(tempDir, "unified-meta.txt"); await Bun.write(filePath, "first\nsecond\nthird\n"); // Full unified diff format with metadata await applyPatch( { path: "unified-meta.txt", op: "update", diff: `diff --git a/unified-meta.txt b/unified-meta.txt index abc123..def456 100644 --- a/unified-meta.txt +++ b/unified-meta.txt @@ -1,3 +1,3 @@ first -second +SECOND third`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("first\nSECOND\nthird\n"); }); }); describe("plan: strip + prefix from file creation", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `plan-create-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("create file strips + prefix when all lines have it", async () => { await applyPatch( { path: "newfile.txt", op: "create", diff: `+line one +line two +line three`, }, { cwd: tempDir }, ); expect(await Bun.file(path.join(tempDir, "newfile.txt")).text()).toBe("line one\nline two\nline three\n"); }); test("create file strips + space prefix", async () => { await applyPatch( { path: "spaced.txt", op: "create", diff: `+ first line + second line`, }, { cwd: tempDir }, ); expect(await Bun.file(path.join(tempDir, "spaced.txt")).text()).toBe("first line\nsecond line\n"); }); test("create file preserves content when not all lines have + prefix", async () => { await applyPatch( { path: "mixed.txt", op: "create", diff: `+line one regular line +line three`, }, { cwd: tempDir }, ); // Should preserve as-is since not all lines have + expect(await Bun.file(path.join(tempDir, "mixed.txt")).text()).toBe("+line one\nregular line\n+line three\n"); }); }); describe("regression: *** End of File marker handling (2A/2G)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-eof-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("*** End of File marker is preserved in hunk parsing", async () => { const filePath = path.join(tempDir, "eof.txt"); await Bun.write(filePath, "line1\nline2\nlast line\n"); await applyPatch( { path: "eof.txt", op: "update", diff: `@@ -last line +modified last line *** End of File`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("line1\nline2\nmodified last line\n"); }); test("EOF marker targets end of file for pattern matching", async () => { const filePath = path.join(tempDir, "eof-target.txt"); // Pattern appears twice - EOF should target the last one await Bun.write(filePath, "item\nmore content\nitem\n"); await applyPatch( { path: "eof-target.txt", op: "update", diff: `@@ -item +FINAL ITEM *** End of File`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toBe("item\nmore content\nFINAL ITEM\n"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // Regression: Model edit attempts that failed due to parser limitations // These tests document real model behaviors that we want to recover from. // Session: 2026-01-19T08-29-03-476Z_v0FEI1ixUrlssLyHL3TT3.jsonl // ═══════════════════════════════════════════════════════════════════════════ describe("regression: model edit attempt - @@ line N syntax (session 2026-01-19)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `model-line-n-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ line 125 is parsed as line hint, not literal context search", async () => { const filePath = path.join(tempDir, "settings.ts"); // Create file with enough lines - the target is around line 125 const lines: string[] = []; for (let i = 1; i <= 130; i++) { if (i === 125) { lines.push("\tfuzzyMatch?: boolean; // default: true"); } else if (i === 126) { lines.push("\tfuzzyThreshold?: number; // default: 0.95"); } else if (i === 127) { lines.push("\tpatchMode?: boolean; // default: false"); } else if (i === 128) { lines.push("}"); } else { lines.push(`// line ${i}`); } } await Bun.write(filePath, `${lines.join("\n")}\n`); // Model's actual attempt: used @@ line 125 as anchor await applyPatch( { path: "settings.ts", op: "update", diff: `@@ line 125 fuzzyMatch?: boolean; // default: true fuzzyThreshold?: number; // default: 0.95 - patchMode?: boolean; // default: false + patchMode?: boolean; // default: true }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("patchMode?: boolean; // default: true"); expect(result).not.toContain("patchMode?: boolean; // default: false"); }); }); describe("regression: model edit attempt - nested @@ anchors (session 2026-01-19)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `model-nested-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ class X followed by @@ method on next line is parsed as nested anchors", async () => { const filePath = path.join(tempDir, "patch.ts"); await Bun.write( filePath, `class OtherTool { constructor(session: ToolSession) { this.session = session; this.mode = false; } } class PatchTool { constructor(session: ToolSession) { this.session = session; this.patchMode = session.xyz(false); this.allowFuzzy = true; } } `, ); // Model's actual attempt: multi-line @@ anchors await applyPatch( { path: "patch.ts", op: "update", diff: `@@ class PatchTool @@ constructor constructor(session: ToolSession) { this.session = session; - this.patchMode = session.xyz(false); + this.patchMode = session.xyz(true); this.allowFuzzy = true;`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); // Should change PatchTool's constructor, not OtherTool's expect(result).toContain("this.patchMode = session.xyz(true);"); expect(result).toContain("this.mode = false;"); // OtherTool unchanged }); test("nested @@ anchors disambiguate between multiple matching methods", async () => { const filePath = path.join(tempDir, "multi-class.ts"); await Bun.write( filePath, `class Alpha { process() { return "alpha"; } } class Beta { process() { return "beta"; } } `, ); await applyPatch( { path: "multi-class.ts", op: "update", diff: `@@ class Beta @@ process process() { - return "beta"; + return "BETA"; }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain('return "alpha"'); // Alpha unchanged expect(result).toContain('return "BETA"'); // Beta changed }); }); describe("regression: model edit attempt - space-separated anchors (session 2026-01-19)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `model-space-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ class PatchTool constructor is parsed as hierarchical anchors", async () => { const filePath = path.join(tempDir, "tool.ts"); await Bun.write( filePath, `class OtherTool { constructor() { this.value = 1; } } class PatchTool { constructor() { this.value = 2; } } `, ); // Model's actual attempt: space-separated anchors await applyPatch( { path: "tool.ts", op: "update", diff: `@@ class PatchTool constructor constructor() { - this.value = 2; + this.value = 200; }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("this.value = 1;"); // OtherTool unchanged expect(result).toContain("this.value = 200;"); // PatchTool changed }); test("space-separated anchors work with function keyword", async () => { const filePath = path.join(tempDir, "funcs.ts"); await Bun.write( filePath, `function outer() { function helper() { return 1; } return helper(); } function process() { function helper() { return 2; } return helper(); } `, ); await applyPatch( { path: "funcs.ts", op: "update", diff: `@@ function process helper function helper() { - return 2; + return 200; }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); // outer's helper unchanged expect(result).toContain("return 200;"); // process's helper changed }); }); describe("regression: model edit attempt - unique substring on long line (session 2026-01-19 #2)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `model-long-line-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ class ClassName matches long export line when unique", async () => { const filePath = path.join(tempDir, "tool.ts"); // Real-world pattern: long line with export, implements, generics await Bun.write( filePath, `import { Something } from "somewhere"; export class EditTool implements AgentTool { public readonly name = "edit"; constructor(session: ToolSession) { this.session = session; this.patchMode = false; } } `, ); // Model's actual attempt: used "class EditTool" which is only ~12% of line length await applyPatch( { path: "tool.ts", op: "update", diff: `@@ class EditTool constructor(session: ToolSession) { this.session = session; - this.patchMode = false; + this.patchMode = true; }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("this.patchMode = true;"); }); test("@@ class ClassName falls back to unique old lines when context is ambiguous", async () => { const filePath = path.join(tempDir, "multi.ts"); await Bun.write( filePath, `export class EditTool implements AgentTool { value = 1; } export class EditTool implements AgentTool { value = 2; } `, ); await applyPatch( { path: "multi.ts", op: "update", diff: `@@ class EditTool - value = 1; + value = 100;`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("value = 100;"); expect(result).toContain("value = 2;"); }); }); describe("regression: bench edit failures (2026-01-19)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `bench-regression-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("@@ @@ is treated as empty context", async () => { const filePath = path.join(tempDir, "empty-context.txt"); await Bun.write(filePath, "alpha\nbeta\ngamma\n"); await applyPatch( { path: "empty-context.txt", op: "update", diff: `@@ @@\n-beta\n+BETA`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("alpha\nBETA\ngamma\n"); }); test.each([ ["@@ line 3 @@", 3], ["@@ lines 3-5", 3], ["@@ Line 3-5", 3], ["@@ line 3-5 @@", 3], ["@@ @@ line 3", 3], ])("line hint variants (%s) target the correct line", async (header: string, targetLine: number) => { const filePath = path.join(tempDir, `line-hint-${targetLine}.txt`); await Bun.write(filePath, "line 1\nline 2\nline 3\nline 4\nline 5\n"); await applyPatch( { path: `line-hint-${targetLine}.txt`, op: "update", diff: `${header}\n-line 3\n+LINE THREE`, }, { cwd: tempDir }, ); const lines = (await Bun.file(filePath).text()).split("\n"); expect(lines[2]).toBe("LINE THREE"); }); test("top of file header anchors to line 1", async () => { const filePath = path.join(tempDir, "top-of-file.txt"); await Bun.write(filePath, "first\nsecond\n"); await applyPatch( { path: "top-of-file.txt", op: "update", diff: "@@ top of file\n-first\n+FIRST", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("FIRST\nsecond\n"); }); test("function name with empty params matches signature", async () => { const filePath = path.join(tempDir, "functions.ts"); await Bun.write( filePath, `function retryIfBlockedOn(reason: string) {\n return reason;\n}\n\nfunction describeNode(node: object) {\n return String(node);\n}\n`, ); await applyPatch( { path: "functions.ts", op: "update", diff: "@@ retryIfBlockedOn()\n- return reason;\n+ return reason.toUpperCase();", }, { cwd: tempDir }, ); await applyPatch( { path: "functions.ts", op: "update", diff: "@@ describeNode()\n- return String(node);\n+ return JSON.stringify(node);", }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return reason.toUpperCase();"); expect(result).toContain("return JSON.stringify(node);"); }); test("label context falls back to unique old lines", async () => { const filePath = path.join(tempDir, "imports.js"); await Bun.write( filePath, `import { startLoggingProfilingEvents, stopLoggingProfilingEvents } from "../SchedulerProfiling";\n\nexport function run() {\n return startLoggingProfilingEvents();\n}\n`, ); await applyPatch( { path: "imports.js", op: "update", diff: '@@ import block\n-import { startLoggingProfilingEvents, stopLoggingProfilingEvents } from "../SchedulerProfiling";\n+import { stopLoggingProfilingEvents, startLoggingProfilingEvents } from "../SchedulerProfiling";', }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain( 'import { stopLoggingProfilingEvents, startLoggingProfilingEvents } from "../SchedulerProfiling";', ); }); test("ambiguous @@ context resolves via unique old lines", async () => { const filePath = path.join(tempDir, "ambiguous.ts"); await Bun.write( filePath, `function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n\nfunction getState() {\n return 3;\n}\n`, ); await applyPatch( { path: "ambiguous.ts", op: "update", diff: "@@ function getState() {\n- return 2;\n+ return 200;", }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); expect(result).toContain("return 3;"); }); test("duplicate context lines collapse for matching", async () => { const filePath = path.join(tempDir, "duplicate-context.txt"); await Bun.write(filePath, "alpha\nbeta\ngamma\n"); await applyPatch( { path: "duplicate-context.txt", op: "update", diff: "@@\n alpha\n beta\n beta\n-gamma\n+GAMMA", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("alpha\nbeta\nGAMMA\n"); }); test("repeated context blocks collapse when duplicated", async () => { const filePath = path.join(tempDir, "repeated-block.txt"); await Bun.write(filePath, "if (ready) {\n handle();\n}\n"); await applyPatch( { path: "repeated-block.txt", op: "update", diff: "@@\n if (ready) {\n handle();\n}\n if (ready) {\n handle();\n}\n- handle();\n+ handleNext();", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("if (ready) {\n handleNext();\n}\n"); }); test("shared prefix/suffix context is trimmed when mismatched", async () => { const filePath = path.join(tempDir, "trim-context.txt"); await Bun.write(filePath, "function doThing() {\n return 1;\n}\n"); await applyPatch( { path: "trim-context.txt", op: "update", diff: "@@\n // NOTE: helper\n function doThing() {\n- return 1;\n+ return 2;\n }", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toBe("function doThing() {\n return 2;\n}\n"); }); test("single-line change fallback uses the unique changed line", async () => { const filePath = path.join(tempDir, "single-line-change.txt"); await Bun.write(filePath, "function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n"); await applyPatch( { path: "single-line-change.txt", op: "update", diff: "@@ function getState() {\n return 2;\n- return 2;\n+ return 200;\n return 2;", }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); }); test("implicit context lines without prefixes are accepted", async () => { const filePath = path.join(tempDir, "implicit-context.ts"); await Bun.write( filePath, `function getMousePosition(\n relativeContainer: null,\n mouseEvent: SyntheticMouseEvent,\n) {\n if (relativeContainer !== null) {\n return initialTooltipState;\n }\n}\n`, ); await applyPatch( { path: "implicit-context.ts", op: "update", diff: `@@ function getMousePosition(\nrelativeContainer: null,\nmouseEvent: SyntheticMouseEvent,\n) {\n- if (relativeContainer !== null) {\n+ if (relativeContainer === null) {\n return initialTooltipState;\n }`, }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("if (relativeContainer === null)"); }); test("context lines preserve original file indentation when fuzzy matched", async () => { const filePath = path.join(tempDir, "context-indent.js"); // File has 4-space indentation throughout the table await Bun.write( filePath, `export function describeWithPointerEvent(message, describeFn) { const pointerEvent = 'PointerEvent'; const fallback = 'MouseEvent/TouchEvent'; describe.each\` value | name $true | $pointerEvent $true | $fallback \`(\`\${message}: $name\`, entry => { const hasPointerEvents = entry.value; setPointerEvent(hasPointerEvents); describeFn(hasPointerEvents); }); } `, ); // Model provides diff with 3-space indentation in context lines (one less than file) // The changed line should be fixed, but context lines should NOT be modified await applyPatch( { path: "context-indent.js", op: "update", diff: `@@ describe.each\` value | name $true | $pointerEvent - $true | $fallback + $false | $fallback \`(\`\${message}: $name\`, entry => {`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); // The changed line should have correct value (false instead of true) expect(result).toContain("$false | $fallback"); // Context lines should preserve original 4-space indentation, not become 3-space expect(result).toContain(" value | name"); expect(result).toContain(" $true | $pointerEvent"); }); test("duplicate context lines are resolved via adjacent match to @@ anchor", async () => { const filePath = path.join(tempDir, "ReactFlightDOMClientNode.js"); await Bun.write( filePath, `const handleEnd = () => { if (--streamEndedCount === 2) { cleanup(); } if (--streamEndedCount === 2) { finalize(); } }; `, ); await applyPatch( { path: "ReactFlightDOMClientNode.js", op: "update", diff: `@@ const handleEnd = () => { if (--streamEndedCount === 2) { - if (--streamEndedCount === 2) { + if (++streamEndedCount === 2) {`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[1]).toContain("--streamEndedCount"); expect(lines[4]).toContain("++streamEndedCount"); }); test("strip line-number prefixes from diff content", async () => { const filePath = path.join(tempDir, "line-numbers.txt"); await Bun.write( filePath, `Permission is hereby granted, free of charge\nA copy of this software and associated docs\nThe above copyright notice\n`, ); await applyPatch( { path: "line-numbers.txt", op: "update", diff: "@@\n 1\tPermission is hereby granted, free of charge\n- 2\tA copy of this software and associated docs\n+ 2\tA copy of this software AND associated docs\n 3\tThe above copyright notice", }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("A copy of this software AND associated docs"); }); test("ellipsis placeholder lines are ignored during matching", async () => { const filePath = path.join(tempDir, "ellipsis.ts"); await Bun.write( filePath, `function progress(done: boolean, value: string) {\n if (done) {\n return;\n }\n const buffer = value;\n return buffer;\n}\n`, ); await applyPatch( { path: "ellipsis.ts", op: "update", diff: "@@ function progress\n if (done) {\n return;\n }\n...\n- const buffer = value;\n+ const buffer = value.toUpperCase();", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("const buffer = value.toUpperCase();"); }); test("context anchor retryIfBlockedOn() matches signature without params", async () => { const filePath = path.join(tempDir, "context-anchor.ts"); await Bun.write(filePath, `function retryIfBlockedOn(reason: string, blockedOn: mixed) {\n return reason;\n}\n`); await applyPatch( { path: "context-anchor.ts", op: "update", diff: "@@ retryIfBlockedOn()\n- return reason;\n+ return reason.toUpperCase();", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("return reason.toUpperCase();"); }); test("ambiguous context falls back to unique old lines", async () => { const filePath = path.join(tempDir, "ambiguous-context.ts"); await Bun.write( filePath, `function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n\nfunction getState() {\n return 3;\n}\n`, ); await applyPatch( { path: "ambiguous-context.ts", op: "update", diff: "@@ function getState() {\n- return 2;\n+ return 200;", }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); expect(result).toContain("return 3;"); }); test("comment-prefix mismatches still match expected lines", async () => { const filePath = path.join(tempDir, "comment-prefix.txt"); await Bun.write( filePath, `/*\n * LICENSE file in the root directory.\n * Copyright (c) Meta Platforms, Inc.\n */\n`, ); await applyPatch( { path: "comment-prefix.txt", op: "update", diff: "@@\n-/ LICENSE file in the root directory.\n+ / LICENSE file in the root directory.\n", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("/ LICENSE file in the root directory."); }); test("context-less fuzzy match applies even with spacing differences", async () => { const filePath = path.join(tempDir, "fuzzy-contextless.ts"); await Bun.write(filePath, "const value = computeTotal(items);\n"); await applyPatch( { path: "fuzzy-contextless.ts", op: "update", diff: "-const value=computeTotal(items);\n+const value = calculateTotal(items);", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("calculateTotal"); }); test("@@ header without space is accepted", async () => { const filePath = path.join(tempDir, "header-nospace.ts"); await Bun.write(filePath, `const value = 1;\nconst other = 2;\n`); await applyPatch( { path: "header-nospace.ts", op: "update", diff: "@@const value = 1;\n-const value = 1;\n+const value = 100;", }, { cwd: tempDir }, ); expect(await Bun.file(filePath).text()).toContain("const value = 100;"); }); }); describe("regression: trailing context lines don't delete file content", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `trailing-context-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("context lines cannot cause collateral deletion via fuzzy match", async () => { const filePath = path.join(tempDir, "file.ts"); // File has extra content between what the diff shows as context await Bun.write( filePath, `function outer() { function inner() { // This is an important comment return 1; } } `, ); // Diff shows context that skips the comment - should this fail or work? // The expected behavior is: match the context lines, only delete - lines // Since there are no - lines between inner() and return 1, nothing should be deleted await applyPatch( { path: "file.ts", op: "update", diff: `@@ function outer function outer() { function inner() { - return 1; + return 42; } }`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("return 42;"); // The comment should still be there! expect(result).toContain("// This is an important comment"); }); test("unprefixed blank line between changes and trailing context", async () => { const filePath = path.join(tempDir, "terminal.ts"); await Bun.write( filePath, `export class Example { private field = false; get value(): boolean { return this.field; } } `, ); // Blank line has NO prefix (model might emit this) // The implementation treats unprefixed blank lines as context await applyPatch( { path: "terminal.ts", op: "update", diff: `@@ export class Example export class Example { private field = false; + private other = true; get value(): boolean {`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("private other = true;"); expect(result).toContain("return this.field;"); }); test("two-hunk diff with trailing getter context preserves getter body", async () => { const filePath = path.join(tempDir, "terminal.ts"); await Bun.write( filePath, `export class ProcessTerminal implements Terminal { private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; private _kittyProtocolActive = false; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; get kittyProtocolActive(): boolean { return this._kittyProtocolActive; } private safeWrite(data: string): void { try { process.stdout.write(data); } catch (err) { // EIO means terminal is dead - exit gracefully instead of crashing if (err && typeof err === "object" && (err as { code?: string }).code === "EIO") { process.exit(1); } throw err; } } } `, ); await applyPatch( { path: "terminal.ts", op: "update", diff: `@@ export class ProcessTerminal implements Terminal { export class ProcessTerminal implements Terminal { \tprivate wasRaw = false; \tprivate inputHandler?: (data: string) => void; \tprivate resizeHandler?: () => void; \tprivate _kittyProtocolActive = false; \tprivate stdinBuffer?: StdinBuffer; \tprivate stdinDataHandler?: (data: string) => void; +\tprivate dead = false; \tget kittyProtocolActive(): boolean { @@ private safeWrite(data: string): void { \tprivate safeWrite(data: string): void { +\t\tif (this.dead) return; \t\ttry { \t\t\tprocess.stdout.write(data); \t\t} catch (err) { -\t\t\t// EIO means terminal is dead - exit gracefully instead of crashing +\t\t\t// EIO means terminal is dead - mark dead and skip all future writes \t\t\tif (err && typeof err === "object" && (err as { code?: string }).code === "EIO") { -\t\t\t\tprocess.exit(1); +\t\t\t\tthis.dead = true; +\t\t\t\treturn; \t\t\t} \t\t\tthrow err; \t\t} \t}`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("private dead = false;"); expect(result).toContain("return this._kittyProtocolActive;"); expect(result).toContain("if (this.dead) return;"); expect(result).toContain("mark dead and skip all future writes"); expect(result).toContain("this.dead = true;"); }); test("context anchor duplicated as first context line preserves file content", async () => { const filePath = path.join(tempDir, "terminal.ts"); // Original file - exact structure from user's report await Bun.write( filePath, `export class ProcessTerminal implements Terminal { private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; private _kittyProtocolActive = false; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; get kittyProtocolActive(): boolean { return this._kittyProtocolActive; } } `, ); // The anchor line and first context line are IDENTICAL // This is the exact pattern from the user's failing case await applyPatch( { path: "terminal.ts", op: "update", diff: `@@ export class ProcessTerminal implements Terminal { export class ProcessTerminal implements Terminal { private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; private _kittyProtocolActive = false; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; + private dead = false; get kittyProtocolActive(): boolean {`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); expect(result).toContain("private dead = false;"); expect(result).toContain("return this._kittyProtocolActive;"); }); test("adding field with getter as trailing context preserves getter body", async () => { const filePath = path.join(tempDir, "terminal.ts"); // Original file has a getter with a body await Bun.write( filePath, `export class ProcessTerminal { private _kittyProtocolActive = false; private stdinDataHandler?: (data: string) => void; get kittyProtocolActive(): boolean { return this._kittyProtocolActive; } } `, ); // Hunk adds a new field, with getter declaration as trailing context // The getter body should NOT be affected await applyPatch( { path: "terminal.ts", op: "update", diff: `@@ export class ProcessTerminal export class ProcessTerminal { private _kittyProtocolActive = false; private stdinDataHandler?: (data: string) => void; + private dead = false; get kittyProtocolActive(): boolean {`, }, { cwd: tempDir }, ); const result = await Bun.file(filePath).text(); // The new field should be added expect(result).toContain("private dead = false;"); // The getter body should still be there! expect(result).toContain("return this._kittyProtocolActive;"); }); }); describe("regression: context-only hunks between @@ markers must not change indentation (agent-session.ts)", () => { let tempDir: string; beforeEach(() => { tempDir = path.join(os.tmpdir(), `regression-ctx-noop-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { removeSyncWithRetries(tempDir); }); test("pure context hunk (no +/- lines) does not alter tab-indented file content", async () => { const filePath = path.join(tempDir, "agent-session.ts"); // Actual file uses tab indentation (\t\t for method body). // Pre-patch state: three callsites lack `await`. const fileContent = [ "class AgentSession {", "\tasync newSession(options?: NewSessionOptions): Promise {", "\t\tconst previousSessionFile = this.sessionFile;", "", "\t\tthis._disconnectFromAgent();", "\t\tawait this.abort();", "\t\tthis.agent.reset();", "\t\tawait this.sessionManager.flush();", "\t\tthis.sessionManager.newSession(options);", "\t\tthis.agent.sessionId = this.sessionManager.getSessionId();", "\t\tthis._steeringMessages = [];", "\t\tthis._followUpMessages = [];", "\t\tthis._pendingNextTurnMessages = [];", "\t}", "}", "", ].join("\n"); await Bun.write(filePath, fileContent); // Exact patch from the regression case (spaces in diff, tabs in file). // The lines between the first @@ and second @@ are pure context. const diff = [ "@@", " async newSession(options?: NewSessionOptions): Promise {", " const previousSessionFile = this.sessionFile;", "@@", " this._disconnectFromAgent();", " await this.abort();", " this.agent.reset();", " await this.sessionManager.flush();", "- this.sessionManager.newSession(options);", "+ await this.sessionManager.newSession(options);", " this.agent.sessionId = this.sessionManager.getSessionId();", " this._steeringMessages = [];", " this._followUpMessages = [];", " this._pendingNextTurnMessages = [];", ].join("\n"); await applyPatch({ path: "agent-session.ts", op: "update", diff }, { cwd: tempDir }); const result = await Bun.file(filePath).text(); // The change should be applied expect(result).toContain("\t\tawait this.sessionManager.newSession(options);"); // Lines covered by the pure-context hunk must keep their original tab indentation expect(result).toContain("\tasync newSession(options?: NewSessionOptions): Promise {"); expect(result).toContain("\t\tconst previousSessionFile = this.sessionFile;"); expect(result).toContain("\t\tthis._disconnectFromAgent();"); }); test("space-to-tab conversion with offset (ax+b model)", async () => { const filePath = path.join(tempDir, "offset.ts"); // File uses tabs: 1 tab for class body, 2 tabs for method body, 3 for nested const fileContent = [ "class Foo {", "\tbar() {", "\t\tif (true) {", "\t\t\tthis.x = 1;", "\t\t}", "\t}", "}", "", ].join("\n"); await Bun.write(filePath, fileContent); // Model rendered tabs as 3 cols with 1 extra offset: // 1 tab -> 4 spaces, 2 tabs -> 7 spaces, 3 tabs -> 10 spaces // => width=3, offset=1 const diff = [ "@@ class Foo {", " bar() {", "- if (true) {", "- this.x = 1;", "+ if (ready) {", "+ this.x = 42;", " }", ].join("\n"); await applyPatch({ path: "offset.ts", op: "update", diff }, { cwd: tempDir }); const result = await Bun.file(filePath).text(); expect(result).toContain("\t\tif (ready) {"); expect(result).toContain("\t\t\tthis.x = 42;"); // Unchanged lines must keep tabs expect(result).toContain("\tbar() {"); }); });