diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.runtime.test.ts new file mode 100644 index 0000000000..12e04aef2d --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.runtime.test.ts @@ -0,0 +1,100 @@ +import type * as HermesSdk from '@hermes/plugin-sdk' +import { atom } from 'nanostores' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const gatewayState = atom<'closed' | 'open'>('closed') + +const listPersistedSessions = vi.fn( + async (_route: unknown, options: { profile: string }) => ({ + sessions: [{ id: `${options.profile}-bot`, profile: options.profile, started_at: 1, title: 'Bot Chat' }] + }) +) + +const setPersistedSessionHidden = vi.fn( + async (_route: unknown, _options: { hidden: boolean; profile: string; sessionId: string }) => ({ hidden: true, ok: true }) +) + +const request = vi.fn(async (method: string) => + method === 'profiles.list' ? { profiles: [{ name: 'alpha' }, { name: 'beta' }] } : {} +) + +vi.mock('@hermes/plugin-sdk', async importOriginal => { + const sdk = await importOriginal() + + return { + ...sdk, + host: { + ...sdk.host, + listPersistedSessions, + onEvent: vi.fn(() => () => undefined), + profileRoutes: vi.fn(async () => []), + request, + setPersistedSessionHidden, + state: { + ...sdk.host.state, + gateway: gatewayState, + profile: atom('default') + } + } + } +}) + +const { createPluginContext } = await import('@/contrib/plugin') +// @ts-expect-error The bundled Bot Mode entry intentionally remains plain ESM JavaScript for older runtime loaders. +const { default: plugin } = await import('../plugin.js') + +const flushSweep = async () => { + await vi.advanceTimersByTimeAsync(0) + await Promise.resolve() +} + +afterEach(() => { + gatewayState.set('closed') + vi.clearAllMocks() + vi.useRealTimers() +}) + +describe('Bot Mode hidden-session reconciliation lifecycle', () => { + it('uses persisted REST on load/reconnect and stops with plugin disposal', async () => { + vi.useFakeTimers() + const disposers: Array<() => void> = [] + + plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) + + gatewayState.set('open') + await flushSweep() + gatewayState.set('closed') + gatewayState.set('open') + await flushSweep() + gatewayState.set('closed') + gatewayState.set('open') + await flushSweep() + + expect(listPersistedSessions).toHaveBeenCalledTimes(6) + expect(listPersistedSessions.mock.calls.map(([, options]) => options.profile)).toEqual([ + 'alpha', + 'beta', + 'alpha', + 'beta', + 'alpha', + 'beta' + ]) + expect(setPersistedSessionHidden).toHaveBeenCalledTimes(6) + expect(setPersistedSessionHidden.mock.calls.map(([, options]) => options)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ hidden: true, profile: 'alpha', sessionId: 'alpha-bot' }), + expect.objectContaining({ hidden: true, profile: 'beta', sessionId: 'beta-bot' }) + ]) + ) + expect(request.mock.calls.some(([method]) => method === 'session.list' || method === 'session.set_hidden')).toBe(false) + + disposers.forEach(dispose => dispose()) + const readsAtDispose = listPersistedSessions.mock.calls.length + + gatewayState.set('closed') + gatewayState.set('open') + await flushSweep() + + expect(listPersistedSessions).toHaveBeenCalledTimes(readsAtDispose) + }) +}) \ No newline at end of file diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index a845550f2a..9a3aca0d2f 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -298,123 +298,6 @@ test('sweepBotProfileSessions hides Bot-Mode-titled rows per roster bot, and onl assert.equal(hidden.find(c => c.params.sessionId === 'r-1').route.connectionId, 'mini') }) -test('load reconciliation uses persisted REST doors and never wakes profile gateways', async () => { - const start = source.indexOf('function hideOwnedBotSessions()') - const end = source.indexOf('/** Fetch server-side avatars', start) - const reads = [] - const writes = [] - const context = { - host: { - request: async () => ({}), - listPersistedSessions: async (route, options) => { - reads.push({ route, options }) - return { sessions: [{ id: `${options.profile}-bot`, profile: options.profile, title: 'Bot Chat', started_at: 1 }] } - }, - setPersistedSessionHidden: async (route, options) => writes.push({ route, options }) - }, - $groupChats: { get: () => ({}) }, - $lastRoster: { - get: () => [ - { - name: 'alpha', - sourceScoped: true, - route: { connectionId: 'local', mode: 'local', profile: 'alpha', targetProfile: 'alpha' } - }, - { - name: 'remy', - sourceScoped: true, - route: { connectionId: 'mini', mode: 'remote', profile: 'remy', targetProfile: 'worker' } - } - ] - }, - PROFILE_SESSION_LIST_LIMIT: 200, - backendTargetProfile: (route, fallback) => route?.targetProfile || fallback, - botConnectionRoute: bot => bot.route || null, - groupMemberKey: member => member.name, - requestForBot: async () => { - throw new Error('reconciliation must not use gateway RPC') - } - } - const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions, sweepBotProfileSessions };\n') - vm.runInNewContext(section, context, { filename: 'persisted-sweep.js' }) - - await context.__h.hideOwnedBotSessions() - - assert.deepEqual(reads.map(call => call.options.profile).sort(), ['alpha', 'worker']) - assert.deepEqual(writes.map(call => call.options.sessionId).sort(), ['alpha-bot', 'worker-bot']) - assert.ok(writes.every(call => call.options.hidden === true)) -}) - -test('hide-sweep scheduling coalesces reconnects, queues one trailing run, and cancels on dispose', async () => { - const start = source.indexOf('function startHideSweepScheduler(') - const end = source.indexOf('function hideOwnedBotSessions()', start) - const timers = new Map() - let nextTimer = 1 - let gatewayListener = null - let dispose = null - let stopped = 0 - let resolveFirst - let sweeps = 0 - const context = { - host: { - state: { - gateway: { - listen: listener => { - gatewayListener = listener - return () => { - stopped += 1 - } - } - } - } - }, - hideOwnedBotSessions: () => { - sweeps += 1 - return sweeps === 1 ? new Promise(resolve => (resolveFirst = resolve)) : Promise.resolve() - }, - setTimeout: callback => { - const id = nextTimer++ - timers.set(id, callback) - return id - }, - clearTimeout: id => timers.delete(id) - } - const ctx = { onDispose: callback => (dispose = callback) } - const runNextTimer = () => { - const [id, callback] = timers.entries().next().value - timers.delete(id) - callback() - } - const section = source.slice(start, end).concat('\nglobalThis.__scheduler = startHideSweepScheduler;\n') - vm.runInNewContext(section, context, { filename: 'hide-scheduler.js' }) - - context.__scheduler(ctx) - gatewayListener('open') - gatewayListener('open') - assert.equal(timers.size, 1, 'load plus duplicate open events coalesce before execution') - - runNextTimer() - await Promise.resolve() - assert.equal(sweeps, 1) - - gatewayListener('open') - runNextTimer() - assert.equal(sweeps, 1, 'an inflight sweep is not overlapped') - - resolveFirst() - await new Promise(resolve => setImmediate(resolve)) - assert.equal(timers.size, 1, 'one trailing sweep preserves the reconnect signal') - runNextTimer() - await Promise.resolve() - assert.equal(sweeps, 2) - - gatewayListener('open') - assert.equal(timers.size, 1) - dispose() - assert.equal(timers.size, 0) - assert.equal(stopped, 1) -}) - test('hideOwnedBotSessions chains the ownership sweep and survives its absence of context', async () => { // The load/reconnect entrypoint runs BOTH halves: known ids first, then // the roster-wide title sweep (best-effort — a throwing sweep never