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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user