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.
@@ -0,0 +1,431 @@
1
+ {
2
+ "source": "captured from Formal AI 0.355.29 (982a7ffcb05d039292712ec9fe33bdbd645ed569) js agent mode by experiments/issue-258/capture-receipts.cjs",
3
+ "capturedAt": "2026-11-02",
4
+ "fixture": {
5
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
6
+ },
7
+ "receipts": [
8
+ {
9
+ "request": "In snippet.mjs, replace the before label declaration with the after label declaration, preserving all other content.",
10
+ "effects": "workspace-write",
11
+ "offered": ["bash"],
12
+ "responses": [
13
+ {
14
+ "finish_reason": "stop",
15
+ "message": {
16
+ "role": "assistant",
17
+ "content": "{\"complete\":false,\"missingCapabilities\":[\"read\",\"edit\"],\"reason\":\"EditToolUnavailable\",\"schema\":\"workspace-edit-capability-gap/v1\",\"target\":\"snippet.mjs\",\"verified\":false}"
18
+ }
19
+ }
20
+ ],
21
+ "tools": [],
22
+ "outcome": {
23
+ "status": "capability-gap",
24
+ "exitCode": 65,
25
+ "message": "Formal AI reported a capability gap for snippet.mjs (EditToolUnavailable; missing read, edit); nothing was verified as done",
26
+ "remainingObligations": [
27
+ "the requested change to snippet.mjs was not made or not verified"
28
+ ],
29
+ "gap": {
30
+ "schema": "workspace-edit-capability-gap/v1",
31
+ "target": "snippet.mjs",
32
+ "reason": "EditToolUnavailable",
33
+ "missingCapabilities": ["read", "edit"],
34
+ "complete": false,
35
+ "verified": false
36
+ }
37
+ },
38
+ "after": {
39
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
40
+ }
41
+ },
42
+ {
43
+ "request": "In snippet.mjs, replace the before label declaration with the after label declaration, preserving all other content.",
44
+ "effects": "read-only",
45
+ "offered": ["bash", "read"],
46
+ "responses": [
47
+ {
48
+ "finish_reason": "stop",
49
+ "message": {
50
+ "role": "assistant",
51
+ "content": "{\"complete\":false,\"missingCapabilities\":[\"edit\"],\"reason\":\"EditToolUnavailable\",\"schema\":\"workspace-edit-capability-gap/v1\",\"target\":\"snippet.mjs\",\"verified\":false}"
52
+ }
53
+ }
54
+ ],
55
+ "tools": [],
56
+ "outcome": {
57
+ "status": "capability-gap",
58
+ "exitCode": 65,
59
+ "message": "Formal AI reported a capability gap for snippet.mjs (EditToolUnavailable; missing edit); nothing was verified as done",
60
+ "remainingObligations": [
61
+ "the requested change to snippet.mjs was not made or not verified",
62
+ "set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools"
63
+ ],
64
+ "gap": {
65
+ "schema": "workspace-edit-capability-gap/v1",
66
+ "target": "snippet.mjs",
67
+ "reason": "EditToolUnavailable",
68
+ "missingCapabilities": ["edit"],
69
+ "complete": false,
70
+ "verified": false
71
+ }
72
+ },
73
+ "after": {
74
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
75
+ }
76
+ },
77
+ {
78
+ "request": "In snippet.mjs, replace the before label declaration with the after label declaration, preserving all other content.",
79
+ "effects": "workspace-write",
80
+ "offered": ["bash", "read", "edit", "write"],
81
+ "responses": [
82
+ {
83
+ "finish_reason": "tool_calls",
84
+ "message": {
85
+ "role": "assistant",
86
+ "content": "Let me open snippet.mjs and read what it says.",
87
+ "tool_calls": [
88
+ {
89
+ "id": "call_3a9e6d58f61cf7c6",
90
+ "type": "function",
91
+ "function": {
92
+ "name": "read",
93
+ "arguments": "{\"path\":\"snippet.mjs\"}"
94
+ }
95
+ }
96
+ ]
97
+ }
98
+ },
99
+ {
100
+ "finish_reason": "tool_calls",
101
+ "message": {
102
+ "role": "assistant",
103
+ "content": "Let me update snippet.mjs for you.",
104
+ "tool_calls": [
105
+ {
106
+ "id": "call_21c111ddfe563da7",
107
+ "type": "function",
108
+ "function": {
109
+ "name": "edit",
110
+ "arguments": "{\"new_string\":\"the after label declaration, preserving all other content\",\"old_string\":\"the before label declaration\",\"path\":\"snippet.mjs\"}"
111
+ }
112
+ }
113
+ ]
114
+ }
115
+ },
116
+ {
117
+ "finish_reason": "stop",
118
+ "message": {
119
+ "role": "assistant",
120
+ "content": "The command failed: snippet.mjs: old_string was not found; nothing changed"
121
+ }
122
+ }
123
+ ],
124
+ "tools": [
125
+ {
126
+ "tool": "read",
127
+ "command": "read snippet.mjs",
128
+ "decision": "allowed",
129
+ "exitCode": 0
130
+ },
131
+ {
132
+ "tool": "edit",
133
+ "command": "edit snippet.mjs",
134
+ "decision": "allowed",
135
+ "exitCode": 1
136
+ }
137
+ ],
138
+ "outcome": {
139
+ "status": "completed",
140
+ "exitCode": 1,
141
+ "message": "finished; the last tool call exited with 1; no file changed",
142
+ "remainingObligations": ["the last tool call failed"]
143
+ },
144
+ "after": {
145
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
146
+ }
147
+ },
148
+ {
149
+ "request": "In snippet.mjs, replace «const label = \"before\";» with «const label = \"after\";». Preserve all other content.",
150
+ "effects": "workspace-write",
151
+ "offered": ["bash"],
152
+ "responses": [
153
+ {
154
+ "finish_reason": "stop",
155
+ "message": {
156
+ "role": "assistant",
157
+ "content": "{\"complete\":false,\"missingCapabilities\":[\"read\",\"edit\"],\"reason\":\"EditToolUnavailable\",\"schema\":\"workspace-edit-capability-gap/v1\",\"target\":\"snippet.mjs\",\"verified\":false}"
158
+ }
159
+ }
160
+ ],
161
+ "tools": [],
162
+ "outcome": {
163
+ "status": "capability-gap",
164
+ "exitCode": 65,
165
+ "message": "Formal AI reported a capability gap for snippet.mjs (EditToolUnavailable; missing read, edit); nothing was verified as done",
166
+ "remainingObligations": [
167
+ "the requested change to snippet.mjs was not made or not verified"
168
+ ],
169
+ "gap": {
170
+ "schema": "workspace-edit-capability-gap/v1",
171
+ "target": "snippet.mjs",
172
+ "reason": "EditToolUnavailable",
173
+ "missingCapabilities": ["read", "edit"],
174
+ "complete": false,
175
+ "verified": false
176
+ }
177
+ },
178
+ "after": {
179
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
180
+ }
181
+ },
182
+ {
183
+ "request": "In snippet.mjs, replace «const label = \"before\";» with «const label = \"after\";». Preserve all other content.",
184
+ "effects": "read-only",
185
+ "offered": ["bash", "read"],
186
+ "responses": [
187
+ {
188
+ "finish_reason": "tool_calls",
189
+ "message": {
190
+ "role": "assistant",
191
+ "content": "Let me open snippet.mjs and read what it says.",
192
+ "tool_calls": [
193
+ {
194
+ "id": "call_af1eccb805c65251",
195
+ "type": "function",
196
+ "function": {
197
+ "name": "read",
198
+ "arguments": "{\"path\":\"snippet.mjs\"}"
199
+ }
200
+ }
201
+ ]
202
+ }
203
+ },
204
+ {
205
+ "finish_reason": "stop",
206
+ "message": {
207
+ "role": "assistant",
208
+ "content": "{\"complete\":false,\"missingCapabilities\":[\"edit\"],\"reason\":\"EditToolUnavailable\",\"schema\":\"workspace-edit-capability-gap/v1\",\"target\":\"snippet.mjs\",\"verified\":false}"
209
+ }
210
+ }
211
+ ],
212
+ "tools": [
213
+ {
214
+ "tool": "read",
215
+ "command": "read snippet.mjs",
216
+ "decision": "allowed",
217
+ "exitCode": 0
218
+ }
219
+ ],
220
+ "outcome": {
221
+ "status": "capability-gap",
222
+ "exitCode": 65,
223
+ "message": "Formal AI reported a capability gap for snippet.mjs (EditToolUnavailable; missing edit); nothing was verified as done",
224
+ "remainingObligations": [
225
+ "the requested change to snippet.mjs was not made or not verified",
226
+ "set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools"
227
+ ],
228
+ "gap": {
229
+ "schema": "workspace-edit-capability-gap/v1",
230
+ "target": "snippet.mjs",
231
+ "reason": "EditToolUnavailable",
232
+ "missingCapabilities": ["edit"],
233
+ "complete": false,
234
+ "verified": false
235
+ }
236
+ },
237
+ "after": {
238
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
239
+ }
240
+ },
241
+ {
242
+ "request": "In snippet.mjs, replace «const label = \"before\";» with «const label = \"after\";». Preserve all other content.",
243
+ "effects": "workspace-write",
244
+ "offered": ["bash", "read", "edit", "write"],
245
+ "responses": [
246
+ {
247
+ "finish_reason": "tool_calls",
248
+ "message": {
249
+ "role": "assistant",
250
+ "content": "Let me open snippet.mjs and read what it says.",
251
+ "tool_calls": [
252
+ {
253
+ "id": "call_af1eccb805c65251",
254
+ "type": "function",
255
+ "function": {
256
+ "name": "read",
257
+ "arguments": "{\"path\":\"snippet.mjs\"}"
258
+ }
259
+ }
260
+ ]
261
+ }
262
+ },
263
+ {
264
+ "finish_reason": "tool_calls",
265
+ "message": {
266
+ "role": "assistant",
267
+ "content": "Let me update snippet.mjs for you.",
268
+ "tool_calls": [
269
+ {
270
+ "id": "call_8867a0cbe9d10d83",
271
+ "type": "function",
272
+ "function": {
273
+ "name": "edit",
274
+ "arguments": "{\"new_string\":\"const label = \\\"after\\\";\",\"old_string\":\"const label = \\\"before\\\";\",\"path\":\"snippet.mjs\"}"
275
+ }
276
+ }
277
+ ]
278
+ }
279
+ },
280
+ {
281
+ "finish_reason": "tool_calls",
282
+ "message": {
283
+ "role": "assistant",
284
+ "content": "Let me run the requested command to get that for you.",
285
+ "tool_calls": [
286
+ {
287
+ "id": "call_410bede5ab85d847",
288
+ "type": "function",
289
+ "function": {
290
+ "name": "bash",
291
+ "arguments": "{\"command\":\"sha256sum -- snippet.mjs\"}"
292
+ }
293
+ }
294
+ ]
295
+ }
296
+ },
297
+ {
298
+ "finish_reason": "stop",
299
+ "message": {
300
+ "role": "assistant",
301
+ "content": "obligation_49c9d049437c0075\n record_type \"obligation_gap\"\n clause \"Preserve all other content.\"\n span_start \"85\"\n span_end \"112\"\n reason \"no_artifact_in_clause\""
302
+ }
303
+ }
304
+ ],
305
+ "tools": [
306
+ {
307
+ "tool": "read",
308
+ "command": "read snippet.mjs",
309
+ "decision": "allowed",
310
+ "exitCode": 0
311
+ },
312
+ {
313
+ "tool": "edit",
314
+ "command": "edit snippet.mjs",
315
+ "decision": "allowed",
316
+ "exitCode": 0
317
+ },
318
+ {
319
+ "command": "sha256sum -- snippet.mjs",
320
+ "decision": "allowed",
321
+ "exitCode": 0
322
+ }
323
+ ],
324
+ "outcome": {
325
+ "status": "completed",
326
+ "exitCode": 0,
327
+ "message": "finished after 3 tool call(s); changed snippet.mjs",
328
+ "remainingObligations": []
329
+ },
330
+ "after": {
331
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"after\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
332
+ }
333
+ },
334
+ {
335
+ "request": "show contents of snippet.mjs",
336
+ "effects": "read-only",
337
+ "offered": ["bash", "read"],
338
+ "responses": [
339
+ {
340
+ "finish_reason": "tool_calls",
341
+ "message": {
342
+ "role": "assistant",
343
+ "content": "Let me open snippet.mjs and read what it says.",
344
+ "tool_calls": [
345
+ {
346
+ "id": "call_83b2d12da8280765",
347
+ "type": "function",
348
+ "function": {
349
+ "name": "read",
350
+ "arguments": "{\"path\":\"snippet.mjs\"}"
351
+ }
352
+ }
353
+ ]
354
+ }
355
+ },
356
+ {
357
+ "finish_reason": "stop",
358
+ "message": {
359
+ "role": "assistant",
360
+ "content": "Contents of `snippet.mjs`:\n\n```text\n// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n```"
361
+ }
362
+ }
363
+ ],
364
+ "tools": [
365
+ {
366
+ "tool": "read",
367
+ "command": "read snippet.mjs",
368
+ "decision": "allowed",
369
+ "exitCode": 0
370
+ }
371
+ ],
372
+ "outcome": {
373
+ "status": "completed",
374
+ "exitCode": 0,
375
+ "message": "finished after 1 tool call(s); no file changed",
376
+ "remainingObligations": []
377
+ },
378
+ "after": {
379
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
380
+ }
381
+ },
382
+ {
383
+ "request": "What is 2 + 2?",
384
+ "effects": "read-only",
385
+ "offered": ["bash", "read"],
386
+ "responses": [
387
+ {
388
+ "finish_reason": "stop",
389
+ "message": {
390
+ "role": "assistant",
391
+ "content": "2 + 2 = 4"
392
+ }
393
+ }
394
+ ],
395
+ "tools": [],
396
+ "outcome": {
397
+ "status": "answered",
398
+ "exitCode": 0,
399
+ "message": "Formal AI answered without tools",
400
+ "remainingObligations": []
401
+ },
402
+ "after": {
403
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
404
+ }
405
+ },
406
+ {
407
+ "request": "What is 2 + 2?",
408
+ "effects": "read-only",
409
+ "offered": ["bash"],
410
+ "responses": [
411
+ {
412
+ "finish_reason": "stop",
413
+ "message": {
414
+ "role": "assistant",
415
+ "content": "2 + 2 = 4"
416
+ }
417
+ }
418
+ ],
419
+ "tools": [],
420
+ "outcome": {
421
+ "status": "answered",
422
+ "exitCode": 0,
423
+ "message": "Formal AI answered without tools",
424
+ "remainingObligations": []
425
+ },
426
+ "after": {
427
+ "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
428
+ }
429
+ }
430
+ ]
431
+ }
@@ -6,16 +6,29 @@
6
6
  * is the number of assistant turns already in the conversation. Unknown
