lonny-agent 0.2.4 → 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.
- package/README.md +326 -326
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +13 -9
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +43 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +4 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +45 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +1 -1
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +875 -371
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +32 -0
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +83 -10
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +50 -9
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +328 -158
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +54 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/registry.d.ts +0 -6
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -29
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +26 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +395 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +996 -949
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +3 -7
- package/dist/web/session-bridge.js.map +1 -1
- package/package.json +55 -54
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/index.ts +25 -25
- package/src/agent/llm.ts +4 -0
- package/src/agent/prompt-builder.ts +13 -9
- package/src/agent/providers/anthropic.ts +179 -179
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -186
- package/src/agent/providers/openai.ts +61 -5
- package/src/agent/session.ts +59 -3
- package/src/config/index.ts +342 -335
- package/src/tools/__tests__/bash.test.ts +1 -1
- package/src/tools/__tests__/edit.test.ts +981 -385
- package/src/tools/__tests__/grep.test.ts +35 -0
- package/src/tools/bash.ts +97 -11
- package/src/tools/edit.ts +362 -169
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/registry.ts +2 -31
- package/src/tui/index.ts +786 -786
- package/src/web/index.ts +25 -3
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +395 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +996 -949
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +193 -194
- package/.claude/settings.local.json +0 -14
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- 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 {
|
|
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
|
-
|
|
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&b')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('escapes less-than', () => {
|
|
216
|
+
expect(escapeHtml('<tag>')).toBe('<tag>')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('escapes greater-than', () => {
|
|
220
|
+
expect(escapeHtml('a > b')).toBe('a > b')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('escapes double quotes', () => {
|
|
224
|
+
expect(escapeHtml('say "hello"')).toBe('say "hello"')
|
|
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
|
-
|
|
16
|
-
applier
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
493
|
+
// ── Advanced edge cases ──────────────────────────────────────────────────
|
|
494
|
+
describe('edit tool — edge cases', () => {
|
|
495
|
+
let tmpDir: string
|
|
496
|
+
let applier: FileReadTracker
|
|
93
497
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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: '
|
|
515
|
+
edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
|
|
115
516
|
})
|
|
116
|
-
expect(r.success).toBe(
|
|
117
|
-
expect(
|
|
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('
|
|
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: '
|
|
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(
|
|
130
|
-
|
|
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('
|
|
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: '
|
|
539
|
+
edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
|
|
141
540
|
})
|
|
142
|
-
expect(r.success).toBe(
|
|
143
|
-
expect(
|
|
541
|
+
expect(r.success).toBe(true)
|
|
542
|
+
expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n')
|
|
144
543
|
})
|
|
145
544
|
|
|
146
|
-
it('
|
|
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: '
|
|
550
|
+
edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
|
|
149
551
|
})
|
|
150
|
-
expect(r.success).toBe(
|
|
151
|
-
expect(
|
|
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('
|
|
157
|
-
it('
|
|
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: '
|
|
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
|
-
|
|
164
|
-
expect(
|
|
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('
|
|
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:
|
|
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('
|
|
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
|
-
|
|
192
|
-
|
|
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(
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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('
|
|
218
|
-
|
|
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
|
-
{
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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('
|
|
632
|
+
it('handles file path with special characters', async () => {
|
|
238
633
|
const r = await tool().execute({
|
|
239
634
|
edits: [
|
|
240
|
-
{
|
|
241
|
-
|
|
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(
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
{
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
expect(
|
|
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('
|
|
265
|
-
const
|
|
266
|
-
|
|
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
|
-
{
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
expect(
|
|
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('
|
|
292
|
-
|
|
293
|
-
|
|
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: '
|
|
310
|
-
old_string: '
|
|
311
|
-
new_string: '
|
|
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(
|
|
316
|
-
|
|
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('
|
|
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: '
|
|
330
|
-
old_string: '
|
|
331
|
-
new_string: '
|
|
710
|
+
file_path: 'nonexistent.txt',
|
|
711
|
+
old_string: 'hello',
|
|
712
|
+
new_string: 'world',
|
|
332
713
|
},
|
|
333
714
|
],
|
|
334
715
|
})
|
|
335
|
-
expect(r.success).toBe(
|
|
336
|
-
|
|
337
|
-
expect(r.
|
|
338
|
-
expect(r.
|
|
339
|
-
// Added lines (green)
|
|
340
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mline 1')
|
|
341
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mline 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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
358
|
-
expect(content).toContain('B')
|
|
359
|
-
// Should show the normalized diff (with \\n, not \\r\\n)
|
|
360
|
-
expect(r.output).toContain('\x1b[38;2;255;80;80mb')
|
|
361
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100mB')
|
|
732
|
+
expect(r.output).toContain('Edited')
|
|
362
733
|
})
|
|
363
734
|
|
|
364
|
-
it('
|
|
735
|
+
it('prefixes output with "Created" for new files', async () => {
|
|
365
736
|
const r = await tool().execute({
|
|
366
|
-
edits: [{ file_path: '
|
|
737
|
+
edits: [{ file_path: 'output-create.txt', old_string: '', new_string: 'new file' }],
|
|
367
738
|
})
|
|
368
739
|
expect(r.success).toBe(true)
|
|
369
|
-
|
|
370
|
-
expect(r.output).toContain('\x1b[38;2;0;200;100monly 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('
|
|
743
|
+
it('includes line count for created files', async () => {
|
|
385
744
|
const r = await tool().execute({
|
|
386
745
|
edits: [
|
|
387
746
|
{
|
|
388
|
-
file_path: '
|
|
747
|
+
file_path: 'output-count.txt',
|
|
389
748
|
old_string: '',
|
|
390
|
-
new_string: '
|
|
749
|
+
new_string: 'line1\nline2\nline3',
|
|
391
750
|
},
|
|
392
751
|
],
|
|
393
752
|
})
|
|
394
753
|
expect(r.success).toBe(true)
|
|
395
|
-
|
|
396
|
-
expect(content).toBe('const x = 1\nexport { x }')
|
|
754
|
+
expect(r.output).toContain('3 lines')
|
|
397
755
|
})
|
|
756
|
+
})
|
|
398
757
|
|
|
399
|
-
|
|
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: '
|
|
403
|
-
{ file_path: '
|
|
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
|
-
|
|
408
|
-
expect(
|
|
770
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
771
|
+
expect(content).toBe('FIRST\nsecond\nTHIRD\n')
|
|
409
772
|
})
|
|
410
773
|
|
|
411
|
-
it('
|
|
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
|
-
|
|
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(
|
|
422
|
-
|
|
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
|
-
|
|
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: '
|
|
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(
|
|
433
|
-
|
|
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('
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
fs.
|
|
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
|
-
{
|
|
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(
|
|
455
|
-
expect(
|
|
456
|
-
//
|
|
457
|
-
expect(
|
|
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('
|
|
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: '
|
|
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(
|
|
468
|
-
expect(fs.
|
|
838
|
+
expect(r.success).toBe(false)
|
|
839
|
+
expect(fs.existsSync(path.join(tmpDir, 'rollback-new.txt'))).toBe(false)
|
|
469
840
|
})
|
|
841
|
+
})
|
|
470
842
|
|
|
471
|
-
|
|
472
|
-
|
|
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: '
|
|
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
|
-
|
|
480
|
-
|
|
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('
|
|
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: '
|
|
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
|
-
|
|
493
|
-
|
|
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('
|
|
498
|
-
fs.
|
|
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: '
|
|
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(
|
|
505
|
-
expect(
|
|
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('
|
|
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: '
|
|
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(
|
|
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('
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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('
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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('
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
})
|