diff --git a/packages/user-intent-kit/bin/uik-daemon.js b/packages/user-intent-kit/bin/uik-daemon.js index 025cdc9..dbefdb1 100755 --- a/packages/user-intent-kit/bin/uik-daemon.js +++ b/packages/user-intent-kit/bin/uik-daemon.js @@ -57,6 +57,15 @@ * writers race and the row flickers, which is how * the MacBook "disappeared" (2026-08-29). * POLL_INTERVAL_MS default: 30000 + * INTENT_CHOOSER_URL optional local chooser base URL. OFF when unset. + * Publishes its catalog/state each device beat and polls + * owner-filed switch requests every 15s. POST selection + * happens only after a fresh local catalog check. + * INTENT_DEVICE_BRAIN_OF optional agent name for the reload warning. + * INTENT_SWITCH_STATE_FILE optional persistent claim/report file path. + * Defaults under ~/.local/state/user-intent-kit per + * user/device. Preserve it across restarts. Run exactly + * ONE chooser-enabled device publisher per device. */ import { hostname } from 'node:os'; diff --git a/packages/user-intent-kit/src/adapters/desktop.js b/packages/user-intent-kit/src/adapters/desktop.js index 08e3611..c37f717 100644 --- a/packages/user-intent-kit/src/adapters/desktop.js +++ b/packages/user-intent-kit/src/adapters/desktop.js @@ -9,6 +9,7 @@ import { collectHostTelemetry } from '../host-telemetry.js'; import { ServedModelProbe } from '../served-model.js'; import { ModelAvailabilityProbe } from '../model-availability.js'; import { StatePublisher } from '../state-publisher.js'; +import { ModelSwitcher } from '../model-switcher.js'; /** * Desktop Adapter - detects active window and context on macOS. @@ -28,6 +29,7 @@ export class DesktopAdapter { #availabilityProbe; #pollIntervalMs; #publisher; + #switcher; /** * @param {import('../client.js').IntentClient} client @@ -44,9 +46,10 @@ export class DesktopAdapter { */ constructor(client, { pollIntervalMs = 30000, machine, kind, model, modelProbe, availabilityProbe, - hostSources, + hostSources, modelSwitcher, chooserUrl, } = {}) { this.#client = client; + this.#switcher = modelSwitcher ?? new ModelSwitcher(client, { chooserUrl }); this.#machine = machine ?? client?.deviceId ?? undefined; this.#kind = kind; this.#model = model; @@ -126,7 +129,8 @@ export class DesktopAdapter { */ async publishState() { const state = this.#detectState(); - await this.#publisher.publish({ ...state, ttl_sec: 90 }); + const chooser = await this.#switcher.refresh(); + await this.#publisher.publish({ ...state, ...chooser, ttl_sec: 90 }); } /** @@ -139,10 +143,13 @@ export class DesktopAdapter { // Started AFTER the first publish and never awaited, so the machine // appears on the dashboard at once with whatever vitals it has, label or // no label. The first probe fills the label in for the next beat. - this.#modelProbe?.start(); + // Chooser mode has one model authority. Do not run the legacy generation + // probe alongside it: a load-on-demand probe can itself load a model. + if (!this.#switcher.enabled) this.#modelProbe?.start(); // Same contract, slower timer: the scan is disk-bound, so it never runs on // the heartbeat's path and the first beat goes out without waiting for it. - this.#availabilityProbe?.start(); + if (!this.#switcher.enabled) this.#availabilityProbe?.start(); + this.#switcher.start(); // this.#client.startHeartbeat() removed: the StatePublisher's own // refreshMs timer now re-sends the last state as the liveness beat, so a // second heartbeat mechanism only doubled the writes (c0ed66d, 18 Sep 2026). @@ -159,6 +166,7 @@ export class DesktopAdapter { } this.#modelProbe?.stop(); this.#availabilityProbe?.stop(); + this.#switcher?.stop(); } /** diff --git a/packages/user-intent-kit/src/client.js b/packages/user-intent-kit/src/client.js index 07e6da8..3bf867c 100644 --- a/packages/user-intent-kit/src/client.js +++ b/packages/user-intent-kit/src/client.js @@ -58,6 +58,16 @@ export class IntentClient { return this.#request('PATCH', `/intent/${this.#userId}/${this.#deviceId}`, fields); } + async getModelSwitch() { + if (!this.#deviceId) throw new Error('No deviceId configured'); + return this.#request('GET', `/intent/${encodeURIComponent(this.#userId)}/${encodeURIComponent(this.#deviceId)}/switch`); + } + + async reportModelSwitch(report) { + if (!this.#deviceId) throw new Error('No deviceId configured'); + return this.#request('PATCH', `/intent/${encodeURIComponent(this.#userId)}/${encodeURIComponent(this.#deviceId)}/switch`, report); + } + async setDevice(fields) { if (!this.#deviceId) throw new Error('No deviceId configured'); return this.#request('PUT', `/intent/${this.#userId}/${this.#deviceId}`, fields); diff --git a/packages/user-intent-kit/src/model-switcher.js b/packages/user-intent-kit/src/model-switcher.js new file mode 100644 index 0000000..1342f97 --- /dev/null +++ b/packages/user-intent-kit/src/model-switcher.js @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0 +import { mkdir, readFile, open, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +export const normalizeModelName = name => typeof name === 'string' + ? name.trim().split('/').pop().replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/, '') : ''; + +/** Optional local chooser bridge. Never sends the intent key to the chooser. + * Claims are durable BEFORE dispatch. An uncertain dispatch is reconciled, + * never replayed: exactly-once side effects cannot be promised by HTTP. + */ +export class ModelSwitcher { + #client; #url; #file; #brain; #fetch; #now; #timeout; #busy = false; + #records; #timer; #fields; #warn; #lastWarning = -Infinity; + constructor(client, { + chooserUrl = process.env.INTENT_CHOOSER_URL, + brainOf = process.env.INTENT_DEVICE_BRAIN_OF || '', + stateFile = process.env.INTENT_SWITCH_STATE_FILE || join(homedir(), '.local', 'state', 'user-intent-kit', + `switch-${encodeURIComponent(client.userId)}-${encodeURIComponent(client.deviceId)}.json`), + fetchImpl = fetch, now = Date.now, timeoutMs = 15 * 60 * 1000, warn = console.warn, + } = {}) { + this.#client = client; this.#brain = brainOf; this.#file = stateFile; + this.#fetch = fetchImpl; this.#now = now; this.#timeout = timeoutMs; + this.#warn = warn; + if (chooserUrl) { + const u = new URL(chooserUrl); + if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.search || u.hash) + throw new Error('Invalid INTENT_CHOOSER_URL'); + this.#url = u.href.replace(/\/+$/, ''); + } + this.#fields = this.#down(); + } + get enabled() { return !!this.#url; } + #down() { return { model_catalog: [], model: null, model_state: 'down', brain_of: this.#brain }; } + fields() { return this.enabled ? structuredClone(this.#fields) : {}; } + #warning(reason) { + const now = this.#now(); + if (now >= this.#lastWarning && now - this.#lastWarning < 300000) return; + this.#lastWarning = now; + // Fixed diagnostics only: never log URLs, keys, response bodies or model paths. + this.#warn(`uik model switcher: ${reason}`); + } + async #registry() { + try { return await this.#readRegistry(); } + catch (error) { this.#warning('local chooser unavailable or invalid; no new switch dispatched'); throw error; } + } + async #readRegistry() { + const response = await this.#fetch(`${this.#url}/api/models`, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) throw new Error('Chooser unavailable'); + const r = await response.json(); + if (!Array.isArray(r.models) || !['ready', 'loading', 'down'].includes(r.state)) + throw new Error('Invalid chooser registry'); + this.#fields = { + model_catalog: r.models.filter(m => m && typeof m.name === 'string' && m.name.trim()).map(m => ({ + name: m.name, size_gb: Number.isFinite(m.size_gb) ? m.size_gb : null, + params_label: typeof m.params_label === 'string' ? m.params_label : null, fits: m.fits === true, + })), + model: typeof r.running === 'string' ? r.running : null, + model_state: r.state, brain_of: this.#brain, + }; + return r; + } + async refresh() { + if (!this.enabled) return {}; + try { await this.#registry(); } catch { this.#fields = this.#down(); } + return this.fields(); + } + async #load() { + if (this.#records) return; + try { + const data = JSON.parse(await readFile(this.#file, 'utf8')); + if (data.version !== 1 || !data.records || typeof data.records !== 'object' || Array.isArray(data.records)) + throw new Error('Invalid switch state'); + const records = new Map(Object.entries(data.records)); + for (const record of records.values()) { + if (!record || typeof record.model !== 'string' || !Number.isFinite(record.started) || + !['claimed', 'dispatched', 'done', 'failed', 'expired'].includes(record.status)) + throw new Error('Invalid switch state record'); + } + this.#records = records; + } catch (error) { + if (error.code !== 'ENOENT') { + this.#warning('state file unreadable or corrupt; switching paused (preserve the file for inspection)'); + throw error; // corrupt/unreadable state fails closed + } + this.#records = new Map(); + } + } + async #save(id, record) { + // Do not update the in-memory claim until persistence succeeds. + const next = new Map(this.#records); next.set(id, record); + await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 }); + const tmp = `${this.#file}.${process.pid}.tmp`; + const file = await open(tmp, 'w', 0o600); + try { + await file.writeFile(JSON.stringify({ version: 1, records: Object.fromEntries(next) })); + await file.sync(); + } finally { await file.close(); } + await rename(tmp, this.#file); + const directory = await open(dirname(this.#file), 'r'); + try { await directory.sync(); } finally { await directory.close(); } + this.#records = next; + } + async #finish(id, record, status, detail) { + const terminal = { ...record, status, detail }; + await this.#save(id, terminal); // retain report even if PATCH is unavailable + await this.#client.reportModelSwitch({ id, status, ...(detail === undefined ? {} : { detail }) }); + } + async poll() { + if (!this.enabled || this.#busy) return; + this.#busy = true; + try { + await this.#load(); + const { request: req } = await this.#client.getModelSwitch(); + if (!req || typeof req.id !== 'string' || !req.id || typeof req.model !== 'string') return; + let record = this.#records.get(req.id); + if (['done', 'failed'].includes(req.status) || (req.status === 'expired' && record?.status === 'expired')) return; + if (record && ['done', 'failed', 'expired'].includes(record.status)) { + await this.#client.reportModelSwitch({ id: req.id, status: record.status, detail: record.detail }); + return; + } + if (!record) { + if (!['pending', 'expired'].includes(req.status)) return; // never adopt an unknown running command + const filed = Date.parse(req.requested_at); + record = { model: req.model, started: this.#now(), status: 'claimed' }; + if (req.status === 'expired' || !Number.isFinite(filed) || this.#now() - filed > 600000 || filed > this.#now() + 60000) + return await this.#finish(req.id, record, 'expired', 'Request expired'); + const registry = await this.#registry(); // current LOCAL catalog, not the server's list + const matches = registry.models.filter(m => normalizeModelName(m?.name) === normalizeModelName(req.model)); + const pick = matches.length === 1 ? matches[0] : null; + if (!pick || pick.fits !== true) + return await this.#finish(req.id, record, 'failed', 'Model is not in the local fitting catalog'); + if (registry.state === 'ready' && normalizeModelName(registry.running) === normalizeModelName(pick.name)) + return await this.#finish(req.id, record, 'done', 'already running'); + await this.#save(req.id, record); + // If this PATCH fails/has an uncertain result, no local POST is made. + await this.#client.reportModelSwitch({ id: req.id, status: 'running' }); + await this.#save(req.id, { ...record, status: 'dispatched' }); + let response; + try { + response = await this.#fetch(`${this.#url}/api/model`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: pick.name }), signal: AbortSignal.timeout(70000), + }); + } catch { + this.#warning('chooser dispatch response uncertain; reconciling without replay'); + return; // reconcile later; NEVER retry an uncertain POST + } + const body = await response.text(); + if (!response.ok) return await this.#finish(req.id, record, 'failed', body); + let result; + try { result = JSON.parse(body); } catch { return; } + if (result.ok !== true) return await this.#finish(req.id, record, 'failed', body); + return; // readiness checked on the independent next 15s poll + } + if (req.status === 'pending') + return await this.#finish(req.id, record, 'failed', 'Interrupted before dispatch; not replayed'); + if (req.status !== 'running') return; + let registry; + try { registry = await this.#registry(); } catch { /* transient outage until deadline */ } + if (registry?.state === 'ready' && normalizeModelName(registry.running) === normalizeModelName(record.model)) + return await this.#finish(req.id, record, 'done'); + if (this.#now() - record.started >= this.#timeout) + await this.#finish(req.id, record, 'failed', 'Timed out waiting for the requested model to be ready; not replayed'); + } catch (error) { + this.#warning('poll/report or state persistence failed; switching fails closed without dispatch replay'); + throw error; + } finally { this.#busy = false; } + } + start() { + this.stop(); + if (!this.enabled) return; + this.poll().catch(() => {}); + this.#timer = setInterval(() => this.poll().catch(() => {}), 15000); + this.#timer.unref?.(); + } + stop() { if (this.#timer) clearInterval(this.#timer); this.#timer = null; } +} diff --git a/packages/user-intent-kit/test/model-switcher.test.js b/packages/user-intent-kit/test/model-switcher.test.js new file mode 100644 index 0000000..b5c8085 --- /dev/null +++ b/packages/user-intent-kit/test/model-switcher.test.js @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ModelSwitcher, normalizeModelName } from '../src/model-switcher.js'; +import { IntentClient } from '../src/client.js'; +import { DesktopAdapter } from '../src/adapters/desktop.js'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'uik-switch-test-')); + const f = { + now: Date.parse('2026-01-01T00:00:00Z'), posts: [], reports: [], reads: 0, + registry: { models: [{ name: 'next', fits: true, size_gb: 12, path: '/private/model', params_label: '20B' }], running: 'old', state: 'ready' }, + request: null, postStatus: 200, postBody: '{"ok":true}', failPatch: null, + }; + const chooser = createServer(async (req, res) => { + assert.equal(req.headers['x-api-key'], undefined); + assert.equal(req.headers.authorization, undefined); + if (req.url === '/api/models') { f.reads++; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(f.registry)); return; } + let body = ''; for await (const chunk of req) body += chunk; + f.posts.push(JSON.parse(body)); + assert.equal(req.url, '/api/model'); + res.writeHead(f.postStatus); res.end(f.postBody); + }); + const intent = createServer(async (req, res) => { + assert.equal(req.headers['x-api-key'], 'fake-test-key'); + assert.equal(req.url, '/api/v1/intent/test-user/test-box/switch'); + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET') { res.end(JSON.stringify({ request: f.request })); return; } + let body = ''; for await (const chunk of req) body += chunk; + const report = JSON.parse(body); f.reports.push(report); + if (f.failPatch === report.status) { res.writeHead(503); res.end('{}'); return; } + assert.equal(report.id, f.request.id); + f.request = { ...f.request, ...report }; + res.end(JSON.stringify({ request: f.request })); + }); + await Promise.all([new Promise(r => chooser.listen(0, '127.0.0.1', r)), new Promise(r => intent.listen(0, '127.0.0.1', r))]); + t.after(async () => { + chooser.closeAllConnections(); intent.closeAllConnections(); + await Promise.all([new Promise(r => chooser.close(r)), new Promise(r => intent.close(r))]); + await rm(dir, { recursive: true, force: true }); + }); + f.client = new IntentClient({ baseUrl: `http://127.0.0.1:${intent.address().port}/api/v1`, apiKey: 'fake-test-key', userId: 'test-user', deviceId: 'test-box' }); + f.warnings = []; + f.options = { chooserUrl: `http://127.0.0.1:${chooser.address().port}`, stateFile: join(dir, 'state.json'), brainOf: 'test-brain', now: () => f.now, warn: message => f.warnings.push(message) }; + f.newSwitcher = () => new ModelSwitcher(f.client, f.options); + f.pending = (extra = {}) => { f.request = { id: 'request-1', model: 'next', requested_at: new Date(f.now).toISOString(), status: 'pending', ...extra }; }; + return f; +} + +test('disabled is network-free and publishes no chooser fields', async () => { + const s = new ModelSwitcher({}, { chooserUrl: '', fetchImpl: () => { throw new Error('network'); } }); + assert.deepEqual(await s.refresh(), {}); await s.poll(); s.start(); s.stop(); +}); + +test('catalog fields are explicit, sanitized, and cleared on outage', async t => { + const f = await fixture(t), s = f.newSwitcher(); + assert.deepEqual(await s.refresh(), { model_catalog: [{ name: 'next', fits: true, size_gb: 12, params_label: '20B' }], model: 'old', model_state: 'ready', brain_of: 'test-brain' }); + f.registry = { models: 'invalid', state: 'ready' }; + assert.deepEqual(await s.refresh(), { model_catalog: [], model: null, model_state: 'down', brain_of: 'test-brain' }); +}); + +test('real fake HTTP services: dispatch once, wait for ready AND matching model, then done', async t => { + const f = await fixture(t), s = f.newSwitcher(); f.pending(); + await s.poll(); assert.deepEqual(f.posts, [{ name: 'next' }]); + assert.equal(f.request.status, 'running'); + f.registry.state = 'loading'; f.registry.running = 'next'; await s.poll(); + assert.equal(f.request.status, 'running'); + f.registry.state = 'ready'; f.registry.running = 'old'; await s.poll(); + assert.equal(f.request.status, 'running'); + f.registry.running = '/models/next-00001-of-00002.gguf'; await s.poll(); + assert.equal(f.request.status, 'done'); await s.poll(); + assert.equal(f.posts.length, 1); +}); + +for (const [label, extra, status] of [ + ['unknown local model', { model: 'server-only' }, 'failed'], + ['old pending', { requested_at: '2025-12-31T23:49:59Z' }, 'expired'], + ['invalid timestamp', { requested_at: 'garbage' }, 'expired'], + ['server-expired', { status: 'expired' }, 'expired'], +]) test(`rejects ${label} without chooser POST`, async t => { + const f = await fixture(t); f.pending(extra); await f.newSwitcher().poll(); + assert.equal(f.request.status, status); assert.equal(f.posts.length, 0); +}); + +test('local fits must be true, even if web offered it', async t => { + const f = await fixture(t); f.registry.models[0].fits = false; f.pending(); + await f.newSwitcher().poll(); assert.equal(f.request.status, 'failed'); assert.equal(f.posts.length, 0); +}); + +test('409 chooser body is relayed unchanged', async t => { + const f = await fixture(t); f.pending(); f.postStatus = 409; f.postBody = '{"ok":false,"error":"busy — wait"}'; + await f.newSwitcher().poll(); assert.equal(f.request.status, 'failed'); assert.equal(f.request.detail, f.postBody); +}); + +test('restart with persisted dispatch reconciles without POST replay', async t => { + const f = await fixture(t); f.pending(); await f.newSwitcher().poll(); + const s = f.newSwitcher(); await s.poll(); assert.equal(f.posts.length, 1); + f.registry.running = 'next'; await s.poll(); assert.equal(f.request.status, 'done'); +}); + +test('failed running PATCH prevents dispatch, restart does not dispatch pending claim', async t => { + const f = await fixture(t); f.pending(); f.failPatch = 'running'; + await assert.rejects(f.newSwitcher().poll()); assert.equal(f.posts.length, 0); + f.failPatch = null; await f.newSwitcher().poll(); + assert.equal(f.request.status, 'failed'); assert.equal(f.posts.length, 0); +}); + +test('terminal report failure retries the report, not the selection', async t => { + const f = await fixture(t); f.pending(); const s = f.newSwitcher(); await s.poll(); + f.registry.running = 'next'; f.failPatch = 'done'; await assert.rejects(s.poll()); + f.failPatch = null; await f.newSwitcher().poll(); + assert.equal(f.request.status, 'done'); assert.equal(f.posts.length, 1); +}); + +test('timeout closes request, even with a down chooser', async t => { + const f = await fixture(t); f.pending(); const s = f.newSwitcher(); await s.poll(); + f.now += 900000; f.registry = {}; await s.poll(); + assert.equal(f.request.status, 'failed'); assert.match(f.request.detail, /Timed out/); assert.equal(f.posts.length, 1); +}); + +test('unreadable/corrupt state fails closed; overlapping polls dispatch once', async t => { + const f = await fixture(t); f.pending(); + await writeFile(f.options.stateFile, 'broken'); + await assert.rejects(f.newSwitcher().poll()); assert.equal(f.posts.length, 0); + await rm(f.options.stateFile); const s = f.newSwitcher(); + await Promise.all([s.poll(), s.poll(), s.poll()]); assert.equal(f.posts.length, 1); + const state = JSON.parse(await readFile(f.options.stateFile, 'utf8')); + assert.equal(state.records['request-1'].status, 'dispatched'); +}); + +test('unknown running request is not adopted or dispatched', async t => { + const f = await fixture(t); f.pending({ status: 'running' }); await f.newSwitcher().poll(); + assert.equal(f.posts.length, 0); assert.equal(f.reports.length, 0); +}); + +test('normalization strips only basename, gguf and numeric shard suffix', () => { + assert.equal(normalizeModelName('/models/My-Model-00001-of-00003.gguf'), 'My-Model'); + assert.equal(normalizeModelName('My-Model'), 'My-Model'); +}); + +test('already ready model completes without chooser POST', async t => { + const f = await fixture(t); f.pending(); f.registry.running = '/models/next-00001-of-00003.gguf'; + await f.newSwitcher().poll(); + assert.equal(f.request.status, 'done'); assert.equal(f.request.detail, 'already running'); + assert.equal(f.posts.length, 0); +}); + +test('normalized catalog match dispatches the LOCAL canonical name', async t => { + const f = await fixture(t); f.pending({ model: '/models/next-00001-of-00003.gguf' }); + await f.newSwitcher().poll(); assert.deepEqual(f.posts, [{ name: 'next' }]); +}); + +test('ambiguous normalized names fail closed', async t => { + const f = await fixture(t); f.pending(); f.registry.models.push({ name: 'next.gguf', fits: true }); + await f.newSwitcher().poll(); assert.equal(f.request.status, 'failed'); assert.equal(f.posts.length, 0); +}); + +test('warnings rate-limit corrupt state and dead chooser, without raw error content', async t => { + const f = await fixture(t); f.pending(); const s = f.newSwitcher(); + await writeFile(f.options.stateFile, 'secret-example-bad-json'); + await assert.rejects(s.poll()); await assert.rejects(s.poll()); + assert.equal(f.warnings.length, 1); assert.match(f.warnings[0], /state file unreadable or corrupt/); + assert.ok(!f.warnings[0].includes('secret-example')); + f.now += 300000; await assert.rejects(s.poll()); assert.equal(f.warnings.length, 2); + const other = f.newSwitcher(); f.registry = {}; + await other.refresh(); await other.refresh(); + assert.equal(f.warnings.length, 3); assert.match(f.warnings[2], /local chooser unavailable/); +}); + +test('uncertain POST response reconciles on restart without replay', async t => { + const f = await fixture(t); f.pending(); + const s = new ModelSwitcher(f.client, { ...f.options, fetchImpl: async (url, opts) => { + const result = await fetch(url, opts); + if (opts.method === 'POST') throw new Error('response lost after side effect'); + return result; + } }); + await s.poll(); assert.equal(f.posts.length, 1); + f.registry.running = 'next'; await f.newSwitcher().poll(); + assert.equal(f.request.status, 'done'); assert.equal(f.posts.length, 1); +}); + +test('desktop chooser fields override stale static/probe labels and uses no generation probe', async t => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const sent = []; let generationStarts = 0, switchStarts = 0; + let fields = { model_catalog: [{ name: 'next' }], model: 'next', model_state: 'ready', brain_of: 'brain' }; + const switcher = { enabled: true, refresh: async () => fields, start() { switchStarts++; }, stop() {} }; + const adapter = new DesktopAdapter({ patchDevice: async x => sent.push(x) }, { + modelSwitcher: switcher, model: 'stale-static', availabilityProbe: null, + modelProbe: { start() { generationStarts++; }, stop() {}, lastResult() { return { model: 'stale-probe' }; } }, + }); + adapter.start(); await new Promise(r => setImmediate(r)); + assert.equal(generationStarts, 0); assert.equal(switchStarts, 1); + assert.equal(sent[0].model, 'next'); + fields = { model_catalog: [], model: null, model_state: 'down', brain_of: '' }; + t.mock.timers.tick(30000); await new Promise(r => setImmediate(r)); + adapter.stop(); assert.equal(sent.length, 2); + for (const key of ['model_catalog', 'model', 'model_state', 'brain_of']) assert.deepEqual(sent[1][key], fields[key]); +});