fix(desktop): preserve qualified bot identity in primary handoffs

Keep the salvaged botHandle normalization, but remove the unconditional
hermes alias on remote default profiles: the parser's last-wins map
otherwise retargets a local @hermes handoff by roster order.

Consolidate regression coverage into two invariants for persisted primary
handles, both handoff directions and three-source qualified identity.
Capture real Electron screenshots, durable logs and source receipts;
exercise the reverse live handoff too. Document the repair and bump the
bundled Desktop patch version.
This commit is contained in:
teknium1
2026-09-14 15:25:28 -07:00
committed by Teknium
parent 52972d15f3
commit 1f1f02e354
7 changed files with 115 additions and 134 deletions

View File

@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
@@ -20,7 +23,11 @@ interface RoomLogEntry {
}
async function openBots(page: Page): Promise<void> {
const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
const tab = page
.getByRole('button', { name: 'Bots', exact: true })
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
.first()
await tab.click()
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
}
@@ -52,6 +59,37 @@ test.beforeAll(async () => {
await waitForAppReady(fixture, 120_000)
})
// Playwright requires an object-destructured fixture argument.
// eslint-disable-next-line no-empty-pattern
test.afterEach(async ({}, info) => {
if (!fixture) {
return
}
await info.attach('room-log', {
body: JSON.stringify(await roomLog(fixture.page, 'Hermes, Code Farmer'), null, 2),
contentType: 'application/json'
})
await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' })
await info.attach('runtime-source', {
body: JSON.stringify(
await fixture.app.evaluate(() => ({
cwd: process.cwd(),
argv: process.argv,
root: process.env.HERMES_DESKTOP_HERMES_ROOT,
home: process.env.HERMES_HOME
})),
null,
2
),
contentType: 'application/json'
})
await info.attach('desktop-log', {
body: readFileSync(join(fixture.sandbox.hermesHome, 'logs/desktop.log')),
contentType: 'text/plain'
})
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
@@ -92,18 +130,35 @@ test('a teammate handing off with @hermes drives the primary profile', async ()
// Code Farmer's handoff line lands first (the reverse direction is not in
// question); then the room must NOT settle without Hermes' turn.
await expect
.poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')), {
timeout: 180_000
})
.poll(
async () =>
(await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')),
{
timeout: 180_000
}
)
.toBe(true)
await expect
.poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'), {
timeout: 180_000,
message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate'
})
.poll(
async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'),
{
timeout: 180_000,
message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate'
}
)
.toBe(true)
// And the transcript shows the handoff answered.
await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await composer.fill(
'@hermes Begin the reverse handoff. E2E_SAY(hermes)[{at}code-farmer Reply with D.] E2E_SAY(code-farmer)[D]'
)
await composer.press('Enter')
await expect
.poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && e.text?.trim() === 'D'), {
timeout: 180_000
})
.toBe(true)
})

View File

