Files
openwork/evals/specs/composer-queued-follow-ups-sequential.e2e.test.ts

652 lines
30 KiB
TypeScript

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<void> => 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<string, unknown> {
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<EngineSnapshot> {
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<EngineSnapshot> {
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void>; release: () => void }> = (() => {
const gate = (): { promise: Promise<void>; release: () => void } => {
let release: () => void = () => undefined;
const promise = new Promise<void>((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<void>((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<void>((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,
);
});