start-command 0.41.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 +10 -0
- package/package.json +2 -2
- package/src/lib/formal-ai-client.js +246 -22
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +10 -3
- package/src/lib/formal-ai.js +164 -11
- package/src/lib/usage.js +3 -1
- package/test/fixtures/formal-ai/receipts-258.json +431 -0
- package/test/fixtures/formal-ai/replay.cjs +57 -7
- package/test/regression-255-cli.js +3 -1
- package/test/regression-255-e2e.js +80 -3
- package/test/regression-255-loop.js +6 -1
- package/test/regression-255-policy.js +1 -1
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #258: Formal AI edit requests through `--use-formal-ai`.
|
|
3
|
+
*
|
|
4
|
+
* - The released Bash-only tool contract makes Formal AI answer with an
|
|
5
|
+
* explicit `workspace-edit-capability-gap/v1`; start must report that as an
|
|
6
|
+
* incomplete, nonzero outcome with its obligations, never as success.
|
|
7
|
+
* - With the structured read/edit/write tools, the same literal edit runs
|
|
8
|
+
* read -> edit -> sha256sum readback and changes only the declaration.
|
|
9
|
+
* - Plain question answering without tools stays exit 0.
|
|
10
|
+
*
|
|
11
|
+
* Replies come from receipts captured from the real Formal AI 0.355.29
|
|
12
|
+
* runtime (test/fixtures/formal-ai/receipts-258.json, written by
|
|
13
|
+
* experiments/issue-258/capture-receipts.cjs) for both tool contracts.
|
|
14
|
+
*/
|
|
15
|
+
const { describe, it, expect, beforeAll, afterAll } = require('bun:test');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const {
|
|
21
|
+
EXIT,
|
|
22
|
+
evaluateCommand,
|
|
23
|
+
resolveBounds,
|
|
24
|
+
} = require('../src/lib/formal-ai-policy');
|
|
25
|
+
const {
|
|
26
|
+
BASH_TOOL,
|
|
27
|
+
createClient,
|
|
28
|
+
offeredTools,
|
|
29
|
+
parseCompletion,
|
|
30
|
+
recognizeCapabilityGap,
|
|
31
|
+
} = require('../src/lib/formal-ai-client');
|
|
32
|
+
const { runFormalAiTask } = require('../src/lib/formal-ai');
|
|
33
|
+
const { createFakeServer, edits } = require('./fixtures/formal-ai/replay.cjs');
|
|
34
|
+
const {
|
|
35
|
+
CLI,
|
|
36
|
+
cleanup,
|
|
37
|
+
cliEnv,
|
|
38
|
+
fakeCheckout,
|
|
39
|
+
populate,
|
|
40
|
+
readEvidence,
|
|
41
|
+
readLog,
|
|
42
|
+
sentRequests,
|
|
43
|
+
sink,
|
|
44
|
+
snapshot,
|
|
45
|
+
tempDir,
|
|
46
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
47
|
+
|
|
48
|
+
const posix = process.platform !== 'win32';
|
|
49
|
+
/** The exact gap response of the issue. */
|
|
50
|
+
const GAP =
|
|
51
|
+
'{"complete":false,"missingCapabilities":["read","edit"],"reason":"EditToolUnavailable","schema":"workspace-edit-capability-gap/v1","target":"snippet.mjs","verified":false}';
|
|
52
|
+
const ISSUE_EDIT =
|
|
53
|
+
'In snippet.mjs, replace the before label declaration with the after label declaration, preserving all other content.';
|
|
54
|
+
const LITERAL_EDIT =
|
|
55
|
+
'In snippet.mjs, replace «const label = "before";» with «const label = "after";». Preserve all other content.';
|
|
56
|
+
const ORIGINAL = edits.fixture['snippet.mjs'];
|
|
57
|
+
const EDITED = ORIGINAL.replace(
|
|
58
|
+
'const label = "before";',
|
|
59
|
+
'const label = "after";'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let server;
|
|
63
|
+
let baseUrl;
|
|
64
|
+
const dirs = [];
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
server = createFakeServer();
|
|
68
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
69
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(() => {
|
|
73
|
+
server.close();
|
|
74
|
+
cleanup(...dirs);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function fixture() {
|
|
78
|
+
const dir = populate(tempDir('258'), edits.fixture);
|
|
79
|
+
dirs.push(dir);
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run one request through the real loop with a chosen tool contract. */
|
|
84
|
+
async function run(request, { effects = 'read-only', tools, cwd } = {}) {
|
|
85
|
+
const dir = cwd || fixture();
|
|
86
|
+
const bounds = { ...resolveBounds({}), effects };
|
|
87
|
+
const deadline = Date.now() + bounds.timeoutMs;
|
|
88
|
+
const client = createClient({ baseUrl, bounds, deadline, tools });
|
|
89
|
+
const sent = [];
|
|
90
|
+
const stdout = sink();
|
|
91
|
+
const notes = [];
|
|
92
|
+
const evidence = await runFormalAiTask({
|
|
93
|
+
request,
|
|
94
|
+
cwd: dir,
|
|
95
|
+
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
|
|
96
|
+
bounds,
|
|
97
|
+
deadline,
|
|
98
|
+
client: {
|
|
99
|
+
complete(messages, options) {
|
|
100
|
+
sent.push(JSON.parse(JSON.stringify(messages)));
|
|
101
|
+
return client.complete(messages, options);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
io: { stdout, stderr: sink(), note: (text) => notes.push(text) },
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
evidence,
|
|
108
|
+
outcome: evidence.outcome,
|
|
109
|
+
tools: evidence.steps.filter((s) => s.kind === 'tool'),
|
|
110
|
+
text: fs.readFileSync(path.join(dir, 'snippet.mjs'), 'utf8'),
|
|
111
|
+
stdout: stdout.text(),
|
|
112
|
+
notes,
|
|
113
|
+
sent,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('capability-gap recognition (#258)', () => {
|
|
118
|
+
it('recognizes the exact gap response', () => {
|
|
119
|
+
expect(recognizeCapabilityGap(GAP, [])).toEqual({
|
|
120
|
+
schema: 'workspace-edit-capability-gap/v1',
|
|
121
|
+
target: 'snippet.mjs',
|
|
122
|
+
reason: 'EditToolUnavailable',
|
|
123
|
+
missingCapabilities: ['read', 'edit'],
|
|
124
|
+
complete: false,
|
|
125
|
+
verified: false,
|
|
126
|
+
});
|
|
127
|
+
expect(recognizeCapabilityGap(`\n ${GAP}\n`, [])).not.toBeNull();
|
|
128
|
+
expect(
|
|
129
|
+
recognizeCapabilityGap(
|
|
130
|
+
'{"complete":false,"reason":"VerificationRunToolUnavailable","schema":"workspace-change-verification-gap/v1","target":"a.js","verified":false}',
|
|
131
|
+
[]
|
|
132
|
+
)
|
|
133
|
+
).toMatchObject({ missingCapabilities: [], target: 'a.js' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accepts only known, explicitly incomplete gap objects', () => {
|
|
137
|
+
const variant = (patch) => JSON.stringify({ ...JSON.parse(GAP), ...patch });
|
|
138
|
+
for (const content of [
|
|
139
|
+
'',
|
|
140
|
+
'I cannot edit files.',
|
|
141
|
+
`Here you go: ${GAP}`,
|
|
142
|
+
`${GAP}\n${GAP}`,
|
|
143
|
+
'[1]',
|
|
144
|
+
variant({ schema: 'workspace-edit-capability-gap/v2' }),
|
|
145
|
+
variant({ schema: 'something-else/v1' }),
|
|
146
|
+
variant({ reason: 'Whatever' }),
|
|
147
|
+
variant({ complete: true }),
|
|
148
|
+
variant({ verified: true }),
|
|
149
|
+
variant({ complete: 'false' }),
|
|
150
|
+
variant({ target: '' }),
|
|
151
|
+
variant({ target: 3 }),
|
|
152
|
+
variant({ missingCapabilities: 'read' }),
|
|
153
|
+
variant({ missingCapabilities: ['read', 'teleport'] }),
|
|
154
|
+
]) {
|
|
155
|
+
expect(recognizeCapabilityGap(content, [])).toBeNull();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('ignores gap text echoed from a tool result', () => {
|
|
160
|
+
const messages = [
|
|
161
|
+
{ role: 'user', content: 'show contents of gap.json' },
|
|
162
|
+
{ role: 'tool', tool_call_id: 'c1', content: GAP },
|
|
163
|
+
];
|
|
164
|
+
expect(recognizeCapabilityGap(GAP, messages)).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('offers the change tools only in workspace-write mode', () => {
|
|
168
|
+
const names = (effects) =>
|
|
169
|
+
offeredTools(effects).map((tool) => tool.function.name);
|
|
170
|
+
expect(names('read-only')).toEqual(['bash', 'read']);
|
|
171
|
+
expect(names('workspace-write')).toEqual(['bash', 'read', 'edit', 'write']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('parses file tool calls with Formal AI argument aliases', () => {
|
|
175
|
+
const completion = (name, args) => ({
|
|
176
|
+
choices: [
|
|
177
|
+
{
|
|
178
|
+
finish_reason: 'tool_calls',
|
|
179
|
+
message: {
|
|
180
|
+
content: '',
|
|
181
|
+
tool_calls: [
|
|
182
|
+
{ id: 'c', function: { name, arguments: JSON.stringify(args) } },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
const [read] = parseCompletion(
|
|
189
|
+
completion('read', {
|
|
190
|
+
filePath: 'a.js',
|
|
191
|
+
file_path: 'a.js',
|
|
192
|
+
path: 'a.js',
|
|
193
|
+
})
|
|
194
|
+
).toolCalls;
|
|
195
|
+
expect(read).toMatchObject({
|
|
196
|
+
tool: 'read',
|
|
197
|
+
command: 'read a.js',
|
|
198
|
+
args: { path: 'a.js' },
|
|
199
|
+
});
|
|
200
|
+
expect(() =>
|
|
201
|
+
parseCompletion(completion('read', { path: 'a.js', file_path: 'b.js' }))
|
|
202
|
+
).toThrow('conflicting');
|
|
203
|
+
expect(() =>
|
|
204
|
+
parseCompletion(completion('edit', { path: 'a.js', old_string: 'x' }))
|
|
205
|
+
).toThrow('edit needs new_string');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('the released Bash-only tool contract (#258)', () => {
|
|
210
|
+
it('reports the gap as incomplete with exit 65 and obligations', async () => {
|
|
211
|
+
const result = await run(ISSUE_EDIT, {
|
|
212
|
+
effects: 'workspace-write',
|
|
213
|
+
tools: [BASH_TOOL],
|
|
214
|
+
});
|
|
215
|
+
expect(result.sent[0]).toEqual([{ role: 'user', content: ISSUE_EDIT }]);
|
|
216
|
+
expect(result.outcome).toEqual({
|
|
217
|
+
status: 'capability-gap',
|
|
218
|
+
exitCode: EXIT.UNSUPPORTED,
|
|
219
|
+
message:
|
|
220
|
+
'Formal AI reported a capability gap for snippet.mjs (EditToolUnavailable; missing read, edit); nothing was verified as done',
|
|
221
|
+
remainingObligations: [
|
|
222
|
+
'the requested change to snippet.mjs was not made or not verified',
|
|
223
|
+
],
|
|
224
|
+
gap: JSON.parse(GAP),
|
|
225
|
+
});
|
|
226
|
+
expect(result.tools).toEqual([]);
|
|
227
|
+
expect(result.text).toBe(ORIGINAL);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('keeps answering plain questions with exit 0', async () => {
|
|
231
|
+
const result = await run('What is 2 + 2?', { tools: [BASH_TOOL] });
|
|
232
|
+
expect(result.outcome).toMatchObject({
|
|
233
|
+
status: 'answered',
|
|
234
|
+
exitCode: 0,
|
|
235
|
+
remainingObligations: [],
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('structured file tools against captured receipts (#258)', () => {
|
|
241
|
+
it.skipIf(!posix)(
|
|
242
|
+
'every captured request replays to its captured outcome and file text',
|
|
243
|
+
async () => {
|
|
244
|
+
for (const receipt of edits.receipts) {
|
|
245
|
+
const offered = receipt.offered.join(',');
|
|
246
|
+
const tools =
|
|
247
|
+
offered === 'bash'
|
|
248
|
+
? [BASH_TOOL]
|
|
249
|
+
: offeredTools(receipt.effects).map((t) => t);
|
|
250
|
+
expect(tools.map((t) => t.function.name)).toEqual(receipt.offered);
|
|
251
|
+
const result = await run(receipt.request, {
|
|
252
|
+
effects: receipt.effects,
|
|
253
|
+
tools,
|
|
254
|
+
});
|
|
255
|
+
expect({
|
|
256
|
+
request: receipt.request,
|
|
257
|
+
offered,
|
|
258
|
+
outcome: result.outcome,
|
|
259
|
+
tools: result.tools.map((t) => [t.command, t.decision, t.exitCode]),
|
|
260
|
+
text: result.text,
|
|
261
|
+
}).toEqual({
|
|
262
|
+
request: receipt.request,
|
|
263
|
+
offered,
|
|
264
|
+
outcome: receipt.outcome,
|
|
265
|
+
tools: receipt.tools.map((t) => [t.command, t.decision, t.exitCode]),
|
|
266
|
+
text: receipt.after['snippet.mjs'],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
it.skipIf(!posix)(
|
|
273
|
+
'reads, edits and reads back, preserving all other bytes',
|
|
274
|
+
async () => {
|
|
275
|
+
const result = await run(LITERAL_EDIT, { effects: 'workspace-write' });
|
|
276
|
+
expect(result.outcome).toMatchObject({
|
|
277
|
+
status: 'completed',
|
|
278
|
+
exitCode: 0,
|
|
279
|
+
message: 'finished after 3 tool call(s); changed snippet.mjs',
|
|
280
|
+
remainingObligations: [],
|
|
281
|
+
});
|
|
282
|
+
expect(result.text).toBe(EDITED);
|
|
283
|
+
const [read, edit, check] = result.tools;
|
|
284
|
+
expect(read).toMatchObject({
|
|
285
|
+
tool: 'read',
|
|
286
|
+
effect: 'read',
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
bytes: Buffer.byteLength(ORIGINAL),
|
|
289
|
+
});
|
|
290
|
+
expect(edit).toMatchObject({
|
|
291
|
+
tool: 'edit',
|
|
292
|
+
effect: 'write',
|
|
293
|
+
target: 'snippet.mjs',
|
|
294
|
+
exitCode: 0,
|
|
295
|
+
replacements: 1,
|
|
296
|
+
previousSha256: read.sha256,
|
|
297
|
+
verification: { verified: true },
|
|
298
|
+
effects: { changed: ['snippet.mjs'], added: [], removed: [] },
|
|
299
|
+
});
|
|
300
|
+
expect(edit.sha256).toBe(
|
|
301
|
+
crypto.createHash('sha256').update(EDITED).digest('hex')
|
|
302
|
+
);
|
|
303
|
+
expect(check).toMatchObject({
|
|
304
|
+
command: 'sha256sum -- snippet.mjs',
|
|
305
|
+
exitCode: 0,
|
|
306
|
+
});
|
|
307
|
+
// Formal AI received the exact file text and the readback hash.
|
|
308
|
+
const toolMessages = result.sent[3].filter((m) => m.role === 'tool');
|
|
309
|
+
expect(toolMessages[0].content).toBe(ORIGINAL);
|
|
310
|
+
expect(toolMessages[2].content).toContain(edit.sha256);
|
|
311
|
+
expect(result.evidence.effects).toMatchObject({
|
|
312
|
+
changed: ['snippet.mjs'],
|
|
313
|
+
added: [],
|
|
314
|
+
removed: [],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
it('reports the missing edit tool in read-only mode with the remedy', async () => {
|
|
320
|
+
const result = await run(LITERAL_EDIT);
|
|
321
|
+
expect(result.outcome).toMatchObject({
|
|
322
|
+
status: 'capability-gap',
|
|
323
|
+
exitCode: EXIT.UNSUPPORTED,
|
|
324
|
+
gap: { missingCapabilities: ['edit'], target: 'snippet.mjs' },
|
|
325
|
+
remainingObligations: [
|
|
326
|
+
'the requested change to snippet.mjs was not made or not verified',
|
|
327
|
+
'set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools',
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
expect(result.text).toBe(ORIGINAL);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('shows a file through the read tool without changing it', async () => {
|
|
334
|
+
const result = await run('show contents of snippet.mjs');
|
|
335
|
+
expect(result.outcome).toMatchObject({ status: 'completed', exitCode: 0 });
|
|
336
|
+
expect(result.tools.map((t) => t.command)).toEqual(['read snippet.mjs']);
|
|
337
|
+
expect(result.stdout).toBe(ORIGINAL);
|
|
338
|
+
expect(result.text).toBe(ORIGINAL);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('no interpreter bypass of the tool policy (#258)', () => {
|
|
343
|
+
it('still refuses interpreters, in-place editors and shell redirection', () => {
|
|
344
|
+
const cwd = fixture();
|
|
345
|
+
const context = {
|
|
346
|
+
cwd,
|
|
347
|
+
effects: 'workspace-write',
|
|
348
|
+
observed: new Set(['snippet.mjs']),
|
|
349
|
+
observedDirs: new Set(['.']),
|
|
350
|
+
};
|
|
351
|
+
for (const command of [
|
|
352
|
+
"node -e \"require('fs').writeFileSync('snippet.mjs', 'x')\"",
|
|
353
|
+
'python3 -c pass',
|
|
354
|
+
'perl -pi -e s/before/after/ snippet.mjs',
|
|
355
|
+
'sed -i s/before/after/ snippet.mjs',
|
|
356
|
+
'awk 1 snippet.mjs',
|
|
357
|
+
'sh -c true',
|
|
358
|
+
'bash -c true',
|
|
359
|
+
'tee snippet.mjs',
|
|
360
|
+
'dd of=snippet.mjs',
|
|
361
|
+
'echo x > snippet.mjs',
|
|
362
|
+
]) {
|
|
363
|
+
expect(evaluateCommand(command, context).allowed).toBe(false);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('edit requests through the CLI (#258)', () => {
|
|
369
|
+
const checkout = fakeCheckout({ version: '0.355.29' });
|
|
370
|
+
dirs.push(checkout);
|
|
371
|
+
|
|
372
|
+
function cli(request, env = {}) {
|
|
373
|
+
const root = tempDir('cli-258');
|
|
374
|
+
const cwd = fixture();
|
|
375
|
+
dirs.push(root);
|
|
376
|
+
const before = snapshot(cwd);
|
|
377
|
+
const result = spawnSync(
|
|
378
|
+
process.execPath,
|
|
379
|
+
[CLI, '--use-formal-ai', '--', request],
|
|
380
|
+
{
|
|
381
|
+
cwd,
|
|
382
|
+
env: cliEnv(root, { START_FORMAL_AI_ROOT: checkout, ...env }),
|
|
383
|
+
encoding: 'utf8',
|
|
384
|
+
timeout: 60000,
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
return {
|
|
388
|
+
...result,
|
|
389
|
+
before,
|
|
390
|
+
after: snapshot(cwd),
|
|
391
|
+
text: fs.readFileSync(path.join(cwd, 'snippet.mjs'), 'utf8'),
|
|
392
|
+
requests: sentRequests(root),
|
|
393
|
+
evidence: readEvidence(readLog(root)),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
it.skipIf(!posix)('edits a file in workspace-write mode', () => {
|
|
398
|
+
const result = cli(LITERAL_EDIT, {
|
|
399
|
+
START_FORMAL_AI_EFFECTS: 'workspace-write',
|
|
400
|
+
});
|
|
401
|
+
expect(result.status).toBe(0);
|
|
402
|
+
expect(result.text).toBe(EDITED);
|
|
403
|
+
expect(result.stderr).toContain('[formal-ai] edit snippet.mjs');
|
|
404
|
+
expect(result.stderr).toContain(
|
|
405
|
+
'[formal-ai] readback confirmed edit snippet.mjs'
|
|
406
|
+
);
|
|
407
|
+
expect(result.requests[0].tools.map((t) => t.function.name)).toEqual([
|
|
408
|
+
'bash',
|
|
409
|
+
'read',
|
|
410
|
+
'edit',
|
|
411
|
+
'write',
|
|
412
|
+
]);
|
|
413
|
+
expect(result.evidence.outcome).toMatchObject({
|
|
414
|
+
status: 'completed',
|
|
415
|
+
exitCode: 0,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('exits 65 on a reported gap without changing anything', () => {
|
|
420
|
+
const result = cli(LITERAL_EDIT);
|
|
421
|
+
expect(result.status).toBe(EXIT.UNSUPPORTED);
|
|
422
|
+
expect(result.after).toEqual(result.before);
|
|
423
|
+
expect(result.stderr).toContain(
|
|
424
|
+
'capability-gap: Formal AI reported a capability gap for snippet.mjs'
|
|
425
|
+
);
|
|
426
|
+
expect(result.stderr).toContain('START_FORMAL_AI_EFFECTS=workspace-write');
|
|
427
|
+
expect(result.evidence.outcome).toMatchObject({
|
|
428
|
+
status: 'capability-gap',
|
|
429
|
+
exitCode: EXIT.UNSUPPORTED,
|
|
430
|
+
gap: { reason: 'EditToolUnavailable' },
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('answers a plain question with exit 0', () => {
|
|
435
|
+
const result = cli('What is 2 + 2?');
|
|
436
|
+
expect(result.status).toBe(0);
|
|
437
|
+
expect(result.stderr).toContain('[formal-ai] 2 + 2 = 4');
|
|
438
|
+
expect(result.evidence.outcome).toMatchObject({
|
|
439
|
+
status: 'answered',
|
|
440
|
+
exitCode: 0,
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|