Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437import assert from "node:assert/strict";import { createHmac } from "node:crypto";import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";import { createServer } from "node:net";import { tmpdir } from "node:os";import { join, resolve } from "node:path";import { test } from "node:test";import { chromium } from "playwright";import { WebSocketChatTransport } from "agents/chat/transport";import { MessageType } from "agents/chat";import { AgentClient } from "agents/client";import WebSocket from "ws";import { unstable_dev } from "wrangler";import { Secret } from "../configuration/secrets.ts";import { createOwnerSession } from "../worker/session.ts";import { customerBindings, installation } from "./fixtures/config.mjs";const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));async function until(read, predicate, label, timeout = 20000) { const end = Date.now() + timeout; let result; do { result = await read(); if (predicate(result)) return result; await sleep(60); } while (Date.now() < end); assert.fail(`${label}: ${JSON.stringify(result)}`);}async function bounded(promise, label, timeout = 15000) { let timer; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`${label} timed out after ${timeout}ms`)), timeout, ); }), ]); } finally { clearTimeout(timer); }}const answer = (text) => `Starting ${Array.from({ length: 20 }, (_, i) => `${text}-${i} `).join("")}finished.`;test( "packaged conversation UI uses native Think streaming, history, tools, resume and cancellation", { timeout: 240000 }, async (t) => { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address(); await new Promise((resolve) => server.close(resolve)); const origin = `http://127.0.0.1:${port}`; const temporary = await mkdtemp(join(tmpdir(), "flarebot-chat-ui-")); const screenshots = process.env.FLAREBOT_CHAT_SCREENSHOTS ?? join(temporary, "screenshots"); const config = JSON.parse( await readFile("dist/release/deployment.json", "utf8"), ); const configPath = join(temporary, "wrangler.json"); const entry = "tests/fixtures/chat-worker.ts"; await writeFile( configPath, JSON.stringify({ ...config, name: "flarebot-chat-ui-test", main: resolve(entry), no_bundle: false, keep_names: true, assets: { ...config.assets, directory: resolve("dist/release/assets") }, }), ); const cookie = ( await createOwnerSession( new Secret(customerBindings.FLAREBOT_SESSION_SECRET), { ...installation, runtimeOrigin: origin }, ) ).split(";")[0]; const start = () => unstable_dev(entry, { config: configPath, vars: { ...customerBindings, FLAREBOT_ENV: "development", FLAREBOT_DEV_OVERRIDES: JSON.stringify({ runtimeOrigin: origin }), }, local: true, ip: "127.0.0.1", port, inspectorPort: 0, persistTo: temporary, logLevel: "error", experimental: { disableExperimentalWarning: true, watch: false }, }); let worker, browser, owner; const clients = new Set(); const gates = new Set(); let cleanupPromise; const cleanup = () => (cleanupPromise ??= (async () => { for (const release of gates) release(); for (const client of clients) client.close(); owner?.close(); const results = await Promise.allSettled([ bounded(Promise.resolve(browser?.close()), "browser cleanup"), bounded(Promise.resolve(worker?.stop()), "Worker cleanup"), ]); await rm(temporary, { recursive: true, force: true }); const failures = results.filter( (result) => result.status === "rejected", ); if (failures.length) throw new AggregateError( failures.map((result) => result.reason), "Chat fixture cleanup failed", ); })()); t.after(cleanup, { timeout: 20000 }); try { worker = await start(); class OwnerSocket extends WebSocket { constructor(url, protocols) { super(url, protocols, { headers: { Cookie: cookie, Origin: origin }, closeTimeout: 100, }); } } owner = new AgentClient({ host: `127.0.0.1:${port}`, protocol: "ws", agent: "PersonalAgent", name: "personal", WebSocket: OwnerSocket, defaultCallTimeout: 10000, }); await bounded(owner.ready, "owner identity"); const call = (method, ...args) => owner.call(method, args); const leaf = async (id) => { const client = new AgentClient({ host: `127.0.0.1:${port}`, protocol: "ws", agent: "PersonalAgent", basePath: `agents/personal-agent/personal/sub/conversation/${id}`, WebSocket: OwnerSocket, defaultCallTimeout: 10000, }); clients.add(client); await bounded(client.ready, "conversation identity"); return client; }; const nativeSend = async (client, id, text) => { const transport = new WebSocketChatTransport({ agent: client }); const stream = await transport.sendMessages({ chatId: id, messages: [ ...(await history(id)), { id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: `chat:${text}` }], }, ], trigger: "submit-message", abortSignal: AbortSignal.any([t.signal, AbortSignal.timeout(20000)]), }); for await (const _chunk of stream) { /* Consume the real native transport. */ } }; const make = (name) => call("createConversation", name); const history = async (id) => ( await fetch( `${origin}/agents/personal-agent/personal/sub/conversation/${id}/get-messages`, { headers: { Cookie: cookie }, signal: AbortSignal.any([t.signal, AbortSignal.timeout(15000)]), }, ) ).json(); browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, }); await context.addCookies([ { name: cookie.split("=")[0], value: cookie.slice(cookie.indexOf("=") + 1), url: origin.replace("http:", "https:"), httpOnly: true, secure: true, sameSite: "Strict", }, ]); const page = await context.newPage(); page.setDefaultTimeout(15000); const errors = []; const run = (name, body) => t.test( name, { skip: process.env.FLAREBOT_CHAT_CASE ? !name.includes(process.env.FLAREBOT_CHAT_CASE) : false, }, async () => { try { await body(); } finally { for (const release of gates) release(); gates.clear(); for (const client of clients) client.close(); clients.clear(); // A failed fault-injection case must not leave delayed routes or // a mobile viewport affecting the next independent assertion. await page.unrouteAll({ behavior: "ignoreErrors" }); if (!page.isClosed()) await page.setViewportSize({ width: 1280, height: 900 }); } }, ); const frames = []; page.on("websocket", (socket) => { socket.on("framereceived", ({ payload }) => { try { const frame = JSON.parse(String(payload)); if ( String(frame.type).startsWith("cf_agent_stream") || frame.type === "cf_agent_chat_recovering" || frame.done || frame.error ) { frames.push(frame); if (frames.length > 100) frames.shift(); } } catch {} }); }); page.on("pageerror", (error) => errors.push(error.message)); const screen = page.locator(".conversation-page"); const rows = page.locator('.chat-message[data-role="assistant"]'); const ready = async (p = page) => p .locator(".conversation-heading") .getByRole("status") .filter({ hasText: /^Connected$/ }) .waitFor(); const open = async (id, p = page) => { await p.goto(`${origin}/conversations/${id}`); await ready(p); }; const send = async (text, p = page) => { await p .getByRole("textbox", { name: "Message", exact: true }) .fill(`chat:${text}`); await p .getByRole("button", { name: "Send message", exact: true }) .click(); }; const first = await make("Private chat fixture"); await run( "previous release model selection keeps settings and conversation connected", async () => { const defaults = await call("getModelSettings"); const conversation = await make("Previous release model"); try { const seeded = await fetch(`${origin}/__chat/previous-model`, { method: "POST", }); assert.deepEqual(await seeded.json(), { ok: true }); await page.goto(`${origin}/conversations/${conversation.id}`); try { await ready(); } catch (error) { await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "previous-model-chat.png"), }); throw error; } assert.equal( await page .getByText("Connection lost.", { exact: false }) .count(), 0, ); assert.deepEqual((await call("getModelSettings")).configuration, { provider: "openrouter", model: "openai/gpt-5-mini", }); await page.goto(`${origin}/settings`); await page .getByRole("combobox", { name: "Model", exact: true }) .waitFor(); assert.equal( await page .getByText("Could not load model settings.", { exact: false }) .count(), 0, ); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "previous-model-settings.png"), }); } finally { await call("updateModelSettings", defaults.configuration); } }, ); await run( "composer model and effort are scoped, native Kumo controls", async () => { const conversation = await make("Model selection"); const other = await make("Independent model selection"); const defaults = await call("getModelSettings"); const choose = async (label, option) => { await page .getByRole("combobox", { name: label, exact: true }) .click(); await page .getByRole("option", { name: option, exact: true }) .click(); }; await open(conversation.id); assert.equal( await page .getByRole("combobox", { name: "Effort", exact: true }) .count(), 0, ); await page .getByRole("combobox", { name: "Model", exact: true }) .click(); assert.equal( await page .getByRole("option", { name: "claude-sonnet-5 · Paid", exact: true, }) .getAttribute("aria-disabled"), "true", ); await page.keyboard.press("Escape"); await choose("Model", "thinkingmachines/inkling-256k · Paid"); assert.match( await page .getByRole("combobox", { name: "Model", exact: true }) .innerText(), /inkling-256k · Paid/, ); await choose("Effort", "High"); const assertComposerLayout = async () => { const bounds = await page .locator(".chat-composer-surface") .evaluate((surface) => { const box = (element) => { const { x, y, right, bottom, height } = element.getBoundingClientRect(); return { x, y, right, bottom, height }; }; return { surface: box(surface), input: box(surface.querySelector("textarea")), model: box(surface.querySelector('[aria-label="Model"]')), effort: box(surface.querySelector('[aria-label="Effort"]')), send: box( surface.querySelector('[aria-label="Send message"]'), ), }; }); for (const control of [bounds.model, bounds.effort, bounds.send]) { assert.ok(control.y >= bounds.input.bottom); assert.ok(control.bottom <= bounds.surface.bottom); assert.ok( control.x >= bounds.surface.x && control.right <= bounds.surface.right, ); assert.ok( Math.abs( control.y + control.height / 2 - (bounds.send.y + bounds.send.height / 2), ) < 2, ); } assert.ok(bounds.model.right <= bounds.effort.x); assert.ok(bounds.effort.right <= bounds.send.x); assert.equal(await page.locator(".chat-model-hint").count(), 0); }; await assertComposerLayout(); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "composer-effort-desktop.png"), }); await page .getByRole("textbox", { name: "Message", exact: true }) .fill("Help me plan the next release."); await page.locator(".chat-composer").screenshot({ path: join(screenshots, "composer-ready.png"), }); await send("stream"); await page.getByRole("button", { name: "Stop response" }).waitFor(); await page.locator(".chat-composer").screenshot({ path: join(screenshots, "composer-streaming.png"), }); assert.ok( await page .getByRole("combobox", { name: "Model", exact: true }) .isDisabled(), ); assert.ok( await page .getByRole("combobox", { name: "Effort", exact: true }) .isDisabled(), ); await ready(); const client = await leaf(conversation.id); assert.deepEqual( (await client.call("getConversationModelSettings")).override, { provider: "workers-ai", model: "thinkingmachines/inkling-256k", effort: "high", }, ); assert.deepEqual( (await call("getModelSettings")).configuration, defaults.configuration, ); await page.reload(); await ready(); assert.match( await page .getByRole("combobox", { name: "Effort", exact: true }) .innerText(), /High/, ); await page.setViewportSize({ width: 390, height: 844 }); await assertComposerLayout(); await page.screenshot({ path: join(screenshots, "composer-effort-mobile.png"), }); assert.ok( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ); await page .getByRole("combobox", { name: "Effort", exact: true }) .click(); assert.deepEqual(await page.getByRole("option").allTextContents(), [ "Provider default", "Low", "Medium", "High", "Extra high", "Maximum", ]); await page.screenshot({ path: join(screenshots, "composer-effort-menu.png"), }); await page.keyboard.press("Escape"); await page.setViewportSize({ width: 1280, height: 900 }); await choose( "Model", "@cf/meta/llama-4-scout-17b-16e-instruct · Free allowance", ); assert.equal( await page .getByRole("combobox", { name: "Effort", exact: true }) .count(), 0, ); await choose("Model", "thinkingmachines/inkling-256k · Paid"); assert.match( await page .getByRole("combobox", { name: "Effort", exact: true }) .innerText(), /Provider default/, ); await choose("Model", "Installation default"); await send("fast"); await ready(); assert.equal( (await client.call("getConversationModelSettings")).override, null, ); await open(other.id); assert.match( await page .getByRole("combobox", { name: "Model", exact: true }) .innerText(), /Installation default/, ); // Defaults changed in another tab are refreshed on submission, not pinned // forever to the configuration that was loaded when opening this chat. await call("updateModelSettings", { provider: "workers-ai", model: "thinkingmachines/inkling-256k", effort: "low", }); await send("fast"); await ready(); assert.match( await page .getByRole("combobox", { name: "Effort", exact: true }) .innerText(), /Low/, ); assert.equal( (await (await leaf(other.id)).call("getConversationModelSettings")) .override, null, ); await call("updateModelSettings", defaults.configuration); }, ); await run( "first-message title updates the sidebar without reload", async () => { const named = await call("createConversation"); await open(named.id); const link = page.locator( `aside a[href="/conversations/${named.id}"]`, ); await link.filter({ hasText: /^New conversation$/ }).waitFor(); await page .getByRole("textbox", { name: "Message", exact: true }) .fill("title:Configure Flarebot locally"); await page .getByRole("button", { name: "Send message", exact: true }) .click(); await link .filter({ hasText: /^Configure Flarebot locally$/ }) .waitFor(); await ready(); await send("fast"); await ready(); assert.equal(await link.innerText(), "Configure Flarebot locally"); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "automatic-title.png"), }); await page.reload(); await ready(); await link .filter({ hasText: /^Configure Flarebot locally$/ }) .waitFor(); }, ); await run( "incremental typed output persists and reloads exactly", async () => { await open(first.id); await send("stream"); await until( () => rows.last().innerText(), (text) => text.includes("stream-0") && !text.includes("finished."), "incremental stream", ); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("stream"), ); await page.reload(); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("stream"), ); assert.equal( (await history(first.id)).filter((m) => m.role === "user").length, 1, ); }, ); await run( "markdown, highlighted code, sources and safe failure details", async () => { await open(first.id); await send("rich"); await ready(); await rows .last() .getByRole("heading", { name: "Research result" }) .waitFor(); assert.equal( await rows.last().locator("strong").innerText(), "clear", ); assert.equal(await rows.last().locator(".chat-tool").count(), 2); assert.deepEqual( await rows.last().locator(".chat-tool-status").allTextContents(), ["succeeded", "failed"], ); await rows.last().locator(".chat-tool summary").last().click(); await rows .last() .getByText("Safe tool failure", { exact: false }) .waitFor(); const disclosure = rows.last().locator(".chat-source-disclosure"); const summary = disclosure.locator("summary"); const sourceLink = disclosure.locator("a"); assert.equal(await summary.innerText(), "Sources (1)"); assert.equal(await sourceLink.isVisible(), false); await summary.focus(); await page.keyboard.press("Enter"); assert.equal(await sourceLink.isVisible(), true); assert.equal( await rows .last() .getByRole("navigation", { name: "Sources" }) .locator("a") .getAttribute("href"), "https://developers.cloudflare.com/agents/", ); assert.equal( await page .locator( '.chat-transcript a[href^="javascript:"],.chat-transcript a[href^="data:"],.chat-transcript img,.chat-transcript script', ) .count(), 0, ); assert.equal(await page.evaluate(() => window.chatXss), undefined); assert.equal( await rows .last() .locator(".chat-tool pre") .first() .evaluate((element) => getComputedStyle(element).fontSize), "14px", ); await rows.last().locator(".chat-tool summary").last().click(); await rows.last().locator(".chat-code").scrollIntoViewIfNeeded(); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await rows .last() .getByRole("button", { name: "Copy code", exact: true }) .click(); await rows .last() .locator(".chat-code-toolbar") .getByRole("status") .filter({ hasText: "Copied" }) .waitFor(); assert.match( await page.evaluate(() => navigator.clipboard.readText()), /const answer =/, ); await page.setViewportSize({ width: 375, height: 812 }); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, ); assert.equal( await page .getByRole("textbox", { name: "Message", exact: true }) .evaluate((e) => getComputedStyle(e).fontSize), "14px", ); await mkdir(screenshots, { recursive: true, }); await disclosure.scrollIntoViewIfNeeded(); await page.screenshot({ path: join(screenshots, "sources-expanded-mobile.png"), }); await summary.click(); assert.equal(await sourceLink.isVisible(), false); await page.screenshot({ path: join(screenshots, "sources-collapsed-mobile.png"), }); await rows.last().locator(".chat-code").scrollIntoViewIfNeeded(); await page.screenshot({ path: join(screenshots, "mobile.png"), }); await page.setViewportSize({ width: 1280, height: 900 }); await rows.last().locator(".chat-code").scrollIntoViewIfNeeded(); await page.screenshot({ path: join(screenshots, "desktop.png"), }); }, ); await run( "reload and offline replay preserve exact text once", async () => { await send("reload"); await until( () => rows.last().innerText(), (text) => text.includes("reload-2"), "partial before reload", ); await page.reload(); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("reload"), ); await send("offline"); await until( () => rows.last().innerText(), (text) => text.includes("offline-2"), "partial before offline", ); await context.setOffline(true); await sleep(700); await context.setOffline(false); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("offline"), ); }, ); await run("pending before first chunk survives reload", async () => { await send("pending"); await sleep(300); await page.reload(); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("pending"), ); }); await run( "observer tab stops actual server work and Retry keeps one user", async () => { const conversation = await make("Observer cancellation"); await open(conversation.id); const observer = await context.newPage(); observer.on("pageerror", (error) => errors.push(error.message)); await open(conversation.id, observer); await send("stop"); await observer.getByText("stop-2", { exact: false }).waitFor(); await observer .getByRole("button", { name: "Stop response", exact: true }) .click(); await ready(observer); await ready(); const partial = await history(conversation.id); assert.equal(partial.filter((m) => m.role === "user").length, 1); assert.ok(!JSON.stringify(partial).includes("finished.")); await page .getByRole("button", { name: "Retry last response", exact: true }) .click(); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("stop"), ); await page.reload(); await ready(); assert.equal( (await history(conversation.id)).filter((m) => m.role === "user") .length, 1, ); assert.equal(await rows.count(), 1); await observer.close(); }, ); await run( "owning tab Stop retains a stable partial after reload", async () => { const conversation = await make("Owning cancellation"); await open(conversation.id); await send("ownstop"); await until( () => rows.last().innerText(), (text) => text.includes("ownstop-2"), "owning partial", ); await page .getByRole("button", { name: "Stop response", exact: true }) .click(); await ready(); const partial = await rows .last() .locator(".chat-markdown") .innerText(); assert.ok(!partial.includes("finished.")); await sleep(700); assert.equal( await rows.last().locator(".chat-markdown").innerText(), partial, ); await page.reload(); await ready(); assert.equal( await rows.last().locator(".chat-markdown").innerText(), partial, ); }, ); await run( "unaccepted send preserves its text without overwriting a newer draft", async () => { const conversation = await make("Unaccepted message"); let drop = true; await page.routeWebSocket( `**/sub/conversation/${conversation.id}*`, (socket) => { const server = socket.connectToServer(); socket.onMessage((message) => { const frame = JSON.parse(String(message)); if ( drop && frame.type === MessageType.CF_AGENT_USE_CHAT_REQUEST ) { drop = false; socket.close({ code: 1011, reason: "Fixture before acceptance", }); server.close(); return; } server.send(message); }); }, ); await open(conversation.id); await send("unsent"); await page .getByRole("textbox", { name: "Message", exact: true }) .fill("Newer draft"); await ready(); assert.equal( drop, false, "actual native send was dropped before forwarding", ); await screen .getByRole("alert") .filter({ hasText: "message was not saved" }) .waitFor(); assert.equal( await page .getByRole("textbox", { name: "Message", exact: true }) .inputValue(), "Newer draft", ); assert.equal((await history(conversation.id)).length, 0); await page .getByRole("textbox", { name: "Message", exact: true }) .fill(""); await page .getByRole("button", { name: "Restore unsent message" }) .click(); assert.equal( await page .getByRole("textbox", { name: "Message", exact: true }) .inputValue(), "chat:unsent", ); await page .getByRole("button", { name: "Send message", exact: true }) .click(); await ready(); assert.equal( (await history(conversation.id)).filter( (message) => message.role === "user", ).length, 1, ); }, ); await run( "switching conversations detaches without cancellation", async () => { const a = await make("Continue after navigation"); const b = await make("Isolated chat"); await open(a.id); await send("navigate"); await until( () => rows.last().innerText(), (text) => text.includes("navigate-2"), "partial before navigation", ); await page.goto(`${origin}/conversations/${b.id}`); await ready(); assert.equal(await rows.count(), 0); await sleep(3500); assert.equal(await rows.count(), 0); await open(a.id); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("navigate"), ); }, ); await run( "long history windows and prepending preserve reading position", async () => { const conversation = await make("Long history"); await fetch(`${origin}/__chat/seed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: conversation.id, count: 100 }), }); await open(conversation.id); assert.equal(await page.locator(".chat-message").count(), 40); const scroll = page.locator(".chat-scroll"); await scroll.evaluate((e) => { e.scrollTop = 0; }); const firstId = await page .locator(".chat-message") .first() .getAttribute("data-message-id"); const preserved = page.locator(`[data-message-id="${firstId}"]`); const before = await preserved.boundingBox(); await page .getByRole("button", { name: "Load earlier messages", exact: false, }) .click(); await until( () => page.locator(".chat-message").count(), (count) => count === 80, "prepended rows", ); await until( () => preserved.boundingBox(), (after) => Math.abs(before.y - after.y) < 4, "prepend preserves anchor", ); assert.equal(await page.locator(".chat-message").count(), 80); const savedTop = await scroll.evaluate( (element) => element.scrollTop, ); await page .getByRole("link", { name: "Scheduled tasks", exact: true }) .click(); await page .getByRole("link", { name: "Long history", exact: true }) .first() .click(); await ready(); assert.equal(await page.locator(".chat-message").count(), 80); await until( () => scroll.evaluate((element) => element.scrollTop), (top) => Math.abs(top - savedTop) < 4, "SPA return restores window and reading position", ); await page .getByRole("button", { name: "Jump to latest", exact: true }) .click(); await until( () => scroll.evaluate( (e) => e.scrollHeight - e.scrollTop - e.clientHeight, ), (distance) => distance < 64, "jump latest", ); }, ); await run( "queued user snapshots never duplicate an active assistant", async () => { const conversation = await make("Queued turn"); await open(conversation.id); const client = await leaf(conversation.id); try { await send("queue"); await until( () => rows.last().innerText(), (text) => text.includes("queue-2"), "first queued stream", ); const queued = nativeSend(client, conversation.id, "fast"); await queued; await ready(); await until( () => rows.count(), (count) => count === 2, "two distinct answers", ); assert.deepEqual( await rows.locator(".chat-markdown").allTextContents(), [answer("queue"), answer("fast")], ); const persisted = await history(conversation.id); assert.deepEqual( persisted.map((message) => message.role), ["user", "user", "assistant", "assistant"], ); assert.equal( new Set( await page .locator(".chat-message") .evaluateAll((elements) => elements.map((element) => element.dataset.messageId), ), ).size, 4, ); } finally { client.close(); } }, ); await run( "accepted disconnect reconciles before retry and a lost probe recovers", async () => { const conversation = await make("Lost accepted reply"); let cut = false, lostProbe = false; await page.routeWebSocket( `**/sub/conversation/${conversation.id}*`, (socket) => { const server = socket.connectToServer(); socket.onMessage((message) => server.send(message)); server.onMessage((message) => { const frame = JSON.parse(String(message)); if ( cut && frame.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE ) { cut = false; socket.close({ code: 1011, reason: "Fixture lost accepted reply", }); server.close(); return; } if ( lostProbe && frame.type === MessageType.CF_AGENT_STREAM_RESUME_NONE ) { lostProbe = false; return; // Lose one reply while the real socket stays open. } socket.send(message); }); }, ); await open(conversation.id); cut = true; await send("accepted"); await until( () => page.locator(".conversation-heading").innerText(), (text) => text.includes("Reconnecting"), "lost accepted socket", ); assert.equal( await page .getByRole("button", { name: "Send message", exact: true }) .isDisabled(), true, ); await ready(); assert.equal(cut, false, "accepted response was actually cut"); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("accepted"), ); assert.equal( (await history(conversation.id)).filter( (message) => message.role === "user", ).length, 1, ); lostProbe = true; await page.reload(); await until( () => lostProbe, (lost) => !lost, "probe reply dropped", ); await sleep(5500); await page .getByRole("textbox", { name: "Message", exact: true }) .fill("Draft while reconciling"); assert.equal( await page .getByRole("button", { name: "Send message", exact: true }) .isDisabled(), true, "native probe timeout does not prove idle", ); await ready(); assert.equal(lostProbe, false, "resume probe was actually lost"); assert.equal( await rows.last().locator(".chat-markdown").innerText(), answer("accepted"), ); }, ); await run( "clear invalidates delayed HTTP and in-flight chunks", async () => { const conversation = await make("Clear race"); await open(conversation.id); await send("fast"); await ready(); let release; const gate = new Promise((resolve) => { release = resolve; gates.add(resolve); }); let captured = false; await page.route( `**/sub/conversation/${conversation.id}/get-messages`, async (route) => { const response = await route.fetch(); if (!captured) { captured = true; await gate; } await route.fulfill({ response }); }, ); await page.reload({ waitUntil: "domcontentloaded" }); await until(() => captured, Boolean, "captured old HTTP history"); const client = await leaf(conversation.id); client.send( JSON.stringify({ type: MessageType.CF_AGENT_CHAT_CLEAR }), ); await until( () => history(conversation.id), (messages) => messages.length === 0, "native clear", ); release(); await ready(); assert.equal(await rows.count(), 0); // The stale reply schedules a native reconcile read; let that // in-flight interception finish before removing the route so // Playwright does not auto-continue it under a pending fulfill. await bounded( page.unrouteAll({ behavior: "wait" }), "clear history routes", ); await send("clear"); await until( () => rows.last().innerText(), (text) => text.includes("clear-2"), "stream before clear", ); client.send( JSON.stringify({ type: MessageType.CF_AGENT_CHAT_CLEAR }), ); await until( () => page.locator(".chat-message").count(), (count) => count === 0, "clear removes streamed rows", ); await sleep(600); assert.equal(await rows.count(), 0); await send("fast"); await ready(); assert.equal(await rows.count(), 1); client.close(); }, ); await run( "stale HTTP from a previous route cannot mutate selection", async () => { const a = await make("Delayed history"); const b = await make("Current conversation"); let release; const gate = new Promise((resolve) => { release = resolve; gates.add(resolve); }); let captured = false; await page.route( `**/sub/conversation/${a.id}/get-messages`, async (route) => { const response = await route.fetch(); captured = true; await gate; await route.fulfill({ response }); }, ); await page.goto(`${origin}/conversations/${a.id}`, { waitUntil: "domcontentloaded", }); await until(() => captured, Boolean, "old history pending"); await page.goto(`${origin}/conversations/${b.id}`); await ready(); await page .getByRole("textbox", { name: "Message", exact: true }) .fill("New conversation draft"); release(); await sleep(250); assert.equal( await screen.getByRole("heading", { level: 1 }).innerText(), "Current conversation", ); assert.equal( await page .getByRole("textbox", { name: "Message", exact: true }) .inputValue(), "New conversation draft", ); assert.equal(await rows.count(), 0); await bounded( page.unrouteAll({ behavior: "wait" }), "previous history routes", ); }, ); await run( "upward reading stays put while native output grows", async () => { const conversation = await make("Reading while streaming"); await fetch(`${origin}/__chat/seed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: conversation.id, count: 50 }), }); await open(conversation.id); await send("reading"); await until( () => rows.last().innerText(), (text) => text.includes("reading-2"), "stream before upward scroll", ); const scroll = page.locator(".chat-scroll"); await scroll.evaluate((e) => { e.scrollTop = 100; }); await sleep(500); assert.ok( Math.abs((await scroll.evaluate((e) => e.scrollTop)) - 100) < 4, ); await page .getByRole("button", { name: "Jump to latest", exact: true }) .click(); await ready(); await until( () => scroll.evaluate( (e) => e.scrollHeight - e.scrollTop - e.clientHeight, ), (distance) => distance < 64, "follow after jump", ); }, ); await run( "full Worker restart preserves native identity and honest recovery", async () => { const conversation = await make("Restart recovery"); await open(conversation.id); await send("restart"); await until( () => rows.last().innerText(), (text) => text.includes("restart-1"), "partial before restart", ); await worker.stop(); worker = await start(); await page .locator(".conversation-heading") .getByRole("status") .filter({ hasText: /^Connected$/ }) .waitFor({ timeout: 45000 }) .catch(async (error) => { console.error( "RESTART UI", JSON.stringify(frames), await screen.innerText(), "HISTORY", JSON.stringify(await history(conversation.id)), ); throw error; }); const persisted = await history(conversation.id); assert.equal( persisted.filter((message) => message.role === "user").length, 1, ); assert.equal( persisted.find((message) => message.role === "user").parts[0].text, "chat:restart", ); assert.ok( (await call("listConversations")).some( (item) => item.id === conversation.id, ), ); assert.deepEqual( await page .locator(".chat-message") .evaluateAll((elements) => elements.map((element) => element.dataset.messageId), ), persisted.map((message) => message.id), ); assert.deepEqual( await page.locator(".chat-message").evaluateAll((elements) => elements.map((element) => ({ id: element.dataset.messageId, texts: [ ...element.querySelectorAll(".chat-user-text,.chat-markdown"), ].map((part) => part.textContent), })), ), persisted.map((message) => ({ id: message.id, texts: message.parts .filter((part) => part.type === "text") .map((part) => part.text), })), "terminal UI parts match authoritative native history", ); const text = await rows.allTextContents(); assert.equal( text.join("").split("Recovered remaining response.").length, 2, "mock honored the actual native continuation instruction", ); assert.ok( text.join("").split("Starting ").length <= 2, JSON.stringify(text), ); assert.ok( await page .getByRole("button", { name: "Retry last response", exact: true }) .isEnabled(), ); }, ); await run( "model failure is recoverable and actual session expiry clears all private UI", async () => { const conversation = await make("Private expired title"); await open(conversation.id); await send("error"); await ready(); await screen .getByRole("alert") .filter({ hasText: "could not be completed" }) .waitFor(); assert.ok( await page .getByRole("button", { name: "Retry last response", exact: true }) .isEnabled(), ); await page .getByRole("button", { name: "Retry last response", exact: true }) .click(); await ready(); assert.equal( (await history(conversation.id)).filter( (message) => message.role === "user", ).length, 1, ); const expiresContext = await browser.newContext(); const claims = JSON.parse( Buffer.from(cookie.split("=")[1].split(".")[0], "base64url"), ); const now = Math.floor(Date.now() / 1000); const body = Buffer.from( JSON.stringify({ ...claims, issuedAt: now, expiresAt: now + 5 }), ).toString("base64url"); const signature = createHmac( "sha256", customerBindings.FLAREBOT_SESSION_SECRET, ) .update(body) .digest("base64url"); await expiresContext.addCookies([ { name: "__Host-flarebot-session", value: `${body}.${signature}`, url: origin.replace("http:", "https:"), httpOnly: true, secure: true, sameSite: "Strict", }, ]); const expiresPage = await expiresContext.newPage(); expiresPage.on("pageerror", (error) => errors.push(error.message)); await open(conversation.id, expiresPage); await expiresPage .getByRole("textbox", { name: "Message", exact: true }) .fill("Private unsent draft"); await expiresPage .locator(".conversation-page") .getByRole("alert") .filter({ hasText: "Sign in" }) .waitFor(); assert.doesNotMatch( await expiresPage.locator(".conversation-page").innerText(), /Private expired title|Private unsent draft|chat:error/, ); assert.equal(await expiresPage.locator(".chat-message").count(), 0); assert.equal( await expiresPage.locator(".conversation-page textarea").count(), 0, ); await expiresContext.close(); }, ); await run( "private SSR and deleted conversation clear content", async () => { assert.doesNotMatch( await (await fetch(`${origin}/conversations/${first.id}`)).text(), /Private chat fixture|Starting stream|Research result/, ); await open(first.id); await call("deleteConversation", first.id); await screen .getByRole("alert") .filter({ hasText: "deleted" }) .waitFor(); assert.equal(await rows.count(), 0); assert.equal( await screen .getByRole("textbox", { name: "Message", exact: true }) .count(), 0, ); }, ); assert.deepEqual(errors, []); } finally { await cleanup(); } },);