lonny-agent 0.2.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/llm.d.ts +4 -0
  5. package/dist/agent/llm.d.ts.map +1 -1
  6. package/dist/agent/project.d.ts +26 -0
  7. package/dist/agent/project.d.ts.map +1 -0
  8. package/dist/agent/project.js +303 -0
  9. package/dist/agent/project.js.map +1 -0
  10. package/dist/agent/prompt-builder.d.ts +1 -1
  11. package/dist/agent/prompt-builder.d.ts.map +1 -1
  12. package/dist/agent/prompt-builder.js +22 -13
  13. package/dist/agent/prompt-builder.js.map +1 -1
  14. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  15. package/dist/agent/providers/anthropic.js +20 -2
  16. package/dist/agent/providers/anthropic.js.map +1 -1
  17. package/dist/agent/providers/google.d.ts.map +1 -1
  18. package/dist/agent/providers/google.js +1 -1
  19. package/dist/agent/providers/google.js.map +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts.map +1 -1
  24. package/dist/agent/providers/openai.js +63 -3
  25. package/dist/agent/providers/openai.js.map +1 -1
  26. package/dist/agent/session.d.ts +8 -2
  27. package/dist/agent/session.d.ts.map +1 -1
  28. package/dist/agent/session.js +250 -153
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +7 -1
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/tools/__tests__/bash.test.js +1 -1
  34. package/dist/tools/__tests__/bash.test.js.map +1 -1
  35. package/dist/tools/__tests__/edit.test.js +875 -371
  36. package/dist/tools/__tests__/edit.test.js.map +1 -1
  37. package/dist/tools/__tests__/grep.test.js +32 -0
  38. package/dist/tools/__tests__/grep.test.js.map +1 -1
  39. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  40. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  41. package/dist/tools/__tests__/sed.test.js +228 -0
  42. package/dist/tools/__tests__/sed.test.js.map +1 -0
  43. package/dist/tools/bash.d.ts.map +1 -1
  44. package/dist/tools/bash.js +83 -10
  45. package/dist/tools/bash.js.map +1 -1
  46. package/dist/tools/edit.d.ts +58 -0
  47. package/dist/tools/edit.d.ts.map +1 -1
  48. package/dist/tools/edit.js +366 -149
  49. package/dist/tools/edit.js.map +1 -1
  50. package/dist/tools/grep.d.ts.map +1 -1
  51. package/dist/tools/grep.js +54 -30
  52. package/dist/tools/grep.js.map +1 -1
  53. package/dist/tools/read.d.ts +6 -0
  54. package/dist/tools/read.d.ts.map +1 -1
  55. package/dist/tools/read.js +42 -15
  56. package/dist/tools/read.js.map +1 -1
  57. package/dist/tools/registry.d.ts +0 -6
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +2 -33
  60. package/dist/tools/registry.js.map +1 -1
  61. package/dist/tools/sed.d.ts +4 -0
  62. package/dist/tools/sed.d.ts.map +1 -0
  63. package/dist/tools/sed.js +121 -0
  64. package/dist/tools/sed.js.map +1 -0
  65. package/dist/tools/write_plan.d.ts +1 -1
  66. package/dist/tools/write_plan.d.ts.map +1 -1
  67. package/dist/tools/write_plan.js +14 -4
  68. package/dist/tools/write_plan.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +5 -4
  71. package/dist/tui/index.js.map +1 -1
  72. package/dist/web/index.d.ts.map +1 -1
  73. package/dist/web/index.js +30 -7
  74. package/dist/web/index.js.map +1 -1
  75. package/dist/web/public/app.js +5 -855
  76. package/dist/web/public/confirm.js +70 -0
  77. package/dist/web/public/index.html +121 -120
  78. package/dist/web/public/input.js +107 -0
  79. package/dist/web/public/messages.js +395 -0
  80. package/dist/web/public/sidebar.js +82 -0
  81. package/dist/web/public/state.js +72 -0
  82. package/dist/web/public/style.css +996 -949
  83. package/dist/web/public/utils.js +37 -0
  84. package/dist/web/public/websocket.js +267 -0
  85. package/dist/web/public/ws.js +19 -0
  86. package/dist/web/session-bridge.d.ts.map +1 -1
  87. package/dist/web/session-bridge.js +3 -7
  88. package/dist/web/session-bridge.js.map +1 -1
  89. package/package.json +55 -54
  90. package/scripts/copy-native.mjs +24 -0
  91. package/scripts/copy-web.mjs +15 -0
  92. package/src/agent/index.ts +25 -25
  93. package/src/agent/llm.ts +4 -0
  94. package/src/agent/project.ts +366 -0
  95. package/src/agent/prompt-builder.ts +23 -13
  96. package/src/agent/providers/anthropic.ts +179 -172
  97. package/src/agent/providers/google.ts +210 -211
  98. package/src/agent/providers/ollama.ts +186 -182
  99. package/src/agent/providers/openai.ts +73 -5
  100. package/src/agent/session.ts +275 -153
  101. package/src/config/index.ts +342 -335
  102. package/src/tools/__tests__/bash.test.ts +1 -1
  103. package/src/tools/__tests__/edit.test.ts +981 -385
  104. package/src/tools/__tests__/grep.test.ts +35 -0
  105. package/src/tools/bash.ts +97 -11
  106. package/src/tools/edit.ts +414 -154
  107. package/src/tools/git.ts +76 -76
  108. package/src/tools/grep.ts +57 -30
  109. package/src/tools/read.ts +61 -25
  110. package/src/tools/registry.ts +231 -265
  111. package/src/tools/write_plan.ts +21 -5
  112. package/src/tui/index.ts +786 -785
  113. package/src/web/index.ts +29 -7
  114. package/src/web/public/app.js +5 -855
  115. package/src/web/public/confirm.js +70 -0
  116. package/src/web/public/index.html +121 -120
  117. package/src/web/public/input.js +107 -0
  118. package/src/web/public/messages.js +395 -0
  119. package/src/web/public/sidebar.js +82 -0
  120. package/src/web/public/state.js +72 -0
  121. package/src/web/public/style.css +996 -949
  122. package/src/web/public/utils.js +37 -0
  123. package/src/web/public/websocket.js +267 -0
  124. package/src/web/public/ws.js +19 -0
  125. package/src/web/session-bridge.ts +193 -194
  126. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  127. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  128. package/.lonny/plan-web-cwd-status.md +0 -38
  129. package/src/tools/exec.ts +0 -348
@@ -1,532 +1,1036 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
4
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
5
  import { FileReadTracker } from '../../diff/apply.js';
5
- import { createEditTool } from '../edit.js';
6
+ import { computeDiff, createEditTool, escapeHtml, findAllLinesTolerant, generateDiff, normalizeLine, parseMarkdownEdit, renderDiffTerminal, } from '../edit.js';
6
7
  import { makeTempDir } from './helpers.js';
