diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index c2664a37f8..43c6fa052c 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -22,6 +22,7 @@ import { listAllProfileSessions, listSessions, listSidebarSessions, + pluginSocket, resetSidebarBatchCapability, setApiRequestProfile, speakText, @@ -529,3 +530,41 @@ describe('Hermes REST helpers', () => { ) }) }) + +describe('pluginSocket', () => { + let getConnection: ReturnType + + beforeEach(() => { + getConnection = vi.fn().mockResolvedValue(null) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api: vi.fn(), getConnection } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(window, 'hermesDesktop') + setApiRequestProfile(null) + }) + + it('scopes the connection to the active profile, like pluginRest', async () => { + setApiRequestProfile('work') + + const dispose = pluginSocket('kanban', '/events', () => {}) + + await vi.waitFor(() => expect(getConnection).toHaveBeenCalled()) + expect(getConnection).toHaveBeenCalledWith('work') + + dispose() + }) + + it('passes null when no profile is scoped (single-profile / primary)', async () => { + const dispose = pluginSocket('kanban', '/events', () => {}) + + await vi.waitFor(() => expect(getConnection).toHaveBeenCalled()) + expect(getConnection).toHaveBeenCalledWith(null) + + dispose() + }) +}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index b186be2170..92a803eeb9 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1,5 +1,6 @@ import { JsonRpcGatewayClient } from '@hermes/shared' +import type { HermesConnection } from '@/global' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { recordTranscriptTail } from '@/store/transcript-tail' import type { @@ -270,6 +271,41 @@ export function getApiRequestProfile(): null | string { return _apiProfile } +// Registry connection serving the active gateway (null → the local pool). +// Pushed from store/gateway's setActive — the single seam BOTH +// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls +// that dial their own backend (pluginSocket) resolve it through the SAME +// source of truth those paths maintain for $connection. That makes the plugin +// socket follow registry-agent activations too, not just profile switches. +// Same no-store-import contract as _apiProfile (avoids a cycle). +let _apiConnectionId: null | string = null + +export function setApiRequestConnection(connectionId: null | string): void { + _apiConnectionId = connectionId || null +} + +/** Registry connection id that connection-scoped WS calls should target + * (null → the local pool). Read-only twin of setApiRequestConnection. */ +export function getApiRequestConnection(): null | string { + return _apiConnectionId +} + +/** Resolve the ACTIVE backend's connection descriptor, (connectionId, + * profile)-scoped — mirroring how store/profile resolves $connection: a + * registry agent's descriptor comes from getConnectionFor (its SOURCE + * connection), everything else from the profile-keyed local pool. The + * getConnectionFor bridge is optional (older Desktop mains); without it the + * profile-scoped pool lookup is the best available answer. */ +async function activeConnection(): Promise { + const getConnectionFor = window.hermesDesktop.getConnectionFor + + if (_apiConnectionId && getConnectionFor) { + return getConnectionFor({ connectionId: _apiConnectionId, profile: _apiProfile }) + } + + return window.hermesDesktop.getConnection(_apiProfile) +} + /** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` * shape, minus the path (which is namespace-derived). */ export interface PluginRestOptions { @@ -331,7 +367,7 @@ export function pluginSocket(pluginId: string, path: string, onMessage: (data: u let attempt = 0 const connect = async () => { - const connection = await window.hermesDesktop.getConnection().catch(() => null) + const connection = await activeConnection().catch(() => null) // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): // stay on the polling fallback rather than half-working. diff --git a/apps/desktop/src/plugin-socket-scope.test.ts b/apps/desktop/src/plugin-socket-scope.test.ts new file mode 100644 index 0000000000..c793ca6a17 --- /dev/null +++ b/apps/desktop/src/plugin-socket-scope.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesConnection } from '@/global' + +// pluginSocket must dial the ACTIVE gateway's backend — resolved through the +// same (connectionId, profile) source of truth ensureGatewayProfile / +// ensureGatewayAgent maintain for $connection — not the unscoped primary +// (#73044). Exercises the REAL hermes + store/gateway + store/profile chain: +// 1. A profile switch routes the plugin socket to the pooled profile backend +// (getConnection(profile), like pluginRest's profileScoped()). +// 2. A registry-agent activation routes it to the agent's SOURCE connection +// (getConnectionFor), not the local pool — the post-#87600 shape. + +vi.mock('@/hermes', async importOriginal => { + const actual = await importOriginal>() + + return { + ...actual, + // Stub only the socket class so gateway activations don't dial real WS. + HermesGateway: class { + connectionState = 'closed' + connect = async (_wsUrl: string): Promise => { + this.connectionState = 'open' + } + close = (): void => { + this.connectionState = 'closed' + } + onEvent = vi.fn(() => () => {}) + onState = vi.fn(() => () => {}) + } + } +}) +vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() })) +vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) + +const { pluginSocket, setApiRequestConnection, setApiRequestProfile } = await import('@/hermes') +const { closeSecondaryGateways, configureGatewayRegistry, setPrimaryGateway } = await import('@/store/gateway') +const { $activeGatewayProfile, ensureGatewayAgent, ensureGatewayProfile } = await import('@/store/profile') + +// authMode 'oauth' makes pluginSocket stop after resolving the connection +// (polling fallback), so the assertions cover resolution without a WS dial. +const conn = (over: Partial = {}): HermesConnection => + ({ + authMode: 'oauth', + baseUrl: 'https://pool.invalid', + mode: 'remote', + token: 'fake-test-token', + wsUrl: 'wss://pool.invalid/api/ws?token=fake-test-token', + ...over + }) as HermesConnection + +let getConnection: ReturnType +let getConnectionFor: ReturnType + +beforeEach(() => { + getConnection = vi.fn(async (profile?: null | string) => conn({ profile: profile ?? 'default' })) + getConnectionFor = vi.fn(async ({ profile }: { connectionId?: null | string; profile?: null | string }) => + conn({ baseUrl: 'https://homelab.invalid', profile: profile ?? 'default' }) + ) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + getConnection, + getConnectionFor, + getGatewayWsUrl: vi.fn(async () => 'wss://pool.invalid/api/ws?ticket=fake'), + getGatewayWsUrlFor: vi.fn(async () => 'wss://homelab.invalid/api/ws?ticket=fake'), + touchBackend: vi.fn(async () => ({ ok: true })) + } + }) + configureGatewayRegistry({ onEvent: vi.fn() }) + setPrimaryGateway({ connectionState: 'open' } as never, 'default') +}) + +afterEach(() => { + closeSecondaryGateways() + $activeGatewayProfile.set('default') + setApiRequestProfile(null) + setApiRequestConnection(null) + Reflect.deleteProperty(window, 'hermesDesktop') + vi.restoreAllMocks() +}) + +describe('pluginSocket active-backend scoping (#73044)', () => { + it('dials the active profile backend after a profile switch', async () => { + await ensureGatewayProfile('work') + getConnection.mockClear() + getConnectionFor.mockClear() + + const dispose = pluginSocket('kanban', '/events', () => {}) + + await vi.waitFor(() => expect(getConnection).toHaveBeenCalled()) + expect(getConnection).toHaveBeenCalledWith('work') + expect(getConnectionFor).not.toHaveBeenCalled() + + dispose() + }) + + it("dials the agent's SOURCE connection after a registry-agent activation", async () => { + await ensureGatewayAgent('homelab', 'research') + getConnection.mockClear() + getConnectionFor.mockClear() + + const dispose = pluginSocket('kanban', '/events', () => {}) + + await vi.waitFor(() => expect(getConnectionFor).toHaveBeenCalled()) + expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'homelab', profile: 'research' }) + expect(getConnection).not.toHaveBeenCalled() + + dispose() + }) + + it('falls back to the primary when no profile or connection is active', async () => { + const dispose = pluginSocket('kanban', '/events', () => {}) + + await vi.waitFor(() => expect(getConnection).toHaveBeenCalled()) + expect(getConnection).toHaveBeenCalledWith(null) + + dispose() + }) +}) diff --git a/apps/desktop/src/store/gateway-agent-scope.test.ts b/apps/desktop/src/store/gateway-agent-scope.test.ts index ddf8e68be4..9100b68c5f 100644 --- a/apps/desktop/src/store/gateway-agent-scope.test.ts +++ b/apps/desktop/src/store/gateway-agent-scope.test.ts @@ -16,6 +16,7 @@ const gatewayMocks = vi.hoisted(() => ({ })) vi.mock('@/hermes', () => ({ + setApiRequestConnection: vi.fn(), HermesGateway: class { connectionState = 'closed' connect = async (wsUrl: string): Promise => { diff --git a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts index 846ee3501e..22b0f9a160 100644 --- a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts +++ b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts @@ -22,6 +22,7 @@ const gatewayMocks = vi.hoisted(() => { }) vi.mock('@/hermes', () => ({ + setApiRequestConnection: vi.fn(), HermesGateway: class { connectionState = 'closed' close = vi.fn(() => { diff --git a/apps/desktop/src/store/gateway-connection-scope.test.ts b/apps/desktop/src/store/gateway-connection-scope.test.ts index 958fd768f5..1c93581c28 100644 --- a/apps/desktop/src/store/gateway-connection-scope.test.ts +++ b/apps/desktop/src/store/gateway-connection-scope.test.ts @@ -17,6 +17,7 @@ const gatewayMocks = vi.hoisted(() => ({ })) vi.mock('@/hermes', () => ({ + setApiRequestConnection: vi.fn(), HermesGateway: class { connectionState = 'closed' wsUrl = '' diff --git a/apps/desktop/src/store/gateway-shared-remote.test.ts b/apps/desktop/src/store/gateway-shared-remote.test.ts index 7d3d33c501..35df2160d1 100644 --- a/apps/desktop/src/store/gateway-shared-remote.test.ts +++ b/apps/desktop/src/store/gateway-shared-remote.test.ts @@ -19,6 +19,7 @@ const gatewayMocks = vi.hoisted(() => ({ })) vi.mock('@/hermes', () => ({ + setApiRequestConnection: vi.fn(), HermesGateway: class { connectionState = 'closed' connect = async (wsUrl: string): Promise => { diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 9ad578592d..2cdcc8b6b0 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -1,7 +1,7 @@ import { backendScopeKey, type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared' import { atom } from 'nanostores' -import { HermesGateway } from '@/hermes' +import { HermesGateway, setApiRequestConnection } from '@/hermes' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { markNativeNotifyBaseline } from '@/store/notify-baseline' import { setConnection, setGatewayState } from '@/store/session' @@ -183,6 +183,11 @@ function setActive(profile: string): void { const gateway = activeGateway() g.$gateway.set(gateway) setGatewayState(gateway?.connectionState ?? 'closed') + // Push the active scope's registry connection into the hermes module (null + // for the local pool) so connection-building WS calls (pluginSocket) resolve + // through the same source of truth every activation path maintains here — + // registry-agent activations included, not just profile switches. + setApiRequestConnection(activeGatewayConnectionId()) } function clearTimer(entry: Secondary): void {