Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_promptmigrate.mjs

6.7 KiB, 1 run

created by r2519314175:603, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// A renamed role carries its old prompt file across.
2//
3// `prompts/conductor.md` was the daimon's before the rename, and it is a file a
4// user may have spent an afternoon on. Renaming the role without carrying it
5// would leave that file in the workspace being read by nothing: the agent
6// quietly back on the shipped default, the edited file still on disk looking as
7// though it were in force. Of the three ways this could go, that is the worst,
8// because nothing about it looks wrong.
9//
10// node dev/verify_promptmigrate.mjs
11// node dev/verify_promptmigrate.mjs --break numbers # must fail something
12import fs from 'node:fs';
13import path from 'node:path';
14import { fileURLToPath } from 'node:url';
15import { open, shot, signInAs } from './harness.mjs';
16
17const HERE = path.dirname(fileURLToPath(import.meta.url));
18const WWW = path.join(HERE, '..', 'www');
19const BREAK = (() => {
20 const i = process.argv.indexOf('--break');
21 return (i >= 0 && process.argv[i + 1]) ? process.argv[i + 1] : '';
22})();
23
24// The break is the real defect, restored: the migration reading the old file
25// through `file_read` — a MODEL-facing rendering — and writing THAT into the new
26// one, so a prompt the user wrote by hand acquires `1\t` on every line, for good.
27// It shipped that way, and the check that should have caught it was itself
28// reading through the same door, so it failed for the wrong reason and was
29// dismissed as noise for four days.
30const BREAKS = {
31 numbers: [{
32 file: 'js/daimond.js',
33 find: '\t\t\t\t\tvar old = await readBytes(from);',
34 with: "\t\t\t\t\tvar old = await tools().run_tool('file_read', JSON.stringify({ path: from }));",
35 }],
36};
37if (BREAK && !BREAKS[BREAK]) {
38 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
39 process.exit(2);
40}
41
42/// `src` with `spec` applied, or a hard stop. An anchor that matched nothing
43/// would leave the run below proving the opposite of what it claims.
44function damaged(src, spec) {
45 const n = src.split(spec.find).length - 1;
46 if (n !== 1) {
47 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
48 + 'so nothing was broken.');
49 process.exit(2);
50 }
51 return src.replace(spec.find, spec.with);
52}
53
54/// The damaged files, ONE BODY PER FILE.
55///
56/// Every edit a break names for a file goes into the SAME body, in order, and
57/// that one body is what the route serves. A `page.route` per edit spec does not
58/// work and does not say so: Playwright hands a request to the LAST route
59/// registered for its URL, so a two-edit break shipped only its second edit --
60/// and still went red, for half the reason it claims, with nothing to notice it.
61function damagedFiles() {
62 const byFile = new Map();
63 for (const spec of (BREAKS[BREAK] || [])) {
64 const src = byFile.has(spec.file) ? byFile.get(spec.file)
65 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
66 byFile.set(spec.file, damaged(src, spec));
67 }
68 return byFile;
69}
70
71const s = await open({ name: 'promptmig' + (BREAK ? '-' + BREAK : '') });
72const { page } = s;
73for (const [file, body] of damagedFiles()) {
74 await page.route('**/' + file, r => r.fulfill({
75 status: 200, contentType: 'application/javascript', body,
76 }));
77}
78let bad = 0;
79const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) bad++; };
80
81const wasm = (fn) => page.evaluate(async (src) => {
82 const m = await import('/pkg/oxedyne_daimond.js');
83 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
84 return await (new Function('app', `return (${src})(app);`))(app);
85}, fn.toString());
86
87/// The bytes on disk, NOT `file_read`'s rendering of them.
88///
89/// This verifier used to read every file through `run_tool('file_read')` and
90/// compare the result to a plain string. `file_read` is a MODEL-facing
91/// rendering — it prefixes every line with `N\t` — so the comparison could never
92/// hold and all three checks had failed since the day they were written, against
93/// a migration that was working. Worse, the failure looked exactly like the real
94/// bug it was meant to catch: line numbers appearing in a user's own prompt.
95/// `read_file` is the raw door and resolves against the same active root.
96const raw = (p) => page.evaluate(async (path) => {
97 const m = await import('/pkg/oxedyne_daimond.js');
98 try { return await m.read_file(path); } catch (e) { return 'ERR'; }
99}, p);
100
101const MINE = '# My own daimon prompt\nKeep the crystal terse.\n';
102
103// A prompt file written under the OLD name, as a user upgrading would have.
104await wasm(async (app) => await app.run_tool('file_write',
105 JSON.stringify({ path: 'prompts/conductor.md', content: '# My own daimon prompt\nKeep the crystal terse.\n' })));
106await page.waitForTimeout(300);
107
108// The real upgrade path: the user reloads into a build where the role has been
109// renamed. Nothing is called directly -- boot has to do it, or it will not
110// happen for anyone.
111await page.reload({ waitUntil: 'domcontentloaded' });
112await signInAs(s, 'promptmig'); // a reload lands on the gate; boot resumes past it
113await page.waitForTimeout(2000);
114
115const after = { neu: await raw('prompts/daimon.md'), old: await raw('prompts/conductor.md') };
116console.log(JSON.stringify(after));
117
118check(after.neu === MINE, 'the edited prompt now reads under the new name');
119check(after.old === MINE, 'and the old file is left alone rather than destroyed');
120
121// The defect this file exists downstream of, hunted rather than assumed absent.
122// The migration once read the old file with `file_read` and wrote the RESULT
123// into the new one, baking `1\t` into a prompt the user had written by hand —
124// permanently, and invisibly, because the same rendering put the numbers back on
125// every subsequent read. Assert the stored bytes carry no such prefix, and gate
126// it on the file existing so an absent file cannot satisfy it vacuously.
127check(after.neu !== 'ERR' && !/^\d+\t/m.test(after.neu),
128 `the carried prompt holds no line numbers (${JSON.stringify(after.neu.slice(0, 40))})`);
129
130// Running again must not clobber a daimon.md the user has since edited.
131await wasm(async (app) => await app.run_tool('file_write',
132 JSON.stringify({ path: 'prompts/daimon.md', content: 'newer\n' })));
133await page.reload({ waitUntil: 'domcontentloaded' });
134await signInAs(s, 'promptmig'); // a reload lands on the gate; boot resumes past it
135await page.waitForTimeout(2000);
136const again = await raw('prompts/daimon.md');
137check(again === 'newer\n', `a second run does not overwrite the new file (${JSON.stringify(again)})`);
138
139await s.close();
140if (BREAK) {
141 console.log(`\nbreak '${BREAK}': ${bad} check(s) failed`
142 + (bad ? '' : ' — NOTHING FAILED, so the checks above prove nothing'));
143 process.exit(bad ? 0 : 1); // a break MUST fail something
144}
145console.log(bad ? `\n${bad} FAILED` : '\nALL PASS');
146process.exit(bad ? 1 : 0);