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:
@@ -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()
|
||||
})
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user