fix(desktop): keep @hermes callable when the primary handle is default

Group mention parse used member.handle before botHandle, so a persisted
or union-stamped handle of "default" never mapped to the user-facing
@hermes alias. Bot-to-bot handoff toward the primary profile then
settled with no continuation, while the reverse direction still worked.

Co-authored-by: Noa <rainbowgore@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-01 15:12:31 +00:00
committed by Teknium
parent 1a990f3062
commit d92ce52e71
5 changed files with 151 additions and 4 deletions
@@ -193,7 +193,7 @@ export function GroupMentionInput({ members, onChange, onSubmitDraft, value, ...
}
for (const member of members) {
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
const handle = String(botHandle(member.name, member) || '').trim()
const display = displayName(member, botRosterMeta(member, allMeta))
// Renamed members complete on their friendly tag; parser resolves both.
const tag = String(botMentionTag(member) || handle).trim()
@@ -240,6 +240,16 @@ describe('seating a room', () => {
expect(members[0].handle).toBe('spark-work')
})
it('durableGroupChatMembers stores the primary profile as handle hermes, not default', () => {
const [primary, farmer] = modules.membership.durableGroupChatMembers([
{ name: 'default', title: '主要助理/任務協調者' },
{ name: 'code-farmer', title: 'Code Farmer' }
])
expect(primary.handle).toBe('hermes')
expect(farmer.handle).toBe('code-farmer')
})
it('durableGroupChatMembers retains active and remote source identities', () => {
const members = modules.membership.durableGroupChatMembers([
{
@@ -4,7 +4,7 @@
* the roster UI both read.
*/
import { $botMeta, botFriendlyNames, botMetaKey, botRosterKey } from './data'
import { $botMeta, botFriendlyNames, botHandle, botMetaKey, botRosterKey } from './data'
import { $groupChats, groupChatRoomKey } from './group-chat'
import { botConnectionRoute, botRosterMeta, resolveBotConnectionRoute } from './routing'
import type { BotMeta, GroupChat, GroupMember, RosterRow } from './types'
@@ -352,7 +352,7 @@ export function durableGroupChatMembers(bots: RosterRow[]): GroupMember[] {
return {
name: bot.name,
handle: bot.handle || bot.name,
handle: botHandle(bot.name, bot) || bot.handle || bot.name,
...(title
? {
title
@@ -0,0 +1,127 @@
/**
* Directional bot-to-bot handoff to the primary profile.
*
* The primary member's internal name is `default`; the user-facing alias is
* `@hermes`. Live roster union rows and durable room descriptors often stamp
* `handle: "default"` (same as the profile id). Using that handle as the
* mention form shadows `botHandle()` and drops `@hermes`, so
* code-farmer → @hermes never selects the primary bot while the reverse
* direction still works (it matches `code-farmer` by name).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $groupChats } from './group-chat'
import { durableGroupChatMembers } from './group-membership'
import { parseGroupChatMentions, resolveGroupResponders, unaddressedGroupMentions } from './group-rounds'
import type { GroupMember, GroupMessage } from './types'
vi.mock('@hermes/plugin-sdk', async () => {
const { atom } = await import('nanostores')
return {
atom,
host: {
request: vi.fn(),
state: { connectionId: { get: () => 'local' }, profile: { get: () => 'default' } }
},
queryClient: { invalidateQueries: vi.fn() },
useQuery: vi.fn(),
useValue: vi.fn()
}
})
vi.mock('./shared', () => ({ getPluginCtx: () => null, ID: 'hermes-bots' }))
const handleless: GroupMember[] = [
{ name: 'default', title: '' },
{ name: 'code-farmer', title: 'Code Farmer' }
]
const persisted: GroupMember[] = [
{ handle: 'default', name: 'default', title: '主要助理/任務協調者' },
{ handle: 'code-farmer', name: 'code-farmer', title: 'Code Farmer' }
]
const fromUser = (text: string): GroupMessage =>
({ at: 1, from: { kind: 'user', name: 'You' }, id: 'u1', text, thread: 't1' }) as GroupMessage
const fromMember = (name: string, text: string, id: string): GroupMessage =>
({ at: 2, from: { kind: 'member', name }, id, text, thread: 't1' }) as GroupMessage
beforeEach(() => {
$groupChats.set({})
})
describe('primary @hermes alias in group mention parse', () => {
it('resolves @hermes when the member has no precomputed handle', () => {
const parsed = parseGroupChatMentions('@hermes Please reply with the letter B.', handleless)
expect(parsed.mentioned.has('default')).toBe(true)
expect(parsed.mentioned.size).toBe(1)
})
it('resolves @hermes when persist/union stamped handle: "default"', () => {
const parsed = parseGroupChatMentions('@hermes Please reply with the letter B.', persisted)
expect(parsed.mentioned.has('default')).toBe(true)
expect(parsed.mentioned.size).toBe(1)
})
it('resolves @hermes after durableGroupChatMembers writes the room descriptor', () => {
const durable = durableGroupChatMembers([
{ name: 'default', title: '主要助理/任務協調者' } as never,
{ name: 'code-farmer', title: 'Code Farmer' } as never
])
expect(durable[0].handle).toBe('hermes')
const parsed = parseGroupChatMentions('@hermes Please reply with the letter B.', durable)
const keys = [...parsed.mentioned]
expect(keys.some(key => String(key).includes('default'))).toBe(true)
})
it('still resolves a device-qualified handle and keeps @hermes as an alias', () => {
const members: GroupMember[] = [
{ handle: 'default-vera', name: 'default' },
{ handle: 'code-farmer', name: 'code-farmer' }
]
const byAlias = parseGroupChatMentions('@hermes take this', members)
const byDevice = parseGroupChatMentions('@default-vera take this', members)
expect(byAlias.mentioned.has('default')).toBe(true)
expect(byDevice.mentioned.has('default')).toBe(true)
})
})
describe('directional bot-to-bot continuation', () => {
it('selects the primary bot when code-farmer hands off with @hermes', () => {
const log = [
fromUser('@code-farmer Please reply with one line only: mention Hermes and ask Hermes to reply with the letter B.'),
fromMember('code-farmer', '@hermes Please reply with the letter B.', 'm1')
]
const responders = resolveGroupResponders(log, persisted).map(member => member.name)
$groupChats.set({ g: { log, members: persisted, roomId: 'room-1' } as never })
expect(responders).toContain('default')
expect(unaddressedGroupMentions('g', persisted, 't1')).toContain('default')
})
it('still selects code-farmer when the primary bot hands off the other way', () => {
const log = [
fromUser(
'@hermes Please reply with one line only: mention Code Farmer and ask Code Farmer to reply with the letter D.'
),
fromMember('default', '@code-farmer Please reply with the letter D.', 'm2')
]
const responders = resolveGroupResponders(log, persisted).map(member => member.name)
$groupChats.set({ g: { log, members: persisted, roomId: 'room-1' } as never })
expect(responders).toContain('code-farmer')
expect(unaddressedGroupMentions('g', persisted, 't1')).toContain('code-farmer')
})
})
@@ -51,7 +51,10 @@ export function parseGroupChatMentions(text: unknown, members: GroupMember[]) {
// Cross-connection members are also addressable by their @name-device
// handle (the roster's disambiguated form) — same-named agents on two
// machines resolve to the right one.
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
// Always go through botHandle — a persisted/union handle of "default"
// must not shadow the user-facing @hermes alias (member.handle ||
// botHandle(...) short-circuits when handle === name === "default").
const handle = String(botHandle(member.name, member) || '').trim()
const forms = new Set([
member.name.toLowerCase(),
@@ -62,6 +65,13 @@ export function parseGroupChatMentions(text: unknown, members: GroupMember[]) {
: [])
])
// The primary profile stays callable as @hermes even when a device-
// qualified handle is precomputed (default-vera) or a stale persist
// stamped handle: "default".
if ((member.name || '').trim().toLowerCase() === 'default') {
forms.add('hermes')
}
// Renamed members answer to their friendly names too (profile
// display_name and Bot Mode title), in slugged and collapsed forms —
// the same tags the roster autocomplete inserts.