7
7
  * requests get the runtime's "did not understand" reply. SYNTHETIC scenarios
8
8
  * (requests starting with `synthetic:`) cover states a real runtime cannot be
9
- * made to produce on demand. A number reply is an HTTP error status.
9
+ * made to produce on demand. A number reply is an HTTP error status, and a
10
+ * `{status, text}` reply an HTTP error with that exact body.
11
+ *
12
+ * Receipts of issue #258 (receipts-258.json) also record the tool names start
13
+ * offered; they answer only a request offering exactly those tools, so the
14
+ * released Bash-only contract and the structured file tools replay apart.
10
15
  */
11
16
 
12
17
  const fs = require('fs');
13
18
  const http = require('http');
14
19
  const path = require('path');
15
20
 
16
- const { receipts } = JSON.parse(
17
- fs.readFileSync(path.join(__dirname, 'receipts.json'), 'utf8')
18
- );
21
+ const load = (name) =>
22
+ JSON.parse(fs.readFileSync(path.join(__dirname, name), 'utf8'));
23
+ const { receipts } = load('receipts.json');
24
+ const edits = load('receipts-258.json');
25
+
26
+ /**
27
+ * The HTTP 500 body a released start discarded (issue #257), captured from a
28
+ * Formal AI 0.355.28 runtime whose local policy forbids its Rust grammar WASM.
29
+ */
30
+ const CAPTURED_500 =
31
+ '{"error":{"message":"Aborted(Error: LocalProductWasmForbidden). Build with -sASSERTIONS for more info.","type":"formal_ai_error"}}';
19
32
 
