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,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #255: the `--use-formal-ai` task loop against the offline Formal AI
|
|
3
|
+
* stand-in, which replays receipts captured from the real runtime
|
|
4
|
+
* (test/fixtures/formal-ai/receipts.json) plus synthetic failure scenarios.
|
|
5
|
+
* Real tools run in temporary directories; nothing touches the network.
|
|
6
|
+
*/
|
|
7
|
+
const { describe, it, expect, beforeAll, afterAll } = require('bun:test');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { resolveBounds, EXIT } = require('../src/lib/formal-ai-policy');
|
|
11
|
+
const { createClient } = require('../src/lib/formal-ai-client');
|
|
12
|
+
const { runFormalAiTask } = require('../src/lib/formal-ai');
|
|
13
|
+
const {
|
|
14
|
+
createFakeServer,
|
|
15
|
+
receipts,
|
|
16
|
+
} = require('./fixtures/formal-ai/replay.cjs');
|
|
17
|
+
const {
|
|
18
|
+
cleanup,
|
|
19
|
+
fakeBin,
|
|
20
|
+
populate,
|
|
21
|
+
receiptFixture,
|
|
22
|
+
sink,
|
|
23
|
+
snapshot,
|
|
24
|
+
tempDir,
|
|
25
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
26
|
+
|
|
27
|
+
const { AbortController } = globalThis;
|
|
28
|
+
const posix = process.platform !== 'win32';
|
|
29
|
+
|
|
30
|
+
let server;
|
|
31
|
+
let baseUrl;
|
|
32
|
+
const dirs = [];
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
server = createFakeServer();
|
|
36
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
37
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
server.close();
|
|
42
|
+
cleanup(...dirs);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function fixture() {
|
|
46
|
+
const dir = receiptFixture();
|
|
47
|
+
dirs.push(dir);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run one request through the real loop and client. */
|
|
52
|
+
async function run(request, options = {}) {
|
|
53
|
+
const cwd = options.cwd || fixture();
|
|
54
|
+
const bounds = { ...resolveBounds({}), ...options.bounds };
|
|
55
|
+
const deadline = options.deadline || Date.now() + bounds.timeoutMs;
|
|
56
|
+
const stdout = sink();
|
|
57
|
+
const stderr = sink();
|
|
58
|
+
const notes = [];
|
|
59
|
+
const client = createClient({ baseUrl, bounds, deadline });
|
|
60
|
+
const sent = [];
|
|
61
|
+
const recording = {
|
|
62
|
+
async complete(messages, opts) {
|
|
63
|
+
sent.push(JSON.parse(JSON.stringify(messages)));
|
|
64
|
+
return client.complete(messages, opts);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const before = snapshot(cwd);
|
|
68
|
+
const evidence = await runFormalAiTask({
|
|
69
|
+
request,
|
|
70
|
+
cwd,
|
|
71
|
+
env: options.env || { PATH: process.env.PATH, LANG: 'C.UTF-8' },
|
|
72
|
+
bounds,
|
|
73
|
+
client: recording,
|
|
74
|
+
deadline,
|
|
75
|
+
signal: options.signal,
|
|
76
|
+
io: {
|
|
77
|
+
stdout,
|
|
78
|
+
stderr,
|
|
79
|
+
note: (text) => {
|
|
80
|
+
notes.push(text);
|
|
81
|
+
if (options.onNote) {
|
|
82
|
+
options.onNote(text);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
cwd,
|
|
89
|
+
before,
|
|
90
|
+
after: snapshot(cwd),
|
|
91
|
+
evidence,
|
|
92
|
+
outcome: evidence.outcome,
|
|
93
|
+
tools: evidence.steps.filter((s) => s.kind === 'tool'),
|
|
94
|
+
stdout: stdout.text(),
|
|
95
|
+
stderr: stderr.text(),
|
|
96
|
+
notes,
|
|
97
|
+
sent,
|
|
98
|
+
requests: client.requests,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lines = (text) => text.split('\n').filter(Boolean).sort();
|
|
103
|
+
|
|
104
|
+
// A captured status, allowing for BSD `ls` (exit 1 where GNU exits 2).
|
|
105
|
+
const sameStatus = (expected, actual) =>
|
|
106
|
+
expected === actual ||
|
|
107
|
+
(expected === 2 && typeof actual === 'number' && actual !== 0);
|
|
108
|
+
|
|
109
|
+
describe('--use-formal-ai receipts (#255)', () => {
|
|
110
|
+
// The receipts ran real POSIX tools (ls, cat, find, wc, test, mkdir).
|
|
111
|
+
it.skipIf(!posix)(
|
|
112
|
+
'every captured request replays to its captured outcome',
|
|
113
|
+
async () => {
|
|
114
|
+
for (const receipt of receipts) {
|
|
115
|
+
const result = await run(receipt.request, {
|
|
116
|
+
bounds: { effects: receipt.effects },
|
|
117
|
+
});
|
|
118
|
+
// Same status as captured, except GNU's 2 may be BSD's 1.
|
|
119
|
+
const status = (expected, actual) =>
|
|
120
|
+
sameStatus(expected, actual) ? expected : actual;
|
|
121
|
+
expect({
|
|
122
|
+
request: receipt.request,
|
|
123
|
+
effects: receipt.effects,
|
|
124
|
+
status: result.outcome.status,
|
|
125
|
+
exitCode: status(receipt.outcome.exitCode, result.outcome.exitCode),
|
|
126
|
+
tools: result.tools.map((t, i) => [
|
|
127
|
+
t.command,
|
|
128
|
+
t.decision,
|
|
129
|
+
status(receipt.tools[i]?.exitCode, t.exitCode ?? null),
|
|
130
|
+
]),
|
|
131
|
+
}).toEqual({
|
|
132
|
+
request: receipt.request,
|
|
133
|
+
effects: receipt.effects,
|
|
134
|
+
status: receipt.outcome.status,
|
|
135
|
+
exitCode: receipt.outcome.exitCode,
|
|
136
|
+
tools: receipt.tools.map((t) => [t.command, t.decision, t.exitCode]),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('--use-formal-ai reads (#255)', () => {
|
|
144
|
+
it.skipIf(!posix)(
|
|
145
|
+
'lists a directory for paraphrased requests without changing it',
|
|
146
|
+
async () => {
|
|
147
|
+
for (const request of [
|
|
148
|
+
'print list of files in current directory',
|
|
149
|
+
'what files are in this folder?',
|
|
150
|
+
'ls -la',
|
|
151
|
+
]) {
|
|
152
|
+
const result = await run(request);
|
|
153
|
+
expect(result.outcome.status).toBe('completed');
|
|
154
|
+
expect(result.outcome.exitCode).toBe(0);
|
|
155
|
+
expect(result.outcome.message).toContain('no file changed');
|
|
156
|
+
expect(result.outcome.remainingObligations).toEqual([]);
|
|
157
|
+
for (const name of fs.readdirSync(result.cwd)) {
|
|
158
|
+
expect(result.stdout).toContain(name);
|
|
159
|
+
}
|
|
160
|
+
expect(result.after).toEqual(result.before);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it.skipIf(!posix)(
|
|
166
|
+
'lists a novel directory with spaces and Unicode, matching an independent listing',
|
|
167
|
+
async () => {
|
|
168
|
+
const cwd = populate(tempDir('novel'), {
|
|
169
|
+
'Ωmega file.txt': 'x',
|
|
170
|
+
'日本語 ノート.md': 'y',
|
|
171
|
+
'two spaces': null,
|
|
172
|
+
'-dash-first': 'z',
|
|
173
|
+
'émoji 🎉.txt': '',
|
|
174
|
+
});
|
|
175
|
+
dirs.push(cwd);
|
|
176
|
+
const result = await run('print list of files in current directory', {
|
|
177
|
+
cwd,
|
|
178
|
+
});
|
|
179
|
+
expect(result.outcome.exitCode).toBe(0);
|
|
180
|
+
expect(result.tools.map((t) => t.argv)).toEqual([['ls']]);
|
|
181
|
+
// Independent comparison: the tool's real output against readdir.
|
|
182
|
+
expect(lines(result.stdout)).toEqual(fs.readdirSync(cwd).sort());
|
|
183
|
+
// Formal AI received exactly that output as untrusted data.
|
|
184
|
+
const toolMessage = result.sent[1].find((m) => m.role === 'tool');
|
|
185
|
+
expect(toolMessage.content).toContain('<untrusted_context>');
|
|
186
|
+
expect(toolMessage.content).toContain('日本語 ノート.md');
|
|
187
|
+
expect(result.after).toEqual(result.before);
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it.skipIf(!posix)('lists a named subdirectory', async () => {
|
|
192
|
+
const result = await run('list files in docs');
|
|
193
|
+
expect(result.outcome.exitCode).toBe(0);
|
|
194
|
+
expect(result.stdout).toBe('guide.md\n');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it.skipIf(!posix)(
|
|
198
|
+
'reports a missing path with the tool’s real status',
|
|
199
|
+
async () => {
|
|
200
|
+
for (const request of [
|
|
201
|
+
'list files in missing-dir',
|
|
202
|
+
// Formal AI 0.355.27 reads "corrent" as a path (reported upstream);
|
|
203
|
+
// start reports that truthfully instead of guessing.
|
|
204
|
+
'print list of files in corrent directory',
|
|
205
|
+
]) {
|
|
206
|
+
const result = await run(request);
|
|
207
|
+
expect(result.outcome.status).toBe('completed');
|
|
208
|
+
const code = result.tools[0].exitCode;
|
|
209
|
+
expect(code).not.toBe(0);
|
|
210
|
+
expect(result.outcome.exitCode).toBe(code);
|
|
211
|
+
expect(result.outcome.message).toContain(`exited with ${code}`);
|
|
212
|
+
expect(result.outcome.remainingObligations).toEqual([
|
|
213
|
+
'the last tool call failed',
|
|
214
|
+
]);
|
|
215
|
+
expect(result.stderr).toContain('No such file or directory');
|
|
216
|
+
expect(result.after).toEqual(result.before);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
it.skipIf(!posix)(
|
|
222
|
+
'keeps malicious file contents as data and neither obeys nor forwards a fake status',
|
|
223
|
+
async () => {
|
|
224
|
+
const result = await run('show contents of notes.txt');
|
|
225
|
+
expect(result.outcome.exitCode).toBe(0);
|
|
226
|
+
expect(result.tools.map((t) => t.command)).toEqual(['cat notes.txt']);
|
|
227
|
+
expect(result.after).toEqual(result.before);
|
|
228
|
+
const toolMessage = result.sent[1].find((m) => m.role === 'tool');
|
|
229
|
+
expect(toolMessage.content.match(/<\/?untrusted_context>/g)).toEqual([
|
|
230
|
+
'<untrusted_context>',
|
|
231
|
+
'</untrusted_context>',
|
|
232
|
+
]);
|
|
233
|
+
expect(toolMessage.content).toContain('[tag removed]');
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it.skipIf(!posix)(
|
|
238
|
+
'never presents a read as a completed deletion',
|
|
239
|
+
async () => {
|
|
240
|
+
// Formal AI 0.355.27 answers "delete all files" with `ls` and claims
|
|
241
|
+
// success (reported upstream); start reports what really happened.
|
|
242
|
+
const result = await run('delete all files');
|
|
243
|
+
expect(result.tools.map((t) => t.command)).toEqual(['ls']);
|
|
244
|
+
expect(result.outcome.message).toBe(
|
|
245
|
+
'finished after 1 tool call(s); no file changed'
|
|
246
|
+
);
|
|
247
|
+
expect(result.after).toEqual(result.before);
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
it.skipIf(!posix)('caps tool output', async () => {
|
|
252
|
+
const result = await run('print list of files in current directory', {
|
|
253
|
+
bounds: { maxOutputBytes: 8 },
|
|
254
|
+
});
|
|
255
|
+
expect(result.stdout.length).toBeLessThanOrEqual(8);
|
|
256
|
+
expect(result.tools[0].truncated).toBe(true);
|
|
257
|
+
expect(result.notes.join('\n')).toContain(
|
|
258
|
+
'output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=8'
|
|
259
|
+
);
|
|
260
|
+
expect(result.sent[1].at(-1).content).toContain(
|
|
261
|
+
'[output truncated by start]'
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('--use-formal-ai refusals and unsupported requests (#255)', () => {
|
|
267
|
+
it('fails explicitly when Formal AI does not understand', async () => {
|
|
268
|
+
for (const request of [
|
|
269
|
+
'book me a flight to Paris',
|
|
270
|
+
'an request nobody recorded',
|
|
271
|
+
// Request text never reaches an inherited method of the stand-in.
|
|
272
|
+
'constructor',
|
|
273
|
+
'toString',
|
|
274
|
+
]) {
|
|
275
|
+
const result = await run(request);
|
|
276
|
+
expect(result.outcome.status).toBe('unsupported');
|
|
277
|
+
expect(result.outcome.exitCode).toBe(EXIT.UNSUPPORTED);
|
|
278
|
+
expect(result.tools).toEqual([]);
|
|
279
|
+
expect(result.after).toEqual(result.before);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('refuses shell syntax Formal AI passes through', async () => {
|
|
284
|
+
const result = await run('ls; rm -rf ~');
|
|
285
|
+
expect(result.outcome.status).toBe('refused');
|
|
286
|
+
expect(result.outcome.exitCode).toBe(EXIT.REFUSED);
|
|
287
|
+
expect(result.tools).toHaveLength(1);
|
|
288
|
+
expect(result.tools[0]).toMatchObject({
|
|
289
|
+
command: 'ls; rm -rf ~',
|
|
290
|
+
decision: 'refused',
|
|
291
|
+
});
|
|
292
|
+
expect(result.tools[0].exitCode).toBeUndefined();
|
|
293
|
+
expect(result.after).toEqual(result.before);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it.skipIf(!posix)('refuses changes in read-only mode', async () => {
|
|
297
|
+
const result = await run('create a directory named build');
|
|
298
|
+
expect(result.outcome.exitCode).toBe(EXIT.REFUSED);
|
|
299
|
+
expect(result.outcome.message).toContain('effect mode is read-only');
|
|
300
|
+
expect(result.tools.map((t) => [t.command, t.decision])).toEqual([
|
|
301
|
+
['test ! -e build', 'allowed'],
|
|
302
|
+
['mkdir build', 'refused'],
|
|
303
|
+
]);
|
|
304
|
+
expect(fs.existsSync(path.join(result.cwd, 'build'))).toBe(false);
|
|
305
|
+
expect(result.after).toEqual(result.before);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('refuses a change to a target it never observed', async () => {
|
|
309
|
+
const result = await run('synthetic: overwrite without looking', {
|
|
310
|
+
bounds: { effects: 'workspace-write' },
|
|
311
|
+
});
|
|
312
|
+
expect(result.outcome.exitCode).toBe(EXIT.REFUSED);
|
|
313
|
+
expect(result.outcome.message).toContain('was not observed');
|
|
314
|
+
expect(result.after).toEqual(result.before);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('refuses a change outside the working directory', async () => {
|
|
318
|
+
const result = await run('synthetic: write outside', {
|
|
319
|
+
bounds: { effects: 'workspace-write' },
|
|
320
|
+
});
|
|
321
|
+
expect(result.outcome.exitCode).toBe(EXIT.REFUSED);
|
|
322
|
+
expect(result.outcome.message).toContain('outside the working directory');
|
|
323
|
+
expect(fs.existsSync(path.join(result.cwd, '..', 'escape.txt'))).toBe(
|
|
324
|
+
false
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('rejects a tool start does not offer', async () => {
|
|
329
|
+
const result = await run('synthetic: call an unknown tool');
|
|
330
|
+
expect(result.outcome.status).toBe('protocol-error');
|
|
331
|
+
expect(result.outcome.exitCode).toBe(EXIT.PROTOCOL);
|
|
332
|
+
expect(result.tools).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('reports a failing runtime after bounded retries', async () => {
|
|
336
|
+
const result = await run('synthetic: server error', {
|
|
337
|
+
bounds: { maxRetries: 2 },
|
|
338
|
+
});
|
|
339
|
+
expect(result.outcome.status).toBe('protocol-error');
|
|
340
|
+
expect(result.outcome.exitCode).toBe(EXIT.PROTOCOL);
|
|
341
|
+
expect(result.outcome.message).toBe(
|
|
342
|
+
'Formal AI returned HTTP 500: synthetic failure (after 2 retries)'
|
|
343
|
+
);
|
|
344
|
+
expect(result.outcome.diagnostics).toMatchObject({
|
|
345
|
+
httpStatus: 500,
|
|
346
|
+
errorMessage: 'synthetic failure',
|
|
347
|
+
attempts: 3,
|
|
348
|
+
});
|
|
349
|
+
expect(result.requests).toBe(3);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('--use-formal-ai changes (#255)', () => {
|
|
354
|
+
it.skipIf(!posix)(
|
|
355
|
+
'observes, changes and reads back in workspace-write mode',
|
|
356
|
+
async () => {
|
|
357
|
+
const result = await run('create a directory named build', {
|
|
358
|
+
bounds: { effects: 'workspace-write' },
|
|
359
|
+
});
|
|
360
|
+
expect(result.outcome).toMatchObject({
|
|
361
|
+
status: 'completed',
|
|
362
|
+
exitCode: 0,
|
|
363
|
+
message: 'finished after 3 tool call(s); added build',
|
|
364
|
+
});
|
|
365
|
+
const mkdir = result.tools.find((t) => t.command === 'mkdir build');
|
|
366
|
+
expect(mkdir.effect).toBe('write');
|
|
367
|
+
expect(mkdir.verification).toEqual({
|
|
368
|
+
verified: true,
|
|
369
|
+
detail: 'readback confirmed mkdir build',
|
|
370
|
+
});
|
|
371
|
+
expect(fs.statSync(path.join(result.cwd, 'build')).isDirectory()).toBe(
|
|
372
|
+
true
|
|
373
|
+
);
|
|
374
|
+
// Unrelated content is untouched.
|
|
375
|
+
const { build, ...rest } = result.after;
|
|
376
|
+
expect(build).toBeDefined();
|
|
377
|
+
expect(Object.keys(rest).sort()).toEqual(
|
|
378
|
+
Object.keys(result.before).sort()
|
|
379
|
+
);
|
|
380
|
+
for (const [name, value] of Object.entries(result.before)) {
|
|
381
|
+
if (name !== '.' && !value.startsWith('dir')) {
|
|
382
|
+
expect(rest[name]).toBe(value);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
it.skipIf(!posix)(
|
|
389
|
+
'does not trust an exit status the readback contradicts',
|
|
390
|
+
async () => {
|
|
391
|
+
const bin = fakeBin({ mkdir: 'exit 0' });
|
|
392
|
+
dirs.push(bin);
|
|
393
|
+
const result = await run('synthetic: unverified mkdir', {
|
|
394
|
+
bounds: { effects: 'workspace-write' },
|
|
395
|
+
env: { PATH: `${bin}:${process.env.PATH}` },
|
|
396
|
+
});
|
|
397
|
+
expect(result.outcome.status).toBe('unverified');
|
|
398
|
+
expect(result.outcome.exitCode).toBe(EXIT.PROTOCOL);
|
|
399
|
+
expect(result.outcome.message).toContain('readback failed: out');
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('--use-formal-ai failures and bounds (#255)', () => {
|
|
405
|
+
it.skipIf(!posix)('reports a missing tool as 127', async () => {
|
|
406
|
+
const bin = fakeBin({});
|
|
407
|
+
dirs.push(bin);
|
|
408
|
+
const result = await run('synthetic: run a missing tool', {
|
|
409
|
+
env: { PATH: bin },
|
|
410
|
+
});
|
|
411
|
+
expect(result.outcome.exitCode).toBe(127);
|
|
412
|
+
expect(result.tools[0]).toMatchObject({
|
|
413
|
+
exitCode: 127,
|
|
414
|
+
spawnError: 'ENOENT',
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('stops at the step budget', async () => {
|
|
419
|
+
const result = await run('synthetic: loop forever', {
|
|
420
|
+
bounds: { maxSteps: 3 },
|
|
421
|
+
});
|
|
422
|
+
expect(result.outcome.status).toBe('budget-exhausted');
|
|
423
|
+
expect(result.outcome.exitCode).toBe(EXIT.BUDGET);
|
|
424
|
+
expect(result.tools).toHaveLength(3);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('stops at the time budget', async () => {
|
|
428
|
+
const result = await run('print list of files in current directory', {
|
|
429
|
+
deadline: Date.now() - 1,
|
|
430
|
+
});
|
|
431
|
+
expect(result.outcome.exitCode).toBe(EXIT.BUDGET);
|
|
432
|
+
expect(result.tools).toEqual([]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it.skipIf(!posix)('stops a tool at its time limit', async () => {
|
|
436
|
+
const bin = fakeBin({ cat: 'exec sleep 30' });
|
|
437
|
+
dirs.push(bin);
|
|
438
|
+
const started = Date.now();
|
|
439
|
+
const result = await run('synthetic: read slowly', {
|
|
440
|
+
bounds: { toolTimeoutMs: 300 },
|
|
441
|
+
env: { PATH: `${bin}:${process.env.PATH}` },
|
|
442
|
+
});
|
|
443
|
+
expect(Date.now() - started).toBeLessThan(10000);
|
|
444
|
+
expect(result.outcome.status).toBe('budget-exhausted');
|
|
445
|
+
expect(result.outcome.exitCode).toBe(EXIT.BUDGET);
|
|
446
|
+
expect(result.tools[0].timedOut).toBe(true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it.skipIf(!posix)(
|
|
450
|
+
'cancels the running tool and reports the signal',
|
|
451
|
+
async () => {
|
|
452
|
+
const bin = fakeBin({ cat: 'exec sleep 30' });
|
|
453
|
+
dirs.push(bin);
|
|
454
|
+
for (const [reason, code] of [
|
|
455
|
+
['SIGTERM', 143],
|
|
456
|
+
['SIGINT', 130],
|
|
457
|
+
]) {
|
|
458
|
+
const controller = new AbortController();
|
|
459
|
+
const started = Date.now();
|
|
460
|
+
const result = await run('synthetic: read slowly', {
|
|
461
|
+
env: { PATH: `${bin}:${process.env.PATH}` },
|
|
462
|
+
signal: controller.signal,
|
|
463
|
+
onNote: (text) => {
|
|
464
|
+
if (text === '$ cat slow.txt') {
|
|
465
|
+
setTimeout(() => controller.abort(reason), 100);
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
expect(Date.now() - started).toBeLessThan(10000);
|
|
470
|
+
expect(result.outcome).toMatchObject({
|
|
471
|
+
status: 'cancelled',
|
|
472
|
+
exitCode: code,
|
|
473
|
+
});
|
|
474
|
+
expect(result.outcome.remainingObligations).toEqual([
|
|
475
|
+
'the task was interrupted; later steps did not run',
|
|
476
|
+
]);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
);
|
|
480
|
+
});
|