From d92ce52e714f7c8b4718e2dfaf3affdbd3c5a40b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 15:12:31 +0000 Subject: [PATCH] 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 --- .../plugins/hermes-bots/group-chat-parts.tsx | 2 +- .../hermes-bots/group-membership.test.ts | 10 ++ .../plugins/hermes-bots/group-membership.ts | 4 +- .../group-rounds.primary-alias.test.ts | 127 ++++++++++++++++++ .../src/plugins/hermes-bots/group-rounds.ts | 12 +- 5 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/group-rounds.primary-alias.test.ts diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx index ecc13870b5..86a33fc52b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx @@ -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() diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts index 8c3476e551..c6d32b4508 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts @@ -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([ { diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index e8d9a86fd8..7badbed01d 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -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 diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.primary-alias.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.primary-alias.test.ts new file mode 100644 index 0000000000..1b1e865611 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.primary-alias.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index d49d44426d..d361eb0305 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -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.