Files
hanzo.ai/e2e/gates/crew.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

776 lines
38 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, type Route } from '@playwright/test'
import { member } from '@hanzo/personas'
import { serveExport } from './export'
import { place, talk } from '@hanzo/app/lib/host'
import { heard, stream as events } from './heard'
import { signIn } from '../session'
/**
* AGENTS IN A CHAT, over the export, with the platform played here.
*
* The composer's `+` › Agents lists the org's own agents (@hanzo/app
* `useAgentShelf`; nothing stands in for one the org has not added) and a pick
* seats or unseats one in the conversation that is open; the header lists who
* is in it, each with a remove control. The page never moves — no route
* change, no organization chooser — and the conversation records its room as
* a roster turn, so a reload opens the same thread with the same people.
*
* The conversation store is a fake with the contract hanzoai/agent serves at
* /v1/agents/chat/conversations: record appends turns (skipping empty ones)
* and answers the id; a read answers the turns in order. Every other /v1 call
* the spec does not answer is a 404, so nothing leaves this machine.
*/
const TWO_ORGS = [
{ org: 'hanzo', role: 'admin' },
{ org: 'webby-ai', role: 'admin' },
]
interface Turn {
id: string
role: string
content: string
}
/** The platform: the conversation store, the agents list and the completion. */
class Platform {
threads = new Map<string, Turn[]>()
records: { id: string; messages: { role: string; content: string }[] }[] = []
completions: { model?: string; messages?: { role: string; content: string }[] }[] = []
agents: Record<string, unknown>[] = []
created: Record<string, unknown>[] = []
reply: (body: { messages?: { role: string; content: string }[] }) => Promise<(route: Route) => Promise<void>> = async () => (route) =>
stream(route, 'Feynman: A hash table is a coat check.\n\nEinstein: As simple as possible.')
agentRefusal: { status: number; body: unknown } | null = null
/** How long the agents list takes to answer, in ms. */
agentsDelay = 0
/** One agent's own record, by name, and how long its read takes. */
personas: Record<string, { row: Record<string, unknown>; delay: number }> = {}
/** When each agent record read answered, by name. */
personaAt: Record<string, number> = {}
/** When each completion was asked for. */
completionAt: number[] = []
seq = 0
async serve(page: Page) {
await page.route(
(url) => url.pathname.startsWith('/v1/'),
(route) => route.fulfill({ status: 404, contentType: 'application/json', body: '{"detail":"not in this spec"}' }),
)
await page.route('**/v1/billing/tier', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd', prepaidAvailable: 0 } }),
}),
)
// The plan is read with the ledger beside it, and a read that fails is no plan.
await page.route(
(url) => url.pathname === '/v1/billing/balance',
(route) => route.fulfill({ json: { balance: 0, holds: 0, available: 0 } }),
)
await page.route(
(url) => url.pathname === '/v1/models',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'enso-auto', object: 'model' },
{ id: 'enso-flash', object: 'model' },
{ id: 'anthropic/claude-opus-4.6', object: 'model' },
],
}),
}),
)
// The org's agents: what the spec gives it, and what it makes; an agent's
// own record answers 404 unless the spec holds one (`personas`).
await page.route(
(url) => url.pathname === '/v1/agents',
async (route) => {
const req = route.request()
if (req.method() === 'POST') {
const body = JSON.parse(req.postData() || '{}')
this.created.push(body)
if (this.agentRefusal) {
await route.fulfill({
status: this.agentRefusal.status,
contentType: 'application/problem+json',
body: JSON.stringify(this.agentRefusal.body),
})
return
}
const row = { id: `agent-${body.name}`, name: body.name, model: body.model, avatar: body.avatar, emoji: body.emoji }
this.agents.push(row)
await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(row) })
return
}
if (this.agentsDelay) await new Promise((ok) => setTimeout(ok, this.agentsDelay))
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ agents: this.agents }) })
},
)
await page.route(
(url) => /^\/v1\/agents\/[A-Za-z][A-Za-z0-9._-]*$/.test(url.pathname) && !url.pathname.startsWith('/v1/agents/chat'),
async (route) => {
const name = new URL(route.request().url()).pathname.split('/').pop() ?? ''
const held = this.personas[name]
if (!held) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"detail":"no such agent"}' })
await new Promise((ok) => setTimeout(ok, held.delay))
this.personaAt[name] = Date.now()
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(held.row) })
},
)
await page.route(
(url) => url.pathname === '/v1/agents/chat/conversations',
async (route) => {
const req = route.request()
if (req.method() === 'POST') {
const body = JSON.parse(req.postData() || '{}') as { conversationId?: string; messages: { role: string; content: string }[] }
const id = body.conversationId || `thread-${++this.seq}`
const held = this.threads.get(id) ?? []
for (const m of body.messages) if (m.content.trim()) held.push({ id: `${id}-${held.length}`, role: m.role, content: m.content })
this.threads.set(id, held)
this.records.push({ id, messages: body.messages })
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversationId: id }) })
return
}
const conversations = [...this.threads.entries()].map(([id, turns]) => ({
id,
title: turns.find((t) => t.role === 'user')?.content ?? '',
updatedAt: new Date().toISOString(),
}))
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations }) })
},
)
await page.route(
(url) => url.pathname.startsWith('/v1/agents/chat/conversations/'),
(route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop() ?? '')
const messages = this.threads.get(id) ?? []
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversationId: id, messages }) })
},
)
await page.route(
(url) => /\/v1\/(agents\/chat|chat\/completions)$/.test(url.pathname),
async (route) => {
const body = JSON.parse(route.request().postData() || '{}')
this.completions.push(body)
this.completionAt.push(Date.now())
const answer = await this.reply(body)
await answer(route)
},
)
}
}
/** A 200 stream of one delta and the terminator. */
function stream(route: Route, text: string) {
return route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ id: 'x', choices: [{ index: 0, delta: { role: 'assistant', content: text } }] })}\n\ndata: [DONE]\n\n`,
})
}
/** The system turn the last completion carried. */
const system = (p: Platform) => (p.completions[p.completions.length - 1]?.messages ?? []).find((m) => m.role === 'system')?.content ?? ''
/** A 1x1 PNG, as a file the picker is handed. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
test.describe.configure({ timeout: 120_000 })
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** The core team as an org keeps them, under their handles: what Add default agents makes. */
const TEAM = ['dev', 'des', 'vi', 'feynman', 'einstein', 'nora', 'maya', 'leo'].map((name) => ({ id: `agent-${name}`, name, model: 'enso-auto' }))
/** The composer's field, by the name it keeps whoever is seated. */
const field = (page: Page) => page.getByRole('textbox', { name: 'Ask anything' })
/** Opens `+` › Agents: a submenu beside the menu on a laptop, a page of it on a phone. */
async function agents(page: Page) {
await page.locator('[data-slot="plus"]').first().click()
await page.locator('[data-slot="plus-agents"]').first().click()
}
/** The row `+` › Agents draws for `name`, by the name a reader sees. */
const row = (page: Page, name: string) => page.locator('[data-slot="plus-row"]').filter({ hasText: new RegExp(`^${name}`) })
/** Seats (or unseats) each of `names` from `+` › Agents, then puts the menu away. */
async function seat(page: Page, ...names: string[]) {
await agents(page)
for (const name of names) await row(page, name).click()
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
test('+ › Agents seats the org’s agents, the header removes one, and a reload keeps the room', async ({ page }) => {
const platform = new Platform()
platform.agents = TEAM
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo', orgs: TWO_ORGS })
await platform.serve(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
const chooser = page.getByRole('region', { name: 'Choose an organization' })
// TWO AGENTS: each row checked, both listed in the header; the page stays where it was.
await agents(page)
await row(page, 'Feynman').click()
await row(page, 'Einstein').click()
await expect(row(page, 'Feynman')).toHaveAttribute('aria-checked', 'true')
await expect(row(page, 'Einstein')).toHaveAttribute('aria-checked', 'true')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
const header = page.getByRole('list', { name: 'Agents in this chat' })
await expect(header.getByRole('listitem')).toHaveCount(2)
expect(new URL(page.url()).pathname).toBe('/')
await expect(chooser).toHaveCount(0)
// THE TURN: one system turn naming both, the roster line first.
await field(page).fill('how does a hash table work')
await field(page).press('Enter')
const main = page.locator('main')
await expect(main.getByText('A hash table is a coat check.')).toBeVisible({ timeout: 20_000 })
expect(system(platform)).toContain('Agents in this conversation: Feynman, Einstein')
expect(system(platform)).toContain('name and a colon')
// The thread is written down, and so is who is in it.
await expect.poll(() => platform.records.some((r) => r.messages.some((m) => m.content === 'Agents in this conversation: Feynman, Einstein'))).toBe(true)
const thread = platform.records[0].id
await expect.poll(() => place(new URL(page.url()).pathname)).toEqual({ mode: 'chat', thread })
const remove = (name: string) => header.getByRole('button', { name: `Remove ${name} from this chat` })
// REMOVE ONE from the header: the header follows, the conversation stays.
await remove('Einstein').click()
await expect(remove('Einstein')).toHaveCount(0)
await expect(remove('Feynman')).toBeVisible()
await expect(header.getByRole('listitem')).toHaveCount(1)
await expect(main.locator('[data-slot="message"]').getByText('how does a hash table work')).toBeVisible()
await expect.poll(() => platform.threads.get(thread)?.at(-1)?.content).toBe('Agents in this conversation: Feynman')
expect(new URL(page.url()).pathname).toBe(talk(thread))
await expect(chooser).toHaveCount(0)
// RELOAD: the same thread, the same room, nothing asked.
await page.reload()
await expect(remove('Feynman')).toBeVisible({ timeout: 60_000 })
await expect(remove('Einstein')).toHaveCount(0)
await expect(header.getByRole('listitem')).toHaveCount(1)
await expect(main.locator('[data-slot="message"]').getByText('how does a hash table work')).toBeVisible()
await expect(chooser).toHaveCount(0)
// The next turn names Feynman alone.
await field(page).fill('and collisions?')
await field(page).press('Enter')
await expect.poll(() => platform.completions.length).toBe(2)
expect(system(platform)).toContain('Agents in this conversation: Feynman')
expect(system(platform), "Feynman's brief rides again after the reload").toContain('first principles')
expect(system(platform)).toMatch(/^Agents in this conversation: Feynman\n/)
expect(system(platform)).not.toContain(member('einstein')!.instructions)
})
test('New chat seats nobody; the thread left behind keeps its own room, and an agent picked after joins the new chat alone', async ({ page }) => {
const platform = new Platform()
platform.agents = TEAM
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo', orgs: TWO_ORGS })
await platform.serve(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await seat(page, 'Feynman', 'Vi', 'Einstein')
await field(page).fill('how does a hash table work')
await field(page).press('Enter')
await expect(page.locator('main').getByText('A hash table is a coat check.')).toBeVisible({ timeout: 20_000 })
await expect.poll(() => platform.records.length).toBeGreaterThan(0)
const thread = platform.records[0].id
// NEW CHAT: the plain room — nobody checked, nobody in the header.
await page.getByRole('button', { name: 'New chat' }).first().click()
await expect(page.getByText(/What’s up/)).toBeVisible()
await expect(page.getByRole('list', { name: 'Agents in this chat' })).toHaveCount(0)
await agents(page)
for (const name of ['Feynman', 'Vi', 'Einstein']) await expect(row(page, name)).toHaveAttribute('aria-checked', 'false')
// One picked now joins this chat, and only that one.
await row(page, 'Vi').click()
await expect(row(page, 'Vi')).toHaveAttribute('aria-checked', 'true')
await expect(row(page, 'Feynman')).toHaveAttribute('aria-checked', 'false')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await expect(page.getByRole('list', { name: 'Agents in this chat' }).getByRole('listitem')).toHaveCount(1)
// The thread left behind still seats its own room when it is opened.
await page.goto(`${base}${talk(thread)}`)
const header = page.getByRole('list', { name: 'Agents in this chat' })
for (const name of ['Feynman', 'Vi', 'Einstein'])
await expect(header.getByRole('button', { name: `Remove ${name} from this chat` })).toBeVisible({ timeout: 60_000 })
await expect(header.getByRole('listitem')).toHaveCount(3)
})
test('on a phone + › Agents is a page of the menu, and the header’s remove works from the keyboard', async ({ page }) => {
const platform = new Platform()
platform.agents = TEAM
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo', orgs: TWO_ORGS })
await platform.serve(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await agents(page)
await row(page, 'Vi').click()
await expect(row(page, 'Vi')).toHaveAttribute('aria-checked', 'true')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
const remove = page.getByRole('button', { name: 'Remove Vi from this chat' })
await expect(remove).toBeVisible()
const box = await remove.boundingBox()
expect(box && box.x + box.width).toBeLessThanOrEqual(390)
await remove.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('list', { name: 'Agents in this chat' })).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
await expect(page.getByRole('region', { name: 'Choose an organization' })).toHaveCount(0)
})
test('an org with no agents of its own is offered none, and + › Agents leads to the ways to add them', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await agents(page)
await expect(page.getByText('No agents yet. Add the default team from Manage agents.')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[data-slot="plus-row"]')).toHaveCount(0)
// Its ways to more, in order, after the rows: make one, manage them, find one.
const ways = page.getByRole('menuitem').filter({ hasText: /^(Create agent|Manage agents|Browse personas)$/ })
await expect(ways).toHaveText(['Create agent', 'Manage agents', 'Browse personas'])
await page.getByRole('menuitem', { name: 'Manage agents' }).click()
// Customize opens on Agents, so its address is Customize's own.
await expect.poll(() => new URL(page.url()).pathname).toBe('/dev/customize')
})
test('a new agent: required fields hold Create, a refusal is said by its field, a photo and a drawn face both land, and the agent joins the chat', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
await page.route(
(url) => url.pathname === '/v1/images/generations',
(route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }) }),
)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await agents(page)
await page.getByRole('menuitem', { name: 'Create agent' }).click()
const sheet = page.getByRole('dialog', { name: 'New agent' })
await expect(sheet).toBeVisible()
const create = sheet.getByRole('button', { name: 'Create' })
// REQUIRED: no name, or one the API would refuse, holds Create.
await expect(create).toBeDisabled()
await sheet.getByLabel('Name').fill('Mi chael')
await expect(sheet.getByText('Use letters, digits, dots, dashes or underscores')).toBeVisible()
await expect(create).toBeDisabled()
await sheet.getByLabel('Name').fill('Michael')
await expect(create).toBeEnabled()
await sheet.getByLabel('Spending limit in dollars').fill('0')
await expect(sheet.getByText('Set a spending limit above $0.')).toBeVisible()
await expect(create).toBeDisabled()
await sheet.getByLabel('Spending limit in dollars').fill('5')
// THE PHOTO: the round says where it goes, takes a file, then says Change.
const circle = sheet.getByRole('button', { name: 'Upload photo' }).first()
await expect(circle).toBeVisible()
await expect(sheet.getByText('Press the circle or drop a photo on it, or Create draws one.')).toBeVisible()
await sheet.locator('input[type=file]').setInputFiles({ name: 'face.png', mimeType: 'image/png', buffer: PNG })
await expect(sheet.getByRole('button', { name: 'Change photo' }).first()).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Remove' })).toBeVisible()
// A DROP on the round is taken the same way.
await sheet.getByRole('button', { name: 'Remove' }).click()
await expect(sheet.getByRole('button', { name: 'Upload photo' }).first()).toBeVisible()
const drop = await page.evaluateHandle((b64) => {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const dt = new DataTransfer()
dt.items.add(new File([bytes], 'drop.png', { type: 'image/png' }))
return dt
}, PNG.toString('base64'))
await circle.dispatchEvent('drop', { dataTransfer: drop })
await expect(sheet.getByRole('button', { name: 'Change photo' }).first()).toBeVisible()
// A REFUSAL is said in plain words under the field it concerns.
platform.agentRefusal = {
status: 400,
body: { type: 'about:blank', title: 'Bad Request', status: 400, detail: 'cap_micro_usd is required and must be a positive integer of micro-USD' },
}
await create.click()
await expect(sheet.getByText('Set a spending limit above $0.')).toBeVisible()
await expect(sheet.getByText('HTTP 400')).toHaveCount(0)
platform.agentRefusal = { status: 409, body: { title: 'Conflict', status: 409, detail: 'an agent named Michael already exists' } }
await create.click()
await expect(sheet.locator('#hire-name-wrong')).toHaveText('an agent named Michael already exists')
// CREATED: the body carries the budget and the photo; the agent joins the chat.
platform.agentRefusal = null
await create.click()
await expect(sheet).toHaveCount(0)
const made = platform.created.at(-1) as Record<string, unknown>
expect(made.name).toBe('Michael')
expect(made.model).toBe('enso')
expect(made.cap_micro_usd).toBe(5_000_000)
expect(made.max_task_micro_usd).toBe(1_000_000)
expect(made.period).toBe('month')
expect(String(made.avatar)).toMatch(/^data:image\/jpeg;base64,/)
const header = page.getByRole('list', { name: 'Agents in this chat' })
await expect(header.getByRole('button', { name: 'Remove Michael from this chat' })).toBeVisible({ timeout: 20_000 })
await expect(header.getByRole('listitem')).toHaveCount(1)
// Drawn with the photo it was made with.
await expect(header.locator('img')).toHaveAttribute('src', /^data:image\/jpeg;base64,/)
// A DRAWN FACE: described, drawn, cropped the same way.
await agents(page)
await page.getByRole('menuitem', { name: 'Create agent' }).click()
await sheet.getByLabel('Name').fill('Nadia')
await sheet.getByLabel('Or describe a face and press Draw').fill('a calm engineer with round glasses')
await sheet.getByRole('button', { name: 'Draw' }).click()
await expect(sheet.getByRole('button', { name: 'Change photo' }).first()).toBeVisible({ timeout: 20_000 })
await sheet.getByRole('button', { name: 'Create' }).click()
await expect(sheet).toHaveCount(0)
expect(String((platform.created.at(-1) as Record<string, unknown>).avatar)).toMatch(/^data:image\/jpeg;base64,/)
await expect(header.getByRole('button', { name: 'Remove Nadia from this chat' })).toBeVisible({ timeout: 20_000 })
await expect(header.getByRole('listitem')).toHaveCount(2)
})
test('a failed model is said with a retry, and the retry asks once', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
let turn = 0
platform.reply = async () => {
turn++
if (turn === 1) {
// A 200 that carries nothing but an error frame.
return (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ error: { message: 'Upstream error from Nvidia: ResourceExhausted' } })}\n\ndata: [DONE]\n\n`,
})
}
if (turn === 2) {
// Every provider refused before a byte went out: a real non-200.
return (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({
status: 'error',
msg: 'model "enso-flash": every provider refused — tried enso (402); last was enso: upstream error',
code: 'providers_exhausted',
}),
})
}
return (route) => stream(route, 'Dev builds the product.')
}
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await field(page).fill('what does dev do?')
await field(page).press('Enter')
// A 200 whose one frame is an error is that error, said under the question
// with Try again — never an answer with nothing in it.
const main = page.locator('main')
await expect(main.getByText('Upstream error from Nvidia: ResourceExhausted')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText(/answered with nothing/)).toHaveCount(0)
expect(await order(page)).toEqual([expect.stringContaining('what does dev do?'), expect.stringContaining('Upstream error from Nvidia: ResourceExhausted')])
await main.getByRole('button', { name: 'Try again' }).click()
// The retry carries the question once and no empty answer; the refusal is said.
await expect.poll(() => platform.completions.length).toBe(2)
const sent = platform.completions[1].messages ?? []
expect(sent.filter((m) => m.role === 'user' && m.content === 'what does dev do?')).toHaveLength(1)
expect(sent.some((m) => m.role === 'assistant' && !m.content.trim())).toBe(false)
// Our supply failing is one plain line; what the server said is folded under Details.
await expect(main.getByText('Enso is unavailable right now.')).toBeVisible({ timeout: 20_000 })
const said = 'model "enso-flash": every provider refused — tried enso (402); last was enso: upstream error'
await expect(main.getByText(said)).toHaveCount(0)
await main.getByRole('button', { name: 'Details' }).click()
await expect(main.getByText(said)).toBeVisible()
// The 402 is the upstream's account, not the reader's: no credit is offered.
await expect(main.getByRole('button', { name: 'Add credit' })).toHaveCount(0)
await main.getByRole('button', { name: 'Try again' }).click()
await expect(main.getByText('Dev builds the product.')).toBeVisible({ timeout: 20_000 })
expect((platform.completions[2].messages ?? []).filter((m) => m.role === 'user')).toHaveLength(1)
})
/** The text of every turn in the thread, in order, one entry per message. */
const order = (page: Page) => page.locator('main [data-slot="message"]').allInnerTexts()
test('a 200 that is a JSON error document is the refusal it names, under the question', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
// A turn that failed before its first frame, answered 200 with a document.
platform.reply = async () => (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'every provider refused — tried enso (402)', type: 'upstream', code: 'providers_exhausted' } }),
})
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await field(page).fill('what does dev do?')
await field(page).press('Enter')
const main = page.locator('main')
await expect(main.getByText('Enso is unavailable right now.')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText(/answered with nothing|No answer came back/)).toHaveCount(0)
await main.getByRole('button', { name: 'Details' }).click()
await expect(main.getByText('every provider refused — tried enso (402)')).toBeVisible()
// The question, then its failure: the card is under the question, not over it.
const turns = await order(page)
expect(turns).toHaveLength(2)
expect(turns[0]).toContain('what does dev do?')
expect(turns[1]).toContain('Enso is unavailable right now.')
const [asked, failed] = await Promise.all([
main.locator('[data-slot="message"]').nth(0).boundingBox(),
main.locator('[data-slot="failure"]').first().boundingBox(),
])
expect(failed!.y).toBeGreaterThan(asked!.y + asked!.height - 1)
})
test('the first question this browser asks is the first action, counted once', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
const seen = await events(page)
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await field(page).fill('what is a hash table?')
await field(page).press('Enter')
await expect(page.locator('main').getByText('A hash table is a coat check.')).toBeVisible({ timeout: 20_000 })
expect(await heard(seen, 'first_action')).toMatchObject({ action: 'chat_message' })
await field(page).fill('and a queue?')
await field(page).press('Enter')
await expect.poll(() => platform.completions.length).toBe(2)
// Leaving flushes whatever the second question sent; the next page's view proves it landed.
const views = () => seen.filter((e) => e.event === '$pageview').length
const before = views()
await page.reload()
await expect.poll(views, { timeout: 20_000 }).toBeGreaterThan(before)
expect(seen.filter((e) => e.event === 'first_action'), 'once, not per question').toHaveLength(1)
})
test('each unanswered question ends in its own failure, directly under it', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
platform.reply = async () => (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ status: 'error', msg: 'model "enso-flash": every provider refused — tried enso (402)', code: 'providers_exhausted' }),
})
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await field(page).fill('what does dev do?')
await field(page).press('Enter')
const main = page.locator('main')
await expect(main.locator('[data-slot="failure"]')).toHaveCount(1, { timeout: 20_000 })
await field(page).fill('what can einstein and nora do?')
await field(page).press('Enter')
await expect(main.locator('[data-slot="failure"]')).toHaveCount(2, { timeout: 20_000 })
// Question, its failure, question, its failure — nothing above the first question.
const turns = await order(page)
expect(turns).toHaveLength(4)
expect(turns[0]).toContain('what does dev do?')
expect(turns[1]).toContain('Enso is unavailable right now.')
expect(turns[2]).toContain('what can einstein and nora do?')
expect(turns[3]).toContain('Enso is unavailable right now.')
// Try again on the FIRST asks it once more at the end, and it is answered there.
platform.reply = async () => (route) => stream(route, 'Dev builds the product.')
await main.locator('[data-slot="failure"]').first().getByRole('button', { name: 'Try again' }).click()
await expect(main.getByText('Dev builds the product.')).toBeVisible({ timeout: 20_000 })
const sent = platform.completions.at(-1)?.messages ?? []
expect(sent.filter((m) => m.content === 'what does dev do?')).toHaveLength(1)
expect(sent.at(-1)?.content).toBe('what does dev do?')
const after = await order(page)
expect(after[0]).toContain('what can einstein and nora do?')
expect(after[1]).toContain('Enso is unavailable right now.')
expect(after[2]).toContain('what does dev do?')
expect(after[3]).toContain('Dev builds the product.')
})
test('Add credit is offered only for the reader\'s own wallet', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
platform.reply = async () => (route) =>
route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({
error: { message: 'Insufficient balance. Add credits to your wallet at https://hanzo.ai/pay', type: 'insufficient_quota', code: 'insufficient_balance' },
}),
})
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await field(page).fill('hello')
await field(page).press('Enter')
const main = page.locator('main')
await expect(main.getByText('Insufficient balance. Add credits to your wallet at https://hanzo.ai/pay')).toBeVisible({ timeout: 20_000 })
await expect(main.getByRole('button', { name: 'Add credit' })).toBeVisible()
})
test('a question to some agents in a room is theirs to answer, and the thread says who answered and who did not', async ({ page }) => {
const platform = new Platform()
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
let release: () => void = () => {}
const held = new Promise<void>((ok) => (release = ok))
platform.reply = async () => {
await held
return (route) => stream(route, 'Einstein: I explain what a rule says and why it holds.')
}
platform.agents = TEAM
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await seat(page, 'Des', 'Einstein', 'Nora')
await field(page).fill('what can einstein and nora do?')
await field(page).press('Enter')
const main = page.locator('main')
// While the answer has no words: the two it addresses are answering.
await expect(main.getByText('Einstein & Nora are answering…')).toBeVisible({ timeout: 20_000 })
await expect.poll(() => platform.completions.length).toBe(1)
expect(system(platform), 'the brief says the named ones answer').toContain('When the user names some of you, those of you answer')
release()
await expect(main.getByText('I explain what a rule says and why it holds.')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText('No answer from Nora in this reply.')).toBeVisible()
// A refusal names who went unanswered.
platform.reply = async () => (route) =>
route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ status: 'error', msg: 'refused', code: 'providers_exhausted' }) })
await field(page).fill('and what does des do?')
await field(page).press('Enter')
await expect(main.getByText('No answer from Des. Enso is unavailable right now.')).toBeVisible({ timeout: 20_000 })
})
test('a thread read back with a question and no answer says so under the question', async ({ page }) => {
const platform = new Platform()
platform.threads.set('thread-old', [
{ id: 't1', role: 'system', content: 'Agents in this conversation: Des' },
{ id: 't2', role: 'user', content: 'what does dev do?' },
{ id: 't3', role: 'user', content: 'still there?' },
{ id: 't4', role: 'assistant', content: 'Des: Yes.' },
{ id: 't5', role: 'user', content: 'what can einstein and nora do?' },
])
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}${talk('thread-old')}`)
const main = page.locator('main')
await expect(main.locator('[data-slot="failure"]')).toHaveCount(2, { timeout: 60_000 })
const turns = await order(page)
expect(turns[0]).toContain('what does dev do?')
expect(turns[1]).toContain('No answer came back for this question.')
expect(turns[2]).toContain('still there?')
expect(turns[3]).toContain('Yes.')
expect(turns[4]).toContain('what can einstein and nora do?')
expect(turns[5]).toContain('No answer came back for this question.')
})
test('a send made before a pinned agent\'s record has loaded goes out on that agent\'s model', async ({ page }) => {
const platform = new Platform()
const row = { id: 'agent-michael', name: 'michael', model: 'anthropic/claude-opus-4.6', instructions: 'You are Michael.' }
platform.agents = [row]
platform.personas = { michael: { row, delay: 2_500 } }
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await seat(page, 'michael')
await field(page).fill('hello')
await field(page).press('Enter')
await expect.poll(() => platform.completions.length, { timeout: 20_000 }).toBe(1)
expect(platform.completions[0].model, 'the pinned model, never the default').toBe('anthropic/claude-opus-4.6')
expect(platform.completionAt[0]).toBeGreaterThanOrEqual(platform.personaAt.michael)
await expect(page.locator('main [data-slot="message"]').getByText('hello', { exact: true })).toBeVisible()
})
test('each reply says which model answered it, and when a pinned model was unavailable', async ({ page }) => {
const platform = new Platform()
const row = { id: 'agent-michael', name: 'michael', model: 'anthropic/claude-opus-4.6' }
platform.agents = [row]
platform.personas = { michael: { row, delay: 0 } }
let turn = 0
platform.reply = async () => {
turn++
if (turn === 1)
// The gateway fell back: the frames name the tier that answered.
return (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ id: 'x', model: 'enso-pro', choices: [{ index: 0, delta: { role: 'assistant', content: 'Hi, Michael here.' } }] })}\n\ndata: [DONE]\n\n`,
})
if (turn === 3)
// A router named back as itself: nothing to say about who answered.
return (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ id: 'z', model: 'enso-auto', choices: [{ index: 0, delta: { role: 'assistant', content: 'Routed answer.' } }] })}\n\ndata: [DONE]\n\n`,
})
// The header names it too, for a routed turn.
return (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
headers: { 'x-hanzo-served': 'enso-flash', 'access-control-expose-headers': 'X-Hanzo-Served' },
body: `data: ${JSON.stringify({ id: 'y', choices: [{ index: 0, delta: { role: 'assistant', content: 'Plain answer.' } }] })}\n\ndata: [DONE]\n\n`,
})
}
await signIn(page, { sub: 'reader', name: 'Antje', org: 'hanzo' })
await platform.serve(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`${base}/`)
await expect(field(page)).toBeVisible({ timeout: 60_000 })
await seat(page, 'michael')
await field(page).fill('hello')
await field(page).press('Enter')
const main = page.locator('main')
await expect(main.getByText('Hi, Michael here.')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText('answered by enso-pro · anthropic/claude-opus-4.6 was unavailable')).toBeVisible()
// Michael leaves; the plain chat is routed by Enso and names what answered.
await page.getByRole('button', { name: 'Remove michael from this chat' }).click()
await field(page).fill('and now?')
await field(page).press('Enter')
await expect(main.getByText('Plain answer.')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText('answered by enso-flash', { exact: true })).toBeVisible()
await field(page).fill('once more')
await field(page).press('Enter')
await expect(main.getByText('Routed answer.')).toBeVisible({ timeout: 20_000 })
await expect(main.getByText('answered by enso-auto')).toHaveCount(0)
})