oxedyne/daimond/dev/verify_linktools.mjs
12.2 KiB, 1 run
created by r2519314175:509, 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 | // verify_linktools.mjs — can a daimon actually read and write the world model? |
| 2 | // |
| 3 | // The Diamond graph exists "to give all daimons access to a Daimond world model with |
| 4 | // relational data", and until now no daimon could read a single edge of it: there was no |
| 5 | // link variant in the tool enum at all, so `all_links` and `links_touching` had exactly two |
| 6 | // readers, both of them JavaScript drawing a picture for a person. This drives the three |
| 7 | // tools that close that gap, through the real steer turn, with the real mock provider, and |
| 8 | // asks the questions a model would fail on rather than the ones the Rust tests already |
| 9 | // answer: |
| 10 | // |
| 11 | // * are the tools OFFERED — do the schemas the daimon's request carries name them, |
| 12 | // and do the chat's not, since `Tool::browser()` is also the Tools panel; |
| 13 | // * does a link a MODEL asserted land on the right Diamond, stamped as the model's |
| 14 | // rather than as the user's; |
| 15 | // * does what comes BACK from link_list actually reach the model, or stop at the app; |
| 16 | // * does a removal go, and does the graph notice; |
| 17 | // * and does `update_link` keep the id and the first-assertion time that a delete plus a |
| 18 | // fresh assertion destroys. |
| 19 | // |
| 20 | // NOT YET RUN. It needs `www/pkg` rebuilt, and that directory is shared with another |
| 21 | // agent's running browser, so the rebuild was deliberately not done. Run with dev/serve.mjs |
| 22 | // and dev/mockllm.mjs up, after a wasm-pack build: |
| 23 | // |
| 24 | // node dev/verify_linktools.mjs |
| 25 | // |
| 26 | import { open, shot, clearMockLog, mockLog } from './harness.mjs'; |
| 27 | |
| 28 | const ok = [], bad = []; |
| 29 | const check = (name, pass, detail) => { |
| 30 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 31 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 32 | }; |
| 33 | |
| 34 | clearMockLog(); |
| 35 | const s = await open({ name: 'linktools', defaults: false }); |
| 36 | const p = s.page; |
| 37 | await p.waitForTimeout(2000); |
| 38 | |
| 39 | /// Call one method on a fresh app handle, as the other link verifiers do. |
| 40 | const call = (fn, args = []) => p.evaluate(async ({ fn, args }) => { |
| 41 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 42 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 43 | return await app[fn](...args); |
| 44 | }, { fn, args }); |
| 45 | |
| 46 | /// Steer the open Diamond and wait for the turn to end. |
| 47 | async function steer(text) { |
| 48 | await p.waitForSelector('#chat-input', { timeout: 10000 }); |
| 49 | await p.fill('#chat-input', text); |
| 50 | await p.click('#chat-send'); |
| 51 | const t0 = Date.now(); |
| 52 | while (Date.now() - t0 < 30000) { |
| 53 | const busy = await p.evaluate(() => { |
| 54 | const b = document.getElementById('chat-send'); |
| 55 | return b ? b.disabled : false; |
| 56 | }); |
| 57 | if (!busy) break; |
| 58 | await p.waitForTimeout(200); |
| 59 | } |
| 60 | await p.waitForTimeout(800); |
| 61 | } |
| 62 | |
| 63 | /// The most recent request the mock was sent. |
| 64 | const lastRequest = () => { |
| 65 | const lines = mockLog(); |
| 66 | return lines.length ? lines[lines.length - 1] : null; |
| 67 | }; |
| 68 | |
| 69 | // ── Two Diamonds, and one of them open ──────────────────────────────────── |
| 70 | await p.click('#new-diamond-btn'); |
| 71 | await p.waitForSelector('.dlg-input', { timeout: 8000 }); |
| 72 | await p.fill('.dlg-input', 'Brand voice'); |
| 73 | await p.click('.dlg-ok'); |
| 74 | await p.waitForTimeout(600); |
| 75 | await p.click('#new-diamond-btn'); |
| 76 | await p.waitForSelector('.dlg-input', { timeout: 8000 }); |
| 77 | await p.fill('.dlg-input', 'Ship the launch'); |
| 78 | await p.click('.dlg-ok'); |
| 79 | await p.waitForSelector('#chat-input', { timeout: 10000 }); |
| 80 | await p.waitForTimeout(800); |
| 81 | |
| 82 | const ids = JSON.parse(await call('list_diamonds')).reduce((acc, d) => { |
| 83 | acc[d.name] = d.id; |
| 84 | return acc; |
| 85 | }, {}); |
| 86 | check('two Diamonds exist to hang a relation between', |
| 87 | !!ids['Brand voice'] && !!ids['Ship the launch'], |
| 88 | JSON.stringify(ids)); |
| 89 | const LAUNCH = ids['Ship the launch'], VOICE = ids['Brand voice']; |
| 90 | |
| 91 | // ── Are they offered at all? ────────────────────────────────────────────── |
| 92 | // |
| 93 | // A tool the prompt names and the registry lacks is the failure mode this change was told |
| 94 | // not to repeat, so the two are asserted against each other rather than separately. |
| 95 | await steer('@text noted'); |
| 96 | const daimonReq = lastRequest(); |
| 97 | const daimonTools = daimonReq?.tools || []; // the mock logs the names, not the schemas |
| 98 | for (const name of ['link_list', 'link_add', 'link_remove']) { |
| 99 | check(`the daimon is offered ${name}`, daimonTools.includes(name), daimonTools.join(',')); |
| 100 | } |
| 101 | const daimonSystem = (daimonReq?.messages || []).filter(m => m.role === 'system') |
| 102 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n'); |
| 103 | for (const name of ['link_list', 'link_add', 'link_remove']) { |
| 104 | check(`and its prompt names ${name}, so the prompt and the registry agree`, |
| 105 | daimonSystem.includes(name)); |
| 106 | } |
| 107 | check('the prompt says there are three things to do, now that there are', |
| 108 | daimonSystem.includes('Three things are yours to do')); |
| 109 | |
| 110 | // ── ...and not claimed to the user in the Tools panel ───────────────────── |
| 111 | const panel = await p.evaluate(async () => { |
| 112 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 113 | return JSON.parse(mod.builtin_tools()).map(t => t.tool); |
| 114 | }); |
| 115 | for (const name of ['link_list', 'link_add', 'link_remove']) { |
| 116 | check(`${name} is not claimed to the user as a chat tool`, !panel.includes(name), |
| 117 | panel.join(',')); |
| 118 | } |
| 119 | check('the precedent holds: the daimon’s other exclusive tool is absent too', |
| 120 | !panel.includes('spawn_agent')); |
| 121 | |
| 122 | // ── A link a MODEL asserted ─────────────────────────────────────────────── |
| 123 | await steer(`@tool link_add {"from":"diamond:${LAUNCH}","to":"diamond:${VOICE}",` |
| 124 | + `"rel":"Derives From","note":"The launch copy has to sound like this."}`); |
| 125 | const made = JSON.parse(await call('all_links')); |
| 126 | check('the model’s call put exactly one link in the store', made.length === 1, |
| 127 | `${made.length} link(s)`); |
| 128 | const link = made[0] || {}; |
| 129 | check('kept on the Diamond it was asserted from', link.owner === LAUNCH, link.owner); |
| 130 | check('and NOT filed as the user’s own', link.by === 'agent:daimon', link.by); |
| 131 | check('the relation was normalised as any other would be', link.rel === 'derives from', link.rel); |
| 132 | check('the note was kept as written', |
| 133 | link.note === 'The launch copy has to sound like this.', link.note); |
| 134 | check('the ends are what the model named', |
| 135 | link.from === `diamond:${LAUNCH}` && link.to === `diamond:${VOICE}`, |
| 136 | `${link.from} -> ${link.to}`); |
| 137 | |
| 138 | // ── Does the answer reach the model, or stop at the app? ────────────────── |
| 139 | await steer('@tool link_list {}'); |
| 140 | const afterList = lastRequest(); |
| 141 | const toolReplies = (afterList?.messages || []) |
| 142 | .filter(m => m.role === 'tool') |
| 143 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))) |
| 144 | .join('\n'); |
| 145 | check('the whole graph came back to the MODEL, not just to the picture', |
| 146 | toolReplies.includes(`diamond:${VOICE}`) && toolReplies.includes('derives from'), |
| 147 | toolReplies.slice(0, 200)); |
| 148 | check('and it carries the owner and id a removal needs', |
| 149 | toolReplies.includes(link.owner) && toolReplies.includes(link.id)); |
| 150 | |
| 151 | await steer(`@tool link_list {"node":"diamond:${VOICE}"}`); |
| 152 | const afterNode = lastRequest(); |
| 153 | const nodeReply = (afterNode?.messages || []).filter(m => m.role === 'tool') |
| 154 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n'); |
| 155 | // THIS TURN'S tool replies, not the whole request's. |
| 156 | // |
| 157 | // A daimon is PERSISTENT as of phase E: its conversation travels to the engine |
| 158 | // and back, so every request now carries the earlier turns' tool replies as |
| 159 | // well as this one's. Counting `"id":` across the whole request therefore |
| 160 | // counted the first `link_list` too and read one record as three. |
| 161 | // |
| 162 | // The property has not changed and is still worth checking -- ONE record, |
| 163 | // found from either end -- so it is measured where it lives: the replies after |
| 164 | // the last user message, which is this turn's. |
| 165 | const turnReplies = (() => { |
| 166 | const msgs = (afterNode && afterNode.messages) || []; |
| 167 | let from = -1; |
| 168 | for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'user') { from = i; break; } } |
| 169 | return msgs.slice(from + 1).filter(m => m.role === 'tool') |
| 170 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))) |
| 171 | .join('\n'); |
| 172 | })(); |
| 173 | // And the store itself, which is the thing the check is really about: a second |
| 174 | // copy would be a second record, whatever any request happens to carry. |
| 175 | const stored = JSON.parse(await call('all_links')); |
| 176 | check('the store holds exactly one record for the pair', stored.length === 1, |
| 177 | stored.map(x => x.id).join(', ')); |
| 178 | check('asking from the far end finds the same one record, not a second copy', |
| 179 | turnReplies.includes(link.id) && (turnReplies.match(/"id":/g) || []).length === 1, |
| 180 | turnReplies.slice(0, 200)); |
| 181 | |
| 182 | // ── A revision keeps what a re-assertion destroys ───────────────────────── |
| 183 | const revised = await call('update_link', [link.owner, link.id, 'Supersedes', 'Rewritten.']); |
| 184 | check('update_link reports the revision', revised === true, String(revised)); |
| 185 | const after = (JSON.parse(await call('all_links')))[0] || {}; |
| 186 | check('the id survived the edit, so anything holding it still resolves', |
| 187 | after.id === link.id, `${link.id} -> ${after.id}`); |
| 188 | check('and so did the moment the two were FIRST said to be related', |
| 189 | after.ts === link.ts, `${link.ts} -> ${after.ts}`); |
| 190 | check('while the relation itself moved', after.rel === 'supersedes', after.rel); |
| 191 | check('and the note with it', after.note === 'Rewritten.', after.note); |
| 192 | check('a revision that changes nothing writes nothing', |
| 193 | (await call('update_link', [link.owner, link.id, 'supersedes', 'Rewritten.'])) === false); |
| 194 | check('and an id that names no link revises nothing', |
| 195 | (await call('update_link', [link.owner, 'nosuch', 'x', ''])) === false); |
| 196 | |
| 197 | // ── The removal, and whether the picture notices ────────────────────────── |
| 198 | await p.click('#link-graph-btn', { force: true }); |
| 199 | await p.waitForTimeout(1000); |
| 200 | const edgesBefore = await p.$$eval('#graph-body g.graph-edge', els => els.length); |
| 201 | check('the graph draws the model’s link', edgesBefore === 1, `${edgesBefore} edge(s)`); |
| 202 | |
| 203 | await steer(`@tool link_remove {"owner":"${link.owner}","id":"${link.id}"}`); |
| 204 | check('the model’s removal emptied the store', |
| 205 | JSON.parse(await call('all_links')).length === 0); |
| 206 | // THE JS SIDE. A link a model made or unmade is a link the page did not make, so nothing |
| 207 | // fires `signalLinksChanged` for it. If this fails, the graph is stale until something else |
| 208 | // redraws it -- see the report: the fix is one call after `steer_crystal` resolves. |
| 209 | const edgesAfter = await p.$$eval('#graph-body g.graph-edge', els => els.length); |
| 210 | check('and the graph noticed without being reopened', edgesAfter === 0, |
| 211 | `${edgesAfter} edge(s) still drawn`); |
| 212 | |
| 213 | // ── A Diamond that is not there is not one to write a sidecar for ───────── |
| 214 | await steer('@tool link_add {"from":"diamond:no-such-diamond","to":"file:notes/x.md","rel":"holds"}'); |
| 215 | const afterGhost = lastRequest(); |
| 216 | const ghostReply = (afterGhost?.messages || []).filter(m => m.role === 'tool') |
| 217 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n'); |
| 218 | check('a mistyped owner is refused rather than given a directory of its own', |
| 219 | ghostReply.includes('no Diamond'), ghostReply.slice(0, 200)); |
| 220 | check('and the rail did not gain a Diamond nothing lists', |
| 221 | JSON.parse(await call('list_diamonds')).length === 2); |
| 222 | |
| 223 | // ── A removal aimed at the wrong Diamond says so ────────────────────────── |
| 224 | await steer(`@tool link_remove {"owner":"${VOICE}","id":"nosuch"}`); |
| 225 | const afterMiss = lastRequest(); |
| 226 | const missReply = (afterMiss?.messages || []).filter(m => m.role === 'tool') |
| 227 | .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n'); |
| 228 | check('a miss is explained rather than reported as success', |
| 229 | missReply.includes('nothing was removed') && missReply.includes('owner'), |
| 230 | missReply.slice(0, 200)); |
| 231 | |
| 232 | await shot(s, 'linktools-graph'); |
| 233 | await s.close(); |
| 234 | |
| 235 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 236 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 237 | process.exit(bad.length ? 1 : 0); |