mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
776 lines
38 KiB
TypeScript
776 lines
38 KiB
TypeScript
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)
|
||
})
|