Oregami
Repositories/oxedyne/daimond

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//
26import { open, shot, clearMockLog, mockLog } from './harness.mjs';
27
28const ok = [], bad = [];
29const check = (name, pass, detail) => {
30 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
31 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
32};
33
34clearMockLog();
35const s = await open({ name: 'linktools', defaults: false });
36const p = s.page;
37await p.waitForTimeout(2000);
38
39/// Call one method on a fresh app handle, as the other link verifiers do.
40const 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.
47async 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.
64const lastRequest = () => {
65 const lines = mockLog();
66 return lines.length ? lines[lines.length - 1] : null;
67};
68
69// ── Two Diamonds, and one of them open ────────────────────────────────────
70await p.click('#new-diamond-btn');
71await p.waitForSelector('.dlg-input', { timeout: 8000 });
72await p.fill('.dlg-input', 'Brand voice');
73await p.click('.dlg-ok');
74await p.waitForTimeout(600);
75await p.click('#new-diamond-btn');
76await p.waitForSelector('.dlg-input', { timeout: 8000 });
77await p.fill('.dlg-input', 'Ship the launch');
78await p.click('.dlg-ok');
79await p.waitForSelector('#chat-input', { timeout: 10000 });
80await p.waitForTimeout(800);
81
82const ids = JSON.parse(await call('list_diamonds')).reduce((acc, d) => {
83 acc[d.name] = d.id;
84 return acc;
85}, {});
86check('two Diamonds exist to hang a relation between',
87 !!ids['Brand voice'] && !!ids['Ship the launch'],
88 JSON.stringify(ids));
89const 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.
95await steer('@text noted');
96const daimonReq = lastRequest();
97const daimonTools = daimonReq?.tools || []; // the mock logs the names, not the schemas
98for (const name of ['link_list', 'link_add', 'link_remove']) {
99 check(`the daimon is offered ${name}`, daimonTools.includes(name), daimonTools.join(','));
100}
101const daimonSystem = (daimonReq?.messages || []).filter(m => m.role === 'system')
102 .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n');
103for (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}
107check('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 ─────────────────────
111const 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});
115for (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}
119check('the precedent holds: the daimon’s other exclusive tool is absent too',
120 !panel.includes('spawn_agent'));
121
122// ── A link a MODEL asserted ───────────────────────────────────────────────
123await steer(`@tool link_add {"from":"diamond:${LAUNCH}","to":"diamond:${VOICE}",`
124 + `"rel":"Derives From","note":"The launch copy has to sound like this."}`);
125const made = JSON.parse(await call('all_links'));
126check('the model’s call put exactly one link in the store', made.length === 1,
127 `${made.length} link(s)`);
128const link = made[0] || {};
129check('kept on the Diamond it was asserted from', link.owner === LAUNCH, link.owner);
130check('and NOT filed as the user’s own', link.by === 'agent:daimon', link.by);
131check('the relation was normalised as any other would be', link.rel === 'derives from', link.rel);
132check('the note was kept as written',
133 link.note === 'The launch copy has to sound like this.', link.note);
134check('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? ──────────────────
139await steer('@tool link_list {}');
140const afterList = lastRequest();
141const 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');
145check('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));
148check('and it carries the owner and id a removal needs',
149 toolReplies.includes(link.owner) && toolReplies.includes(link.id));
150
151await steer(`@tool link_list {"node":"diamond:${VOICE}"}`);
152const afterNode = lastRequest();
153const 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.
165const 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.
175const stored = JSON.parse(await call('all_links'));
176check('the store holds exactly one record for the pair', stored.length === 1,
177 stored.map(x => x.id).join(', '));
178check('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 ─────────────────────────
183const revised = await call('update_link', [link.owner, link.id, 'Supersedes', 'Rewritten.']);
184check('update_link reports the revision', revised === true, String(revised));
185const after = (JSON.parse(await call('all_links')))[0] || {};
186check('the id survived the edit, so anything holding it still resolves',
187 after.id === link.id, `${link.id} -> ${after.id}`);
188check('and so did the moment the two were FIRST said to be related',
189 after.ts === link.ts, `${link.ts} -> ${after.ts}`);
190check('while the relation itself moved', after.rel === 'supersedes', after.rel);
191check('and the note with it', after.note === 'Rewritten.', after.note);
192check('a revision that changes nothing writes nothing',
193 (await call('update_link', [link.owner, link.id, 'supersedes', 'Rewritten.'])) === false);
194check('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 ──────────────────────────
198await p.click('#link-graph-btn', { force: true });
199await p.waitForTimeout(1000);
200const edgesBefore = await p.$$eval('#graph-body g.graph-edge', els => els.length);
201check('the graph draws the model’s link', edgesBefore === 1, `${edgesBefore} edge(s)`);
202
203await steer(`@tool link_remove {"owner":"${link.owner}","id":"${link.id}"}`);
204check('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.
209const edgesAfter = await p.$$eval('#graph-body g.graph-edge', els => els.length);
210check('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 ─────────
214await steer('@tool link_add {"from":"diamond:no-such-diamond","to":"file:notes/x.md","rel":"holds"}');
215const afterGhost = lastRequest();
216const ghostReply = (afterGhost?.messages || []).filter(m => m.role === 'tool')
217 .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n');
218check('a mistyped owner is refused rather than given a directory of its own',
219 ghostReply.includes('no Diamond'), ghostReply.slice(0, 200));
220check('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 ──────────────────────────
224await steer(`@tool link_remove {"owner":"${VOICE}","id":"nosuch"}`);
225const afterMiss = lastRequest();
226const missReply = (afterMiss?.messages || []).filter(m => m.role === 'tool')
227 .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))).join('\n');
228check('a miss is explained rather than reported as success',
229 missReply.includes('nothing was removed') && missReply.includes('owner'),
230 missReply.slice(0, 200));
231
232await shot(s, 'linktools-graph');
233await s.close();
234
235console.log(`\n${ok.length} passed, ${bad.length} failed`);
236if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
237process.exit(bad.length ? 1 : 0);