From 52972d15f32bebf95f4fb02fa23087d56806e184 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:45:34 -0700 Subject: [PATCH] test(desktop): live e2e for a teammate's @hermes handoff driving the primary bot Real-Electron Playwright spec for #100406: a two-member room (primary profile + code-farmer). The user addresses only @code-farmer; its scripted reply @mentions hermes; the assertion is a `default`-authored "B" entry in the persisted room log. On origin/main the room settles after Code Farmer's line and the spec fails at that assertion; with the mention-alias fix it passes. The mock inference server gains a per-speaker script for group rooms: `E2E_SAY()[]` tokens in the user's send answer the member whose turn prompt opens with `You are @`; unscripted members reply "(pass)". `{at}` stands for `@` so the script itself never mentions anyone and round one only drives the member the user tagged. --- .../e2e/group-handoff-to-primary-bot.spec.ts | 109 ++++++++++++++++++ tests-js/scripts/mock-server.ts | 43 +++++++ 2 files changed, 152 insertions(+) create mode 100644 apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts diff --git a/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts new file mode 100644 index 0000000000..46a5e800d6 --- /dev/null +++ b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts @@ -0,0 +1,109 @@ +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +// #100406: in a Bot Mode group room a teammate's `@hermes` handoff must give +// the primary profile (internal name `default`) its turn, exactly like +// `@hermes → @code-farmer` already does. The live roster stamps the primary +// row's handle as the bare profile id ("default"), and the mention parser let +// that stamped handle shadow the `@hermes` alias — so the room settled with +// Hermes never driven. The mock inference server scripts each member's line +// from the user's send (`E2E_SAY()[…]`), so the assertion is on the +// persisted room log: an entry authored by `default` saying "B". + +let fixture: MockBackendFixture | null = null + +type Page = MockBackendFixture['page'] + +interface RoomLogEntry { + from?: { kind?: string; name?: string } + text?: string +} + +async function openBots(page: Page): Promise { + 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() +} + +async function createAgent(page: Page, name: string, title: string): Promise { + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Bot' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Bot' }) + await dialog.getByPlaceholder('inbox-triage').fill(name) + await dialog.getByPlaceholder('Inbox Triage').fill(title) + await dialog.getByRole('button', { name: 'Create Bot' }).click() + await expect(dialog).toBeHidden({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) +} + +/** The plugin's persisted room log (`hermes.plugin.hermes-bots.group-chats`). */ +async function roomLog(page: Page, group: string): Promise { + return page.evaluate(name => { + const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats') + const rooms = raw ? (JSON.parse(raw) as Record) : {} + + return rooms[name]?.log ?? [] + }, group) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a teammate handing off with @hermes drives the primary profile', async () => { + test.setTimeout(420_000) + const page = fixture!.page + const group = 'Hermes, Code Farmer' + + await openBots(page) + await createAgent(page, 'code-farmer', 'Code Farmer') + + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Group Chat' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) + + for (const title of ['Hermes', 'Code Farmer']) { + await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() + } + + await dialog.getByRole('textbox', { name: 'Group name' }).fill(group) + await dialog.getByRole('button', { name: 'Create Group (2)' }).click() + + const composer = page.getByRole('textbox', { name: `Message ${group}` }).filter({ visible: true }) + await expect(composer).toBeVisible({ timeout: 20_000 }) + + // Only Code Farmer is addressed by the user. Its scripted reply hands off + // to @hermes; Hermes' scripted reply is "B". Neither script token carries + // a literal `@`, so the user send itself never mentions Hermes. + await composer.fill( + '@code-farmer Please reply with one line only. ' + + 'E2E_SAY(code-farmer)[{at}hermes Please reply with the letter B.] E2E_SAY(hermes)[B]' + ) + await composer.press('Enter') + + // 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 + }) + .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' + }) + .toBe(true) + + // And the transcript shows the handoff answered. + await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible() +}) diff --git a/tests-js/scripts/mock-server.ts b/tests-js/scripts/mock-server.ts index 6dffd2cf0f..f768a2e084 100644 --- a/tests-js/scripts/mock-server.ts +++ b/tests-js/scripts/mock-server.ts @@ -382,6 +382,34 @@ function includesBatchClarifyTrigger(value: unknown): boolean { return false } +/** + * Per-speaker scripted line for Bot Mode group rooms. A room turn prompt opens + * with `You are @` and quotes the user's message verbatim, so one user + * send can script every member's reply: + * `E2E_SAY(code-farmer)[{at}hermes Reply with B.] E2E_SAY(hermes)[B]`. + * The script deliberately carries no literal `@` (the room's mention parser + * would otherwise pull every scripted speaker into round one); `{at}` becomes + * `@` in the reply. The mock answers with the bracketed text whose handle + * matches the prompt's `You are @…` line; other prompts fall through. + */ +export function groupScriptedLine(userText: string, history: string[] = []): string | null { + const viewer = /You are @([a-z0-9][a-z0-9._-]*)/i.exec(userText)?.[1]?.toLowerCase() + + if (!viewer || ![userText, ...history].some(text => text.includes('E2E_SAY('))) { + return null + } + + for (const match of userText.matchAll(/E2E_SAY\(([a-z0-9][a-z0-9._-]*)\)\[([^\]]*)\]/gi)) { + if (match[1].toLowerCase() === viewer) { + return match[2].replace(/\{at\}/g, '@') + } + } + + // A scripted room turn with nothing for this speaker stays silent, so the + // room settles instead of every member echoing the canned reply. + return '(pass)' +} + function includesBlockingClarifyTrigger(value: unknown): boolean { if (typeof value === 'string') { return value.includes(BLOCKING_CLARIFY_TRIGGER) @@ -649,6 +677,21 @@ export function startMockServer(options: MockServerOptions = {}): Promise (m?.role === 'user' && typeof m?.content === 'string' ? [m.content] : [])), + ) + + if (groupLine !== null) { + if (stream) { + streamTextResponse(res, model, groupLine) + } else { + nonStreamingTextResponse(res, model, groupLine) + } + + return + } + if (stream) { const holdThisStream = Boolean( options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&