Files
PaperClipAI/packages/paperclip-runner/examples/phase7-chat.spec.ts
T
DottaandClaude Opus 5 e72925a848 feat(runner): Phase 7I interactive scenario chat over the mock control plane
Adds a chat surface to the Phase 7 browser explorer where the board sends
prompts to a scenario and sees exactly how the mock Paperclip control plane
was used on every turn — the point of the phase is that the activity stays
visible rather than hidden behind the conversation (PAP-16916).

Runtime owns the turn model, so the UI derives nothing:

- `Phase7ChatSession` keeps one long-lived mock core across turns, so the
  state a turn changes is still there for the next one. Turn 0 is the
  control-plane seed (checkout, wake routing, exposure) that exists before
  anyone can type.
- Timeline entries gain a `turn`; the artifact gains `turns[]` with per-turn
  exposure, authorization records, state diff, reconciliation events, counts,
  and a turn-scoped verdict. A new `user_message` kind joins the five
  existing ones.
- `phase7TurnParity` judges a turn on what that turn did. A session-level
  expectation about work nobody has asked for yet would read as a failure of
  turn 1 rather than as work still to come.
- Recording machinery moves to `scenario-execution.ts`, shared with the 7F
  single-shot runner so a scenario reads the same either way.

Two scenarios carry authored multi-turn scripts: `ap-mcp-gate-01` (allowed
turn, then a denied one) and `ix-checkbox-01` (open an interaction, then the
board answers it — a control-plane-owned action with no agent tool, which is
what makes the control plane schedule the continuation wake). Every other
corpus scenario falls back to its 7F plan as one scripted turn.

The surface implements the approved 7I interaction map: turn strips bridging
each turn to its evidence, the six-section activity stream, the mobile
Chat/Activity segments with a deny count chip, reset and scenario-switch
confirmation, and deterministic deep links including a staged streaming hold.
Deviations taken during implementation are recorded in §11a of the map rather
than left for a reviewer to find.

No ACPX implementation, no real control-plane integration, no credential in
the browser: Codex mode stays disabled with a named reason and scripted mode
drives the same in-page mock core offline.

Tests: 30 new runtime/component tests and 18 browser tests; 79 phase-7 unit
and 43 phase-7 browser tests pass. Ten §11 screenshots recorded by
`record:phase7i`, which fails the run on any horizontal page scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:56:47 +00:00

