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 |
| 12 | import fs from 'node:fs'; |
| 13 | import path from 'node:path'; |
| 14 | import { fileURLToPath } from 'node:url'; |
| 15 | import { open, shot, signInAs } from './harness.mjs'; |
| 16 | |
| 17 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 18 | const WWW = path.join(HERE, '..', 'www'); |
| 19 | const 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. |
| 30 | const 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 | }; |
| 37 | if (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. |
| 44 | function 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. |
| 61 | function 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 | |
| 71 | const s = await open({ name: 'promptmig' + (BREAK ? '-' + BREAK : '') }); |
| 72 | const { page } = s; |
| 73 | for (const [file, body] of damagedFiles()) { |
| 74 | await page.route('**/' + file, r => r.fulfill({ |
| 75 | status: 200, contentType: 'application/javascript', body, |
| 76 | })); |
| 77 | } |
| 78 | let bad = 0; |
| 79 | const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) bad++; }; |
| 80 | |
| 81 | const 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. |
| 96 | const 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 | |
| 101 | const 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. |
| 104 | await 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' }))); |
| 106 | await 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. |
| 111 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 112 | await signInAs(s, 'promptmig'); // a reload lands on the gate; boot resumes past it |
| 113 | await page.waitForTimeout(2000); |
| 114 | |
| 115 | const after = { neu: await raw('prompts/daimon.md'), old: await raw('prompts/conductor.md') }; |
| 116 | console.log(JSON.stringify(after)); |
| 117 | |
| 118 | check(after.neu === MINE, 'the edited prompt now reads under the new name'); |
| 119 | check(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. |
| 127 | check(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. |
| 131 | await wasm(async (app) => await app.run_tool('file_write', |
| 132 | JSON.stringify({ path: 'prompts/daimon.md', content: 'newer\n' }))); |
| 133 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 134 | await signInAs(s, 'promptmig'); // a reload lands on the gate; boot resumes past it |
| 135 | await page.waitForTimeout(2000); |
| 136 | const again = await raw('prompts/daimon.md'); |
| 137 | check(again === 'newer\n', `a second run does not overwrite the new file (${JSON.stringify(again)})`); |
| 138 | |
| 139 | await s.close(); |
| 140 | if (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 | } |
| 145 | console.log(bad ? `\n${bad} FAILED` : '\nALL PASS'); |
| 146 | process.exit(bad ? 1 : 0); |