Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_ending.mjs

15.8 KiB, 1 run

created by r2519314175:397, 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_ending.mjs — every turn says how it ended, and it reads as furniture.
2//
3// ── WHY ──────────────────────────────────────────────────────────────────────
4//
5// A model announced "let me rewrite from line 43 through 49 applying the
6// lessons" and then ended its turn having written nothing. The spinner stopped,
7// which was correct — the turn HAD ended — and the owner was left saying **"I
8// have no visibility on what occurred here."** `AgentEvent::Ended` now closes
9// every turn and `dev/CONTRACT_CLAIMS.md` §3 says how it is drawn.
10//
11// ── WHAT IT ASSERTS, WHICH IS THE FURNITURE RULE AND NOT THE FEATURE ─────────
12//
13// Drawing a line is the easy half and it is not the half that matters. §3 is a
14// rule about NOT crying wolf, and two checks here are the whole of it:
15//
16// - **a toolless turn draws nothing at all.** A pure chat has no tool log, so
17// a line under every message in it is noise with nothing behind it.
18// - **an ordinary successful turn is not styled as a notice.** An app that
19// appends a warning to every turn teaches its reader to skip the one that
20// mattered, which is the failure this whole mechanism exists to prevent.
21// Only `refused`, `failed` or a missing path may promote it.
22//
23// Without those two this becomes a warning nobody reads, and every other check
24// here would still be green.
25//
26// node dev/verify_ending.mjs
27// node dev/verify_ending.mjs --break nofurniture # a toolless turn draws a line
28// node dev/verify_ending.mjs --break alwaysnotice # every ending is a notice
29// node dev/verify_ending.mjs --break nopersist # a reload loses the ending
30//
31// A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when
32// nothing did, because a break that changes nothing is itself a failing run.
33//
34// Needs dev/serve.mjs (:8777) and dev/mockllm.mjs (:9099).
35
36import fs from 'node:fs';
37import path from 'node:path';
38import { fileURLToPath } from 'node:url';
39import { open, newChat, signInAs, shot, errors } from './harness.mjs';
40
41const HERE = path.dirname(fileURLToPath(import.meta.url));
42const WWW = path.join(HERE, '..', 'www');
43
44const ok = [], bad = [];
45const check = (name, pass, detail) => {
46 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
47 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
48};
49const sleep = ms => new Promise(r => setTimeout(r, ms));
50
51// ── The breaks ───────────────────────────────────────────────────────────────
52//
53// Each is scoped to survive every check but the one it proves. `nofurniture`
54// drops the `offered === 0` guard, which is the rule that keeps a pure chat
55// clean; `alwaysnotice` promotes every ending, which is the app teaching its
56// reader to skip the line; `nopersist` leaves the live drawing alone and stops
57// the reload redrawing it, so only the durability check moves.
58const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
59const BREAKS = {
60 // BOTH GUARDS, and the fact that there are two is the finding. Patching
61 // `endingParts` alone reddened NOTHING on the first run: `endLogOf` refuses to
62 // store a toolless ending as well, so the line was never reached even with the
63 // drawing rule removed. That is belt and braces rather than a defect -- but a
64 // break that restores only half of a two-place rule launders a plain run as
65 // proof of the other half, which is exactly what this note exists to stop the
66 // next reader repeating.
67 nofurniture: [{
68 file: 'js/daimond.js',
69 find: " // Rule 2, first, so no caller can forget it.\n if (!e || !((e.offered | 0) > 0)) return null;",
70 with: " if (!e) return null; // --break nofurniture",
71 }, {
72 file: 'js/daimond.js',
73 find: " if (!ev || !((ev.offered | 0) > 0)) return null;",
74 with: " if (!ev) return null; // --break nofurniture",
75 }],
76 alwaysnotice: [{
77 file: 'js/daimond.js',
78 find: " notice: !!(refused || failed || missing.length),",
79 with: " notice: true, /* --break alwaysnotice */",
80 }],
81 nopersist: [{
82 file: 'js/daimond.js',
83 find: " else if (m.role === 'end_log') { appendEnding(m); }",
84 with: " else if (m.role === 'end_log') { /* --break nopersist */ }",
85 }],
86};
87
88function damagedFiles() {
89 const byFile = new Map();
90 for (const spec of (BREAKS[BREAK] || [])) {
91 // From what is ALREADY DAMAGED where a break edits one file twice, or the
92 // second edit would drop the first and a two-place rule would be half
93 // restored -- silently, since both anchors are found either way.
94 const src = byFile.get(spec.file) || fs.readFileSync(path.join(WWW, spec.file), 'utf8');
95 if (!src.includes(spec.find)) {
96 // A break whose anchor is not there patches nothing and launders a plain
97 // run as proof. Loud, and fatal.
98 console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`);
99 process.exit(1);
100 }
101 byFile.set(spec.file, src.replace(spec.find, spec.with));
102 }
103 return byFile;
104}
105
106async function serveBreaks(page) {
107 if (!BREAK) return;
108 for (const [file, body] of damagedFiles()) {
109 await page.route('**/' + file, r => r.fulfill({
110 status: 200, contentType: 'application/javascript', body,
111 }));
112 }
113}
114
115const s = await open({ name: 'ending', route: serveBreaks });
116const p = s.page;
117
118/// Every ending line in the chat on screen, with whether it is a notice.
119const endings = () => p.evaluate(() =>
120 [...document.querySelectorAll('#chat-output .chat-msg-ended')].map(e => ({
121 text: (e.textContent || '').trim(),
122 notice: e.classList.contains('ended-notice'),
123 title: e.getAttribute('title') || '',
124 })));
125
126/// Come back from a reload, past the gate.
127///
128/// A RELOAD IS A LOCK — `boot()` finds the stored identity and returns before
129/// `renderAll`, so waiting only for `__DAIMOND_READY` reads the lock screen (see
130/// dev/verify_reopen.mjs). AND THE LOCK LANDS AFTER THAT FLAG: a single read of
131/// `body.locked` straight after it can be taken in the gap before the gate is up,
132/// which skips the sign-in and then waits thirty seconds on a modal nobody
133/// answered. So the gate is waited FOR, briefly, and only then answered.
134///
135/// Nothing here throws: a deadline that expires leaves the checks below to report
136/// what they see, because a race that takes the process with it is a run with no
137/// check attributed to it.
138async function reboot() {
139 await p.reload({ waitUntil: 'domcontentloaded' });
140 await p.waitForFunction(() => window.__DAIMOND_READY === true, null, { timeout: 30000 })
141 .catch(() => {});
142 const gate = Date.now() + 10000;
143 let locked = false;
144 while (Date.now() < gate) {
145 locked = await p.evaluate(() => document.body.classList.contains('locked'));
146 if (locked) break;
147 await sleep(200);
148 }
149 if (locked) await signInAs(s, 'ending').catch(() => {});
150 await p.waitForFunction(() => !document.body.classList.contains('locked'), null,
151 { timeout: 30000 }).catch(() => {});
152 await sleep(600);
153}
154
155/// Send one message and wait for the turn to stop.
156///
157/// NOT `harness.chat`, which opens a chat of its own when none is in focus. Each
158/// case here owns its chat, so the send is the send and nothing else.
159async function say(text, timeout = 40000) {
160 await p.fill('#chat-input', text);
161 await p.click('#chat-send', { force: true });
162 await p.waitForTimeout(300);
163 const until = Date.now() + timeout;
164 while (Date.now() < until) {
165 const busy = await p.evaluate(() => {
166 const b = document.getElementById('chat-send');
167 if (!b) return false;
168 const t = (b.getAttribute('title') || '') + (b.className || '');
169 return /stop/i.test(t) || b.disabled;
170 });
171 if (!busy) break;
172 await sleep(200);
173 }
174 await sleep(500);
175}
176
177// ── 1. THE OWNER'S CASE: tools on the table, and no call made ────────────────
178//
179// `offered > 0, calls === 0`. This is the turn he watched: nothing technically
180// wrong, so nothing was said. The line is what replaces that silence — and it is
181// FURNITURE, because a turn that simply answered is not a fault and drawing it
182// as one is how the reader learns to skip the line.
183await newChat(s);
184await say('@text I will rewrite lines 43 through 49 applying the lessons.');
185const idle = await endings();
186check('1a a turn that used no tool still says how it ended',
187 idle.length === 1, JSON.stringify(idle));
188check('1b and it says NO TOOLS WERE USED, which is the fact he could not see',
189 idle.length === 1 && /no tools used/i.test(idle[0].text), (idle[0] || {}).text || '(none)');
190check('1c AND IT IS NOT A NOTICE — a turn that answered is not a fault',
191 idle.length === 1 && idle[0].notice === false,
192 idle.length ? ('notice=' + idle[0].notice) : '(no line)');
193check('1d the figures nobody put on the line are in its title',
194 idle.length === 1 && /\d/.test(idle[0].title), (idle[0] || {}).title || '(no title)');
195await shot(s, 'ending-1-no-tools');
196
197// ── 2. An ordinary successful turn, WITH a tool call ─────────────────────────
198//
199// The second half of the not-crying-wolf rule: the commonest turn in the app
200// must read as quietly as the one above.
201await newChat(s);
202const dir = await p.evaluate(() => {
203 const f = window.DaimondAttach.focus();
204 return window.DaimondAttach.chatScratch(f.id);
205});
206await say(`@tool file_write {"path":"${dir}/ok.txt","content":"hi"}`);
207const good = await endings();
208check('2a a turn that called a tool says so',
209 good.length === 1 && /1/.test(good[0].text), JSON.stringify(good));
210check('2b AND AN ORDINARY SUCCESSFUL TURN IS NOT STYLED AS A NOTICE',
211 good.length === 1 && good[0].notice === false,
212 good.length ? ('notice=' + good[0].notice + ' — ' + good[0].text) : '(no line)');
213await shot(s, 'ending-2-ordinary');
214
215// ── 3. A refusal, which is what a notice is FOR ──────────────────────────────
216//
217// A write outside the chat's fence comes back `CallOutcome::Refused` — the door
218// turned it away, nothing was written, and the turn finished normally. That is
219// exactly the ending a reader has to be able to pick out of a run of quiet ones.
220await newChat(s);
221await say('@tool file_write {"path":"/etc/passwd","content":"no"}');
222const refused = await endings();
223check('3a a refused call is counted on the line',
224 refused.length === 1 && /1/.test(refused[0].text), JSON.stringify(refused));
225check('3b AND IT IS PROMOTED TO A NOTICE',
226 refused.length === 1 && refused[0].notice === true,
227 refused.length ? ('notice=' + refused[0].notice + ' — ' + refused[0].text) : '(no line)');
228await shot(s, 'ending-3-refused');
229
230// ── 4. A reload still shows how the turn ended ───────────────────────────────
231//
232// Persisted the way `think_log` and `fold_log` are. A RELOAD IS A LOCK: `boot()`
233// finds the stored identity and returns before `renderAll`, so waiting only for
234// `__DAIMOND_READY` reads the lock screen — see dev/verify_reopen.mjs.
235await reboot();
236// The app restores the chat that was open, which is the refused one above.
237let after = [];
238const until = Date.now() + 20000;
239while (Date.now() < until) {
240 after = await endings();
241 if (after.length) break;
242 await sleep(250);
243}
244check('4 A RELOAD STILL SHOWS HOW THE TURN ENDED',
245 after.length === 1 && after[0].notice === true, JSON.stringify(after));
246await shot(s, 'ending-4-reloaded');
247
248// ── 4b. The Diamond's own thread, which is the SECOND of three sinks ────────
249//
250// A daimon's conversation is durable and it is the surface this app is developed
251// from, so a steer that quietly did nothing is exactly as invisible there as it
252// was in a chat — and the reader is the same person. Same line, same rule.
253await p.evaluate(() => document.querySelector('#diamond-list [data-id]').click());
254await sleep(900);
255await p.$('#dview-chat').then(el => el && el.click({ force: true }));
256await sleep(400);
257await say('@text I will rewrite lines 43 through 49.');
258const daimon = await endings();
259check('4e the DAIMON’S OWN THREAD closes the same way a chat’s does',
260 daimon.length >= 1 && /no tools used/i.test(daimon[daimon.length - 1].text),
261 JSON.stringify(daimon));
262check('4f and it is furniture there too',
263 daimon.length >= 1 && daimon[daimon.length - 1].notice === false,
264 daimon.length ? ('notice=' + daimon[daimon.length - 1].notice) : '(no line)');
265await shot(s, 'ending-4b-daimon');
266
267// ── 4c. A worker's tile, which is the THIRD sink and NOT a thread ───────────
268//
269// A worker has no transcript to close: `run.text` is what it SAID, gathered and
270// handed back to the daimon that dispatched it, and the app's audit written into
271// that would be delivered to another model as if the worker had written it. So
272// the ending rides on the TILE, beside the vision note — the other fact the app
273// states about a run in its own voice.
274await newChat(s);
275await p.evaluate(() => window.DaimondPanels.show('agents'));
276await say('@tool spawn_agent {"name":"scribe","task":"@text I will fix it."}');
277const untilRun = Date.now() + 45000;
278let tiles = [];
279while (Date.now() < untilRun) {
280 tiles = await p.evaluate(() => [...document.querySelectorAll('#panel-agents .acard')].map(c => ({
281 pill: ((c.querySelector('.pill') || {}).textContent || ''),
282 ended: ((c.querySelector('.aended') || {}).textContent || ''),
283 notice: !!c.querySelector('.aended.ended-notice'),
284 })));
285 if (tiles.length && tiles.every(t => ['done', 'error', 'stopped'].includes(t.pill))) break;
286 await sleep(400);
287}
288check('4g A WORKER’S TILE SAYS HOW ITS TURN ENDED',
289 tiles.length >= 1 && /no tools used/i.test(tiles[0].ended), JSON.stringify(tiles));
290check('4h and a worker that simply answered is not badged as a notice either',
291 tiles.length >= 1 && tiles[0].notice === false, JSON.stringify(tiles));
292await shot(s, 'ending-4c-worker');
293
294// ── 5. A TOOLLESS TURN DRAWS NOTHING AT ALL ─────────────────────────────────
295//
296// `offered === 0` is the pure-chat path, and the app reaches it for real: `cfg.tools`
297// is read from the stored config (`loadCfg`) and handed to `DaimondApp`'s
298// `enable_tools`. So the fixture is the product's own setting rather than an
299// invented one, and what it produces is a genuine `Ended { offered: 0 }`.
300await p.evaluate(() => {
301 const raw = JSON.parse(localStorage.getItem('daimond-byok') || '{}');
302 raw.tools = false;
303 localStorage.setItem('daimond-byok', JSON.stringify(raw));
304});
305await reboot();
306await newChat(s);
307await say('@text a plain answer with no tools anywhere near it');
308const quiet = await endings();
309check('5a A TOOLLESS TURN DRAWS NOTHING AT ALL',
310 quiet.length === 0, JSON.stringify(quiet));
311// And the turn really happened, so 5a is not passing because nothing ran.
312const said = await p.evaluate(() => (document.getElementById('chat-output') || {}).innerText || '');
313check('5b …and the turn really ran, so 5a is not green for want of a turn',
314 /plain answer|no tools anywhere/i.test(said) || said.trim().length > 0,
315 said.slice(0, 80));
316await shot(s, 'ending-5-toolless');
317
318// ── The console ─────────────────────────────────────────────────────────────
319const errs = errors(s).filter(e => !/502|Account service|429/.test(e));
320check('6 drawing the ending raised nothing in the console', errs.length === 0,
321 errs.slice(0, 2).join(' | '));
322
323await s.close();
324
325console.log(`\n${ok.length} passed, ${bad.length} failed`);
326if (BREAK) {
327 console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must`
328 : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`);
329 process.exit(bad.length ? 0 : 1);
330}
331process.exit(bad.length ? 1 : 0);