diff --git a/apps/desktop/src/plugins/hermes-bots/data.bot-meta.test.ts b/apps/desktop/src/plugins/hermes-bots/data.bot-meta.test.ts index 1ea0f82091..5f24a8fd6f 100644 --- a/apps/desktop/src/plugins/hermes-bots/data.bot-meta.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/data.bot-meta.test.ts @@ -445,6 +445,26 @@ describe("a bot is named by its own backend, never by another bot's cached recor expect(groupSpeakerLabel('vps::default')).not.toBe('Agent B') }) + it('the row shows the name its @handle uses when the backend title and display_name differ', () => { + $botMeta.set({}) + const thin = { + ...vps, + connectionLabel: 'VPS', + display_name: 'Remote Human Name', + title: 'Remote Bot Title' + } as RosterRow + + mergeServerMeta([thin]) + + expect(displayName(thin, botRosterMeta(thin, $botMeta.get()))).toBe('Remote Bot Title') + expect(botMentionTag(thin)).toBe('remote-bot-title') + + // No display_name: the backend's title, never the Desktop-side connection label. + const titleOnly = { ...thin, display_name: '' } as RosterRow + + expect(displayName(titleOnly, botRosterMeta(titleOnly, $botMeta.get()))).toBe('Remote Bot Title') + }) + it("only a fresh answer from the bot's backend corrects its record, and a title alone never mints one", () => { $botMeta.set({ 'vps::default': { color: '#00ff00', title: 'New title' } }) // The VPS stops answering; the pane keeps its last-painted row, which predates the rename. 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 40d4bbe262..2592db9253 100644 --- a/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx @@ -20,12 +20,13 @@ * Mode until previously-painted rows were carried forward. */ +import { queryClient } from '@hermes/plugin-sdk' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { $lastRoster, ROSTER_KEY, useRoster } from './data' +import { $botMeta, $lastRoster, botMentionTag, cachedUnionRoster, primeRoster, ROSTER_KEY, useRoster } from './data' import type { RosterRow } from './types' const { hostMock } = vi.hoisted(() => ({ @@ -40,12 +41,12 @@ const { hostMock } = vi.hoisted(() => ({ vi.mock('@hermes/plugin-sdk', async () => { const { atom } = await import('nanostores') - const { useQuery } = await import('@tanstack/react-query') + const { QueryClient: Client, useQuery } = await import('@tanstack/react-query') return { atom, host: hostMock, - queryClient: { getQueryData: vi.fn(), invalidateQueries: vi.fn() }, + queryClient: new Client({ defaultOptions: { queries: { retry: false } } }), useQuery, useValue: (store: { get: () => unknown }) => store.get() } @@ -698,3 +699,35 @@ describe('the roster only caches rows from the connection it is keyed under', () expect(hostMock.requestProfile.mock.calls.every(([route]) => route.connectionId === 'vps')).toBe(true) }) }) + +describe('a launch that never opens the Bots pane', () => { + it('names a remote bot by what its backend reports, not a stale cached title', async () => { + queryClient.clear() + $botMeta.set({ 'vps::default': { title: 'Agent B' } }) + hostMock.state.connectionId.get.mockReturnValue('local') + hostMock.request.mockResolvedValue({ profiles: [{ display_name: 'Agent B', name: 'default' }] }) + hostMock.agents.mockResolvedValue({ + agents: [ + { connectionId: 'local', connectionKind: 'local', handle: 'default', profile: 'default' }, + { + connectionId: 'vps', + connectionKind: 'remote', + connectionLabel: 'VPS', + handle: 'default', + profile: 'default', + profileMetadata: { display_name: 'Agent A', title: 'Agent A' } + } + ], + primaryConnectionId: 'local' + }) + + await primeRoster() + + const vps = cachedUnionRoster()?.profiles?.find(row => row.connectionId === 'vps') + + expect(vps).toBeTruthy() + // The composer's @handle and group prompts read these; the pane never ran. + expect(botMentionTag(vps!)).toBe('agent-a') + expect($botMeta.get()['vps::default']?.title).toBe('Agent A') + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/data.ts b/apps/desktop/src/plugins/hermes-bots/data.ts index 24b91ecfdb..0ec9c63f8f 100644 --- a/apps/desktop/src/plugins/hermes-bots/data.ts +++ b/apps/desktop/src/plugins/hermes-bots/data.ts @@ -830,10 +830,12 @@ async function fetchRosterSnapshot(activeConnectionId: null | string | undefined const merged = mergeMultiSourceRoster(local, union, activeConnectionId, previous) const sources = Array.isArray(union?.sources) ? union.sources : [] traceRosterSnapshot(local, sources, activeConnectionId, merged?.profiles || []) + const profiles = (merged?.profiles || []).map(row => annotateBotSource(row, sources)) + await reconcileBotMeta(profiles, issuedAt) return { ...merged, - profiles: (merged?.profiles || []).map(row => annotateBotSource(row, sources)), + profiles, sources, fetchedAt: issuedAt } @@ -845,21 +847,41 @@ async function fetchRosterSnapshot(activeConnectionId: null | string | undefined * snapshot rather than clearing it. */ const previous: RosterRow[] = $lastRoster.get().filter(row => !row?.ghost) const merged = mergeMultiSourceRoster(local, null, activeConnectionId, previous) + const profiles = (merged?.profiles || []).map(row => + row?.remoteSource ? { ...row, sourceReachable: false } : row + ) + await reconcileBotMeta(profiles, issuedAt) return { ...merged, - profiles: (merged?.profiles || []).map(row => (row?.remoteSource ? { ...row, sourceReachable: false } : row)), + profiles, fetchedAt: issuedAt } } } + await reconcileBotMeta(Array.isArray(local?.profiles) ? local.profiles : [], issuedAt) + return { ...(local && typeof local === 'object' ? local : {}), fetchedAt: issuedAt } } +/** Every fresh snapshot corrects the cached names before anyone reads them — + * the Bots pane's query and the composer's cold `primeRoster` alike. Run only + * from the pane's effect, a launch that never opened Bots kept a stale cached + * title ahead of the backend's own in mentions and group prompts. Late import: + * profile-ops imports this module. */ +async function reconcileBotMeta(rows: RosterRow[], fetchedAt: number): Promise { + const { mergeServerMeta } = await import('./profile-ops') + + mergeServerMeta( + rows.filter(row => !row?.ghost), + fetchedAt + ) +} + export function useRoster() { const activeConnectionId = useValue(host.state.connectionId) diff --git a/apps/desktop/src/plugins/hermes-bots/labels.ts b/apps/desktop/src/plugins/hermes-bots/labels.ts index 433a60c0ca..b8aab26119 100644 --- a/apps/desktop/src/plugins/hermes-bots/labels.ts +++ b/apps/desktop/src/plugins/hermes-bots/labels.ts @@ -17,18 +17,26 @@ export function displayName(bot: Partial, meta?: BotMeta | null): str // activation and Cloud-only rosters (#89131). const alias = aliasIdentityFor(bot) + // The Bot Mode title the row's own backend reports: rich rows carry it in + // ui_meta, thin rows from another connection as `title` (bot_title). Same + // rung as botFriendlyNames, so the row and its @handle name the same bot. + const backendTitle = alias + ? '' + : String(bot?.ui_meta?.['hermes-bots']?.title || (typeof bot?.title === 'string' ? bot.title : '')).trim() + // Only THIN rows from another source trade the friendly name for their // connection label — the active gateway's own default must keep reading // "Hermes". Annotated active rows carry sourceScoped too, and keying this // off sourceScoped renamed the user's main agent to an IP-derived label - // (community report, Aug 17 2026). A display_name its own backend reports - // is a real name, never traded for a Desktop-side label. + // (community report, Aug 17 2026). A name its own backend reports is a real + // name, never traded for a Desktop-side label. if ( bot?.remoteSource && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel && !alias && !meta?.title?.trim() && + !backendTitle && !bot.display_name?.trim() ) { return bot.connectionLabel @@ -38,6 +46,10 @@ export function displayName(bot: Partial, meta?: BotMeta | null): str return meta.title.trim() } + if (backendTitle) { + return backendTitle + } + // Core-profile display name (profile.yaml, set via `hermes profile rename // default ` or the dashboard) — the CLI-level equivalent of a Bot // Mode title. Rides the profiles.list row; presentation-only. diff --git a/apps/desktop/src/plugins/hermes-bots/relay.ts b/apps/desktop/src/plugins/hermes-bots/relay.ts index 369b44e163..c16771434c 100644 --- a/apps/desktop/src/plugins/hermes-bots/relay.ts +++ b/apps/desktop/src/plugins/hermes-bots/relay.ts @@ -378,6 +378,16 @@ async function syncRelayRosters() { return } + // A connection removed and re-added under a new id answers from the same + // install; remembering the departed id flagged the replacement MISROUTED. + const live = new Set(connections.map(connection => connection.id)) + + for (const [install, connectionId] of answeringInstall) { + if (!live.has(connectionId)) { + answeringInstall.delete(install) + } + } + const labels = await connectionLabels() if (!isCurrent()) { diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.test.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.test.tsx index ff415d2f50..8a4bb08396 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.test.tsx @@ -90,7 +90,7 @@ describe('usePublishRosterSnapshot', () => { const firstFetchedAt = result.current.data?.fetchedAt const published = $lastRoster.get() expect(published.map(row => row.name)).toEqual(['default', 'coder']) - expect(mergeServerMeta).toHaveBeenCalledTimes(1) + expect(trackInboundActivity).toHaveBeenCalledTimes(1) // Second poll: same rows from the gateway, new issue stamp on the envelope. await new Promise(resolve => setTimeout(resolve, 5)) @@ -101,7 +101,6 @@ describe('usePublishRosterSnapshot', () => { expect(result.current.data?.profiles).toBe(firstProfiles) // ...so the publish does not fan out again. expect($lastRoster.get()).toBe(published) - expect(mergeServerMeta).toHaveBeenCalledTimes(1) expect(trackInboundActivity).toHaveBeenCalledTimes(1) }) @@ -118,7 +117,6 @@ describe('usePublishRosterSnapshot', () => { await waitFor(() => expect($lastRoster.get()).not.toBe(published)) expect($lastRoster.get()[1]?.last_session?.preview).toBe('later') - expect(mergeServerMeta).toHaveBeenCalledTimes(2) expect(trackInboundActivity).toHaveBeenCalledTimes(2) }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts index 6fdfe69b49..884335b49d 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { $lastRoster } from './data' import type { useRoster } from './data' import { displayName } from './labels' -import { mergeServerMeta, pullServerAvatars } from './profile-ops' +import { pullServerAvatars } from './profile-ops' import { trackInboundActivity } from './roster-actions' import { botRosterMeta, botWorkspaceOwnerKey } from './routing' import { backfillMessagingProtocol } from './soul' @@ -43,13 +43,8 @@ export function usePublishRosterSnapshot({ data, live, roster, allMeta, activeSo $lastSources.set(data.sources) } - // Every live row, not just the active source's: a bot on another - // connection reports its own title too, and skipping it left that bot - // named by whatever this Desktop last cached for it. - mergeServerMeta( - roster.filter(row => !row?.ghost), - data?.fetchedAt || 0 - ) + // Names were already reconciled when the snapshot was fetched + // (data.ts::fetchRosterSnapshot), for every reader, pane open or not. pullServerAvatars(activeSourceRoster) trackInboundActivity(roster) backfillMessagingProtocol(activeSourceRoster)