Files
hanzo.ai/e2e/gates/together.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

195 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test'
import { member, team } from '@hanzo/personas'
import { serveExport } from './export'
import { signIn, answer } from '../session'
/**
* A ROOM HOLDS MORE THAN ONE CHARACTER, and each of them speaks as themselves.
*
* Picking an agent from the composer's `+` › Agents seats it in the open chat —
* the heading names it and the header lists its face and name. Picking a
* second ADDS them: both checked on the shelf, both names in the heading and the
* header, and the system turn on the wire says who is in the room and how each
* of them talks, so a reply comes back in two voices rather than the house one.
* The header lets one out, and the conversation stays.
*
* The faces under the composer that used to seat a character are gone: the
* shelf is the one way in (@hanzo/app crew.tsx `useAgentShelf`), and it lists
* the org's own agents — nothing stands in for one the org has not added, so a
* new org adds the core team from Customize › Agents first. The org here holds
* Feynman's and Einstein's rows, which the cloud keeps in step with
* hanzoai/personas, so what rides the wire is each member's file there. That
* is the point being measured: a name picked for the room never answers as the
* plain model.
*/
/** The org's row for a core member, as the cloud keeps it: the lower-case handle and the member's file. */
const row = (id: string) => ({ id: `agent_${id}`, name: id, model: 'enso-auto', instructions: member(id)!.instructions })
/** Opens `+` › Agents under the composer and hands back the shelf. */
async function shelf(page: Page) {
await page.locator('main').getByRole('button', { name: 'Add files, agents, skills and more' }).click()
await page.getByRole('menu', { name: 'Add files, agents, skills and more' }).getByRole('menuitem', { name: 'Agents', exact: true }).click()
const agents = page.getByRole('menu', { name: 'Agents', exact: true })
await expect(agents).toBeVisible()
return agents
}
/** Puts the shelf and the menu it opened from away. */
async function away(page: Page) {
for (let i = 0; i < 3 && (await page.getByRole('menu').count()); i++) await page.keyboard.press('Escape')
await expect(page.getByRole('menu')).toHaveCount(0)
}
test('two agents picked make a room, and the wire carries both briefs', async ({ page }) => {
await signIn(page, { sub: 'gate', name: 'gate', org: 'hanzo' })
const rows = [row('feynman'), row('einstein')]
await answer(page, '/v1/agents', { agents: rows })
for (const one of rows) await answer(page, `/v1/agents/${one.name}`, one)
await answer(page, '/v1/models', { data: [{ id: 'enso-auto', object: 'model' }, { id: 'enso-free', object: 'model' }] })
// The org's plan is read with its balance; both answer, so the room opens.
await answer(page, '/v1/billing/balance', { available: 0 })
// The completion, on whichever route the client takes to it.
const sent: { messages?: { role: string; content: string }[] }[] = []
// A fenced line that opens with a name is code, not a byline.
const REPLY =
'Feynman: A hash table is a coat check.\n\n```yaml\nEinstein: not a byline\n```\n\nEinstein: As simple as possible. No simpler.'
await page.route((url) => /\/v1\/(agents\/chat|chat\/completions)$/.test(url.pathname), async (route) => {
const body = JSON.parse(route.request().postData() || '{}')
sent.push(body)
// Answered the way the client asked: a stream of one delta, or one message.
if (body.stream) {
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ id: 'x', choices: [{ index: 0, delta: { role: 'assistant', content: REPLY } }] })}\n\ndata: [DONE]\n\n`,
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'x', choices: [{ message: { role: 'assistant', content: REPLY } }] }),
})
})
// Every platform call the room makes, so a turn that never reaches the wire says
// where it went instead.
const calls: string[] = []
page.on('request', (r) => {
if (r.url().includes('/v1/')) calls.push(`${r.method()} ${new URL(r.url()).pathname}`)
})
const site = await serveExport()
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/chat`)
const field = page.locator('main').getByRole('textbox', { name: 'Ask anything' })
await expect(field).toBeVisible({ timeout: 20_000 })
// ONE: picked from the shelf, checked there, and named at the top.
const agents = await shelf(page)
const feynman = agents.getByRole('menuitem', { name: `Feynman ${member('feynman')!.role}`, exact: true })
const einstein = agents.getByRole('menuitem', { name: `Einstein ${member('einstein')!.role}`, exact: true })
await feynman.click()
await expect(feynman).toHaveAttribute('aria-checked', 'true')
await expect(page.getByRole('heading', { name: 'Feynman', exact: true })).toBeVisible()
// TWO: the second is added beside the first, not put in their place.
await einstein.click()
await expect(einstein).toHaveAttribute('aria-checked', 'true')
await expect(feynman).toHaveAttribute('aria-checked', 'true')
await away(page)
await expect(page.getByRole('heading', { name: 'Feynman & Einstein' })).toBeVisible()
const header = page.getByRole('list', { name: 'Agents in this chat' })
await expect(header.getByRole('listitem')).toHaveCount(2)
// THE WIRE: one system turn naming both, each with how they talk.
await field.fill('how does a hash table work')
await field.press('Enter')
await expect.poll(() => sent.length, { timeout: 20_000 }).toBeGreaterThan(0).catch(() => undefined)
expect(sent.length, `no turn reached the completion; calls: ${calls.join(', ') || 'none'}`).toBeGreaterThan(0)
const brief = (sent[0].messages ?? []).find((m) => m.role === 'system')
expect(brief, 'the room rides as the system turn').toBeTruthy()
const said = brief!.content
expect(said, 'the roster line leads the turn').toMatch(/^Agents in this conversation: Feynman, Einstein\n/)
expect(said, 'the rule that tells the speakers apart').toContain('name and a colon')
// Each brief is the member's file in hanzoai/personas, word for word.
expect(said, "Feynman's own brief").toContain(`## Feynman\n\n${member('feynman')!.instructions}`)
expect(said, "Einstein's own brief").toContain(`## Einstein\n\n${member('einstein')!.instructions}`)
// THE REPLY, drawn by speaker: each part under its own name.
const main = page.locator('main')
await expect(main.getByText('A hash table is a coat check.')).toBeVisible({ timeout: 10_000 })
await expect(main.getByText('As simple as possible. No simpler.')).toBeVisible()
await expect(main.getByText('Einstein: not a byline'), 'the fence stays whole in Feynman\'s part').toBeVisible()
// Under the answer, its Reply reads it aloud (@hanzo/build's Reply).
await expect(main.getByRole('button', { name: 'Listen to this answer' }).last()).toBeVisible()
// OUT AGAIN: the header lets one out, and the conversation stays open
// with what was said in it.
await header.getByRole('button', { name: 'Remove Einstein from this chat' }).click()
await expect(header.getByRole('listitem')).toHaveCount(1)
await expect(header).toContainText('Feynman')
await expect(main.getByText('A hash table is a coat check.')).toBeVisible()
} finally {
await site.close()
}
})
/**
* THE CORE TEAM IS LISTED BY THE NAMES hanzoai/personas GIVES IT, and an agent
* the org made under a core member's name speaks from its own row: the name is
* not what makes an agent Hanzo's.
*/
test('the core team is listed by name, and an org agent under a core name keeps its own brief', async ({ page }) => {
await signIn(page, { sub: 'gate', name: 'gate', org: 'hanzo' })
// The org holds the core team's rows under their lower-case handles, and made
// its own agent under the handle "maya".
const OURS = 'You are Maya. Answer Acme billing questions.'
const maya = { id: 'agent_maya', name: 'maya', model: 'enso-auto', instructions: OURS }
const rows = team().map((one) => (one.id === 'maya' ? maya : row(one.id)))
await answer(page, '/v1/agents', { agents: rows })
await answer(page, '/v1/agents/maya', maya)
await answer(page, '/v1/models', { data: [{ id: 'enso-auto', object: 'model' }, { id: 'enso-free', object: 'model' }] })
await answer(page, '/v1/billing/balance', { available: 0 })
const sent: { model?: string; messages?: { role: string; content: string }[] }[] = []
await page.route((url) => /\/v1\/(agents\/chat|chat\/completions)$/.test(url.pathname), async (route) => {
const body = JSON.parse(route.request().postData() || '{}')
sent.push(body)
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ id: 'x', choices: [{ index: 0, delta: { role: 'assistant', content: 'Your invoice is under Billing.' } }] })}\n\ndata: [DONE]\n\n`,
})
})
const site = await serveExport()
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/chat`)
const field = page.locator('main').getByRole('textbox', { name: 'Ask anything' })
await expect(field).toBeVisible({ timeout: 20_000 })
// Every member of the core team, by name, capitalized, with their part.
const agents = await shelf(page)
for (const one of team()) {
await expect(agents.getByRole('menuitem', { name: `${one.name} ${one.role}`, exact: true }), one.name).toBeVisible({ timeout: 20_000 })
}
// Listed by name, never by its lower-case handle.
await expect(agents.getByRole('menuitem', { name: /^maya\b/ })).toHaveCount(0)
const picked = agents.getByRole('menuitem', { name: /^Maya\b/ })
await expect(picked).toHaveCount(1)
await picked.click()
await expect(picked).toHaveAttribute('aria-checked', 'true')
await away(page)
await expect(page.getByRole('heading', { name: 'Maya', exact: true })).toBeVisible()
await field.fill('where is my invoice')
await field.press('Enter')
await expect.poll(() => sent.length, { timeout: 20_000 }).toBeGreaterThan(0)
const system = (sent[0].messages ?? []).find((m) => m.role === 'system')?.content ?? ''
expect(system, "the roster line, then the org's own Maya").toBe(`Agents in this conversation: Maya\n\n${OURS}`)
expect(sent[0].model, "the row's model").toBe('enso-auto')
} finally {
await site.close()
}
})