From e72925a8483fdfd186591fa1bca965c2d760df2b Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 9 Aug 2026 15:56:47 +0000 Subject: [PATCH] feat(runner): Phase 7I interactive scenario chat over the mock control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../docs/design/phase-7i-mobile-chat-ux.md | 63 +- packages/paperclip-runner/docs/index.md | 3 + .../docs/phase-07i-scenario-chat.md | 105 ++++ .../docs/tutorials/phase-07i-scenario-chat.md | 129 ++++ .../examples/phase7-chat.spec.ts | 251 ++++++++ .../examples/phase7-explorer/src/app.tsx | 193 +++++- .../phase7-explorer/src/chat.test.tsx | 295 +++++++++ .../src/components/chat-activity-stream.tsx | 398 ++++++++++++ .../src/components/chat-conversation.tsx | 120 ++++ .../src/components/chat-session-header.tsx | 167 ++++++ .../src/components/chat-surface.tsx | 333 +++++++++++ .../src/components/chat-turn-strip.tsx | 83 +++ .../src/components/scenario-transcript.tsx | 14 +- .../examples/phase7-explorer/src/explorer.css | 286 ++++++++- .../examples/phase7-explorer/src/route.ts | 62 +- .../examples/playwright.phase7.config.ts | 2 +- .../2026-08-09-phase-07i-chat-verification.md | 117 ++++ .../knowledge/evidence/index.md | 1 + .../phase-7i-chat-activity-diff-1440x900.png | Bin 0 -> 941711 bytes .../phase-7i-chat-activity-diff-390x844.png | Bin 0 -> 387980 bytes .../phase-7i-chat-denied-1440x900.png | Bin 0 -> 821233 bytes .../phase-07/phase-7i-chat-denied-390x844.png | Bin 0 -> 357291 bytes .../phase-07/phase-7i-chat-home-1440x900.png | Bin 0 -> 133594 bytes .../phase-07/phase-7i-chat-home-390x844.png | Bin 0 -> 92025 bytes .../phase-7i-chat-session-1440x900.png | Bin 0 -> 940057 bytes .../phase-7i-chat-session-390x844.png | Bin 0 -> 326213 bytes .../phase-7i-chat-streaming-1440x900.png | Bin 0 -> 770718 bytes .../phase-7i-chat-streaming-390x844.png | Bin 0 -> 213990 bytes .../phase-7i-screenshot-manifest.json | 47 ++ packages/paperclip-runner/package.json | 1 + .../scripts/record-phase7i-evidence.mjs | 178 ++++++ .../src/phase7/chat-script.ts | 207 +++++++ .../src/phase7/chat-session.test.ts | 218 +++++++ .../src/phase7/chat-session.ts | 389 ++++++++++++ packages/paperclip-runner/src/phase7/index.ts | 11 + .../src/phase7/scenario-execution.ts | 564 ++++++++++++++++++ .../src/phase7/scenario-explorer-types.ts | 66 ++ .../src/phase7/scenario-parity.ts | 126 ++++ .../src/phase7/scenario-plan.ts | 10 + .../src/phase7/scenario-runner.ts | 436 ++------------ packages/paperclip-runner/styles.css | 1 + 41 files changed, 4450 insertions(+), 426 deletions(-) create mode 100644 packages/paperclip-runner/docs/phase-07i-scenario-chat.md create mode 100644 packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md create mode 100644 packages/paperclip-runner/examples/phase7-chat.spec.ts create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/chat.test.tsx create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/components/chat-activity-stream.tsx create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/components/chat-conversation.tsx create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/components/chat-session-header.tsx create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/components/chat-surface.tsx create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/components/chat-turn-strip.tsx create mode 100644 packages/paperclip-runner/knowledge/evidence/2026-08-09-phase-07i-chat-verification.md create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-activity-diff-1440x900.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-activity-diff-390x844.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-denied-1440x900.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-denied-390x844.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-home-1440x900.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-home-390x844.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-session-1440x900.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-session-390x844.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-streaming-1440x900.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-chat-streaming-390x844.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/phase-7i-screenshot-manifest.json create mode 100644 packages/paperclip-runner/scripts/record-phase7i-evidence.mjs create mode 100644 packages/paperclip-runner/src/phase7/chat-script.ts create mode 100644 packages/paperclip-runner/src/phase7/chat-session.test.ts create mode 100644 packages/paperclip-runner/src/phase7/chat-session.ts create mode 100644 packages/paperclip-runner/src/phase7/scenario-execution.ts diff --git a/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md b/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md index 828bb1f87e5..ee15e74379a 100644 --- a/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md +++ b/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md @@ -1,6 +1,8 @@ # Phase 7I Interaction Map — Mobile Chat with Visible Mock Activity -Status: **approved UX contract for 7I implementation** (PAP-16914, 2026-08-09). +Status: **approved UX contract**, implemented in PAP-16916 (2026-08-09). +Deviations taken during implementation are recorded in §11a; the §11 matrix is +recorded by `pnpm run record:phase7i`. Owner: UXDesigner. Implementer: 7I implementation (PAP-16916), consuming the existing 7C mock adapter, 7D semantic catalog, and 7F explorer components. Companion records: [Phase 7 interaction map](phase-7-scenario-explorer-ux.md) @@ -321,9 +323,9 @@ the correct active segment and no horizontal scrollbar. | --- | --- | --- | --- | | 1 | `chat-home` | `#/chat` | Picker + chat intro; no dead panels. | | 2 | `chat-session` | `#/chat/ix-checkbox-01?replay=fake` | Turn-0 control-plane strips; user/model/tool/control-plane grammars all distinguishable; turn activity strips with counts; composer in its mode-correct state; desktop shows the activity rail with the newest turn expanded. | -| 3 | `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=1` | Denied call on danger surface in both conversation and authorization section; missing claim + reason; Activity segment chip counting the deny (mobile). | -| 4 | `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=2` | Turn group with exposure counts, calls, authorization, control-plane rows, per-turn state diff (changed vs unchanged domains), reconciliation/wake row, turn parity verdict. | -| 5 | `chat-streaming` | live route, staged | Streaming model card, composer `active-turn` (Steer/Stop), pending activity group header, disabled session controls with reasons. Exempt from pixel determinism; capture once per viewport. | +| 3 | `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2` | Denied call on danger surface in both conversation and authorization section; missing claim + reason; Activity segment chip counting the deny (mobile). | +| 4 | `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3` | Turn group with exposure counts, calls, authorization, control-plane rows, per-turn state diff (changed vs unchanged domains), reconciliation/wake row, turn parity verdict. | +| 5 | `chat-streaming` | `#/chat/ap-mcp-gate-01?replay=fake&stage=streaming` | Streaming model card, composer `active-turn` (Steer/Stop), pending activity group header, disabled session controls with reasons. Exempt from pixel determinism; capture once per viewport. | The scripted fixtures for `ix-checkbox-01` and `ap-mcp-gate-01` chat sessions are new 7I fixtures (the 7F fake-agent plans are single-shot, not @@ -334,6 +336,59 @@ Acceptance procedure: UXDesigner reviews all ten images against this matrix before 7I implementation is accepted; deviations are fixed or recorded as revisions in this document — silent divergence fails the gate (7F §9 rule). +## 11a. Revisions recorded during 7I implementation (PAP-16916) + +Silent divergence fails the §11 gate, so every deviation the implementation +made from §1–§11 is recorded here rather than left for a reviewer to discover. + +1. **Mode names: `Scripted (deterministic)` / `Codex (bounded)`, and the + composer stays usable in scripted mode.** §3 said replay mode renders the + composer `disconnected` because "free-text input is a live-driver + capability". With the provider relay unavailable in the package-local demo + (the same 7F §3.1 condition), that rule would have made the phase's own + acceptance — "a user can complete successful and denied mock Paperclip + interactions from chat" — unreachable. Scripted mode therefore accepts a + prompt and drives the next recorded turn against the live mock core, and the + composer note says exactly that, with the number of scripted turns left. The + board is never told a model wrote the reply. +2. **`replay=fake` plays once per session, not per route.** An explicit + **Reset session** on a `replay=fake` route returns to the seeded turn 0 and + stays there; the route's auto-play applies to the first session only. + Otherwise reset would be undone in the same frame by the route replaying, + which is the opposite of what §2 promises. +3. **`stage=streaming` added to §9.** §11 shot 5 said "live route, staged". + Staging by hand is a race, so the route parameter holds the last scripted + turn mid-reveal and the shell reports `data-chat-state="streaming"` — a + stable, linkable wait target. `pending` covers the mid-reveal transition. +4. **`data-chat-state` lives on the shell, not only on `chat-shell`.** On the + mobile Activity segment the chat column is hidden, so a wait target that + only existed inside it was unreachable exactly where the evidence is. Both + elements now carry it. +5. **Tool cards reuse the 7F transcript disclosure rather than the SDK + `tool-item`.** `ToolItem` requires a reducer `SessionItemSnapshot`, which a + semantic call is not. The 7F rendering already follows the same + `pcr-disclosure` grammar `ToolItem` itself uses, and reusing it keeps one + description of a semantic call across both surfaces. `Message`, `Composer`, + `Dialog`, `Badge`, and `Banner` are used as specified. +6. **Turn 0 reports `Pass`, not a blank verdict.** The seed makes exactly one + checkable claim — control-plane work with no agent tool — and reports a + verdict over that claim alone. Turns that did no agent work and made no + control-plane claim report `Not run`; no turn ever reports a verdict for + work nobody did. +7. **A third authored turn for `ix-checkbox-01`.** §11 shot 4 asks for a + wake/reconciliation row, but the mock core schedules the continuation wake + when an interaction is **resolved**, not when it is requested. Turn 3 is the + board answering its own checkbox — a control-plane-owned action with no + agent tool — which is a better demonstration of the phase's point than a + fabricated wake would have been. Shot 4's route is therefore `turn=3`. +8. **Filtering the picker no longer clears the selected case on the chat + surface.** Narrowing the rail must not end a live session; the explorer + keeps its existing behaviour. +9. **Evidence captures neutralise the sticky composer.** A full-page capture + stretches the viewport to the content height, which leaves a sticky element + floating mid-document. Pinning is proven by a browser test that scrolls the + transcript and asserts the composer is still on screen. + ## 12. Out of scope / rejected - All 7F §10 rejections stand (no Tailwind/Radix, no dark mode, no markdown diff --git a/packages/paperclip-runner/docs/index.md b/packages/paperclip-runner/docs/index.md index 28bf11705c4..d6d83c34dc9 100644 --- a/packages/paperclip-runner/docs/index.md +++ b/packages/paperclip-runner/docs/index.md @@ -15,6 +15,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase - [Phase 5: run the SDK console and mini consumer](tutorials/phase-05-sdk-console.md) — exercise the versioned public browser/React surface in two independent consumers against fake and real drivers. - [Phase 6: run the thin Paperclip adapter](tutorials/phase-06-thin-paperclip-adapter.md) — prove mock/real conformance, run one feature-flagged local task, inspect replay/finalization, disable the kill switch, and verify legacy fallback. - [Phase 7: explore the capability contract and scenario explorer](tutorials/phase-07-scenario-explorer.md) — prove the 106-case conformance suite, open the read-only browser explorer over the mock control plane, and run the focused verification set, all from a clean checkout with no Paperclip service or external eval repository. +- [Phase 7I: chat with the mock control plane](tutorials/phase-07i-scenario-chat.md) — send prompts to a Phase 7 scenario and watch the mock Paperclip activity for every turn: exposure, typed calls, denials, control-plane-owned actions, state diffs, wakes, and per-turn parity. - [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase. ## Reference @@ -35,9 +36,11 @@ Each tutorial starts with an explanation of what the phase is and what the phase - [Phase 7 authorization and exposure](phase-07-authorization-and-exposure.md) - [Phase 7 eval-derived conformance](phase-07-eval-conformance.md) - [Phase 7 browser scenario explorer](phase-07-scenario-explorer.md) +- [Phase 7I interactive scenario chat](phase-07i-scenario-chat.md) - [Phase 7 future binding boundary (Phase 8 / ACPX)](phase-07-future-binding-boundary.md) - [Phase 7 verification commands](phase-07-verification-commands.md) - [Phase 7 scenario explorer UX interaction map](design/phase-7-scenario-explorer-ux.md) +- [Phase 7I mobile chat UX interaction map](design/phase-7i-mobile-chat-ux.md) - [Engineering journal guide](journal.md) - [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md) - [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md) diff --git a/packages/paperclip-runner/docs/phase-07i-scenario-chat.md b/packages/paperclip-runner/docs/phase-07i-scenario-chat.md new file mode 100644 index 00000000000..f1d4bd2382a --- /dev/null +++ b/packages/paperclip-runner/docs/phase-07i-scenario-chat.md @@ -0,0 +1,105 @@ +# Phase 7I Scenario Chat + +The scenario chat is an interactive surface over the same package-local Phase 7 +runner and [mock control plane](phase-07-mock-control-plane-port.md) that the +[scenario explorer](phase-07-scenario-explorer.md) reads. You pick a scenario, +send prompts, and watch the mock Paperclip activity for every turn instead of +having it hidden behind the conversation. It contacts no Paperclip service, +holds no credential, and introduces no ACPX implementation. + +Sources: `src/phase7/chat-session.ts`, `src/phase7/chat-script.ts`, +`src/phase7/scenario-execution.ts`, and `examples/phase7-explorer/`. +Interaction contract: +[Phase 7I mobile chat UX](design/phase-7i-mobile-chat-ux.md). + +## The turn model + +A **session** is one long-lived mock control plane. Unlike a 7F single-shot +run, the state a turn changes is still there for the next turn — which is what +makes a per-turn diff mean anything. + +- **Turn 0 is the session seed**: checkout, wake routing, and the first tool + exposure decision, all performed by the control plane before anyone can type. + It renders with control-plane styling because no agent tool was involved. +- **Turns 1..N** are one user message each, plus everything the runtime + recorded until the agent settled. + +Every timeline entry carries the `turn` it belongs to, and the artifact carries +a `turns[]` array. The UI groups on those records; it never derives a turn +boundary, a per-turn diff, or a per-turn verdict itself. + +## What each turn records + +| Record | Produced by | +| --- | --- | +| Exposed tools (always / optional with unlocking grant / control-plane — no tool) | the 7D authorization engine | +| Semantic calls and typed results | the 7D tool runtime | +| Authorization decisions, missing claims, redactions | the 7D authorization engine | +| Control-plane actions with audit and decision references | the 7C mock core | +| State diff over the ten entity domains | immutable fixture snapshots | +| Wake / reconciliation events | the 7C mock core's own scheduled wakes | +| Turn parity verdict | `phase7TurnParity` | + +A turn is judged on what that turn did. A session-level expectation about work +the board has not asked for yet would read as a failure of turn 1 rather than +as work still to come, so the session verdict stays separate and is judged over +the whole transcript. + +## Scripts and drivers + +Every one of the 106 corpus scenarios opens in chat. Two carry authored +multi-turn conversations because the acceptance matrix names them: + +- `ix-checkbox-01` — read the options, open a checkbox interaction, then the + board answers it. That answer is a **control-plane-owned action with no agent + tool**, and it is what makes the control plane schedule the continuation wake. +- `ap-mcp-gate-01` — read the approval queue (allowed), then attempt the gated + request without the decide claim (**denied**). The denial is a turn outcome + with its own authorization slice, not a broken session. + +Every other scenario falls back to its 7F plan as a single scripted turn, which +keeps the whole corpus openable without inventing dialogue the traceability rows +do not support. Past the end of a script the session stays usable: the agent +re-reads the task context so the turn still shows real mock activity. + +**Scripted (deterministic)** is the working driver: your prompt drives the next +recorded turn against the in-page mock core. **Codex (bounded)** is disabled +with a named reason unless the local provider relay is running — the same rule +the explorer applies. No provider credential ever reaches the page in either +mode. + +## Routes + +``` +#/chat chat home (picker + intro) +#/chat/ live session, seeded at turn 0 +#/chat/?replay=fake scripted session, auto-played once +#/chat/?replay=fake&view=activity&turn= +#/chat/?replay=fake&filter= +#/chat/?replay=fake&stage=streaming holds the last turn mid-stream +``` + +The shell reports `data-chat-state` (`seeding` / `pending` / `streaming` / +`settled` / `failed`) so captures and tests wait on a state rather than a +timeout. `replay=fake` plays the script once per session: an explicit **Reset +session** returns to the seeded turn 0 and stays there. + +## Boundary + +- No network request leaves the page's own origin, and nothing the board types + is written to `localStorage`. +- The chat renders records the runtime produced. It computes no exposure, + evaluates no claim, applies no redaction, and re-times nothing. +- Redaction happens at the artifact boundary, so a raw secret cannot reach a + rendered record even if an upstream layer returned one. + +## Commands + +```sh +pnpm --filter @paperclipai/paperclip-runner run demo:phase7 # open the surface +pnpm --filter @paperclipai/paperclip-runner run test:phase7 # runtime + component tests +pnpm --filter @paperclipai/paperclip-runner run test:browser:phase7 +pnpm --filter @paperclipai/paperclip-runner run record:phase7i # §11 screenshot matrix +``` + +Walkthrough: [Phase 7I scenario chat tutorial](tutorials/phase-07i-scenario-chat.md). diff --git a/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md b/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md new file mode 100644 index 00000000000..0ca87f526e8 --- /dev/null +++ b/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md @@ -0,0 +1,129 @@ +# Phase 7I Tutorial: Chat With the Mock Control Plane + +**Time to first success: about 3 minutes.** One command opens a chat where you +send prompts to a Phase 7 scenario and watch exactly how the mock Paperclip +control plane was used on every turn. The tutorial runs from the repository +root. It starts no Paperclip service, contacts no Paperclip control plane, and +holds no provider credential. + +Phase 7I extends the Phase 7 browser surface; it does not integrate the runner +into Paperclip. Real integration is Phase 8 and requires separate approval. See +[the future binding boundary reference](../phase-07-future-binding-boundary.md). + +## What you need + +- Node.js 20 or newer and pnpm 9 or newer. Verified with Node 22.22.2. +- No Rust toolchain and no network access after `pnpm install`. + +Install the package workspace from the repository root: + +```sh +pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev +``` + +## 1. Open the chat (about 1 minute) + +```sh +pnpm --filter @paperclipai/paperclip-runner run demo:phase7 +``` + +Open . + +Before you type anything, the conversation already has content: a **session +seed** strip reading "Control plane checked out the task and opened the run — no +agent tool exists for this," and a **Session seed activity** strip counting what +it did. That is the point of turn 0. Work happened, and no agent tool was +involved. + +The session status chip reads **Idle · seeded**. The right rail — **Activity** +on desktop, the **Activity** segment on a phone — shows the seed group with its +exposure decision, state diff, and verdict. + +## 2. Run a turn that succeeds + +Type into the composer and press Enter: + +``` +Where does the MCP tool approval for this task stand? +``` + +The turn appends: your message as a right-aligned bubble, two semantic calls +(`get_task_context`, `list_approvals`) as tool cards with their disposition +badges, the agent's reply, and a **Turn 1 activity** strip: + +``` +Turn 1 activity ⚙ 2 tools ⌘ 0 control plane Δ 0 domains ✓ Pass +``` + +Click the strip. The Activity stream opens turn 1's group with the six evidence +sections in order — exposed tools, calls and results, authorization, control +plane, state diff, parity. Nothing changed state, and the diff says so. + +## 3. Run a turn that is denied + +``` +Raise the approval request yourself and get the tool switched on. +``` + +`request_approval` is denied. The denial renders in place on the danger surface +with the failing operation, the code `policy_denied`, the reason +`required_claim_missing`, and an explicit line that **no protected state was +returned**. The turn strip counts it (`✕ 1 denied`), and on mobile the Activity +segment carries a `· 1 deny` chip so you can see it without opening the segment. + +The session status still reads **Scripted · deterministic** and the session +parity still passes. A denial is a correct outcome, not a broken session — this +run simply does not hold `governance:approvals:request`. Open the turn's +**Authorization** section to see the claims considered and the missing one. + +## 4. See control-plane-owned work with no agent tool + +Open . +`replay=fake` plays the recorded three-turn conversation. + +Turn 2 opens a checkbox interaction. Turn 3 is the board answering it — and +that answer is performed by the control plane, not by a tool: + +``` +⌘ Control plane recorded the board's answer and scheduled the continuation + wake — no agent tool exists for this. +``` + +Open turn 3's **State diff** section. The `interactions` domain shows +`status: pending → answered`, the unchanged domains collapse to one line, and a +**Control plane** badged row records `wake.scheduled`. The wake is scheduled by +reconciliation; the agent never asked for it. + +## 5. Reset and replay + +**Reset session** asks first, because it discards mutable mock state. Accept it +and the transcript returns to the seeded turn 0 — no turns, no denials, a clean +diff. Switching scenario from the rail mid-session asks the same question, since +two scenarios never share mock state. + +**Replay scripted session** plays the remaining recorded turns and needs no +confirmation: it reproduces an identical timeline. + +## 6. Run the tests and record the evidence + +```sh +pnpm --filter @paperclipai/paperclip-runner run test:phase7 +pnpm --filter @paperclipai/paperclip-runner run test:browser:phase7 +pnpm --filter @paperclipai/paperclip-runner run record:phase7i +``` + +The first covers the session data contract and the chat components, the second +drives the surface in a real browser at both viewports, and the third writes the +ten acceptance screenshots to `knowledge/evidence/phase-07/` — failing if any +route scrolls horizontally. + +## What this does not do + +- No ACPX implementation and no real control-plane integration. +- No provider credential in the browser. **Codex (bounded)** stays disabled with + a named reason unless the local provider relay is running; **Scripted + (deterministic)** drives the same mock core offline. +- Nothing you type is persisted. A reload drops an unsent draft, which is the + accepted trade against storing conversation content. + +Reference: [Phase 7I scenario chat](../phase-07i-scenario-chat.md). diff --git a/packages/paperclip-runner/examples/phase7-chat.spec.ts b/packages/paperclip-runner/examples/phase7-chat.spec.ts new file mode 100644 index 00000000000..174f3f8255b --- /dev/null +++ b/packages/paperclip-runner/examples/phase7-chat.spec.ts @@ -0,0 +1,251 @@ +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 { + 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 { + 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 => { + 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(); + }); +}); diff --git a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx index 2bc7ffdd84d..3767eb6d773 100644 --- a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx +++ b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { Tabs, type TabDefinition } from "@paperclipai/paperclip-runner/react"; import { phase7RunScenario, + Phase7ChatSession, + type Phase7ChatSessionArtifact, type Phase7EvalSuiteLookup, type Phase7ParityStatus, type Phase7RunArtifact, @@ -21,11 +23,18 @@ import { buildFacets, ScenarioPicker, type Phase7PickerFacets } from "./componen import { RunHeader } from "./components/run-header.js"; import { ScenarioTranscript } from "./components/scenario-transcript.js"; import { EmptyState, Mono } from "./components/primitives.js"; +import { ChatSurface, type Phase7ChatState } from "./components/chat-surface.js"; +import { + ActivityEmptyState, + ActivityStream, + activityDenialCount, +} from "./components/chat-activity-stream.js"; import { DISPOSITION_LABEL } from "./labels.js"; import { EMPTY_FILTERS, parsePhase7Route, serializePhase7Route, + type Phase7ActivityFilter, type Phase7Filters, type Phase7InspectorView, type Phase7Route, @@ -47,10 +56,28 @@ export interface ExplorerAppProps { initialRoute?: Phase7Route; /** Test seam: run scenarios synchronously through an injected runner. */ runScenario?: typeof phase7RunScenario; + /** Test seam: open chat sessions through an injected session factory. */ + openSession?: typeof Phase7ChatSession.open; } type RunState = "idle" | "pending" | "settled" | "failed"; -type Segment = "scenarios" | "run" | "inspect"; +/** + * Mobile segments. The explorer and the chat are two surfaces in one shell, so + * they share the segmented-control mechanism but never its labels: "Run" and + * "Chat" are different jobs and must not look like the same tab renamed. + */ +type Segment = "scenarios" | "run" | "inspect" | "chat" | "activity"; + +const EXPLORER_SEGMENTS = ["scenarios", "run", "inspect"] as const; +const CHAT_SEGMENTS = ["scenarios", "chat", "activity"] as const; + +const SEGMENT_LABEL: Record = { + scenarios: "Scenarios", + run: "Run", + inspect: "Inspect", + chat: "Chat", + activity: "Activity", +}; const INSPECTOR_TABS: readonly TabDefinition[] = [ { id: "context", label: "Context" }, @@ -65,6 +92,7 @@ export function ExplorerApp({ codexAvailable = false, initialRoute, runScenario = phase7RunScenario, + openSession, }: ExplorerAppProps) { const [route, setRoute] = React.useState( () => initialRoute ?? parsePhase7Route(typeof window === "undefined" ? "#/" : window.location.hash), @@ -72,12 +100,13 @@ export function ExplorerApp({ const [artifacts, setArtifacts] = React.useState>({}); const [runState, setRunState] = React.useState("idle"); const [mode, setMode] = React.useState<"fake" | "codex">(route.run ?? "fake"); - const [segment, setSegment] = React.useState(() => - route.caseId === null ? "scenarios" : route.view === "transcript" ? "run" : "inspect", - ); + const [segment, setSegment] = React.useState(() => defaultSegment(route)); const [replayPosition, setReplayPosition] = React.useState(0); const [highlight, setHighlight] = React.useState(null); const [runFocusRequest, setRunFocusRequest] = React.useState(0); + const [chatArtifact, setChatArtifact] = React.useState(null); + const [chatState, setChatState] = React.useState("seeding"); + const [stripFocusRequest, setStripFocusRequest] = React.useState({ turn: 0, nonce: 0 }); const shellRef = React.useRef(null); const runHeaderRef = React.useRef(null); @@ -102,14 +131,23 @@ export function ExplorerApp({ // button) has to move the mobile segment too, or the route says "parity" // while the screen still shows the picker. React.useEffect(() => { - setSegment(route.caseId === null ? "scenarios" : route.view === "transcript" ? "run" : "inspect"); - }, [route.caseId, route.view]); + setSegment(defaultSegment(route)); + }, [route.caseId, route.chatView, route.surface, route.view]); React.useEffect(() => { if (runFocusRequest === 0 || selected === null) return; runHeaderRef.current?.focus(); }, [runFocusRequest, selected]); + React.useEffect(() => { + if (stripFocusRequest.nonce === 0) return; + document + .querySelector( + `[data-testid="turn-activity-strip-${stripFocusRequest.turn}"]`, + ) + ?.focus(); + }, [stripFocusRequest]); + React.useEffect(() => { const cycleRegions = (event: KeyboardEvent): void => { const documentedAlternative = event.ctrlKey && event.code === "Period"; @@ -207,22 +245,67 @@ export function ExplorerApp({ } function setFilters(patch: Partial): void { - navigate({ ...route, caseId: null, filters: { ...route.filters, ...patch } }); + // Narrowing the picker must not end a live chat session, so the chat + // surface keeps its case while the explorer still returns to the corpus. + const caseId = route.surface === "chat" ? route.caseId : null; + navigate({ ...route, caseId, filters: { ...route.filters, ...patch } }); setSegment("scenarios"); } + function setChatView(next: "chat" | "activity"): void { + navigate({ ...route, chatView: next }); + } + + function openTurn(turn: number): void { + navigate({ ...route, chatView: "activity", turn }); + setSegment("activity"); + } + + function backToChat(turn: number): void { + navigate({ ...route, chatView: "chat", turn }); + setSegment("chat"); + // Focus after the commit: on mobile the strip is still hidden at this point. + setStripFocusRequest((request) => ({ turn, nonce: request.nonce + 1 })); + } + + const chat = route.surface === "chat"; + const denials = chatArtifact === null ? 0 : activityDenialCount(chatArtifact); + return (
- Skip to run view + Skip to {chat ? "the chat" : "run view"} + +

Region shortcut: press F6, or Control+Period when the browser reserves F6, to cycle the scenario picker, run view, and inspector. @@ -236,18 +319,27 @@ export function ExplorerApp({

- {(["scenarios", "run", "inspect"] as const).map((value) => ( + {(chat ? CHAT_SEGMENTS : EXPLORER_SEGMENTS).map((value) => ( ))}
@@ -266,7 +358,12 @@ export function ExplorerApp({ onCommitSelection={commitCase} /> -
+
{route.caseId !== null && selected === null ? ( ) : selected === null ? ( - + + ) : chat ? ( + openTurn(chatArtifact?.turns.at(-1)?.turn ?? 0)} + onArtifactChange={setChatArtifact} + onStateChange={setChatState} + onCancelScenarioSwitch={(entryId) => navigate({ ...route, caseId: entryId })} + openSession={openSession} + headerRef={runHeaderRef} + /> ) : ( <> -