@xandout/libra-harness 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +20 -9
  3. package/dist/agent.js.map +1 -1
  4. package/dist/extras/extensions/code-tools/extension.d.ts +69 -0
  5. package/dist/extras/extensions/code-tools/extension.d.ts.map +1 -0
  6. package/dist/extras/extensions/code-tools/extension.js +83 -0
  7. package/dist/extras/extensions/code-tools/extension.js.map +1 -0
  8. package/dist/extras/extensions/code-tools/extension.json +7 -0
  9. package/dist/extras/extensions/code-tools/extension.test.d.ts +2 -0
  10. package/dist/extras/extensions/code-tools/extension.test.d.ts.map +1 -0
  11. package/dist/extras/extensions/code-tools/extension.test.js +656 -0
  12. package/dist/extras/extensions/code-tools/extension.test.js.map +1 -0
  13. package/dist/extras/extensions/code-tools/index.d.ts +3 -0
  14. package/dist/extras/extensions/code-tools/index.d.ts.map +1 -0
  15. package/dist/extras/extensions/code-tools/index.js +2 -0
  16. package/dist/extras/extensions/code-tools/index.js.map +1 -0
  17. package/dist/extras/extensions/code-tools/tools/find.d.ts +3 -0
  18. package/dist/extras/extensions/code-tools/tools/find.d.ts.map +1 -0
  19. package/dist/extras/extensions/code-tools/tools/find.js +160 -0
  20. package/dist/extras/extensions/code-tools/tools/find.js.map +1 -0
  21. package/dist/extras/extensions/code-tools/tools/grep.d.ts +3 -0
  22. package/dist/extras/extensions/code-tools/tools/grep.d.ts.map +1 -0
  23. package/dist/extras/extensions/code-tools/tools/grep.js +306 -0
  24. package/dist/extras/extensions/code-tools/tools/grep.js.map +1 -0
  25. package/dist/extras/extensions/code-tools/tools/read.d.ts +3 -0
  26. package/dist/extras/extensions/code-tools/tools/read.d.ts.map +1 -0
  27. package/dist/extras/extensions/code-tools/tools/read.js +97 -0
  28. package/dist/extras/extensions/code-tools/tools/read.js.map +1 -0
  29. package/dist/extras/extensions/code-tools/tools/shared.d.ts +14 -0
  30. package/dist/extras/extensions/code-tools/tools/shared.d.ts.map +1 -0
  31. package/dist/extras/extensions/code-tools/tools/shared.js +53 -0
  32. package/dist/extras/extensions/code-tools/tools/shared.js.map +1 -0
  33. package/dist/extras/extensions/code-tools/tools/shell.d.ts +47 -0
  34. package/dist/extras/extensions/code-tools/tools/shell.d.ts.map +1 -0
  35. package/dist/extras/extensions/code-tools/tools/shell.js +492 -0
  36. package/dist/extras/extensions/code-tools/tools/shell.js.map +1 -0
  37. package/dist/extras/extensions/code-tools/tools/todo.d.ts +24 -0
  38. package/dist/extras/extensions/code-tools/tools/todo.d.ts.map +1 -0
  39. package/dist/extras/extensions/code-tools/tools/todo.js +131 -0
  40. package/dist/extras/extensions/code-tools/tools/todo.js.map +1 -0
  41. package/dist/extras/extensions/code-tools/tools/write.d.ts +4 -0
  42. package/dist/extras/extensions/code-tools/tools/write.d.ts.map +1 -0
  43. package/dist/extras/extensions/code-tools/tools/write.js +179 -0
  44. package/dist/extras/extensions/code-tools/tools/write.js.map +1 -0
  45. package/dist/extras/extensions/disk-session/extension.d.ts +5 -0
  46. package/dist/extras/extensions/disk-session/extension.d.ts.map +1 -1
  47. package/dist/extras/extensions/disk-session/extension.js +114 -16
  48. package/dist/extras/extensions/disk-session/extension.js.map +1 -1
  49. package/package.json +5 -1
