oxedyne/daimond/dev/verify_continue_resume.mjs
11.3 KiB, 1 run
created by r2519314175:315, 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_continue_resume.mjs — Continue RESUMES a turn, it does not re-run the prompt. |
| 2 | // |
| 3 | // THE DEFECT (dev/PERSISTENCE_STUDY.md §1.3 and §4.4 item 2). `continueTurn` used to tombstone |
| 4 | // every message of the interrupted turn — the partial reply included — drop `chat.app`, and call |
| 5 | // `runTurn(chat, text)` with the ORIGINAL prompt. Output tokens the user had already paid for were |
| 6 | // thrown away and bought again, and the answer they had been READING was replaced by a different |
| 7 | // one. |
| 8 | // |
| 9 | // THE FIX. When something arrived, the partial STAYS as an ordinary assistant message, its badge |
| 10 | // comes off, `chat.app` is nulled so `ensureApp` rebuilds the session from the messages that remain |
| 11 | // — which now end with the partial — and the model is asked to carry on with `CONTINUE_NUDGE`. Only |
| 12 | // the pre-token case, where nothing arrived and nothing was billed, still re-runs the prompt. |
| 13 | // |
| 14 | // WHAT THIS FILE PROVES, without a browser or a model: that `continueTurn` builds a RESUME payload |
| 15 | // carrying the partial (the partial kept, not tombstoned, and the continuation dispatched rather |
| 16 | // than the original prompt), that the empty case still re-runs, and that the idempotency guards |
| 17 | // hold. The real function is LIFTED from www/js/daimond.js and run against stubbed dependencies, so |
| 18 | // a change to the shipped logic is what this measures. |
| 19 | // |
| 20 | // The visible append itself — the continuation text landing after the retained partial — is |
| 21 | // runTurn's job and is covered in a real page by dev/verify_dropped.mjs and dev/verify_predrop.mjs. |
| 22 | // Here the claim is the DISPATCH: partial retained + continue message, not a bare re-run. |
| 23 | // |
| 24 | // PROVED AGAINST THE PRE-FIX DISPATCH. `--break rerun` flips the one deciding line — the resume |
| 25 | // branch's `runTurn(chat, CONTINUE_NUDGE)` back to `runTurn(chat, text)`, which is what the old |
| 26 | // code did — and runs the SAME checks. The two dispatch assertions in section A then fail, which |
| 27 | // is the proof they bite on the resume and not on something incidental: |
| 28 | // |
| 29 | // node dev/verify_continue_resume.mjs --break rerun # the resume-dispatch checks fail |
| 30 | // node dev/verify_continue_resume.mjs # and then, clean |
| 31 | // |
| 32 | // node dev/verify_continue_resume.mjs |
| 33 | // |
| 34 | // Needs nothing running. |
| 35 | import fs from 'node:fs'; |
| 36 | import path from 'node:path'; |
| 37 | import { fileURLToPath } from 'node:url'; |
| 38 | |
| 39 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 40 | const SRC = fs.readFileSync(path.join(HERE, '..', 'www', 'js', 'daimond.js'), 'utf8'); |
| 41 | |
| 42 | const BREAK = (() => { |
| 43 | const i = process.argv.indexOf('--break'); |
| 44 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 45 | })(); |
| 46 | if (BREAK && BREAK !== 'rerun') { |
| 47 | console.error(`unknown break '${BREAK}'; the only one is 'rerun'`); |
| 48 | process.exit(2); |
| 49 | } |
| 50 | |
| 51 | let bad = 0, ran = 0; |
| 52 | const check = (pass, name, detail) => { |
| 53 | ran++; |
| 54 | if (!pass) bad++; |
| 55 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 56 | }; |
| 57 | |
| 58 | // ── Lift the real function and its nudge out of the source ─── |
| 59 | |
| 60 | /// A `var NAME = …;` statement, however many lines its right-hand side spans, up to the first `;` |
| 61 | /// that ends the statement. `CONTINUE_NUDGE` is a multi-line string concatenation with no `;` |
| 62 | /// inside its literals. |
| 63 | function grabVarStmt(name) { |
| 64 | const start = SRC.indexOf('var ' + name + ' ='); |
| 65 | if (start < 0) { console.error(`could not find 'var ${name}' in js/daimond.js`); process.exit(2); } |
| 66 | const end = SRC.indexOf(';', start); |
| 67 | return SRC.slice(start, end + 1); |
| 68 | } |
| 69 | |
| 70 | /// A function declaration by signature, brace-matched from its opening `{`. |
| 71 | function grabFn(sig) { |
| 72 | const start = SRC.indexOf(sig); |
| 73 | if (start < 0) { console.error(`could not find '${sig}' in js/daimond.js`); process.exit(2); } |
| 74 | const open = SRC.indexOf('{', start); |
| 75 | let depth = 0, i = open; |
| 76 | for (; i < SRC.length; i++) { |
| 77 | const c = SRC[i]; |
| 78 | if (c === '{') depth++; |
| 79 | else if (c === '}') { depth--; if (depth === 0) { i++; break; } } |
| 80 | } |
| 81 | return SRC.slice(start, i); |
| 82 | } |
| 83 | |
| 84 | const NUDGE_STMT = grabVarStmt('CONTINUE_NUDGE'); |
| 85 | const CT_ORIG = grabFn('function continueTurn('); // the pristine lift, for the sentinels |
| 86 | |
| 87 | // The source actually built. `--break rerun` flips the one deciding line to the pre-fix dispatch; |
| 88 | // the sentinels above still read CT_ORIG, so under break only the resume-dispatch checks fail. |
| 89 | let CT_SRC = CT_ORIG; |
| 90 | if (BREAK === 'rerun') { |
| 91 | const from = 'runTurn(chat, CONTINUE_NUDGE);'; |
| 92 | if (CT_SRC.split(from).length !== 2) { |
| 93 | console.error("the line '--break rerun' patches is not in continueTurn exactly once"); |
| 94 | process.exit(2); |
| 95 | } |
| 96 | CT_SRC = CT_SRC.replace(from, 'runTurn(chat, text);'); |
| 97 | console.log('\n*** RUNNING UNDER --break rerun: the resume-dispatch failures below are the point ***\n'); |
| 98 | } |
| 99 | |
| 100 | // Sentinels: a mis-lift or a rename must fail here, not silently test nothing. Read the pristine |
| 101 | // lift, so `--break rerun` leaves these green and trips only the dispatch checks in section A. |
| 102 | check(/CONTINUE_NUDGE/.test(CT_ORIG), |
| 103 | 'the lifted continueTurn references CONTINUE_NUDGE'); |
| 104 | check(/runTurn\(chat, CONTINUE_NUDGE\)/.test(CT_ORIG), |
| 105 | 'and dispatches the continuation with runTurn(chat, CONTINUE_NUDGE)'); |
| 106 | check(/runTurn\(chat, text\)/.test(CT_ORIG), |
| 107 | 'and keeps runTurn(chat, text) for the empty-partial case'); |
| 108 | |
| 109 | /// Build the real `continueTurn` with its free identifiers supplied as stubs. Everything else it |
| 110 | /// uses is a parameter or a local. |
| 111 | function makeContinueTurn(stubs) { |
| 112 | const names = ['loadMsgTombs', 'msgTombstone', 'touchChat', 'persistChats', 'renderHistory', 'runTurn']; |
| 113 | const f = new Function( |
| 114 | ...names, |
| 115 | NUDGE_STMT + '\n' + CT_SRC + '\nreturn continueTurn;'); |
| 116 | return f(...names.map((n) => stubs[n])); |
| 117 | } |
| 118 | |
| 119 | /// A spy set with sensible defaults; a test overrides `loadMsgTombs` where it needs to. |
| 120 | function spies(over) { |
| 121 | const calls = { runTurn: [], msgTombstone: [] }; |
| 122 | const s = { |
| 123 | loadMsgTombs: () => ({}), |
| 124 | msgTombstone: (mids) => { calls.msgTombstone.push(mids); }, |
| 125 | touchChat: () => {}, |
| 126 | persistChats: () => {}, |
| 127 | renderHistory: () => {}, |
| 128 | runTurn: (chat, text) => { calls.runTurn.push({ chat, text }); }, |
| 129 | }; |
| 130 | Object.assign(s, over || {}); |
| 131 | return { stubs: s, calls }; |
| 132 | } |
| 133 | |
| 134 | /// A fresh interrupted turn: the user's prompt and a half-written assistant answer, both tagged |
| 135 | /// with the same `iturn`. |
| 136 | function freshChat(partialText) { |
| 137 | return { |
| 138 | _generating: false, |
| 139 | app: { marker: 1 }, |
| 140 | messages: [ |
| 141 | { role: 'user', iturn: 'T1', mid: 'u1', content: 'What is the capital of France?' }, |
| 142 | { role: 'assistant', iturn: 'T1', mid: 'a1', interrupted: true, why: 'offline', content: partialText }, |
| 143 | ], |
| 144 | }; |
| 145 | } |
| 146 | |
| 147 | // The nudge value the real statement defines, to compare dispatch against it. |
| 148 | const NUDGE = new Function(NUDGE_STMT + '\nreturn CONTINUE_NUDGE;')(); |
| 149 | |
| 150 | console.log('the nudge is a real continuation instruction, distinct from any prompt'); |
| 151 | check(typeof NUDGE === 'string' && NUDGE.length > 0, 'CONTINUE_NUDGE is a non-empty string'); |
| 152 | check(NUDGE !== 'What is the capital of France?', |
| 153 | 'and it is not the original prompt', JSON.stringify(NUDGE.slice(0, 40))); |
| 154 | |
| 155 | // ── A: something arrived → RESUME ──────────────────────────── |
| 156 | console.log('\nA turn with a partial resumes, carrying the partial'); |
| 157 | { |
| 158 | const { stubs, calls } = spies(); |
| 159 | const ct = makeContinueTurn(stubs); |
| 160 | const chat = freshChat('The capital of France is'); |
| 161 | ct(chat, 'T1', 'What is the capital of France?'); |
| 162 | |
| 163 | check(calls.runTurn.length === 1 && calls.runTurn[0].text === NUDGE, |
| 164 | 'runTurn is called with the CONTINUE nudge', |
| 165 | calls.runTurn.length ? JSON.stringify(String(calls.runTurn[0].text).slice(0, 40)) : 'not called'); |
| 166 | check(calls.runTurn.length === 1 && calls.runTurn[0].text !== 'What is the capital of France?', |
| 167 | 'and NOT with the original prompt — this is the whole fix'); |
| 168 | check(calls.msgTombstone.length === 0, |
| 169 | 'the partial is NOT tombstoned — it is being kept, not deleted from other devices'); |
| 170 | const asst = chat.messages.find((m) => m.mid === 'a1'); |
| 171 | check(!!asst && asst.content === 'The capital of France is', |
| 172 | 'the partial text is retained intact for the model to continue from', |
| 173 | asst ? JSON.stringify(asst.content) : 'gone'); |
| 174 | check(!!asst && !('interrupted' in asst) && !('why' in asst), |
| 175 | 'and its badge is cleared — it is now an ordinary answer'); |
| 176 | check(chat.app === null, |
| 177 | 'chat.app is nulled so ensureApp rebuilds the session ENDING with the partial'); |
| 178 | check(chat.messages.filter((m) => m.role === 'user').length === 1, |
| 179 | 'the original prompt stays in the thread exactly once'); |
| 180 | } |
| 181 | |
| 182 | // ── B: nothing arrived → RE-RUN (the one right re-run) ─────── |
| 183 | console.log('\na turn that died before the first token re-runs the prompt'); |
| 184 | { |
| 185 | const { stubs, calls } = spies(); |
| 186 | const ct = makeContinueTurn(stubs); |
| 187 | const chat = freshChat(''); // no token ever came back |
| 188 | ct(chat, 'T1', 'What is the capital of France?'); |
| 189 | |
| 190 | check(calls.runTurn.length === 1 && calls.runTurn[0].text === 'What is the capital of France?', |
| 191 | 'runTurn is called with the ORIGINAL prompt — nothing to continue from', |
| 192 | calls.runTurn.length ? JSON.stringify(String(calls.runTurn[0].text).slice(0, 40)) : 'not called'); |
| 193 | check(calls.runTurn.length === 1 && calls.runTurn[0].text !== NUDGE, |
| 194 | 'and NOT with the continue nudge — a model cannot continue an empty answer'); |
| 195 | check(calls.msgTombstone.length === 1, |
| 196 | 'the empty turn IS tombstoned, so the append-only merge cannot resurrect it beside the retry'); |
| 197 | check(chat.messages.filter((m) => m.iturn === 'T1').length === 0, |
| 198 | 'and its messages are dropped from this tab'); |
| 199 | } |
| 200 | |
| 201 | // ── C-F: the idempotency and safety guards ─────────────────── |
| 202 | console.log('\nthe guards that stop a double-run or a wipe'); |
| 203 | { |
| 204 | // C. Already tombstoned by another tab: do nothing. |
| 205 | const { stubs, calls } = spies({ loadMsgTombs: () => ({ u1: true, a1: true }) }); |
| 206 | const ct = makeContinueTurn(stubs); |
| 207 | ct(freshChat('The capital of France is'), 'T1', 'What is the capital of France?'); |
| 208 | check(calls.runTurn.length === 0 && calls.msgTombstone.length === 0, |
| 209 | 'C: an already-continued turn does nothing — cross-tab idempotence'); |
| 210 | } |
| 211 | { |
| 212 | // D. No turn id: the filter would match every message, so the guard must fire FIRST. |
| 213 | const { stubs, calls } = spies(); |
| 214 | const ct = makeContinueTurn(stubs); |
| 215 | ct(freshChat('The capital of France is'), undefined, 'What is the capital of France?'); |
| 216 | check(calls.runTurn.length === 0, |
| 217 | 'D: a missing iturn is refused — never a whole-transcript wipe'); |
| 218 | } |
| 219 | { |
| 220 | // E. A turn already running: withTurnLock's partner guard. |
| 221 | const { stubs, calls } = spies(); |
| 222 | const ct = makeContinueTurn(stubs); |
| 223 | const chat = freshChat('The capital of France is'); |
| 224 | chat._generating = true; |
| 225 | ct(chat, 'T1', 'What is the capital of France?'); |
| 226 | check(calls.runTurn.length === 0, |
| 227 | 'E: a chat mid-generation is not continued on top of itself'); |
| 228 | } |
| 229 | { |
| 230 | // F. No prompt at all: the `!text` guard. |
| 231 | const { stubs, calls } = spies(); |
| 232 | const ct = makeContinueTurn(stubs); |
| 233 | ct(freshChat('The capital of France is'), 'T1', ''); |
| 234 | check(calls.runTurn.length === 0, 'F: an empty prompt argument is refused'); |
| 235 | } |
| 236 | |
| 237 | // ── The count is pinned ────────────────────────────────────── |
| 238 | const EXPECTED = 20; |
| 239 | const ranBefore = ran; |
| 240 | check(ranBefore === EXPECTED, |
| 241 | `exactly ${EXPECTED} checks ran — a displaced case trips this`, |
| 242 | `ran ${ranBefore}`); |
| 243 | |
| 244 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 245 | process.exit(bad ? 1 : 0); |