From 36a66e837aae06627b3cdec9619ced9fefa67fe2 Mon Sep 17 00:00:00 2001 From: addel Date: Wed, 19 Aug 2026 15:43:39 +1000 Subject: [PATCH] fix(desktop): route profile tabs through active gateway --- apps/desktop/src/store/profile.test.ts | 74 +++++++++++++++++++++++++- apps/desktop/src/store/profile.ts | 28 ++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index 7ac9dbb6bc8a..003393b3a37a 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -37,8 +37,10 @@ const { $profiles, ensureGatewayProfile, invalidateProfileListFetches, + newSessionInProfile, prewarmProfileBackend, - refreshProfiles + refreshProfiles, + selectProfile } = await import('./profile') const { $connection } = await import('./session') @@ -63,17 +65,23 @@ const localConn = (over: Partial = {}): HermesConnection => const getConnection = vi.fn<(profile?: string | null) => Promise>() +const getConnectionFor = vi.fn< + (payload: { connectionId?: null | string; profile?: null | string }) => Promise +>() + beforeEach(() => { getConnection.mockReset() + getConnectionFor.mockReset() activateGateway.mockClear() ensureGatewayForProfile.mockClear() + prepareGatewayForAgent.mockClear() prepareGatewayForProfile.mockClear() openGatewayForProfile.mockClear() $gateway.set({ id: 'live-socket' }) $activeGatewayProfile.set('default') $connection.set(localConn()) $profiles.set([]) - vi.stubGlobal('window', { hermesDesktop: { getConnection } }) + vi.stubGlobal('window', { hermesDesktop: { getConnection, getConnectionFor } }) vi.mocked(invalidateProfileScopedQueries).mockClear() resetStarmapGraph.mockClear() }) @@ -83,6 +91,68 @@ afterEach(() => { $connection.set(null) }) +describe('profile rail routing on a registered gateway', () => { + it('switches through the active registry connection instead of the legacy profile route', async () => { + $connection.set(localConn({ connectionId: 'local', registryScoped: false })) + getConnectionFor.mockResolvedValue( + localConn({ connectionId: 'local', profile: 'research', registryScoped: true }) + ) + + selectProfile('research') + + await vi.waitFor(() => expect(prepareGatewayForAgent).toHaveBeenCalledWith('local', 'research')) + expect(prepareGatewayForProfile).not.toHaveBeenCalled() + }) + + it('keeps unscoped legacy windows on the legacy profile route', async () => { + getConnection.mockResolvedValue(localConn({ profile: 'research' })) + + selectProfile('research') + + await vi.waitFor(() => expect(prepareGatewayForProfile).toHaveBeenCalledWith('research')) + expect(prepareGatewayForAgent).not.toHaveBeenCalled() + }) + + it('does not promote an inferred non-local legacy descriptor into a registry route', async () => { + $connection.set(remoteConn({ connectionId: 'inferred-homelab', registryScoped: false })) + getConnection.mockResolvedValue(remoteConn({ connectionId: 'inferred-homelab', profile: 'research' })) + + selectProfile('research') + + await vi.waitFor(() => expect(prepareGatewayForProfile).toHaveBeenCalledWith('research')) + expect(prepareGatewayForAgent).not.toHaveBeenCalled() + }) + + it('starts a profile session on the active registered gateway', async () => { + $connection.set(remoteConn({ connectionId: 'homelab', registryScoped: true })) + getConnectionFor.mockResolvedValue( + remoteConn({ connectionId: 'homelab', profile: 'research', registryScoped: true }) + ) + + newSessionInProfile('research') + + await vi.waitFor(() => expect(prepareGatewayForAgent).toHaveBeenCalledWith('homelab', 'research')) + expect(prepareGatewayForProfile).not.toHaveBeenCalled() + }) + + it('contains a registered gateway activation rejection', async () => { + const error = new Error('gateway unavailable') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + $connection.set(remoteConn({ connectionId: 'homelab', registryScoped: true })) + prepareGatewayForAgent.mockRejectedValueOnce(error) + + try { + selectProfile('research') + + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith('[profile] gateway switch failed', { error, profile: 'research' }) + ) + } finally { + warn.mockRestore() + } + }) +}) + describe('ensureGatewayProfile → $connection sync (#46651)', () => { it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => { // Regression: the primary window backend is local, so $connection.mode is diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 5e73d84c8796..85ece51ea9f6 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -14,7 +14,7 @@ import { } from '@/lib/storage' import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-scope' import { $gateway, openGatewayForProfile, prepareGatewayForAgent, prepareGatewayForProfile } from '@/store/gateway' -import { setConnection } from '@/store/session' +import { $connection, setConnection } from '@/store/session' import { resetStarmapGraph } from '@/store/starmap' import type { ProfileInfo } from '@/types/hermes' @@ -507,6 +507,28 @@ export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], showAll ? ALL_PROFILES : normalizeProfileKey(gateway) ) +function activeProfileConnectionId(): null | string { + const connection = $connection.get() + const connectionId = String(connection?.connectionId ?? '').trim() + + if (!connectionId) { + return null + } + + // Explicit registry descriptors are authoritative. The app-managed local + // id is also authoritative even on the legacy primary descriptor: "This + // device" must never inherit a v1 remote override. Other inferred legacy + // ids remain on the v1 profile route until Electron can prove their exact + // registry identity (duplicate URL/SSH registrations can be ambiguous). + return connection?.registryScoped === true || connectionId === 'local' ? connectionId : null +} + +function activateProfileOnCurrentConnection(target: string): void { + void ensureGatewayAgent(activeProfileConnectionId(), target).catch(error => { + console.warn('[profile] gateway switch failed', { error, profile: target }) + }) +} + // Switch the active context to `name`: leave "All profiles" mode, point new // chats at it, and swap the single live gateway onto its backend (which moves // $activeGatewayProfile → name, so $profileScope follows). @@ -522,7 +544,7 @@ export function selectProfile(name: string): void { requestFreshSession() } - void ensureGatewayProfile(target) + activateProfileOnCurrentConnection(target) } // Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse @@ -535,7 +557,7 @@ export function newSessionInProfile(name: string): void { const target = normalizeProfileKey(name) $newChatProfile.set(target) requestFreshSession() - void ensureGatewayProfile(target) + activateProfileOnCurrentConnection(target) } export function setShowAllProfiles(value: boolean): void {