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>
This commit is contained in:
DottaandClaude Opus 5 committed 2026-08-09 15:56:47 +00:00
1 parent 570f3d7a16
commit e72925a848
41 files changed
+4450 -426

No files matched your search

@@ -0,0 +1,178 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createServer } from "node:net";
import { chromium } from "@playwright/test";
/**
* Records the Phase 7I screenshot acceptance matrix.
*
* The five slugs, routes, and viewports are the ones the approved 7I
* interaction map fixes in §11. Each shot waits for the state the route is
* supposed to reach — `settled` for the scripted routes, `streaming` for the
* staged capture — so a capture can never be a race.
*/
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const evidenceDirectory = resolve(packageRoot, "knowledge/evidence/phase-07");
/**
* Shared dev hosts routinely have the 418x/419x range occupied, so the
* recorder binds an ephemeral port rather than competing for a fixed one.
*/
async function freePort() {
const explicit = process.env.PHASE7_EVIDENCE_PORT;
if (explicit !== undefined) return Number(explicit);
return await new Promise((settle, fail) => {
const probe = createServer();
probe.on("error", fail);
probe.listen(0, "127.0.0.1", () => {
const { port } = probe.address();
probe.close(() => settle(port));
});
});
}
const VIEWPORTS = [
{ label: "1440x900", width: 1440, height: 900 },
{ label: "390x844", width: 390, height: 844 },
];
/** 7I map §11 — the acceptance set reviewed before 7I is accepted. */
export const PHASE7I_SHOTS = [
{ slug: "chat-home", route: "#/chat", state: null },
{ slug: "chat-session", route: "#/chat/ix-checkbox-01?replay=fake", state: "settled" },
{
slug: "chat-denied",
route: "#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2",
state: "settled",
},
{
slug: "chat-activity-diff",
route: "#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3",
state: "settled",
},
{
slug: "chat-streaming",
route: "#/chat/ap-mcp-gate-01?replay=fake&stage=streaming",
state: "streaming",
},
];
async function waitForServer(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The dev server is still starting.
}
await new Promise((settle) => setTimeout(settle, 250));
}
throw new Error(`explorer did not start on ${url}`);
}
async function main() {
await mkdir(evidenceDirectory, { recursive: true });
const port = await freePort();
const origin = `http://127.0.0.1:${port}`;
const server = spawn(
"pnpm",
[
"exec",
"vite",
"--config",
"vite.phase7.config.ts",
"--host",
"127.0.0.1",
"--port",
String(port),
"--strictPort",
],
{ cwd: packageRoot, stdio: "ignore" },
);
const captured = [];
const overflows = [];
try {
await waitForServer(`${origin}/phase7-explorer/`, 60_000);
const browser = await chromium.launch();
try {
for (const viewport of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
reducedMotion: "reduce",
});
const page = await context.newPage();
for (const shot of PHASE7I_SHOTS) {
// A hash-only navigation would keep the previous session in memory,
// so every capture starts from a clean document.
await page.goto("about:blank");
await page.goto(`${origin}/phase7-explorer/${shot.route}`);
if (shot.state !== null) {
await page.waitForSelector(
`[data-testid="explorer-shell"][data-chat-state="${shot.state}"]`,
{ timeout: 30_000 },
);
}
// A full-page capture stretches the viewport to the content height,
// which leaves the sticky mobile composer floating mid-document and
// misrepresents the reading order. Pinning is proven by the browser
// spec; the evidence shot shows the layout in document order.
await page.addStyleTag({
content: ".pcr7-chat-composer { position: static !important; }",
});
// Expand the chat and activity disclosures so the matrix evidence is
// visible: arguments, denial records, diff rows, parity assertions.
// The picker's facets keep their default state — the rail is
// navigation, not evidence.
await page.evaluate(() => {
for (const element of document.querySelectorAll("main details, aside details")) {
element.setAttribute("open", "");
}
});
// The 7I map makes "no horizontal page scroll" a hard gate, so the
// recorder refuses to hand over a shot that violates it.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
if (overflow > 0) overflows.push(`${shot.slug}-${viewport.label} (+${overflow}px)`);
const path = resolve(evidenceDirectory, `phase-7i-${shot.slug}-${viewport.label}.png`);
await page.screenshot({ path, fullPage: true });
captured.push(`phase-7i-${shot.slug}-${viewport.label}.png`);
process.stdout.write(`captured ${shot.slug} at ${viewport.label}\n`);
}
await context.close();
}
} finally {
await browser.close();
}
} finally {
server.kill();
}
await writeFile(
resolve(evidenceDirectory, "phase-7i-screenshot-manifest.json"),
`${JSON.stringify(
{
schema: "paperclip.phase7.screenshot-manifest.v1",
source: "docs/design/phase-7i-mobile-chat-ux.md §11",
viewports: VIEWPORTS.map((viewport) => viewport.label),
shots: PHASE7I_SHOTS,
files: captured.sort(),
},
null,
2,
)}\n`,
);
if (overflows.length > 0) {
throw new Error(`horizontal page scroll in: ${overflows.join(", ")}`);
}
process.stdout.write(`Recorded ${captured.length} Phase 7I chat screenshots.\n`);
}
await main();