if (!report.manualQuiet) {
if (report.ollamaReadyWhileBridgeWaits && models.length) {
diff --git a/docs/scripts/smoke-model-choice-sticks.js b/docs/scripts/smoke-model-choice-sticks.js
index 9ec63a0..b3e0e76 100644
--- a/docs/scripts/smoke-model-choice-sticks.js
+++ b/docs/scripts/smoke-model-choice-sticks.js
@@ -275,6 +275,7 @@ function connectHost() {
await flush();
assert.ok(/Your Bridge answered on 11500, but no mind is running behind it yet\./.test(h.innerHTML), 'honest words');
assert.ok(!/Nothing answered on/.test(h.innerHTML), 'no false "Nothing answered"');
+ assert.strictEqual((h.innerHTML.match(/data-flc-look-again/g) || []).length, 1, 'one Look again, not two (v0.1.1)');
b.FlConnect.unmount();
// and when the mind is there, it is saved as before
const st2 = store0({ fl_localPort_manual: '11500' });
diff --git a/docs/scripts/smoke-model-choice-survives-reload.js b/docs/scripts/smoke-model-choice-survives-reload.js
new file mode 100644
index 0000000..91f0cdb
--- /dev/null
+++ b/docs/scripts/smoke-model-choice-survives-reload.js
@@ -0,0 +1,255 @@
+#!/usr/bin/env node
+// v-model-choice-survives-reload-v0.1.1 (Hypha walk 5, items 7 and 10).
+// Boots the REAL docs/app.html in headless Chrome, with a mock Ollama (answered inside
+// Chrome through the DevTools Fetch domain, so a real Ollama on this computer is never
+// touched). It taps the second model in Connect, reloads, and checks that the chat POST
+// carries the model the person tapped. Also: the chosen model is gone at boot (plain
+// words, automatic fallback), and nothing chosen (the automatic pick is never written as 'user').
+// Zero dependencies: Chrome is driven over --remote-debugging-pipe.
+// Chrome: set CHROME_PATH, or it looks in the usual places. No Chrome -> SMOKE_SKIP (exit 0).
+'use strict';
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const assert = require('assert');
+const { spawn } = require('child_process');
+
+const DOCS = path.join(__dirname, '..');
+const APP = path.join(DOCS, 'app.html');
+const MODELS_BOTH = ['llama3.2:latest', 'qwen2.5:latest'];
+
+function findChrome() {
+ const c = [process.env.CHROME_PATH,
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'];
+ return c.find((p) => p && fs.existsSync(p)) || null;
+}
+
+// ---- tiny static server for docs/ (127.0.0.1 only; sw.js answered 404 so no worker caches) ----
+function serveDocs() {
+ const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' };
+ const srv = http.createServer((req, res) => {
+ let p = decodeURIComponent((req.url || '/').split('?')[0]);
+ if (p === '/' ) p = '/app.html';
+ if (p === '/sw.js' || p.endsWith('/sw.js')) { res.writeHead(404); return res.end(); }
+ if (p === '/blank.html') { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end('
b'); }
+ const f = path.normalize(path.join(DOCS, p));
+ if (!f.startsWith(DOCS) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'Content-Type': types[path.extname(f)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
+ fs.createReadStream(f).pipe(res);
+ });
+ return new Promise((ok) => srv.listen(0, '127.0.0.1', () => ok(srv)));
+}
+
+// ---- Chrome over the DevTools pipe ----
+function launch(chrome) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fl-mcsr-'));
+ const args = ['--headless=new', '--remote-debugging-pipe', '--user-data-dir=' + dir, '--no-first-run',
+ '--no-default-browser-check', '--disable-extensions', '--disable-background-networking', '--disable-sync',
+ '--mute-audio', '--window-size=1280,900', 'about:blank'];
+ if (process.platform === 'linux') args.unshift('--no-sandbox');
+ const cp = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
+ const out = cp.stdio[3], inp = cp.stdio[4];
+ let id = 0, buf = '';
+ const waits = new Map(), handlers = [];
+ inp.on('data', (d) => {
+ buf += d.toString('utf8');
+ let i;
+ while ((i = buf.indexOf('\0')) >= 0) {
+ const msg = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
+ if (msg.id && waits.has(msg.id)) { const w = waits.get(msg.id); waits.delete(msg.id); msg.error ? w.no(new Error(JSON.stringify(msg.error))) : w.ok(msg.result); }
+ else handlers.forEach((h) => h(msg));
+ }
+ });
+ function send(method, params, sessionId) {
+ const m = { id: ++id, method, params: params || {} };
+ if (sessionId) m.sessionId = sessionId;
+ out.write(JSON.stringify(m) + '\0');
+ return new Promise((ok, no) => waits.set(m.id, { ok, no }));
+ }
+ return { cp, dir, send, on: (h) => handlers.push(h) };
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// Static locks, run even without Chrome: no path saves an automatic first pick as the person's own
+function staticLocks() {
+ const app = fs.readFileSync(APP, 'utf8');
+ assert.ok(/v-model-choice-survives-reload-v0\.1\.1/.test(app), 'marker in app.html');
+ assert.ok(/function ensure\(\) \{ if \(!_ready\) init\(\); \}/.test(app), 'FLActiveModel reads the saved choice first');
+ assert.ok(!/FLActiveModel\.set\(models\[0\],[^)]*'user'\)/.test(app), 'no first-model pick saved as user');
+ assert.ok(/FLActiveModel\.honor\(data\.models, textModel\.name, 'ollama'\)/.test(app), 'the Cascade asks the guard');
+ const idx = fs.readFileSync(path.join(DOCS, '..', 'index.html'), 'utf8');
+ assert.strictEqual(idx, app, 'index.html identical to docs/app.html');
+}
+
+async function run() {
+ const chrome = findChrome();
+ if (!chrome) { staticLocks(); console.log('SMOKE_SKIP model choice survives reload v0.1.1 (no Chrome found; set CHROME_PATH)'); return; }
+ const srv = await serveDocs();
+ const ORIGIN = 'http://127.0.0.1:' + srv.address().port;
+ const br = launch(chrome);
+ const mock = { models: MODELS_BOTH.slice(), chats: [] };
+ let sid = null;
+
+ function ollamaAnswer(method, url, body, origin) {
+ const u = new URL(url);
+ const cors = [{ name: 'Access-Control-Allow-Origin', value: origin || ORIGIN }, { name: 'Vary', value: 'Origin' },
+ { name: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
+ { name: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
+ { name: 'Access-Control-Allow-Private-Network', value: 'true' }];
+ if (method === 'OPTIONS') return { code: 204, headers: cors, body: '' };
+ const j = (o) => ({ code: 200, headers: cors.concat([{ name: 'Content-Type', value: 'application/json' }]), body: JSON.stringify(o) });
+ const p = u.pathname;
+ if (p === '/api/tags') return j({ models: mock.models.map((n) => ({ name: n, model: n, size: 2e9, details: { family: n.split(/[.:]/)[0], parameter_size: '3B' } })) });
+ if (p === '/api/version') return j({ version: '0.12.0' });
+ if (p === '/api/ps') return j({ models: [] });
+ if (p === '/') return { code: 200, headers: cors, body: 'Ollama is running' };
+ let b = {}; try { b = JSON.parse(body || '{}'); } catch (e) {}
+ if (p === '/v1/chat/completions' || p === '/api/chat' || p === '/api/generate') {
+ mock.chats.push({ path: p, model: b.model });
+ const txt = 'MOCK reply from ' + b.model;
+ if (p === '/v1/chat/completions') {
+ if (b.stream) {
+ const c1 = { id: 'x', object: 'chat.completion.chunk', model: b.model, choices: [{ index: 0, delta: { role: 'assistant', content: txt }, finish_reason: null }] };
+ const c2 = { id: 'x', object: 'chat.completion.chunk', model: b.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
+ return { code: 200, headers: cors.concat([{ name: 'Content-Type', value: 'text/event-stream' }]), body: 'data: ' + JSON.stringify(c1) + '\n\ndata: ' + JSON.stringify(c2) + '\n\ndata: [DONE]\n\n' };
+ }
+ return j({ id: 'x', object: 'chat.completion', model: b.model, choices: [{ index: 0, message: { role: 'assistant', content: txt }, finish_reason: 'stop' }] });
+ }
+ if (b.stream !== false) {
+ const line = p === '/api/chat' ? { model: b.model, message: { role: 'assistant', content: txt }, done: false } : { model: b.model, response: txt, done: false };
+ return { code: 200, headers: cors.concat([{ name: 'Content-Type', value: 'application/x-ndjson' }]), body: JSON.stringify(line) + '\n' + JSON.stringify({ model: b.model, done: true }) + '\n' };
+ }
+ return j(p === '/api/chat' ? { model: b.model, message: { role: 'assistant', content: txt }, done: true } : { model: b.model, response: txt, done: true });
+ }
+ if (p === '/api/show') return j({ details: {} });
+ if (p === '/api/embed' || p