start-command 0.40.0 → 0.42.0
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/CHANGELOG.md +22 -0
- package/eslint.config.mjs +31 -0
- package/package.json +3 -3
- package/src/bin/cli.js +11 -1
- package/src/lib/args-parser.js +33 -0
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +390 -0
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +508 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +482 -0
- package/src/lib/usage.js +5 -1
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts-258.json +431 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +194 -0
- package/test/regression-255-cli.js +296 -0
- package/test/regression-255-e2e.js +167 -0
- package/test/regression-255-loop.js +480 -0
- package/test/regression-255-policy.js +436 -0
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #258: the structured read/edit/write tools of `--use-formal-ai`
|
|
3
|
+
* (src/lib/formal-ai-files.js) and their loop integration, offline.
|
|
4
|
+
*/
|
|
5
|
+
const { describe, it, expect, afterAll } = require('bun:test');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const {
|
|
9
|
+
BOUND_DEFAULTS,
|
|
10
|
+
resolveBounds,
|
|
11
|
+
EXIT,
|
|
12
|
+
} = require('../src/lib/formal-ai-policy');
|
|
13
|
+
const files = require('../src/lib/formal-ai-files');
|
|
14
|
+
const { runFormalAiTask } = require('../src/lib/formal-ai');
|
|
15
|
+
const {
|
|
16
|
+
cleanup,
|
|
17
|
+
populate,
|
|
18
|
+
sink,
|
|
19
|
+
snapshot,
|
|
20
|
+
tempDir,
|
|
21
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
22
|
+
|
|
23
|
+
const posix = process.platform !== 'win32';
|
|
24
|
+
const dirs = [];
|
|
25
|
+
afterAll(() => cleanup(...dirs));
|
|
26
|
+
|
|
27
|
+
const TEXT =
|
|
28
|
+
'// keep this comment\r\nconst a = 1;\t \n\n const b = 2; // b\nconst a2 = 1;\n';
|
|
29
|
+
|
|
30
|
+
function workspace(extra = {}) {
|
|
31
|
+
const dir = populate(tempDir('files'), {
|
|
32
|
+
'a.js': TEXT,
|
|
33
|
+
'sub/b.txt': 'one two one\n',
|
|
34
|
+
...extra,
|
|
35
|
+
});
|
|
36
|
+
dirs.push(dir);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function context(cwd, effects = 'workspace-write') {
|
|
41
|
+
return {
|
|
42
|
+
cwd,
|
|
43
|
+
effects,
|
|
44
|
+
observed: new Set(),
|
|
45
|
+
observedDirs: new Set(),
|
|
46
|
+
contents: new Map(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const bounds = { maxFileBytes: BOUND_DEFAULTS.maxFileBytes.value };
|
|
51
|
+
|
|
52
|
+
/** Normalize, decide and run one call like the loop does. */
|
|
53
|
+
function call(tool, args, ctx, limits = bounds) {
|
|
54
|
+
const normalized = files.normalizeFileArgs(tool, args);
|
|
55
|
+
if (normalized.problem) {
|
|
56
|
+
return { problem: normalized.problem };
|
|
57
|
+
}
|
|
58
|
+
const decision = files.evaluateFileCall(tool, normalized.args, ctx);
|
|
59
|
+
if (!decision.allowed) {
|
|
60
|
+
return { refused: decision.reason };
|
|
61
|
+
}
|
|
62
|
+
return files.runFileCall(tool, normalized.args, decision, ctx, limits);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('file tool arguments (#258)', () => {
|
|
66
|
+
it('normalizes aliases and rejects malformed arguments', () => {
|
|
67
|
+
expect(
|
|
68
|
+
files.normalizeFileArgs('edit', {
|
|
69
|
+
filePath: 'a.js',
|
|
70
|
+
oldString: 'x',
|
|
71
|
+
newString: 'y',
|
|
72
|
+
})
|
|
73
|
+
).toEqual({
|
|
74
|
+
args: { path: 'a.js', oldString: 'x', newString: 'y', replaceAll: false },
|
|
75
|
+
});
|
|
76
|
+
for (const [tool, args] of [
|
|
77
|
+
['read', null],
|
|
78
|
+
['read', []],
|
|
79
|
+
['read', {}],
|
|
80
|
+
['read', { path: 1 }],
|
|
81
|
+
['read', { path: ' ' }],
|
|
82
|
+
['read', { path: 'a\0b' }],
|
|
83
|
+
['read', { path: 'a', file_path: 'b' }],
|
|
84
|
+
['edit', { path: 'a', old_string: 'x' }],
|
|
85
|
+
['edit', { path: 'a', old_string: 'x', new_string: 'y', replace_all: 1 }],
|
|
86
|
+
['write', { path: 'a' }],
|
|
87
|
+
]) {
|
|
88
|
+
expect(files.normalizeFileArgs(tool, args).problem).toBeString();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('file tool policy (#258)', () => {
|
|
94
|
+
it('refuses changes in read-only mode, outside paths and unobserved targets', () => {
|
|
95
|
+
const cwd = workspace();
|
|
96
|
+
const outside = tempDir('outside');
|
|
97
|
+
dirs.push(outside);
|
|
98
|
+
fs.writeFileSync(path.join(outside, 'secret.txt'), 'secret');
|
|
99
|
+
fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(cwd, 'link'));
|
|
100
|
+
const ro = context(cwd, 'read-only');
|
|
101
|
+
expect(
|
|
102
|
+
call('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }, ro)
|
|
103
|
+
).toMatchObject({ refused: expect.stringContaining('read-only') });
|
|
104
|
+
expect(call('write', { path: 'a.js', content: '' }, ro).refused).toContain(
|
|
105
|
+
'START_FORMAL_AI_EFFECTS=workspace-write'
|
|
106
|
+
);
|
|
107
|
+
const rw = context(cwd);
|
|
108
|
+
for (const target of [
|
|
109
|
+
'../x',
|
|
110
|
+
'/etc/passwd',
|
|
111
|
+
'~/.ssh/id_rsa',
|
|
112
|
+
'link',
|
|
113
|
+
path.join(outside, 'secret.txt'),
|
|
114
|
+
]) {
|
|
115
|
+
expect(call('read', { path: target }, rw).refused).toContain(
|
|
116
|
+
'outside the working directory'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
expect(call('read', { path: '.' }, rw).refused).toContain('not a file');
|
|
120
|
+
expect(
|
|
121
|
+
call('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }, rw)
|
|
122
|
+
.refused
|
|
123
|
+
).toBe('target "a.js" was not read before the change');
|
|
124
|
+
expect(call('write', { path: 'new.txt', content: 'x' }, rw).refused).toBe(
|
|
125
|
+
'target "new.txt" was not observed before the change'
|
|
126
|
+
);
|
|
127
|
+
rw.observedDirs.add('.');
|
|
128
|
+
// A new file in a listed directory may be created, an existing one not.
|
|
129
|
+
expect(call('write', { path: 'a.js', content: 'x' }, rw).refused).toBe(
|
|
130
|
+
'target "a.js" was not observed before the change'
|
|
131
|
+
);
|
|
132
|
+
expect(
|
|
133
|
+
call('write', { path: 'new.txt', content: 'x\n' }, rw)
|
|
134
|
+
).toMatchObject({ exitCode: 0, verification: { verified: true } });
|
|
135
|
+
expect(fs.readFileSync(path.join(cwd, 'new.txt'), 'utf8')).toBe('x\n');
|
|
136
|
+
expect(fs.readFileSync(path.join(outside, 'secret.txt'), 'utf8')).toBe(
|
|
137
|
+
'secret'
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('read (#258)', () => {
|
|
143
|
+
it('returns the exact text and records the observation', () => {
|
|
144
|
+
const cwd = workspace();
|
|
145
|
+
const ctx = context(cwd, 'read-only');
|
|
146
|
+
const receipt = call('read', { path: './a.js' }, ctx);
|
|
147
|
+
expect(receipt).toMatchObject({
|
|
148
|
+
exitCode: 0,
|
|
149
|
+
result: TEXT,
|
|
150
|
+
bytes: Buffer.byteLength(TEXT),
|
|
151
|
+
});
|
|
152
|
+
expect(ctx.observed.has('a.js')).toBe(true);
|
|
153
|
+
expect(ctx.contents.get('a.js')).toBe(receipt.sha256);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('fails for missing, directory, oversized and non-text files', () => {
|
|
157
|
+
const cwd = workspace({ 'bin.dat': '', 'big.txt': 'x'.repeat(64) });
|
|
158
|
+
fs.writeFileSync(path.join(cwd, 'bin.dat'), Buffer.from([0xff, 0xfe, 0]));
|
|
159
|
+
fs.writeFileSync(path.join(cwd, 'nul.txt'), 'a\0b');
|
|
160
|
+
const ctx = context(cwd, 'read-only');
|
|
161
|
+
const error = (name, limits) => call('read', { path: name }, ctx, limits);
|
|
162
|
+
expect(error('missing.txt')).toMatchObject({
|
|
163
|
+
exitCode: 1,
|
|
164
|
+
error: 'missing.txt: no such file',
|
|
165
|
+
});
|
|
166
|
+
expect(JSON.parse(error('missing.txt').result)).toEqual({
|
|
167
|
+
ok: false,
|
|
168
|
+
is_error: true,
|
|
169
|
+
error: 'missing.txt: no such file',
|
|
170
|
+
});
|
|
171
|
+
expect(error('sub').error).toBe('sub: is a directory, not a file');
|
|
172
|
+
expect(error('bin.dat').error).toBe('bin.dat: is not UTF-8 text');
|
|
173
|
+
expect(error('nul.txt').error).toBe('nul.txt: is not UTF-8 text');
|
|
174
|
+
expect(error('big.txt', { maxFileBytes: 10 }).error).toBe(
|
|
175
|
+
'big.txt: has 64 bytes; START_FORMAL_AI_MAX_FILE_BYTES is 10'
|
|
176
|
+
);
|
|
177
|
+
expect(ctx.observed.size).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('edit (#258)', () => {
|
|
182
|
+
it('replaces one literal occurrence, preserving every other byte and the mode', () => {
|
|
183
|
+
const cwd = workspace();
|
|
184
|
+
const file = path.join(cwd, 'a.js');
|
|
185
|
+
if (posix) {
|
|
186
|
+
fs.chmodSync(file, 0o640);
|
|
187
|
+
}
|
|
188
|
+
const ctx = context(cwd);
|
|
189
|
+
call('read', { path: 'a.js' }, ctx);
|
|
190
|
+
const receipt = call(
|
|
191
|
+
'edit',
|
|
192
|
+
{
|
|
193
|
+
path: 'a.js',
|
|
194
|
+
old_string: 'const b = 2;',
|
|
195
|
+
new_string: 'const b = $&3;',
|
|
196
|
+
},
|
|
197
|
+
ctx
|
|
198
|
+
);
|
|
199
|
+
expect(receipt).toMatchObject({
|
|
200
|
+
exitCode: 0,
|
|
201
|
+
result: '',
|
|
202
|
+
replacements: 1,
|
|
203
|
+
verification: { verified: true },
|
|
204
|
+
});
|
|
205
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(
|
|
206
|
+
TEXT.replace('const b = 2;', () => 'const b = $&3;')
|
|
207
|
+
);
|
|
208
|
+
if (posix) {
|
|
209
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o640);
|
|
210
|
+
}
|
|
211
|
+
// No temporary file is left behind.
|
|
212
|
+
expect(fs.readdirSync(cwd).sort()).toEqual(['a.js', 'sub']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('keeps a byte order mark', () => {
|
|
216
|
+
const cwd = workspace({ 'bom.txt': '\ufefffirst\nsecond\n' });
|
|
217
|
+
const ctx = context(cwd);
|
|
218
|
+
expect(call('read', { path: 'bom.txt' }, ctx).result).toBe(
|
|
219
|
+
'\ufefffirst\nsecond\n'
|
|
220
|
+
);
|
|
221
|
+
const receipt = call(
|
|
222
|
+
'edit',
|
|
223
|
+
{ path: 'bom.txt', old_string: 'second', new_string: '2nd' },
|
|
224
|
+
ctx
|
|
225
|
+
);
|
|
226
|
+
expect(receipt.exitCode).toBe(0);
|
|
227
|
+
expect(fs.readFileSync(path.join(cwd, 'bom.txt'))).toEqual(
|
|
228
|
+
Buffer.from('\ufefffirst\n2nd\n', 'utf8')
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('refuses ambiguous, missing, empty and no-op replacements', () => {
|
|
233
|
+
const cwd = workspace();
|
|
234
|
+
const ctx = context(cwd);
|
|
235
|
+
call('read', { path: 'sub/b.txt' }, ctx);
|
|
236
|
+
const edit = (args) => call('edit', { path: 'sub/b.txt', ...args }, ctx);
|
|
237
|
+
expect(edit({ old_string: 'one', new_string: '1' }).error).toBe(
|
|
238
|
+
'sub/b.txt: old_string occurs 2 times; give more surrounding text or set replace_all; nothing changed'
|
|
239
|
+
);
|
|
240
|
+
expect(edit({ old_string: 'three', new_string: '3' }).error).toContain(
|
|
241
|
+
'not found'
|
|
242
|
+
);
|
|
243
|
+
expect(edit({ old_string: '', new_string: '3' }).error).toContain('empty');
|
|
244
|
+
expect(edit({ old_string: 'two', new_string: 'two' }).error).toContain(
|
|
245
|
+
'the same'
|
|
246
|
+
);
|
|
247
|
+
expect(fs.readFileSync(path.join(cwd, 'sub/b.txt'), 'utf8')).toBe(
|
|
248
|
+
'one two one\n'
|
|
249
|
+
);
|
|
250
|
+
expect(
|
|
251
|
+
edit({ old_string: 'one', new_string: '1', replace_all: true })
|
|
252
|
+
).toMatchObject({ exitCode: 0, replacements: 2 });
|
|
253
|
+
expect(fs.readFileSync(path.join(cwd, 'sub/b.txt'), 'utf8')).toBe(
|
|
254
|
+
'1 two 1\n'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('does not overwrite a file changed since it was read', () => {
|
|
259
|
+
const cwd = workspace();
|
|
260
|
+
const ctx = context(cwd);
|
|
261
|
+
call('read', { path: 'a.js' }, ctx);
|
|
262
|
+
fs.writeFileSync(path.join(cwd, 'a.js'), 'someone else\n');
|
|
263
|
+
expect(
|
|
264
|
+
call('edit', { path: 'a.js', old_string: 'else', new_string: 'x' }, ctx)
|
|
265
|
+
.error
|
|
266
|
+
).toContain('changed since start read it');
|
|
267
|
+
expect(call('write', { path: 'a.js', content: 'x' }, ctx).error).toContain(
|
|
268
|
+
'changed since start read it'
|
|
269
|
+
);
|
|
270
|
+
expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe(
|
|
271
|
+
'someone else\n'
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('keeps the file size bound for the new text', () => {
|
|
276
|
+
const cwd = workspace();
|
|
277
|
+
const ctx = context(cwd);
|
|
278
|
+
const limits = { maxFileBytes: Buffer.byteLength(TEXT) };
|
|
279
|
+
call('read', { path: 'a.js' }, ctx, limits);
|
|
280
|
+
expect(
|
|
281
|
+
call(
|
|
282
|
+
'edit',
|
|
283
|
+
{
|
|
284
|
+
path: 'a.js',
|
|
285
|
+
old_string: 'const a = 1;',
|
|
286
|
+
new_string: 'const a = 10;',
|
|
287
|
+
},
|
|
288
|
+
ctx,
|
|
289
|
+
limits
|
|
290
|
+
).error
|
|
291
|
+
).toContain('START_FORMAL_AI_MAX_FILE_BYTES');
|
|
292
|
+
expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe(TEXT);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('write (#258)', () => {
|
|
297
|
+
it('replaces a read file and refuses a missing directory', () => {
|
|
298
|
+
const cwd = workspace();
|
|
299
|
+
const ctx = context(cwd);
|
|
300
|
+
call('read', { path: 'a.js' }, ctx);
|
|
301
|
+
expect(
|
|
302
|
+
call('write', { path: 'a.js', content: 'new\n' }, ctx)
|
|
303
|
+
).toMatchObject({
|
|
304
|
+
exitCode: 0,
|
|
305
|
+
bytes: 4,
|
|
306
|
+
verification: { verified: true },
|
|
307
|
+
});
|
|
308
|
+
expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe('new\n');
|
|
309
|
+
ctx.observedDirs.add('gone');
|
|
310
|
+
expect(call('write', { path: 'gone/x.txt', content: 'x' }, ctx).error).toBe(
|
|
311
|
+
'gone/x.txt: its directory does not exist'
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('START_FORMAL_AI_MAX_FILE_BYTES (#258)', () => {
|
|
317
|
+
it('defaults to 1 MiB and is bounded', () => {
|
|
318
|
+
expect(resolveBounds({}).maxFileBytes).toBe(1024 * 1024);
|
|
319
|
+
expect(
|
|
320
|
+
resolveBounds({ START_FORMAL_AI_MAX_FILE_BYTES: '4096' }).maxFileBytes
|
|
321
|
+
).toBe(4096);
|
|
322
|
+
for (const value of ['0', '-1', 'abc', String(64 * 1024 * 1024)]) {
|
|
323
|
+
expect(() =>
|
|
324
|
+
resolveBounds({ START_FORMAL_AI_MAX_FILE_BYTES: value })
|
|
325
|
+
).toThrow();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('file tools in the task loop (#258)', () => {
|
|
331
|
+
/** A scripted client: each turn returns the next message. */
|
|
332
|
+
function scripted(turns) {
|
|
333
|
+
let index = 0;
|
|
334
|
+
return {
|
|
335
|
+
complete: () => {
|
|
336
|
+
const message = turns[index++];
|
|
337
|
+
return Promise.resolve({
|
|
338
|
+
content: message.content || '',
|
|
339
|
+
finishReason: message.toolCalls ? 'tool_calls' : 'stop',
|
|
340
|
+
handler: null,
|
|
341
|
+
toolCalls: message.toolCalls || [],
|
|
342
|
+
raw: {},
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const fileCall = (tool, args) => ({
|
|
348
|
+
id: `c_${tool}`,
|
|
349
|
+
name: tool,
|
|
350
|
+
tool,
|
|
351
|
+
args: files.normalizeFileArgs(tool, args).args,
|
|
352
|
+
arguments: JSON.stringify(args),
|
|
353
|
+
command: `${tool} ${args.path}`,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
async function loop(turns, effects = 'workspace-write') {
|
|
357
|
+
const cwd = workspace();
|
|
358
|
+
const before = snapshot(cwd);
|
|
359
|
+
const evidence = await runFormalAiTask({
|
|
360
|
+
request: 'scripted',
|
|
361
|
+
cwd,
|
|
362
|
+
env: { PATH: process.env.PATH },
|
|
363
|
+
bounds: { ...resolveBounds({}), effects },
|
|
364
|
+
client: scripted(turns),
|
|
365
|
+
io: { stdout: sink(), stderr: sink(), note: () => {} },
|
|
366
|
+
});
|
|
367
|
+
return { cwd, before, after: snapshot(cwd), evidence };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
it('stops with 77 before an unobserved edit runs', async () => {
|
|
371
|
+
const result = await loop([
|
|
372
|
+
{
|
|
373
|
+
toolCalls: [
|
|
374
|
+
fileCall('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }),
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
expect(result.evidence.outcome).toMatchObject({
|
|
379
|
+
status: 'refused',
|
|
380
|
+
exitCode: EXIT.REFUSED,
|
|
381
|
+
remainingObligations: [
|
|
382
|
+
'the refused call did not run; nothing after it ran either',
|
|
383
|
+
],
|
|
384
|
+
});
|
|
385
|
+
expect(result.after).toEqual(result.before);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('refuses edits in read-only mode even after a read', async () => {
|
|
389
|
+
const result = await loop(
|
|
390
|
+
[
|
|
391
|
+
{ toolCalls: [fileCall('read', { path: 'a.js' })] },
|
|
392
|
+
{
|
|
393
|
+
toolCalls: [
|
|
394
|
+
fileCall('edit', {
|
|
395
|
+
path: 'a.js',
|
|
396
|
+
old_string: 'a',
|
|
397
|
+
new_string: 'b',
|
|
398
|
+
}),
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
'read-only'
|
|
403
|
+
);
|
|
404
|
+
expect(result.evidence.outcome.exitCode).toBe(EXIT.REFUSED);
|
|
405
|
+
expect(result.evidence.outcome.message).toContain('read-only');
|
|
406
|
+
expect(result.after).toEqual(result.before);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('reports a failed edit with its real status and no change', async () => {
|
|
410
|
+
const result = await loop([
|
|
411
|
+
{ toolCalls: [fileCall('read', { path: 'a.js' })] },
|
|
412
|
+
{
|
|
413
|
+
toolCalls: [
|
|
414
|
+
fileCall('edit', {
|
|
415
|
+
path: 'a.js',
|
|
416
|
+
old_string: 'zzz',
|
|
417
|
+
new_string: 'b',
|
|
418
|
+
}),
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
{ content: 'The edit failed.' },
|
|
422
|
+
]);
|
|
423
|
+
expect(result.evidence.outcome).toMatchObject({
|
|
424
|
+
status: 'completed',
|
|
425
|
+
exitCode: 1,
|
|
426
|
+
remainingObligations: ['the last tool call failed'],
|
|
427
|
+
});
|
|
428
|
+
expect(result.after).toEqual(result.before);
|
|
429
|
+
});
|
|
430
|
+
});
|