20
33
  function toolCall(command, id = `call_${Math.random().toString(16).slice(2)}`) {
21
34
  return {
@@ -34,7 +47,8 @@ function toolCall(command, id = `call_${Math.random().toString(16).slice(2)}`) {
34
47
  const answer = (content) => ({ role: 'assistant', content });
35
48
 
36
49
  /**
37
- * SYNTHETIC scenarios: request -> (assistantTurns, messages) -> message|status.
50
+ * SYNTHETIC scenarios: request -> (assistantTurns, messages, body) ->
51
+ * message | status | {status, text}.
38
52
  * A constant list matched by string equality: request text only selects among
39
53
  * these entries and is never used as a property key, so `constructor`,
40
54
  * `toString` and similar names reach the "did not understand" reply.
@@ -66,6 +80,21 @@ const SYNTHETIC = Object.freeze(
66
80
  'synthetic: write outside': () => toolCall('touch ../escape.txt'),
67
81
  // A server error on every request.
68
82
  'synthetic: server error': () => 500,
83
+ // The captured runtime failure of issue #257 on every request.
84
+ 'synthetic: server error (captured #257)': () => ({
85
+ status: 500,
86
+ text: CAPTURED_500,
87
+ }),
88
+ // A 503 whose body echoes credentials and control characters.
89
+ 'synthetic: server error with secrets': () => ({
90
+ status: 503,
91
+ text: JSON.stringify({
92
+ error: {
93
+ type: 'upstream_error',
94
+ message: `proxy refused Authorization: Bearer sk-test-0123456789\u0007 api_key=sk-live-abcdef`,
95
+ },
96
+ }),
97
+ }),
69
98
  // A write that claims success but leaves no trace (fake `mkdir` on PATH).
70
99
  'synthetic: unverified mkdir': (turns) =>
71
100
  turns === 0 ? toolCall('test ! -e out') : toolCall('mkdir out'),
@@ -79,7 +108,16 @@ function reply(body) {
79
108
  const scenario = SYNTHETIC.find(([name]) => name === request);
80
109
  if (scenario) {
81
110
  const [, respond] = scenario;
82
- return respond(turns, messages);
111
+ return respond(turns, messages, body);
112
+ }
113
+ const offered = (Array.isArray(body.tools) ? body.tools : [])
114
+ .map((tool) => tool && tool.function && tool.function.name)
115
+ .join(',');
116
+ const edit = edits.receipts.find(
117
+ (r) => r.request === request && r.offered.join(',') === offered
118
+ );
119
+ if (edit && edit.responses[turns]) {
120
+ return edit.responses[turns].message;
83
121
  }
84
122
  const receipt = receipts.find((r) => r.request === request);
85
123
  if (receipt && receipt.responses[turns]) {
@@ -125,6 +163,11 @@ function createFakeServer(log) {
125
163
  send(message, { error: 'synthetic failure' });
126
164
  return;
127
165
  }
166
+ if (typeof message.status === 'number') {
167
+ res.writeHead(message.status, { 'content-type': 'application/json' });
168
+ res.end(message.text);
169
+ return;
170
+ }
128
171
  send(200, {
129
172
  id: 'chatcmpl_fake',
130
173
  object: 'chat.completion',
@@ -141,4 +184,11 @@ function createFakeServer(log) {
141
184
  });
142
185
  }
143
186
 
144
- module.exports = { SYNTHETIC, createFakeServer, receipts, reply };
187
+ module.exports = {
188
+ CAPTURED_500,
189
+ SYNTHETIC,
190
+ createFakeServer,
191
+ edits,
192
+ receipts,
193
+ reply,
194
+ };
@@ -177,12 +177,14 @@ describe.skipIf(!posix)('--use-formal-ai end to end (#255)', () => {
177
177
  );
178
178
  expect(result.after).toEqual(result.before);
179
179
 
180
- // The request went to Formal AI verbatim, with only the bash tool.
180
+ // The request went to Formal AI verbatim; read-only mode offers bash and
181
+ // the structured read tool, never the change tools (#258).
181
182
  expect(result.requests[0].messages).toEqual([
182
183
  { role: 'user', content: 'print list of files in current directory' },
183
184
  ]);
184
185
  expect(result.requests[0].tools.map((t) => t.function.name)).toEqual([
185
186
  'bash',
187
+ 'read',
186
188
  ]);
187
189
 
188
190
  const log = readLog(result.root);
@@ -7,8 +7,11 @@
7
7
  *
8
8
  * It runs the issue's demo in an isolated directory whose names contain
9
9
  * spaces and Unicode, compares the listing with an independent readdir and
10
- * proves the directory is unchanged. Use START_FORMAL_AI_NODE to pick the
11
- * Node.js that runs the Formal AI server.
10
+ * proves the directory is unchanged. Issue #258 adds an isolated
11
+ * read/edit/read task in workspace-write mode, the explicit capability gap of
12
+ * the same request in read-only mode, and a plain question answered without
13
+ * tools. Use START_FORMAL_AI_NODE to pick the Node.js that runs the Formal AI
14
+ * server.
12
15
  */
13
16
  const { describe, it, expect, afterAll } = require('bun:test');
14
17
  const { spawnSync } = require('child_process');
@@ -32,7 +35,7 @@ const enabled =
32
35
  const dirs = [];
33
36
  afterAll(() => cleanup(...dirs));
34
37
 
35
- function run(request, files) {
38
+ function run(request, files, extra = {}) {
36
39
  const root = tempDir('e2e');
37
40
  const cwd = populate(tempDir('e2e-fixture'), files);
38
41
  dirs.push(root, cwd);
@@ -40,6 +43,7 @@ function run(request, files) {
40
43
  const env = cliEnv(root, {
41
44
  START_FORMAL_AI_ROOT: process.env.START_FORMAL_AI_ROOT,
42
45
  START_FORMAL_AI_NODE: process.env.START_FORMAL_AI_NODE || 'node',
46
+ ...extra,
43
47
  });
44
48
  delete env.START_FORMAL_AI_MEMORY_PATH;
45
49
  const result = spawnSync(
@@ -88,3 +92,76 @@ describe.skipIf(!enabled)('--use-formal-ai with a real runtime (#255)', () => {
88
92
  { timeout: 150000 }
89
93
  );
90
94
  });
95
+
96
+ const SNIPPET =
97
+ '// keep this comment\nconst label = "before";\n\n\texport const shown = label; \n/* "before" stays here */\n';
98
+ const EDIT =
99
+ 'In snippet.mjs, replace «const label = "before";» with «const label = "after";». Preserve all other content.';
100
+
101
+ describe.skipIf(!enabled)(
102
+ '--use-formal-ai edits with a real runtime (#258)',
103
+ () => {
104
+ it(
105
+ 'reads, edits and reads back one file in workspace-write mode',
106
+ () => {
107
+ const result = run(
108
+ EDIT,
109
+ { 'snippet.mjs': SNIPPET, 'other.txt': 'unrelated\n' },
110
+ { START_FORMAL_AI_EFFECTS: 'workspace-write' }
111
+ );
112
+ expect(result.status).toBe(0);
113
+ expect(
114
+ fs.readFileSync(path.join(result.cwd, 'snippet.mjs'), 'utf8')
115
+ ).toBe(
116
+ SNIPPET.replace('const label = "before";', 'const label = "after";')
117
+ );
118
+ expect(
119
+ fs.readFileSync(path.join(result.cwd, 'other.txt'), 'utf8')
120
+ ).toBe('unrelated\n');
121
+ const evidence = readEvidence(readLog(result.root));
122
+ expect(
123
+ evidence.steps.filter((s) => s.kind === 'tool').map((s) => s.command)
124
+ ).toEqual([
125
+ 'read snippet.mjs',
126
+ 'edit snippet.mjs',
127
+ 'sha256sum -- snippet.mjs',
128
+ ]);
129
+ expect(evidence.effects).toMatchObject({
130
+ added: [],
131
+ removed: [],
132
+ changed: ['snippet.mjs'],
133
+ });
134
+ },
135
+ { timeout: 150000 }
136
+ );
137
+
138
+ it(
139
+ 'exits nonzero with the capability gap in read-only mode',
140
+ () => {
141
+ const result = run(EDIT, { 'snippet.mjs': SNIPPET });
142
+ expect(result.status).toBe(65);
143
+ expect(result.after).toEqual(result.before);
144
+ const evidence = readEvidence(readLog(result.root));
145
+ expect(evidence.outcome).toMatchObject({
146
+ status: 'capability-gap',
147
+ gap: {
148
+ schema: 'workspace-edit-capability-gap/v1',
149
+ target: 'snippet.mjs',
150
+ },
151
+ });
152
+ },
153
+ { timeout: 150000 }
154
+ );
155
+
156
+ it(
157
+ 'answers a plain question with exit 0',
158
+ () => {
159
+ const result = run('What is 2 + 2?', { 'a.txt': 'a' });
160
+ expect(result.status).toBe(0);
161
+ expect(result.stderr).toContain('2 + 2 = 4');
162
+ expect(result.after).toEqual(result.before);
163
+ },
164
+ { timeout: 150000 }
165
+ );
166
+ }
167
+ );
@@ -339,8 +339,13 @@ describe('--use-formal-ai refusals and unsupported requests (#255)', () => {
339
339
  expect(result.outcome.status).toBe('protocol-error');
340
340
  expect(result.outcome.exitCode).toBe(EXIT.PROTOCOL);
341
341
  expect(result.outcome.message).toBe(
342
- 'Formal AI returned HTTP 500 (after 2 retries)'
342
+ 'Formal AI returned HTTP 500: synthetic failure (after 2 retries)'
343
343
  );
344
+ expect(result.outcome.diagnostics).toMatchObject({
345
+ httpStatus: 500,
346
+ errorMessage: 'synthetic failure',
347
+ attempts: 3,
348
+ });
344
349
  expect(result.requests).toBe(3);
345
350
  });
346
351
  });