Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions apps/desktop/src/store/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ProfileInfo } from '@/types/hermes'
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
// the REST query client must not run for real in a unit test.
const ensureGatewayForProfile = vi.fn(async () => undefined)
const ensureGatewayForAgent = vi.fn(async () => undefined)
const ensureGatewayForAgent = vi.fn(async () => true)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const $gateway = atom<unknown>({ id: 'live-socket' })
const resetStarmapGraph = vi.fn()
Expand All @@ -25,8 +25,10 @@ const {
$profiles,
ensureGatewayProfile,
invalidateProfileListFetches,
newSessionInProfile,
prewarmProfileBackend,
refreshProfiles
refreshProfiles,
selectProfile
} = await import('./profile')

const { $connection } = await import('./session')
Expand All @@ -51,15 +53,22 @@ const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>

const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()

const getConnectionFor = vi.fn<
(payload: { connectionId?: null | string; profile?: null | string }) => Promise<HermesConnection>
>()

beforeEach(() => {
getConnection.mockReset()
getConnectionFor.mockReset()
ensureGatewayForAgent.mockReset()
ensureGatewayForAgent.mockResolvedValue(true)
ensureGatewayForProfile.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()
})
Expand All @@ -69,6 +78,72 @@ 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(ensureGatewayForAgent).toHaveBeenCalledWith('local', 'research'))
await vi.waitFor(() => {
expect($connection.get()?.connectionId).toBe('local')
expect($connection.get()?.profile).toBe('research')
})
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})

it('keeps unscoped legacy windows on the legacy profile route', async () => {
getConnection.mockResolvedValue(localConn({ profile: 'research' }))

selectProfile('research')

await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('research'))
expect(ensureGatewayForAgent).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(ensureGatewayForProfile).toHaveBeenCalledWith('research'))
expect(ensureGatewayForAgent).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(ensureGatewayForAgent).toHaveBeenCalledWith('homelab', 'research'))
expect(ensureGatewayForProfile).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 }))
ensureGatewayForAgent.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
Expand Down
28 changes: 25 additions & 3 deletions apps/desktop/src/store/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@/lib/storage'
import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-scope'
import { $gateway, ensureGatewayForAgent, ensureGatewayForProfile, openGatewayForProfile } 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'

Expand Down Expand Up @@ -447,6 +447,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).
Expand All @@ -462,7 +484,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
Expand All @@ -475,7 +497,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 {
Expand Down
Loading