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

196 lines
8.9 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 } from '@playwright/test'
import { serveExport } from './export'
import { signIn, answer } from '../session'
import { talk } from '@hanzo/app/lib/host'
/**
* A CONVERSATION ON /chat RIDES THE ADDRESS.
*
* The open thread lives in a module (`components/workspace/open.ts`) that resets
* on a real page load, so before this a reload dropped the conversation you were
* reading — the Recents rail could reopen it by click, but the URL held nothing
* and a conversation could not be linked. A conversation is its own path now,
* `/chat/<id>` (@hanzo/app lib/host `talk`): the path opens the thread on
* arrival and the open thread mirrors back out. `/chat?thread=<id>`, the address
* a conversation had before, moves to its path (app/(app)/chat/_door.tsx `moved`).
*
* The subject is `out/` — the bytes that ship — because the defect this guards is
* in the WIRING: the seed has to reach `Chat`, which reads the transcript through
* `threads.get`. So the transcript endpoint is answered here with a known two-turn
* conversation, and the test asserts those turns render — proving the param opened
* the thread and the pane read it, not merely that a URL was rewritten.
*
* NOTHING LEAVES THIS MACHINE. `threads.get` is `GET /v1/agents/chat/conversations/<id>`;
* it is fulfilled locally, so the transcript is canned and no real conversation is
* touched. A thread this caller may not read answers empty in production and the
* pane draws a blank, which is why a shared link degrades rather than leaks — that
* is the same `null`-on-empty contract the SDK's `get` already enforces.
*/
/** A clearly-fake id: it names no real conversation, only proves the address opens one. */
const SEED = 'gate-seed-thread'
/** The transcript the endpoint answers with. `threads.get` returns null unless
* `messages.length`, so two real turns are what make it a conversation. */
const TRANSCRIPT = {
id: SEED,
messages: [
{ id: 'm1', role: 'user', content: 'Ping from a shared link' },
{ id: 'm2', role: 'assistant', content: 'Pong, restored from the URL' },
],
}
test.describe('a conversation on /chat rides the address', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('/chat/<id> opens the conversation: its turns render, the path is kept', async ({ page }) => {
// A SESSION THAT PAYS, as every room's fixture is (../session): with no
// token `threads.get` throws before a request leaves the page, and with no
// plan the organization gate swaps the room for the ask as soon as the real
// billing read answers, so the turns render or vanish by the API's latency.
await signIn(page)
// The transcript store, answered here. Any host — the export talks to
// api.hanzo.ai, a local build to localhost — so the glob matches on path.
await page.route('**/v1/agents/chat/conversations/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(TRANSCRIPT),
}),
)
await page.goto(`${base}${talk(SEED)}`)
// The param opened the thread and the pane read its transcript. Both turns
// are the proof; the empty state ("What’s up?") is absent for the
// same reason.
await expect(page.getByText('Ping from a shared link')).toBeVisible({ timeout: 15000 })
await expect(page.getByText('Pong, restored from the URL')).toBeVisible()
// The address still carries the conversation, so a reload lands here again.
expect(new URL(page.url()).pathname).toBe(talk(SEED))
})
/**
* A MODEL ANSWERS IN MARKDOWN, and the transcript used to show the characters.
*
* `@hanzo/ui/chat` renders a turn's words plainly and says so — it ships no
* pipeline because the plugin set belongs to the surface — so an answer
* arrived here reading "```ts" with the backticks on screen while two other
* chat surfaces on this site already drew through `lib/prose.ts`. This asserts
* the elements, not the absence of the fence: a renderer that stripped the
* backticks and emitted nothing would pass on absence alone.
*/
test('a turn renders as markdown, not as characters', async ({ page }) => {
await signIn(page)
await page.route('**/v1/agents/chat/conversations/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'gate-markdown',
messages: [
{ id: 'm1', role: 'user', content: 'show me' },
{
id: 'm2',
role: 'assistant',
content: 'A **bold** claim.\n\n```ts\nconst n = 1\n```',
},
],
}),
}),
)
await page.goto(`${base}${talk('gate-markdown')}`)
const room = page.locator('main')
await expect(room.locator('[data-slot="prose"] strong')).toHaveText('bold', { timeout: 15000 })
await expect(room.locator('[data-slot="code"]')).toContainText('const n = 1')
// And the fence itself is gone, which is the thing a reader was seeing.
expect(await room.innerText(), 'the backticks are still on screen').not.toContain('```')
// AN ANSWER CAN BE TAKEN AWAY, and only an answer: your own turn is already
// in your hands. What lands on the clipboard is the source rather than the
// rendering, which is what makes it paste back with its fence intact.
await expect(room.getByRole('button', { name: 'Copy this answer' })).toHaveCount(1)
})
/**
* OPENING ANOTHER CONVERSATION OPENS IT, AND THE PAGE STAYS UP.
*
* The pane holds two names for the open conversation: the rail's (`open`) and
* the chat hook's (`thread`, taken from `open` in an effect). Pressing a Recent
* moves `open` first, and the pane used to read the hook's stale `thread` as a
* conversation just written down and open it again; the hook then took the new
* one, and the two swapped back and forth until React stopped the page with
* "Maximum update depth exceeded" (#185). Seen in production on `/`.
*/
test('a Recent opened from another conversation opens, with no update loop', async ({ page }) => {
await signIn(page)
const errors: string[] = []
page.on('pageerror', (e) => errors.push(e.message))
const turns = (id: string, words: string) => ({
id,
messages: [
{ id: `${id}-1`, role: 'user', content: `Asked in ${words}` },
{ id: `${id}-2`, role: 'assistant', content: `Answered in ${words}` },
],
})
await page.route('**/v1/agents/chat/conversations/**', (route) => {
const id = new URL(route.request().url()).pathname.split('/').pop()!
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(turns(id, id === 't-borealis' ? 'Borealis' : 'Aurora')) })
})
await answer(page, '/v1/agents/chat/conversations', {
conversations: [
{ id: 't-aurora', title: 'Aurora', updatedAt: new Date().toISOString() },
{ id: 't-borealis', title: 'Borealis', updatedAt: new Date().toISOString() },
],
})
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}${talk('t-aurora')}`)
await expect(page.getByText('Answered in Aurora')).toBeVisible({ timeout: 20000 })
// Each swap rewrote the address, so the loop is counted where it showed:
// 54 rewrites in four seconds before the fix, one after.
let moves = 0
page.on('framenavigated', (f) => {
if (f === page.mainFrame()) moves++
})
await page.getByRole('button', { name: 'Borealis' }).first().click()
await expect(page.getByText('Answered in Borealis')).toBeVisible({ timeout: 15000 })
await page.waitForTimeout(1500)
expect(new URL(page.url()).pathname).toBe(talk('t-borealis'))
expect(moves, 'the address settles on the conversation opened').toBeLessThanOrEqual(3)
expect(errors.filter((m) => /#185|Maximum update depth/.test(m))).toEqual([])
})
test('/chat with no thread reads no conversation and keeps a clean address', async ({ page }) => {
// A SESSION, because the room is behind one now. A stranger at /chat is
// sent to sign in rather than shown the composer, so a spec about what a
// bare room reads has to be someone before it can read anything.
await signIn(page)
// Fail loudly if a bare /chat reaches for a transcript it has no id for.
let asked = false
await page.route('**/v1/agents/chat/conversations/**', (route) => {
asked = true
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"messages":[]}' })
})
await page.goto(`${base}/chat`)
await expect(page.getByText(/What’s up/)).toBeVisible({ timeout: 15000 })
expect(new URL(page.url()).pathname, 'no thread, no conversation in the address').toBe('/chat')
expect(asked, 'a bare /chat must not read a transcript').toBe(false)
})
})