Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_daimonchat.mjs

13.3 KiB, 1 run

created by r2519314175:341, 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_daimonchat.mjs — phase E: a Diamond has a chat, and the daimon remembers.
2//
3// Notes2, in the user's own words: *"The idea of just having a prompt box and
4// hiding the chat sequence doesn't work. … a diamond should offer the crystal
5// view and a chat view."* and *"because the daimon is meant to be persistant"*.
6//
7// Five properties:
8//
9// 1. A Diamond has two faces and a control that switches between them; a chat
10// has one, and is not offered a second.
11// 2. What the daimon says lands in a thread, and stays there. The old surface
12// put a text-only answer in a dismissable box that the next steer cleared.
13// 3. THE DAIMON REMEMBERS. Measured at the wire: the second turn's request
14// carries the first turn's exchange. Every steer used to build a fresh
15// `Session`, so a follow-up question reached a model that had never heard
16// the first one — and no screenshot of a thread can tell a model that
17// remembers from a transcript that merely still has the words on it.
18// 4. The conversation survives a reload, because it is an ordinary chat record
19// in the ordinary store.
20// 5. It has no tile in the Chats rail. It belongs to its Diamond, and a record
21// with a tile AND a Diamond would be reachable two ways with one state.
22//
23// node dev/verify_daimonchat.mjs
24// node dev/verify_daimonchat.mjs --break stateless # the daimon forgets again
25// node dev/verify_daimonchat.mjs --break railtile # the daimon gets a tile
26//
27// Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both).
28import fs from 'node:fs';
29import path from 'node:path';
30import os from 'node:os';
31import { open, connectMock, signInAs, scratch, shot, mockLog, clearMockLog, storedChats, contentText } from './harness.mjs';
32
33const OUT = path.join(os.homedir(), '.cache/daimond/daimonchat-shots');
34fs.mkdirSync(OUT, { recursive: true });
35
36const BI = process.argv.indexOf('--break');
37const BEQ = process.argv.find(a => a.startsWith('--break='));
38const BREAK = BEQ ? BEQ.split('=')[1] : (BI >= 0 ? (process.argv[BI + 1] || '') : '');
39
40let failures = 0;
41const check = (cond, msg, detail) => {
42 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
43 if (!cond) failures++;
44};
45
46const MODEL = 'accounts/fireworks/models/glm-5p2';
47
48async function create(p, name) {
49 await p.evaluate(() => document.getElementById('new-diamond-btn').click());
50 await p.waitForSelector('.dlg-card', { timeout: 8000 });
51 await p.evaluate((nm) => {
52 const card = [...document.querySelectorAll('.dlg-card')]
53 .filter(c => c.getClientRects().length).pop();
54 const inp = card.querySelector('input.dlg-input');
55 inp.value = nm;
56 inp.dispatchEvent(new Event('input', { bubbles: true }));
57 card.querySelector('.dlg-ok').click();
58 }, name);
59 await p.waitForTimeout(1200);
60}
61
62/// Say something to whatever is on screen, through the one composer.
63async function say(p, text, ms = 4000) {
64 await p.fill('#chat-input', text);
65 await p.click('#chat-send');
66 await p.waitForTimeout(ms);
67}
68
69/// Every user message the provider was sent, in the LAST request it received.
70function lastRequestUserTexts() {
71 const rows = mockLog();
72 if (!rows.length) return null;
73 const msgs = rows[rows.length - 1].messages || [];
74 return msgs.filter(m => m.role === 'user').map(m => contentText(m.content));
75}
76
77const s = await open({ name: 'daimonchat', profile: scratch('pw', 'daimonchat-' + process.pid) });
78const { page: p } = s;
79try {
80 await connectMock(s, { model: MODEL });
81 if (BREAK) console.log(` .. running with --break ${BREAK}`);
82
83 // ══ 1. Two faces on a Diamond, one on a chat ══════════════════════
84 await create(p, 'Dee');
85 const sw = () => p.evaluate(() => {
86 const d = document.getElementById('diamond-view');
87 if (!d) return null;
88 return {
89 shown: d.getClientRects().length > 0,
90 crystal: (document.getElementById('dview-crystal') || {}).getAttribute
91 ? document.getElementById('dview-crystal').getAttribute('aria-pressed') : '',
92 chat: (document.getElementById('dview-chat') || {}).getAttribute
93 ? document.getElementById('dview-chat').getAttribute('aria-pressed') : '',
94 };
95 });
96 let v = await sw();
97 check(v !== null, 'the face switch exists');
98 check(!!(v && v.shown), 'it is drawn on a Diamond');
99 check(!!(v && v.crystal === 'true' && v.chat === 'false'),
100 'and a Diamond opens on its crystal', v && `${v.crystal}/${v.chat}`);
101
102 await p.click('#dview-chat');
103 await p.waitForTimeout(700);
104 v = await sw();
105 check(!!(v && v.chat === 'true' && v.crystal === 'false'),
106 'pressing Chat moves the switch', v && `${v.crystal}/${v.chat}`);
107 const faces = await p.evaluate(() => ({
108 crystal: document.getElementById('crystal-view').style.display,
109 bar: getComputedStyle(document.querySelector('.chat-input-bar')).display,
110 }));
111 check(faces.crystal === 'none', 'the crystal goes away', faces.crystal);
112 check(faces.bar !== 'none', 'and the composer comes back', faces.bar);
113
114 if (BREAK === 'railtile') {
115 // The record's binding to its Diamond dropped on the way out of the store --
116 // which is exactly what `hydrateChat` did before this phase, and the reason a
117 // reloaded daimon appeared in the Chats rail while its Diamond started a
118 // second, empty conversation beside it.
119 await p.evaluate(() => {
120 const rec = DaimondDiamond.conversation(DaimondDiamond.current().id);
121 if (rec) rec.diamondId = '';
122 });
123 }
124
125 // ══ 2. What the daimon says lands in a thread ═════════════════════
126 clearMockLog();
127 await say(p, 'remember the word ORTOLAN');
128 let thread = await p.evaluate(() =>
129 [...document.querySelectorAll('#chat-output .chat-msg')]
130 .map(n => ({ cls: n.className, text: (n.textContent || '').trim().slice(0, 80) })));
131 check(thread.some(m => /chat-msg-user/.test(m.cls) && /ORTOLAN/.test(m.text)),
132 'what was asked is in the thread', thread.length + ' messages');
133 check(thread.some(m => /chat-msg-assistant/.test(m.cls) && m.text),
134 'and what the daimon answered is too',
135 (thread.find(m => /assistant/.test(m.cls)) || {}).text);
136
137 // ══ 3. The daimon remembers — measured at the wire ════════════════
138 if (BREAK === 'stateless') {
139 // Exactly the pre-phase-E daimon: a fresh session per instruction. The one
140 // thing that makes it persistent is the conversation that travels to the
141 // engine and back, so emptying that is the whole of the old behaviour.
142 await p.evaluate(() => {
143 const rec = DaimondDiamond.conversation(DaimondDiamond.current().id);
144 if (rec) rec.session = null;
145 });
146 }
147 clearMockLog();
148 await say(p, 'what was the word?');
149 const sent = lastRequestUserTexts();
150 check(sent !== null, 'the provider was reached');
151 check(!!(sent && sent.some(x => /what was the word/.test(x))),
152 'the second turn is in the request', sent && String(sent.length));
153 check(!!(sent && sent.some(x => /ORTOLAN/.test(x))),
154 'AND SO IS THE FIRST — the daimon carried the conversation forward',
155 sent ? sent.map(x => x.slice(0, 40)).join(' | ') : '');
156
157 // ══ 4. It survives a reload ═══════════════════════════════════════
158 await p.reload({ waitUntil: 'domcontentloaded' });
159 // A reload lands on the lock screen: the account's keys live in memory, so
160 // every reload is a fresh unlock. Signing back in is part of what is being
161 // tested here -- a conversation that survived the reload but not the unlock
162 // would still be lost.
163 await signInAs(s, 'daimonchat');
164 // Waited FOR rather than slept through: a reload boots the wasm, opens the
165 // store and reads the Diamonds off OPFS, and how long that takes is a property
166 // of the machine rather than of the app.
167 try {
168 await p.waitForFunction(() => {
169 return [...document.querySelectorAll('#diamond-list .diamond-box')]
170 .some(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === 'Dee');
171 }, null, { timeout: 20000 });
172 } catch { /* reported by the check below */ }
173 const back = await p.evaluate(() => {
174 const box = [...document.querySelectorAll('#diamond-list .diamond-box')]
175 .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === 'Dee');
176 if (!box) return null;
177 box.click();
178 return true;
179 });
180 check(!!back, 'the Diamond is still on the rail after a reload');
181 await p.waitForTimeout(1200);
182 const face = await p.evaluate(() =>
183 (document.getElementById('dview-chat') || {}).getAttribute
184 ? document.getElementById('dview-chat').getAttribute('aria-pressed') : '');
185 check(face === 'true', 'and it opens back on the face you left it on', face);
186 thread = await p.evaluate(() =>
187 [...document.querySelectorAll('#chat-output .chat-msg')]
188 .map(n => (n.textContent || '').trim()));
189 check(thread.some(x => /ORTOLAN/.test(x)),
190 'with the conversation still in it', String(thread.length) + ' messages');
191 await shot(s, 'chat-view');
192
193 // ══ 5. No tile in the Chats rail ══════════════════════════════════
194 //
195 // The harness's `newChat` returns early when `#chat-input` is already visible,
196 // and in a Diamond's chat view it IS — that composer is the daimon's. So the
197 // rail's own + is pressed directly, which is what a person does.
198 // A direct DOM click. A coordinate click lands on whatever is drawn over the
199 // rail — the harness's own `newChat` closes the Admin drawer first for exactly
200 // this reason, and here the Diamond's chat view is what is in the way.
201 await p.evaluate(() => document.getElementById('new-session-btn').click());
202 await p.waitForTimeout(800);
203 const rail = await p.evaluate(() =>
204 [...document.querySelectorAll('#session-list .chat-box .tile-when')]
205 .map(n => n.value || ''));
206 check(!rail.includes('Dee'),
207 'the daimon has no tile in the Chats rail', rail.join(', ') || '(empty)');
208 check(rail.length >= 1,
209 'while an ordinary chat still has one', rail.join(', ') || '(empty)');
210 // And the store DOES hold it, so what is filtered is the rail and not the
211 // record: a conversation dropped from the store would be one that never
212 // reached another device, and the daimon would forget on every reload.
213 const stored = await storedChats(s);
214 const mine = stored.filter(c => c && c.diamondId);
215 check(mine.length === 1, 'and the store holds it exactly once, bound to its Diamond',
216 String(mine.length) + ' of ' + stored.length);
217 check(!!(mine[0] && mine[0].session && (mine[0].session.msgs || []).length > 0),
218 'with the MODEL\'s conversation stored beside the transcript',
219 mine[0] && mine[0].session ? String((mine[0].session.msgs || []).length) : 'none');
220
221 // ══ 6. A HALF-TYPED MESSAGE BELONGS TO ITS CONVERSATION ═══════════
222 //
223 // Reported live: "I started interacting with my Daimond daimon, switched to
224 // the Daimond Optimiser diamond, and a text box from the previous chat then
225 // appeared in the new one." There is ONE `#chat-input` for every chat and both
226 // faces of every Diamond, and the switch re-pointed everything about it except
227 // the words in it -- so a sentence meant for one Diamond followed the user into
228 // the next, and `syncComposerAttachPrefix` then glued the arriving Diamond's
229 // attachment prefix onto the front of it.
230 //
231 // A ROUND TRIP AND NOT A CLEARING. Emptying the box on every switch would pass
232 // the first check below and is not the fix: it loses the sentence instead of
233 // misplacing it. What is asserted is that each Diamond gets its OWN words back,
234 // which only a save-and-restore can satisfy.
235 //
236 // Proved against the broken code by hand before it was written -- there is no
237 // `page.route` machinery in this file -- by removing the two `moveComposerTo`
238 // calls from `selectChat` and `selectDiamond` in `www/js/daimond.js`. Checks
239 // two and four go red, and check two's detail prints the other Diamond's
240 // sentence, which is the defect verbatim.
241 const goTo = (name) => p.evaluate((nm) => {
242 const box = [...document.querySelectorAll('#diamond-list .diamond-box')]
243 .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm);
244 if (!box) return false;
245 box.click();
246 return true;
247 }, name);
248 const boxText = () => p.evaluate(() => document.getElementById('chat-input').value);
249
250 await create(p, 'Dum');
251 await p.waitForTimeout(600);
252 const DEE = 'half a sentence meant for Dee';
253 const DUM = 'and a different one for Dum';
254
255 check(await goTo('Dee'), 'both Diamonds are on the rail');
256 await p.waitForTimeout(700);
257 await p.fill('#chat-input', DEE);
258 await goTo('Dum');
259 await p.waitForTimeout(700);
260 const atDum = await boxText();
261 check(!atDum.includes(DEE),
262 'a draft typed in one Diamond does not follow the user into another',
263 JSON.stringify(atDum));
264
265 await p.fill('#chat-input', DUM);
266 await goTo('Dee');
267 await p.waitForTimeout(700);
268 const atDee = await boxText();
269 check(atDee === DEE,
270 'and going back finds the words that were left there',
271 JSON.stringify(atDee));
272
273 await goTo('Dum');
274 await p.waitForTimeout(700);
275 const atDum2 = await boxText();
276 check(atDum2 === DUM,
277 'each Diamond keeping its own, so nothing was cleared to make the first check pass',
278 JSON.stringify(atDum2));
279
280} catch (e) {
281 check(false, 'the run finished', String(e && e.message || e));
282 try { await shot(s, 'threw'); } catch {}
283} finally {
284 await s.close();
285}
286
287console.log(failures === 0
288 ? `\nverify_daimonchat: all checks pass.`
289 : `\nverify_daimonchat: ${failures} failed.`);
290process.exit(failures === 0 ? 0 : 1);