fix(desktop/bots): name a bot by what its own backend reports on every surface

Follow-up to #131853 and #131867 from an independent review, each finding
reproduced in native Electron against two real serve backends.

- Cold composer path: the cache reconciliation (mergeServerMeta) ran only in
  the Bots pane's publish effect, so a launch that never opened the pane kept
  a stale cached title ahead of the backend's own in @handles and group
  prompts ("You are @local-stale-name"). It now runs where every snapshot is
  produced (data.ts::fetchRosterSnapshot), with the same fetch-time write
  fence, so the pane query and primeRoster reconcile alike.
- Pane label vs @handle: displayName ranked display_name above the backend's
  Bot Mode title on a thin remote row (and a titled remote `default` with no
  display_name fell to the connection label), so the row read "Remote Human
  Name" while mentions and prompts said @remote-bot-title. It now takes the
  same backend-title rung botFriendlyNames uses.
- Relay trace: a connection removed and re-added under a new id was logged
  `MISROUTED (same install as <removed id>)`; the remembered owner is now
  pruned against the current connection set.

The roster-pane-lifecycle test probed "republished" via mergeServerMeta's call
count; reconciliation now happens per fetch by design, so that invariant is
asserted via trackInboundActivity alone.
This commit is contained in:
teknium1
2026-10-03 02:05:58 -07:00
committed by Teknium
parent 18fa384634
commit be3fd671d7
7 changed files with 108 additions and 18 deletions

View File

@@ -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.

View File

@@ -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')
})
})

View File

@@ -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<void> {
const { mergeServerMeta } = await import('./profile-ops')
mergeServerMeta(
rows.filter(row => !row?.ghost),
fetchedAt
)
}
export function useRoster() {
const activeConnectionId = useValue(host.state.connectionId)

View File

@@ -17,18 +17,26 @@ export function displayName(bot: Partial<RosterRow>, 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<RosterRow>, meta?: BotMeta | null): str
return meta.title.trim()
}
if (backendTitle) {
return backendTitle
}
// Core-profile display name (profile.yaml, set via `hermes profile rename
// default <name>` or the dashboard) — the CLI-level equivalent of a Bot
// Mode title. Rides the profiles.list row; presentation-only.

View File

@@ -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()) {

View File

@@ -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)
})
})

View File

@@ -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)