import { createServer } from "node:http"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, onTestFinished } from "vitest"; import { clickButton, control, createAndSelectWorkspace, evalIn, waitFor } from "@openwork/behaviors"; import type { Surface } from "@openwork/cdp"; import { screenshot, validate } from "@openwork/test-evidence"; import { desktop } from "@openwork/hosts"; import { needs, test, unmetNeeds } from "@openwork/testkit"; import type { TestNeeds } from "@openwork/testkit"; const requirements: TestNeeds = { optIn: ["OPENWORK_EVAL_E2E_TESTS"], }; const missingRequirements = unmetNeeds(requirements, process.env); const title = missingRequirements.length > 0 ? `sequential queued follow-ups skipped — needs: ${missingRequirements.join(", ")}` : "queued follow-ups drain as separate FIFO user turns, never one merged message"; const providerId = "sequential-queue-mock"; const modelId = "sequential-queue-model"; const firstPrompt = "Start the long deterministic task for sequential queue proof."; const queuedPromptOne = "Queued follow-up ONE for sequential drain proof."; const queuedPromptTwo = "Queued follow-up TWO for sequential drain proof."; const replies = [ "Deterministic long-task reply.", "Deterministic drain-one reply.", "Deterministic drain-two reply.", ] as const; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); type RuntimeCredentials = { port: string; token: string }; type EngineMessage = { id: string; role: string; text: string }; type EngineSnapshot = { sessionId: string; messages: EngineMessage[] }; type MainRequestLabel = "first" | "one" | "two" | "unexpected"; type MainRequest = { label: MainRequestLabel; lastUserText: string }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseRuntimeCredentials(raw: unknown): RuntimeCredentials { if (typeof raw !== "string") throw new Error(`Local server credentials were not serialized: ${String(raw)}`); const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || typeof parsed.port !== "string" || typeof parsed.token !== "string") { throw new Error(`Invalid local server credentials: ${raw}`); } return { port: parsed.port, token: parsed.token }; } function parseEngineSnapshot(payload: unknown): EngineSnapshot { if (!isRecord(payload) || !isRecord(payload.item)) throw new Error("Session snapshot response has no item."); const item = payload.item; if (!isRecord(item.session) || typeof item.session.id !== "string" || !Array.isArray(item.messages)) { throw new Error("Session snapshot item is malformed."); } const messages: EngineMessage[] = []; for (const entry of item.messages) { if (!isRecord(entry) || !isRecord(entry.info) || typeof entry.info.id !== "string") continue; const parts = Array.isArray(entry.parts) ? entry.parts : []; const text = parts.flatMap((part) => ( isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] )).join(""); messages.push({ id: entry.info.id, role: typeof entry.info.role === "string" ? entry.info.role : "", text, }); } return { sessionId: item.session.id, messages }; } async function readEngineSnapshot( credentials: RuntimeCredentials, workspaceId: string, sessionId: string, ): Promise { const base = `http://127.0.0.1:${credentials.port}/workspace/${encodeURIComponent(workspaceId)}/opencode/session`; const encodedSessionId = encodeURIComponent(sessionId); const options = { headers: { Authorization: `Bearer ${credentials.token}` }, signal: AbortSignal.timeout(15_000) }; const responses = await Promise.all([ fetch(`${base}/${encodedSessionId}`, options), fetch(`${base}/${encodedSessionId}/message`, options), fetch(`${base}/${encodedSessionId}/todo`, options), fetch(`${base}/status`, options), ]); const failed = responses.find((response) => !response.ok); if (failed) throw new Error(`Snapshot component failed: ${failed.status} ${(await failed.text()).slice(0, 500)}`); const [session, messages, todos, statuses]: unknown[] = await Promise.all(responses.map((response) => response.json())); const status = isRecord(statuses) && isRecord(statuses[sessionId]) ? statuses[sessionId] : { type: "idle" }; return parseEngineSnapshot({ item: { session, messages, todos, status } }); } async function waitForEngineSnapshot( credentials: RuntimeCredentials, workspaceId: string, sessionId: string, label: string, predicate: (snapshot: EngineSnapshot) => boolean, ): Promise { const deadline = Date.now() + 60_000; let latest: EngineSnapshot | null = null; let lastError: unknown = null; while (Date.now() < deadline) { try { latest = await readEngineSnapshot(credentials, workspaceId, sessionId); if (predicate(latest)) return latest; } catch (error) { lastError = error; } await sleep(250); } const failure = lastError instanceof Error ? ` lastError=${lastError.message}` : ""; throw new Error(`${label} did not converge; latest=${JSON.stringify(latest)}${failure}`); } async function waitForEngineReady( credentials: RuntimeCredentials, workspaceId: string, ): Promise { const deadline = Date.now() + 60_000; let last = ""; while (Date.now() < deadline) { try { const response = await fetch( `http://127.0.0.1:${credentials.port}/workspace/${encodeURIComponent(workspaceId)}/opencode/session`, { headers: { Authorization: `Bearer ${credentials.token}` }, signal: AbortSignal.timeout(15_000) }, ); if (response.ok) return; last = `HTTP ${response.status}`; } catch (error) { last = error instanceof Error ? error.message : String(error); } await sleep(500); } throw new Error(`Engine did not become ready after reload; last=${last}`); } async function activeSessionId(app: Surface): Promise { await waitFor(app, `(document.querySelector("[data-session-surface-id]")?.getAttribute("data-session-surface-id") ?? "").startsWith("ses_")`, { timeoutMs: 30_000, label: "active session surface", }); const value = await evalIn(app, `document.querySelector("[data-session-surface-id]")?.getAttribute("data-session-surface-id") ?? ""`); if (typeof value !== "string" || !value.startsWith("ses_")) throw new Error(`Active session id was unavailable: ${String(value)}`); return value; } async function typeIntoComposer(app: Surface, text: string): Promise { await waitFor(app, `(() => { const editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'); return editor instanceof HTMLElement && (editor.innerText ?? "").trim() === ""; })()`, { timeoutMs: 30_000, label: "empty composer ready" }); const focused = await evalIn(app, `(() => { const editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'); if (!(editor instanceof HTMLElement)) return false; editor.focus(); return true; })()`); expect(focused).toBe(true); await app.client.send("Input.insertText", { text }); await waitFor(app, `(document.querySelector('[contenteditable="true"][data-lexical-editor="true"]')?.innerText ?? "").trim() === ${JSON.stringify(text)}`, { timeoutMs: 10_000, label: `composer contains ${text}`, }); } // Plain Enter on the Lexical composer: while the agent is busy this must // queue (Cmd/Ctrl+Enter is the steer modifier, deliberately not sent here). async function pressEnter(app: Surface): Promise { await app.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: "\r", unmodifiedText: "\r", }); await app.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, }); } async function waitForReply(app: Surface, reply: string): Promise { await waitFor(app, `[...document.querySelectorAll('[data-message-role="assistant"]')] .some((message) => message.textContent?.includes(${JSON.stringify(reply)}))`, { timeoutMs: 120_000, label: `assistant reply ${reply}`, }); } function lastUserTextFromBody(rawBody: string): string { let parsed: unknown; try { parsed = JSON.parse(rawBody); } catch { return ""; } if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return ""; for (let index = parsed.messages.length - 1; index >= 0; index -= 1) { const message: unknown = parsed.messages[index]; if (!isRecord(message) || message.role !== "user") continue; const content = message.content; if (typeof content === "string") return content; if (Array.isArray(content)) { return content.flatMap((part) => ( isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] )).join(""); } return ""; } return ""; } test(title, async ({ evidence }) => { needs(requirements); // Deterministic OpenAI-compatible provider. The conversation grows // monotonically, so replies key off the newest prompt first. Utility // requests (session titles) carry no tools array and never count as main // completions. The first two main completions are HELD OPEN until the // runner releases them, so the busy windows last exactly as long as the // queue interactions and mid-drain assertions need — no timing races. const mainRequests: MainRequest[] = []; const releases: Record<"first" | "one", { promise: Promise; release: () => void }> = (() => { const gate = (): { promise: Promise; release: () => void } => { let release: () => void = () => undefined; const promise = new Promise((resolve) => { release = resolve; }); return { promise, release }; }; return { first: gate(), one: gate() }; })(); const classify = (rawBody: string): MainRequestLabel => { if (rawBody.includes(queuedPromptTwo)) return "two"; if (rawBody.includes(queuedPromptOne)) return "one"; if (rawBody.includes(firstPrompt)) return "first"; return "unexpected"; }; const mock = createServer((request, response) => { const url = request.url ?? ""; if (request.method === "GET" && url.startsWith("/v1/models")) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ object: "list", data: [{ id: modelId, object: "model" }] })); return; } if (request.method === "POST" && (url === "/v1/chat/completions" || url === "/chat/completions")) { request.setEncoding("utf8"); let rawBody = ""; request.on("data", (chunk: string) => { rawBody += chunk; }); request.on("end", () => { let parsedBody: unknown; try { parsedBody = JSON.parse(rawBody); } catch { parsedBody = null; } const tools = isRecord(parsedBody) ? parsedBody.tools : undefined; const isMain = Array.isArray(tools) && tools.length > 0; const label = classify(rawBody); let reply = "Session title"; if (isMain) { mainRequests.push({ label, lastUserText: lastUserTextFromBody(rawBody) }); reply = label === "unexpected" ? `Unexpected completion for: ${rawBody.slice(0, 200)}` : label === "first" ? replies[0] : label === "one" ? replies[1] : replies[2]; } const id = `chatcmpl-sequential-queue-${mainRequests.length}`; response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); const write = (chunk: unknown): void => { if (!response.writableEnded) response.write(`data: ${JSON.stringify(chunk)}\n\n`); }; write({ id, object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }); const finish = (): void => { write({ id, object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: reply }, finish_reason: null }] }); setTimeout(() => { write({ id, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }); setTimeout(() => { if (!response.writableEnded) response.end("data: [DONE]\n\n"); }, 300); }, 300); }; if (isMain && (label === "first" || label === "one")) { void releases[label].promise.then(finish); return; } setTimeout(finish, 400); }); return; } response.writeHead(404, { "content-type": "application/json" }); response.end(JSON.stringify({ error: { message: "not found" } })); }); await new Promise((resolve, reject) => { mock.once("error", reject); mock.listen(0, "127.0.0.1", resolve); }); onTestFinished(async () => { // Release any still-gated completions and drop held SSE connections so a // failed run cannot hang server close. releases.first.release(); releases.one.release(); mock.closeAllConnections(); await new Promise((resolve, reject) => mock.close((error) => error ? reject(error) : resolve())); }); const address = mock.address(); if (!address || typeof address === "string") throw new Error("Mock provider did not bind a TCP port."); const baseUrl = `http://127.0.0.1:${address.port}/v1`; await using app = await desktop({ name: "sequential-queue", mode: process.env.OPENWORK_EVAL_CDP_URL?.trim() ? "attach" : "spawn", // Provider keys in the runner env (e.g. via infisical) would make the // engine register real providers and out-default the deterministic mock. env: { ANTHROPIC_API_KEY: "", OPENAI_API_KEY: "", OPENROUTER_API_KEY: "", GOOGLE_GENERATIVE_AI_API_KEY: "", OPENWORK_API_KEY: "", OPENWORK_INFERENCE_BASE_URL: "", }, }); // Register the deterministic provider through the workspace's own // opencode.json BEFORE the workspace is added. The engine reads it at // spawn, so no config PATCH, engine reload, or renderer reload is needed — // reloading mid-session detaches the renderer's engine event stream and // would starve the live busy status this spec asserts on. const workspacePath = `/tmp/openwork-sequential-queue-${Date.now()}`; await mkdir(workspacePath, { recursive: true }); await writeFile(join(workspacePath, "opencode.json"), `${JSON.stringify({ $schema: "https://opencode.ai/config.json", provider: { [providerId]: { npm: "@ai-sdk/openai-compatible", name: "Sequential queue mock", options: { baseURL: baseUrl, apiKey: "sk-sequential-queue" }, models: { [modelId]: { name: "Sequential queue model" } }, }, }, }, null, 2)}\n`); const workspace = await createAndSelectWorkspace(app, { path: workspacePath }); const credentials = parseRuntimeCredentials(await evalIn(app, `JSON.stringify({ port: localStorage.getItem("openwork.server.port") ?? "", token: localStorage.getItem("openwork.server.token") ?? "", })`)); await waitForEngineReady(credentials, workspace.workspaceId); await waitFor(app, `window.__openworkControl.listActions().some((action) => action.id === "session.create_task" && !action.disabled)`, { timeoutMs: 30_000, label: "new task action enabled", }); // Right after the reload the route can briefly report the action enabled // while its workspace endpoints are still hydrating, and task creation // returns null. Retry like a user re-clicking New task. { const deadline = Date.now() + 30_000; let created = false; let lastError: unknown = null; while (!created && Date.now() < deadline) { try { await control(app, "session.create_task"); created = true; } catch (error) { lastError = error; await sleep(500); } } if (!created) { throw new Error(`session.create_task kept failing after reload: ${lastError instanceof Error ? lastError.message : String(lastError)}`); } } // Select the deterministic mock model the way a user would: through the // compact model picker. This keeps the whole flow on the untouched boot // path, whose live engine event stream drives the busy composer state. await waitFor(app, `Boolean(document.querySelector('button[aria-label="Change model"]'))`, { timeoutMs: 60_000, label: "composer model selector", }); const pickerOpened = await evalIn(app, `(() => { const trigger = document.querySelector('button[aria-label="Change model"]'); if (!(trigger instanceof HTMLButtonElement)) return false; trigger.click(); return true; })()`); expect(pickerOpened).toBe(true); await waitFor(app, `(() => { const popover = document.querySelector('[data-slot="popover-content"]'); if (!(popover instanceof HTMLElement)) return false; return [...popover.querySelectorAll('[data-slot="command-item"]')] .some((item) => (item.textContent ?? "").includes("Sequential queue model")); })()`, { timeoutMs: 60_000, label: "mock model listed in the picker" }); const modelPicked = await evalIn(app, `(() => { const popover = document.querySelector('[data-slot="popover-content"]'); const item = [...(popover?.querySelectorAll('[data-slot="command-item"]') ?? [])] .find((candidate) => (candidate.textContent ?? "").includes("Sequential queue model")); if (!(item instanceof HTMLElement)) return false; item.click(); return true; })()`); expect(modelPicked).toBe(true); await waitFor(app, `(document.querySelector('button[aria-label="Change model"]')?.textContent ?? "").includes("Sequential queue model")`, { timeoutMs: 15_000, label: "mock model selected for the session", }); await typeIntoComposer(app, firstPrompt); await clickButton(app, "Run task", { timeoutMs: 30_000 }); const sessionId = await activeSessionId(app); // Anchor on the mock actually serving the first main completion. The mock // holds this completion open until the runner releases it, so from here the // session is deterministically busy for as long as the queue interactions // need. If this times out the engine never used the deterministic mock, so // surface what the session shows instead. { const deadline = Date.now() + 60_000; while (mainRequests.length === 0 && Date.now() < deadline) await sleep(200); if (mainRequests.length === 0) { const bodyText = await evalIn(app, "document.body.innerText.slice(0, 2000)"); throw new Error(`The mock provider never received a main completion for the first prompt. On screen: ${String(bodyText)}`); } expect(mainRequests[0]?.label).toBe("first"); } // Claim 1 — busy affordance: the idle round send control becomes one round // Stop control in the same slot, with no Run task button and no legacy // Steer | queue split while the run is busy. await waitFor(app, `Boolean(document.querySelector('button[aria-label="Stop"]'))`, { timeoutMs: 90_000, label: "busy composer shows the round stop control", }); const busyControls = await evalIn(app, `(() => ({ stopButtons: document.querySelectorAll('button[aria-label="Stop"]').length, runTaskButton: Boolean(document.querySelector('button[aria-label="Run task"]')), queueSplitButton: Boolean(document.querySelector('button[aria-label="Send when agent finishes"]')), steerSplitButton: [...document.querySelectorAll("button")] .some((button) => (button.getAttribute("title") ?? "") === "Send now — the agent will adjust mid-task"), }))()`); expect(busyControls).toEqual({ stopButtons: 1, runTaskButton: false, queueSplitButton: false, steerSplitButton: false, }); evidence.recordAssertionEvidence( "While the agent is busy the composer offers exactly one round Stop control", `Busy control census: ${JSON.stringify(busyControls)} — one Stop button, no Run task button, no legacy Steer/queue split.`, isRecord(busyControls) && busyControls.stopButtons === 1 && busyControls.runTaskButton === false && busyControls.queueSplitButton === false && busyControls.steerSplitButton === false, ); // Claim 2 — plain Enter while busy queues instead of sending: two queued // follow-ups stack FIFO in the queued panel while the provider has seen // exactly one main completion and the engine exactly one user turn. await typeIntoComposer(app, queuedPromptOne); const busyBeforeFirstEnter = await evalIn(app, `Boolean(document.querySelector('button[aria-label="Stop"]'))`); expect(busyBeforeFirstEnter).toBe(true); await pressEnter(app); await waitFor(app, `document.body.innerText.includes("1 queued")`, { timeoutMs: 15_000, label: "first follow-up queued", }); await typeIntoComposer(app, queuedPromptTwo); const busyBeforeSecondEnter = await evalIn(app, `Boolean(document.querySelector('button[aria-label="Stop"]'))`); expect(busyBeforeSecondEnter).toBe(true); await pressEnter(app); await waitFor(app, `document.body.innerText.includes("2 queued")`, { timeoutMs: 15_000, label: "second follow-up queued", }); const queuedState = await evalIn(app, `(() => { const body = document.body.innerText; const userBubbles = [...document.querySelectorAll('[data-message-role="user"]')] .map((bubble) => bubble.textContent ?? ""); return { firstIndex: body.indexOf(${JSON.stringify(queuedPromptOne)}), secondIndex: body.indexOf(${JSON.stringify(queuedPromptTwo)}), queuedHeader: body.includes("2 queued"), userBubbleWithQueuedText: userBubbles.some((text) => text.includes(${JSON.stringify(queuedPromptOne)}) || text.includes(${JSON.stringify(queuedPromptTwo)})), }; })()`); if (!isRecord(queuedState) || typeof queuedState.firstIndex !== "number" || typeof queuedState.secondIndex !== "number") { throw new Error(`Queued panel state unavailable: ${JSON.stringify(queuedState)}`); } expect(queuedState.queuedHeader).toBe(true); expect(queuedState.firstIndex).toBeGreaterThanOrEqual(0); expect(queuedState.secondIndex).toBeGreaterThan(queuedState.firstIndex); expect(queuedState.userBubbleWithQueuedText).toBe(false); const engineWhileQueued = await readEngineSnapshot(credentials, workspace.workspaceId, sessionId); const userTurnsWhileQueued = engineWhileQueued.messages.filter((message) => message.role === "user"); expect(userTurnsWhileQueued.length).toBe(1); expect(engineWhileQueued.messages.some((message) => message.text.includes(queuedPromptOne) || message.text.includes(queuedPromptTwo))).toBe(false); expect(mainRequests.length).toBe(1); expect(mainRequests[0]?.label).toBe("first"); { const shot = await screenshot(app); const seen = await validate(shot, [ "A queued messages panel headed '2 queued' lists two queued follow-up messages", "The composer's round action button shows a stop control (filled square icon), not an upward send arrow", "No error dialog or crash is visible", ]); expect(seen.ok, seen.why).toBe(true); } evidence.recordAssertionEvidence( "Plain Enter while busy queues follow-ups FIFO instead of sending them", `With the run still busy, both follow-ups sat in the queued panel in submission order ("2 queued", first at index ${queuedState.firstIndex} before second at ${queuedState.secondIndex}), no user bubble contained them, the engine held exactly ${userTurnsWhileQueued.length} user turn, and the provider had served exactly ${mainRequests.length} main completion.`, queuedState.queuedHeader === true && queuedState.firstIndex >= 0 && queuedState.secondIndex > queuedState.firstIndex && queuedState.userBubbleWithQueuedText === false && userTurnsWhileQueued.length === 1 && mainRequests.length === 1, ); // Claim 3 — idle drains only the FIRST queued item as its own user turn; // the second stays queued until the drained turn's run finishes. The mock // holds the drained turn's completion open, so this intermediate state is // frozen while it is asserted. releases.first.release(); await waitForReply(app, replies[0]); await waitFor(app, `(() => { const body = document.body.innerText; const userBubbles = [...document.querySelectorAll('[data-message-role="user"]')] .map((bubble) => bubble.textContent ?? ""); return body.includes("1 queued") && userBubbles.some((text) => text.includes(${JSON.stringify(queuedPromptOne)})) && !userBubbles.some((text) => text.includes(${JSON.stringify(queuedPromptTwo)})); })()`, { timeoutMs: 60_000, label: "first queued item drained alone, second still queued" }); // The drained turn must be visibly busy again (round Stop control) before // its reply is released: the sequential contract waits for busy before the // next idle may drain the second item. await waitFor(app, `Boolean(document.querySelector('button[aria-label="Stop"]'))`, { timeoutMs: 90_000, label: "drained turn is busy in the composer", }); expect(mainRequests.length).toBe(2); expect(mainRequests[1]?.label).toBe("one"); const drainOneUserText = mainRequests[1]?.lastUserText ?? ""; expect(drainOneUserText.includes(queuedPromptOne)).toBe(true); expect(drainOneUserText.includes(queuedPromptTwo)).toBe(false); expect(drainOneUserText.includes(firstPrompt)).toBe(false); { const shot = await screenshot(app); const seen = await validate(shot, [ "The conversation shows a user message reading 'Queued follow-up ONE for sequential drain proof.'", "A queued messages panel headed '1 queued' still holds the second follow-up", "No error dialog or crash is visible", ]); expect(seen.ok, seen.why).toBe(true); } evidence.recordAssertionEvidence( "The idle drain sends only the first queued item as its own turn", `After the busy run finished, the transcript gained the first follow-up as its own user turn while "1 queued" still held the second; the provider's second main completion carried exactly the first follow-up as its newest user message (contains second follow-up: ${drainOneUserText.includes(queuedPromptTwo)}, contains initial prompt: ${drainOneUserText.includes(firstPrompt)}).`, mainRequests.length === 2 && mainRequests[1]?.label === "one" && drainOneUserText.includes(queuedPromptOne) && !drainOneUserText.includes(queuedPromptTwo) && !drainOneUserText.includes(firstPrompt), ); // Claim 4 — the second item drains after the first turn's run completes: // the final transcript is three separate FIFO user turns, never merged. releases.one.release(); await waitForReply(app, replies[1]); await waitForReply(app, replies[2]); await waitFor(app, `!/\\b\\d+ queued\\b/.test(document.body.innerText)`, { timeoutMs: 30_000, label: "queued panel emptied", }); const finalSnapshot = await waitForEngineSnapshot( credentials, workspace.workspaceId, sessionId, "final six-message transcript", (snapshot) => snapshot.messages.length === 6 && snapshot.messages.some((message) => message.text === replies[2]), ); expect(finalSnapshot.messages.map((message) => message.role)) .toEqual(["user", "assistant", "user", "assistant", "user", "assistant"]); const [turnOne, replyOne, turnTwo, replyTwo, turnThree, replyThree] = finalSnapshot.messages; expect(turnOne?.text.includes(firstPrompt)).toBe(true); expect(turnOne?.text.includes(queuedPromptOne)).toBe(false); expect(turnOne?.text.includes(queuedPromptTwo)).toBe(false); expect(replyOne?.text).toBe(replies[0]); expect(turnTwo?.text.trim()).toBe(queuedPromptOne); expect(replyTwo?.text).toBe(replies[1]); expect(turnThree?.text.trim()).toBe(queuedPromptTwo); expect(replyThree?.text).toBe(replies[2]); const mergedMessages = finalSnapshot.messages.filter((message) => message.text.includes(queuedPromptOne) && message.text.includes(queuedPromptTwo)); expect(mergedMessages).toEqual([]); expect(mainRequests.map((request) => request.label)).toEqual(["first", "one", "two"]); const drainTwoUserText = mainRequests[2]?.lastUserText ?? ""; expect(drainTwoUserText.trim()).toBe(queuedPromptTwo); const finalBubbles = await evalIn(app, `(() => { const userBubbles = [...document.querySelectorAll('[data-message-role="user"]')] .map((bubble) => bubble.textContent ?? ""); return { withFirstQueued: userBubbles.filter((text) => text.includes(${JSON.stringify(queuedPromptOne)})).length, withSecondQueued: userBubbles.filter((text) => text.includes(${JSON.stringify(queuedPromptTwo)})).length, withBoth: userBubbles.filter((text) => text.includes(${JSON.stringify(queuedPromptOne)}) && text.includes(${JSON.stringify(queuedPromptTwo)})).length, }; })()`); expect(finalBubbles).toEqual({ withFirstQueued: 1, withSecondQueued: 1, withBoth: 0 }); { const shot = await screenshot(app); const seen = await validate(shot, [ "The conversation shows two separate follow-up user messages, ONE before TWO, each followed by its own assistant reply", "No queued messages panel is visible anymore", "No error dialog or crash is visible", ]); expect(seen.ok, seen.why).toBe(true); } evidence.recordAssertionEvidence( "Both queued follow-ups landed as separate FIFO user turns, never one merged message", `Engine session ${sessionId} ended with six messages in exact FIFO order (initial turn, its reply, first follow-up, its reply, second follow-up, its reply); ${mergedMessages.length} engine messages and ${isRecord(finalBubbles) ? String(finalBubbles.withBoth) : "?"} rendered user bubbles contained both follow-up texts; the provider served exactly three main completions ordered ${JSON.stringify(mainRequests.map((request) => request.label))}, and the drained turns' newest user messages were exactly the queued texts.`, finalSnapshot.messages.length === 6 && mergedMessages.length === 0 && turnTwo?.text.trim() === queuedPromptOne && turnThree?.text.trim() === queuedPromptTwo && drainTwoUserText.trim() === queuedPromptTwo && isRecord(finalBubbles) && finalBubbles.withFirstQueued === 1 && finalBubbles.withSecondQueued === 1 && finalBubbles.withBoth === 0, ); });