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