From 1f1f02e354f3e3c1ca502d9562fc65432bf9f0e1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:25:28 -0700 Subject: [PATCH] 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. --- .../e2e/group-handoff-to-primary-bot.spec.ts | 71 ++++++++- apps/desktop/package.json | 2 +- .../hermes-bots/group-membership.test.ts | 10 -- .../group-rounds.primary-alias.test.ts | 148 ++++++------------ .../src/plugins/hermes-bots/group-rounds.ts | 15 +- package-lock.json | 2 +- website/docs/user-guide/bot-mode.md | 1 + 7 files changed, 115 insertions(+), 134 deletions(-) diff --git a/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts index 46a5e800d6..dac04f545a 100644 --- a/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts +++ b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts @@ -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 { - 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) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b21be619de..5b18673654 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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": { 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 c6d32b4508..8c3476e551 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.test.ts @@ -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([ { 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 index 1b1e865611..c409964f2f 100644 --- 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 @@ -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]) + } + } + } }) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index d361eb0305..ab2edf76a8 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -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. diff --git a/package-lock.json b/package-lock.json index eaf154d450..10a4728ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 8db271d9d7..5329b0334e 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -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: ` session, so room context survives like any other conversation.