@@ -0,0 +1,656 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { Agent } from '@xandout/libra-harness';
6
+ import createCodeToolsExtension from './extension.js';
7
+ // ── Mock model that echoes the last user message ─────────────────────
8
+ function mockModel() {
9
+ return {
10
+ async generate() {
11
+ return {
12
+ message: { role: 'assistant', content: 'ok' },
13
+ finishReason: 'stop',
14
+ };
15
+ },
16
+ };
17
+ }
18
+ describe('code-tools extension', () => {
19
+ let tmpDir;
20
+ beforeEach(() => {
21
+ tmpDir = mkdtempSync(join(tmpdir(), 'code-tools-test-'));
22
+ });
23
+ afterEach(() => {
24
+ rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+ // ── read tool ──────────────────────────────────────────────────────
27
+ describe('read tool', () => {
28
+ it('reads a text file with line numbers', async () => {
29
+ const filePath = join(tmpDir, 'test.txt');
30
+ writeFileSync(filePath, 'line one\nline two\nline three');
31
+ const agent = new Agent({ model: mockModel() });
32
+ agent.use(createCodeToolsExtension());
33
+ // Access the tool directly via the agent's tool registry.
34
+ const tools = agent.tools;
35
+ expect(tools).toBeDefined();
36
+ expect(tools.size).toBe(10); // read, write, edit, find_file_by_name, grep, exec, get_output, kill_shell, write_to_process, todo_write
37
+ const readTool = tools.get('read');
38
+ expect(readTool).toBeDefined();
39
+ expect(readTool.name).toBe('read');
40
+ const result = await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata: {} });
41
+ expect(result.content).toContain('test.txt');
42
+ expect(result.content).toContain('line one');
43
+ expect(result.content).toContain('line two');
44
+ expect(result.content).toContain('line three');
45
+ // Line numbers should be present
46
+ expect(result.content).toMatch(/1\tline one/);
47
+ expect(result.content).toMatch(/2\tline two/);
48
+ expect(result.content).toMatch(/3\tline three/);
49
+ });
50
+ it('returns error for non-existent file', async () => {
51
+ const agent = new Agent({ model: mockModel() });
52
+ agent.use(createCodeToolsExtension());
53
+ const tools = agent.tools;
54
+ const readTool = tools.get('read');
55
+ const result = await readTool.execute({ file_path: join(tmpDir, 'nope.txt') }, { signal: new AbortController().signal, metadata: {} });
56
+ expect(result.content).toContain('File not found');
57
+ });
58
+ it('returns error for directory path', async () => {
59
+ const agent = new Agent({ model: mockModel() });
60
+ agent.use(createCodeToolsExtension());
61
+ const tools = agent.tools;
62
+ const readTool = tools.get('read');
63
+ const result = await readTool.execute({ file_path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
64
+ expect(result.content).toContain('directory');
65
+ });
66
+ it('respects offset and limit', async () => {
67
+ const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
68
+ const filePath = join(tmpDir, 'big.txt');
69
+ writeFileSync(filePath, lines.join('\n'));
70
+ const agent = new Agent({ model: mockModel() });
71
+ agent.use(createCodeToolsExtension());
72
+ const tools = agent.tools;
73
+ const readTool = tools.get('read');
74
+ const result = await readTool.execute({ file_path: filePath, offset: 10, limit: 5 }, { signal: new AbortController().signal, metadata: {} });
75
+ expect(result.content).toContain('line 10');
76
+ expect(result.content).toContain('line 14');
77
+ expect(result.content).not.toContain('line 9');
78
+ expect(result.content).not.toContain('line 15');
79
+ expect(result.content).toContain('showing lines 10-14');
80
+ });
81
+ it('returns base64 for binary files', async () => {
82
+ const filePath = join(tmpDir, 'data.bin');
83
+ writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
84
+ const agent = new Agent({ model: mockModel() });
85
+ agent.use(createCodeToolsExtension());
86
+ const tools = agent.tools;
87
+ const readTool = tools.get('read');
88
+ const result = await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata: {} });
89
+ expect(result.content).toContain('binary');
90
+ expect(result.content).toContain('base64:');
91
+ });
92
+ it('returns data URL for image files', async () => {
93
+ const filePath = join(tmpDir, 'pic.png');
94
+ writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
95
+ const agent = new Agent({ model: mockModel() });
96
+ agent.use(createCodeToolsExtension());
97
+ const tools = agent.tools;
98
+ const readTool = tools.get('read');
99
+ const result = await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata: {} });
100
+ expect(result.content).toContain('image/png');
101
+ expect(result.content).toContain('data:image/png;base64,');
102
+ });
103
+ it('tracks read paths in metadata for edit/write enforcement', async () => {
104
+ const filePath = join(tmpDir, 'test.txt');
105
+ writeFileSync(filePath, 'hello');
106
+ const agent = new Agent({ model: mockModel() });
107
+ agent.use(createCodeToolsExtension());
108
+ const tools = agent.tools;
109
+ const readTool = tools.get('read');
110
+ const metadata = {};
111
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
112
+ const readSet = metadata.__codeToolsReadPaths;
113
+ expect(readSet).toBeDefined();
114
+ expect(readSet.has(filePath)).toBe(true);
115
+ });
116
+ });
117
+ // ── write tool ─────────────────────────────────────────────────────
118
+ describe('write tool', () => {
119
+ it('creates a new file', async () => {
120
+ const filePath = join(tmpDir, 'new.txt');
121
+ const agent = new Agent({ model: mockModel() });
122
+ agent.use(createCodeToolsExtension());
123
+ const tools = agent.tools;
124
+ const writeTool = tools.get('write');
125
+ const result = await writeTool.execute({ file_path: filePath, content: 'hello world' }, { signal: new AbortController().signal, metadata: {} });
126
+ expect(result.content).toContain('Wrote 11 chars');
127
+ expect(readFileSync(filePath, 'utf-8')).toBe('hello world');
128
+ });
129
+ it('creates parent directories', async () => {
130
+ const filePath = join(tmpDir, 'sub', 'dir', 'file.txt');
131
+ const agent = new Agent({ model: mockModel() });
132
+ agent.use(createCodeToolsExtension());
133
+ const tools = agent.tools;
134
+ const writeTool = tools.get('write');
135
+ const result = await writeTool.execute({ file_path: filePath, content: 'nested' }, { signal: new AbortController().signal, metadata: {} });
136
+ expect(result.content).toContain('Wrote');
137
+ expect(readFileSync(filePath, 'utf-8')).toBe('nested');
138
+ });
139
+ it('overwrites an existing file after reading it', async () => {
140
+ const filePath = join(tmpDir, 'existing.txt');
141
+ writeFileSync(filePath, 'old content');
142
+ const agent = new Agent({ model: mockModel() });
143
+ agent.use(createCodeToolsExtension());
144
+ const tools = agent.tools;
145
+ const readTool = tools.get('read');
146
+ const writeTool = tools.get('write');
147
+ const metadata = {};
148
+ // Read first
149
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
150
+ // Now overwrite
151
+ const result = await writeTool.execute({ file_path: filePath, content: 'new content' }, { signal: new AbortController().signal, metadata });
152
+ expect(result.content).toContain('Wrote');
153
+ expect(readFileSync(filePath, 'utf-8')).toBe('new content');
154
+ });
155
+ it('refuses to overwrite without reading first', async () => {
156
+ const filePath = join(tmpDir, 'protected.txt');
157
+ writeFileSync(filePath, 'original');
158
+ const agent = new Agent({ model: mockModel() });
159
+ agent.use(createCodeToolsExtension());
160
+ const tools = agent.tools;
161
+ const writeTool = tools.get('write');
162
+ const result = await writeTool.execute({ file_path: filePath, content: 'hacked' }, { signal: new AbortController().signal, metadata: {} });
163
+ expect(result.isError).toBe(true);
164
+ expect(result.content).toContain('must read');
165
+ expect(readFileSync(filePath, 'utf-8')).toBe('original');
166
+ });
167
+ });
168
+ // ── edit tool ──────────────────────────────────────────────────────
169
+ describe('edit tool', () => {
170
+ it('replaces a unique string', async () => {
171
+ const filePath = join(tmpDir, 'edit.txt');
172
+ writeFileSync(filePath, 'const x = 1;\nconst y = 2;\n');
173
+ const agent = new Agent({ model: mockModel() });
174
+ agent.use(createCodeToolsExtension());
175
+ const tools = agent.tools;
176
+ const readTool = tools.get('read');
177
+ const editTool = tools.get('edit');
178
+ const metadata = {};
179
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
180
+ const result = await editTool.execute({ file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 42;' }, { signal: new AbortController().signal, metadata });
181
+ expect(result.content).toContain('Replaced 1 occurrence');
182
+ expect(readFileSync(filePath, 'utf-8')).toBe('const x = 42;\nconst y = 2;\n');
183
+ });
184
+ it('fails if old_string not found', async () => {
185
+ const filePath = join(tmpDir, 'no-match.txt');
186
+ writeFileSync(filePath, 'hello world');
187
+ const agent = new Agent({ model: mockModel() });
188
+ agent.use(createCodeToolsExtension());
189
+ const tools = agent.tools;
190
+ const readTool = tools.get('read');
191
+ const editTool = tools.get('edit');
192
+ const metadata = {};
193
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
194
+ const result = await editTool.execute({ file_path: filePath, old_string: 'nonexistent', new_string: 'replacement' }, { signal: new AbortController().signal, metadata });
195
+ expect(result.isError).toBe(true);
196
+ expect(result.content).toContain('not found');
197
+ });
198
+ it('fails on non-unique match without replace_all', async () => {
199
+ const filePath = join(tmpDir, 'dup.txt');
200
+ writeFileSync(filePath, 'const x = 1;\nconst x = 1;\n');
201
+ const agent = new Agent({ model: mockModel() });
202
+ agent.use(createCodeToolsExtension());
203
+ const tools = agent.tools;
204
+ const readTool = tools.get('read');
205
+ const editTool = tools.get('edit');
206
+ const metadata = {};
207
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
208
+ const result = await editTool.execute({ file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' }, { signal: new AbortController().signal, metadata });
209
+ expect(result.isError).toBe(true);
210
+ expect(result.content).toContain('2 times');
211
+ });
212
+ it('replaces all occurrences with replace_all', async () => {
213
+ const filePath = join(tmpDir, 'all.txt');
214
+ writeFileSync(filePath, 'foo bar foo bar foo');
215
+ const agent = new Agent({ model: mockModel() });
216
+ agent.use(createCodeToolsExtension());
217
+ const tools = agent.tools;
218
+ const readTool = tools.get('read');
219
+ const editTool = tools.get('edit');
220
+ const metadata = {};
221
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
222
+ const result = await editTool.execute({ file_path: filePath, old_string: 'foo', new_string: 'baz', replace_all: true }, { signal: new AbortController().signal, metadata });
223
+ expect(result.content).toContain('Replaced 3 occurrences');
224
+ expect(readFileSync(filePath, 'utf-8')).toBe('baz bar baz bar baz');
225
+ });
226
+ it('refuses to edit without reading first', async () => {
227
+ const filePath = join(tmpDir, 'unread.txt');
228
+ writeFileSync(filePath, 'original');
229
+ const agent = new Agent({ model: mockModel() });
230
+ agent.use(createCodeToolsExtension());
231
+ const tools = agent.tools;
232
+ const editTool = tools.get('edit');
233
+ const result = await editTool.execute({ file_path: filePath, old_string: 'original', new_string: 'changed' }, { signal: new AbortController().signal, metadata: {} });
234
+ expect(result.isError).toBe(true);
235
+ expect(result.content).toContain('must read');
236
+ });
237
+ it('rejects identical old_string and new_string', async () => {
238
+ const filePath = join(tmpDir, 'same.txt');
239
+ writeFileSync(filePath, 'hello');
240
+ const agent = new Agent({ model: mockModel() });
241
+ agent.use(createCodeToolsExtension());
242
+ const tools = agent.tools;
243
+ const readTool = tools.get('read');
244
+ const editTool = tools.get('edit');
245
+ const metadata = {};
246
+ await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata });
247
+ const result = await editTool.execute({ file_path: filePath, old_string: 'hello', new_string: 'hello' }, { signal: new AbortController().signal, metadata });
248
+ expect(result.isError).toBe(true);
249
+ expect(result.content).toContain('must differ');
250
+ });
251
+ });
252
+ // ── Extension config ───────────────────────────────────────────────
253
+ describe('configuration', () => {
254
+ it('supports tool prefix', async () => {
255
+ const agent = new Agent({ model: mockModel() });
256
+ agent.use(createCodeToolsExtension({ toolPrefix: 'code' }));
257
+ const tools = agent.tools;
258
+ expect(tools.get('code_read')).toBeDefined();
259
+ expect(tools.get('code_write')).toBeDefined();
260
+ expect(tools.get('code_edit')).toBeDefined();
261
+ expect(tools.get('code_exec')).toBeDefined();
262
+ });
263
+ it('respects maxReadSize', async () => {
264
+ const filePath = join(tmpDir, 'big.txt');
265
+ writeFileSync(filePath, 'x'.repeat(2048));
266
+ const agent = new Agent({ model: mockModel() });
267
+ agent.use(createCodeToolsExtension({ maxReadSize: 100 }));
268
+ const tools = agent.tools;
269
+ const readTool = tools.get('read');
270
+ const result = await readTool.execute({ file_path: filePath }, { signal: new AbortController().signal, metadata: {} });
271
+ expect(result.content).toContain('too large');
272
+ });
273
+ });
274
+ // ── find_file_by_name tool ─────────────────────────────────────────
275
+ describe('find_file_by_name tool', () => {
276
+ it('finds files by glob pattern', async () => {
277
+ writeFileSync(join(tmpDir, 'a.ts'), '');
278
+ writeFileSync(join(tmpDir, 'b.ts'), '');
279
+ writeFileSync(join(tmpDir, 'c.js'), '');
280
+ mkdirSync(join(tmpDir, 'sub'));
281
+ writeFileSync(join(tmpDir, 'sub', 'd.ts'), '');
282
+ const agent = new Agent({ model: mockModel() });
283
+ agent.use(createCodeToolsExtension());
284
+ const tools = agent.tools;
285
+ const findTool = tools.get('find_file_by_name');
286
+ const result = await findTool.execute({ pattern: '*.ts', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
287
+ expect(result.content).toContain('a.ts');
288
+ expect(result.content).toContain('b.ts');
289
+ expect(result.content).not.toContain('c.js');
290
+ // *.ts should not match in subdirectories
291
+ expect(result.content).not.toContain('d.ts');
292
+ });
293
+ it('finds files recursively with **', async () => {
294
+ writeFileSync(join(tmpDir, 'top.py'), '');
295
+ mkdirSync(join(tmpDir, 'deep', 'nested'), { recursive: true });
296
+ writeFileSync(join(tmpDir, 'deep', 'nested', 'bottom.py'), '');
297
+ const agent = new Agent({ model: mockModel() });
298
+ agent.use(createCodeToolsExtension());
299
+ const tools = agent.tools;
300
+ const findTool = tools.get('find_file_by_name');
301
+ const result = await findTool.execute({ pattern: '**/*.py', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
302
+ expect(result.content).toContain('top.py');
303
+ expect(result.content).toContain('bottom.py');
304
+ });
305
+ it('supports brace expansion', async () => {
306
+ writeFileSync(join(tmpDir, 'a.ts'), '');
307
+ writeFileSync(join(tmpDir, 'a.tsx'), '');
308
+ writeFileSync(join(tmpDir, 'a.js'), '');
309
+ const agent = new Agent({ model: mockModel() });
310
+ agent.use(createCodeToolsExtension());
311
+ const tools = agent.tools;
312
+ const findTool = tools.get('find_file_by_name');
313
+ const result = await findTool.execute({ pattern: '*.{ts,tsx}', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
314
+ expect(result.content).toContain('a.ts');
315
+ expect(result.content).toContain('a.tsx');
316
+ expect(result.content).not.toContain('a.js');
317
+ });
318
+ it('returns message when no files match', async () => {
319
+ const agent = new Agent({ model: mockModel() });
320
+ agent.use(createCodeToolsExtension());
321
+ const tools = agent.tools;
322
+ const findTool = tools.get('find_file_by_name');
323
+ const result = await findTool.execute({ pattern: '*.nonexistent', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
324
+ expect(result.content).toContain('No files matching');
325
+ });
326
+ it('skips node_modules and .git', async () => {
327
+ mkdirSync(join(tmpDir, 'node_modules'));
328
+ writeFileSync(join(tmpDir, 'node_modules', 'dep.ts'), '');
329
+ mkdirSync(join(tmpDir, '.git'));
330
+ writeFileSync(join(tmpDir, '.git', 'config.ts'), '');
331
+ writeFileSync(join(tmpDir, 'real.ts'), '');
332
+ const agent = new Agent({ model: mockModel() });
333
+ agent.use(createCodeToolsExtension());
334
+ const tools = agent.tools;
335
+ const findTool = tools.get('find_file_by_name');
336
+ const result = await findTool.execute({ pattern: '**/*.ts', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
337
+ expect(result.content).toContain('real.ts');
338
+ expect(result.content).not.toContain('dep.ts');
339
+ expect(result.content).not.toContain('config.ts');
340
+ });
341
+ });
342
+ // ── grep tool ──────────────────────────────────────────────────────
343
+ describe('grep tool', () => {
344
+ it('finds matching lines in content mode', async () => {
345
+ writeFileSync(join(tmpDir, 'code.ts'), 'const x = 1;\nconst y = 2;\nconst x = 3;');
346
+ const agent = new Agent({ model: mockModel() });
347
+ agent.use(createCodeToolsExtension());
348
+ const tools = agent.tools;
349
+ const grepTool = tools.get('grep');
350
+ const result = await grepTool.execute({ pattern: 'const x', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
351
+ expect(result.content).toContain('code.ts:1:');
352
+ expect(result.content).toContain('code.ts:3:');
353
+ expect(result.content).toContain('2 matches');
354
+ });
355
+ it('supports files_with_matches mode', async () => {
356
+ writeFileSync(join(tmpDir, 'a.txt'), 'hello world');
357
+ writeFileSync(join(tmpDir, 'b.txt'), 'hello there');
358
+ writeFileSync(join(tmpDir, 'c.txt'), 'no match here');
359
+ const agent = new Agent({ model: mockModel() });
360
+ agent.use(createCodeToolsExtension());
361
+ const tools = agent.tools;
362
+ const grepTool = tools.get('grep');
363
+ const result = await grepTool.execute({ pattern: 'hello', path: tmpDir, output_mode: 'files_with_matches' }, { signal: new AbortController().signal, metadata: {} });
364
+ expect(result.content).toContain('a.txt');
365
+ expect(result.content).toContain('b.txt');
366
+ expect(result.content).not.toContain('c.txt');
367
+ });
368
+ it('supports count mode', async () => {
369
+ writeFileSync(join(tmpDir, 'a.txt'), 'foo\nfoo\nbar');
370
+ writeFileSync(join(tmpDir, 'b.txt'), 'foo\nbar');
371
+ const agent = new Agent({ model: mockModel() });
372
+ agent.use(createCodeToolsExtension());
373
+ const tools = agent.tools;
374
+ const grepTool = tools.get('grep');
375
+ const result = await grepTool.execute({ pattern: 'foo', path: tmpDir, output_mode: 'count' }, { signal: new AbortController().signal, metadata: {} });
376
+ expect(result.content).toContain('a.txt:2');
377
+ expect(result.content).toContain('b.txt:1');
378
+ expect(result.content).toContain('Total: 3');
379
+ });
380
+ it('supports case_insensitive', async () => {
381
+ writeFileSync(join(tmpDir, 'mix.txt'), 'Hello\nHELLO\nworld');
382
+ const agent = new Agent({ model: mockModel() });
383
+ agent.use(createCodeToolsExtension());
384
+ const tools = agent.tools;
385
+ const grepTool = tools.get('grep');
386
+ const result = await grepTool.execute({ pattern: 'hello', path: tmpDir, case_insensitive: true }, { signal: new AbortController().signal, metadata: {} });
387
+ expect(result.content).toContain('mix.txt:1:');
388
+ expect(result.content).toContain('mix.txt:2:');
389
+ });
390
+ it('supports context_lines', async () => {
391
+ writeFileSync(join(tmpDir, 'ctx.txt'), 'line1\nline2\nMATCH\nline4\nline5');
392
+ const agent = new Agent({ model: mockModel() });
393
+ agent.use(createCodeToolsExtension());
394
+ const tools = agent.tools;
395
+ const grepTool = tools.get('grep');
396
+ const result = await grepTool.execute({ pattern: 'MATCH', path: tmpDir, context_lines: 1 }, { signal: new AbortController().signal, metadata: {} });
397
+ expect(result.content).toContain('ctx.txt:2:');
398
+ expect(result.content).toContain('ctx.txt:3:');
399
+ expect(result.content).toContain('ctx.txt:4:');
400
+ expect(result.content).toContain('>'); // marker for matching line
401
+ });
402
+ it('supports glob_pattern filter', async () => {
403
+ writeFileSync(join(tmpDir, 'a.ts'), 'target');
404
+ writeFileSync(join(tmpDir, 'b.js'), 'target');
405
+ const agent = new Agent({ model: mockModel() });
406
+ agent.use(createCodeToolsExtension());
407
+ const tools = agent.tools;
408
+ const grepTool = tools.get('grep');
409
+ const result = await grepTool.execute({ pattern: 'target', path: tmpDir, glob_pattern: '*.ts' }, { signal: new AbortController().signal, metadata: {} });
410
+ expect(result.content).toContain('a.ts');
411
+ expect(result.content).not.toContain('b.js');
412
+ });
413
+ it('returns message when no matches', async () => {
414
+ writeFileSync(join(tmpDir, 'empty.txt'), 'nothing here');
415
+ const agent = new Agent({ model: mockModel() });
416
+ agent.use(createCodeToolsExtension());
417
+ const tools = agent.tools;
418
+ const grepTool = tools.get('grep');
419
+ const result = await grepTool.execute({ pattern: 'nonexistent', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
420
+ expect(result.content).toContain('No matches');
421
+ });
422
+ it('skips node_modules', async () => {
423
+ mkdirSync(join(tmpDir, 'node_modules'));
424
+ writeFileSync(join(tmpDir, 'node_modules', 'dep.ts'), 'secret');
425
+ writeFileSync(join(tmpDir, 'main.ts'), 'secret');
426
+ const agent = new Agent({ model: mockModel() });
427
+ agent.use(createCodeToolsExtension());
428
+ const tools = agent.tools;
429
+ const grepTool = tools.get('grep');
430
+ const result = await grepTool.execute({ pattern: 'secret', path: tmpDir }, { signal: new AbortController().signal, metadata: {} });
431
+ expect(result.content).toContain('main.ts');
432
+ expect(result.content).not.toContain('dep.ts');
433
+ });
434
+ });
435
+ // ── exec tool ──────────────────────────────────────────────────────
436
+ describe('exec tool', () => {
437
+ it('runs a command and returns output', async () => {
438
+ const agent = new Agent({ model: mockModel() });
439
+ agent.use(createCodeToolsExtension());
440
+ const tools = agent.tools;
441
+ const execTool = tools.get('exec');
442
+ const result = await execTool.execute({ command: 'echo hello world' }, { signal: new AbortController().signal, metadata: {} });
443
+ expect(result.content).toContain('Exit code: 0');
444
+ expect(result.content).toContain('hello world');
445
+ });
446
+ it('returns exit code for failing commands', async () => {
447
+ const agent = new Agent({ model: mockModel() });
448
+ agent.use(createCodeToolsExtension());
449
+ const tools = agent.tools;
450
+ const execTool = tools.get('exec');
451
+ const result = await execTool.execute({ command: 'exit 42' }, { signal: new AbortController().signal, metadata: {} });
452
+ expect(result.content).toContain('Exit code: 42');
453
+ });
454
+ it('captures stderr', async () => {
455
+ const agent = new Agent({ model: mockModel() });
456
+ agent.use(createCodeToolsExtension());
457
+ const tools = agent.tools;
458
+ const execTool = tools.get('exec');
459
+ const result = await execTool.execute({ command: 'echo "err msg" >&2' }, { signal: new AbortController().signal, metadata: {} });
460
+ expect(result.content).toContain('err msg');
461
+ });
462
+ it('backgrounds on timeout and returns shell_id', async () => {
463
+ const agent = new Agent({ model: mockModel() });
464
+ agent.use(createCodeToolsExtension());
465
+ const tools = agent.tools;
466
+ const execTool = tools.get('exec');
467
+ const result = await execTool.execute({ command: 'sleep 10', timeout: 200 }, { signal: new AbortController().signal, metadata: {} });
468
+ expect(result.content).toContain('Detached');
469
+ expect(result.content).toContain('shell_');
470
+ });
471
+ it('backgrounds immediately with timeout 0', async () => {
472
+ const agent = new Agent({ model: mockModel() });
473
+ agent.use(createCodeToolsExtension());
474
+ const tools = agent.tools;
475
+ const execTool = tools.get('exec');
476
+ const result = await execTool.execute({ command: 'echo hi', timeout: 0 }, { signal: new AbortController().signal, metadata: {} });
477
+ expect(result.content).toContain('Backgrounded');
478
+ expect(result.content).toContain('shell_');
479
+ });
480
+ });
481
+ // ── get_output tool ────────────────────────────────────────────────
482
+ describe('get_output tool', () => {
483
+ it('reads output from a backgrounded shell', async () => {
484
+ const agent = new Agent({ model: mockModel() });
485
+ agent.use(createCodeToolsExtension());
486
+ const tools = agent.tools;
487
+ const execTool = tools.get('exec');
488
+ const getOutputTool = tools.get('get_output');
489
+ const metadata = {};
490
+ const execResult = await execTool.execute({ command: 'echo background output', timeout: 0 }, { signal: new AbortController().signal, metadata });
491
+ const shellId = execResult.content.match(/shell_\d+/)?.[0];
492
+ expect(shellId).toBeDefined();
493
+ // Wait for output to accumulate.
494
+ await new Promise((r) => setTimeout(r, 200));
495
+ const result = await getOutputTool.execute({ shell_id: shellId }, { signal: new AbortController().signal, metadata });
496
+ expect(result.content).toContain('background output');
497
+ });
498
+ it('reports process exited', async () => {
499
+ const agent = new Agent({ model: mockModel() });
500
+ agent.use(createCodeToolsExtension());
501
+ const tools = agent.tools;
502
+ const execTool = tools.get('exec');
503
+ const getOutputTool = tools.get('get_output');
504
+ const metadata = {};
505
+ const execResult = await execTool.execute({ command: 'echo done', timeout: 0 }, { signal: new AbortController().signal, metadata });
506
+ const shellId = execResult.content.match(/shell_\d+/)?.[0];
507
+ // Wait for process to finish.
508
+ await new Promise((r) => setTimeout(r, 300));
509
+ const result = await getOutputTool.execute({ shell_id: shellId }, { signal: new AbortController().signal, metadata });
510
+ expect(result.content).toContain('Process exited');
511
+ expect(result.content).toContain('code: 0');
512
+ });
513
+ it('errors for unknown shell_id', async () => {
514
+ const agent = new Agent({ model: mockModel() });
515
+ agent.use(createCodeToolsExtension());
516
+ const tools = agent.tools;
517
+ const getOutputTool = tools.get('get_output');
518
+ const result = await getOutputTool.execute({ shell_id: 'shell_999' }, { signal: new AbortController().signal, metadata: {} });
519
+ expect(result.content).toContain('no shell');
520
+ });
521
+ });
522
+ // ── kill_shell tool ────────────────────────────────────────────────
523
+ describe('kill_shell tool', () => {
524
+ it('kills a backgrounded shell', async () => {
525
+ const agent = new Agent({ model: mockModel() });
526
+ agent.use(createCodeToolsExtension());
527
+ const tools = agent.tools;
528
+ const execTool = tools.get('exec');
529
+ const killTool = tools.get('kill_shell');
530
+ const metadata = {};
531
+ const execResult = await execTool.execute({ command: 'sleep 30', timeout: 0 }, { signal: new AbortController().signal, metadata });
532
+ const shellId = execResult.content.match(/shell_\d+/)?.[0];
533
+ const result = await killTool.execute({ shell_id: shellId }, { signal: new AbortController().signal, metadata });
534
+ expect(result.content).toContain('Killed');
535
+ // Verify the shell is gone — get_output should report no shell.
536
+ const getResult = await tools.get('get_output').execute({ shell_id: shellId }, { signal: new AbortController().signal, metadata });
537
+ expect(getResult.content).toContain('no shell');
538
+ });
539
+ it('errors for unknown shell_id', async () => {
540
+ const agent = new Agent({ model: mockModel() });
541
+ agent.use(createCodeToolsExtension());
542
+ const tools = agent.tools;
543
+ const killTool = tools.get('kill_shell');
544
+ const result = await killTool.execute({ shell_id: 'shell_999' }, { signal: new AbortController().signal, metadata: {} });
545
+ expect(result.content).toContain('no shell');
546
+ });
547
+ });
548
+ // ── write_to_process tool ──────────────────────────────────────────
549
+ describe('write_to_process tool', () => {
550
+ it('writes to stdin of a backgrounded shell', async () => {
551
+ const agent = new Agent({ model: mockModel() });
552
+ agent.use(createCodeToolsExtension());
553
+ const tools = agent.tools;
554
+ const execTool = tools.get('exec');
555
+ const writeTool = tools.get('write_to_process');
556
+ const getOutputTool = tools.get('get_output');
557
+ const metadata = {};
558
+ // Start cat (reads stdin, echoes to stdout)
559
+ const execResult = await execTool.execute({ command: 'cat', timeout: 0 }, { signal: new AbortController().signal, metadata });
560
+ const shellId = execResult.content.match(/shell_\d+/)?.[0];
561
+ const writeResult = await writeTool.execute({ shell_id: shellId, input: 'hello from stdin\n' }, { signal: new AbortController().signal, metadata });
562
+ expect(writeResult.content).toContain('Wrote');
563
+ // Wait for cat to echo.
564
+ await new Promise((r) => setTimeout(r, 200));
565
+ const outputResult = await getOutputTool.execute({ shell_id: shellId }, { signal: new AbortController().signal, metadata });
566
+ expect(outputResult.content).toContain('hello from stdin');
567
+ });
568
+ it('errors for unknown shell_id', async () => {
569
+ const agent = new Agent({ model: mockModel() });
570
+ agent.use(createCodeToolsExtension());
571
+ const tools = agent.tools;
572
+ const writeTool = tools.get('write_to_process');
573
+ const result = await writeTool.execute({ shell_id: 'shell_999', input: 'test' }, { signal: new AbortController().signal, metadata: {} });
574
+ expect(result.content).toContain('no shell');
575
+ });
576
+ it('errors for exited process', async () => {
577
+ const agent = new Agent({ model: mockModel() });
578
+ agent.use(createCodeToolsExtension());
579
+ const tools = agent.tools;
580
+ const execTool = tools.get('exec');
581
+ const writeTool = tools.get('write_to_process');
582
+ const metadata = {};
583
+ const execResult = await execTool.execute({ command: 'echo quick', timeout: 0 }, { signal: new AbortController().signal, metadata });
584
+ const shellId = execResult.content.match(/shell_\d+/)?.[0];
585
+ // Wait for process to exit.
586
+ await new Promise((r) => setTimeout(r, 300));
587
+ const result = await writeTool.execute({ shell_id: shellId, input: 'test' }, { signal: new AbortController().signal, metadata });
588
+ expect(result.content).toContain('already exited');
589
+ });
590
+ });
591
+ // ── todo_write tool ────────────────────────────────────────────────
592
+ describe('todo_write tool', () => {
593
+ it('creates a todo list', async () => {
594
+ const agent = new Agent({ model: mockModel() });
595
+ agent.use(createCodeToolsExtension());
596
+ const tools = agent.tools;
597
+ const todoTool = tools.get('todo_write');
598
+ const result = await todoTool.execute({ todos: [
599
+ { content: 'Read the file', status: 'completed' },
600
+ { content: 'Edit the file', status: 'in_progress' },
601
+ { content: 'Run tests', status: 'pending' },
602
+ ] }, { signal: new AbortController().signal, metadata: {} });
603
+ expect(result.content).toContain('[x] Read the file');
604
+ expect(result.content).toContain('[~] Edit the file');
605
+ expect(result.content).toContain('[ ] Run tests');
606
+ });
607
+ it('replaces the list on each call', async () => {
608
+ const agent = new Agent({ model: mockModel() });
609
+ agent.use(createCodeToolsExtension());
610
+ const tools = agent.tools;
611
+ const todoTool = tools.get('todo_write');
612
+ await todoTool.execute({ todos: [
613
+ { content: 'Task A', status: 'pending' },
614
+ { content: 'Task B', status: 'pending' },
615
+ { content: 'Task C', status: 'pending' },
616
+ ] }, { signal: new AbortController().signal, metadata: {} });
617
+ const result = await todoTool.execute({ todos: [
618
+ { content: 'Task A', status: 'completed' },
619
+ { content: 'Task B', status: 'in_progress' },
620
+ ] }, { signal: new AbortController().signal, metadata: {} });
621
+ expect(result.content).toContain('[x] Task A');
622
+ expect(result.content).toContain('[~] Task B');
623
+ expect(result.content).not.toContain('Task C');
624
+ });
625
+ it('rejects multiple in_progress todos', async () => {
626
+ const agent = new Agent({ model: mockModel() });
627
+ agent.use(createCodeToolsExtension());
628
+ const tools = agent.tools;
629
+ const todoTool = tools.get('todo_write');
630
+ const result = await todoTool.execute({ todos: [
631
+ { content: 'Task A', status: 'in_progress' },
632
+ { content: 'Task B', status: 'in_progress' },
633
+ ] }, { signal: new AbortController().signal, metadata: {} });
634
+ expect(result.isError).toBe(true);
635
+ expect(result.content).toContain('only one todo');
636
+ });
637
+ it('clears the list with an empty array', async () => {
638
+ const agent = new Agent({ model: mockModel() });
639
+ agent.use(createCodeToolsExtension());
640
+ const tools = agent.tools;
641
+ const todoTool = tools.get('todo_write');
642
+ await todoTool.execute({ todos: [{ content: 'Task', status: 'pending' }] }, { signal: new AbortController().signal, metadata: {} });
643
+ const result = await todoTool.execute({ todos: [] }, { signal: new AbortController().signal, metadata: {} });
644
+ expect(result.content).toContain('cleared');
645
+ });
646
+ it('defaults invalid status to pending', async () => {
647
+ const agent = new Agent({ model: mockModel() });
648
+ agent.use(createCodeToolsExtension());
649
+ const tools = agent.tools;
650
+ const todoTool = tools.get('todo_write');
651
+ const result = await todoTool.execute({ todos: [{ content: 'Task', status: 'bogus' }] }, { signal: new AbortController().signal, metadata: {} });
652
+ expect(result.content).toContain('[ ] Task');
653
+ });
654
+ });
655
+ });
656
+ //# sourceMappingURL=extension.test.js.map