start-command 0.41.0 → 0.43.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 +23 -0
- package/package.json +3 -3
- package/src/lib/formal-ai-client.js +249 -22
- package/src/lib/formal-ai-files.js +797 -0
- package/src/lib/formal-ai-policy.js +10 -3
- package/src/lib/formal-ai.js +328 -12
- package/src/lib/log-sanitizer.js +4 -0
- package/src/lib/usage.js +3 -1
- package/test/fixtures/formal-ai/receipts-258.json +449 -0
- package/test/fixtures/formal-ai/receipts-261.json +589 -0
- package/test/fixtures/formal-ai/replay.cjs +60 -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 +452 -0
- package/test/regression-258.js +456 -0
- package/test/regression-261.js +856 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #261: bounded typed source edits in `--use-formal-ai`, offline.
|
|
3
|
+
*
|
|
4
|
+
* A change is bound to a complete fresh read of the exact target and its
|
|
5
|
+
* preimage, applies only the owned replacement, keeps the matched mutation
|
|
6
|
+
* receipt and is verified byte for byte on readback. Failed, stale, ambiguous
|
|
7
|
+
* or missing evidence stays unresolved with its original failure. The
|
|
8
|
+
* held-out requests replay the receipts of a real Formal AI runtime
|
|
9
|
+
* (test/fixtures/formal-ai/receipts-261.json, written by
|
|
10
|
+
* experiments/issue-261/capture-receipts.cjs).
|
|
11
|
+
*/
|
|
12
|
+
const { describe, it, expect, beforeAll, afterAll } = require('bun:test');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { EXIT, resolveBounds } = require('../src/lib/formal-ai-policy');
|
|
18
|
+
const {
|
|
19
|
+
createClient,
|
|
20
|
+
offeredTools,
|
|
21
|
+
recognizeCapabilityGap,
|
|
22
|
+
} = require('../src/lib/formal-ai-client');
|
|
23
|
+
const files = require('../src/lib/formal-ai-files');
|
|
24
|
+
const { runFormalAiTask } = require('../src/lib/formal-ai');
|
|
25
|
+
const {
|
|
26
|
+
createFakeServer,
|
|
27
|
+
heldOut,
|
|
28
|
+
} = require('./fixtures/formal-ai/replay.cjs');
|
|
29
|
+
const {
|
|
30
|
+
CLI,
|
|
31
|
+
cleanup,
|
|
32
|
+
cliEnv,
|
|
33
|
+
fakeCheckout,
|
|
34
|
+
populate,
|
|
35
|
+
readEvidence,
|
|
36
|
+
readLog,
|
|
37
|
+
sentRequests,
|
|
38
|
+
sink,
|
|
39
|
+
snapshot,
|
|
40
|
+
tempDir,
|
|
41
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
42
|
+
|
|
43
|
+
const posix = process.platform !== 'win32';
|
|
44
|
+
const root = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
45
|
+
const dirs = [];
|
|
46
|
+
const hash = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
47
|
+
const digest = (text) => ({
|
|
48
|
+
sha256: hash(Buffer.from(text, 'utf8')),
|
|
49
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** The issue's synthetic reproduction. */
|
|
53
|
+
const REPRO = { 'draft.txt': 'draft\n', 'verified-source.txt': 'verified\n' };
|
|
54
|
+
const REQUEST = 'In draft.txt, replace «draft» with «verified».';
|
|
55
|
+
|
|
56
|
+
let server;
|
|
57
|
+
let baseUrl;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
server = createFakeServer();
|
|
61
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
62
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(() => {
|
|
66
|
+
server.close();
|
|
67
|
+
cleanup(...dirs);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function workspace(extra = REPRO) {
|
|
71
|
+
const dir = populate(tempDir('261'), extra);
|
|
72
|
+
dirs.push(dir);
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function context(cwd, effects = 'workspace-write') {
|
|
77
|
+
return {
|
|
78
|
+
cwd,
|
|
79
|
+
effects,
|
|
80
|
+
observed: new Set(),
|
|
81
|
+
observedDirs: new Set(['.']),
|
|
82
|
+
contents: new Map(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const limits = { maxFileBytes: 1024 * 1024 };
|
|
87
|
+
|
|
88
|
+
/** Normalize, decide and run one call like the loop does. */
|
|
89
|
+
function call(tool, args, ctx) {
|
|
90
|
+
const normalized = files.normalizeFileArgs(tool, args);
|
|
91
|
+
if (normalized.problem) {
|
|
92
|
+
return { problem: normalized.problem };
|
|
93
|
+
}
|
|
94
|
+
const decision = files.evaluateFileCall(tool, normalized.args, ctx);
|
|
95
|
+
if (!decision.allowed) {
|
|
96
|
+
return { refused: decision.reason };
|
|
97
|
+
}
|
|
98
|
+
return files.runFileCall(tool, normalized.args, decision, ctx, limits);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const read = (cwd, name) => fs.readFileSync(path.join(cwd, name), 'utf8');
|
|
102
|
+
const edit = (ctx, file, oldString, newString, extra = {}) =>
|
|
103
|
+
call(
|
|
104
|
+
'edit',
|
|
105
|
+
{ path: file, old_string: oldString, new_string: newString, ...extra },
|
|
106
|
+
ctx
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
describe('read frame (#261)', () => {
|
|
110
|
+
it('frames the complete text so Formal AI decodes it exactly', () => {
|
|
111
|
+
for (const text of [
|
|
112
|
+
'draft\n',
|
|
113
|
+
'',
|
|
114
|
+
'no final newline',
|
|
115
|
+
'a\r\nb\r\n',
|
|
116
|
+
'Ünïcödé 🙂\n\n\n',
|
|
117
|
+
'x\n\n(End of file - total 3 lines)\n</file>',
|
|
118
|
+
'00001| looks numbered\n',
|
|
119
|
+
]) {
|
|
120
|
+
const frame = files.readFrame(text);
|
|
121
|
+
expect(frame.startsWith('<file>\n')).toBe(true);
|
|
122
|
+
expect(frame.endsWith('</file>')).toBe(true);
|
|
123
|
+
expect(files.readFrameText(frame)).toBe(text);
|
|
124
|
+
}
|
|
125
|
+
expect(files.readFrame('draft\n')).toBe(
|
|
126
|
+
'<file>\n00001| draft\n00002| \n\n(End of file - total 2 lines)\n</file>'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('decodes nothing from a partial or altered frame', () => {
|
|
131
|
+
const frame = files.readFrame('a\nb\n');
|
|
132
|
+
for (const altered of [
|
|
133
|
+
'a\nb\n',
|
|
134
|
+
frame.replace('total 3 lines', 'total 2 lines'),
|
|
135
|
+
frame.replace('00002| b', '00003| b'),
|
|
136
|
+
frame.replace('00002| b', '00002|b'),
|
|
137
|
+
frame.slice(0, -1),
|
|
138
|
+
frame.replace('<file>\n', ''),
|
|
139
|
+
]) {
|
|
140
|
+
expect(files.readFrameText(altered)).toBeNull();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('bounded typed edits (#261)', () => {
|
|
146
|
+
it('binds the read, applies only the owned change and keeps the receipt', () => {
|
|
147
|
+
const cwd = workspace();
|
|
148
|
+
const ctx = context(cwd);
|
|
149
|
+
const seen = call('read', { path: 'draft.txt' }, ctx);
|
|
150
|
+
expect(seen).toMatchObject({
|
|
151
|
+
exitCode: 0,
|
|
152
|
+
text: 'draft\n',
|
|
153
|
+
result: files.readFrame('draft\n'),
|
|
154
|
+
});
|
|
155
|
+
const receipt = edit(ctx, 'draft.txt', 'draft', 'verified');
|
|
156
|
+
expect(receipt).toMatchObject({
|
|
157
|
+
exitCode: 0,
|
|
158
|
+
replacements: 1,
|
|
159
|
+
previousSha256: seen.sha256,
|
|
160
|
+
sha256: hash('verified\n'),
|
|
161
|
+
verification: {
|
|
162
|
+
verified: true,
|
|
163
|
+
detail:
|
|
164
|
+
'readback confirmed edit draft.txt (9 bytes; 1 replacement(s); every other byte unchanged)',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(receipt.mutation).toEqual({
|
|
168
|
+
tool: 'edit',
|
|
169
|
+
target: 'draft.txt',
|
|
170
|
+
preimage: digest('draft\n'),
|
|
171
|
+
postimage: digest('verified\n'),
|
|
172
|
+
matches: [{ offset: 0, oldBytes: 5, newBytes: 8 }],
|
|
173
|
+
readback: digest('verified\n'),
|
|
174
|
+
});
|
|
175
|
+
expect(JSON.parse(receipt.result)).toEqual({
|
|
176
|
+
ok: true,
|
|
177
|
+
path: 'draft.txt',
|
|
178
|
+
tool: 'edit',
|
|
179
|
+
preimage: digest('draft\n'),
|
|
180
|
+
postimage: digest('verified\n'),
|
|
181
|
+
replacements: 1,
|
|
182
|
+
verified: true,
|
|
183
|
+
});
|
|
184
|
+
expect(read(cwd, 'draft.txt')).toBe('verified\n');
|
|
185
|
+
expect(read(cwd, 'verified-source.txt')).toBe('verified\n');
|
|
186
|
+
// The verified postimage is the new binding: a second edit may follow.
|
|
187
|
+
expect(ctx.contents.get('draft.txt')).toBe(hash('verified\n'));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('fails a mismatched OLD and changes nothing', () => {
|
|
191
|
+
const cwd = workspace();
|
|
192
|
+
const ctx = context(cwd);
|
|
193
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
194
|
+
expect(edit(ctx, 'draft.txt', 'drafted', 'verified')).toMatchObject({
|
|
195
|
+
exitCode: 1,
|
|
196
|
+
error: 'draft.txt: old_string was not found; nothing changed',
|
|
197
|
+
});
|
|
198
|
+
expect(edit(ctx, 'draft.txt', 'Draft', 'verified').exitCode).toBe(1);
|
|
199
|
+
expect(read(cwd, 'draft.txt')).toBe('draft\n');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('fails repeated occurrences unless replace_all is set', () => {
|
|
203
|
+
const text = 'draft one\ndraft two\n';
|
|
204
|
+
const cwd = workspace({ 'twice.txt': text });
|
|
205
|
+
const ctx = context(cwd);
|
|
206
|
+
call('read', { path: 'twice.txt' }, ctx);
|
|
207
|
+
expect(edit(ctx, 'twice.txt', 'draft', 'verified').error).toBe(
|
|
208
|
+
'twice.txt: old_string occurs 2 times; give more surrounding text or set replace_all; nothing changed'
|
|
209
|
+
);
|
|
210
|
+
expect(read(cwd, 'twice.txt')).toBe(text);
|
|
211
|
+
const receipt = edit(ctx, 'twice.txt', 'draft', 'verified', {
|
|
212
|
+
replace_all: true,
|
|
213
|
+
});
|
|
214
|
+
expect(receipt).toMatchObject({ exitCode: 0, replacements: 2 });
|
|
215
|
+
expect(receipt.mutation.matches).toEqual([
|
|
216
|
+
{ offset: 0, oldBytes: 5, newBytes: 8 },
|
|
217
|
+
{ offset: 10, oldBytes: 5, newBytes: 8 },
|
|
218
|
+
]);
|
|
219
|
+
expect(read(cwd, 'twice.txt')).toBe('verified one\nverified two\n');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('preserves Unicode, CRLF, a BOM and a missing final newline', () => {
|
|
223
|
+
const text = 'Ünïcödé café ☕\r\nsecond line 🙂\r\nno final newline';
|
|
224
|
+
const cwd = workspace({ 'unicode.txt': text });
|
|
225
|
+
const ctx = context(cwd);
|
|
226
|
+
call('read', { path: 'unicode.txt' }, ctx);
|
|
227
|
+
const receipt = edit(ctx, 'unicode.txt', 'café', 'naïve');
|
|
228
|
+
expect(receipt.verification.verified).toBe(true);
|
|
229
|
+
const before = Buffer.from(text, 'utf8');
|
|
230
|
+
expect(receipt.mutation.matches).toEqual([
|
|
231
|
+
{
|
|
232
|
+
offset: before.indexOf(Buffer.from('café', 'utf8')),
|
|
233
|
+
oldBytes: 5,
|
|
234
|
+
newBytes: 6,
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
expect(fs.readFileSync(path.join(cwd, 'unicode.txt'))).toEqual(
|
|
238
|
+
Buffer.from(text.replace('café', 'naïve'), 'utf8')
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('refuses text that is not valid Unicode', () => {
|
|
243
|
+
const cwd = workspace();
|
|
244
|
+
const ctx = context(cwd);
|
|
245
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
246
|
+
expect(edit(ctx, 'draft.txt', 'draft', 'bad \ud800').error).toBe(
|
|
247
|
+
'draft.txt: old_string or new_string is not valid Unicode text; nothing changed'
|
|
248
|
+
);
|
|
249
|
+
expect(
|
|
250
|
+
call('write', { path: 'draft.txt', content: '\udfff' }, ctx).error
|
|
251
|
+
).toBe('draft.txt: content is not valid Unicode text; nothing changed');
|
|
252
|
+
expect(read(cwd, 'draft.txt')).toBe('draft\n');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('needs a complete read with the read tool, not cat or a listing', () => {
|
|
256
|
+
const cwd = workspace();
|
|
257
|
+
const ctx = context(cwd);
|
|
258
|
+
// What `cat draft.txt` or `ls` records: observed, but no exact preimage.
|
|
259
|
+
ctx.observed.add('draft.txt');
|
|
260
|
+
const message =
|
|
261
|
+
'draft.txt: was not read completely with the read tool in this task; read it before changing it; nothing changed';
|
|
262
|
+
expect(edit(ctx, 'draft.txt', 'draft', 'verified').error).toBe(message);
|
|
263
|
+
expect(
|
|
264
|
+
call('write', { path: 'draft.txt', content: 'verified\n' }, ctx).error
|
|
265
|
+
).toBe(message);
|
|
266
|
+
expect(read(cwd, 'draft.txt')).toBe('draft\n');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('fails a stale read and works again after a fresh one', () => {
|
|
270
|
+
const cwd = workspace();
|
|
271
|
+
const ctx = context(cwd);
|
|
272
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
273
|
+
fs.writeFileSync(path.join(cwd, 'draft.txt'), 'draft draft\n');
|
|
274
|
+
expect(edit(ctx, 'draft.txt', 'draft draft', 'verified').error).toBe(
|
|
275
|
+
'draft.txt: changed since start read it; read it again before changing it; nothing changed'
|
|
276
|
+
);
|
|
277
|
+
expect(read(cwd, 'draft.txt')).toBe('draft draft\n');
|
|
278
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
279
|
+
expect(edit(ctx, 'draft.txt', 'draft draft', 'verified').exitCode).toBe(0);
|
|
280
|
+
expect(read(cwd, 'draft.txt')).toBe('verified\n');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('never overwrites a change made while it was writing', () => {
|
|
284
|
+
const cwd = workspace();
|
|
285
|
+
const ctx = context(cwd);
|
|
286
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
287
|
+
ctx.beforeReplace = (file) => fs.writeFileSync(file, 'theirs\n');
|
|
288
|
+
const receipt = edit(ctx, 'draft.txt', 'draft', 'verified');
|
|
289
|
+
expect(receipt).toMatchObject({
|
|
290
|
+
exitCode: 1,
|
|
291
|
+
error:
|
|
292
|
+
'draft.txt: changed while start was changing it; read it again; nothing changed',
|
|
293
|
+
});
|
|
294
|
+
// The failed mutation keeps its receipt; the stale binding is dropped.
|
|
295
|
+
expect(receipt.mutation).toMatchObject({
|
|
296
|
+
tool: 'edit',
|
|
297
|
+
preimage: digest('draft\n'),
|
|
298
|
+
postimage: digest('verified\n'),
|
|
299
|
+
});
|
|
300
|
+
expect(read(cwd, 'draft.txt')).toBe('theirs\n');
|
|
301
|
+
expect(ctx.contents.has('draft.txt')).toBe(false);
|
|
302
|
+
expect(fs.readdirSync(cwd).sort()).toEqual([
|
|
303
|
+
'draft.txt',
|
|
304
|
+
'verified-source.txt',
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('never replaces a file created while it was writing a new one', () => {
|
|
309
|
+
const cwd = workspace();
|
|
310
|
+
const ctx = context(cwd);
|
|
311
|
+
ctx.beforeReplace = (file) => fs.writeFileSync(file, 'theirs\n');
|
|
312
|
+
expect(
|
|
313
|
+
call('write', { path: 'new.txt', content: 'mine\n' }, ctx).error
|
|
314
|
+
).toBe('new.txt: was created while start was writing it; nothing changed');
|
|
315
|
+
expect(read(cwd, 'new.txt')).toBe('theirs\n');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reports a readback that differs from the intended postimage', () => {
|
|
319
|
+
const cwd = workspace();
|
|
320
|
+
const ctx = context(cwd);
|
|
321
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
322
|
+
ctx.afterReplace = (file) => fs.writeFileSync(file, 'verified!\n');
|
|
323
|
+
const receipt = edit(ctx, 'draft.txt', 'draft', 'verified');
|
|
324
|
+
expect(receipt.verification.verified).toBe(false);
|
|
325
|
+
expect(receipt.verification.detail).toContain(
|
|
326
|
+
'readback of draft.txt differs from the intended text'
|
|
327
|
+
);
|
|
328
|
+
expect(JSON.parse(receipt.result)).toMatchObject({
|
|
329
|
+
ok: false,
|
|
330
|
+
verified: false,
|
|
331
|
+
});
|
|
332
|
+
expect(receipt.mutation.readback).toEqual(digest('verified!\n'));
|
|
333
|
+
expect(ctx.contents.has('draft.txt')).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it.skipIf(!posix || root)(
|
|
337
|
+
'keeps the receipt of a mutation that could not be written',
|
|
338
|
+
() => {
|
|
339
|
+
const cwd = workspace();
|
|
340
|
+
const ctx = context(cwd);
|
|
341
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
342
|
+
fs.chmodSync(cwd, 0o555);
|
|
343
|
+
try {
|
|
344
|
+
const receipt = edit(ctx, 'draft.txt', 'draft', 'verified');
|
|
345
|
+
expect(receipt.exitCode).toBe(1);
|
|
346
|
+
expect(receipt.error).toMatch(
|
|
347
|
+
/^draft\.txt: failed \(EACCES\); nothing changed$/
|
|
348
|
+
);
|
|
349
|
+
expect(receipt.mutation).toMatchObject({
|
|
350
|
+
preimage: digest('draft\n'),
|
|
351
|
+
postimage: digest('verified\n'),
|
|
352
|
+
});
|
|
353
|
+
} finally {
|
|
354
|
+
fs.chmodSync(cwd, 0o755);
|
|
355
|
+
}
|
|
356
|
+
expect(read(cwd, 'draft.txt')).toBe('draft\n');
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('foreign and symlinked paths (#261)', () => {
|
|
362
|
+
it.skipIf(!posix)('edits the real file behind an in-workspace link', () => {
|
|
363
|
+
const cwd = workspace({ 'real.txt': 'draft\n' });
|
|
364
|
+
fs.symlinkSync('real.txt', path.join(cwd, 'link.txt'));
|
|
365
|
+
const ctx = context(cwd);
|
|
366
|
+
expect(call('read', { path: 'link.txt' }, ctx).text).toBe('draft\n');
|
|
367
|
+
const receipt = edit(ctx, 'link.txt', 'draft', 'verified');
|
|
368
|
+
expect(receipt).toMatchObject({
|
|
369
|
+
exitCode: 0,
|
|
370
|
+
mutation: { target: 'real.txt' },
|
|
371
|
+
});
|
|
372
|
+
expect(fs.lstatSync(path.join(cwd, 'link.txt')).isSymbolicLink()).toBe(
|
|
373
|
+
true
|
|
374
|
+
);
|
|
375
|
+
expect(read(cwd, 'real.txt')).toBe('verified\n');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it.skipIf(!posix)('refuses links and paths leaving the workspace', () => {
|
|
379
|
+
const outside = tempDir('261-outside');
|
|
380
|
+
dirs.push(outside);
|
|
381
|
+
fs.writeFileSync(path.join(outside, 'outside.txt'), 'draft\n');
|
|
382
|
+
const cwd = workspace();
|
|
383
|
+
fs.symlinkSync(
|
|
384
|
+
path.join(outside, 'outside.txt'),
|
|
385
|
+
path.join(cwd, 'escape.txt')
|
|
386
|
+
);
|
|
387
|
+
const ctx = context(cwd);
|
|
388
|
+
for (const target of [
|
|
389
|
+
'escape.txt',
|
|
390
|
+
path.join(outside, 'outside.txt'),
|
|
391
|
+
'../outside.txt',
|
|
392
|
+
]) {
|
|
393
|
+
expect(call('read', { path: target }, ctx).refused).toContain(
|
|
394
|
+
'outside the working directory'
|
|
395
|
+
);
|
|
396
|
+
expect(edit(ctx, target, 'draft', 'verified').refused).toBeString();
|
|
397
|
+
}
|
|
398
|
+
expect(read(outside, 'outside.txt')).toBe('draft\n');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it.skipIf(!posix)('never writes through a dangling link', () => {
|
|
402
|
+
const cwd = workspace();
|
|
403
|
+
fs.symlinkSync('missing.txt', path.join(cwd, 'dangling.txt'));
|
|
404
|
+
const ctx = context(cwd);
|
|
405
|
+
expect(
|
|
406
|
+
call('write', { path: 'dangling.txt', content: 'x\n' }, ctx).error
|
|
407
|
+
).toBe(
|
|
408
|
+
'dangling.txt: is a symbolic link start cannot resolve inside the working directory; nothing changed'
|
|
409
|
+
);
|
|
410
|
+
expect(fs.existsSync(path.join(cwd, 'missing.txt'))).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it.skipIf(!posix)('fails when the target becomes a link mid-change', () => {
|
|
414
|
+
const outside = tempDir('261-swap');
|
|
415
|
+
dirs.push(outside);
|
|
416
|
+
fs.writeFileSync(path.join(outside, 'outside.txt'), 'draft\n');
|
|
417
|
+
const cwd = workspace();
|
|
418
|
+
const ctx = context(cwd);
|
|
419
|
+
call('read', { path: 'draft.txt' }, ctx);
|
|
420
|
+
ctx.beforeReplace = (file) => {
|
|
421
|
+
fs.rmSync(file);
|
|
422
|
+
fs.symlinkSync(path.join(outside, 'outside.txt'), file);
|
|
423
|
+
};
|
|
424
|
+
expect(edit(ctx, 'draft.txt', 'draft', 'verified').error).toContain(
|
|
425
|
+
'changed while start was changing it'
|
|
426
|
+
);
|
|
427
|
+
expect(read(outside, 'outside.txt')).toBe('draft\n');
|
|
428
|
+
expect(fs.lstatSync(path.join(cwd, 'draft.txt')).isSymbolicLink()).toBe(
|
|
429
|
+
true
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('independent postimage verification (#261)', () => {
|
|
435
|
+
const pre = Buffer.from('aXbXc', 'utf8');
|
|
436
|
+
const oldBytes = Buffer.from('X', 'utf8');
|
|
437
|
+
const newBytes = Buffer.from('YY', 'utf8');
|
|
438
|
+
const matches = [
|
|
439
|
+
{ offset: 1, oldBytes: 1, newBytes: 2 },
|
|
440
|
+
{ offset: 3, oldBytes: 1, newBytes: 2 },
|
|
441
|
+
];
|
|
442
|
+
const verify = (post, m = matches, o = oldBytes) =>
|
|
443
|
+
files.verifyMutation(pre, Buffer.from(post, 'utf8'), m, o, newBytes);
|
|
444
|
+
|
|
445
|
+
it('accepts exactly the owned replacements', () => {
|
|
446
|
+
expect(verify('aYYbYYc')).toEqual({
|
|
447
|
+
verified: true,
|
|
448
|
+
detail: '2 replacement(s); every other byte unchanged',
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('rejects any other byte change, length or receipt mismatch', () => {
|
|
453
|
+
for (const post of ['aYYbYYd', 'aYYbXc', 'aYYbYYcc', 'YYbYYc', 'aYYBYYc']) {
|
|
454
|
+
expect(verify(post).verified).toBe(false);
|
|
455
|
+
}
|
|
456
|
+
// A receipt whose matched region does not hold OLD in the preimage.
|
|
457
|
+
expect(
|
|
458
|
+
verify('aYYbYYc', [
|
|
459
|
+
{ offset: 0, oldBytes: 1, newBytes: 2 },
|
|
460
|
+
{ offset: 3, oldBytes: 1, newBytes: 2 },
|
|
461
|
+
]).verified
|
|
462
|
+
).toBe(false);
|
|
463
|
+
expect(verify('aYYbYYc', matches, Buffer.from('Z')).verified).toBe(false);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe('unresolved failures in the task loop (#261)', () => {
|
|
468
|
+
/** A scripted client: each turn returns the next message. */
|
|
469
|
+
function scripted(turns) {
|
|
470
|
+
let index = 0;
|
|
471
|
+
const sent = [];
|
|
472
|
+
return {
|
|
473
|
+
sent,
|
|
474
|
+
complete(messages) {
|
|
475
|
+
sent.push(JSON.parse(JSON.stringify(messages)));
|
|
476
|
+
const message = turns[index++];
|
|
477
|
+
return Promise.resolve({
|
|
478
|
+
content: message.content || '',
|
|
479
|
+
finishReason: message.toolCalls ? 'tool_calls' : 'stop',
|
|
480
|
+
handler: null,
|
|
481
|
+
toolCalls: message.toolCalls || [],
|
|
482
|
+
raw: {},
|
|
483
|
+
});
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
let ids = 0;
|
|
488
|
+
const typed = (tool, args) => ({
|
|
489
|
+
id: `c_${++ids}`,
|
|
490
|
+
name: tool,
|
|
491
|
+
tool,
|
|
492
|
+
args: files.normalizeFileArgs(tool, args).args,
|
|
493
|
+
arguments: JSON.stringify(args),
|
|
494
|
+
command: files.describeFileCall(
|
|
495
|
+
tool,
|
|
496
|
+
files.normalizeFileArgs(tool, args).args
|
|
497
|
+
),
|
|
498
|
+
});
|
|
499
|
+
const bash = (command) => ({
|
|
500
|
+
id: `c_${++ids}`,
|
|
501
|
+
name: 'bash',
|
|
502
|
+
tool: 'bash',
|
|
503
|
+
command,
|
|
504
|
+
});
|
|
505
|
+
const readDraft = { toolCalls: [typed('read', { path: 'draft.txt' })] };
|
|
506
|
+
const editDraft = (oldString = 'draft') => ({
|
|
507
|
+
toolCalls: [
|
|
508
|
+
typed('edit', {
|
|
509
|
+
path: 'draft.txt',
|
|
510
|
+
old_string: oldString,
|
|
511
|
+
new_string: 'verified',
|
|
512
|
+
}),
|
|
513
|
+
],
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
async function loop(turns, fixture = REPRO) {
|
|
517
|
+
const cwd = workspace(fixture);
|
|
518
|
+
const client = scripted(turns);
|
|
519
|
+
const evidence = await runFormalAiTask({
|
|
520
|
+
request: REQUEST,
|
|
521
|
+
cwd,
|
|
522
|
+
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
|
|
523
|
+
bounds: { ...resolveBounds({}), effects: 'workspace-write' },
|
|
524
|
+
client,
|
|
525
|
+
io: { stdout: sink(), stderr: sink(), note: () => {} },
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
cwd,
|
|
529
|
+
evidence,
|
|
530
|
+
outcome: evidence.outcome,
|
|
531
|
+
sent: client.sent,
|
|
532
|
+
draft: read(cwd, 'draft.txt'),
|
|
533
|
+
source: read(cwd, 'verified-source.txt'),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
it('completes the exact reproduction with a verified edit', async () => {
|
|
538
|
+
const result = await loop([
|
|
539
|
+
readDraft,
|
|
540
|
+
editDraft(),
|
|
541
|
+
{ toolCalls: [bash('sha256sum -- draft.txt')] },
|
|
542
|
+
{ content: 'Replaced `draft` with `verified` in `draft.txt`.' },
|
|
543
|
+
]);
|
|
544
|
+
expect(result.outcome).toEqual({
|
|
545
|
+
status: 'completed',
|
|
546
|
+
exitCode: 0,
|
|
547
|
+
message: 'finished after 3 tool call(s); changed draft.txt',
|
|
548
|
+
remainingObligations: [],
|
|
549
|
+
});
|
|
550
|
+
expect(result.draft).toBe('verified\n');
|
|
551
|
+
expect(result.source).toBe('verified\n');
|
|
552
|
+
const [readMessage, editMessage, digestMessage] = result.sent[3].filter(
|
|
553
|
+
(m) => m.role === 'tool'
|
|
554
|
+
);
|
|
555
|
+
expect(readMessage.content).toBe(files.readFrame('draft\n'));
|
|
556
|
+
expect(JSON.parse(editMessage.content)).toMatchObject({
|
|
557
|
+
ok: true,
|
|
558
|
+
verified: true,
|
|
559
|
+
postimage: digest('verified\n'),
|
|
560
|
+
});
|
|
561
|
+
expect(digestMessage.content).toContain(hash('verified\n'));
|
|
562
|
+
const step = result.evidence.steps.find((s) => s.tool === 'edit');
|
|
563
|
+
expect(step.mutation).toMatchObject({
|
|
564
|
+
preimage: digest('draft\n'),
|
|
565
|
+
postimage: digest('verified\n'),
|
|
566
|
+
readback: digest('verified\n'),
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('keeps an edit after cat unresolved', async () => {
|
|
571
|
+
const result = await loop([
|
|
572
|
+
{ toolCalls: [bash('cat draft.txt')] },
|
|
573
|
+
editDraft(),
|
|
574
|
+
{ content: 'Done.' },
|
|
575
|
+
]);
|
|
576
|
+
expect(result.outcome).toMatchObject({
|
|
577
|
+
status: 'incomplete',
|
|
578
|
+
exitCode: 1,
|
|
579
|
+
failures: [
|
|
580
|
+
{
|
|
581
|
+
command: 'edit draft.txt',
|
|
582
|
+
error:
|
|
583
|
+
'draft.txt: was not read completely with the read tool in this task; read it before changing it; nothing changed',
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
});
|
|
587
|
+
expect(result.draft).toBe('draft\n');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('keeps a mismatched OLD unresolved after a later read', async () => {
|
|
591
|
+
const result = await loop([
|
|
592
|
+
readDraft,
|
|
593
|
+
editDraft('drafted'),
|
|
594
|
+
readDraft,
|
|
595
|
+
{ content: 'Done.' },
|
|
596
|
+
]);
|
|
597
|
+
expect(result.outcome).toEqual({
|
|
598
|
+
status: 'incomplete',
|
|
599
|
+
exitCode: 1,
|
|
600
|
+
message:
|
|
601
|
+
'`edit draft.txt` failed (draft.txt: old_string was not found; nothing changed) and no later call resolved it; no file changed',
|
|
602
|
+
remainingObligations: [
|
|
603
|
+
'`edit draft.txt` failed: draft.txt: old_string was not found; nothing changed',
|
|
604
|
+
],
|
|
605
|
+
failures: [
|
|
606
|
+
{
|
|
607
|
+
command: 'edit draft.txt',
|
|
608
|
+
tool: 'edit',
|
|
609
|
+
target: 'draft.txt',
|
|
610
|
+
exitCode: 1,
|
|
611
|
+
error: 'draft.txt: old_string was not found; nothing changed',
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('resolves a failed edit only by a verified change of the target', async () => {
|
|
618
|
+
const result = await loop([
|
|
619
|
+
readDraft,
|
|
620
|
+
editDraft('drafted'),
|
|
621
|
+
editDraft('draft'),
|
|
622
|
+
{ content: 'Done.' },
|
|
623
|
+
]);
|
|
624
|
+
expect(result.outcome).toMatchObject({ status: 'completed', exitCode: 0 });
|
|
625
|
+
expect(result.draft).toBe('verified\n');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('never takes a copy as the substring edit', async () => {
|
|
629
|
+
const result = await loop([
|
|
630
|
+
readDraft,
|
|
631
|
+
editDraft('drafted'),
|
|
632
|
+
{ toolCalls: [typed('read', { path: 'verified-source.txt' })] },
|
|
633
|
+
{ toolCalls: [bash('cp verified-source.txt draft.txt')] },
|
|
634
|
+
{ content: 'Done.' },
|
|
635
|
+
]);
|
|
636
|
+
// The copy ran and its effect is reported, but the edit stays failed.
|
|
637
|
+
expect(result.draft).toBe('verified\n');
|
|
638
|
+
expect(result.outcome).toMatchObject({
|
|
639
|
+
status: 'incomplete',
|
|
640
|
+
exitCode: 1,
|
|
641
|
+
failures: [{ command: 'edit draft.txt' }],
|
|
642
|
+
});
|
|
643
|
+
expect(result.outcome.message).toContain('changed draft.txt');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('keeps a failed bash write unresolved until the same call succeeds', async () => {
|
|
647
|
+
const result = await loop([
|
|
648
|
+
readDraft,
|
|
649
|
+
{ toolCalls: [bash('mkdir draft.txt')] },
|
|
650
|
+
{ toolCalls: [bash('touch draft.txt')] },
|
|
651
|
+
{ content: 'Done.' },
|
|
652
|
+
]);
|
|
653
|
+
expect(result.outcome).toMatchObject({
|
|
654
|
+
status: 'incomplete',
|
|
655
|
+
failures: [{ command: 'mkdir draft.txt' }],
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('exits 65 when Formal AI cannot bind the preimage', async () => {
|
|
660
|
+
const gap = JSON.stringify({
|
|
661
|
+
complete: false,
|
|
662
|
+
reason: 'SourcePreimageUnavailable',
|
|
663
|
+
schema: 'workspace-change-verification-gap/v1',
|
|
664
|
+
target: 'draft.txt',
|
|
665
|
+
verified: false,
|
|
666
|
+
});
|
|
667
|
+
expect(recognizeCapabilityGap(gap, [])).toMatchObject({
|
|
668
|
+
reason: 'SourcePreimageUnavailable',
|
|
669
|
+
target: 'draft.txt',
|
|
670
|
+
});
|
|
671
|
+
const result = await loop([readDraft, editDraft(), { content: gap }]);
|
|
672
|
+
expect(result.outcome).toMatchObject({
|
|
673
|
+
status: 'capability-gap',
|
|
674
|
+
exitCode: EXIT.UNSUPPORTED,
|
|
675
|
+
message:
|
|
676
|
+
'Formal AI reported a capability gap for draft.txt (SourcePreimageUnavailable); Formal AI did not confirm the task as done; start observed: changed draft.txt',
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('exits 65 on an obligation gap in Formal AI notation', async () => {
|
|
681
|
+
const record =
|
|
682
|
+
'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"';
|
|
683
|
+
const result = await loop([readDraft, editDraft(), { content: record }]);
|
|
684
|
+
expect(result.outcome).toEqual({
|
|
685
|
+
status: 'incomplete',
|
|
686
|
+
exitCode: EXIT.UNSUPPORTED,
|
|
687
|
+
message:
|
|
688
|
+
'Formal AI reported obligation_gap (no_artifact_in_clause) for the clause "Preserve all other content."; nothing was verified as done by Formal AI; changed draft.txt',
|
|
689
|
+
remainingObligations: ['Formal AI did not confirm the requested change'],
|
|
690
|
+
gapRecord: {
|
|
691
|
+
schema: 'obligation_gap',
|
|
692
|
+
reason: 'no_artifact_in_clause',
|
|
693
|
+
clause: 'Preserve all other content.',
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
// Prose, other records and a record echoed from a file are no gap.
|
|
697
|
+
for (const content of [
|
|
698
|
+
'The edit is done.',
|
|
699
|
+
'obligation_1\n record_type "obligation_done"',
|
|
700
|
+
'obligation 1\n record_type "obligation_gap"',
|
|
701
|
+
'obligation_1\nrecord_type "obligation_gap"',
|
|
702
|
+
]) {
|
|
703
|
+
const other = await loop([readDraft, { content }]);
|
|
704
|
+
expect(other.outcome.status).toBe('completed');
|
|
705
|
+
}
|
|
706
|
+
// A record or gap the read tool returned from a file, numbered in the
|
|
707
|
+
// read frame, is file data, not Formal AI's answer.
|
|
708
|
+
const echoed = await loop([readDraft, { content: record }], {
|
|
709
|
+
...REPRO,
|
|
710
|
+
'draft.txt': `${record}\n`,
|
|
711
|
+
});
|
|
712
|
+
expect(echoed.outcome.status).toBe('completed');
|
|
713
|
+
const gap = JSON.stringify(
|
|
714
|
+
{
|
|
715
|
+
schema: 'workspace-change-verification-gap/v1',
|
|
716
|
+
target: 'draft.txt',
|
|
717
|
+
reason: 'SourcePreimageUnavailable',
|
|
718
|
+
complete: false,
|
|
719
|
+
verified: false,
|
|
720
|
+
},
|
|
721
|
+
null,
|
|
722
|
+
2
|
|
723
|
+
);
|
|
724
|
+
const echoedGap = await loop([readDraft, { content: gap }], {
|
|
725
|
+
...REPRO,
|
|
726
|
+
'draft.txt': `${gap}\n`,
|
|
727
|
+
});
|
|
728
|
+
expect(echoedGap.outcome.status).toBe('completed');
|
|
729
|
+
expect(
|
|
730
|
+
files.echoedByTool(record, [
|
|
731
|
+
{ role: 'tool', content: files.readFrame(`${record}\n`) },
|
|
732
|
+
])
|
|
733
|
+
).toBe(true);
|
|
734
|
+
expect(
|
|
735
|
+
files.echoedByTool(record, [{ role: 'user', content: record }])
|
|
736
|
+
).toBe(false);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
describe('held-out edits against captured receipts (#261)', () => {
|
|
741
|
+
/** Run one captured request through the real loop and the replay server. */
|
|
742
|
+
async function replay(receipt) {
|
|
743
|
+
const base = tempDir('261-replay');
|
|
744
|
+
dirs.push(base);
|
|
745
|
+
const cwd = path.join(base, 'ws');
|
|
746
|
+
fs.mkdirSync(cwd);
|
|
747
|
+
populate(cwd, receipt.files);
|
|
748
|
+
for (const [link, target] of Object.entries(receipt.links)) {
|
|
749
|
+
fs.symlinkSync(target, path.join(cwd, link));
|
|
750
|
+
}
|
|
751
|
+
const bounds = { ...resolveBounds({}), effects: receipt.effects };
|
|
752
|
+
const deadline = Date.now() + bounds.timeoutMs;
|
|
753
|
+
const tools = offeredTools(receipt.effects);
|
|
754
|
+
const evidence = await runFormalAiTask({
|
|
755
|
+
request: receipt.request,
|
|
756
|
+
cwd,
|
|
757
|
+
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
|
|
758
|
+
bounds,
|
|
759
|
+
deadline,
|
|
760
|
+
client: createClient({ baseUrl, bounds, deadline, tools }),
|
|
761
|
+
io: { stdout: sink(), stderr: sink(), note: () => {} },
|
|
762
|
+
});
|
|
763
|
+
return {
|
|
764
|
+
name: receipt.name,
|
|
765
|
+
outcome: evidence.outcome,
|
|
766
|
+
tools: evidence.steps
|
|
767
|
+
.filter((s) => s.kind === 'tool')
|
|
768
|
+
.map((s) => [s.command, s.decision, s.exitCode ?? null]),
|
|
769
|
+
after: Object.fromEntries(
|
|
770
|
+
Object.keys(receipt.files).map((name) => [name, read(cwd, name)])
|
|
771
|
+
),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
it('covers every held-out case of the issue', () => {
|
|
776
|
+
expect(heldOut.receipts.map((r) => r.name)).toEqual([
|
|
777
|
+
'issue reproduction',
|
|
778
|
+
'issue reproduction, read-only',
|
|
779
|
+
'mismatched OLD',
|
|
780
|
+
'repeated occurrences',
|
|
781
|
+
'Unicode and CRLF',
|
|
782
|
+
'symlinked path',
|
|
783
|
+
'symlink leaving the workspace',
|
|
784
|
+
'foreign path',
|
|
785
|
+
]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it.skipIf(!posix)(
|
|
789
|
+
'every captured request replays to its outcome and file bytes',
|
|
790
|
+
async () => {
|
|
791
|
+
for (const receipt of heldOut.receipts) {
|
|
792
|
+
expect(await replay(receipt)).toEqual({
|
|
793
|
+
name: receipt.name,
|
|
794
|
+
outcome: receipt.outcome,
|
|
795
|
+
tools: receipt.tools.map((t) => [t.command, t.decision, t.exitCode]),
|
|
796
|
+
after: receipt.after,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
it.skipIf(!posix)('the reproduction leaves the source unchanged', () => {
|
|
803
|
+
const [repro] = heldOut.receipts;
|
|
804
|
+
expect(repro.outcome).toMatchObject({ status: 'completed', exitCode: 0 });
|
|
805
|
+
expect(repro.after).toEqual({
|
|
806
|
+
'draft.txt': 'verified\n',
|
|
807
|
+
'verified-source.txt': 'verified\n',
|
|
808
|
+
});
|
|
809
|
+
expect(repro.tools.map((t) => t.command)).toEqual([
|
|
810
|
+
'read draft.txt',
|
|
811
|
+
'edit draft.txt',
|
|
812
|
+
'sha256sum -- draft.txt',
|
|
813
|
+
]);
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
describe('the reproduction through the CLI (#261)', () => {
|
|
818
|
+
const checkout = fakeCheckout({ version: '0.355.32' });
|
|
819
|
+
dirs.push(checkout);
|
|
820
|
+
|
|
821
|
+
it.skipIf(!posix)('exits 0 with the exact bytes', () => {
|
|
822
|
+
const base = tempDir('cli-261');
|
|
823
|
+
dirs.push(base);
|
|
824
|
+
const cwd = workspace();
|
|
825
|
+
const before = snapshot(cwd);
|
|
826
|
+
const result = spawnSync(
|
|
827
|
+
process.execPath,
|
|
828
|
+
[CLI, '--use-formal-ai', '--', REQUEST],
|
|
829
|
+
{
|
|
830
|
+
cwd,
|
|
831
|
+
env: cliEnv(base, {
|
|
832
|
+
START_FORMAL_AI_ROOT: checkout,
|
|
833
|
+
START_FORMAL_AI_EFFECTS: 'workspace-write',
|
|
834
|
+
}),
|
|
835
|
+
encoding: 'utf8',
|
|
836
|
+
timeout: 60000,
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
expect(result.status).toBe(0);
|
|
840
|
+
expect(fs.readFileSync(path.join(cwd, 'draft.txt'))).toEqual(
|
|
841
|
+
Buffer.from('verified\n')
|
|
842
|
+
);
|
|
843
|
+
const after = snapshot(cwd);
|
|
844
|
+
expect(after['verified-source.txt']).toBe(before['verified-source.txt']);
|
|
845
|
+
expect(result.stderr).toContain(
|
|
846
|
+
'[formal-ai] readback confirmed edit draft.txt (9 bytes; 1 replacement(s); every other byte unchanged)'
|
|
847
|
+
);
|
|
848
|
+
const requests = sentRequests(base);
|
|
849
|
+
const readResult = requests[1].messages.find((m) => m.role === 'tool');
|
|
850
|
+
expect(readResult.content).toBe(files.readFrame('draft\n'));
|
|
851
|
+
expect(readEvidence(readLog(base)).outcome).toMatchObject({
|
|
852
|
+
status: 'completed',
|
|
853
|
+
exitCode: 0,
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
});
|