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:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user