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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user