import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const secondaryGateways: Array<{ close: ReturnType connect: ReturnType connectionState: string request: ReturnType }> = [] let connectGate: Promise | null = null vi.mock('@/hermes', () => ({ HermesGateway: class { connectionState = 'closed' connect = vi.fn(async () => { if (this.connectionState === 'connecting') { return } this.connectionState = 'connecting' if (connectGate) { await connectGate } this.connectionState = 'open' }) request = vi.fn(async (method: string, params: Record) => { if (this.connectionState !== 'open') { throw new Error('gateway is not connected') } return { method, params } }) close = vi.fn() onEvent = vi.fn(() => () => {}) onState = vi.fn(() => () => {}) constructor() { secondaryGateways.push(this) } }, setApiRequestConnection: vi.fn() })) vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() })) vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() })) const { $gateway, closeSecondaryGateways, configureGatewayRegistry, ensureGatewayForAgent, ensureGatewayForProfile, gatewayActivationEpoch, disposeSecondariesForConnection, openGatewayForAgent, pruneSecondaryGateways, requestGatewayForAgent, requestGatewayForProfile, retainGatewayForAgent, setPrimaryGateway, setPrimaryGatewayConnection } = await import('./gateway') function installDesktop(getConnection: ReturnType): void { ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection, touchBackend: vi.fn(async () => undefined) } } function makePrimary() { return { connectionState: 'open', request: vi.fn(async (method: string, params: Record) => ({ method, params })) } } beforeEach(async () => { secondaryGateways.length = 0 connectGate = null configureGatewayRegistry({ onEvent: vi.fn() }) closeSecondaryGateways() }) afterEach(() => { closeSecondaryGateways() vi.clearAllMocks() delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) describe('requestGatewayForProfile', () => { it('requests through a pooled profile gateway without changing the active gateway', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop( vi.fn(async (profile: null | string) => profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' } ) ) await ensureGatewayForProfile('default') const result = await requestGatewayForProfile('worker', 'profiles.list', { include_sessions: true }) expect(result).toEqual({ method: 'profiles.list', params: { include_sessions: true } }) expect(secondaryGateways).toHaveLength(1) expect(secondaryGateways[0].request).toHaveBeenCalledWith('profiles.list', { include_sessions: true }) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() expect($gateway.get()).toBe(primary) }) it('uses the primary socket and adds profile scope for a shared global remote route', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop( vi.fn(async (profile: null | string) => ({ port: 4242, ...(profile ? { profile, sharedPrimary: true } : {}), token: 'primary-token' })) ) await ensureGatewayForProfile('default') const result = await requestGatewayForProfile('venture', 'session.list', { limit: 20, profile: 'wrong' }) expect(result).toEqual({ method: 'session.list', params: { limit: 20, profile: 'venture' } }) expect(primary.request).toHaveBeenCalledWith('session.list', { limit: 20, profile: 'venture' }) expect(secondaryGateways).toHaveLength(0) expect($gateway.get()).toBe(primary) }) it('serializes concurrent requests while a secondary gateway is connecting', async () => { let releaseConnect: () => void = () => undefined connectGate = new Promise(resolve => { releaseConnect = resolve }) const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop( vi.fn(async (profile: null | string) => profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' } ) ) await ensureGatewayForProfile('default') const first = requestGatewayForProfile('worker', 'profiles.list') await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce()) const second = requestGatewayForProfile('worker', 'profiles.list') const guardedSecond = second.catch(error => error) await Promise.resolve() releaseConnect() const [firstResult, secondResult] = await Promise.all([first, guardedSecond]) expect(firstResult).toEqual({ method: 'profiles.list', params: {} }) expect(secondResult).toEqual({ method: 'profiles.list', params: {} }) expect(secondaryGateways[0].connect).toHaveBeenCalledOnce() expect(secondaryGateways[0].request).toHaveBeenCalledTimes(2) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() expect($gateway.get()).toBe(primary) }) it('prevents pruning while a background request is connecting or in flight', async () => { let releaseConnect: () => void = () => undefined connectGate = new Promise(resolve => { releaseConnect = resolve }) const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop( vi.fn(async (profile: null | string) => profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' } ) ) await ensureGatewayForProfile('default') const request = requestGatewayForProfile('worker', 'profiles.list') await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce()) pruneSecondaryGateways(new Set()) releaseConnect() await expect(request).resolves.toEqual({ method: 'profiles.list', params: {} }) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() pruneSecondaryGateways(new Set()) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) }) describe('requestGatewayForAgent', () => { it('reuses the active primary socket when its registry connection owns the session', async () => { const primary = makePrimary() const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile, token: 'secondary-token' })) setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'remote-primary' }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(), getConnectionFor, getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') await openGatewayForAgent('remote-primary', 'default') await ensureGatewayForAgent('remote-primary', 'default') const result = await requestGatewayForAgent('remote-primary', 'default', 'session.resume', { session_id: 'stored-session' }) expect(result).toEqual({ method: 'session.resume', params: { session_id: 'stored-session' } }) expect(primary.request).toHaveBeenCalledWith('session.resume', { session_id: 'stored-session' }) expect(getConnectionFor).not.toHaveBeenCalled() expect(secondaryGateways).toHaveLength(0) expect($gateway.get()).toBe(primary) }) it('keeps another profile on the same registry source isolated from the primary', async () => { const primary = makePrimary() const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile, token: 'secondary-token' })) setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'remote-primary' }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(), getConnectionFor, getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })), touchBackend: vi.fn(async () => undefined) } await requestGatewayForAgent('remote-primary', 'research', 'session.resume', { session_id: 'research-session' }) expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'remote-primary', profile: 'research' }) expect(primary.request).not.toHaveBeenCalled() expect(secondaryGateways).toHaveLength(1) }) it('leases separate registry sockets for duplicate profile names without changing the active gateway', async () => { const primary = makePrimary() const getConnection = vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 'legacy-token' })) const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: connectionId === 'source-a' ? 5151 : 5252, profile, token: `${connectionId}-token` })) const getGatewayWsUrlFor = vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })) setPrimaryGateway(primary as never, 'default') ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection, getConnectionFor, getGatewayWsUrlFor, touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') const [fromA, fromB] = await Promise.all([ requestGatewayForAgent('source-a', 'research', 'session.list', { limit: 1 }), requestGatewayForAgent('source-b', 'research', 'session.list', { limit: 2 }) ]) expect(fromA).toEqual({ method: 'session.list', params: { limit: 1 } }) expect(fromB).toEqual({ method: 'session.list', params: { limit: 2 } }) expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' }) expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' }) expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' }) expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' }) expect(getConnection).not.toHaveBeenCalled() expect(secondaryGateways).toHaveLength(2) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() expect(secondaryGateways[1].close).toHaveBeenCalledOnce() expect($gateway.get()).toBe(primary) }) it('routes an explicit local registry descriptor through getConnectionFor', async () => { const primary = makePrimary() const getConnection = vi.fn(async (profile: null | string) => ({ mode: 'remote', profile, wsUrl: 'wss://legacy-remote.invalid/api/ws?token=legacy' })) const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, mode: 'local', port: 5151, profile })) setPrimaryGateway(primary as never, 'default') ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection, getConnectionFor, getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') await expect(requestGatewayForAgent('local', 'worker', 'profiles.list')).resolves.toEqual({ method: 'profiles.list', params: {} }) expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'worker' }) expect(getConnection).not.toHaveBeenCalled() expect($gateway.get()).toBe(primary) }) it('evicts registry sockets when their source is edited or removed', async () => { const primary = makePrimary() const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })) const onActiveConnectionInvalidated = vi.fn() setPrimaryGateway(primary as never, 'default') configureGatewayRegistry({ onActiveConnectionInvalidated, onEvent: vi.fn() }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(), getConnectionFor, getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await openGatewayForAgent('source-a', 'research') await requestGatewayForAgent('source-a', 'research', 'session.list') disposeSecondariesForConnection('source-a') expect(secondaryGateways[0].close).toHaveBeenCalledOnce() expect(onActiveConnectionInvalidated).not.toHaveBeenCalled() await requestGatewayForAgent('source-a', 'research', 'session.list') expect(secondaryGateways).toHaveLength(2) expect(getConnectionFor).toHaveBeenCalledTimes(2) }) it('invalidates an active pinned source with its window profile and a route epoch', async () => { const primary = makePrimary() const onActiveConnectionChanged = vi.fn() const onActiveConnectionInvalidated = vi.fn() setPrimaryGateway(primary as never, 'pinned') configureGatewayRegistry({ onActiveConnectionChanged, onActiveConnectionInvalidated, onEvent: vi.fn() }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(), getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForAgent('source-a', 'pinned') disposeSecondariesForConnection('source-a') const invalidationEpoch = gatewayActivationEpoch() expect(onActiveConnectionInvalidated).toHaveBeenCalledWith('pinned', invalidationEpoch) expect($gateway.get()).toBe(primary) // Same profile, different source advances the guard as soon as activation // starts, before its deferred socket connect can finish. let releaseConnect: () => void = () => undefined connectGate = new Promise(resolve => { releaseConnect = resolve }) const sourceBActivation = ensureGatewayForAgent('source-b', 'pinned') await vi.waitFor(() => expect(secondaryGateways[1]?.connect).toHaveBeenCalledOnce()) expect(gatewayActivationEpoch()).toBeGreaterThan(invalidationEpoch) releaseConnect() await sourceBActivation expect(onActiveConnectionChanged).toHaveBeenLastCalledWith(expect.objectContaining({ connectionId: 'source-b' })) }) it('does not activate or publish a source invalidated while its dial is pending', async () => { const primary = makePrimary() const onActiveConnectionChanged = vi.fn() setPrimaryGateway(primary as never, 'default') configureGatewayRegistry({ onActiveConnectionChanged, onEvent: vi.fn() }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async profile => ({ port: 4242, profile })), getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') let releaseConnect: () => void = () => undefined connectGate = new Promise(resolve => { releaseConnect = resolve }) const pendingActivation = ensureGatewayForAgent('source-b', 'default') await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce()) disposeSecondariesForConnection('source-b') releaseConnect() await pendingActivation expect(secondaryGateways[0].close).toHaveBeenCalled() expect(onActiveConnectionChanged).not.toHaveBeenCalled() expect($gateway.get()).toBe(primary) }) it('does not activate or publish a source whose activation owner aborted while dialing', async () => { const primary = makePrimary() const onActiveConnectionChanged = vi.fn() const controller = new AbortController() setPrimaryGateway(primary as never, 'default') configureGatewayRegistry({ onActiveConnectionChanged, onEvent: vi.fn() }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async profile => ({ port: 4242, profile })), getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') let releaseConnect: () => void = () => undefined connectGate = new Promise(resolve => { releaseConnect = resolve }) const abandoned = ensureGatewayForAgent('source-b', 'default', { signal: controller.signal }) await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce()) controller.abort() releaseConnect() expect(await abandoned).toBe(false) expect(onActiveConnectionChanged).not.toHaveBeenCalled() expect($gateway.get()).toBe(primary) }) }) describe('retainGatewayForAgent (#93602)', () => { function installRegistryDesktop() { ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } } it('holds the registry socket across multiple leased requests — no mid-turn disposal', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installRegistryDesktop() await ensureGatewayForProfile('default') const release = await retainGatewayForAgent('mini', 'helper') // The whole member-turn RPC sequence: each call takes and releases its own // per-request lease. Without the retain, refcount 0 between calls disposes // the socket and the runtime session with it. await requestGatewayForAgent('mini', 'helper', 'session.create', { title: 'g' }) await requestGatewayForAgent('mini', 'helper', 'image.attach_bytes', { session_id: 'rt-1' }) await requestGatewayForAgent('mini', 'helper', 'prompt.submit', { session_id: 'rt-1', text: 'hi' }) expect(secondaryGateways).toHaveLength(1) expect(secondaryGateways[0].close).not.toHaveBeenCalled() expect(secondaryGateways[0].request).toHaveBeenCalledTimes(3) release() expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) it('release is idempotent — a double release never underflows into a foreign disposal', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installRegistryDesktop() await ensureGatewayForProfile('default') const release = await retainGatewayForAgent('mini', 'helper') release() release() expect(secondaryGateways[0].close).toHaveBeenCalledOnce() // A fresh retain still works after the double release. const again = await retainGatewayForAgent('mini', 'helper') await requestGatewayForAgent('mini', 'helper', 'prompt.submit', { session_id: 'rt-2', text: 'hi' }) expect(secondaryGateways[1].close).not.toHaveBeenCalled() again() expect(secondaryGateways[1].close).toHaveBeenCalledOnce() }) it('without the retain, the leased socket closes after each request (the #93602 race)', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installRegistryDesktop() await ensureGatewayForProfile('default') await requestGatewayForAgent('mini', 'helper', 'session.create', { title: 'g' }) // Refcount hit 0 → disposed: this is the socket close that reaps the // runtime session server-side and makes the later prompt.submit 4001. expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) it('plain-profile retain leases the pooled profile socket and releases it', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop( vi.fn(async (profile: null | string) => profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' } ) ) await ensureGatewayForProfile('default') const release = await retainGatewayForAgent(null, 'worker') await requestGatewayForProfile('worker', 'session.create', { title: 'g' }) await requestGatewayForProfile('worker', 'prompt.submit', { session_id: 'rt-1', text: 'hi' }) expect(secondaryGateways).toHaveLength(1) expect(secondaryGateways[0].close).not.toHaveBeenCalled() release() expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) }) describe('attached shared-remote group turns (#96493)', () => { function installAttachedSharedRemote() { ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ connectionId, port: 9119, profile, sharedRemote: true })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } } it('reuses the primary socket for a named profile on the attached shared remote', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'homelab' }) installAttachedSharedRemote() await ensureGatewayForProfile('default') const release = await retainGatewayForAgent('homelab', 'voter') await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'Group: room' }) await requestGatewayForAgent('homelab', 'voter', 'prompt.submit', { session_id: 'rt-1', text: 'hi' }) expect(secondaryGateways).toHaveLength(0) expect(primary.request).toHaveBeenCalledTimes(2) expect(primary.request).toHaveBeenNthCalledWith(1, 'session.create', { title: 'Group: room', profile: 'voter' }) expect(primary.request).toHaveBeenNthCalledWith(2, 'prompt.submit', { session_id: 'rt-1', text: 'hi', profile: 'voter' }) release() expect(secondaryGateways).toHaveLength(0) }) it('still dials a secondary when the attached source is not a shared remote', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'homelab' }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ connectionId, port: 5151, profile, sharedRemote: false })), getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ok: true as const, wsUrl: `ws://${connectionId}/${profile}` })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'g' }) expect(secondaryGateways).toHaveLength(1) expect(primary.request).not.toHaveBeenCalled() }) it('reuses the primary when the shared-remote probe fails instead of dialing a ghost secondary', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'homelab' }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), getConnectionFor: vi.fn(async () => { throw new Error('Timed out connecting to profile "voter"') }), getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'ws://homelab/voter' })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'g' }) expect(secondaryGateways).toHaveLength(0) expect(primary.request).toHaveBeenCalledOnce() }) it('openGatewayForAgent and ensureGatewayForAgent do not dial a secondary', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'homelab' }) installAttachedSharedRemote() await ensureGatewayForProfile('default') await openGatewayForAgent('homelab', 'voter') expect(await ensureGatewayForAgent('homelab', 'voter')).toBe(true) expect(secondaryGateways).toHaveLength(0) }) it('ensureGatewayForAgent is false when the attached primary socket is closed', async () => { const primary = { connectionState: 'closed', request: vi.fn() } setPrimaryGateway(primary as never, 'default') setPrimaryGatewayConnection({ connectionId: 'homelab' }) installAttachedSharedRemote() expect(await ensureGatewayForAgent('homelab', 'voter')).toBe(false) expect(secondaryGateways).toHaveLength(0) }) })