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(<handle>)[<line>]` tokens in the user's send answer the member
whose turn prompt opens with `You are @<handle>`; unscripted members
reply "(pass)". `{at}` stands for `@` so the script itself never
mentions anyone and round one only drives the member the user tagged.
This commit is contained in:
teknium1
2026-09-14 14:45:34 -07:00
committed by Teknium
parent d92ce52e71
commit 52972d15f3
2 changed files with 152 additions and 0 deletions
@@ -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(<handle>)[…]`), 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<void> {
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<void> {
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<RoomLogEntry[]> {
return page.evaluate(name => {
const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats')
const rooms = raw ? (JSON.parse(raw) as Record<string, { log?: RoomLogEntry[] }>) : {}
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()
})
+43
View File
@@ -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 @<handle>` 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<MockSe
return
}
const groupLine = groupScriptedLine(
userText,
messages.flatMap(m => (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' &&