import { createRequire } from "node:module"; import { expect, test, type Page } from "@playwright/test"; import { CAPABILITY_UI_SHOT_SLUGS } from "../../src/issue-thread/fixtures"; import type { CapabilityIssueThreadSnapshot, CapabilityThreadItem, } from "../../src/issue-thread/types"; const require = createRequire(import.meta.url); const AXE_PATH = require.resolve("axe-core/axe.min.js"); const DESKTOP = { width: 1440, height: 900 }; const MOBILE = { width: 390, height: 844 }; function route(slug: string, params: Record = {}): string { const query = new URLSearchParams({ shot: slug, capture: "1", ...params }); return `/#/issue/hb-baseline?${query.toString()}`; } /** Every route settles on a data attribute, never on a timeout (§10.1). */ async function open(page: Page, slug: string, params: Record = {}) { await page.goto(route(slug, params)); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); } interface AxeViolation { id: string; impact: string | null; nodes: unknown[]; } async function seriousAxeViolations(page: Page): Promise { await page.addScriptTag({ path: AXE_PATH }); const violations = await page.evaluate(async () => { const axe = (window as unknown as { axe: { run: (context: unknown, options: unknown) => Promise<{ violations: AxeViolation[] }> } }).axe; const results = await axe.run(document, { resultTypes: ["violations"], runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] }, }); return results.violations.map((violation) => ({ id: violation.id, impact: violation.impact ?? null, nodes: violation.nodes.length, })); }); return (violations as unknown as AxeViolation[]).filter( (violation) => violation.impact === "serious" || violation.impact === "critical", ); } test.describe("Capability issue thread", () => { test("loads the bundled sans and mono faces before the thread settles", async ({ page }) => { await open(page, "thread-baseline"); const probe = await page.evaluate(async () => { const normalizeFamily = (family: string) => family.trim().replace(/^['"]|['"]$/g, ""); const styles = getComputedStyle(document.documentElement); const sansFamily = normalizeFamily(styles.getPropertyValue("--pit-font-sans").split(",")[0]); const monoFamily = normalizeFamily(styles.getPropertyValue("--pit-font-mono").split(",")[0]); const symbolFamily = normalizeFamily(styles.getPropertyValue("--pit-font-symbols")); await document.fonts.load(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E"); const statuses = (family: string) => [...document.fonts] .filter((face) => normalizeFamily(face.family) === family) .map((face) => face.status); return { sansFamily, monoFamily, symbolFamily, sansStatuses: statuses(sansFamily), monoStatuses: statuses(monoFamily), symbolStatuses: statuses(symbolFamily), sansWeightsLoaded: [400, 500, 600, 700].every((weight) => document.fonts.check(`${weight} 16px "${sansFamily}"`, "Paperclip"), ), monoWeightsLoaded: [400, 700].every((weight) => document.fonts.check(`${weight} 16px "${monoFamily}"`, "TASK-17003"), ), symbolsLoaded: document.fonts.check(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E"), }; }); expect(probe).toEqual({ sansFamily: "Paperclip Issue Thread Inter", monoFamily: "Paperclip Issue Thread DejaVu Sans Mono", symbolFamily: "Paperclip Issue Thread Symbols", sansStatuses: ["loaded"], monoStatuses: ["loaded", "loaded"], symbolStatuses: ["loaded", "loaded"], sansWeightsLoaded: true, monoWeightsLoaded: true, symbolsLoaded: true, }); }); test("baseline thread renders identity, turns, and the §3 item types", async ({ page }) => { await open(page, "thread-baseline"); await expect(page.locator('[data-session-mode="fake"]').first()).toBeVisible(); const chips = page.getByTestId("identity-chips"); await expect(chips.getByTestId("agent-chip")).toHaveText("Fake agent"); await expect(chips.getByTestId("runner-chip")).toContainText("In-process runner"); await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip"); await expect(page.locator("h1")).toHaveCount(1); await expect(page.locator('[data-turn-id]')).toHaveCount(3); for (const kind of ["user_message", "agent_message", "durable_comment", "tool_activity"]) { await expect(page.locator(`[data-thread-item="${kind}"]`).first()).toBeVisible(); } await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1); }); test("a durable progress comment is visually distinct from model prose", async ({ page }) => { await open(page, "thread-baseline"); await expect( page.locator('[data-thread-item="durable_comment"]').getByText("Recorded to mock thread"), ).toBeVisible(); await expect( page.locator('[data-thread-item="agent_message"]').first().getByText("Recorded to mock thread"), ).toHaveCount(0); }); test("tool strips are collapsed disclosures that deep-link into Evidence", async ({ page }) => { await open(page, "thread-baseline"); const strip = page.locator('[data-tool-strip="get_task_context"]').first(); await expect(strip).not.toHaveAttribute("open", ""); await strip.locator("summary").click(); await expect(strip).toHaveAttribute("open", ""); await page.getByRole("button", { name: "View in Evidence" }).first().click(); const panel = page.getByTestId("evidence-panel"); await expect(panel).toBeVisible(); await expect(panel.locator('[data-record-id="call-1"][data-highlighted="true"]')).toBeVisible(); }); test("a pending question card resolves inline and releases the composer", async ({ page }) => { await open(page, "interaction-question-pending"); await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible(); const card = page.locator('[data-interaction-id="ix-questions-01"]'); await expect(card).toHaveAttribute("data-interaction-state", "pending"); await expect(card.getByTestId("interaction-state-chip")).toContainText("Waiting for you"); // The submit is rejected until every required question is answered. await card.getByRole("button", { name: "Submit answers" }).click(); await expect(card.getByRole("alert")).toContainText("required"); await card.getByRole("radio", { name: "Yes — runner owns it" }).check(); await card.getByRole("combobox").selectOption("hb-baseline"); await card.getByRole("button", { name: "Submit answers" }).click(); await expect(card).toHaveAttribute("data-interaction-state", "answered"); await expect(card.getByTestId("interaction-state-chip")).toContainText("Answered"); await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible(); }); test("a revision-bound confirmation names its target and requires a reject reason", async ({ page, }) => { await open(page, "interaction-confirmation-pending"); const card = page.locator('[data-interaction-id="ix-confirmation-plan-01"]'); await expect(card.getByTestId("interaction-target")).toHaveText("plan · r4"); await card.getByRole("button", { name: "Request changes" }).click(); await expect(card.getByRole("alert")).toContainText("reason is required"); await expect(card).toHaveAttribute("data-interaction-state", "pending"); await card.getByRole("textbox").fill("Split the acceptance section first."); await card.getByRole("button", { name: "Request changes" }).click(); await expect(card).toHaveAttribute("data-interaction-state", "rejected"); await expect(card).toContainText("Split the acceptance section first."); }); test("resolved, rejected, stale, and superseded cards stay distinct in history", async ({ page, }) => { await open(page, "interaction-resolved-mixed"); for (const state of ["answered", "accepted", "rejected", "stale_target", "superseded_by_comment"]) { await expect( page.locator(`[data-interaction-state="${state}"]`), `missing ${state}`, ).toHaveCount(1); } // Expired treatments keep their controls removed but stay reachable. await expect( page.locator('[data-interaction-state="stale_target"] button', { hasText: "Approve" }), ).toHaveCount(0); await expect( page.locator('[data-interaction-state="stale_target"]').getByRole("button", { name: "View request evidence", }), ).toBeVisible(); }); test("the deliverable strip and card report one size in one unit", async ({ page }) => { await open(page, "deliverable-registered"); const card = page.locator('[data-thread-item="deliverable"]'); await expect(card).toContainText("18.0 kB"); await expect( page.locator('[data-tool-strip="register_deliverable"]'), ).toContainText("18.0 kB"); }); test("a denial quotes the authorization record verbatim and badges Evidence", async ({ page, }) => { await open(page, "denial-optional-tool"); await expect(page.getByTestId("denial-reason")).toHaveText( "denied: missing grant su:read_or_write", ); await expect(page.getByTestId("evidence-toggle")).toContainText("(1)"); await page.locator('[data-thread-item="denial"]').getByRole("button", { name: "View in Evidence" }).click(); const record = page.getByTestId("evidence-panel").locator('[data-record-id="authz-deny-1"]'); await expect(record).toHaveAttribute("data-allowed", "false"); await expect(record).toContainText("denied: missing grant su:read_or_write"); }); test("Evidence exposes eight sections, a turn selector, and the withheld control-plane list", async ({ page, }) => { await open(page, "debug-panel-open", { panel: "authorization" }); const panel = page.getByTestId("evidence-panel"); await expect(panel).toBeVisible(); await expect(panel.locator("[data-evidence-section]")).toHaveCount(8); await expect(panel.locator("#evidence-turn-selector")).toBeVisible(); // The Tools section is open by default; assert its groups directly. await expect( panel.locator('[data-evidence-section="tools"] button').first(), ).toHaveAttribute("aria-expanded", "true"); await expect(panel.getByText("Control plane (not exposed to the agent)")).toBeVisible(); await expect( panel.locator('[data-disposition="control_plane_owned"]', { hasText: "create_task" }), ).toBeVisible(); await expect(panel.getByText("Agent tool — always")).toBeVisible(); await expect(panel.getByText("Agent tool — granted")).toBeVisible(); }); test("tool exposure groups are deduplicated, foldable, and open catalog details", async ({ page }) => { await open(page, "debug-panel-open", { panel: "tools" }); const panel = page.getByTestId("evidence-panel"); const always = panel.getByRole("button", { name: /Agent tool — always/ }); await expect(always).toHaveCount(1); await always.click(); await expect(panel.getByRole("button", { name: /get_task_context/ })).toHaveCount(0); await always.click(); await panel.getByRole("button", { name: /get_task_context/ }).click(); const dialog = page.getByRole("dialog", { name: "Get active task context" }); await expect(dialog).toContainText("Read the active mock task, actor, wake, ancestors, budget, and interaction results."); await expect(dialog).toContainText("Input schema"); await dialog.getByRole("button", { name: "Close tool details" }).click(); await expect(dialog).toHaveCount(0); }); test("Evidence records link back to their thread anchor", async ({ page }) => { await open(page, "debug-panel-open", { panel: "calls" }); const panel = page.getByTestId("evidence-panel"); await panel.locator('[data-record-id="call-1"]').getByRole("button", { name: "Show in thread" }).click(); await expect(page.locator('[data-tool-strip="get_task_context"]')).toBeInViewport(); }); test("the splitter resizes the panel from the keyboard", async ({ page }) => { await open(page, "debug-panel-open", { panel: "tools" }); const splitter = page.getByRole("separator", { name: "Resize the evidence panel" }); const before = await splitter.getAttribute("aria-valuenow"); await splitter.focus(); await page.keyboard.press("ArrowLeft"); await expect(splitter).not.toHaveAttribute("aria-valuenow", before ?? ""); }); test("the splitter drag makes the DevTools panel wider", async ({ page }) => { await open(page, "debug-panel-open", { panel: "tools" }); const splitter = page.getByRole("separator", { name: "Resize the evidence panel" }); const panel = page.getByTestId("evidence-panel"); const handle = await splitter.boundingBox(); const before = await panel.boundingBox(); expect(handle).not.toBeNull(); expect(before).not.toBeNull(); await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2); await page.mouse.down(); await page.mouse.move(handle!.x - 140, handle!.y + handle!.height / 2, { steps: 5 }); await page.mouse.up(); const after = await panel.boundingBox(); expect(after!.width).toBeGreaterThan(before!.width + 100); }); test("reset is confirmed, escapable, and clears the thread", async ({ page }) => { await open(page, "disposition-terminal"); await page.getByTestId("reset-button").click(); const dialog = page.getByTestId("reset-dialog"); await expect(dialog).toBeVisible(); await expect(dialog).toContainText("The transcript will be lost."); await expect(page.getByRole("button", { name: "Cancel" })).toBeFocused(); await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await page.getByTestId("reset-button").click(); await page .getByTestId("reset-dialog") .getByRole("button", { name: "Reset scenario", exact: true }) .click(); await expect(page.locator('[data-thread-item="disposition"]')).toHaveCount(0); await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible(); }); test("stop keeps the partial turn and marks it", async ({ page }) => { await open(page, "turn-streaming"); await expect(page.locator('[data-composer-state="streaming"]')).toBeVisible(); await expect(page.getByTestId("composer-stop")).toBeVisible(); await expect(page.locator('[data-composer-state="streaming"] textarea')).toBeEnabled(); await page.getByTestId("composer-stop").click(); await expect(page.getByTestId("stopped-marker")).toBeVisible(); await expect(page.locator('[data-thread-item="agent_message"]').last()).toContainText( "Two dependent tasks are still open", ); }); test("reconnect pins an amber banner and disables the composer", async ({ page }) => { await open(page, "reconnect-banner"); await expect(page.getByTestId("reconnect-banner")).toContainText("attempt 2"); await expect(page.locator('[data-composer-state="reconnecting"]')).toBeVisible(); await expect(page.locator('[data-composer-state="reconnecting"] textarea')).toBeDisabled(); await page.getByRole("button", { name: "Retry now" }).click(); await expect(page.getByText("Reconnected")).toBeVisible(); }); test("replay is read-only and labelled as fake-derived", async ({ page }) => { await open(page, "replay-mode"); await expect(page.getByTestId("agent-chip")).toContainText("Replay · fake source"); await expect(page.getByTestId("composer-reason")).toHaveText("Replay is read-only"); await expect(page.getByTestId("replay-strip")).toContainText("Replay 12/18"); await expect(page.locator('[data-composer-state="disabled"] textarea')).toBeDisabled(); }); test("the replay strip steps, advances, and plays through to the end", async ({ page }) => { await open(page, "replay-mode"); const strip = page.getByTestId("replay-strip"); const progress = page.getByRole("progressbar", { name: "Replay progress" }); await page.getByTestId("replay-step-back").click(); await expect(strip).toContainText("Replay 11/18"); await expect(progress).toHaveAttribute("aria-valuenow", "11"); await page.getByTestId("replay-next-turn").click(); await expect(strip).toContainText("Replay 12/18"); // Play-all runs the recording out and parks itself at the last ordinal // instead of leaving the operator to click Next turn eighteen times (§6). const playAll = page.getByTestId("replay-play-all"); await playAll.click(); await expect(playAll).toHaveAttribute("aria-pressed", "true"); await expect(strip).toContainText("Replay 18/18", { timeout: 15_000 }); await expect(playAll).toHaveAttribute("aria-pressed", "false"); await expect(page.getByTestId("replay-next-turn")).toBeDisabled(); }); test("a terminal disposition keeps the composer available", async ({ page }) => { await open(page, "disposition-terminal"); await expect(page.locator('[data-thread-item="disposition"]')).toContainText("Done"); await expect(page.locator("#composer-input")).toBeEnabled(); await expect(page.getByText("This task is done. You can still continue the conversation.")).toBeVisible(); }); test("composer drafts survive a refresh", async ({ page }) => { await open(page, "thread-baseline"); await page.locator("#composer-input").fill("Draft that must survive F5."); await page.reload(); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); await expect(page.locator("#composer-input")).toHaveValue("Draft that must survive F5."); }); test("mobile keeps the thread usable with no horizontal page scroll", async ({ page }) => { await page.setViewportSize(MOBILE); await open(page, "turn-streaming"); const overflow = await page.evaluate(() => { const element = document.scrollingElement as HTMLElement; return element.scrollWidth - element.clientWidth; }); expect(overflow).toBeLessThanOrEqual(0); // Stop stays outside the overflow menu while a turn is active (§2.2). await expect(page.getByTestId("stop-button")).toBeVisible(); await expect(page.getByTestId("stop-button")).toBeEnabled(); await expect(page.getByTestId("overflow-menu")).toHaveCount(0); await page.getByTestId("segment-evidence").click(); await expect(page.getByTestId("evidence-panel")).toBeVisible(); await expect(page.getByTestId("evidence-panel")).toHaveAttribute("data-layout", "segment"); }); test("mobile surfaces the denial count on the Evidence segment", async ({ page }) => { await page.setViewportSize(MOBILE); await open(page, "denial-optional-tool"); await expect(page.getByTestId("segment-denial-badge")).toHaveText("1"); }); test("every actionable control meets the 44px touch target on mobile", async ({ page }) => { await page.setViewportSize(MOBILE); await open(page, "interaction-question-pending"); const undersized = await page.evaluate(() => { const selectors = "button:not([hidden]), select, textarea, [role='radio']"; return [...document.querySelectorAll(selectors)] .filter((node) => { const element = node as HTMLElement; if (element.offsetParent === null) return false; const rect = element.getBoundingClientRect(); return rect.height < 44; }) .map((node) => (node as HTMLElement).className || node.tagName); }); expect(undersized).toEqual([]); }); test("the mobile overflow menu closes with Escape", async ({ page }) => { await page.setViewportSize(MOBILE); await open(page, "thread-baseline"); await page.getByTestId("overflow-menu-button").click(); await expect(page.getByTestId("overflow-menu")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByTestId("overflow-menu")).toHaveCount(0); }); test("opening Evidence moves focus to its heading", async ({ page }) => { await open(page, "thread-baseline"); await page.getByTestId("evidence-toggle").click(); await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused(); }); test("Escape closes the desktop overlay sheet and returns focus to its toggle", async ({ page, }) => { // Between the mobile segment and the docked side panel, Evidence renders as // an overlay sheet; §9.1 makes Escape dismiss it. await page.setViewportSize({ width: 1000, height: 800 }); await open(page, "thread-baseline"); await page.getByTestId("evidence-toggle").click(); const panel = page.getByTestId("evidence-panel"); await expect(panel).toHaveAttribute("data-layout", "overlay"); await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused(); await page.keyboard.press("Escape"); await expect(panel).toHaveCount(0); await expect(page.getByTestId("evidence-toggle")).toBeFocused(); }); test("resolving an interaction card moves focus to its state chip", async ({ page }) => { await open(page, "interaction-question-pending"); const card = page.locator('[data-interaction-id="ix-questions-01"]'); await card.getByRole("radio", { name: "Yes — runner owns it" }).check(); await card.getByRole("combobox").selectOption("hb-baseline"); await card.getByRole("button", { name: "Submit answers" }).click(); await expect(card).toHaveAttribute("data-interaction-state", "answered"); await expect(card.getByTestId("interaction-state-chip")).toBeFocused(); }); test("the waiting composer anchor focuses the pending card", async ({ page }) => { await open(page, "interaction-question-pending"); await page.getByTestId("pending-anchor").click(); await expect(page.locator('[data-interaction-id="ix-questions-01"]')).toBeInViewport(); }); for (const slug of CAPABILITY_UI_SHOT_SLUGS) { test(`axe reports no serious or critical violation on ${slug} (desktop)`, async ({ page }) => { await page.setViewportSize(DESKTOP); await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization" } : {}); expect(await seriousAxeViolations(page)).toEqual([]); }); test(`axe reports no serious or critical violation on ${slug} (mobile)`, async ({ page }) => { await page.setViewportSize(MOBILE); await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization", seg: "evidence" } : {}); expect(await seriousAxeViolations(page)).toEqual([]); }); } }); /* ------------------------------------------------------- Capability clean room */ /** * The clean room is live-only by construction, so these tests stub the package * session route rather than starting a real Codex process: what is under test * here is the surface — entry, blank state, evidence-on-demand, identity * rotation, failure honesty, and the narrow layout — not the tool loop, which * `smoke:capability:cleanroom` and the server suite cover against the real thing. */ const CLEAN_ROOM_API = "**/api/capability/ui/cleanroom/session*"; const DEVTOOLS_API = "**/api/capability/ui/devtools?*"; function devtoolsPayload() { const state = { company: { id: "company-1", name: "Mock Paperclip" }, actors: [], tasks: [], comments: [], interactions: [], approvals: [], artifacts: [], workProducts: [], blockers: [], workspaceServices: [], budgets: [], runs: [], wakes: [], audit: [], decisions: [], idempotency: [], faults: [], documents: [{ id: "document-1", key: "plan", title: "Runner plan", revisions: [{ id: "revision-1", revision: 1, body: "# Real document body\n\nInspectable from DevTools.", changeSummary: "Initial draft", createdAt: "2026-08-10T12:00:00.000Z" }], }], }; return { schema: "paperclip.capability.devtools.v1", currentRevision: 4, revisions: [{ revision: 4, at: "2026-08-10T12:00:00.000Z", turnId: "turn-1", operationId: "write_document", state }], protocol: [], runtime: {}, authority: {}, }; } function cleanRoomView(identifier: string, withTurn: boolean) { const guard = { id: `network-guard-session-${identifier}`, turnId: "turn-0", category: "session", outcome: "no_real_paperclip_request", reason: "Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.", stateRevision: 3, threadAnchorId: null, }; return { schema: "paperclip.capability.issue-thread-view.v1", sessionId: `session-${identifier}`, mode: "live", identity: { agentLabel: "Real Codex", runnerLabel: "Real runnerd", runnerAttached: true, controlPlaneLabel: "Mock Paperclip", controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.", replaySource: null, }, issue: { identifier, title: "Clean-room chat", status: "in_progress", priority: "medium", assignee: "Mock Agent", runState: `run-${identifier} · idle`, scenarioId: "capability-clean-room", fixtureProfile: "clean-room", }, turns: withTurn ? [ { id: "turn-1", ordinal: 1, mode: "live", toolCallCount: 1, at: "2026-08-10T12:00:00.000Z", stoppedByUser: false, items: [ { kind: "user_message", id: "transcript-1", at: "2026-08-10T12:00:00.000Z", author: "You (board user)", body: "Read this issue and record a status.", }, { kind: "tool_activity", id: "item-call-call-1", at: "2026-08-10T12:00:01.000Z", status: "ok", operationId: "report_progress", summary: "state revision 3", input: { body: "first status" }, result: { ok: true, stateRevision: 3 }, evidenceRef: { section: "calls", recordId: "call-1" }, }, { kind: "agent_message", id: "transcript-2", at: "2026-08-10T12:00:02.000Z", author: "Real Codex", body: "Recorded a first status on the mock issue.", streaming: false, }, ], }, ] : [], composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null }, evidence: { tools: [], calls: [], authorization: [], control_plane: [guard], runner: [], state: [], traceability: [], parity: [], }, connection: { state: "connected", attempt: 0 }, replay: null, renderedAt: "2026-08-10T12:00:02.000Z", }; } function cleanRoomPayload(identifier: string, withTurn = false) { return { sessionId: `session-${identifier}`, surface: "cleanroom", identity: { token: identifier.toLowerCase().replace("mck-", "tok"), sequence: Number(identifier.slice(4)), companyId: `company-cleanroom-${identifier}`, actorId: `actor-cleanroom-${identifier}`, taskId: `task-cleanroom-${identifier}`, identifier, }, limits: { maxTurns: 24, maxMessageBytes: 8192 }, view: cleanRoomView(identifier, withTurn), }; } /** * A turn now answers with the NDJSON stream, so a stubbed turn has to speak it. * These stubs deliver one settled frame: they exercise what a turn *produced*, * while incremental delivery is proved against the real server below. */ function turnStreamBody(payload: unknown, interim: unknown[] = []): string { const frames = interim.map((view, index) => ({ schema: "paperclip.capability.turn-stream.v1", type: "frame", seq: index + 1, reason: "delta", turnId: "turn-1", view, })); return [ ...frames, { schema: "paperclip.capability.turn-stream.v1", type: "settled", seq: frames.length + 1, payload, }, ] .map((frame) => `${JSON.stringify(frame)}\n`) .join(""); } async function stubCleanRoom(page: Page, identifiers: string[]) { let opened = 0; await page.route(CLEAN_ROOM_API, async (route) => { const requestedSessionId = new URL(route.request().url()).searchParams.get("sessionId"); const identifier = requestedSessionId?.replace(/^session-/, "") ?? identifiers[Math.min(opened, identifiers.length - 1)] ?? "MCK-1000"; if (requestedSessionId === null) opened += 1; await route.fulfill({ status: route.request().method() === "POST" ? 201 : 200, contentType: "application/json", body: JSON.stringify(cleanRoomPayload(identifier)), }); }); } async function openCleanRoom(page: Page, identifiers: string[] = ["MCK-1000"]) { await stubCleanRoom(page, identifiers); await page.addInitScript(() => window.localStorage.clear()); await page.goto("/#/chat"); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); } test.describe("Capability clean-room chat", () => { test("the landing surface offers a clean-room entry that needs no scenario", async ({ page }) => { await open(page, "thread-baseline"); const entry = page.getByTestId("surface-chat-link"); await expect(entry).toBeVisible(); await expect(entry).toHaveRole("button"); await expect(entry).toContainText("New chat"); }); test("a new chat opens a blank live thread with no scenario controls", async ({ page }) => { await openCleanRoom(page); await expect(page).toHaveTitle("🫧 Mock Paperclip · Issue thread"); await expect(page.locator('[data-surface="chat"]')).toBeVisible(); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); await expect(page.locator("[data-turn-id]")).toHaveCount(0); await expect(page.getByTestId("scenario-picker")).toHaveCount(0); await expect(page.getByTestId("replay-button")).toHaveCount(0); await expect(page.getByTestId("replay-strip")).toHaveCount(0); const chips = page.getByTestId("identity-chips"); await expect(chips.getByTestId("agent-chip")).toHaveText("Real Codex"); await expect(chips.getByTestId("runner-chip")).toContainText("Real runnerd"); await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip"); await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1); }); test("evidence stays collapsed until it is asked for", async ({ page }) => { await openCleanRoom(page); await expect(page.locator(".pit-panel")).toHaveCount(0); await expect(page.getByTestId("evidence-toggle")).toHaveAttribute("aria-expanded", "false"); await page.getByTestId("evidence-toggle").click(); await expect(page.locator(".pit-panel")).toBeVisible(); await page.getByRole("button", { name: /Control plane/ }).click(); await expect(page.getByText("Real Paperclip API requests: 0")).toBeVisible(); }); test("documents written to company state are readable in DevTools", async ({ page }) => { await page.route(DEVTOOLS_API, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(devtoolsPayload()) }); }); await openCleanRoom(page); await page.getByTestId("evidence-toggle").click(); const panel = page.getByTestId("evidence-panel"); const tabs = panel.getByRole("tab"); await expect(tabs.first()).toHaveText(/Evidence/); await expect(tabs.nth(1)).toHaveText(/Timeline/); await expect(panel.locator(".pit-devtools-tabs .pit-icon")).toHaveCount(8); const centerOffsets = await tabs.evaluateAll((elements) => elements.map((element) => { const icon = element.querySelector(".pit-tab-glyph")?.getBoundingClientRect(); const label = element.querySelector(".pit-tab-glyph + span")?.getBoundingClientRect(); return icon === undefined || label === undefined ? Number.POSITIVE_INFINITY : Math.abs((icon.top + icon.height / 2) - (label.top + label.height / 2)); })); expect(Math.max(...centerOffsets)).toBeLessThanOrEqual(1); await expect(tabs.first()).toHaveAttribute("aria-selected", "true"); await expect(panel.locator("#evidence-turn-selector")).toBeVisible(); await panel.getByRole("tab", { name: /Timeline/ }).click(); await expect(panel.locator("#evidence-turn-selector")).toHaveCount(0); await panel.getByRole("tab", { name: /Evidence/ }).click(); await expect(panel.locator("#evidence-turn-selector")).toBeVisible(); await page.getByRole("tab", { name: /Documents/ }).click(); await expect(page.getByRole("heading", { name: "Runner plan" })).toBeVisible(); await expect(page.getByText("Inspectable from DevTools.")).toBeVisible(); }); test("Runner events expand completely while stream deltas stay grouped", async ({ page }) => { await page.addInitScript(() => window.localStorage.clear()); await page.route(CLEAN_ROOM_API, async (route) => { const payload = cleanRoomPayload("MCK-1000"); const view = payload.view as CapabilityIssueThreadSnapshot; view.evidence.runner = [ { id: "session-1", turnId: "turn-0", kind: "session", ordinal: 1, detail: "session · started", details: [ { label: "Action", value: "started" }, { label: "Runner", value: "paperclip-runnerd" }, ], }, ...[2, 3].map((ordinal) => ({ id: `delta-${ordinal}`, turnId: "turn-1", kind: "provider_event", ordinal, detail: "provider event · assistant_delta", details: [{ label: "Event", value: "assistant_delta" }], })), ]; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload), }); }); await page.goto("/#/chat"); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); await page.getByTestId("evidence-toggle").click(); await page.getByRole("button", { name: /Runner & events/ }).click(); const session = page.locator('[data-record-id="session-1"]'); await session.locator("summary").click(); await expect(session).toContainText("paperclip-runnerd"); const group = page.locator('[data-runner-delta-group="assistant_delta"]'); await expect(group).toContainText("2 streamed updates"); await group.locator(":scope > summary").click(); await expect(group.locator(".pit-runner-event")).toHaveCount(2); await group.locator(".pit-runner-event").first().locator("summary").click(); await expect(group.locator(".pit-runner-event").first()).toContainText("assistant_delta"); }); test("a sent message renders the live turn it produced", async ({ page }) => { await openCleanRoom(page); await page.route("**/api/capability/ui/message", async (route) => { await route.fulfill({ status: 200, contentType: "application/x-ndjson", body: turnStreamBody({ sessionId: "session-MCK-1000", surface: "cleanroom", identity: cleanRoomPayload("MCK-1000").identity, limits: { maxTurns: 24, maxMessageBytes: 8192 }, view: cleanRoomView("MCK-1000", true), }), }); }); await page.locator("#composer-input").fill("Read this issue and record a status."); await page.getByTestId("composer-send").click(); await expect(page.getByTestId("clean-room-empty")).toHaveCount(0); await expect(page.locator('[data-thread-item="user_message"]')).toBeVisible(); await expect(page.locator('[data-thread-item="agent_message"]')).toBeVisible(); await expect(page.locator('[data-tool-strip="report_progress"]')).toBeVisible(); }); test("New chat visibly rotates the mock identity", async ({ page }) => { await openCleanRoom(page, ["MCK-1000", "MCK-2000"]); await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000"); await page.getByTestId("surface-chat-link").click(); await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000"); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); const history = page.getByRole("region", { name: "Session history" }); await expect(history.getByRole("button")).toHaveCount(2); await history.getByRole("button", { name: /MCK-1000/ }).click(); await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000"); await expect(page.locator("#composer-input")).toBeEnabled(); await history.getByRole("button", { name: /MCK-2000/ }).click(); await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000"); await expect(page.locator("#composer-input")).toBeEnabled(); }); test("a failed live start reports the failure instead of a fixture", async ({ page }) => { await page.addInitScript(() => window.localStorage.clear()); await page.route(CLEAN_ROOM_API, async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "capability_issue_thread_unavailable", message: "paperclip-runnerd exited before the Codex app-server was ready", }), }); }); await page.goto("/#/chat"); const error = page.getByTestId("surface-error"); await expect(error).toBeVisible(); await expect(error).toContainText("could not start the selected provider"); await expect(page.getByTestId("surface-error-retry")).toBeVisible(); await expect(page.locator("[data-turn-id]")).toHaveCount(0); }); test("the narrow layout keeps thread and composer usable without horizontal scroll", async ({ page, }) => { await page.setViewportSize(MOBILE); await openCleanRoom(page); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); await expect(page.locator("#composer-input")).toBeVisible(); const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(overflow).toBeLessThanOrEqual(0); await page.getByTestId("segment-evidence").click(); await expect(page.locator(".pit-panel")).toBeVisible(); expect( await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ), ).toBeLessThanOrEqual(0); }); test("axe reports no serious or critical violation on the clean room", async ({ page }) => { await page.setViewportSize(DESKTOP); await openCleanRoom(page); expect(await seriousAxeViolations(page)).toEqual([]); await page.setViewportSize(MOBILE); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); expect(await seriousAxeViolations(page)).toEqual([]); }); }); /* ------------------------------------ pending-request live region (TASK-16978) */ /** * Clean-room QA (TASK-16974) heard the visually hidden status region still * announce "A request is waiting for your answer." after the request had been * answered: the live path re-renders with the same request still pending while * the submit is in flight, which used to re-arm the pending copy and leave it * standing once the request settled. These tests pin the live region to the * request's actual state across pending → answered → a later turn. */ const PENDING_ANNOUNCEMENT = "A request is waiting for your answer."; function questionsItem(state: "pending" | "answered" | "withdrawn"): CapabilityThreadItem { return { kind: "interaction", id: "item-ix-1", at: "2026-08-10T12:00:03.000Z", interactionId: "ix-questions-01", interactionKind: "questions", title: "One scoping question", prompt: "Answer this before I continue.", payload: { kind: "questions", submitLabel: "Submit answers", questions: [ { id: "q1", prompt: "Should the runner own process cleanup?", control: "radio", options: ["Yes — runner owns it", "No — harness owns it"], required: true, }, ], }, state, target: null, stateLabel: state === "pending" ? "Waiting for you" : state === "answered" ? "Answered" : "Withdrawn", resolvedSummary: state === "answered" ? ["Should the runner own process cleanup? Yes — runner owns it"] : [], reason: null, supersededBy: null, evidenceRef: { section: "authorization", recordId: "authz-ix-1" }, }; } /** A clean-room view carrying one request in the given state. */ function interactionView( identifier: string, state: "pending" | "answered", options: { withdrawn?: boolean; extraTurn?: boolean } = {}, ): CapabilityIssueThreadSnapshot { const view = JSON.parse( JSON.stringify(cleanRoomView(identifier, true)), ) as CapabilityIssueThreadSnapshot; view.turns[0].items.push(questionsItem(options.withdrawn === true ? "withdrawn" : state)); if (options.extraTurn === true) { view.turns.push({ id: "turn-2", ordinal: 2, mode: "live", toolCallCount: 0, at: "2026-08-10T12:01:00.000Z", stoppedByUser: false, items: [ { kind: "user_message", id: "transcript-3", at: "2026-08-10T12:01:00.000Z", author: "You (board user)", body: "Carry on with the plan.", }, { kind: "agent_message", id: "transcript-4", at: "2026-08-10T12:01:01.000Z", author: "Real Codex", body: "Folded the answer into the plan.", streaming: false, }, ], }); } view.composer = state === "pending" ? { state: "waiting", helper: "Answer the pending request above to continue.", reason: null, pendingInteractionId: "ix-questions-01", } : { state: "ready", helper: null, reason: null, pendingInteractionId: null }; return view; } async function openWithPendingRequest(page: Page) { await page.addInitScript(() => window.localStorage.clear()); await page.route(CLEAN_ROOM_API, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...cleanRoomPayload("MCK-1000"), view: interactionView("MCK-1000", "pending"), }), }); }); await page.goto("/#/chat"); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); } test.describe("pending-request live region", () => { test("the pending announcement is dropped once the request is answered", async ({ page }) => { await openWithPendingRequest(page); const live = page.getByTestId("thread-live-region"); await expect(live).toHaveText(PENDING_ANNOUNCEMENT); await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible(); let interactionRequests = 0; await page.route("**/api/capability/ui/interaction", async (route) => { interactionRequests += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...cleanRoomPayload("MCK-1000"), view: interactionView("MCK-1000", "answered"), }), }); }); const card = page.locator('[data-interaction-id="ix-questions-01"]'); await card.getByRole("radio", { name: "Yes — runner owns it" }).check(); await card.getByRole("button", { name: "Submit answers" }).click(); await expect(card).toHaveAttribute("data-interaction-state", "answered"); expect(interactionRequests).toBe(1); await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1); await expect(live).not.toContainText(PENDING_ANNOUNCEMENT); await expect(live).toHaveText("Your answer was recorded."); // A later turn must not resurrect the pending guidance either. await page.route("**/api/capability/ui/message", async (route) => { await route.fulfill({ status: 200, contentType: "application/x-ndjson", body: turnStreamBody({ ...cleanRoomPayload("MCK-1000"), view: interactionView("MCK-1000", "answered", { extraTurn: true }), }), }); }); await page.locator("#composer-input").fill("Carry on with the plan."); await page.getByTestId("composer-send").click(); await expect(page.locator("[data-turn-id]")).toHaveCount(2); await expect(live).not.toContainText(PENDING_ANNOUNCEMENT); }); test("a request withdrawn server-side replaces the pending announcement", async ({ page }) => { // Nobody answers here: the request goes away while the session is // reconnecting, so the correction has to come from the settled snapshot. await page.addInitScript(() => window.localStorage.clear()); await page.route(CLEAN_ROOM_API, async (route) => { const view = interactionView("MCK-1000", "pending"); view.composer.state = "reconnecting"; view.connection = { state: "reconnecting", attempt: 2 }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...cleanRoomPayload("MCK-1000"), view }), }); }); await page.goto("/#/chat"); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); const live = page.getByTestId("thread-live-region"); await expect(live).toHaveText(PENDING_ANNOUNCEMENT); await page.route("**/api/capability/ui/reconnect", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...cleanRoomPayload("MCK-1000"), view: interactionView("MCK-1000", "answered", { withdrawn: true }), }), }); }); await page.getByRole("button", { name: "Retry now" }).click(); await expect(page.locator('[data-interaction-state="withdrawn"]')).toBeVisible(); await expect(live).not.toContainText(PENDING_ANNOUNCEMENT); await expect(live).toHaveText("The pending request is resolved. Withdrawn."); }); }); /* -------------------------------------------- Capability streamed live turn */ /** * Streaming is proved against the real package server, not a stub: port 4185 * runs the same built bundle and the same session middleware with a scripted * Codex provider (`vite.issue-thread-stream.config.ts`), so the NDJSON turn * stream, the live session, the projection, and the browser client are all the * shipped code. A `route.fulfill` stub cannot show this — it can only deliver a * body in one piece, which is exactly the behaviour under repair. */ const STREAM_ORIGIN = "http://127.0.0.1:4185"; const STREAM_REPLY = "Reading the clean-room issue. It is blank, with one mock agent and one mock task. " + "Recording a first status against the mock control plane. Done — every record stayed in the mock port."; async function openStreamingCleanRoom(page: Page): Promise { await page.addInitScript(() => window.localStorage.clear()); await page.goto(`${STREAM_ORIGIN}/#/chat`); await expect(page.locator('[data-thread-state="settled"]')).toBeVisible({ timeout: 60_000 }); } test.describe("Capability streamed live turn", () => { test("shows sanitized thinking progress before assistant text arrives", async ({ page }) => { await openStreamingCleanRoom(page); await page.locator("#composer-input").fill("Show progress while you inspect this issue."); await page.getByTestId("composer-send").click(); const liveActivity = page.getByTestId("live-activity"); await expect(liveActivity).toBeVisible(); const progress = page.locator('[data-thread-item="progress_activity"][data-activity="thinking"]'); await expect(progress).toBeVisible({ timeout: 30_000 }); await expect(progress).toHaveAttribute("data-status", "running"); await expect(progress).toContainText("Thinking"); await expect(liveActivity).toContainText(/Reasoning|Thinking|Codex/); await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(0); await expect(page.locator("body")).not.toContainText( "PRIVATE reasoning text must never reach the browser.", ); await expect(page.locator('[data-thread-item="agent_message"]')).toContainText(STREAM_REPLY, { timeout: 60_000, }); await expect(progress).toHaveAttribute("data-status", "complete"); }); test("one assistant card grows while the turn POST is still open", async ({ page }) => { await openStreamingCleanRoom(page); const states: Array<{ id: string; body: string }> = []; let pendingWhileGrowing = 0; let settledWhileGrowing = 0; let turnFinished = false; // Sample the rendered card, not the network: what has to be proved is that // a reader sees the reply grow, and only the DOM can say that. const sampler = (async () => { while (!turnFinished) { const sample = await page.evaluate(() => { const card = document.querySelector('[data-thread-item="agent_message"]'); const app = document.querySelector(".pit-app"); return { id: card?.id ?? null, body: card?.querySelector(".pit-card-body")?.textContent ?? null, threadState: app?.getAttribute("data-thread-state") ?? null, composer: document.querySelector("[data-composer-state]")?.getAttribute("data-composer-state") ?? null, }; }); if (sample.id !== null && sample.body !== null && sample.body.length > 0) { if (states.at(-1)?.body !== sample.body) states.push({ id: sample.id, body: sample.body }); if (sample.body !== STREAM_REPLY) { pendingWhileGrowing += 1; if (sample.threadState === "settled") settledWhileGrowing += 1; } } await page.waitForTimeout(60); } })(); await page.locator("#composer-input").fill("Read this issue and record a status."); await page.getByTestId("composer-send").click(); // The whole reply only appears at the end; the card gets there in pieces. await expect(page.locator('[data-thread-item="agent_message"]')).toContainText(STREAM_REPLY, { timeout: 60_000, }); turnFinished = true; await sampler; const bodies = states.map((state) => state.body); expect(bodies.length).toBeGreaterThanOrEqual(3); // One card: every observed state belonged to the same transcript entry. expect(new Set(states.map((state) => state.id)).size).toBe(1); for (let index = 1; index < bodies.length; index += 1) { expect(bodies[index]!.startsWith(bodies[index - 1]!)).toBe(true); } expect(bodies.at(-1)).toBe(STREAM_REPLY); // Partial states were observed, and the surface never claimed to be // settled while one of them was on screen. expect(pendingWhileGrowing).toBeGreaterThanOrEqual(2); expect(settledWhileGrowing).toBe(0); // Settled arrives only after the terminal frame. await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1); await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(1); await expect(page.locator('[data-thread-item="agent_message"]')).toHaveAttribute( "data-streaming", "false", ); }); test("Stop interrupts the streamed turn and keeps the partial reply", async ({ page }) => { await openStreamingCleanRoom(page); await page.locator("#composer-input").fill("Start a long answer so I can stop it."); await page.getByTestId("composer-send").click(); const card = page.locator('[data-thread-item="agent_message"]'); await expect(card).toBeVisible({ timeout: 30_000 }); await expect(card).toHaveAttribute("data-streaming", "true"); const partial = ((await card.locator(".pit-card-body").textContent()) ?? "").trim(); expect(partial.length).toBeGreaterThan(0); expect(STREAM_REPLY.startsWith(partial)).toBe(true); await page.getByTestId("composer-stop").click(); // The stopped turn settles on what it had said, and nothing lands later. await expect(page.locator('[data-thread-state="settled"]')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("stopped-marker")).toBeVisible(); const stopped = ((await card.locator(".pit-card-body").textContent()) ?? "").trim(); expect(STREAM_REPLY.startsWith(stopped)).toBe(true); expect(stopped).not.toBe(STREAM_REPLY); await page.waitForTimeout(1_500); expect(((await card.locator(".pit-card-body").textContent()) ?? "").trim()).toBe(stopped); await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(1); }); });