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