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.
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
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()
|
||
}
|
||
})
|