Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
JavaScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287import { checkActivityProvenance } from "./fixtures/activity-provenance-checks.mjs";import { checkScrollClamp } from "./fixtures/chat-scroll-clamp-checks.mjs";import { checkSkillScriptOutput } from "./fixtures/skill-script-ui-checks.mjs";import { checkCapabilityApprovals } from "./fixtures/capability-approval-checks.mjs";import { waitForNativeReady } from "./fixtures/native-readiness.mjs";import { identityDropSocket, chatLifecycleTest,} from "./fixtures/native-readiness-probe.mjs";import { createChatDiagnostics } from "./fixtures/chat-diagnostics.mjs";import * as Effect from "effect/Effect";import 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 { 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); }}async function nativeIdentity(client, label, t) { const cancellation = new AbortController(); const connection = {}; try { await bounded( waitForNativeReady( client, AbortSignal.any([t.signal, cancellation.signal]), connection, ), label, ); } catch (error) { t.diagnostic( `${label}: ${JSON.stringify({ ...connection, readyState: client.readyState, identified: client.identified })}`, ); throw error; } finally { cancellation.abort(); }}const answer = (text) => `Starting ${Array.from({ length: 20 }, (_, i) => `${text}-${i} `).join("")}finished.`;chatLifecycleTest( "packaged conversation UI uses native Think streaming, history, tools, resume and cancellation", { timeout: 240000 }, async (t, startupOnly) => { 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 Effect.runPromise( 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(); const Socket = startupOnly ? identityDropSocket(WebSocket) : WebSocket; class OwnerSocket extends Socket { 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, }); const initialOwnerReady = owner.ready; await nativeIdentity(owner, "owner identity", t); if (startupOnly) assert.notEqual(owner.ready, initialOwnerReady); 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); const initialReady = client.ready; await nativeIdentity(client, "conversation identity", t); if (startupOnly) assert.notEqual(client.ready, initialReady); 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(); if (startupOnly) { const conversation = await make("Readiness reconnect"); await leaf(conversation.id); assert.equal(Socket.droppedRoutes.size, 2); return; } 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 () => { const diagnostics = createChatDiagnostics(page, name); try { await body(diagnostics); } catch (error) { await diagnostics?.report(); throw error; } finally { await diagnostics?.dispose(); 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 .getByRole("button", { name: "Attach files", exact: true }) .and(p.locator(":enabled")) .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(); }; await run( "image and PDF attachments upload, persist, download and enforce ownership", async () => { const conversation = await make("Attachments"); const other = await make("Other attachments"); await open(conversation.id); const pdf = Buffer.from("%PDF-1.4\nA fixture PDF\n%%EOF"); await page.getByLabel("Upload images or PDFs").setInputFiles({ name: "notes.pdf", mimeType: "application/pdf", buffer: pdf, }); await page .locator(".chat-composer") .getByRole("link", { name: "notes.pdf" }) .waitFor(); const download = page .locator(".chat-composer") .getByRole("link", { name: "notes.pdf" }); const href = await download.getAttribute("href"); const bytes = await context.request.get(origin + href, { headers: { Cookie: cookie }, }); assert.deepEqual(await bytes.body(), pdf); const unauthenticated = await fetch(origin + href); assert.equal(unauthenticated.status, 401); const cross = await context.request.get( origin + href.replace(conversation.id, other.id), { headers: { Cookie: cookie } }, ); assert.equal(cross.status(), 404); await page .getByRole("button", { name: "Send message", exact: true }) .click(); await page .locator('.chat-message[data-role="user"]') .getByRole("link", { name: "notes.pdf" }) .waitFor(); await ready(); const saved = await history(conversation.id); assert.equal(saved[0].metadata.attachments[0].name, "notes.pdf"); assert.ok( JSON.stringify(saved).length < 20000, "transcript stores references, not file bytes", ); await page.reload(); await ready(); await page .locator('.chat-message[data-role="user"]') .getByRole("link", { name: "notes.pdf" }) .waitFor(); const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64", ); await page.getByLabel("Upload images or PDFs").setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: png, }); await page.locator(".chat-composer img").waitFor(); assert.equal( await page .getByRole("button", { name: "Send message", exact: true }) .isDisabled(), true, ); await page.getByRole("button", { name: "Remove pixel.png" }).click(); await until( () => page.locator(".chat-composer img").count(), (n) => n === 0, "draft attachment removed", ); const bad = await context.request.post( `${origin}/api/conversations/${conversation.id}/attachments`, { headers: { Origin: origin, Cookie: cookie, "X-Filename": "fake.pdf", }, data: "<script>bad</script>", }, ); assert.equal(bad.status(), 400); const large = Buffer.alloc(20 * 1024 * 1024); large.write("%PDF-1.4\n"); const uploaded = await context.request.post( `${origin}/api/conversations/${conversation.id}/attachments`, { headers: { Origin: origin, Cookie: cookie, "X-Filename": "large.pdf", }, data: large, }, ); assert.equal(uploaded.status(), 201, await uploaded.text()); const largeFile = await uploaded.json(); const largeUrl = `${origin}/api/conversations/${conversation.id}/attachments/${largeFile.id}`; const largeDownload = await context.request.get(largeUrl, { headers: { Cookie: cookie }, }); assert.equal(largeDownload.status(), 200); assert.equal( Buffer.compare(await largeDownload.body(), large), 0, "maximum-size PDF survives native parent/child RPC and R2", ); const objects = async () => ( await context.request.get(`${origin}/__chat/attachment-objects`) ).json(); const prefix = `${conversation.id}/default/attachments/`; assert.ok((await objects()).some((key) => key.startsWith(prefix))); await call("deleteConversation", conversation.id); assert.ok( !(await objects()).some((key) => key.startsWith(prefix)), "native conversation deletion removes its R2 objects", ); assert.equal( ( await context.request.get(origin + href, { headers: { Cookie: cookie }, }) ).status(), 404, ); }, ); await run( "native Think reads image attachments through a real tool round trip", async () => { const defaults = await call("getModelSettings"); try { await call("updateModelSettings", { provider: "workers-ai", model: "@cf/meta/llama-4-scout-17b-16e-instruct", }); const conversation = await make("Native image read"); await open(conversation.id); const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64", ); await page.getByLabel("Upload images or PDFs").setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: png, }); const link = page .locator(".chat-composer") .getByRole("link", { name: "pixel.png" }); await link.waitFor(); const href = await link.getAttribute("href"); const id = href.split("/").at(-1).split("?")[0]; await send("What's this?"); await rows.filter({ hasText: "Resource saved." }).waitFor(); await ready(); const saved = await history(conversation.id); const result = saved .flatMap((m) => m.parts) .find((p) => p.type === "tool-read_attachment"); assert.equal(result.state, "output-available"); assert.equal(result.output.result.kind, "image"); assert.equal(result.input.id, id); assert.ok(!JSON.stringify(saved).includes(png.toString("base64"))); await page.reload(); await ready(); await page .getByRole("button", { name: "Retry last response" }) .click(); await ready(); assert.equal( (await history(conversation.id)).filter((m) => m.role === "user") .length, 1, ); await send("The attached image?"); await ready(); const followup = await history(conversation.id); assert.ok( followup .findLast((m) => m.role === "assistant") .parts.some( (p) => p.type === "tool-read_attachment" && p.state === "output-available", ), ); } finally { await call("updateModelSettings", defaults.configuration); } }, ); const first = await make("Private chat fixture"); await run( "approval controls review arguments and reconnect safely", async () => { const conversation = await make("Approval controls"); await checkCapabilityApprovals(context, origin, conversation.id); }, ); 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) => { if (label === "Model") await page .getByRole("combobox", { name: "Model", exact: true }) .click(); else await page .getByRole("combobox", { name: label, exact: true }) .click(); await page .getByRole("option", { name: option, exact: true }) .click(); }; await open(conversation.id); await mkdir(screenshots, { recursive: true }); const modelButton = page.getByRole("combobox", { name: "Model", exact: true, }); for (const width of [320, 390]) { await page.setViewportSize({ width, height: 844 }); // Viewport acknowledgement can precede Kumo's matchMedia render. await page .locator('[data-sidebar="sidebar"][data-mobile="true"]') .waitFor({ state: "attached" }); assert.equal(await modelButton.innerText(), "Installation default"); assert.ok( await modelButton .locator(".chat-model-trigger-label") .evaluate((label) => label.scrollWidth <= label.clientWidth), ); assert.ok((await modelButton.boundingBox()).height >= 44); } await page .getByRole("button", { name: "Switch to dark mode", exact: true }) .click(); await page.screenshot({ path: join(screenshots, "model-button-mobile.png"), }); await modelButton.click(); const picker = page.locator(".chat-model-dropdown"); await picker.waitFor(); await until( () => picker.evaluate((element) => getComputedStyle(element).opacity), (opacity) => opacity === "1", "model picker finishes opening", ); const dropdownBounds = await picker.boundingBox(); const triggerBounds = await page .locator(".chat-model-trigger") .boundingBox(); assert.ok(dropdownBounds.height <= 384, "model menu stays compact"); assert.ok( Math.abs( dropdownBounds.y + dropdownBounds.height - triggerBounds.y, ) <= 8, "model menu is anchored above the select", ); await page.screenshot({ path: join(screenshots, "model-picker-mobile.png"), }); const touch = await context.newCDPSession(page); for (const viewport of [ { width: 1280, height: 900 }, { width: 1280, height: 720 }, { width: 390, height: 667 }, { width: 390, height: 420 }, ]) { await page.keyboard.press("Escape"); await picker.waitFor({ state: "hidden" }); await page.setViewportSize(viewport); await modelButton.click(); await picker.waitFor(); await sleep(200); const list = picker.getByRole("listbox").locator(".."); const box = await list.boundingBox(); await page.mouse.move( box.x + box.width / 2, box.y + box.height / 2, ); if (viewport.width > 767) { for (let step = 0; step < 16; step++) { await page.mouse.wheel(0, 120); await sleep(80); } } else { await touch.send("Emulation.setTouchEmulationEnabled", { enabled: true, }); for (let swipe = 0; swipe < 8; swipe++) { const x = box.x + box.width / 2; const start = box.y + box.height - 20; await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y: start }], }); for (let step = 1; step <= 8; step++) { await touch.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [ { x, y: start - ((box.height - 40) * step) / 8 }, ], }); await sleep(16); } await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [], }); } await touch.send("Emulation.setTouchEmulationEnabled", { enabled: false, }); } await until( () => picker.getByRole("option").last().boundingBox(), (bounds) => bounds && bounds.y > 0 && bounds.y + bounds.height <= viewport.height - 16, `model picker scroll reaches final option at ${viewport.width}px`, 3000, ); await page.mouse.wheel(0, -3000); await sleep(200); } await page.setViewportSize({ width: 390, height: 420 }); await touch.detach(); await until( () => picker.boundingBox(), (box) => box && box.y >= 0 && box.y + box.height <= 420, "model picker fits above the mobile keyboard", ); await page .getByPlaceholder("Search models…", { exact: true }) .fill("INKLING"); await page .getByRole("option", { name: "thinkingmachines/inkling-256k", exact: true, }) .waitFor(); await page.screenshot({ path: join(screenshots, "model-search-keyboard-mobile.png"), }); await page.keyboard.press("Escape"); await picker.waitFor({ state: "hidden" }); assert.equal( await modelButton.getAttribute("aria-expanded"), "false", ); await page .getByRole("button", { name: "Switch to light mode", exact: true }) .click(); await page.setViewportSize({ width: 1280, height: 900 }); assert.equal( await page .getByRole("combobox", { name: "Effort", exact: true }) .count(), 0, ); await modelButton.click(); await picker.waitFor(); await page.screenshot({ path: join(screenshots, "model-select-desktop.png"), animations: "disabled", }); await page .getByPlaceholder("Search models…", { exact: true }) .fill("GLM"); await page .getByRole("option", { name: "@cf/zai-org/glm-5.3", exact: true }) .waitFor(); await until( () => picker.getByRole("option").count(), (count) => count === 4, "search filters the dropdown before capture", ); await page.screenshot({ path: join(screenshots, "model-search-desktop.png"), animations: "disabled", }); await page.keyboard.press("Escape"); await choose("Model", "@cf/zai-org/glm-5.3"); await choose("Effort", "High"); assert.match( await page .getByRole("combobox", { name: "Effort", exact: true }) .innerText(), /High/, ); await choose("Effort", "Low"); assert.match( await page .getByRole("combobox", { name: "Effort", exact: true }) .innerText(), /Low/, ); await choose("Model", "Installation default"); await page .getByRole("combobox", { name: "Model", exact: true }) .click(); assert.equal( await page .getByRole("option", { name: "claude-sonnet-5 Anthropic · Set up in Settings", exact: true, }) .getAttribute("aria-disabled"), "true", ); const search = page.getByPlaceholder("Search models…", { exact: true, }); await search.fill("no-such-model"); await until( () => page.getByText("No models found.", { exact: true }).isVisible(), (visible) => visible, "unmatched model search shows empty state", ); await search.fill("INKLING"); await page .getByRole("option", { name: "thinkingmachines/inkling-256k", exact: true, }) .waitFor(); assert.equal( await page .getByRole("option", { name: "Installation default", exact: true, }) .count(), 0, ); await search.press("ArrowDown"); const focusedModel = page.locator( '.chat-model-dropdown [role="option"][data-focused="true"]', ); assert.equal( await search.getAttribute("aria-activedescendant"), await focusedModel.getAttribute("id"), ); await search.press("Enter"); assert.match( await page .getByRole("combobox", { name: "Model", exact: true }) .innerText(), /inkling-256k/, ); 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, JSON.stringify(bounds), ); 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 page .locator('[data-sidebar="sidebar"][data-mobile="true"]') .waitFor({ state: "attached" }); 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"); assert.equal( await page .getByRole("combobox", { name: "Effort", exact: true }) .count(), 0, ); await choose("Model", "thinkingmachines/inkling-256k"); 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( "mobile composer uses a focus-zoom-safe font without disabling zoom", async () => { const conversation = await make("Mobile composer"); await open(conversation.id); const input = page.getByRole("textbox", { name: "Message", exact: true, }); await page.setViewportSize({ width: 1280, height: 900 }); assert.equal( await input.evaluate( (element) => getComputedStyle(element).fontSize, ), "14px", ); await page.setViewportSize({ width: 390, height: 844 }); await input.focus(); assert.equal( await input.evaluate( (element) => getComputedStyle(element).fontSize, ), "16px", ); assert.doesNotMatch( await page.locator('meta[name="viewport"]').getAttribute("content"), /user-scalable\s*=\s*no|maximum-scale/i, ); await input.fill("A readable message without automatic focus zoom."); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "composer-focused-mobile.png"), }); const touchContext = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 844, height: 390 }, }); try { await touchContext.addCookies(await context.cookies()); const touchPage = await touchContext.newPage(); await open(conversation.id, touchPage); const landscapeInput = touchPage.getByRole("textbox", { name: "Message", exact: true, }); await landscapeInput.focus(); assert.equal( await landscapeInput.evaluate( (element) => getComputedStyle(element).fontSize, ), "16px", ); } finally { await touchContext.close(); } await page.setViewportSize({ width: 1280, height: 900 }); }, ); 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( "native extension activity provenance persists and fits mobile chat", () => checkActivityProvenance({ page, call, make, open, send, ready, leaf, until, screenshots, }), ); await run( "Skill script output expands safely and fits mobile chat", async () => { const conversation = await make("Script output"); await open(conversation.id); await send( "resource:" + JSON.stringify({ toolName: "fixtureScriptOutput", input: {} }), ); await checkSkillScriptOutput({ page, rows, ready, screenshots }); }, ); await run( "markdown, highlighted code, sources and tool status banners", async () => { await open(first.id); await send("rich"); const tools = rows.last().locator(".chat-tool"); await tools.locator('[aria-label="Loading"]').first().waitFor(); assert.equal(await tools.first().getAttribute("aria-busy"), "true"); assert.equal( await tools.first().getAttribute("data-status"), "running", ); 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"], ); assert.equal(await tools.locator("pre, details[open]").count(), 0); assert.deepEqual(await tools.locator("summary").allTextContents(), [ "Activity details", "Activity details", ]); assert.equal( await tools.locator('[aria-label="Loading"]').count(), 0, ); assert.deepEqual( await tools.evaluateAll((elements) => elements.map((element) => element.getAttribute("aria-busy")), ), ["false", "false"], ); assert.equal( await tools.last().getAttribute("data-status"), "failed", ); await tools .last() .getByText("Tool invocation failed", { exact: false }) .waitFor(); assert.doesNotMatch( await tools.allTextContents().then((texts) => texts.join(" ")), /Safe tool failure|Native evidence|"fail"/, ); const colors = await tools.evaluateAll((elements) => elements.map( (element) => getComputedStyle(element).backgroundColor, ), ); assert.notEqual(colors[0], colors[1]); 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") .first() .evaluate((element) => getComputedStyle(element).fontSize), "14px", ); 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), "16px", ); 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 tools.first().scrollIntoViewIfNeeded(); await page.screenshot({ path: join(screenshots, "tool-banners-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 page.reload(); await ready(); assert.deepEqual( await tools.locator(".chat-tool-status").allTextContents(), ["Succeeded", "Failed"], ); assert.equal( await tools .locator("details[open], pre, svg[aria-label='Loading']") .count(), 0, ); await tools.first().scrollIntoViewIfNeeded(); await page.screenshot({ path: join(screenshots, "tool-banners-desktop.png"), }); }, ); await run("resource banner actions open the saved memory", async () => { const conversation = await make("Memory actions"); await open(conversation.id); const content = "Remembered from the resource banner test."; await send( "resource:" + JSON.stringify({ toolName: "remember", input: { content } }), ); await ready(); const fact = (await call("listMemories")).find( (item) => item.content === content, ); assert.ok(fact); const href = `/settings#memory-${fact.id}`; const viewMemory = rows .last() .locator(".chat-tool") .getByRole("link", { name: "View memory", exact: true }); assert.equal(await viewMemory.getAttribute("href"), href); assert.equal( await viewMemory.getAttribute("data-kumo-component"), "LinkButton", ); for (let index = 0; index < 5; index++) await call("addMemory", `Newer memory ${index}`); await page.setViewportSize({ width: 375, height: 812 }); await viewMemory.scrollIntoViewIfNeeded(); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, ); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "memory-action-mobile.png"), }); await viewMemory.focus(); await page.keyboard.press("Enter"); await page.waitForURL(origin + href); const target = page.locator(`#memory-${fact.id}`); await until( () => target.evaluate((element) => ({ focused: document.activeElement === element, top: element.getBoundingClientRect().top, bottom: element.getBoundingClientRect().bottom, })), (value) => value.focused && value.top >= 0 && value.bottom <= 812, "memory is focused and in view", ); assert.match( await target.innerText(), /Remembered from the resource banner test/, ); await page.reload(); await target.waitFor(); await until( () => target.evaluate((element) => document.activeElement === element), Boolean, "memory focus after reload", ); await open(conversation.id); await send( "resource:" + JSON.stringify({ toolName: "updateMemory", input: { id: fact.id, version: fact.version, content: "Updated from the banner test.", }, }), ); await ready(); assert.equal(await viewMemory.getAttribute("href"), href); await viewMemory.click(); await target .getByText("Updated from the banner test.", { exact: true }) .waitFor(); await call("deleteMemory", fact.id, fact.version + 1); await page.reload(); await page .getByText("This memory is no longer available.", { exact: true }) .waitFor(); }); await run( "reload and offline replay preserve exact text once", async () => { await open(first.id); 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 open(first.id); 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(".chat-notice").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 (diagnostics) => { 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"); const jump = page.getByRole("button", { name: "Jump to latest", exact: true, }); await scroll.hover(); const before = await scroll.evaluate((e) => e.scrollTop); await page.mouse.wheel(0, -24); await until( () => scroll.evaluate((e) => e.scrollTop), (top) => top < before, "small upward wheel moves away from bottom", ); await jump.waitFor(); assert.match(await jump.innerText(), /Response streaming/); const pausedTop = await scroll.evaluate((e) => e.scrollTop); const partial = await rows.last().innerText(); await sleep(500); assert.ok( Math.abs((await scroll.evaluate((e) => e.scrollTop)) - pausedTop) < 4, ); assert.notEqual( await rows.last().innerText(), partial, "output grows while reading stays put", ); await mkdir(screenshots, { recursive: true }); await page.screenshot({ path: join(screenshots, "scroll-streaming.png"), }); await ready(); assert.match(await jump.innerText(), /Response complete/); assert.ok( Math.abs((await scroll.evaluate((e) => e.scrollTop)) - pausedTop) < 4, ); await page.screenshot({ path: join(screenshots, "scroll-complete.png"), }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: join(screenshots, "scroll-complete-mobile.png"), }); await diagnostics.watchScroll(); await jump.click(); await jump.waitFor({ state: "hidden" }); await page.setViewportSize({ width: 1280, height: 900 }); await send("reading"); await until( () => rows.last().innerText(), (text) => text.includes("reading-2"), "second stream", ); await scroll.focus(); await page.keyboard.press("ArrowUp"); await jump.waitFor(); await sleep(250); const keyboardTop = await scroll.evaluate((e) => e.scrollTop); await sleep(350); assert.ok( Math.abs( (await scroll.evaluate((e) => e.scrollTop)) - keyboardTop, ) < 4, ); // Native downward scrolling to the bottom resumes following without a click. // The viewport and composer changed since the first wheel gesture. await scroll.hover(); await page.mouse.wheel(0, 10000); await jump.waitFor({ state: "hidden" }); await ready(); await until( () => scroll.evaluate( (e) => e.scrollHeight - e.scrollTop - e.clientHeight, ), (distance) => distance <= 1, "follow after returning to bottom", ); await page.mouse.wheel(0, -24); await jump.waitFor(); assert.match(await jump.innerText(), /Jump to latest/); assert.doesNotMatch(await jump.innerText(), /Response complete/); await jump.click(); // Scrollbar/assistive scrolling has no wheel or keyboard precursor. await scroll.evaluate((element) => { element.scrollTop -= 24; }); await jump.waitFor(); await jump.click(); await page.setViewportSize({ width: 390, height: 844 }); await send("reading"); await until( () => rows.last().innerText(), (text) => text.includes("reading-2"), "touch stream", ); const touch = await context.newCDPSession(page); await touch.send("Emulation.setTouchEmulationEnabled", { enabled: true, }); const mobileBox = await scroll.boundingBox(); const x = mobileBox.x + mobileBox.width / 2; const y = mobileBox.y + mobileBox.height / 2; const touchBefore = await scroll.evaluate( (element) => element.scrollTop, ); await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }], }); for (let step = 1; step <= 6; step++) { await touch.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + step * 8 }], }); await sleep(40); } // Hold before release to avoid kinetic scrolling in the position assertion. await sleep(150); await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [], }); await jump.waitFor(); assert.match(await jump.innerText(), /Response streaming/); const touchTop = await scroll.evaluate( (element) => element.scrollTop, ); assert.ok(touchTop < touchBefore); await sleep(350); assert.ok( Math.abs( (await scroll.evaluate((element) => element.scrollTop)) - touchTop, ) < 4, ); await page.screenshot({ path: join(screenshots, "scroll-streaming-mobile.png"), }); await jump.click(); await jump.waitFor({ state: "hidden" }); await ready(); assert.ok( await scroll.evaluate( (element) => element.scrollHeight - element.scrollTop - element.clientHeight <= 1, ), ); await touch.send("Emulation.setTouchEmulationEnabled", { enabled: false, }); await touch.detach(); await page.setViewportSize({ width: 1280, height: 900 }); }, ); await run("downward wheel resumes following after a viewport clamp", () => checkScrollClamp({ page, origin, make, open, send, ready, until, sleep, signal: t.signal, }), ); 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 .getByRole("button", { name: "Attach files", exact: true }) .and(page.locator(":enabled")) .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(); } },);