@@ -2,7 +2,7 @@
"name": "hermes",
"productName": "Hermes",
"private": true,
"version": "0.17.2",
"version": "0.17.3",
"description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research",
"repository": {

View File

@@ -240,16 +240,6 @@ 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([
{

View File

@@ -1,18 +1,7 @@
/**
* 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 { beforeEach, expect, it, vi } from 'vitest'
import { $groupChats } from './group-chat'
import { durableGroupChatMembers } from './group-membership'
import { durableGroupChatMembers, groupMemberKey } from './group-membership'
import { parseGroupChatMentions, resolveGroupResponders, unaddressedGroupMentions } from './group-rounds'
import type { GroupMember, GroupMessage } from './types'
@@ -21,107 +10,64 @@ vi.mock('@hermes/plugin-sdk', async () => {
return {
atom,
host: {
request: vi.fn(),
state: { connectionId: { get: () => 'local' }, profile: { get: () => 'default' } }
},
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' }
]
beforeEach(() => $groupChats.set({}))
const persisted: GroupMember[] = [
{ handle: 'default', name: 'default', title: '主要助理/任務協調者' },
{ handle: 'code-farmer', name: 'code-farmer', title: 'Code Farmer' }
]
it('keeps primary handoffs callable in both directions across persisted descriptor shapes', () => {
for (const handle of [undefined, 'default', 'hermes']) {
const original: GroupMember[] = [{ name: 'default', handle }, { name: 'code-farmer' }]
const fromUser = (text: string): GroupMessage =>
({ at: 1, from: { kind: 'user', name: 'You' }, id: 'u1', text, thread: 't1' }) as GroupMessage
const durable = durableGroupChatMembers(original)
expect(durable.map(member => member.handle)).toEqual(['hermes', 'code-farmer'])
const fromMember = (name: string, text: string, id: string): GroupMessage =>
({ at: 2, from: { kind: 'member', name }, id, text, thread: 't1' }) as GroupMessage
for (const members of [original, durable]) {
for (const [sender, target, senderTag, targetTag] of [
['code-farmer', 'default', 'code-farmer', 'hermes'],
['default', 'code-farmer', 'hermes', 'code-farmer']
]) {
const targetKey = groupMemberKey(members.find(member => member.name === target)!)
beforeEach(() => {
$groupChats.set({})
const log: GroupMessage[] = [
{ at: 1, from: { kind: 'user', name: 'You' }, id: 'u', text: `@${senderTag} begin`, thread: 't' },
{ at: 2, from: { kind: 'member', name: sender }, id: 'm', text: `@${targetTag} continue`, thread: 't' }
]
$groupChats.set({ g: { log, members, roomId: 'room', watermarks: {} } })
expect([...parseGroupChatMentions(log[1].text, members).mentioned]).toEqual([targetKey])
expect(resolveGroupResponders(log, members).map(member => member.name)).toContain(target)
expect(unaddressedGroupMentions('g', members, 't')).toEqual([targetKey])
}
}
}
})
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)
it('keeps qualified remote defaults distinct from the primary alias regardless of roster order', () => {
const local: GroupMember = { name: 'default', handle: 'hermes', connectionId: 'local', sourceScoped: true }
expect(parsed.mentioned.has('default')).toBe(true)
expect(parsed.mentioned.size).toBe(1)
})
const remotes: GroupMember[] = ['vera', 'spark'].map(device => ({
name: 'default',
handle: `default-${device}`,
connectionId: device,
remoteSource: true,
sourceScoped: true
}))
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')
})
for (const members of [[local, ...remotes], [...remotes].reverse().concat(local)]) {
for (const descriptors of [members, durableGroupChatMembers(members)]) {
for (const [tag, key] of [
['hermes', 'local::default'],
['default-vera', 'vera::default'],
['default-spark', 'spark::default']
]) {
expect([...parseGroupChatMentions(`@${tag} continue`, descriptors).mentioned]).toEqual([key])
}
}
}
})

View File

@@ -48,12 +48,8 @@ export function parseGroupChatMentions(text: unknown, members: GroupMember[]) {
for (const member of members) {
const title = String(member.title || '').trim()
// 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.
// 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").
// Normalize legacy "default" handles without aliasing device-qualified
// defaults to @hermes: that would retarget the primary tag by roster order.
const handle = String(botHandle(member.name, member) || '').trim()
const forms = new Set([
@@ -65,13 +61,6 @@ 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.

2
package-lock.json generated
View File

@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "hermes",
"version": "0.17.2",
"version": "0.17.3",
"dependencies": {
"@assistant-ui/core": "0.2.23",
"@assistant-ui/react": "0.14.24",

View File

@@ -98,6 +98,7 @@ Use the **Move up** and **Move down** arrows beside a room to choose its positio
- **One visible conversation.** Public messages and each member's reply stay readable in arrival order, with the speaker's name and timestamp. Starting another topic does not collapse earlier replies. **Reply in thread** continues that topic without reordering the room; **Activity** is a secondary status view, not a replacement for messages. Private Bot Chats remain separate.
- Your message triggers up to **three serial rounds** of member turns. @-mentioned Bots respond (everyone responds when nobody is mentioned); each Bot replies briefly or passes, and the room settles when a full round stays silent.
- Teammates can hand off to the primary Bot with `@hermes`, including in older saved rooms; Bots on other gateways keep their device-qualified tags (for example, `@default-vera`).
- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens. Pending questions and command approvals also light that badge; resolving the last prompt clears only prompt attention, not an independent mention. Prompts follow a renamed room, while disbanding retires them even if a member's in-flight poll arrives later.
- Hard caps (10 messages per send, 3 rounds) keep rooms from spinning.
- Each member keeps its own persistent `Group: <name>` session, so room context survives like any other conversation.