Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_visible.mjs

9.3 KiB, 1 run

created by r2519314175:789, 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// The switch called Steps hides the STEPS, and not the model's own sentences.
2//
3// The owner's ruling of 2026-08-28, reversing a decision taken on 2026-08-23. A run of prose
4// that a tool call follows is demoted by `demoteToWorking` (www/js/daimond.js) into a
5// `.chat-msg-working` tile, and that tile carried `.chat-output.hide-tools`'s `display: none`.
6// So a reader who had turned the tool steps off was ALSO shown none of the model's prose: a
7// turn of twenty calls drew twenty paragraphs of working, and with the switch on it drew a
8// blank thread with one final answer at the bottom of it.
9//
10// That is note 05's complaint arriving by the other door, and it is the likelier cause of it:
11// *"Chats are losing responses from models. I see some response text from a model, then later
12// it is superceded by a final answer and I can't see what was there before."* With the switch
13// off the tile is collapsed, which the merged fix made legible; with the switch on it measured
14// zero by zero and there was nothing to open at all.
15//
16// Measured as INK -- a bounding box, not a query -- because a `display: none` element still
17// answers `textContent` and still appears in the markup. A check that read the transcript
18// would have passed throughout the whole of the defect.
19//
20// node dev/verify_visible.mjs
21// node dev/verify_visible.mjs --break hidework # the rule that hid the working, put back
22
23import fs from 'node:fs';
24import path from 'node:path';
25import { fileURLToPath } from 'node:url';
26
27const HERE = path.dirname(fileURLToPath(import.meta.url));
28const ROOT = path.dirname(HERE);
29const WWW = path.join(ROOT, 'www');
30const H = await import(path.join(HERE, 'harness.mjs'));
31const { open, newChat, chat, transcript, shot, connectMock, scratch } = H;
32
33const BREAK = (() => {
34 const i = process.argv.indexOf('--break');
35 return i === -1 ? '' : (process.argv[i + 1] || '');
36})();
37const NAME = 'visible';
38const MARK = 'INTERIM-PROSE-MARKER';
39
40// ── Every check is proved against broken code first ──────────────────────────
41const BREAKS = {
42 // The rule as it stood until 2026-08-28: the working goes when the steps go.
43 hidework: [{
44 file: 'css/app.css',
45 find: '.chat-msg-working { margin: 4px 0 4px 2px; }',
46 with: '.chat-output.hide-tools .chat-msg-working { display: none; }\n'
47 + '.chat-msg-working { margin: 4px 0 4px 2px; }',
48 }],
49};
50if (BREAK && !BREAKS[BREAK]) {
51 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
52 process.exit(2);
53}
54function damaged(src, spec) {
55 const n = src.split(spec.find).length - 1;
56 if (n !== 1) {
57 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
58 + 'so nothing was broken and the run below would prove nothing.');
59 process.exit(2);
60 }
61 return src.replace(spec.find, spec.with);
62}
63const TYPE = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' };
64const bodies = new Map();
65for (const spec of (BREAKS[BREAK] || [])) {
66 const src = bodies.has(spec.file) ? bodies.get(spec.file)
67 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
68 bodies.set(spec.file, damaged(src, spec));
69}
70async function serveBreaks(page) {
71 for (const [file, body] of bodies) {
72 const type = TYPE[path.extname(file)] || 'text/plain';
73 await page.route('**/' + file, r => r.fulfill({ status: 200, contentType: type, body }));
74 }
75}
76
77let failures = 0;
78const log = (...a) => console.log(...a);
79const line = (t) => log('\n════════ ' + t + ' ════════');
80const check = (ok, what, detail = '') => {
81 log((ok ? ' PASS ' : ' FAIL ') + what + (detail ? ' -- ' + detail : ''));
82 if (!ok) failures++;
83};
84
85// The narration has to be long enough to be demoted into a control rather than a single quiet
86// line, since the control is the shape the reader loses when the tile is hidden.
87const LONG = `${MARK} and then the model keeps going in one long clause about the line numbers `
88 + 'it means to pin down and the files it means to open before it would ever commit to '
89 + 'anything at all, still with no full stop anywhere in sight, and only here does it '
90 + 'finally stop. Then a second sentence, which is the part that goes behind the control '
91 + 'and must be findable whichever way the switch is set.';
92
93const s = await open({ name: NAME, connect: false,
94 profile: scratch('pw', 'visible' + (BREAK ? '-' + BREAK : '')),
95 route: serveBreaks });
96await connectMock(s);
97await newChat(s);
98
99const dir = await s.page.evaluate(() => {
100 const f = window.DaimondAttach.focus();
101 return f && f.id ? window.DaimondAttach.chatScratch(f.id) : '';
102});
103check(!!dir, 'the chat has a scratch folder, so the tool call has somewhere legal to point', dir);
104await chat(s, `@narrate ${LONG} ;; file_list {"path":"${dir}"}`, { timeout: 60000 });
105
106/// What is actually on the screen, in pixels, for each of the three things the switch governs.
107const ink = () => s.page.evaluate(() => {
108 const out = document.getElementById('chat-output');
109 const box = (n) => {
110 if (!n) return 0;
111 const r = n.getBoundingClientRect();
112 // A `display: none` element reports 0x0; so does one clipped to nothing. Either way
113 // there is no ink, which is the only question being asked.
114 return Math.round(r.width) * Math.round(r.height) > 0 ? Math.round(r.height) : 0;
115 };
116 const work = out.querySelector('.chat-msg-working');
117 return {
118 hidden: out.classList.contains('hide-tools'),
119 working: box(work),
120 // The words inside it, whether or not they are drawn.
121 words: work ? String(work.textContent || '') : '',
122 steps: box(out.querySelector('.tool-block')),
123 answer: box(out.querySelector('.chat-msg-assistant')),
124 };
125});
126
127line('1. with the steps shown');
128// PUT WHERE THIS RUN NEEDS IT, not assumed to be there. The switch is kept in localStorage and
129// the browser profile is reused between runs, so a second run of this file started with the
130// switch left on by the first and failed a check about the default. What is under test is what
131// each position DRAWS, not which of them a fresh profile opens in.
132await s.page.evaluate(() => {
133 const out = document.getElementById('chat-output');
134 const b = document.getElementById('steps-toggle-btn');
135 if (out && b && out.classList.contains('hide-tools')) b.click();
136});
137await s.page.waitForTimeout(400);
138let seen = await ink();
139check(!seen.hidden, 'the switch is off, which is this half of the comparison');
140check(seen.working > 0, 'the model\'s working is on screen', `${seen.working}px tall`);
141check(seen.words.includes(MARK), 'and it is the prose the reader watched arrive');
142check(seen.steps > 0, 'and so is the tool step it decided on', `${seen.steps}px tall`);
143await shot(s, 'visible-steps-on');
144
145line('2. with the steps hidden, which is what the ruling is about');
146await s.page.evaluate(() => {
147 const b = document.getElementById('steps-toggle-btn');
148 if (b) b.click();
149});
150await s.page.waitForTimeout(400);
151seen = await ink();
152check(seen.hidden, 'the switch is on');
153check(seen.steps === 0, 'THE TOOL STEP IS GONE, which is what the switch is for');
154check(seen.working > 0,
155 'AND THE MODEL\'S OWN PROSE IS STILL DRAWN, which is the ruling',
156 `${seen.working}px tall`);
157check(seen.words.includes(MARK),
158 'with the words the reader watched arrive still in it');
159check(seen.answer > 0, 'and the answer below it, untouched');
160await shot(s, 'visible-steps-off');
161
162line('3. and the collapsed summary still earns its keep');
163// Both halves the merged lane added: the count that says there is something to open, and a
164// summary that does not end inside a word. The prose is COLLAPSED even when it is not hidden,
165// so neither stops mattering under this ruling.
166const control = await s.page.evaluate(() => {
167 const w = document.querySelector('.chat-msg-working');
168 const d = w && w.querySelector('details');
169 const sum = d && d.querySelector('summary');
170 const more = sum && sum.querySelector('.chat-working-more');
171 const label = sum ? String(sum.textContent || '')
172 .replace(more ? String(more.textContent || '') : '', '').trim() : '';
173 return {
174 label,
175 more: more ? String(more.textContent || '').trim() : '',
176 moreBox: more ? Math.round(more.getBoundingClientRect().width) : 0,
177 open: d ? d.open : null,
178 };
179});
180check(/\d/.test(control.more) && control.moreBox > 0,
181 'the control still says how much is behind it', `"${control.more}" at ${control.moreBox}px`);
182check(control.open === false, 'and it is still closed, because the working is not the answer');
183check(!!control.label && !/\S$/.test(LONG.slice(control.label.length, control.label.length + 1)),
184 'and its summary still stops on a whole word',
185 `ends "${control.label.slice(-24)}"`);
186
187line('4. the switch survives a reload, and so does the prose');
188await s.page.reload({ waitUntil: 'domcontentloaded' });
189await s.page.waitForTimeout(1200);
190await H.signInAs(s, NAME);
191await s.page.waitForTimeout(1500);
192await s.page.evaluate(() => {
193 const b = document.querySelector('#session-list .chat-box.active')
194 || document.querySelector('#session-list .chat-box');
195 if (b) b.click();
196});
197await s.page.waitForTimeout(1200);
198const after = await transcript(s);
199check(after.includes(MARK),
200 'a reader coming back tomorrow reads it too, with the steps still hidden');
201
202const errs = s.errs.filter(e => !/favicon|manifest|502|Bad Gateway/i.test(e));
203check(errs.length === 0, 'no unexpected console errors', errs.slice(0, 3).join(' | '));
204await s.close();
205
206log(`\n${failures ? failures + ' FAILED' : 'all passed'}`);
207process.exit(failures ? 1 : 0);