252 lines
12 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
/**
* Phase 7I browser acceptance.
*
* The chat is where the board actually uses the mock control plane, so these
* cover the paths a person takes: send a prompt, get a denial, follow a turn to
* its evidence, reset, and do it again on a phone. Screen captures for the §11
* matrix are recorded by `scripts/record-phase7i-evidence.mjs`, which reuses
* these routes.
*/
const BASE = "/phase7-explorer/";
const MOBILE = { width: 390, height: 844 };
async function open(page: Page, hash: string, state = "settled"): Promise<void> {
await page.goto(`${BASE}${hash}`);
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
"data-chat-state",
state,
{ timeout: 30_000 },
);
}
async function send(page: Page, prompt: string): Promise<void> {
await page.fill('[data-testid="composer-input"]', prompt);
await page.press('[data-testid="composer-input"]', "Enter");
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
"data-chat-state",
"settled",
{ timeout: 30_000 },
);
}
test.describe("chat shell", () => {
test("chat home offers the picker and an intro, with no dead panel", async ({ page }) => {
await page.goto(`${BASE}#/chat`);
await expect(page.locator('[data-testid="explorer-intro"]')).toContainText("scenario chat");
await expect(page.locator("nav[aria-label='Scenario picker']")).toBeVisible();
await expect(page.locator("aside[aria-label='Turn activity']")).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
});
test("an unknown chat case is named rather than silently redirected", async ({ page }) => {
await page.goto(`${BASE}#/chat/not-a-real-case`);
await expect(page.locator('[data-testid="unknown-scenario"]')).toContainText("not-a-real-case");
});
test("the seeded session shows control-plane work before any message", async ({ page }) => {
await open(page, "#/chat/ix-checkbox-01");
await expect(page.locator('[data-testid="chat-seed-label"]')).toBeVisible();
await expect(page.locator('[data-testid="chat-turn-0"]')).toContainText("Control plane");
await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded");
await expect(page.locator('[data-testid="turn-activity-strip-0"]')).toBeVisible();
});
});
test.describe("driving a session from the composer", () => {
test("a typed prompt runs a turn and a denial lands in place", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01");
await send(page, "Where does the MCP tool approval stand?");
await send(page, "Raise the approval request yourself.");
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
const denial = page.locator('[data-outcome="denied"]').first();
await expect(denial).toBeVisible();
await expect(denial).toContainText("required_claim_missing");
// A denial must never carry protected state.
await expect(denial).toContainText("not returned");
await expect(page.locator('[data-testid="denial-announcement"]').first()).toHaveAttribute(
"aria-live",
"assertive",
);
// The session survives a denial; it is an outcome, not a failure.
await expect(page.locator('[data-testid="session-status"]')).toHaveText(
"Scripted · deterministic",
);
});
test("a turn strip opens that turn's evidence and mirrors it to the route", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
await page.click('[data-testid="turn-activity-strip-2"]');
await expect(page).toHaveURL(/view=activity&turn=2/);
await expect(page.locator('[data-testid="activity-turn-2"]')).toHaveAttribute(
"data-open",
"true",
);
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
await expect(page.locator('[data-testid="activity-diff-2"]')).toBeVisible();
});
test("reset confirms, returns to the seed, and leaks nothing", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
await page.click('[data-testid="reset-session"]');
await page.click('[data-testid="confirm-cancel"]');
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
await page.click('[data-testid="reset-session"]');
await page.click('[data-testid="confirm-accept"]');
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
"data-chat-state",
"settled",
{ timeout: 30_000 },
);
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0);
await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0);
await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded");
});
test("switching scenario mid-session asks before discarding the mock state", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
await page.fill('[data-testid="scenario-search"]', "ix-checkbox-01");
await page.click('[data-testid="case-row-ix-checkbox-01"]');
await expect(page.locator("dialog[open]")).toContainText("Switch scenario?");
await page.click('[data-testid="confirm-accept"]');
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
"data-chat-state",
"settled",
{ timeout: 30_000 },
);
await expect(page.locator('[data-testid="session-header"]')).toContainText("ix-checkbox-01");
await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0);
});
});
test.describe("determinism and evidence", () => {
test("the scripted route settles to a byte-identical transcript on reload", async ({ page }) => {
const read = async (): Promise<string> => {
await open(page, "#/chat/ix-checkbox-01?replay=fake");
return (await page.locator('[data-testid="chat-conversation"]').innerText()).trim();
};
const first = await read();
await page.goto("about:blank");
expect(await read()).toBe(first);
});
test("every turn group carries the six evidence sections in order", async ({ page }) => {
await open(page, "#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3");
const group = page.locator('[data-testid="activity-turn-3"]');
await expect(group.locator('[data-testid="activity-section-3-exposure"]')).toBeVisible();
await expect(group.locator('[data-testid="activity-section-3-calls"]')).toBeVisible();
await expect(group.locator('[data-testid="activity-section-3-control"]')).toBeVisible();
await expect(group.locator('[data-testid="activity-section-3-diff"]')).toBeVisible();
await expect(group.locator('[data-testid="activity-section-3-parity"]')).toBeVisible();
await expect(group.locator('[data-testid="activity-reconciliation-3"]')).toContainText(
"wake.scheduled",
);
});
test("a filter collapses sections without deleting turn groups", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2&filter=authorization");
await expect(page.locator('[data-testid="activity-turn-0"]')).toBeVisible();
await expect(page.locator('[data-testid="activity-turn-2"]')).toBeVisible();
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
await expect(page.locator('[data-testid="activity-section-2-diff"]')).toHaveCount(0);
});
test("the staged streaming route holds a running turn", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake&stage=streaming", "streaming");
await expect(page.locator('[data-slot="composer"]')).toHaveAttribute("data-state", "active-turn");
await expect(page.locator('[data-testid^="turn-pending-"]')).toBeVisible();
// Controls that would corrupt the running turn state their reason.
await expect(page.locator('[data-testid="reset-session"]')).toBeDisabled();
await expect(page.locator('[data-testid="replay-session"]')).toBeDisabled();
});
test("no credential or raw secret reaches the chat page", async ({ page }) => {
await open(page, "#/chat/rs-secret-hygiene-01?replay=fake");
const body = await page.locator("body").innerText();
expect(body).not.toContain("fixture-secret-value-not-a-real-credential");
expect(await page.evaluate(() => window.localStorage.length)).toBe(0);
});
});
test.describe("mobile and accessibility", () => {
test.use({ viewport: MOBILE });
test("the activity segment carries the whole stream and counts denials", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
await expect(page.locator('[data-testid="segment-activity-denials"]')).toContainText("deny");
await page.click('[data-testid="segment-activity"]');
await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible();
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
await expect(page.locator('[data-testid="chat-conversation"]')).toBeHidden();
});
test("back to chat returns focus to the turn strip that opened the group", async ({ page }) => {
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
await page.click('[data-testid="turn-activity-strip-2"]');
await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible();
await page.click('[data-testid="activity-back-to-chat-2"]');
await expect(page.locator('[data-testid="chat-conversation"]')).toBeVisible();
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeFocused();
});
test("long payloads never scroll the page sideways", async ({ page }) => {
for (const hash of [
"#/chat/ix-checkbox-01?replay=fake",
"#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2",
"#/chat/mh-subtask-tree-01?replay=fake&view=activity",
]) {
await open(page, hash);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow, `${hash} scrolls horizontally`).toBeLessThanOrEqual(0);
}
});
test("touch targets on the chat controls meet the minimum", async ({ page }) => {
await open(page, "#/chat/ix-checkbox-01?replay=fake");
for (const testId of ["segment-chat", "segment-activity", "turn-activity-strip-1"]) {
const box = await page.locator(`[data-testid="${testId}"]`).first().boundingBox();
expect(box, testId).not.toBeNull();
expect(box!.height, testId).toBeGreaterThanOrEqual(40);
}
});
test("the composer stays in the thumb zone while the transcript scrolls", async ({ page }) => {
await open(page, "#/chat/ix-checkbox-01?replay=fake");
await page.evaluate(() => window.scrollTo(0, 0));
const composer = page.locator('[data-testid="composer"]');
const top = (await composer.boundingBox())!.y;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const bottom = (await composer.boundingBox())!.y;
expect(bottom).not.toBe(top);
// Still on screen after the scroll, not left behind at the top.
const box = (await composer.boundingBox())!;
expect(box.y).toBeLessThan(MOBILE.height);
expect(box.y + box.height).toBeGreaterThan(0);
});
test("the composer keyboard contract holds", async ({ page }) => {
await open(page, "#/chat/ix-checkbox-01");
const input = page.locator('[data-testid="composer-input"]');
await input.fill("first line");
await input.press("Shift+Enter");
await input.type("second line");
expect(await input.inputValue()).toContain("\n");
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0);
await input.press("Enter");
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
"data-chat-state",
"settled",
{ timeout: 30_000 },
);
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toBeVisible();
});
});