7
- describe('edit tool', () => {
8
+ // ── Minimal mock for FileReadTracker ─────────────────────────────────────
9
+ function makeApplier() {
10
+ const readSet = new Set();
11
+ return {
12
+ markRead(f) {
13
+ readSet.add(f);
14
+ },
15
+ checkModified(f) {
16
+ return readSet.has(f) ? null : `Warning: file was not read first.`;
17
+ },
18
+ };
19
+ }
20
+ // ── Tests for pure functions ─────────────────────────────────────────────
21
+ describe('normalizeLine', () => {
22
+ it('trims leading whitespace', () => {
23
+ expect(normalizeLine(' hello')).toBe('hello');
24
+ });
25
+ it('trims trailing whitespace', () => {
26
+ expect(normalizeLine('hello ')).toBe('hello');
27
+ });
28
+ it('collapses internal space runs', () => {
29
+ expect(normalizeLine('a b')).toBe('a b');
30
+ });
31
+ it('collapses internal tab runs', () => {
32
+ expect(normalizeLine('a\t\tb')).toBe('a b');
33
+ });
34
+ it('handles mixed spaces and tabs', () => {
35
+ expect(normalizeLine(' a \t b ')).toBe('a b');
36
+ });
37
+ it('reduces blank line with spaces to empty string', () => {
38
+ expect(normalizeLine(' ')).toBe('');
39
+ });
40
+ it('preserves single spaces', () => {
41
+ expect(normalizeLine('a b c')).toBe('a b c');
42
+ });
43
+ it('handles empty string', () => {
44
+ expect(normalizeLine('')).toBe('');
45
+ });
46
+ });
47
+ describe('findAllLinesTolerant', () => {
48
+ it('finds exact match', () => {
49
+ const result = findAllLinesTolerant('hello\nworld\nfoo\n', 'hello\nworld');
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0].index).toBe(0);
52
+ expect(result[0].length).toBe('hello\nworld'.length);
53
+ });
54
+ it('finds match with trailing whitespace in content', () => {
55
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
56
+ expect(result).toHaveLength(1);
57
+ // "hello \n" = 7 chars, "world" = 5 chars, total match = 12
58
+ expect(result[0].length).toBe(12);
59
+ });
60
+ it('finds match with leading whitespace in content', () => {
61
+ const result = findAllLinesTolerant(' hello\nworld\n', 'hello\nworld');
62
+ expect(result).toHaveLength(1);
63
+ // " hello\n" = 8 chars, "world" = 5, total = 13
64
+ expect(result[0].length).toBe(13);
65
+ });
66
+ it('finds match with extra internal spaces', () => {
67
+ const result = findAllLinesTolerant('foo bar\nbaz\n', 'foo bar\nbaz');
68
+ expect(result).toHaveLength(1);
69
+ });
70
+ it('returns empty array for empty oldString', () => {
71
+ expect(findAllLinesTolerant('abc', '')).toEqual([]);
72
+ });
73
+ it('returns empty when oldString longer than content', () => {
74
+ expect(findAllLinesTolerant('a', 'a\nb\nc')).toEqual([]);
75
+ });
76
+ it('returns empty when not found', () => {
77
+ expect(findAllLinesTolerant('abc\ndef\n', 'xyz')).toEqual([]);
78
+ });
79
+ it('finds multiple matches', () => {
80
+ const result = findAllLinesTolerant('a\nb\na\nb\n', 'a\nb');
81
+ expect(result).toHaveLength(2);
82
+ });
83
+ it('computes correct index for non-first match', () => {
84
+ const result = findAllLinesTolerant('x\na\nb\n', 'a\nb');
85
+ expect(result).toHaveLength(1);
86
+ expect(result[0].index).toBe(2); // "x\n" = 2 chars
87
+ });
88
+ it('computes correct length matching original whitespace', () => {
89
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
90
+ expect(result).toHaveLength(1);
91
+ const match = contentSlice('hello \nworld\n', result[0]);
92
+ expect(match).toBe('hello \nworld');
93
+ });
94
+ });
95
+ function contentSlice(s, m) {
96
+ return s.slice(m.index, m.index + m.length);
97
+ }
98
+ describe('computeDiff', () => {
99
+ it('returns empty array for empty strings', () => {
100
+ expect(computeDiff('', '')).toEqual([]);
101
+ });
102
+ it('returns delete lines when new is empty', () => {
103
+ const result = computeDiff('a\nb', '');
104
+ expect(result).toHaveLength(2);
105
+ expect(result.every(l => l.type === 'delete')).toBe(true);
106
+ });
107
+ it('returns insert lines when old is empty', () => {
108
+ const result = computeDiff('', 'a\nb');
109
+ expect(result).toHaveLength(2);
110
+ expect(result.every(l => l.type === 'insert')).toBe(true);
111
+ });
112
+ it('returns equal lines for unchanged content', () => {
113
+ const result = computeDiff('hello\nworld', 'hello\nworld');
114
+ expect(result).toHaveLength(2);
115
+ expect(result.every(l => l.type === 'equal')).toBe(true);
116
+ });
117
+ it('produces mixed diff for changed content', () => {
118
+ const result = computeDiff('hello\nworld', 'hi\nworld');
119
+ // hello→hi should be delete+insert, world should be equal
120
+ expect(result).toHaveLength(3);
121
+ expect(result[0].type).toBe('delete');
122
+ expect(result[0].content).toBe('hello');
123
+ expect(result[1].type).toBe('insert');
124
+ expect(result[1].content).toBe('hi');
125
+ expect(result[2].type).toBe('equal');
126
+ expect(result[2].content).toBe('world');
127
+ });
128
+ });
129
+ describe('renderDiffTerminal', () => {
130
+ it('returns empty string for empty input', () => {
131
+ expect(renderDiffTerminal([])).toBe('');
132
+ });
133
+ it('renders delete lines in red with - prefix', () => {
134
+ const lines = computeDiff('old', '');
135
+ const output = renderDiffTerminal(lines);
136
+ expect(output).toContain('\x1b[38;2;255;80;80m');
137
+ expect(output).toContain('- 1 old');
138
+ expect(output).toContain('\x1b[0m');
139
+ });
140
+ it('renders insert lines in green with + prefix', () => {
141
+ const lines = computeDiff('', 'new');
142
+ const output = renderDiffTerminal(lines);
143
+ expect(output).toContain('\x1b[38;2;0;200;100m');
144
+ expect(output).toContain('+ 1 new');
145
+ });
146
+ it('renders equal lines in dim with space prefix', () => {
147
+ const lines = computeDiff('same', 'same');
148
+ const output = renderDiffTerminal(lines);
149
+ expect(output).toContain('\x1b[38;2;100;100;100m');
150
+ expect(output).toContain(' same');
151
+ expect(output).toContain('\x1b[0m');
152
+ });
153
+ });
154
+ describe('generateDiff', () => {
155
+ it('returns terminal-colored unified diff with line numbers', () => {
156
+ const output = generateDiff('old', 'new');
157
+ expect(output).toContain('\x1b[38;2;255;80;80m');
158
+ expect(output).toContain('- 1 old');
159
+ expect(output).toContain('\x1b[38;2;0;200;100m');
160
+ expect(output).toContain('+ 1 new');
161
+ expect(output).toContain('1');
162
+ });
163
+ it('returns terminal-colored unified diff with custom start line number', () => {
164
+ const output = generateDiff('old', 'new', 10);
165
+ expect(output).toContain('- 10 old');
166
+ expect(output).toContain('+ 10 new');
167
+ });
168
+ });
169
+ describe('escapeHtml', () => {
170
+ it('escapes ampersands', () => {
171
+ expect(escapeHtml('a&b')).toBe('a&b');
172
+ });
173
+ it('escapes less-than', () => {
174
+ expect(escapeHtml('<tag>')).toBe('&lt;tag&gt;');
175
+ });
176
+ it('escapes greater-than', () => {
177
+ expect(escapeHtml('a > b')).toBe('a &gt; b');
178
+ });
179
+ it('escapes double quotes', () => {
180
+ expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;');
181
+ });
182
+ it('handles empty string', () => {
183
+ expect(escapeHtml('')).toBe('');
184
+ });
185
+ it('handles string with no special chars', () => {
186
+ expect(escapeHtml('hello world')).toBe('hello world');
187
+ });
188
+ });
189
+ describe('parseMarkdownEdit', () => {
190
+ it('parses single edit block', () => {
191
+ const input = '```edit\nfile: src/test.ts\nold: |\n hello\nnew: |\n world\n```';
192
+ const edits = parseMarkdownEdit(input);
193
+ expect(edits).toHaveLength(1);
194
+ expect(edits[0].file_path).toBe('src/test.ts');
195
+ // Pipe format preserves leading whitespace
196
+ expect(edits[0].old_string).toBe(' hello');
197
+ expect(edits[0].new_string).toBe(' world');
198
+ });
199
+ it('parses create file (empty old)', () => {
200
+ const input = '```edit\nfile: src/new.ts\nold:\nnew: |\n const x = 1\n```';
201
+ const edits = parseMarkdownEdit(input);
202
+ expect(edits).toHaveLength(1);
203
+ expect(edits[0].file_path).toBe('src/new.ts');
204
+ expect(edits[0].old_string).toBe('');
205
+ // Pipe format preserves leading whitespace
206
+ expect(edits[0].new_string).toBe(' const x = 1');
207
+ });
208
+ it('parses create file with pipe (old: | empty content)', () => {
209
+ const input = [
210
+ '```edit',
211
+ 'file: src/new-pipe.ts',
212
+ 'old: |',
213
+ 'new: |',
214
+ ' const x = 1',
215
+ '```',
216
+ ].join('\n');
217
+ const edits = parseMarkdownEdit(input);
218
+ expect(edits).toHaveLength(1);
219
+ expect(edits[0].file_path).toBe('src/new-pipe.ts');
220
+ expect(edits[0].old_string).toBe('');
221
+ // Pipe format preserves leading whitespace
222
+ expect(edits[0].new_string).toBe(' const x = 1');
223
+ });
224
+ it('parses multiple edit blocks', () => {
225
+ const input = [
226
+ '```edit',
227
+ 'file: a.ts',
228
+ 'old: |',
229
+ ' foo',
230
+ 'new: |',
231
+ ' bar',
232
+ '```',
233
+ '```edit',
234
+ 'file: b.ts',
235
+ 'old: |',
236
+ ' baz',
237
+ 'new: |',
238
+ ' qux',
239
+ '```',
240
+ ].join('\n');
241
+ const edits = parseMarkdownEdit(input);
242
+ expect(edits).toHaveLength(2);
243
+ expect(edits[0].file_path).toBe('a.ts');
244
+ // Pipe format preserves leading whitespace
245
+ expect(edits[0].old_string).toBe(' foo');
246
+ expect(edits[0].new_string).toBe(' bar');
247
+ expect(edits[1].file_path).toBe('b.ts');
248
+ expect(edits[1].old_string).toBe(' baz');
249
+ expect(edits[1].new_string).toBe(' qux');
250
+ });
251
+ it('returns empty array when no edit blocks found', () => {
252
+ expect(parseMarkdownEdit('no code blocks here')).toEqual([]);
253
+ });
254
+ it('returns empty array when block has no file path', () => {
255
+ const input = '```edit\nold: |\n a\nnew: |\n b\n```';
256
+ expect(parseMarkdownEdit(input)).toEqual([]);
257
+ });
258
+ it('parses edit block without pipe (single line)', () => {
259
+ const input = '```edit\nfile: a.ts\nold: hello\nnew: world\n```';
260
+ const edits = parseMarkdownEdit(input);
261
+ expect(edits).toHaveLength(1);
262
+ // Non-pipe format: "old: hello" → content after "old: " includes the leading space
263
+ expect(edits[0].old_string).toBe(' hello');
264
+ expect(edits[0].new_string).toBe(' world');
265
+ });
266
+ it('parses old with pipe-newline format', () => {
267
+ const input = '```edit\nfile: a.ts\nold: |\n multi\n line\nnew: |\n replaced\n```';
268
+ const edits = parseMarkdownEdit(input);
269
+ expect(edits).toHaveLength(1);
270
+ // Pipe format preserves leading whitespace (only strips the first \n)
271
+ expect(edits[0].old_string).toBe(' multi\n line');
272
+ expect(edits[0].new_string).toBe(' replaced');
273
+ });
274
+ it('handles extra whitespace after ```edit', () => {
275
+ const input = '```edit \nfile: a.ts\nold: |\n x\nnew: |\n y\n```';
276
+ const edits = parseMarkdownEdit(input);
277
+ expect(edits).toHaveLength(1);
278
+ expect(edits[0].file_path).toBe('a.ts');
279
+ });
280
+ it('ignores non-edit code blocks', () => {
281
+ const input = '```typescript\nconst x = 1\n```';
282
+ expect(parseMarkdownEdit(input)).toEqual([]);
283
+ });
284
+ it('handles mixed content with edit blocks', () => {
285
+ const input = [
286
+ 'some text before',
287
+ '',
288
+ '```edit',
289
+ 'file: a.ts',
290
+ 'old: |',
291
+ ' hello',
292
+ 'new: |',
293
+ ' hi',
294
+ '```',
295
+ 'some text after',
296
+ ].join('\n');
297
+ const edits = parseMarkdownEdit(input);
298
+ expect(edits).toHaveLength(1);
299
+ expect(edits[0].file_path).toBe('a.ts');
300
+ });
301
+ it('handles early-``` closing (new: outside the block)', () => {
302
+ // Model may close ``` before new: — common mistake
303
+ const input = [
304
+ '```edit',
305
+ 'file: a.ts',
306
+ 'old: |',
307
+ ' hello',
308
+ '```',
309
+ 'new: |',
310
+ ' world',
311
+ '```',
312
+ ].join('\n');
313
+ const edits = parseMarkdownEdit(input);
314
+ expect(edits).toHaveLength(1);
315
+ expect(edits[0].file_path).toBe('a.ts');
316
+ expect(edits[0].old_string).toBe(' hello');
317
+ expect(edits[0].new_string).toBe(' world');
318
+ });
319
+ it('handles raw file:/old:/new: without ``` markers', () => {
320
+ // Model outputs edit without ```edit / ``` wrappers
321
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
322
+ const edits = parseMarkdownEdit(input);
323
+ expect(edits).toHaveLength(1);
324
+ expect(edits[0].file_path).toBe('a.ts');
325
+ expect(edits[0].old_string).toBe(' hello');
326
+ expect(edits[0].new_string).toBe(' world');
327
+ });
328
+ it('handles edit block without any ``` markers (raw file:/old:/new: content)', () => {
329
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
330
+ const edits = parseMarkdownEdit(input);
331
+ expect(edits).toHaveLength(1);
332
+ expect(edits[0].file_path).toBe('a.ts');
333
+ expect(edits[0].old_string).toBe(' hello');
334
+ expect(edits[0].new_string).toBe(' world');
335
+ });
336
+ });
337
+ // ── Markdown format integration tests ────────────────────────────────────
338
+ describe('edit tool — markdown format', () => {
8
339
  let tmpDir;
9
340
  let applier;
10
341
  beforeAll(() => {
11
342
  tmpDir = makeTempDir();
12
343
  fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n');
13
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n');
14
- applier = new FileReadTracker();
344
+ applier = makeApplier();
345
+ applier.markRead(path.join(tmpDir, 'a.txt'));
15
346
  });
16
347
  afterAll(() => {
17
348
  fs.rmSync(tmpDir, { recursive: true, force: true });
18
349
  });
19
350
  const tool = () => createEditTool(applier, tmpDir);
20
- describe('single edit mode', () => {
21
- beforeAll(() => {
22
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n');
23
- fs.writeFileSync(path.join(tmpDir, 'dup.txt'), 'abc\ndef\nabc\n');
24
- });
25
- it('replaces an exact string', async () => {
351
+ it('replaces exact string via markdown format', async () => {
352
+ const input = '```edit\nfile: a.txt\nold: |\n line two\nnew: |\n line TWO\n```';
353
+ const r = await tool().execute({ content: input });
354
+ expect(r.success).toBe(true);
355
+ expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO');
356
+ });
357
+ it('replaces multi-line via markdown format', async () => {
358
+ // Use a fresh file to avoid cross-test contamination
359
+ const file = path.join(tmpDir, 'multi-md.txt');
360
+ fs.writeFileSync(file, 'line one\nline two\nline three\n');
361
+ applier.markRead(file);
362
+ const input = [
363
+ '```edit',
364
+ 'file: multi-md.txt',
365
+ 'old: |',
366
+ ' line one',
367
+ ' line two',
368
+ 'new: |',
369
+ ' line 1',
370
+ ' line 2',
371
+ '```',
372
+ ].join('\n');
373
+ const r = await tool().execute({ content: input });
374
+ expect(r.success).toBe(true);
375
+ // Pipe format preserves leading whitespace from the block
376
+ const content = fs.readFileSync(file, 'utf8');
377
+ expect(content).toContain(' line 1');
378
+ expect(content).toContain(' line 2');
379
+ });
380
+ it('creates file via markdown format', async () => {
381
+ const input = '```edit\nfile: markdown-new.txt\nold:\nnew: |\n created via markdown\n```';
382
+ const r = await tool().execute({ content: input });
383
+ expect(r.success).toBe(true);
384
+ // Pipe format preserves leading whitespace from the block
385
+ expect(fs.readFileSync(path.join(tmpDir, 'markdown-new.txt'), 'utf8')).toBe(' created via markdown');
386
+ });
387
+ it('reports parse error for malformed markdown', async () => {
388
+ const r = await tool().execute({ content: 'not a valid edit block' });
389
+ expect(r.success).toBe(false);
390
+ expect(r.error).toContain('Failed to parse');
391
+ });
392
+ it('applies multiple markdown blocks', async () => {
393
+ fs.writeFileSync(path.join(tmpDir, 'multi-a.txt'), 'foo\nbar\n');
394
+ fs.writeFileSync(path.join(tmpDir, 'multi-b.txt'), 'baz\nqux\n');
395
+ applier.markRead(path.join(tmpDir, 'multi-a.txt'));
396
+ applier.markRead(path.join(tmpDir, 'multi-b.txt'));
397
+ const input = [
398
+ '```edit',
399
+ 'file: multi-a.txt',
400
+ 'old: |',
401
+ ' foo',
402
+ 'new: |',
403
+ ' FOO',
404
+ '```',
405
+ '```edit',
406
+ 'file: multi-b.txt',
407
+ 'old: |',
408
+ ' baz',
409
+ 'new: |',
410
+ ' BAZ',
411
+ '```',
412
+ ].join('\n');
413
+ const r = await tool().execute({ content: input });
414
+ expect(r.success).toBe(true);
415
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-a.txt'), 'utf8')).toContain('FOO');
416
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-b.txt'), 'utf8')).toContain('BAZ');
417
+ });
418
+ });
419
+ // ── Advanced edge cases ──────────────────────────────────────────────────
420
+ describe('edit tool — edge cases', () => {
421
+ let tmpDir;
422
+ let applier;
423
+ beforeAll(() => {
424
+ tmpDir = makeTempDir();
425
+ applier = makeApplier();
426
+ });
427
+ afterAll(() => {
428
+ fs.rmSync(tmpDir, { recursive: true, force: true });
429
+ });
430
+ const tool = () => createEditTool(applier, tmpDir);
431
+ describe('special characters', () => {
432
+ it('handles unicode characters', async () => {
433
+ const file = path.join(tmpDir, 'unicode.txt');
434
+ fs.writeFileSync(file, 'héllo wörld\n');
435
+ applier.markRead(file);
26
436
  const r = await tool().execute({
27
- edits: [{ file_path: 'a.txt', old_string: 'line two', new_string: 'line TWO' }],
437
+ edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
28
438
  });
29
439
  expect(r.success).toBe(true);
30
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO');
440
+ expect(fs.readFileSync(file, 'utf8')).toBe('HELLO WORLD\n');
31
441
  });
32
- it('replaces multi-line string', async () => {
442
+ it('handles emoji characters', async () => {
443
+ const file = path.join(tmpDir, 'emoji.txt');
444
+ fs.writeFileSync(file, 'hello 👍 world\n');
445
+ applier.markRead(file);
33
446
  const r = await tool().execute({
34
447
  edits: [
35
- {
36
- file_path: 'a.txt',
37
- old_string: 'line one\nline TWO',
38
- new_string: 'line 1\nline 2',
39
- },
448
+ { file_path: 'emoji.txt', old_string: 'hello 👍 world', new_string: 'hello world' },
40
449
  ],
41
450
  });
42
451
  expect(r.success).toBe(true);
43
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
44
- expect(content).toContain('line 1');
45
- expect(content).toContain('line 2');
46
- });
47
- it('reports old_string not found', async () => {
48
- const r = await tool().execute({
49
- edits: [{ file_path: 'a.txt', old_string: 'nonexistent', new_string: 'x' }],
50
- });
51
- expect(r.success).toBe(false);
52
- expect(r.error).toContain('not found');
452
+ expect(fs.readFileSync(file, 'utf8')).toBe('hello world\n');
53
453
  });
54
- it('reports duplicate old_string', async () => {
454
+ it('handles Chinese characters', async () => {
455
+ const file = path.join(tmpDir, 'chinese.txt');
456
+ fs.writeFileSync(file, '你好世界\n');
457
+ applier.markRead(file);
55
458
  const r = await tool().execute({
56
- edits: [{ file_path: 'dup.txt', old_string: 'abc', new_string: 'xyz' }],
459
+ edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
57
460
  });
58
- expect(r.success).toBe(false);
59
- expect(r.error).toContain('MULTIPLE times');
60
- });
61
- it('auto-corrects flat file_path/old_string/new_string into edits array', async () => {
62
- // The tool auto-corrects { file_path, old_string, new_string }
63
- // into { edits: [{ file_path, old_string, new_string }] }
64
- // So the error should be about file not found, not 'edits' missing
65
- const r = await tool().execute({ file_path: 'x', old_string: 'y', new_string: 'z' });
66
- expect(r.success).toBe(false);
67
- expect(r.error).toContain('not found');
461
+ expect(r.success).toBe(true);
462
+ expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n');
68
463
  });
69
- it('reports file not found', async () => {
464
+ it('handles tab characters', async () => {
465
+ const file = path.join(tmpDir, 'tabs.txt');
466
+ fs.writeFileSync(file, 'a\tb\nc\td\n');
467
+ applier.markRead(file);
70
468
  const r = await tool().execute({
71
- edits: [{ file_path: 'nonexistent.txt', old_string: 'x', new_string: 'y' }],
469
+ edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
72
470
  });
73
- expect(r.success).toBe(false);
74
- expect(r.error).toContain('not found');
471
+ expect(r.success).toBe(true);
472
+ expect(fs.readFileSync(file, 'utf8')).toBe('A\tB\nc\td\n');
75
473
  });
76
474
  });
77
- describe('validation', () => {
78
- beforeAll(() => {
79
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'test content\n');
80
- });
81
- it('rejects edit missing old_string', async () => {
82
- const r = await tool().execute({
83
- edits: [{ file_path: 'a.txt', new_string: 'replacement' }],
84
- });
85
- expect(r.success).toBe(false);
86
- expect(r.error).toContain('missing');
87
- expect(r.error).toContain('old_string');
88
- });
89
- it('rejects edit missing new_string', async () => {
475
+ describe('empty and boundary files', () => {
476
+ it('edits a file with single line', async () => {
477
+ const file = path.join(tmpDir, 'single.txt');
478
+ fs.writeFileSync(file, 'only line\n');
479
+ applier.markRead(file);
90
480
  const r = await tool().execute({
91
- edits: [{ file_path: 'a.txt', old_string: 'original' }],
481
+ edits: [{ file_path: 'single.txt', old_string: 'only line', new_string: 'replaced line' }],
92
482
  });
93
- expect(r.success).toBe(false);
94
- expect(r.error).toContain('missing');
95
- expect(r.error).toContain('new_string');
483
+ expect(r.success).toBe(true);
484
+ expect(fs.readFileSync(file, 'utf8')).toBe('replaced line\n');
96
485
  });
97
- it('rejects edit missing file_path', async () => {
486
+ it('edits a file with no trailing newline', async () => {
487
+ const file = path.join(tmpDir, 'no-nl.txt');
488
+ fs.writeFileSync(file, 'line one\nline two');
489
+ applier.markRead(file);
98
490
  const r = await tool().execute({
99
- edits: [{ old_string: 'a', new_string: 'b' }],
491
+ edits: [{ file_path: 'no-nl.txt', old_string: 'line two', new_string: 'line TWO' }],
100
492
  });
101
- expect(r.success).toBe(false);
102
- expect(r.error).toContain('missing');
103
- expect(r.error).toContain('file_path');
493
+ expect(r.success).toBe(true);
494
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline TWO');
104
495
  });
105
- it('reports all missing fields in one error', async () => {
496
+ it('appends to end of file', async () => {
497
+ const file = path.join(tmpDir, 'append.txt');
498
+ fs.writeFileSync(file, 'line one\n');
499
+ applier.markRead(file);
106
500
  const r = await tool().execute({
107
501
  edits: [
108
- { file_path: 'a.txt', old_string: 'a', new_string: 'b' },
109
- { file_path: 'a.txt', new_string: 'c' },
110
- { file_path: 'a.txt', old_string: 'd' },
502
+ {
503
+ file_path: 'append.txt',
504
+ old_string: 'line one',
505
+ new_string: 'line one\nline two',
506
+ },
111
507
  ],
112
508
  });
113
- expect(r.success).toBe(false);
114
- // Should report BOTH errors, not just the first
115
- expect(r.error).toContain('2 of 3');
116
- expect(r.error).toContain('edit #2');
117
- expect(r.error).toContain('edit #3');
118
- // Should mention not to split across edits
119
- expect(r.error).toContain('COMPLETE');
120
- });
121
- it('reports which fields each malformed edit has', async () => {
122
- const r = await tool().execute({
123
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
124
- });
125
- expect(r.success).toBe(false);
126
- expect(r.error).toContain('has: file_path, new_string');
127
- });
128
- it('includes rawInput in the error message', async () => {
129
- const r = await tool().execute({
130
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
131
- });
132
- expect(r.success).toBe(false);
133
- expect(r.error).toContain('a.txt');
134
- expect(r.error).toContain('new_string');
135
- });
136
- });
137
- describe('auto-correction', () => {
138
- it('wraps top-level file_path/old_string/new_string into edits array', async () => {
139
- const r = await tool().execute({
140
- file_path: 'nonexistent.ts',
141
- old_string: 'x',
142
- new_string: 'y',
143
- });
144
- // Should be auto-corrected so the error is about file not found, not about missing edits
145
- expect(r.success).toBe(false);
146
- expect(r.error).toContain('not found');
147
- });
148
- it('wraps flat file_path + old_string (missing new_string) with default empty new_string', async () => {
149
- const r = await tool().execute({
150
- file_path: path.join(tmpDir, 'new.txt'),
151
- old_string: '',
152
- });
153
- // Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
154
- // This creates a file with empty content
155
- expect(r.success).toBe(true);
156
- expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true);
157
- expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('');
158
- fs.unlinkSync(path.join(tmpDir, 'new.txt'));
159
- });
160
- it('handles edits passed directly as an array (not wrapped)', async () => {
161
- // @ts-expect-error — runtime accepts array, TS expects object
162
- const r = await tool().execute([
163
- { file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
164
- ]);
165
509
  expect(r.success).toBe(true);
510
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline two\n');
166
511
  });
167
- it('handles file_path + new_string (no old_string) at top level as create', async () => {
512
+ it('replaces entire file content', async () => {
513
+ const file = path.join(tmpDir, 'full-replace.txt');
514
+ fs.writeFileSync(file, 'old content\n');
515
+ applier.markRead(file);
168
516
  const r = await tool().execute({
169
- file_path: path.join(tmpDir, 'auto-create.txt'),
170
- new_string: 'auto created',
171
- });
172
- expect(r.success).toBe(true);
173
- expect(fs.readFileSync(path.join(tmpDir, 'auto-create.txt'), 'utf8')).toBe('auto created');
174
- fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'));
175
- });
176
- it('handles only file_path at top level as empty file creation', async () => {
177
- const r = await tool().execute({
178
- file_path: path.join(tmpDir, 'empty-file.txt'),
517
+ edits: [
518
+ {
519
+ file_path: 'full-replace.txt',
520
+ old_string: 'old content',
521
+ new_string: 'brand new content',
522
+ },
523
+ ],
179
524
  });
180
525
  expect(r.success).toBe(true);
181
- expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true);
182
- expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('');
183
- fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'));
184
- });
185
- it('rejects nonsensical top-level keys with helpful error', async () => {
186
- const r = await tool().execute({ foo: 'bar' });
187
- expect(r.success).toBe(false);
188
- expect(r.error).toContain('edits');
189
- expect(r.error).toContain('array');
526
+ expect(fs.readFileSync(file, 'utf8')).toBe('brand new content\n');
190
527
  });
191
528
  });
192
- describe('batch mode', () => {
193
- beforeAll(() => {
194
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n');
195
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n');
196
- });
197
- it('applies multiple edits to one file', async () => {
529
+ describe('file path edge cases', () => {
530
+ it('creates file in subdirectory that does not exist', async () => {
198
531
  const r = await tool().execute({
199
532
  edits: [
200
- { file_path: 'a.txt', old_string: 'one', new_string: 'ONE' },
201
- { file_path: 'a.txt', old_string: 'three', new_string: 'THREE' },
533
+ {
534
+ file_path: 'deep/nested/dir/new-file.txt',
535
+ old_string: '',
536
+ new_string: 'created in nested dir',
537
+ },
202
538
  ],
203
539
  });
204
540
  expect(r.success).toBe(true);
205
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
206
- expect(content).toContain('ONE');
207
- expect(content).toContain('THREE');
208
- expect(content).toContain('two');
541
+ expect(fs.readFileSync(path.join(tmpDir, 'deep/nested/dir/new-file.txt'), 'utf8')).toBe('created in nested dir');
209
542
  });
210
- it('applies edits across multiple files', async () => {
543
+ it('handles file path with special characters', async () => {
211
544
  const r = await tool().execute({
212
545
  edits: [
213
- { file_path: 'a.txt', old_string: 'ONE', new_string: 'one' },
214
- { file_path: 'b.txt', old_string: 'foo\nbar', new_string: 'FOO\nBAR' },
546
+ {
547
+ file_path: 'file-with-dashes_and_underscores.txt',
548
+ old_string: '',
549
+ new_string: 'special path',
550
+ },
215
551
  ],
216
552
  });
217
553
  expect(r.success).toBe(true);
218
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('one');
219
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('FOO');
220
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('BAR');
554
+ expect(fs.readFileSync(path.join(tmpDir, 'file-with-dashes_and_underscores.txt'), 'utf8')).toBe('special path');
221
555
  });
222
- it('rolls back all changes on failure', async () => {
223
- const original = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
556
+ });
557
+ describe('error message quality', () => {
558
+ it('includes proximity hint when old_string not found', async () => {
559
+ const file = path.join(tmpDir, 'proximity.txt');
560
+ fs.writeFileSync(file, 'keep this line\nfind me\nkeep that line\n');
561
+ applier.markRead(file);
224
562
  const r = await tool().execute({
225
563
  edits: [
226
- { file_path: 'a.txt', old_string: 'THREE', new_string: 'three' },
227
- { file_path: 'a.txt', old_string: 'zzz_nonexistent_zzz', new_string: 'FAIL' },
564
+ {
565
+ file_path: 'proximity.txt',
566
+ old_string: 'find me\nnonexistent',
567
+ new_string: 'x',
568
+ },
228
569
  ],
229
570
  });
230
571
  expect(r.success).toBe(false);
231
- expect(r.error).toContain('rolled back');
232
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(original);
572
+ // Should include proximity info
573
+ expect(r.error).toContain('Near line');
574
+ expect(r.error).toContain('find me');
233
575
  });
234
- it('rolls back multi-file on any failure', async () => {
235
- const origA = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
236
- const origB = fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8');
576
+ it('includes file content preview when no close match found', async () => {
577
+ const file = path.join(tmpDir, 'preview.txt');
578
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
579
+ applier.markRead(file);
237
580
  const r = await tool().execute({
238
581
  edits: [
239
- { file_path: 'a.txt', old_string: 'four', new_string: 'FOUR' },
240
- { file_path: 'missing.txt', old_string: 'x', new_string: 'y' },
582
+ {
583
+ file_path: 'preview.txt',
584
+ old_string: 'zzz_nonexistent_zzz',
585
+ new_string: 'x',
586
+ },
241
587
  ],
242
588
  });
243
589
  expect(r.success).toBe(false);
244
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(origA);
245
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toBe(origB);
246
- });
247
- it('rejects empty edits array', async () => {
248
- const r = await tool().execute({ edits: [] });
249
- expect(r.success).toBe(false);
250
- expect(r.error).toContain('empty');
251
- });
252
- });
253
- describe('diff output', () => {
254
- beforeAll(() => {
255
- fs.writeFileSync(path.join(tmpDir, 'diff-test.txt'), 'line one\nline two\nline three\n');
256
- });
257
- it('shows green + for new file creation (no red - lines)', async () => {
258
- const r = await tool().execute({
259
- edits: [{ file_path: 'diff-new.txt', old_string: '', new_string: 'hello\nworld' }],
260
- });
261
- expect(r.success).toBe(true);
262
- // Should contain green ANSI code for added lines
263
- expect(r.output).toContain('\x1b[38;2;0;200;100m'); // green
264
- expect(r.output).toContain('+ hello');
265
- expect(r.output).toContain('+ world');
266
- // Should NOT contain red ANSI code (no removed lines)
267
- expect(r.output).not.toContain('\x1b[38;2;255;80;80m');
268
- expect(r.output).toContain('Created');
590
+ // Should include file content preview
591
+ expect(r.error).toContain('File content');
592
+ expect(r.error).toContain('alpha');
593
+ expect(r.error).toContain('beta');
269
594
  });
270
- it('shows red - for removed lines and green + for added lines on replacement', async () => {
595
+ it('includes read warning when file not read first', async () => {
596
+ // Don't call markRead — simulate stale content
597
+ const file = path.join(tmpDir, 'no-read.txt');
598
+ fs.writeFileSync(file, 'content\n');
271
599
  const r = await tool().execute({
272
600
  edits: [
273
601
  {
274
- file_path: 'diff-test.txt',
275
- old_string: 'line two',
276
- new_string: 'line TWO',
602
+ file_path: 'no-read.txt',
603
+ old_string: 'zzz',
604
+ new_string: 'x',
277
605
  },
278
606
  ],
279
607
  });
280
- expect(r.success).toBe(true);
281
- // Should contain red ANSI code for removed content
282
- expect(r.output).toContain('\x1b[38;2;255;80;80m');
283
- expect(r.output).toContain('- line two');
284
- // Should contain green ANSI code for added content
285
- expect(r.output).toContain('\x1b[38;2;0;200;100m');
286
- expect(r.output).toContain('+ line TWO');
287
- expect(r.output).toContain('Edited');
608
+ expect(r.success).toBe(false);
609
+ expect(r.error).toContain('was not read first');
288
610
  });
289
- it('shows multi-line diff correctly', async () => {
611
+ it('includes rawInput in error for easy debugging', async () => {
290
612
  const r = await tool().execute({
291
613
  edits: [
292
614
  {
293
- file_path: 'diff-test.txt',
294
- old_string: 'line one\nline TWO',
295
- new_string: 'line 1\nline 2',
615
+ file_path: 'nonexistent.txt',
616
+ old_string: 'hello',
617
+ new_string: 'world',
296
618
  },
297
619
  ],
298
620
  });
299
- expect(r.success).toBe(true);
300
- // Removed lines
301
- expect(r.output).toContain('- line one');
302
- expect(r.output).toContain('- line TWO');
303
- // Added lines
304
- expect(r.output).toContain('+ line 1');
305
- expect(r.output).toContain('+ line 2');
621
+ expect(r.success).toBe(false);
622
+ expect(r.error).toContain('nonexistent.txt');
623
+ expect(r.error).toContain('hello');
624
+ expect(r.error).toContain('world');
306
625
  });
307
- it('handles CRLF in old_string (Windows compatibility)', async () => {
308
- fs.writeFileSync(path.join(tmpDir, 'crlf-test.txt'), 'a\\r\\nb\\r\\nc\\r\\n');
626
+ });
627
+ describe('output format', () => {
628
+ it('prefixes output with "Edited" for modifications', async () => {
629
+ const file = path.join(tmpDir, 'output-test.txt');
630
+ fs.writeFileSync(file, 'original\n');
631
+ applier.markRead(file);
309
632
  const r = await tool().execute({
310
- edits: [
311
- {
312
- file_path: 'crlf-test.txt',
313
- // Simulate AI sending old_string with \\r\\n (CRLF) on Windows
314
- old_string: 'b\\r\\n',
315
- new_string: 'B\\r\\n',
316
- },
317
- ],
633
+ edits: [{ file_path: 'output-test.txt', old_string: 'original', new_string: 'modified' }],
318
634
  });
319
635
  expect(r.success).toBe(true);
320
- const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8');
321
- expect(content).toContain('B');
322
- // Should show the normalized diff (with \\n, not \\r\\n)
323
- expect(r.output).toContain('- b');
324
- expect(r.output).toContain('+ B');
636
+ expect(r.output).toContain('Edited');
325
637
  });
326
- it('does not show empty red line when old_string is empty', async () => {
638
+ it('prefixes output with "Created" for new files', async () => {
327
639
  const r = await tool().execute({
328
- edits: [{ file_path: 'empty-old.txt', old_string: '', new_string: 'only added' }],
640
+ edits: [{ file_path: 'output-create.txt', old_string: '', new_string: 'new file' }],
329
641
  });
330
642
  expect(r.success).toBe(true);
331
- // Should have green + lines
332
- expect(r.output).toContain('+ only added');
333
- // Count red-start markers — should be 0 (no removed content)
334
- const redCount = (r.output.match(/\x1b\[38;2;255;80;80m/g) || []).length;
335
- expect(redCount).toBe(0);
336
- });
337
- });
338
- describe('create mode', () => {
339
- const createdDir = () => path.join(tmpDir, 'created-test');
340
- afterAll(() => {
341
- fs.rmSync(createdDir(), { recursive: true, force: true });
643
+ expect(r.output).toContain('Created');
342
644
  });
343
- it('creates a new file with empty old_string', async () => {
645
+ it('includes line count for created files', async () => {
344
646
  const r = await tool().execute({
345
647
  edits: [
346
648
  {
347
- file_path: 'created-test/new.ts',
649
+ file_path: 'output-count.txt',
348
650
  old_string: '',
349
- new_string: 'const x = 1\nexport { x }',
651
+ new_string: 'line1\nline2\nline3',
350
652
  },
351
653
  ],
352
654
  });
353
655
  expect(r.success).toBe(true);
354
- const content = fs.readFileSync(path.join(createdDir(), 'new.ts'), 'utf8');
355
- expect(content).toBe('const x = 1\nexport { x }');
656
+ expect(r.output).toContain('3 lines');
356
657
  });
357
- it('creates a file in batch mode', async () => {
658
+ });
659
+ describe('concurrent edits to same line area', () => {
660
+ it('applies edits bottom-to-top within same file', async () => {
661
+ const file = path.join(tmpDir, 'concurrent.txt');
662
+ fs.writeFileSync(file, 'first\nsecond\nthird\n');
663
+ applier.markRead(file);
358
664
  const r = await tool().execute({
359
665
  edits: [
360
- { file_path: 'created-test/a.ts', old_string: '', new_string: '// a' },
361
- { file_path: 'created-test/b.ts', old_string: '', new_string: '// b' },
666
+ { file_path: 'concurrent.txt', old_string: 'first', new_string: 'FIRST' },
667
+ { file_path: 'concurrent.txt', old_string: 'third', new_string: 'THIRD' },
362
668
  ],
363
669
  });
364
670
  expect(r.success).toBe(true);
365
- expect(fs.readFileSync(path.join(createdDir(), 'a.ts'), 'utf8')).toBe('// a');
366
- expect(fs.readFileSync(path.join(createdDir(), 'b.ts'), 'utf8')).toBe('// b');
367
- });
368
- it('rejects create when file already exists', async () => {
369
- const r = await tool().execute({
370
- edits: [
371
- {
372
- file_path: 'created-test/new.ts',
373
- old_string: '',
374
- new_string: 'x',
375
- },
376
- ],
377
- });
378
- expect(r.success).toBe(false);
379
- expect(r.error).toContain('already exists');
671
+ const content = fs.readFileSync(file, 'utf8');
672
+ expect(content).toBe('FIRST\nsecond\nTHIRD\n');
380
673
  });
381
- it('rolls back on create failure in batch', async () => {
674
+ it('applies edits bottom-to-top to avoid line offset issues', async () => {
675
+ const file = path.join(tmpDir, 'offset.txt');
676
+ fs.writeFileSync(file, 'a\nb\nc\n');
677
+ applier.markRead(file);
678
+ // If applied top-to-bottom: after replacing "a" with "A1\nA2",
679
+ // "c" would be at line 4, not line 2
382
680
  const r = await tool().execute({
383
681
  edits: [
384
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'keep me' },
385
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'duplicate' },
682
+ { file_path: 'offset.txt', old_string: 'a', new_string: 'A1\nA2' },
683
+ { file_path: 'offset.txt', old_string: 'c', new_string: 'C' },
386
684
  ],
387
685
  });
388
- expect(r.success).toBe(false);
389
- expect(fs.existsSync(path.join(createdDir(), 'rollback-1.ts'))).toBe(false);
686
+ expect(r.success).toBe(true);
687
+ const content = fs.readFileSync(file, 'utf8');
688
+ expect(content).toBe('A1\nA2\nb\nC\n');
390
689
  });
391
690
  });
392
- describe('whitespace tolerance', () => {
393
- const wsDir = () => path.join(tmpDir, 'ws-test');
394
- const f = (name) => path.join(wsDir(), name);
395
- beforeAll(() => {
396
- fs.mkdirSync(wsDir(), { recursive: true });
397
- });
398
- const tool = () => createEditTool(applier, wsDir());
399
- it('handles trailing space in file (AI omits trailing space)', async () => {
400
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
691
+ describe('CRLF handling', () => {
692
+ it('handles CRLF in file content', async () => {
693
+ const file = path.join(tmpDir, 'crlf-content.txt');
694
+ fs.writeFileSync(file, 'hello\r\nworld\r\n');
695
+ applier.markRead(file);
401
696
  const r = await tool().execute({
402
697
  edits: [
403
- { file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
698
+ { file_path: 'crlf-content.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
404
699
  ],
405
700
  });
406
701
  expect(r.success).toBe(true);
407
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
408
- // Should indicate whitespace-normalized in output
409
- expect(r.output).toContain('whitespace-normalized');
702
+ const content = fs.readFileSync(file, 'utf8');
703
+ expect(content).toBe('HELLO\nWORLD\n');
410
704
  });
411
- it('handles trailing space in old_string (AI adds extra trailing space)', async () => {
412
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
705
+ });
706
+ describe('rollback behavior', () => {
707
+ it('restores original file content on failure', async () => {
708
+ const file = path.join(tmpDir, 'rollback-restore.txt');
709
+ fs.writeFileSync(file, 'original content\n');
710
+ applier.markRead(file);
711
+ const original = fs.readFileSync(file, 'utf8');
413
712
  const r = await tool().execute({
414
713
  edits: [
415
- { file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
714
+ {
715
+ file_path: 'rollback-restore.txt',
716
+ old_string: 'original content',
717
+ new_string: 'changed',
718
+ },
719
+ { file_path: 'rollback-restore.txt', old_string: 'nonexistent', new_string: 'fail' },
416
720
  ],
417
721
  });
418
- expect(r.success).toBe(true);
419
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
722
+ expect(r.success).toBe(false);
723
+ expect(r.error).toContain('rolled back');
724
+ // File should be restored to original
725
+ expect(fs.readFileSync(file, 'utf8')).toBe(original);
420
726
  });
421
- it('handles leading space in file (AI omits leading indent)', async () => {
422
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n');
727
+ it('deletes newly created file on rollback', async () => {
423
728
  const r = await tool().execute({
424
729
  edits: [
425
- { file_path: 'leading.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
730
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'new file' },
731
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'duplicate' },
426
732
  ],
427
733
  });
428
- expect(r.success).toBe(true);
429
- // Leading spaces in the original file become part of the matched block
430
- // and are preserved in the result
431
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n');
734
+ expect(r.success).toBe(false);
735
+ expect(fs.existsSync(path.join(tmpDir, 'rollback-new.txt'))).toBe(false);
736
+ });
737
+ });
738
+ describe('multi-file scenarios', () => {
739
+ beforeAll(() => {
740
+ fs.writeFileSync(path.join(tmpDir, 'mf-a.txt'), 'edit me\n');
741
+ fs.writeFileSync(path.join(tmpDir, 'mf-b.txt'), 'edit me too\n');
742
+ applier.markRead(path.join(tmpDir, 'mf-a.txt'));
743
+ applier.markRead(path.join(tmpDir, 'mf-b.txt'));
432
744
  });
433
- it('handles leading space in old_string (AI adds extra indent)', async () => {
434
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n');
745
+ it('edits multiple existing files in one call', async () => {
435
746
  const r = await tool().execute({
436
747
  edits: [
437
- { file_path: 'leading.txt', old_string: ' hello\nworld', new_string: 'HELLO\nWORLD' },
748
+ { file_path: 'mf-a.txt', old_string: 'edit me', new_string: 'edited' },
749
+ { file_path: 'mf-b.txt', old_string: 'edit me too', new_string: 'edited too' },
438
750
  ],
439
751
  });
440
752
  expect(r.success).toBe(true);
441
- // The file's leading spaces are part of the matched block, so they
442
- // get replaced along with the rest of the matched text
443
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
753
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited\n');
754
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-b.txt'), 'utf8')).toBe('edited too\n');
444
755
  });
445
- it('handles extra internal spaces in file (AI sends single space)', async () => {
446
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n');
756
+ it('mixes create and edit across files', async () => {
447
757
  const r = await tool().execute({
448
758
  edits: [
449
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
759
+ { file_path: 'mf-a.txt', old_string: 'edited', new_string: 'edited again' },
760
+ { file_path: 'mf-create-new.txt', old_string: '', new_string: 'newly created' },
450
761
  ],
451
762
  });
452
763
  expect(r.success).toBe(true);
453
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n');
764
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited again\n');
765
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-create-new.txt'), 'utf8')).toBe('newly created');
454
766
  });
455
- it('handles extra internal spaces in old_string (AI adds double space)', async () => {
456
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n');
767
+ it('rolls back mixed create+edit on failure', async () => {
768
+ const origA = fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8');
457
769
  const r = await tool().execute({
458
770
  edits: [
459
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
771
+ { file_path: 'mf-a.txt', old_string: 'edited again', new_string: 'changed' },
772
+ { file_path: 'mf-rollback-new.txt', old_string: '', new_string: 'will be deleted' },
773
+ { file_path: 'mf-nonexistent.txt', old_string: 'nope', new_string: 'x' },
460
774
  ],
461
775
  });
462
- expect(r.success).toBe(true);
463
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n');
464
- });
465
- it('handles blank line with spaces matching empty blank line', async () => {
466
- fs.writeFileSync(f('blank.txt'), 'foo\n \nbar\n');
467
- const r = await tool().execute({
468
- edits: [{ file_path: 'blank.txt', old_string: 'foo\n\nbar', new_string: 'FOO\n\nBAR' }],
469
- });
470
- expect(r.success).toBe(true);
471
- // The blank line with spaces is part of the matched block and gets
472
- // replaced entirely by the new_string's blank line
473
- expect(fs.readFileSync(f('blank.txt'), 'utf8')).toBe('FOO\n\nBAR\n');
474
- });
475
- it('handles mixed whitespace across multiple lines', async () => {
476
- fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n');
477
- const r = await tool().execute({
478
- edits: [{ file_path: 'mixed.txt', old_string: 'a\nb\nc', new_string: 'A\nB\nC' }],
479
- });
480
- expect(r.success).toBe(true);
481
- expect(fs.readFileSync(f('mixed.txt'), 'utf8')).toBe('A\nB\nC\n');
482
- });
483
- it('still fails when text content differs (not just whitespace)', async () => {
484
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
485
- const r = await tool().execute({
486
- edits: [{ file_path: 'trailing.txt', old_string: 'hello\nmundo', new_string: 'x\ny' }],
487
- });
488
776
  expect(r.success).toBe(false);
489
- expect(r.error).toContain('not found');
490
- });
491
- it('still fails for completely wrong old_string', async () => {
492
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
493
- const r = await tool().execute({
494
- edits: [{ file_path: 'trailing.txt', old_string: 'zzz\nzzz', new_string: 'x\ny' }],
495
- });
496
- expect(r.success).toBe(false);
497
- expect(r.error).toContain('not found');
498
- });
499
- it('reports duplicate in whitespace-normalized mode', async () => {
500
- fs.writeFileSync(f('duplicate.txt'), 'a \nb\na \nb\n');
501
- const r = await tool().execute({
502
- edits: [{ file_path: 'duplicate.txt', old_string: 'a\nb', new_string: 'X\nY' }],
503
- });
504
- expect(r.success).toBe(false);
505
- expect(r.error).toContain('MULTIPLE times');
506
- expect(r.error).toContain('whitespace-normalized');
777
+ expect(r.error).toContain('rolled back');
778
+ // Existing file restored
779
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe(origA);
780
+ // Created file should be deleted
781
+ expect(fs.existsSync(path.join(tmpDir, 'mf-rollback-new.txt'))).toBe(false);
507
782
  });
508
- it('prefers exact match when available', async () => {
509
- fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n');
783
+ it('creates multiple files in subdirectories', async () => {
510
784
  const r = await tool().execute({
511
785
  edits: [
512
- { file_path: 'exact-prefer.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
786
+ { file_path: 'multi-dir/sub1/x.ts', old_string: '', new_string: '// x' },
787
+ { file_path: 'multi-dir/sub2/y.ts', old_string: '', new_string: '// y' },
513
788
  ],
514
789
  });
515
790
  expect(r.success).toBe(true);
516
- expect(fs.readFileSync(f('exact-prefer.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
517
- // Exact match should NOT have the whitespace-normalized note
518
- expect(r.output).not.toContain('whitespace-normalized');
791
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub1/x.ts'), 'utf8')).toBe('// x');
792
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub2/y.ts'), 'utf8')).toBe('// y');
519
793
  });
520
- it('provides context snippet when old_string not found', async () => {
521
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
522
- const r = await tool().execute({
523
- edits: [{ file_path: 'trailing.txt', old_string: 'nonexistent_line_xyz', new_string: 'x' }],
524
- });
794
+ it('creates multiple files with markdown format', async () => {
795
+ const input = [
796
+ '```edit',
797
+ 'file: md-multi-1.txt',
798
+ 'old:',
799
+ 'new: |',
800
+ ' first file',
801
+ '```',
802
+ '```edit',
803
+ 'file: md-multi-2.txt',
804
+ 'old:',
805
+ 'new: |',
806
+ ' second file',
807
+ '```',
808
+ ].join('\n');
809
+ const r = await tool().execute({ content: input });
810
+ expect(r.success).toBe(true);
811
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-1.txt'), 'utf8')).toBe(' first file');
812
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-2.txt'), 'utf8')).toBe(' second file');
813
+ });
814
+ it('rolls back multi-file via markdown format on failure', async () => {
815
+ const file = path.join(tmpDir, 'md-rollback-target.txt');
816
+ fs.writeFileSync(file, 'preserve me\n');
817
+ applier.markRead(file);
818
+ const input = [
819
+ '```edit',
820
+ 'file: md-rollback-target.txt',
821
+ 'old: |',
822
+ ' preserve me',
823
+ 'new: |',
824
+ ' changed',
825
+ '```',
826
+ '```edit',
827
+ 'file: md-nonexistent.txt',
828
+ 'old: |',
829
+ ' not there',
830
+ 'new: |',
831
+ ' fail',
832
+ '```',
833
+ ].join('\n');
834
+ const r = await tool().execute({ content: input });
525
835
  expect(r.success).toBe(false);
526
- expect(r.error).toContain('not found');
527
- // Should contain a snippet with the file's actual content
528
- expect(r.error).toContain('hello');
836
+ expect(r.error).toContain('rolled back');
837
+ // Target file should be restored
838
+ expect(fs.readFileSync(file, 'utf8')).toBe('preserve me\n');
529
839
  });
530
840
  });
531
841
  });
842
+ // ── Model-generated edit format integration tests ──────────────────────
843
+ // These tests call a real LLM (DeepSeek-V4-Flash via ~/.lonny/config.json)
844
+ // to generate edit format content, then feed it into the edit tool.
845
+ const LONNY_CONFIG_PATH = path.join(os.homedir(), '.lonny', 'config.json');
846
+ function loadLonnyConfig() {
847
+ try {
848
+ return JSON.parse(fs.readFileSync(LONNY_CONFIG_PATH, 'utf-8'));
849
+ }
850
+ catch {
851
+ return {};
852
+ }
853
+ }
854
+ const lonnyConfig = loadLonnyConfig();
855
+ const hasApiKey = !!lonnyConfig.apiKey;
856
+ /** Call the model with a prompt and return the full text response (non-streaming). */
857
+ async function callModel(prompt, signal) {
858
+ const { default: OpenAI } = await import('openai');
859
+ const client = new OpenAI({
860
+ apiKey: lonnyConfig.apiKey,
861
+ baseURL: lonnyConfig.baseUrl,
862
+ });
863
+ const response = await client.chat.completions.create({
864
+ model: lonnyConfig.model || 'deepseek-v4-flash',
865
+ messages: [{ role: 'user', content: prompt }],
866
+ stream: false,
867
+ max_tokens: 4096,
868
+ }, signal ? { signal } : undefined);
869
+ return response.choices[0]?.message?.content || '';
870
+ }
871
+ describe.runIf(hasApiKey)('model-generated edit format', () => {
872
+ const MODEL_TIMEOUT = 60_000; // Model API calls can take 10-30s
873
+ let tmpDir;
874
+ let applier;
875
+ beforeAll(() => {
876
+ tmpDir = makeTempDir();
877
+ applier = new FileReadTracker();
878
+ });
879
+ afterAll(() => {
880
+ fs.rmSync(tmpDir, { recursive: true, force: true });
881
+ });
882
+ const tool = () => createEditTool(applier, tmpDir);
883
+ it('model replaces exact string in a file', { timeout: MODEL_TIMEOUT }, async () => {
884
+ const file = path.join(tmpDir, 'simple.txt');
885
+ fs.writeFileSync(file, 'hello world\nfoo bar\n');
886
+ applier.markRead(file);
887
+ const prompt = [
888
+ 'Output ONLY an edit code block (```edit ... ```) that replaces "hello world" with "hello universe" in file "simple.txt".',
889
+ '',
890
+ 'The EXACT format is:',
891
+ '```edit',
892
+ 'file: simple.txt',
893
+ 'old: |',
894
+ ' hello world',
895
+ 'new: |',
896
+ ' hello universe',
897
+ '```',
898
+ '',
899
+ 'Do NOT add quotes, do NOT add backticks around the text inside old/new. Output ONLY the code block.',
900
+ ].join('\n');
901
+ const modelOutput = await callModel(prompt);
902
+ const edits = parseMarkdownEdit(modelOutput);
903
+ expect(edits.length).toBeGreaterThanOrEqual(1);
904
+ const result = await tool().execute({ content: modelOutput });
905
+ expect(result.success).toBe(true);
906
+ const content = fs.readFileSync(file, 'utf8');
907
+ expect(content).toContain('hello universe');
908
+ expect(content).toContain('foo bar');
909
+ });
910
+ it('model replaces multi-line text in a file', { timeout: MODEL_TIMEOUT }, async () => {
911
+ const file = path.join(tmpDir, 'multiline.txt');
912
+ fs.writeFileSync(file, 'line A\nline B\nline C\nline D\n');
913
+ applier.markRead(file);
914
+ const prompt = [
915
+ 'Output ONLY an edit code block that replaces:',
916
+ '',
917
+ ' line B',
918
+ ' line C',
919
+ '',
920
+ 'with:',
921
+ '',
922
+ ' line X',
923
+ ' line Y',
924
+ '',
925
+ `in file "multiline.txt".`,
926
+ '',
927
+ 'Format:',
928
+ '```edit',
929
+ 'file: multiline.txt',
930
+ 'old: |',
931
+ ' line B',
932
+ ' line C',
933
+ 'new: |',
934
+ ' line X',
935
+ ' line Y',
936
+ '```',
937
+ '',
938
+ 'IMPORTANT: old: must contain EXACTLY "line B" and "line C" on separate lines with 2-space indent.',
939
+ 'Do NOT add quotes around the text. Output ONLY the code block.',
940
+ ].join('\n');
941
+ const modelOutput = await callModel(prompt);
942
+ const edits = parseMarkdownEdit(modelOutput);
943
+ expect(edits.length).toBeGreaterThanOrEqual(1);
944
+ const result = await tool().execute({ content: modelOutput });
945
+ expect(result.success).toBe(true);
946
+ const content = fs.readFileSync(file, 'utf8');
947
+ expect(content).toContain('line X');
948
+ expect(content).toContain('line Y');
949
+ });
950
+ it('model creates a new file', { timeout: MODEL_TIMEOUT }, async () => {
951
+ const prompt = [
952
+ 'Output ONLY an edit code block that CREATES a new file called "fresh.txt" with content "hello world".',
953
+ '',
954
+ 'Format (note: old: has NO content - this means create):',
955
+ '```edit',
956
+ 'file: fresh.txt',
957
+ 'old:',
958
+ 'new: |',
959
+ ' hello world',
960
+ '```',
961
+ '',
962
+ 'Output ONLY the code block.',
963
+ ].join('\n');
964
+ const modelOutput = await callModel(prompt);
965
+ const edits = parseMarkdownEdit(modelOutput);
966
+ expect(edits.length).toBeGreaterThanOrEqual(1);
967
+ expect(edits[0].file_path).toBe('fresh.txt');
968
+ const result = await tool().execute({ content: modelOutput });
969
+ expect(result.success).toBe(true);
970
+ expect(fs.readFileSync(path.join(tmpDir, 'fresh.txt'), 'utf8')).toContain('hello world');
971
+ });
972
+ it('model edits a file with special characters (double quotes)', {
973
+ timeout: MODEL_TIMEOUT,
974
+ }, async () => {
975
+ const file = path.join(tmpDir, 'quotes.txt');
976
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
977
+ applier.markRead(file);
978
+ const prompt = [
979
+ 'Output ONLY an edit code block that replaces "beta" with "BETA" in file "quotes.txt".',
980
+ '',
981
+ '```edit',
982
+ 'file: quotes.txt',
983
+ 'old: |',
984
+ ' beta',
985
+ 'new: |',
986
+ ' BETA',
987
+ '```',
988
+ '',
989
+ 'Output ONLY the code block. No extra text.',
990
+ ].join('\n');
991
+ const modelOutput = await callModel(prompt);
992
+ const edits = parseMarkdownEdit(modelOutput);
993
+ expect(edits.length).toBeGreaterThanOrEqual(1);
994
+ const result = await tool().execute({ content: modelOutput });
995
+ expect(result.success).toBe(true);
996
+ const content = fs.readFileSync(file, 'utf8');
997
+ expect(content).toContain('BETA');
998
+ expect(content).toContain('alpha');
999
+ expect(content).toContain('gamma');
1000
+ });
1001
+ it('model generates edit block that round-trips successfully', {
1002
+ timeout: MODEL_TIMEOUT,
1003
+ }, async () => {
1004
+ const file = path.join(tmpDir, 'roundtrip.txt');
1005
+ fs.writeFileSync(file, 'first\nsecond\nthird\nfourth\n');
1006
+ applier.markRead(file);
1007
+ const prompt = [
1008
+ 'Output ONLY an edit code block that replaces "second" with "SECOND" in file "roundtrip.txt".',
1009
+ 'Include 1 line of context before and after to ensure uniqueness.',
1010
+ '',
1011
+ 'Format:',
1012
+ '```edit',
1013
+ 'file: roundtrip.txt',
1014
+ 'old: |',
1015
+ ' first',
1016
+ ' second',
1017
+ ' third',
1018
+ 'new: |',
1019
+ ' first',
1020
+ ' SECOND',
1021
+ ' third',
1022
+ '```',
1023
+ '',
1024
+ 'Output ONLY the code block.',
1025
+ ].join('\n');
1026
+ const modelOutput = await callModel(prompt);
1027
+ const edits = parseMarkdownEdit(modelOutput);
1028
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1029
+ const result = await tool().execute({ content: modelOutput });
1030
+ expect(result.success).toBe(true);
1031
+ const content = fs.readFileSync(file, 'utf8');
1032
+ expect(content).toContain('SECOND');
1033
+ expect(content).toContain('fourth');
1034
+ });
1035
+ });
532
1036
  //# sourceMappingURL=edit.test.js.map