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). |
| 28 | import fs from 'node:fs'; |
| 29 | import path from 'node:path'; |
| 30 | import os from 'node:os'; |
| 31 | import { open, connectMock, signInAs, scratch, shot, mockLog, clearMockLog, storedChats, contentText } from './harness.mjs'; |
| 32 | |
| 33 | const OUT = path.join(os.homedir(), '.cache/daimond/daimonchat-shots'); |
| 34 | fs.mkdirSync(OUT, { recursive: true }); |
| 35 | |
| 36 | const BI = process.argv.indexOf('--break'); |
| 37 | const BEQ = process.argv.find(a => a.startsWith('--break=')); |
| 38 | const BREAK = BEQ ? BEQ.split('=')[1] : (BI >= 0 ? (process.argv[BI + 1] || '') : ''); |
| 39 | |
| 40 | let failures = 0; |
| 41 | const check = (cond, msg, detail) => { |
| 42 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 43 | if (!cond) failures++; |
| 44 | }; |
| 45 | |
| 46 | const MODEL = 'accounts/fireworks/models/glm-5p2'; |
| 47 | |
| 48 | async 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. |
| 63 | async 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. |
| 70 | function 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 | |
| 77 | const s = await open({ name: 'daimonchat', profile: scratch('pw', 'daimonchat-' + process.pid) }); |
| 78 | const { page: p } = s; |
| 79 | try { |
| 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 | |
| 287 | console.log(failures === 0 |
| 288 | ? `\nverify_daimonchat: all checks pass.` |
| 289 | : `\nverify_daimonchat: ${failures} failed.`); |
| 290 | process.exit(failures === 0 ? 0 : 1); |