diff --git a/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx b/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx
index ba9679ed52..1c9ad85998 100644
--- a/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx
@@ -25,7 +25,7 @@ import { renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { $lastRoster, useRoster } from './data'
+import { $lastRoster, ROSTER_KEY, useRoster } from './data'
import type { RosterRow } from './types'
const { hostMock } = vi.hoisted(() => ({
@@ -633,3 +633,68 @@ describe('a stalled profiles.list cannot pin the spinner forever', () => {
expect(hostMock.request.mock.calls.length).toBeGreaterThan(1)
})
})
+
+describe('the roster only caches rows from the connection it is keyed under', () => {
+ const routes = [
+ { connectionId: 'local', mode: 'local', primary: true, profile: 'default', targetProfile: 'default' },
+ { connectionId: 'vps', mode: 'remote', profile: 'default', targetProfile: 'default' }
+ ]
+
+ const vpsList = { profiles: [{ display_name: 'Agent A', name: 'default' }] }
+ const localList = { profiles: [{ display_name: 'Agent B', name: 'default' }] }
+
+ function renderRoster() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+
+ return { client, result: renderHook(() => useRoster(), { wrapper }).result }
+ }
+
+ afterEach(() => {
+ hostMock.profileRoutes = undefined
+ })
+
+ it("asks the keyed connection by route, not an ambient socket that sits on another machine", async () => {
+ hostMock.state.connectionId.get.mockReturnValue('vps')
+ hostMock.agents.mockRejectedValue(new Error('no union roster on this build'))
+ hostMock.profileRoutes = vi.fn(async () => routes)
+ hostMock.request.mockResolvedValue(localList)
+ hostMock.requestProfile.mockImplementation(async (route: { connectionId: string }) =>
+ route.connectionId === 'vps' ? vpsList : localList
+ )
+
+ const { result } = renderRoster()
+
+ await waitFor(() => expect(result.current.data).toBeTruthy())
+ expect(result.current.data?.profiles?.map(row => row.display_name)).toEqual(['Agent A'])
+ expect(hostMock.request).not.toHaveBeenCalled()
+ })
+
+ it('never answers a fetch whose connection was switched away mid-flight', async () => {
+ // Keyed to 'local' at render; the window switches to the VPS while the
+ // fetch is underway, so whatever socket answers now is not 'local'.
+ let published = 'local'
+ hostMock.state.connectionId.get.mockImplementation(() => published)
+ hostMock.agents.mockRejectedValue(new Error('no union roster on this build'))
+ hostMock.profileRoutes = vi.fn(async () => {
+ published = 'vps'
+
+ return routes
+ })
+ hostMock.request.mockResolvedValue(vpsList)
+ hostMock.requestProfile.mockResolvedValue(vpsList)
+
+ const { client } = renderRoster()
+ const localKey = [...ROSTER_KEY, 'local']
+
+ // The fetch and its bounded retries (1s, 2s) all refuse: none may send a
+ // request, so the VPS answer is never cached as this device's roster.
+ await waitFor(() => expect(client.getQueryState(localKey)?.status).toBe('error'), { timeout: 6000 })
+ expect(client.getQueryData(localKey)).toBeUndefined()
+ expect(hostMock.request).not.toHaveBeenCalled()
+ expect(hostMock.requestProfile.mock.calls.every(([route]) => route.connectionId === 'vps')).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/plugins/hermes-bots/data.ts b/apps/desktop/src/plugins/hermes-bots/data.ts
index 18323ce7ca..ae7bb22c83 100644
--- a/apps/desktop/src/plugins/hermes-bots/data.ts
+++ b/apps/desktop/src/plugins/hermes-bots/data.ts
@@ -695,23 +695,43 @@ async function fetchRosterSnapshot(activeConnectionId: null | string | undefined
// keep its configured friendly identity after activation (#89131).
// Best-effort and feature-detected — a failed read keeps the last
// good index rather than dropping identities mid-session.
+ let routes: ProfileRoute[] = []
+
if (typeof host.profileRoutes === 'function') {
const epoch = beginAliasRouteIndex()
try {
- indexAliasRoutes(await host.profileRoutes(), epoch)
+ routes = await host.profileRoutes()
+ indexAliasRoutes(routes, epoch)
} catch {
/* keep the previous alias index */
}
}
- // Owner routing is ambient in the SDK now (post-#92731): requestForBot
- // resolves the active owner itself, no captured route needed here.
+ // The rows are cached under `activeConnectionId`, so they must come from
+ // that connection. A retry or late run of a query keyed to the connection
+ // the window just switched away from would otherwise be answered by the
+ // one it switched to and cached under the old key — the cross-machine
+ // swap that relabelled a VPS bot with the local bot's title.
+ if ((host.state.connectionId?.get?.() || null) !== (activeConnectionId || null)) {
+ throw new Error('Bot roster query outlived its connection')
+ }
+
const activeBot = {
name: String(host.state.profile?.get?.() || 'default').trim() || 'default'
}
- const local = await requestForBot(activeBot, 'profiles.list', {})
+ // Ask the keyed connection by name rather than the ambient socket, which
+ // can sit on another machine (a secondary foregrounded across a connection
+ // apply). No exact route (older Desktop host) → the ambient owner.
+ const route = Array.isArray(routes)
+ ? routes.find(r => r?.connectionId === activeConnectionId && r?.profile === activeBot.name)
+ : undefined
+
+ const local = route
+ ? await host.requestProfile(route, 'profiles.list', {})
+ : await requestForBot(activeBot, 'profiles.list', {})
+
// Newer backends inject the teammate-messaging protocol into every
// session's system prompt (agent.bot_mode_protocol) — SOUL.md must not
// carry a second copy. Older gateways lack the flag: keep appending.