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>
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<case-id> live session, seeded at turn 0
|
||||
#/chat/<case-id>?replay=fake scripted session, auto-played once
|
||||
#/chat/<case-id>?replay=fake&view=activity&turn=<n>
|
||||
#/chat/<case-id>?replay=fake&filter=<tools|authorization|control|diff|parity>
|
||||
#/chat/<case-id>?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).
|
||||
@@ -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 <http://127.0.0.1:4183/phase7-explorer/#/chat/ap-mcp-gate-01>.
|
||||
|
||||
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 <http://127.0.0.1:4183/phase7-explorer/#/chat/ix-checkbox-01?replay=fake>.
|
||||
`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).
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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<Segment, string> = {
|
||||
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<Phase7Route>(
|
||||
() => initialRoute ?? parsePhase7Route(typeof window === "undefined" ? "#/" : window.location.hash),
|
||||
@@ -72,12 +100,13 @@ export function ExplorerApp({
|
||||
const [artifacts, setArtifacts] = React.useState<Record<string, Phase7RunArtifact>>({});
|
||||
const [runState, setRunState] = React.useState<RunState>("idle");
|
||||
const [mode, setMode] = React.useState<"fake" | "codex">(route.run ?? "fake");
|
||||
const [segment, setSegment] = React.useState<Segment>(() =>
|
||||
route.caseId === null ? "scenarios" : route.view === "transcript" ? "run" : "inspect",
|
||||
);
|
||||
const [segment, setSegment] = React.useState<Segment>(() => defaultSegment(route));
|
||||
const [replayPosition, setReplayPosition] = React.useState(0);
|
||||
const [highlight, setHighlight] = React.useState<number | null>(null);
|
||||
const [runFocusRequest, setRunFocusRequest] = React.useState(0);
|
||||
const [chatArtifact, setChatArtifact] = React.useState<Phase7ChatSessionArtifact | null>(null);
|
||||
const [chatState, setChatState] = React.useState<Phase7ChatState>("seeding");
|
||||
const [stripFocusRequest, setStripFocusRequest] = React.useState({ turn: 0, nonce: 0 });
|
||||
const shellRef = React.useRef<HTMLDivElement>(null);
|
||||
const runHeaderRef = React.useRef<HTMLElement>(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<HTMLButtonElement>(
|
||||
`[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<Phase7Filters>): 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 (
|
||||
<div
|
||||
ref={shellRef}
|
||||
className="pcr-root pcr7-shell"
|
||||
data-run-state={settled}
|
||||
data-surface={route.surface}
|
||||
data-chat-state={chat && selected !== null ? chatState : undefined}
|
||||
data-segment={segment}
|
||||
data-testid="explorer-shell"
|
||||
aria-keyshortcuts="F6 Control+."
|
||||
>
|
||||
<a className="pcr7-skip-link" href="#run-view">
|
||||
Skip to run view
|
||||
Skip to {chat ? "the chat" : "run view"}
|
||||
</a>
|
||||
|
||||
<nav className="pcr7-surface-nav" aria-label="Surfaces" data-testid="surface-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-chip"
|
||||
aria-pressed={!chat}
|
||||
data-testid="surface-explorer"
|
||||
onClick={() => navigate({ ...route, surface: "explorer" })}
|
||||
>
|
||||
Scenario explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-chip"
|
||||
aria-pressed={chat}
|
||||
data-testid="surface-chat"
|
||||
onClick={() => navigate({ ...route, surface: "chat" })}
|
||||
>
|
||||
Scenario chat
|
||||
</button>
|
||||
</nav>
|
||||
<p className="pcr7-visually-hidden">
|
||||
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({
|
||||
<div
|
||||
className="pcr7-segmented pcr7-segmented--mobile"
|
||||
role="radiogroup"
|
||||
aria-label="Explorer sections"
|
||||
aria-label={chat ? "Chat sections" : "Explorer sections"}
|
||||
>
|
||||
{(["scenarios", "run", "inspect"] as const).map((value) => (
|
||||
{(chat ? CHAT_SEGMENTS : EXPLORER_SEGMENTS).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={segment === value}
|
||||
data-testid={`segment-${value}`}
|
||||
onClick={() => setSegment(value)}
|
||||
onClick={() => {
|
||||
setSegment(value);
|
||||
if (chat && (value === "chat" || value === "activity")) setChatView(value);
|
||||
}}
|
||||
>
|
||||
{value === "scenarios" ? "Scenarios" : value === "run" ? "Run" : "Inspect"}
|
||||
{SEGMENT_LABEL[value]}
|
||||
{chat && value === "activity" && denials > 0 ? (
|
||||
<span className="pcr7-segment-chip" data-testid="segment-activity-denials">
|
||||
{" "}
|
||||
· {denials} deny
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -266,7 +358,12 @@ export function ExplorerApp({
|
||||
onCommitSelection={commitCase}
|
||||
/>
|
||||
|
||||
<main className="pcr7-center" id="run-view" aria-label="Scenario run" tabIndex={-1}>
|
||||
<main
|
||||
className="pcr7-center"
|
||||
id="run-view"
|
||||
aria-label={chat ? "Scenario chat" : "Scenario run"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{route.caseId !== null && selected === null ? (
|
||||
<UnknownScenarioCard
|
||||
caseId={route.caseId}
|
||||
@@ -276,7 +373,22 @@ export function ExplorerApp({
|
||||
}}
|
||||
/>
|
||||
) : selected === null ? (
|
||||
<IntroCard index={index} onSelect={selectCase} />
|
||||
<IntroCard index={index} chat={chat} onSelect={selectCase} />
|
||||
) : chat ? (
|
||||
<ChatSurface
|
||||
entry={selected}
|
||||
autoReplay={route.replay === "fake"}
|
||||
stageStreaming={route.stage === "streaming"}
|
||||
codexAvailable={codexAvailable}
|
||||
activeTurn={route.turn}
|
||||
onOpenTurn={openTurn}
|
||||
onOpenParity={() => openTurn(chatArtifact?.turns.at(-1)?.turn ?? 0)}
|
||||
onArtifactChange={setChatArtifact}
|
||||
onStateChange={setChatState}
|
||||
onCancelScenarioSwitch={(entryId) => navigate({ ...route, caseId: entryId })}
|
||||
openSession={openSession}
|
||||
headerRef={runHeaderRef}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RunHeader
|
||||
@@ -329,8 +441,28 @@ export function ExplorerApp({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="pcr7-inspector" aria-label="Scenario inspector" tabIndex={-1}>
|
||||
{selected === null ? (
|
||||
<aside
|
||||
className="pcr7-inspector"
|
||||
aria-label={chat ? "Turn activity" : "Scenario inspector"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{chat ? (
|
||||
selected === null || chatArtifact === null ? (
|
||||
<ActivityEmptyState />
|
||||
) : (
|
||||
<ActivityStream
|
||||
artifact={chatArtifact}
|
||||
filter={route.activityFilter}
|
||||
onFilterChange={(filter: Phase7ActivityFilter) =>
|
||||
navigate({ ...route, activityFilter: filter })
|
||||
}
|
||||
openTurn={route.turn}
|
||||
onOpenTurn={openTurn}
|
||||
onBackToChat={backToChat}
|
||||
showBackToChat={segment === "activity"}
|
||||
/>
|
||||
)
|
||||
) : selected === null ? (
|
||||
<CorpusSummary index={index} />
|
||||
) : (
|
||||
<InspectorTabs
|
||||
@@ -406,9 +538,11 @@ function UnknownScenarioCard({ caseId, onBack }: { caseId: string; onBack: () =>
|
||||
|
||||
function IntroCard({
|
||||
index,
|
||||
chat,
|
||||
onSelect,
|
||||
}: {
|
||||
index: Phase7ScenarioIndex;
|
||||
chat: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const examples = ["hb-scoped-wake-01", "ap-mcp-gate-01", "rs-question-only-01"].filter((id) =>
|
||||
@@ -416,14 +550,20 @@ function IntroCard({
|
||||
);
|
||||
return (
|
||||
<section className="pcr7-intro" data-testid="explorer-intro">
|
||||
<p className="pcr-eyebrow">Phase 7 scenario explorer — mock control plane</p>
|
||||
<p className="pcr-eyebrow">
|
||||
Phase 7 scenario {chat ? "chat" : "explorer"} — mock control plane
|
||||
</p>
|
||||
<h1>{index.entries.length} scenarios across {index.groups.length} eval groups</h1>
|
||||
<p>
|
||||
Every scenario runs against the deterministic mock control plane in this page. The explorer
|
||||
renders records the runtime produced: it holds no credential, decides no policy, and owns no
|
||||
control-plane state.
|
||||
Every scenario runs against the deterministic mock control plane in this page. The
|
||||
{chat ? " chat" : " explorer"} renders records the runtime produced: it holds no credential,
|
||||
decides no policy, and owns no control-plane state.
|
||||
</p>
|
||||
<p className="pcr7-label">
|
||||
{chat
|
||||
? "Pick a scenario to start chatting, or start here"
|
||||
: "Pick a scenario from the rail, or start here"}
|
||||
</p>
|
||||
<p className="pcr7-label">Pick a scenario from the rail, or start here</p>
|
||||
<ul className="pcr7-exposure" data-testid="intro-examples">
|
||||
{examples.map((id) => (
|
||||
<li key={id}>
|
||||
@@ -479,6 +619,17 @@ function CorpusSummary({ index }: { index: Phase7ScenarioIndex }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which region a route should land on when the viewport only shows one. A deep
|
||||
* link that says "activity" has to move the segment too, or the route claims
|
||||
* one thing while the screen shows another.
|
||||
*/
|
||||
function defaultSegment(route: Phase7Route): Segment {
|
||||
if (route.caseId === null) return "scenarios";
|
||||
if (route.surface === "chat") return route.chatView === "activity" ? "activity" : "chat";
|
||||
return route.view === "transcript" ? "run" : "inspect";
|
||||
}
|
||||
|
||||
function findEntry(index: Phase7ScenarioIndex, id: string): Phase7ScenarioIndexEntry | null {
|
||||
return index.entries.find((entry) => entry.id === id) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildPhase7ScenarioIndex,
|
||||
phase7ReplayChatSession,
|
||||
type Phase7ChatSessionArtifact,
|
||||
type Phase7ScenarioIndex,
|
||||
type Phase7ScenarioIndexEntry,
|
||||
} from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { ActivityStream } from "./components/chat-activity-stream.js";
|
||||
import { ChatConversation } from "./components/chat-conversation.js";
|
||||
import { SessionHeader } from "./components/chat-session-header.js";
|
||||
import { TurnStrip, summaryText } from "./components/chat-turn-strip.js";
|
||||
import { ExplorerApp } from "./app.js";
|
||||
import { parsePhase7Route, serializePhase7Route } from "./route.js";
|
||||
|
||||
/**
|
||||
* Phase 7I chat rendering contract.
|
||||
*
|
||||
* The chat is a read surface: these check that every record the runtime
|
||||
* produced reaches the screen with its actor attached, that the turn grouping
|
||||
* matches the artifact rather than a UI guess, and that a denial is impossible
|
||||
* to miss. Session behaviour itself is covered by the runtime tests and the
|
||||
* browser spec.
|
||||
*/
|
||||
|
||||
const MANIFEST_PATH = resolve(import.meta.dirname, "../../../spec/phase-07/eval-traceability.yaml");
|
||||
|
||||
let index: Phase7ScenarioIndex;
|
||||
const sessions = new Map<string, Phase7ChatSessionArtifact>();
|
||||
|
||||
beforeAll(async () => {
|
||||
index = buildPhase7ScenarioIndex(await readFile(MANIFEST_PATH, "utf8"));
|
||||
for (const id of ["ix-checkbox-01", "ap-mcp-gate-01"]) {
|
||||
sessions.set(id, await phase7ReplayChatSession(entryFor(id)));
|
||||
}
|
||||
});
|
||||
|
||||
function entryFor(id: string): Phase7ScenarioIndexEntry {
|
||||
const entry = index.entries.find((candidate) => candidate.id === id);
|
||||
if (entry === undefined) throw new Error(`scenario ${id} is missing`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function sessionFor(id: string): Phase7ChatSessionArtifact {
|
||||
const artifact = sessions.get(id);
|
||||
if (artifact === undefined) throw new Error(`session ${id} is missing`);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function conversation(id: string, revealed = Number.MAX_SAFE_INTEGER): string {
|
||||
return renderToStaticMarkup(
|
||||
<ChatConversation
|
||||
artifact={sessionFor(id)}
|
||||
revealedSequence={revealed}
|
||||
runningTurn={null}
|
||||
activeTurn={null}
|
||||
onOpenTurn={() => undefined}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function activity(id: string, filter: Parameters<typeof ActivityStream>[0]["filter"] = "all", openTurn: number | null = null): string {
|
||||
return renderToStaticMarkup(
|
||||
<ActivityStream
|
||||
artifact={sessionFor(id)}
|
||||
filter={filter}
|
||||
onFilterChange={() => undefined}
|
||||
openTurn={openTurn}
|
||||
onOpenTurn={() => undefined}
|
||||
onBackToChat={() => undefined}
|
||||
showBackToChat={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("chat conversation", () => {
|
||||
it("gives every turn a strip and every entry its actor", () => {
|
||||
const artifact = sessionFor("ix-checkbox-01");
|
||||
const markup = conversation("ix-checkbox-01");
|
||||
for (const turn of artifact.turns) {
|
||||
expect(markup).toContain(`data-testid="chat-turn-${turn.turn}"`);
|
||||
expect(markup).toContain(`data-testid="turn-activity-strip-${turn.turn}"`);
|
||||
}
|
||||
for (const entry of artifact.timeline) {
|
||||
expect(markup, `entry ${entry.sequence} lost its channel`).toContain(
|
||||
`data-testid="chat-entry-${entry.sequence}"`,
|
||||
);
|
||||
}
|
||||
// Four actors, four grammars — none of them optional.
|
||||
expect(markup).toContain('data-channel="user"');
|
||||
expect(markup).toContain('data-channel="agent"');
|
||||
expect(markup).toContain('data-channel="control_plane"');
|
||||
expect(markup).toContain('data-kind="user_message"');
|
||||
});
|
||||
|
||||
it("labels the seed turn as control-plane work that preceded the first message", () => {
|
||||
const markup = conversation("ix-checkbox-01");
|
||||
expect(markup).toContain('data-testid="chat-seed-label"');
|
||||
expect(markup).toContain("before the first message");
|
||||
});
|
||||
|
||||
it("renders a denial in place with its reason and no protected state", () => {
|
||||
const markup = conversation("ap-mcp-gate-01");
|
||||
expect(markup).toContain('data-outcome="denied"');
|
||||
expect(markup).toContain("required_claim_missing");
|
||||
expect(markup).toContain("not returned — the denial carries no task data");
|
||||
expect(markup).toContain('data-testid="denial-announcement"');
|
||||
});
|
||||
|
||||
it("hides entries the reveal has not reached and marks the turn pending", () => {
|
||||
const artifact = sessionFor("ap-mcp-gate-01");
|
||||
const lastTurn = artifact.turns.at(-1)!;
|
||||
const markup = conversation("ap-mcp-gate-01", lastTurn.firstSequence);
|
||||
expect(markup).toContain(`data-testid="turn-pending-${lastTurn.turn}"`);
|
||||
expect(markup).not.toContain(`data-testid="turn-activity-strip-${lastTurn.turn}"`);
|
||||
expect(markup).not.toContain(`data-testid="chat-entry-${lastTurn.lastSequence}"`);
|
||||
// Earlier turns are untouched by the reveal of a later one.
|
||||
expect(markup).toContain('data-testid="turn-activity-strip-1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn strip", () => {
|
||||
it("states the counts as icon plus text and reads them into the label", () => {
|
||||
const turn = sessionFor("ap-mcp-gate-01").turns.at(-1)!;
|
||||
const markup = renderToStaticMarkup(<TurnStrip turn={turn} onOpen={() => undefined} />);
|
||||
expect(markup).toContain(`${turn.counts.calls} tool`);
|
||||
expect(markup).toContain(`${turn.counts.denied} denied`);
|
||||
expect(markup).toContain("control plane");
|
||||
expect(markup).toContain(summaryText(turn));
|
||||
expect(markup).toContain(`data-testid="turn-parity-${turn.turn}"`);
|
||||
});
|
||||
|
||||
it("pluralises counts so a strip never reads '1 tools'", () => {
|
||||
for (const artifact of sessions.values()) {
|
||||
for (const turn of artifact.turns) {
|
||||
expect(summaryText(turn)).not.toMatch(/\b1 (tools|domains)\b/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity stream", () => {
|
||||
it("groups by turn and orders the sections as the map specifies", () => {
|
||||
const artifact = sessionFor("ap-mcp-gate-01");
|
||||
const markup = activity("ap-mcp-gate-01", "all", 2);
|
||||
for (const turn of artifact.turns) {
|
||||
expect(markup).toContain(`data-testid="activity-turn-${turn.turn}"`);
|
||||
}
|
||||
const order = ["exposure", "calls", "authorization", "control", "diff", "parity"]
|
||||
.map((name) => markup.indexOf(`data-testid="activity-section-2-${name}"`))
|
||||
.filter((position) => position >= 0);
|
||||
expect(order.length).toBeGreaterThan(3);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
});
|
||||
|
||||
it("scopes authorization and diff records to the turn that produced them", () => {
|
||||
const artifact = sessionFor("ap-mcp-gate-01");
|
||||
const denied = artifact.turns.find((turn) => turn.counts.denied > 0)!;
|
||||
const markup = activity("ap-mcp-gate-01", "all", denied.turn);
|
||||
expect(markup).toContain(`data-testid="activity-section-${denied.turn}-authorization"`);
|
||||
expect(markup).toContain("required_claim_missing");
|
||||
expect(markup).toContain(`data-testid="activity-diff-${denied.turn}"`);
|
||||
expect(markup).toContain(`data-testid="activity-parity-${denied.turn}"`);
|
||||
});
|
||||
|
||||
it("shows the wake the control plane scheduled as a reconciliation row", () => {
|
||||
const artifact = sessionFor("ix-checkbox-01");
|
||||
const withWake = artifact.turns.find((turn) => turn.reconciliationEvents.length > 0)!;
|
||||
const markup = activity("ix-checkbox-01", "all", withWake.turn);
|
||||
expect(markup).toContain(`data-testid="activity-reconciliation-${withWake.turn}"`);
|
||||
expect(markup).toContain("wake.scheduled");
|
||||
});
|
||||
|
||||
it("collapses non-matching sections without deleting turn groups", () => {
|
||||
const artifact = sessionFor("ap-mcp-gate-01");
|
||||
const markup = activity("ap-mcp-gate-01", "authorization", 2);
|
||||
for (const turn of artifact.turns) {
|
||||
expect(markup).toContain(`data-testid="activity-turn-${turn.turn}"`);
|
||||
}
|
||||
expect(markup).toContain('data-testid="activity-section-2-authorization"');
|
||||
expect(markup).not.toContain('data-testid="activity-section-2-diff"');
|
||||
expect(markup).not.toContain('data-testid="activity-section-2-calls"');
|
||||
});
|
||||
|
||||
it("keeps a session group with the cumulative diff and the session verdict", () => {
|
||||
const markup = activity("ix-checkbox-01");
|
||||
expect(markup).toContain('data-testid="activity-session"');
|
||||
expect(markup).toContain('data-testid="activity-session-diff"');
|
||||
expect(markup).toContain('data-testid="activity-session-verdict"');
|
||||
});
|
||||
|
||||
it("expands only the newest turn when the route names none", () => {
|
||||
const artifact = sessionFor("ix-checkbox-01");
|
||||
const markup = activity("ix-checkbox-01");
|
||||
const newest = artifact.turns.at(-1)!.turn;
|
||||
expect(markup).toContain(`data-testid="activity-turn-${newest}" data-open="true"`);
|
||||
expect(markup).toContain('data-testid="activity-turn-0" data-open="false"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("session header", () => {
|
||||
it("names the driver, the status, and the parity so far", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionHeader
|
||||
entry={entryFor("ap-mcp-gate-01")}
|
||||
artifact={sessionFor("ap-mcp-gate-01")}
|
||||
mode="scripted"
|
||||
codexAvailable={false}
|
||||
busy={false}
|
||||
onModeChange={() => undefined}
|
||||
onReset={() => undefined}
|
||||
onReplay={() => undefined}
|
||||
onOpenParity={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain('data-testid="session-status"');
|
||||
expect(markup).toContain("Scripted · deterministic");
|
||||
expect(markup).toContain('data-testid="reset-session"');
|
||||
expect(markup).toContain('data-testid="replay-session"');
|
||||
expect(markup).toContain('data-testid="session-parity-chip"');
|
||||
// Codex is disabled with a named reason, never silently inert.
|
||||
expect(markup).toContain('data-testid="chat-codex-unavailable"');
|
||||
expect(markup).toContain("provider relay not running");
|
||||
expect(markup).toContain("no provider credential ever reaches this page");
|
||||
});
|
||||
|
||||
it("reports a seeded session as idle rather than as a finished one", () => {
|
||||
const artifact = sessionFor("ix-checkbox-01");
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionHeader
|
||||
entry={entryFor("ix-checkbox-01")}
|
||||
artifact={{ ...artifact, turns: artifact.turns.slice(0, 1) }}
|
||||
mode="scripted"
|
||||
codexAvailable={false}
|
||||
busy={false}
|
||||
onModeChange={() => undefined}
|
||||
onReset={() => undefined}
|
||||
onReplay={() => undefined}
|
||||
onOpenParity={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain("Idle · seeded");
|
||||
expect(markup).not.toContain('data-testid="session-parity-chip"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat routes and shell", () => {
|
||||
it("round-trips every deep link in the acceptance matrix", () => {
|
||||
for (const hash of [
|
||||
"#/chat",
|
||||
"#/chat/ix-checkbox-01",
|
||||
"#/chat/ix-checkbox-01?replay=fake",
|
||||
"#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=1",
|
||||
"#/chat/ix-checkbox-01?replay=fake&view=activity&turn=2&filter=parity",
|
||||
"#/chat/ap-mcp-gate-01?replay=fake&stage=streaming",
|
||||
]) {
|
||||
expect(serializePhase7Route(parsePhase7Route(hash))).toBe(hash);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the explorer routes untouched", () => {
|
||||
const route = parsePhase7Route("#/case/ap-mcp-gate-01?run=fake&view=authorization");
|
||||
expect(route.surface).toBe("explorer");
|
||||
expect(route.caseId).toBe("ap-mcp-gate-01");
|
||||
expect(serializePhase7Route(route)).toBe("#/case/ap-mcp-gate-01?run=fake&view=authorization");
|
||||
});
|
||||
|
||||
it("renders three landmarks and the chat segments on a chat route", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ExplorerApp index={index} initialRoute={parsePhase7Route("#/chat/ix-checkbox-01")} />,
|
||||
);
|
||||
expect(markup).toContain('aria-label="Scenario chat"');
|
||||
expect(markup).toContain('aria-label="Turn activity"');
|
||||
expect(markup).toContain('aria-label="Chat sections"');
|
||||
expect(markup).toContain('data-testid="segment-chat"');
|
||||
expect(markup).toContain('data-testid="segment-activity"');
|
||||
expect(markup).toContain('data-testid="surface-nav"');
|
||||
// The explorer's own segments must not leak onto the chat surface.
|
||||
expect(markup).not.toContain('data-testid="segment-inspect"');
|
||||
});
|
||||
|
||||
it("names an unknown chat scenario instead of falling back to home", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ExplorerApp index={index} initialRoute={parsePhase7Route("#/chat/not-a-real-case")} />,
|
||||
);
|
||||
expect(markup).toContain('data-testid="unknown-scenario"');
|
||||
expect(markup).toContain("not-a-real-case");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@paperclipai/paperclip-runner/react";
|
||||
import type {
|
||||
Phase7ChatSessionArtifact,
|
||||
Phase7ChatTurn,
|
||||
Phase7TimelineEntry,
|
||||
} from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { CHANGE_ICON, CHANGE_LABEL } from "../labels.js";
|
||||
import {
|
||||
AuthorizationPanel,
|
||||
ExposureList,
|
||||
ParityPanel,
|
||||
} from "./inspector-panels.js";
|
||||
import { EmptyState, Mono } from "./primitives.js";
|
||||
import { TimelineEntryBody } from "./scenario-transcript.js";
|
||||
import { summaryText } from "./chat-turn-strip.js";
|
||||
import {
|
||||
PHASE7_ACTIVITY_FILTERS,
|
||||
type Phase7ActivityFilter,
|
||||
} from "../route.js";
|
||||
|
||||
/**
|
||||
* Activity stream (7I map §4).
|
||||
*
|
||||
* The per-turn evidence, grouped by turn and ordered exactly as the map
|
||||
* specifies: exposure, calls, authorization, control plane, state diff, parity.
|
||||
* Mobile gets all of it — a summary-only small screen would drop the evidence
|
||||
* this phase exists to show.
|
||||
*
|
||||
* A filter collapses non-matching sections rather than deleting groups, so the
|
||||
* turn skeleton stays scannable and the route stays honest about what is on
|
||||
* screen.
|
||||
*/
|
||||
|
||||
const FILTER_LABEL: Record<Phase7ActivityFilter, string> = {
|
||||
all: "All",
|
||||
tools: "Tools",
|
||||
authorization: "Authorization",
|
||||
control: "Control plane",
|
||||
diff: "Diffs",
|
||||
parity: "Parity",
|
||||
};
|
||||
|
||||
type SectionName = "exposure" | "calls" | "authorization" | "control" | "diff" | "parity";
|
||||
|
||||
const SECTION_FILTER: Record<SectionName, Phase7ActivityFilter> = {
|
||||
exposure: "tools",
|
||||
calls: "tools",
|
||||
authorization: "authorization",
|
||||
control: "control",
|
||||
diff: "diff",
|
||||
parity: "parity",
|
||||
};
|
||||
|
||||
export function ActivityStream({
|
||||
artifact,
|
||||
filter,
|
||||
onFilterChange,
|
||||
openTurn,
|
||||
onOpenTurn,
|
||||
onBackToChat,
|
||||
showBackToChat,
|
||||
}: {
|
||||
artifact: Phase7ChatSessionArtifact;
|
||||
filter: Phase7ActivityFilter;
|
||||
onFilterChange: (filter: Phase7ActivityFilter) => void;
|
||||
openTurn: number | null;
|
||||
onOpenTurn: (turn: number) => void;
|
||||
onBackToChat: (turn: number) => void;
|
||||
showBackToChat: boolean;
|
||||
}) {
|
||||
const newest = artifact.turns[artifact.turns.length - 1]?.turn ?? 0;
|
||||
return (
|
||||
<div className="pcr7-activity" data-testid="activity-stream">
|
||||
<div className="pcr7-activity-filters" role="group" aria-label="Activity filter">
|
||||
{PHASE7_ACTIVITY_FILTERS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="pcr7-chip"
|
||||
aria-pressed={filter === value}
|
||||
data-testid={`activity-filter-${value}`}
|
||||
onClick={() => onFilterChange(value)}
|
||||
>
|
||||
{FILTER_LABEL[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{artifact.turns.map((turn) => (
|
||||
<ActivityTurnGroup
|
||||
key={turn.turn}
|
||||
turn={turn}
|
||||
entries={artifact.timeline.filter((entry) => entry.turn === turn.turn)}
|
||||
filter={filter}
|
||||
open={openTurn === null ? turn.turn === newest : openTurn === turn.turn}
|
||||
onToggle={() => onOpenTurn(turn.turn)}
|
||||
onBackToChat={onBackToChat}
|
||||
showBackToChat={showBackToChat}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SessionGroup artifact={artifact} filter={filter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityTurnGroup({
|
||||
turn,
|
||||
entries,
|
||||
filter,
|
||||
open,
|
||||
onToggle,
|
||||
onBackToChat,
|
||||
showBackToChat,
|
||||
}: {
|
||||
turn: Phase7ChatTurn;
|
||||
entries: readonly Phase7TimelineEntry[];
|
||||
filter: Phase7ActivityFilter;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onBackToChat: (turn: number) => void;
|
||||
showBackToChat: boolean;
|
||||
}) {
|
||||
const calls = entries.filter(
|
||||
(entry) => entry.kind === "semantic_call" || entry.kind === "semantic_result",
|
||||
);
|
||||
const controlPlane = entries.filter((entry) => entry.kind === "control_plane_action");
|
||||
const visible = (section: SectionName): boolean =>
|
||||
filter === "all" || SECTION_FILTER[section] === filter;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="pcr7-activity-group"
|
||||
data-testid={`activity-turn-${turn.turn}`}
|
||||
data-open={open}
|
||||
id={`activity-turn-${turn.turn}`}
|
||||
>
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-activity-group-header"
|
||||
aria-expanded={open}
|
||||
aria-controls={`activity-turn-body-${turn.turn}`}
|
||||
data-testid={`activity-turn-header-${turn.turn}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span>{turn.turn === 0 ? "Session seed" : `Turn ${turn.turn}`}</span>
|
||||
<span className="pcr7-muted">{summaryText(turn)}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{open ? (
|
||||
<div className="pcr7-activity-group-body" id={`activity-turn-body-${turn.turn}`}>
|
||||
{turn.prompt === null ? null : (
|
||||
<p className="pcr7-activity-prompt">
|
||||
<span className="pcr7-label pcr7-block">Prompt</span>
|
||||
{turn.prompt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visible("exposure") ? (
|
||||
<Section name="exposure" turn={turn.turn} title="Exposed tools">
|
||||
<p className="pcr7-muted">
|
||||
Always {turn.exposure.always.length} · Optional {turn.exposure.optional.length} ·
|
||||
Control plane — no tool {turn.exposure.controlPlane.length}
|
||||
</p>
|
||||
<details className="pcr7-facet">
|
||||
<summary className="pcr7-facet-summary">Full exposure decision</summary>
|
||||
<ExposureList exposure={turn.exposure} />
|
||||
</details>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{visible("calls") && calls.length > 0 ? (
|
||||
<Section name="calls" turn={turn.turn} title={`Calls and results (${calls.length})`}>
|
||||
<ol className="pcr7-activity-calls">
|
||||
{calls.map((entry) => (
|
||||
<li key={entry.sequence} data-testid={`activity-call-${entry.sequence}`}>
|
||||
<TimelineEntryBody entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{visible("authorization") && turn.authorizationRecords.length > 0 ? (
|
||||
<Section
|
||||
name="authorization"
|
||||
turn={turn.turn}
|
||||
title={`Authorization (${turn.authorizationRecords.length})`}
|
||||
>
|
||||
<AuthorizationPanel records={turn.authorizationRecords} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{visible("control") && controlPlane.length > 0 ? (
|
||||
<Section
|
||||
name="control"
|
||||
turn={turn.turn}
|
||||
title={`Control plane (${controlPlane.length})`}
|
||||
>
|
||||
<ol className="pcr7-activity-calls">
|
||||
{controlPlane.map((entry) => (
|
||||
<li key={entry.sequence} data-testid={`activity-control-${entry.sequence}`}>
|
||||
<TimelineEntryBody entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{visible("diff") ? (
|
||||
<Section name="diff" turn={turn.turn} title="State diff">
|
||||
<TurnDiff turn={turn} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{visible("parity") ? (
|
||||
<Section name="parity" turn={turn.turn} title="Parity">
|
||||
<span className="pcr7-visually-hidden" data-testid={`activity-parity-${turn.turn}`}>
|
||||
Turn {turn.turn} parity {turn.parity.verdict.replace("_", " ")}
|
||||
</span>
|
||||
<ParityPanel parity={turn.parity} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{showBackToChat ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-linkish"
|
||||
data-testid={`activity-back-to-chat-${turn.turn}`}
|
||||
onClick={() => onBackToChat(turn.turn)}
|
||||
>
|
||||
Back to chat
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
name,
|
||||
turn,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
name: SectionName;
|
||||
turn: number;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="pcr7-activity-section"
|
||||
data-testid={`activity-section-${turn}-${name}`}
|
||||
aria-label={`${title} — turn ${turn}`}
|
||||
>
|
||||
<h4 className="pcr7-label">{title}</h4>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-turn diff. Unchanged domains collapse to one muted row on purpose:
|
||||
* "nothing else moved" is evidence, and printing ten empty domains would bury
|
||||
* the one that did.
|
||||
*/
|
||||
function TurnDiff({ turn }: { turn: Phase7ChatTurn }) {
|
||||
const changed = turn.diff.domains.filter((domain) => domain.changed);
|
||||
const unchanged = turn.diff.domains.filter((domain) => !domain.changed);
|
||||
return (
|
||||
<div data-testid={`activity-diff-${turn.turn}`}>
|
||||
<p className="pcr7-muted">
|
||||
Revision {turn.diff.beforeRevision} → {turn.diff.afterRevision}
|
||||
</p>
|
||||
{changed.length === 0 ? (
|
||||
<p className="pcr7-muted" data-testid={`activity-diff-unchanged-${turn.turn}`}>
|
||||
No control-plane state changed in this turn.
|
||||
</p>
|
||||
) : (
|
||||
changed.map((domain) => (
|
||||
<section key={domain.domain} className="pcr7-diff-domain">
|
||||
<h5 className="pcr7-label">
|
||||
{domain.domain} — {domain.summary}
|
||||
</h5>
|
||||
<ul className="pcr7-exposure">
|
||||
{domain.rows.map((row) => (
|
||||
<li key={`${domain.domain}-${row.entityId}`}>
|
||||
<span aria-hidden="true">{CHANGE_ICON[row.change]}</span>{" "}
|
||||
{CHANGE_LABEL[row.change]} <Mono>{row.entityId}</Mono>
|
||||
<ul className="pcr7-exposure">
|
||||
{row.fields.map((field) => (
|
||||
<li key={field.field}>
|
||||
<Mono>{field.field}</Mono>: <Mono>{field.before ?? "—"}</Mono> →{" "}
|
||||
<Mono>{field.after ?? "—"}</Mono>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
{unchanged.length === 0 ? null : (
|
||||
<p className="pcr7-muted" data-testid={`activity-diff-unchanged-list-${turn.turn}`}>
|
||||
Unchanged: {unchanged.map((domain) => domain.domain).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{turn.reconciliationEvents.length === 0 ? null : (
|
||||
<ul className="pcr7-exposure" data-testid={`activity-reconciliation-${turn.turn}`}>
|
||||
{turn.reconciliationEvents.map((event) => (
|
||||
<li key={event.sequence}>
|
||||
<Badge tone="neutral">Control plane</Badge> <Mono>{event.action}</Mono> —{" "}
|
||||
{event.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session footer group: what the whole conversation changed, the session
|
||||
* verdict, and — when the correct behaviour was absence — the restraint note,
|
||||
* so "nothing happened" still reads as a deliberate result.
|
||||
*/
|
||||
function SessionGroup({
|
||||
artifact,
|
||||
filter,
|
||||
}: {
|
||||
artifact: Phase7ChatSessionArtifact;
|
||||
filter: Phase7ActivityFilter;
|
||||
}) {
|
||||
const restraint = artifact.timeline.find((entry) => entry.kind === "system_note");
|
||||
const changed = artifact.diff.domains.filter((domain) => domain.changed);
|
||||
return (
|
||||
<section className="pcr7-activity-group" data-testid="activity-session" data-open="true">
|
||||
<h3 className="pcr7-activity-group-title">Session</h3>
|
||||
<div className="pcr7-activity-group-body">
|
||||
{filter === "all" || filter === "diff" ? (
|
||||
<section className="pcr7-activity-section" data-testid="activity-session-diff">
|
||||
<h4 className="pcr7-label">Cumulative diff against the seed</h4>
|
||||
{changed.length === 0 ? (
|
||||
<p className="pcr7-muted">No control-plane state changed in this session.</p>
|
||||
) : (
|
||||
<ul className="pcr7-exposure">
|
||||
{changed.map((domain) => (
|
||||
<li key={domain.domain}>
|
||||
<Mono>{domain.domain}</Mono> — {domain.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{filter === "all" || filter === "parity" ? (
|
||||
<section className="pcr7-activity-section" data-testid="activity-session-parity">
|
||||
<h4 className="pcr7-label">Session parity</h4>
|
||||
<span className="pcr7-visually-hidden" data-testid="activity-session-verdict">
|
||||
Session parity {artifact.parity.verdict.replace("_", " ")}
|
||||
</span>
|
||||
<ParityPanel parity={artifact.parity} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{restraint?.kind === "system_note" ? (
|
||||
<p className="pcr7-system-note" data-testid="activity-restraint-note">
|
||||
{restraint.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Denials the board has not opened yet, surfaced on the mobile segment chip. */
|
||||
export function activityDenialCount(artifact: Phase7ChatSessionArtifact): number {
|
||||
return artifact.turns.reduce((total, turn) => total + turn.counts.denied, 0);
|
||||
}
|
||||
|
||||
export function ActivityEmptyState() {
|
||||
return (
|
||||
<EmptyState title="Pick a scenario to start a session.">
|
||||
<p className="pcr7-muted">
|
||||
Every turn's exposure, calls, authorization decisions, control-plane actions, state diff, and
|
||||
parity verdict appear here as the conversation happens.
|
||||
</p>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
Phase7ChatSessionArtifact,
|
||||
Phase7ChatTurn,
|
||||
Phase7TimelineEntry,
|
||||
} from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { TimelineEntryBody } from "./scenario-transcript.js";
|
||||
import { TurnStrip } from "./chat-turn-strip.js";
|
||||
|
||||
/**
|
||||
* The chat conversation (7I map §3).
|
||||
*
|
||||
* Four actors, four grammars: the board's message, the model's reply, the
|
||||
* agent's tool work, and the control plane's own work. Attribution is never
|
||||
* optional — a fact with no visible actor would be exactly the confusion this
|
||||
* phase exists to remove — so every row carries its channel and the shared
|
||||
* transcript body renders the detail identically to the 7F explorer.
|
||||
*
|
||||
* The component reorders nothing. Entries arrive in artifact order and are
|
||||
* grouped by the `turn` the runtime stamped on them.
|
||||
*/
|
||||
|
||||
export function ChatConversation({
|
||||
artifact,
|
||||
revealedSequence,
|
||||
runningTurn,
|
||||
activeTurn,
|
||||
onOpenTurn,
|
||||
}: {
|
||||
artifact: Phase7ChatSessionArtifact;
|
||||
/** Entries above this sequence are still streaming in and stay hidden. */
|
||||
revealedSequence: number;
|
||||
runningTurn: number | null;
|
||||
activeTurn: number | null;
|
||||
onOpenTurn: (turn: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<ol className="pcr7-chat-conversation" data-testid="chat-conversation">
|
||||
{artifact.turns.map((turn) => (
|
||||
<TurnBlock
|
||||
key={turn.turn}
|
||||
turn={turn}
|
||||
entries={artifact.timeline.filter(
|
||||
(entry) => entry.turn === turn.turn && entry.sequence <= revealedSequence,
|
||||
)}
|
||||
settled={runningTurn !== turn.turn && turn.lastSequence <= revealedSequence}
|
||||
activeTurn={activeTurn}
|
||||
onOpenTurn={onOpenTurn}
|
||||
/>
|
||||
))}
|
||||
{runningTurn !== null &&
|
||||
!artifact.turns.some((turn) => turn.turn === runningTurn) ? (
|
||||
<li className="pcr7-chat-turn" data-testid={`chat-turn-${runningTurn}`}>
|
||||
<PendingTurn turn={runningTurn} />
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnBlock({
|
||||
turn,
|
||||
entries,
|
||||
settled,
|
||||
activeTurn,
|
||||
onOpenTurn,
|
||||
}: {
|
||||
turn: Phase7ChatTurn;
|
||||
entries: readonly Phase7TimelineEntry[];
|
||||
settled: boolean;
|
||||
activeTurn: number | null;
|
||||
onOpenTurn: (turn: number) => void;
|
||||
}) {
|
||||
if (entries.length === 0 && !settled) return null;
|
||||
return (
|
||||
<li className="pcr7-chat-turn" data-testid={`chat-turn-${turn.turn}`} data-turn={turn.turn}>
|
||||
{turn.turn === 0 ? (
|
||||
<p className="pcr7-chat-seed-label" data-testid="chat-seed-label">
|
||||
Session seed — the control plane did this before the first message.
|
||||
</p>
|
||||
) : null}
|
||||
<ol className="pcr7-chat-entries">
|
||||
{entries.map((entry) => (
|
||||
<li
|
||||
key={entry.sequence}
|
||||
id={`chat-entry-${entry.sequence}`}
|
||||
className="pcr7-chat-entry"
|
||||
data-channel={entry.channel}
|
||||
data-kind={entry.kind}
|
||||
data-testid={`chat-entry-${entry.sequence}`}
|
||||
>
|
||||
<TimelineEntryBody entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{turn.failure === null ? null : (
|
||||
<p className="pcr7-chat-failure" role="alert" data-testid={`chat-turn-failure-${turn.turn}`}>
|
||||
This turn stopped on a harness error: {turn.failure.message}. The activity below is
|
||||
partial; send the turn again or reset the session.
|
||||
</p>
|
||||
)}
|
||||
{settled ? (
|
||||
<TurnStrip turn={turn} onOpen={onOpenTurn} active={activeTurn === turn.turn} />
|
||||
) : (
|
||||
<PendingTurn turn={turn.turn} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingTurn({ turn }: { turn: number }) {
|
||||
return (
|
||||
<p className="pcr7-turn-pending" data-testid={`turn-pending-${turn}`}>
|
||||
<span className="pcr7-streaming-caret" aria-hidden="true" />
|
||||
Turn {turn} · running — the mock control plane is still recording this turn.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge, Button } from "@paperclipai/paperclip-runner/react";
|
||||
import type {
|
||||
Phase7ChatSessionArtifact,
|
||||
Phase7ScenarioIndexEntry,
|
||||
} from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { groupLabel } from "../labels.js";
|
||||
import { DispositionBadge, Mono, ParityStatusBadge } from "./primitives.js";
|
||||
|
||||
/**
|
||||
* Session header (7I map §2).
|
||||
*
|
||||
* Says what session this is, what is driving it, and how it has fared so far —
|
||||
* and offers the two session-scoped controls, reset and replay. Reset is
|
||||
* destructive and confirms; replay is not, because it reproduces an identical
|
||||
* timeline.
|
||||
*/
|
||||
|
||||
export type Phase7ChatMode = "scripted" | "codex";
|
||||
|
||||
export interface SessionHeaderProps {
|
||||
entry: Phase7ScenarioIndexEntry;
|
||||
artifact: Phase7ChatSessionArtifact | null;
|
||||
mode: Phase7ChatMode;
|
||||
codexAvailable: boolean;
|
||||
busy: boolean;
|
||||
onModeChange: (mode: Phase7ChatMode) => void;
|
||||
onReset: () => void;
|
||||
onReplay: () => void;
|
||||
onOpenParity: () => void;
|
||||
headerRef?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
export function SessionHeader({
|
||||
entry,
|
||||
artifact,
|
||||
mode,
|
||||
codexAvailable,
|
||||
busy,
|
||||
onModeChange,
|
||||
onReset,
|
||||
onReplay,
|
||||
onOpenParity,
|
||||
headerRef,
|
||||
}: SessionHeaderProps) {
|
||||
const status = sessionStatus(artifact, mode);
|
||||
const completed = artifact?.turns.filter((turn) => turn.turn > 0) ?? [];
|
||||
const failing = completed.find((turn) => turn.parity.verdict === "fail");
|
||||
// Controls that would corrupt a running turn are disabled with the reason
|
||||
// named, never silently inert.
|
||||
const busyReason = busy ? "A turn is running — wait for it to settle." : undefined;
|
||||
|
||||
return (
|
||||
<header ref={headerRef} className="pcr7-session-header" data-testid="session-header" tabIndex={-1}>
|
||||
<div className="pcr7-run-title">
|
||||
<Mono>{entry.id}</Mono>
|
||||
<h1>{entry.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="pcr7-badge-row">
|
||||
<Badge tone="neutral">
|
||||
{groupLabel(entry.group)} <Mono>{entry.group}</Mono>
|
||||
</Badge>
|
||||
<DispositionBadge disposition={entry.primaryDisposition} />
|
||||
<Badge tone={status.tone} data-testid="session-status">
|
||||
{status.label}
|
||||
</Badge>
|
||||
{completed.length === 0 ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-linkish"
|
||||
data-testid="session-parity-chip"
|
||||
onClick={onOpenParity}
|
||||
>
|
||||
<ParityStatusBadge
|
||||
status={failing === undefined ? "pass" : "fail"}
|
||||
testId="session-parity-status"
|
||||
/>{" "}
|
||||
{failing === undefined
|
||||
? `Parity · ${completed.length} turn${completed.length === 1 ? "" : "s"} pass`
|
||||
: `Parity · turn ${failing.turn} fail`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pcr7-run-controls">
|
||||
<div
|
||||
className="pcr7-segmented"
|
||||
role="radiogroup"
|
||||
aria-label="Session driver"
|
||||
data-testid="chat-mode-toggle"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === "scripted"}
|
||||
data-testid="chat-mode-scripted"
|
||||
onClick={() => onModeChange("scripted")}
|
||||
>
|
||||
Scripted (deterministic)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === "codex"}
|
||||
disabled={!codexAvailable}
|
||||
data-testid="chat-mode-codex"
|
||||
title={
|
||||
codexAvailable
|
||||
? "Drive the session through the local provider relay"
|
||||
: "provider relay not running — see tutorial §4"
|
||||
}
|
||||
onClick={() => onModeChange("codex")}
|
||||
>
|
||||
Codex (bounded)
|
||||
</button>
|
||||
</div>
|
||||
{codexAvailable ? null : (
|
||||
<p className="pcr7-muted" data-testid="chat-codex-unavailable">
|
||||
Codex mode needs the local provider relay — provider relay not running, see tutorial §4.
|
||||
Scripted mode drives the same mock control plane offline, and no provider credential
|
||||
ever reaches this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onReset}
|
||||
disabled={busy}
|
||||
title={busyReason}
|
||||
data-testid="reset-session"
|
||||
>
|
||||
Reset session
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onReplay}
|
||||
disabled={busy || (artifact?.remainingScriptedTurns ?? 0) === 0}
|
||||
title={
|
||||
busyReason ??
|
||||
((artifact?.remainingScriptedTurns ?? 0) === 0
|
||||
? "The scripted session has already played to the end."
|
||||
: undefined)
|
||||
}
|
||||
data-testid="replay-session"
|
||||
>
|
||||
Replay scripted session
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function sessionStatus(
|
||||
artifact: Phase7ChatSessionArtifact | null,
|
||||
mode: Phase7ChatMode,
|
||||
): { label: string; tone: "accent" | "neutral" | "danger" } {
|
||||
if (artifact === null) return { label: "Seeding…", tone: "neutral" };
|
||||
if (artifact.status === "failed") return { label: "Harness error", tone: "danger" };
|
||||
if (artifact.turns.length <= 1) return { label: "Idle · seeded", tone: "neutral" };
|
||||
return mode === "codex"
|
||||
? { label: "Live · Codex (bounded)", tone: "accent" }
|
||||
: { label: "Scripted · deterministic", tone: "neutral" };
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Composer, Dialog, Button } from "@paperclipai/paperclip-runner/react";
|
||||
import {
|
||||
Phase7ChatSession,
|
||||
type Phase7ChatSessionArtifact,
|
||||
type Phase7ScenarioIndexEntry,
|
||||
} from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { ChatConversation } from "./chat-conversation.js";
|
||||
import { SessionHeader, type Phase7ChatMode } from "./chat-session-header.js";
|
||||
import { EmptyState } from "./primitives.js";
|
||||
|
||||
/**
|
||||
* Phase 7I chat surface.
|
||||
*
|
||||
* Owns one live `Phase7ChatSession` and nothing else: no protocol state, no
|
||||
* policy decision, no credential. Prompts go to the package-local mock control
|
||||
* plane in this page, and the records that come back are rendered as they
|
||||
* arrived.
|
||||
*
|
||||
* Session identity is deliberately explicit. `activeEntry` is the scenario the
|
||||
* open session belongs to, which is not always the scenario the route names:
|
||||
* switching discards mutable mock state, so it asks first, and until the board
|
||||
* answers, the header keeps naming the session that is actually running.
|
||||
*/
|
||||
|
||||
export type Phase7ChatState = "seeding" | "pending" | "streaming" | "settled" | "failed";
|
||||
|
||||
export interface ChatSurfaceProps {
|
||||
entry: Phase7ScenarioIndexEntry;
|
||||
/** `replay=fake` plays the whole script on load for screenshot routes. */
|
||||
autoReplay: boolean;
|
||||
/** Holds the last scripted turn mid-stream for the streaming capture. */
|
||||
stageStreaming: boolean;
|
||||
codexAvailable?: boolean;
|
||||
activeTurn: number | null;
|
||||
onOpenTurn: (turn: number) => void;
|
||||
onOpenParity: () => void;
|
||||
onArtifactChange: (artifact: Phase7ChatSessionArtifact | null) => void;
|
||||
/**
|
||||
* Mirrors the settle state to the shell. The chat column is hidden on the
|
||||
* mobile Activity segment, so a wait target that only lives here would be
|
||||
* unreachable exactly where the evidence is.
|
||||
*/
|
||||
onStateChange: (state: Phase7ChatState) => void;
|
||||
/** Called when the board declines a scenario switch, to restore the route. */
|
||||
onCancelScenarioSwitch: (entryId: string) => void;
|
||||
openSession?: typeof Phase7ChatSession.open;
|
||||
headerRef?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
export function ChatSurface({
|
||||
entry,
|
||||
autoReplay,
|
||||
stageStreaming,
|
||||
codexAvailable = false,
|
||||
activeTurn,
|
||||
onOpenTurn,
|
||||
onOpenParity,
|
||||
onArtifactChange,
|
||||
onStateChange,
|
||||
onCancelScenarioSwitch,
|
||||
openSession = Phase7ChatSession.open,
|
||||
headerRef,
|
||||
}: ChatSurfaceProps) {
|
||||
const [artifact, setArtifact] = React.useState<Phase7ChatSessionArtifact | null>(null);
|
||||
const [activeEntry, setActiveEntry] = React.useState(entry);
|
||||
const [epoch, setEpoch] = React.useState(0);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [revealed, setRevealed] = React.useState(0);
|
||||
const [mode, setMode] = React.useState<Phase7ChatMode>("scripted");
|
||||
const [confirm, setConfirm] = React.useState<"none" | "reset" | "switch">("none");
|
||||
const sessionRef = React.useRef<Phase7ChatSession | null>(null);
|
||||
const composerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const switching = entry.id !== activeEntry.id;
|
||||
// A deep link plays its script once. An explicit reset means "back to the
|
||||
// seed", so it must not be immediately undone by the route replaying again.
|
||||
const shouldAutoReplay = autoReplay && epoch === 0;
|
||||
const instant = shouldAutoReplay && !stageStreaming;
|
||||
|
||||
const publish = React.useCallback(
|
||||
(next: Phase7ChatSessionArtifact | null) => {
|
||||
setArtifact(next);
|
||||
onArtifactChange(next);
|
||||
},
|
||||
[onArtifactChange],
|
||||
);
|
||||
|
||||
// One session per (scenario, epoch). Reset bumps the epoch, which is what
|
||||
// makes "reset" mean a genuinely new mock core rather than a cleared view.
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
setArtifact(null);
|
||||
setRevealed(0);
|
||||
setBusy(true);
|
||||
const opening = openSession(activeEntry, {});
|
||||
void opening.then(
|
||||
async (session) => {
|
||||
if (!live) {
|
||||
await session.close();
|
||||
return;
|
||||
}
|
||||
sessionRef.current = session;
|
||||
publish(session.artifact());
|
||||
if (shouldAutoReplay) {
|
||||
const played = await session.replay();
|
||||
if (!live) return;
|
||||
publish(played);
|
||||
}
|
||||
if (live) setBusy(false);
|
||||
},
|
||||
() => {
|
||||
if (live) setBusy(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
void opening.then((session) => session.close()).catch(() => undefined);
|
||||
sessionRef.current = null;
|
||||
};
|
||||
}, [activeEntry, epoch, openSession, publish, shouldAutoReplay]);
|
||||
|
||||
const target = artifact?.timeline.at(-1)?.sequence ?? 0;
|
||||
const lastTurn = artifact?.turns.at(-1) ?? null;
|
||||
const holdAt =
|
||||
lastTurn === null
|
||||
? 0
|
||||
: Math.max(
|
||||
Math.min(lastTurn.firstSequence + 2, lastTurn.lastSequence - 1),
|
||||
lastTurn.firstSequence,
|
||||
);
|
||||
const ceiling = stageStreaming && lastTurn !== null && lastTurn.turn > 0 ? holdAt : target;
|
||||
|
||||
// Streaming reveal. Replay routes and reduced-motion readers get the settled
|
||||
// transcript immediately; nothing about the record changes either way.
|
||||
React.useEffect(() => {
|
||||
if (instant || prefersReducedMotion()) {
|
||||
setRevealed(ceiling);
|
||||
return undefined;
|
||||
}
|
||||
if (revealed >= ceiling) return undefined;
|
||||
const timer = window.setTimeout(() => setRevealed((current) => Math.min(current + 1, ceiling)), 45);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [ceiling, instant, revealed]);
|
||||
|
||||
const streaming = revealed < target;
|
||||
// "streaming" is a state the reveal has *arrived* at, not one it passes
|
||||
// through: a staged capture needs a stable wait target, and mid-reveal is
|
||||
// still pending.
|
||||
const chatState: Phase7ChatState =
|
||||
artifact === null
|
||||
? "seeding"
|
||||
: artifact.status === "failed"
|
||||
? "failed"
|
||||
: busy || revealed < ceiling
|
||||
? "pending"
|
||||
: ceiling < target
|
||||
? "streaming"
|
||||
: "settled";
|
||||
|
||||
React.useEffect(() => {
|
||||
onStateChange(chatState);
|
||||
}, [chatState, onStateChange]);
|
||||
|
||||
const send = React.useCallback(
|
||||
async (prompt: string) => {
|
||||
const session = sessionRef.current;
|
||||
if (session === null || prompt.trim().length === 0) return;
|
||||
setBusy(true);
|
||||
setDraft("");
|
||||
try {
|
||||
publish(await session.send(prompt.trim()));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[publish],
|
||||
);
|
||||
|
||||
const replay = React.useCallback(async () => {
|
||||
const session = sessionRef.current;
|
||||
if (session === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
publish(await session.replay());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [publish]);
|
||||
|
||||
const focusComposer = (): void => {
|
||||
composerRef.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus();
|
||||
};
|
||||
|
||||
// A scenario switch is a state discard, so it asks before it happens rather
|
||||
// than after (Forgiveness). A session with no turns has nothing to lose.
|
||||
React.useEffect(() => {
|
||||
if (!switching) return;
|
||||
if ((artifact?.turns.length ?? 0) > 1) setConfirm("switch");
|
||||
else setActiveEntry(entry);
|
||||
}, [artifact, entry, switching]);
|
||||
|
||||
const suggestion = sessionRef.current?.nextPrompt() ?? null;
|
||||
const busyReason = busy
|
||||
? "A turn is running."
|
||||
: artifact === null
|
||||
? "The session is still seeding."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="pcr7-chat" data-testid="chat-shell" data-chat-state={chatState}>
|
||||
<SessionHeader
|
||||
entry={activeEntry}
|
||||
artifact={artifact}
|
||||
mode={mode}
|
||||
codexAvailable={codexAvailable}
|
||||
busy={busy || revealed < target}
|
||||
onModeChange={setMode}
|
||||
onReset={() => setConfirm("reset")}
|
||||
onReplay={() => void replay()}
|
||||
onOpenParity={onOpenParity}
|
||||
headerRef={headerRef}
|
||||
/>
|
||||
|
||||
<p className="pcr7-visually-hidden" role="status" aria-live="polite" data-testid="chat-live-region">
|
||||
{chatState === "settled" && lastTurn !== null && lastTurn.turn > 0
|
||||
? `Turn ${lastTurn.turn} settled — ${lastTurn.counts.denied} denied operation${
|
||||
lastTurn.counts.denied === 1 ? "" : "s"
|
||||
}, parity ${lastTurn.parity.verdict.replace("_", " ")}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
<div className="pcr7-chat-scroll">
|
||||
{artifact === null ? (
|
||||
<EmptyState title="Seeding the session…">
|
||||
<p className="pcr7-muted">
|
||||
The control plane is checking out the task and deciding tool exposure. Nothing has been
|
||||
asked of the agent yet.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ChatConversation
|
||||
artifact={artifact}
|
||||
revealedSequence={revealed}
|
||||
runningTurn={busy || streaming ? (lastTurn?.turn ?? null) : null}
|
||||
activeTurn={activeTurn}
|
||||
onOpenTurn={onOpenTurn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={composerRef} className="pcr7-chat-composer" data-testid="composer">
|
||||
{suggestion === null ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-chip"
|
||||
data-testid="chat-suggested-prompt"
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(suggestion)}
|
||||
>
|
||||
Suggested: {suggestion}
|
||||
</button>
|
||||
)}
|
||||
<Composer
|
||||
state={busy || streaming ? "active-turn" : "idle"}
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={() => void send(draft)}
|
||||
onStop={() => undefined}
|
||||
disabledReason={busyReason}
|
||||
label="Message the agent"
|
||||
/>
|
||||
<p className="pcr7-muted" data-testid="composer-scope-note">
|
||||
{mode === "scripted"
|
||||
? `Scripted mode — your prompt drives the next recorded turn against the in-page mock control plane. ${
|
||||
artifact?.remainingScriptedTurns ?? 0
|
||||
} scripted turn${(artifact?.remainingScriptedTurns ?? 0) === 1 ? "" : "s"} left.`
|
||||
: "Codex mode drives the same mock control plane through the server-side relay; no provider credential reaches this page."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={confirm !== "none"}
|
||||
onClose={() => setConfirm("none")}
|
||||
title={confirm === "switch" ? "Switch scenario?" : "Reset this session?"}
|
||||
description={
|
||||
confirm === "switch"
|
||||
? "The mock core is seeded per scenario, so switching discards this transcript and its mock state."
|
||||
: "The transcript and mock state return to the seeded scenario."
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
data-testid="confirm-cancel"
|
||||
onClick={() => {
|
||||
if (confirm === "switch") onCancelScenarioSwitch(activeEntry.id);
|
||||
setConfirm("none");
|
||||
focusComposer();
|
||||
}}
|
||||
>
|
||||
Keep this session
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="confirm-accept"
|
||||
onClick={() => {
|
||||
if (confirm === "switch") setActiveEntry(entry);
|
||||
else setEpoch((current) => current + 1);
|
||||
setConfirm("none");
|
||||
focusComposer();
|
||||
}}
|
||||
>
|
||||
{confirm === "switch" ? "Switch scenario" : "Reset session"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Phase7ChatTurn } from "@paperclip-runner-local/phase7";
|
||||
|
||||
import { ParityStatusBadge } from "./primitives.js";
|
||||
|
||||
/**
|
||||
* Per-turn activity strip (7I map §3).
|
||||
*
|
||||
* The guaranteed bridge from a turn to its evidence: whatever the board is
|
||||
* reading in the conversation, the strip below it says what that turn actually
|
||||
* did and opens the matching activity group. Counts are icon plus text — never
|
||||
* colour alone — so the strip is readable before anything is opened.
|
||||
*/
|
||||
export function TurnStrip({
|
||||
turn,
|
||||
onOpen,
|
||||
active,
|
||||
}: {
|
||||
turn: Phase7ChatTurn;
|
||||
onOpen: (turn: number) => void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pcr7-turn-strip"
|
||||
data-testid={`turn-activity-strip-${turn.turn}`}
|
||||
data-active={active === true}
|
||||
aria-label={`Turn ${turn.turn} activity — ${summaryText(turn)}. Open the activity stream.`}
|
||||
onClick={() => onOpen(turn.turn)}
|
||||
>
|
||||
<span className="pcr7-turn-strip-label">
|
||||
{turn.turn === 0 ? "Session seed activity" : `Turn ${turn.turn} activity`}
|
||||
</span>
|
||||
<span className="pcr7-turn-strip-counts">
|
||||
<Count icon="⚙" label={plural(turn.counts.calls, "tool")} value={turn.counts.calls} />
|
||||
{turn.counts.denied === 0 ? null : (
|
||||
<Count icon="✕" label="denied" value={turn.counts.denied} tone="danger" />
|
||||
)}
|
||||
<Count icon="⌘" label="control plane" value={turn.counts.controlPlane} />
|
||||
<Count
|
||||
icon="Δ"
|
||||
label={plural(turn.counts.changedDomains, "domain")}
|
||||
value={turn.counts.changedDomains}
|
||||
/>
|
||||
<ParityStatusBadge status={turn.parity.verdict} testId={`turn-parity-${turn.turn}`} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Count({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "danger";
|
||||
}) {
|
||||
return (
|
||||
<span className="pcr7-turn-count" data-tone={tone ?? "neutral"}>
|
||||
<span aria-hidden="true">{icon}</span> {value} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function summaryText(turn: Phase7ChatTurn): string {
|
||||
const parts = [
|
||||
`${turn.counts.calls} ${plural(turn.counts.calls, "tool")}`,
|
||||
`${turn.counts.denied} denied`,
|
||||
`${turn.counts.controlPlane} control plane`,
|
||||
`diff ${turn.counts.changedDomains} ${plural(turn.counts.changedDomains, "domain")}`,
|
||||
];
|
||||
return `${parts.join(", ")}, parity ${turn.parity.verdict.replace("_", " ")}`;
|
||||
}
|
||||
|
||||
function plural(count: number, noun: string): string {
|
||||
return count === 1 ? noun : `${noun}s`;
|
||||
}
|
||||
@@ -43,8 +43,20 @@ export function ScenarioTranscript({
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEntryBody({ entry }: { entry: Phase7TimelineEntry }) {
|
||||
/**
|
||||
* One timeline entry in the transcript grammar. Shared with the Phase 7I chat
|
||||
* so a semantic call, a denial, and a control-plane strip look the same
|
||||
* wherever they are read.
|
||||
*/
|
||||
export function TimelineEntryBody({ entry }: { entry: Phase7TimelineEntry }) {
|
||||
switch (entry.kind) {
|
||||
case "user_message":
|
||||
return (
|
||||
<div className="pcr7-user-message">
|
||||
<p>{entry.text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "agent_message":
|
||||
return (
|
||||
<div className="pcr7-agent-message">
|
||||
|
||||
@@ -572,6 +572,267 @@ body {
|
||||
position: static;
|
||||
}
|
||||
|
||||
|
||||
/* Phase 7I — chat surface (7I interaction map §1-§5) ------------------ *
|
||||
*
|
||||
* The activity rail is wider than the 7F inspector because it carries the
|
||||
* per-turn evidence rather than a tab of it. Everything else is the same
|
||||
* shell, the same tokens, and the same two-channel grammar.
|
||||
*/
|
||||
|
||||
.pcr7-shell[data-surface="chat"] .pcr7-columns {
|
||||
grid-template-columns: var(--pcr-rail-width) minmax(0, 1fr) var(--pcr-activity-width);
|
||||
}
|
||||
|
||||
.pcr7-surface-nav {
|
||||
display: flex;
|
||||
gap: var(--pcr-space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pcr7-chat {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-session-header {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
}
|
||||
|
||||
.pcr7-session-header .pcr7-run-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--pcr-space-2);
|
||||
}
|
||||
|
||||
.pcr7-session-header .pcr7-run-controls > .pcr7-muted {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.pcr7-chat-scroll {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-chat-conversation,
|
||||
.pcr7-chat-entries {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-chat-turn {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-chat-entry {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The board's own words are the only right-aligned thing on the surface, so a
|
||||
* stranger can find "what did I ask" without reading a single label.
|
||||
*/
|
||||
.pcr7-user-message {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pcr7-user-message p {
|
||||
margin: 0;
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
background: var(--pcr-accent-surface);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-md);
|
||||
max-width: 85%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pcr7-chat-seed-label,
|
||||
.pcr7-activity-prompt {
|
||||
margin: 0;
|
||||
color: var(--pcr-muted-foreground);
|
||||
font-size: var(--pcr-font-size-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pcr7-turn-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--pcr-space-2);
|
||||
width: 100%;
|
||||
min-height: var(--pcr-touch-target);
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
background: var(--pcr-muted);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-md);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pcr7-turn-strip[data-active="true"] {
|
||||
border-color: var(--pcr-accent);
|
||||
}
|
||||
|
||||
.pcr7-turn-strip-label {
|
||||
font-weight: var(--pcr-weight-semibold);
|
||||
font-size: var(--pcr-font-size-sm);
|
||||
}
|
||||
|
||||
.pcr7-turn-strip-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--pcr-space-2);
|
||||
}
|
||||
|
||||
.pcr7-turn-count {
|
||||
color: var(--pcr-muted-foreground);
|
||||
font-size: var(--pcr-font-size-xs);
|
||||
}
|
||||
|
||||
.pcr7-turn-count[data-tone="danger"] {
|
||||
color: var(--pcr-danger);
|
||||
}
|
||||
|
||||
.pcr7-turn-pending {
|
||||
margin: 0;
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
background: var(--pcr-surface-raised);
|
||||
border: 0.0625rem dashed var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-md);
|
||||
color: var(--pcr-muted-foreground);
|
||||
font-size: var(--pcr-font-size-xs);
|
||||
}
|
||||
|
||||
.pcr7-streaming-caret {
|
||||
display: inline-block;
|
||||
width: var(--pcr-space-2);
|
||||
height: var(--pcr-space-3);
|
||||
margin-right: var(--pcr-space-2);
|
||||
background: var(--pcr-accent);
|
||||
vertical-align: middle;
|
||||
animation: pcr7-caret var(--pcr-motion-medium) steps(2, start) infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pcr7-caret {
|
||||
to {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.pcr7-chat-failure {
|
||||
margin: 0;
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
background: var(--pcr-danger-surface);
|
||||
border: 0.0625rem solid var(--pcr-danger);
|
||||
border-radius: var(--pcr-radius-md);
|
||||
color: var(--pcr-danger);
|
||||
font-size: var(--pcr-font-size-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pcr7-chat-composer {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-segment-chip {
|
||||
color: var(--pcr-danger);
|
||||
}
|
||||
|
||||
/* Activity stream ---------------------------------------------------- */
|
||||
|
||||
.pcr7-activity {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--pcr-space-2);
|
||||
}
|
||||
|
||||
.pcr7-activity-group {
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-md);
|
||||
background: var(--pcr-surface-raised);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-group h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-group-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--pcr-space-1);
|
||||
width: 100%;
|
||||
min-height: var(--pcr-touch-target);
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--pcr-weight-semibold);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pcr7-activity-group-title {
|
||||
margin: 0;
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
font-size: var(--pcr-font-size-sm);
|
||||
}
|
||||
|
||||
.pcr7-activity-group-body {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
padding: 0 var(--pcr-space-3) var(--pcr-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-section {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-calls {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcr7-activity-verdict {
|
||||
margin: 0;
|
||||
font-size: var(--pcr-font-size-xs);
|
||||
}
|
||||
|
||||
.pcr7-diff-domain {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Responsive: one content-driven breakpoint (UX map §6) -------------- */
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
@@ -595,10 +856,33 @@ body {
|
||||
|
||||
.pcr7-shell[data-segment="scenarios"] .pcr7-rail,
|
||||
.pcr7-shell[data-segment="run"] .pcr7-center,
|
||||
.pcr7-shell[data-segment="inspect"] .pcr7-inspector {
|
||||
.pcr7-shell[data-segment="chat"] .pcr7-center,
|
||||
.pcr7-shell[data-segment="inspect"] .pcr7-inspector,
|
||||
.pcr7-shell[data-segment="activity"] .pcr7-inspector {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/*
|
||||
* The composer sits in the thumb zone on the Chat segment. It is sticky
|
||||
* rather than fixed so it never covers the last turn strip when the
|
||||
* conversation is short.
|
||||
*/
|
||||
.pcr7-shell[data-segment="chat"] .pcr7-chat-composer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--pcr-space-2) 0;
|
||||
background: var(--pcr-background);
|
||||
z-index: var(--pcr-z-banner);
|
||||
}
|
||||
|
||||
.pcr7-shell[data-surface="chat"] .pcr7-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pcr7-user-message p {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pcr7-expected-observed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,28 @@
|
||||
|
||||
export type Phase7InspectorView = "transcript" | "context" | "authorization" | "diff" | "parity";
|
||||
|
||||
/** The explorer reviews a corpus; the chat drives one session. Same shell. */
|
||||
export type Phase7Surface = "explorer" | "chat";
|
||||
|
||||
export type Phase7ChatView = "chat" | "activity";
|
||||
|
||||
export type Phase7ActivityFilter =
|
||||
| "all"
|
||||
| "tools"
|
||||
| "authorization"
|
||||
| "control"
|
||||
| "diff"
|
||||
| "parity";
|
||||
|
||||
export const PHASE7_ACTIVITY_FILTERS: readonly Phase7ActivityFilter[] = [
|
||||
"all",
|
||||
"tools",
|
||||
"authorization",
|
||||
"control",
|
||||
"diff",
|
||||
"parity",
|
||||
];
|
||||
|
||||
export const PHASE7_INSPECTOR_VIEWS: readonly Phase7InspectorView[] = [
|
||||
"transcript",
|
||||
"context",
|
||||
@@ -26,9 +48,21 @@ export interface Phase7Filters {
|
||||
}
|
||||
|
||||
export interface Phase7Route {
|
||||
surface: Phase7Surface;
|
||||
caseId: string | null;
|
||||
run: "fake" | "codex" | null;
|
||||
/** `replay=fake` auto-plays the scripted session for screenshot routes. */
|
||||
replay: "fake" | null;
|
||||
view: Phase7InspectorView;
|
||||
chatView: Phase7ChatView;
|
||||
/** Anchors and expands one turn group in the activity stream. */
|
||||
turn: number | null;
|
||||
activityFilter: Phase7ActivityFilter;
|
||||
/**
|
||||
* Holds the last scripted turn mid-stream so the streaming state can be
|
||||
* captured deterministically instead of raced by hand.
|
||||
*/
|
||||
stage: "streaming" | null;
|
||||
filters: Phase7Filters;
|
||||
}
|
||||
|
||||
@@ -46,13 +80,23 @@ export function parsePhase7Route(hash: string): Phase7Route {
|
||||
const [pathPart = "", queryPart = ""] = raw.split("?", 2);
|
||||
const params = new URLSearchParams(queryPart);
|
||||
const segments = pathPart.split("/").filter((segment) => segment.length > 0);
|
||||
const caseId = segments[0] === "case" && segments[1] !== undefined ? decodeURIComponent(segments[1]) : null;
|
||||
const surface: Phase7Surface = segments[0] === "chat" ? "chat" : "explorer";
|
||||
const caseSegment = surface === "chat" ? segments[1] : segments[0] === "case" ? segments[1] : undefined;
|
||||
const caseId = caseSegment === undefined ? null : decodeURIComponent(caseSegment);
|
||||
const run = params.get("run");
|
||||
const view = params.get("view");
|
||||
const turn = Number.parseInt(params.get("turn") ?? "", 10);
|
||||
const filter = params.get("filter");
|
||||
return {
|
||||
surface,
|
||||
caseId,
|
||||
run: run === "fake" || run === "codex" ? run : null,
|
||||
replay: params.get("replay") === "fake" ? "fake" : null,
|
||||
view: isInspectorView(view) ? view : "transcript",
|
||||
chatView: view === "activity" ? "activity" : "chat",
|
||||
turn: Number.isInteger(turn) && turn >= 0 ? turn : null,
|
||||
activityFilter: isActivityFilter(filter) ? filter : "all",
|
||||
stage: params.get("stage") === "streaming" ? "streaming" : null,
|
||||
filters: {
|
||||
group: params.get("group"),
|
||||
disposition: params.get("disposition"),
|
||||
@@ -66,6 +110,7 @@ export function parsePhase7Route(hash: string): Phase7Route {
|
||||
|
||||
export function serializePhase7Route(route: Phase7Route): string {
|
||||
const params = new URLSearchParams();
|
||||
if (route.surface === "chat") return serializeChatRoute(route, params);
|
||||
if (route.run !== null) params.set("run", route.run);
|
||||
if (route.view !== "transcript") params.set("view", route.view);
|
||||
if (route.caseId === null) {
|
||||
@@ -114,6 +159,21 @@ export function hasActiveFilters(filters: Phase7Filters): boolean {
|
||||
return activeFilterChips(filters).length > 0;
|
||||
}
|
||||
|
||||
function serializeChatRoute(route: Phase7Route, params: URLSearchParams): string {
|
||||
if (route.replay !== null) params.set("replay", route.replay);
|
||||
if (route.chatView !== "chat") params.set("view", route.chatView);
|
||||
if (route.turn !== null) params.set("turn", String(route.turn));
|
||||
if (route.activityFilter !== "all") params.set("filter", route.activityFilter);
|
||||
if (route.stage !== null) params.set("stage", route.stage);
|
||||
const path = route.caseId === null ? "/chat" : `/chat/${encodeURIComponent(route.caseId)}`;
|
||||
const query = params.toString();
|
||||
return `#${path}${query.length > 0 ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function isInspectorView(value: string | null): value is Phase7InspectorView {
|
||||
return value !== null && (PHASE7_INSPECTOR_VIEWS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isActivityFilter(value: string | null): value is Phase7ActivityFilter {
|
||||
return value !== null && (PHASE7_ACTIVITY_FILTERS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "phase7-explorer.spec.ts",
|
||||
testMatch: /phase7-(explorer|chat)\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 7I interactive scenario chat verification
|
||||
description: Turn model, per-turn evidence, denial handling, reset isolation, route determinism, responsive behaviour, and the ten-image screenshot acceptance set for the Phase 7I chat.
|
||||
tags: [native-runner, phase-7, verification, browser, chat, parity]
|
||||
status: stable
|
||||
generated: { by: anthropic/claude-opus-5, at: 2026-08-09T16:05:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record verifies the Phase 7I interactive chat (PAP-16916) against the
|
||||
approved interaction map,
|
||||
[Phase 7I Mobile Chat UX](../../docs/design/phase-7i-mobile-chat-ux.md), whose
|
||||
§11a lists every deviation taken during implementation.
|
||||
|
||||
The chat runs against the Phase 7C mock control plane in the page. No Paperclip
|
||||
service is contacted, no ACPX implementation is introduced, and no provider or
|
||||
Paperclip credential reaches the browser.
|
||||
|
||||
# Commands and results
|
||||
|
||||
All commands run from the repository root.
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run test:phase7` | 5 files, 79 tests passed (49 Phase 7 runtime + 12 chat session + 18 chat rendering, plus the 7F sets) |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run test:browser:phase7` | 43 tests passed (25 Phase 7F + 18 Phase 7I) |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run typecheck:typescript` | passed |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run typecheck:browser` | passed |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run check:browser-tokens` | passed — no hex, raw pixel, inline style, or raw font size in the chat components |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run check:forbidden-imports` | passed |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run test:phase7-evals` | 106 cases, 16 groups — unchanged by 7I |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run record:phase7i` | 10 screenshots; the recorder fails the run on any horizontal page scroll |
|
||||
| `pnpm --filter @paperclipai/paperclip-runner run docs:validate` | passed |
|
||||
|
||||
# What was verified
|
||||
|
||||
## Turn model and the data contract
|
||||
|
||||
`Phase7ChatSession` seeds turn 0 from control-plane work alone (checkout, wake
|
||||
routing, exposure) and numbers user turns from 1. Every timeline entry carries
|
||||
its `turn`; the artifact carries `turns[]` with per-turn exposure,
|
||||
authorization records, state diff, reconciliation events, counts, and verdict.
|
||||
The UI derives none of these — asserted in `src/phase7/chat-session.test.ts`
|
||||
and `examples/phase7-explorer/src/chat.test.tsx`.
|
||||
|
||||
The turn-0 verdict reports `Pass` over the single claim it makes (control-plane
|
||||
work with no agent tool) and carries no agent-tool assertion.
|
||||
|
||||
## Successful and denied mock interactions from chat
|
||||
|
||||
`ap-mcp-gate-01` in a live (non-replay) session: two typed prompts produce an
|
||||
allowed turn and a denied turn. The denial renders in place on the danger
|
||||
surface with `policy_denied`, `required_claim_missing`, the missing claim, and
|
||||
an explicit "not returned — the denial carries no task data". The session
|
||||
status stays `Scripted · deterministic`; a denial is an outcome, not a failure.
|
||||
The mobile Activity segment carries a `· 1 deny` count chip.
|
||||
|
||||
## Control-plane-owned action with no agent tool
|
||||
|
||||
`ix-checkbox-01` turn 3 applies `resolve_human_input` as a control-plane
|
||||
command. The mock core schedules the continuation wake in response, which
|
||||
appears as a `wake.scheduled` reconciliation row inside that turn's state-diff
|
||||
section, badged "Control plane".
|
||||
|
||||
## Reset, replay, and session isolation
|
||||
|
||||
Reset confirms through the SDK `Dialog`, returns to the seeded turn 0, and
|
||||
leaves no turn strips and no denials behind — verified in the browser and at
|
||||
the runtime level (a fresh session shows one turn, no user messages, and an
|
||||
unchanged diff). Switching scenario mid-session asks the same question.
|
||||
`replay=fake` plays once per session, so an explicit reset is not undone by the
|
||||
route.
|
||||
|
||||
Replaying the same scenario twice produces byte-identical `timeline` and
|
||||
`turns` JSON, and the scripted route settles to identical DOM across loads.
|
||||
|
||||
## Boundary
|
||||
|
||||
- `rs-secret-hygiene-01` in chat: the rendered page contains no raw fixture
|
||||
secret, and `localStorage.length` is 0.
|
||||
- Codex mode is disabled with a named reason (provider relay not running);
|
||||
scripted mode drives the same mock core offline.
|
||||
- All 106 corpus scenarios replay in chat with no harness failure.
|
||||
|
||||
## Responsive, keyboard, and accessibility
|
||||
|
||||
- Three landmarks on chat routes (`nav` picker, `main` chat, `complementary`
|
||||
turn activity), one `h1`, chat-specific segment labels.
|
||||
- 390×844: no horizontal page scroll on the chat, activity, and long-payload
|
||||
routes; segment and turn-strip touch targets meet the minimum; the composer
|
||||
stays in the thumb zone while the transcript scrolls.
|
||||
- Enter sends, Shift+Enter inserts a newline without sending.
|
||||
- "Back to chat" returns focus to the turn strip that opened the group.
|
||||
- Turn settle announces politely; a denial announces assertively once.
|
||||
|
||||
# Screenshot acceptance set
|
||||
|
||||
Ten images in [`phase-07/`](phase-07/), slugs `phase-7i-<slug>-<viewport>` at
|
||||
1440×900 and 390×844, recorded from the §11 routes after the shell reports the
|
||||
expected `data-chat-state`. The manifest is
|
||||
[`phase-07/phase-7i-screenshot-manifest.json`](phase-07/phase-7i-screenshot-manifest.json).
|
||||
|
||||
| Slug | Route |
|
||||
| --- | --- |
|
||||
| `chat-home` | `#/chat` |
|
||||
| `chat-session` | `#/chat/ix-checkbox-01?replay=fake` |
|
||||
| `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2` |
|
||||
| `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3` |
|
||||
| `chat-streaming` | `#/chat/ap-mcp-gate-01?replay=fake&stage=streaming` |
|
||||
|
||||
# Open items
|
||||
|
||||
The UXDesigner §11 acceptance review of these ten images is the remaining gate
|
||||
(PAP-16914 owns it). Deviations are recorded in §11a of the interaction map
|
||||
rather than left implicit.
|
||||
@@ -14,5 +14,6 @@
|
||||
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
|
||||
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock and database-backed conformance, authoritative completion, restart recovery, migration repair, atomic liveness, internal canary, and deterministic post-kill-switch legacy evidence.
|
||||
* [Phase 7F browser scenario explorer verification](2026-08-09-phase-07f-explorer-verification.md) - Scenario index, run artifacts, exposure, authorization, state diff, parity, route determinism, accessibility, and the 24-image screenshot acceptance set.
|
||||
* [Phase 7I interactive scenario chat verification](2026-08-09-phase-07i-chat-verification.md) - Turn model, per-turn evidence, denied and successful mock interactions from chat, reset isolation, route determinism, responsive and keyboard behaviour, and the ten-image screenshot acceptance set.
|
||||
* [Phase 7 documentation, tutorial, and evidence manifest](2026-08-09-phase-07-verification.md) - Command→result matrix for every focused Phase 7 verification, artifact links, and the security, UX, and QA gate issues.
|
||||
* [Phase 7 evidence](phase-07/) - Screenshot acceptance captures and the eval conformance parity report.
|
||||
|
After Width: | Height: | Size: 920 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 918 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 753 KiB |
|
After Width: | Height: | Size: 209 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema": "paperclip.phase7.screenshot-manifest.v1",
|
||||
"source": "docs/design/phase-7i-mobile-chat-ux.md §11",
|
||||
"viewports": [
|
||||
"1440x900",
|
||||
"390x844"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"phase-7i-chat-activity-diff-1440x900.png",
|
||||
"phase-7i-chat-activity-diff-390x844.png",
|
||||
"phase-7i-chat-denied-1440x900.png",
|
||||
"phase-7i-chat-denied-390x844.png",
|
||||
"phase-7i-chat-home-1440x900.png",
|
||||
"phase-7i-chat-home-390x844.png",
|
||||
"phase-7i-chat-session-1440x900.png",
|
||||
"phase-7i-chat-session-390x844.png",
|
||||
"phase-7i-chat-streaming-1440x900.png",
|
||||
"phase-7i-chat-streaming-390x844.png"
|
||||
]
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"record:phase4b": "pnpm run build:typescript && node scripts/record-phase4b-evidence.mjs",
|
||||
"record:phase4": "pnpm run build:typescript && node scripts/record-phase4-evidence.mjs",
|
||||
"record:phase7": "node scripts/record-phase7-evidence.mjs",
|
||||
"record:phase7i": "node scripts/record-phase7i-evidence.mjs",
|
||||
"diagnose:phase3": "pnpm run trace:phase3",
|
||||
"record:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node scripts/record-phase3-evidence.mjs",
|
||||
"record:phase3:lost-ack": "pnpm run trace:phase3 -- --fault lost-ack --output knowledge/evidence/phase-03-lost-ack.json",
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
Phase7JsonValue,
|
||||
Phase7SemanticCommand,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import type { Phase7ScenarioIndexEntry } from "./scenario-explorer-types.js";
|
||||
import type { Phase7ScenarioFixture } from "./scenario-fixtures.js";
|
||||
import {
|
||||
CREATED_INTERACTION_REF,
|
||||
phase7ScenarioPlan,
|
||||
type Phase7PlanStep,
|
||||
} from "./scenario-plan.js";
|
||||
|
||||
/**
|
||||
* Conversational scripts for the Phase 7I chat.
|
||||
*
|
||||
* A 7F scenario plan is single-shot: one fake-agent script for one run. The
|
||||
* chat needs the same work split across user turns so turn grouping, per-turn
|
||||
* diffs, and per-turn verdicts are actually exercised (7I map §11). A script is
|
||||
* data, never behaviour — the mock core still decides every outcome, and a
|
||||
* scripted turn asserts nothing about what it will produce.
|
||||
*
|
||||
* Two scenarios carry authored multi-turn conversations because the acceptance
|
||||
* matrix names them; every other scenario falls back to its 7F plan as a single
|
||||
* scripted turn, which keeps the whole 49-case corpus openable in chat without
|
||||
* inventing dialogue the traceability rows do not support.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A control-plane-owned action the conversation triggers with no agent tool:
|
||||
* the board answering its own interaction is control-plane work, so the chat
|
||||
* has to be able to script it without pretending a semantic operation exists.
|
||||
*/
|
||||
export interface Phase7ControlPlaneCommandStep {
|
||||
kind: "control_plane_command";
|
||||
command: Phase7SemanticCommand;
|
||||
idempotencySuffix: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type Phase7ChatStep = Phase7PlanStep | Phase7ControlPlaneCommandStep;
|
||||
|
||||
export interface Phase7ChatScriptTurn {
|
||||
/** The prompt the scripted board member sends to open this turn. */
|
||||
prompt: string;
|
||||
steps: Phase7ChatStep[];
|
||||
}
|
||||
|
||||
export interface Phase7ChatScript {
|
||||
scenarioId: string;
|
||||
/** True when the turns were authored for chat rather than derived. */
|
||||
authored: boolean;
|
||||
turns: Phase7ChatScriptTurn[];
|
||||
controlPlaneCapabilities: Array<{ operationId: string; reason: string }>;
|
||||
restraint: boolean;
|
||||
}
|
||||
|
||||
export function phase7ChatScript(
|
||||
entry: Phase7ScenarioIndexEntry,
|
||||
fixture: Phase7ScenarioFixture,
|
||||
): Phase7ChatScript {
|
||||
const plan = phase7ScenarioPlan(entry, fixture);
|
||||
const authored = AUTHORED_CHAT_SCRIPTS[entry.id]?.(entry);
|
||||
return {
|
||||
scenarioId: entry.id,
|
||||
authored: authored !== undefined,
|
||||
turns: authored ?? [{ prompt: derivedPrompt(entry), steps: plan.steps }],
|
||||
controlPlaneCapabilities: plan.controlPlaneCapabilities,
|
||||
restraint: plan.restraint,
|
||||
};
|
||||
}
|
||||
|
||||
type ChatScriptFactory = (entry: Phase7ScenarioIndexEntry) => Phase7ChatScriptTurn[];
|
||||
|
||||
const AUTHORED_CHAT_SCRIPTS: Record<string, ChatScriptFactory> = {
|
||||
/*
|
||||
* Two turns that separate reading from asking: turn 1 changes no state, turn
|
||||
* 2 opens an interaction and makes the control plane schedule the
|
||||
* continuation wake — so the per-turn diff and the reconciliation row have
|
||||
* something real to show side by side.
|
||||
*/
|
||||
"ix-checkbox-01": (entry) => [
|
||||
{
|
||||
prompt: "What scope options does the board already have on this task?",
|
||||
steps: [
|
||||
read("get_task_context", {}, "Read the options the board already has."),
|
||||
message(
|
||||
"The task is scoped to a subset selection and no interaction is open yet, so nothing is waiting on the board.",
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: "Ask them to pick which scopes stay in, then park until they answer.",
|
||||
steps: [
|
||||
write(
|
||||
"request_human_input",
|
||||
{
|
||||
interactionKind: "checkbox",
|
||||
title: "Select the fixture scope",
|
||||
prompt: "Select every item that should stay in scope.",
|
||||
payload: {
|
||||
options: [
|
||||
{ id: "scope-a", label: "Scope A" },
|
||||
{ id: "scope-b", label: "Scope B" },
|
||||
{ id: "scope-c", label: "Scope C" },
|
||||
],
|
||||
},
|
||||
continuationPolicy: "wake_assignee",
|
||||
},
|
||||
"checkbox",
|
||||
"Ask for a subset selection as a checkbox interaction.",
|
||||
),
|
||||
message(
|
||||
`Subset selection for ${entry.id} is parked until the continuation wake arrives.`,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: "Scope A and Scope C stay in — we are dropping Scope B.",
|
||||
steps: [
|
||||
{
|
||||
kind: "control_plane_command",
|
||||
idempotencySuffix: "resolve-checkbox",
|
||||
command: {
|
||||
kind: "resolve_human_input",
|
||||
interactionId: CREATED_INTERACTION_REF(1),
|
||||
outcome: "answered",
|
||||
result: { selectedOptionIds: ["scope-a", "scope-c"] },
|
||||
},
|
||||
summary:
|
||||
"Control plane recorded the board's answer and scheduled the continuation wake — no agent tool exists for this.",
|
||||
},
|
||||
read(
|
||||
"get_task_context",
|
||||
{},
|
||||
"Re-read the task on the continuation wake before acting on the selection.",
|
||||
),
|
||||
message(
|
||||
"The board kept Scope A and Scope C. The continuation wake is scheduled, so the next heartbeat picks the task up with the answer already attached.",
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
/*
|
||||
* Turn 1 succeeds, turn 2 is denied. Keeping them apart is the point: the
|
||||
* chat has to show that a denial is a recorded turn outcome with its own
|
||||
* authorization slice, not a failed session.
|
||||
*/
|
||||
"ap-mcp-gate-01": (entry) => [
|
||||
{
|
||||
prompt: "Where does the MCP tool approval for this task stand?",
|
||||
steps: [
|
||||
read("get_task_context", {}, "Read the task before touching the gated capability."),
|
||||
read("list_approvals", {}, "Check the approval queue for the pending gate."),
|
||||
message(
|
||||
"The approval queue is readable with the claims this run holds, and the MCP gate is still pending a decision.",
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: "Raise the approval request yourself and get the tool switched on.",
|
||||
steps: [
|
||||
write(
|
||||
"request_approval",
|
||||
{ approvalType: "mcp_tool_access", payload: { caseId: entry.id } },
|
||||
"request",
|
||||
"Attempt the gated request without the decide claim.",
|
||||
),
|
||||
message(
|
||||
"The approval gate is pending, so the task parks in review instead of retrying the tool.",
|
||||
),
|
||||
write(
|
||||
"request_review",
|
||||
{ summary: "Parked pending the MCP tool approval; no retry loop was attempted." },
|
||||
"review",
|
||||
"Park in review rather than retrying behind the gate.",
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The derived prompt names the case rather than inventing dialogue: a script
|
||||
* that has not been authored for chat should read as a replay of the 7F plan,
|
||||
* not as a conversation someone actually had.
|
||||
*/
|
||||
function derivedPrompt(entry: Phase7ScenarioIndexEntry): string {
|
||||
return `Handle this wake: ${entry.title}`;
|
||||
}
|
||||
|
||||
function message(text: string): Phase7ChatStep {
|
||||
return { kind: "agent_message", text };
|
||||
}
|
||||
|
||||
function read(operationId: string, input: Phase7JsonValue, summary: string): Phase7ChatStep {
|
||||
return { kind: "tool_call", operationId, input, summary };
|
||||
}
|
||||
|
||||
function write(
|
||||
operationId: string,
|
||||
input: Phase7JsonValue,
|
||||
idempotencySuffix: string,
|
||||
summary: string,
|
||||
): Phase7ChatStep {
|
||||
return { kind: "tool_call", operationId, input, idempotencySuffix, summary };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPhase7ScenarioIndex } from "./scenario-index.js";
|
||||
import { phase7ChatScript } from "./chat-script.js";
|
||||
import { phase7ScenarioFixture } from "./scenario-fixtures.js";
|
||||
import { Phase7ChatSession, phase7ReplayChatSession } from "./chat-session.js";
|
||||
import type {
|
||||
Phase7ScenarioIndex,
|
||||
Phase7ScenarioIndexEntry,
|
||||
} from "./scenario-explorer-types.js";
|
||||
|
||||
/**
|
||||
* Phase 7I chat session contract.
|
||||
*
|
||||
* These assert the data contract the chat UI depends on: turn boundaries,
|
||||
* per-turn evidence, state that accumulates across turns, and a reset that
|
||||
* really returns to the seed. If a fact the UI groups by turn is not asserted
|
||||
* here, the UI is deriving it — which the 7I map forbids.
|
||||
*/
|
||||
|
||||
const MANIFEST_PATH = resolve(import.meta.dirname, "../../spec/phase-07/eval-traceability.yaml");
|
||||
|
||||
let index: Phase7ScenarioIndex;
|
||||
|
||||
beforeAll(async () => {
|
||||
index = buildPhase7ScenarioIndex(await readFile(MANIFEST_PATH, "utf8"));
|
||||
});
|
||||
|
||||
function entryFor(id: string): Phase7ScenarioIndexEntry {
|
||||
const entry = index.entries.find((candidate) => candidate.id === id);
|
||||
if (entry === undefined) throw new Error(`unknown scenario ${id}`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
describe("phase 7I chat scripts", () => {
|
||||
it("scripts at least two user turns for both acceptance scenarios", () => {
|
||||
for (const id of ["ix-checkbox-01", "ap-mcp-gate-01"]) {
|
||||
const entry = entryFor(id);
|
||||
const script = phase7ChatScript(entry, phase7ScenarioFixture(entry));
|
||||
expect(script.authored, `${id} needs an authored chat script`).toBe(true);
|
||||
expect(script.turns.length).toBeGreaterThanOrEqual(2);
|
||||
for (const turn of script.turns) {
|
||||
expect(turn.prompt.length).toBeGreaterThan(0);
|
||||
expect(turn.steps.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the 7F plan as a single turn for every other case", () => {
|
||||
const entry = entryFor("hb-scoped-wake-01");
|
||||
const script = phase7ChatScript(entry, phase7ScenarioFixture(entry));
|
||||
expect(script.authored).toBe(false);
|
||||
expect(script.turns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens every scenario in the corpus without an authoring gap", () => {
|
||||
for (const entry of index.entries) {
|
||||
const script = phase7ChatScript(entry, phase7ScenarioFixture(entry));
|
||||
expect(script.turns.length, `${entry.id} produced no chat turn`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 7I chat session", () => {
|
||||
it("seeds turn 0 with control-plane work and no agent tool", async () => {
|
||||
const session = await Phase7ChatSession.open(entryFor("ix-checkbox-01"));
|
||||
try {
|
||||
const artifact = session.artifact();
|
||||
expect(artifact.status).toBe("idle");
|
||||
expect(artifact.turns).toHaveLength(1);
|
||||
const seed = artifact.turns[0]!;
|
||||
expect(seed.turn).toBe(0);
|
||||
expect(seed.prompt).toBeNull();
|
||||
expect(seed.counts.calls).toBe(0);
|
||||
expect(seed.counts.controlPlane).toBeGreaterThan(0);
|
||||
// The seed's only claim is the control-plane invariant; it must not
|
||||
// report an agent-tool verdict for work no agent did.
|
||||
expect(seed.parity.verdict).toBe("pass");
|
||||
expect(seed.parity.assertions.map((assertion) => assertion.assertionClass)).toEqual([
|
||||
"control_plane_invariant",
|
||||
]);
|
||||
expect(artifact.timeline.every((item) => item.turn === 0)).toBe(true);
|
||||
expect(seed.exposure.always.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers turns, records the user message, and scopes evidence per turn", async () => {
|
||||
const artifact = await phase7ReplayChatSession(entryFor("ix-checkbox-01"));
|
||||
expect(artifact.turns.map((turn) => turn.turn)).toEqual([0, 1, 2, 3]);
|
||||
expect(artifact.status).toBe("settled");
|
||||
|
||||
const userMessages = artifact.timeline.filter((item) => item.kind === "user_message");
|
||||
expect(userMessages).toHaveLength(3);
|
||||
expect(userMessages.every((item) => item.channel === "user")).toBe(true);
|
||||
|
||||
for (const turn of artifact.turns.slice(1)) {
|
||||
expect(turn.prompt).not.toBeNull();
|
||||
const entries = artifact.timeline.filter((item) => item.turn === turn.turn);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
expect(entries[0]!.sequence).toBe(turn.firstSequence);
|
||||
expect(entries[entries.length - 1]!.sequence).toBe(turn.lastSequence);
|
||||
expect(entries[0]!.kind).toBe("user_message");
|
||||
}
|
||||
|
||||
// Turn 1 only reads; turn 2 opens the interaction; turn 3 is the board
|
||||
// answering it, which is control-plane work that schedules the continuation
|
||||
// wake. The per-turn diffs have to show those as three different things.
|
||||
const [, first, second, third] = artifact.turns;
|
||||
expect(first!.counts.changedDomains).toBe(0);
|
||||
expect(second!.counts.changedDomains).toBeGreaterThan(0);
|
||||
expect(second!.diff.domains.some((domain) => domain.domain === "interactions" && domain.changed)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(second!.reconciliationEvents).toHaveLength(0);
|
||||
|
||||
// The wake is scheduled by the control plane, not requested by the agent.
|
||||
expect(third!.counts.controlPlane).toBeGreaterThan(0);
|
||||
expect(third!.reconciliationEvents.some((event) => event.action === "wake.scheduled")).toBe(true);
|
||||
expect(third!.parity.verdict).toBe("pass");
|
||||
});
|
||||
|
||||
it("keeps a denial inside its own turn with its own authorization slice", async () => {
|
||||
const artifact = await phase7ReplayChatSession(entryFor("ap-mcp-gate-01"));
|
||||
expect(artifact.turns).toHaveLength(3);
|
||||
const [, allowed, denied] = artifact.turns;
|
||||
|
||||
expect(allowed!.counts.denied).toBe(0);
|
||||
expect(denied!.counts.denied).toBeGreaterThan(0);
|
||||
expect(
|
||||
denied!.authorizationRecords.some((record) => record.outcome !== "allowed"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
allowed!.authorizationRecords.every((record) => record.outcome === "allowed"),
|
||||
).toBe(true);
|
||||
|
||||
const denial = artifact.timeline.find(
|
||||
(item) => item.kind === "semantic_result" && item.outcome === "denied",
|
||||
);
|
||||
expect(denial?.turn).toBe(denied!.turn);
|
||||
if (denial?.kind === "semantic_result") {
|
||||
expect(denial.value, "a denial must carry no protected state").toBeNull();
|
||||
expect(denial.failure).not.toBeNull();
|
||||
}
|
||||
// A denied operation is a correct outcome, not a broken session.
|
||||
expect(artifact.failure).toBeNull();
|
||||
expect(artifact.status).toBe("settled");
|
||||
});
|
||||
|
||||
it("accumulates state across turns and reproduces it exactly on replay", async () => {
|
||||
const first = await phase7ReplayChatSession(entryFor("ap-mcp-gate-01"));
|
||||
const second = await phase7ReplayChatSession(entryFor("ap-mcp-gate-01"));
|
||||
expect(JSON.stringify(second.timeline)).toBe(JSON.stringify(first.timeline));
|
||||
expect(JSON.stringify(second.turns)).toBe(JSON.stringify(first.turns));
|
||||
|
||||
// The session diff is cumulative; the last turn's diff is not.
|
||||
const cumulative = first.diff.domains.filter((domain) => domain.changed).map((d) => d.domain);
|
||||
const lastTurn = first.turns[first.turns.length - 1]!;
|
||||
expect(cumulative.length).toBeGreaterThanOrEqual(lastTurn.counts.changedDomains);
|
||||
});
|
||||
|
||||
it("does not leak state from a closed session into a fresh one", async () => {
|
||||
const used = await Phase7ChatSession.open(entryFor("ix-checkbox-01"));
|
||||
await used.send("Ask the board to choose the scope.");
|
||||
await used.send("Anything else outstanding?");
|
||||
const usedArtifact = used.artifact();
|
||||
await used.close();
|
||||
|
||||
const fresh = await Phase7ChatSession.open(entryFor("ix-checkbox-01"));
|
||||
try {
|
||||
const freshArtifact = fresh.artifact();
|
||||
expect(freshArtifact.turns).toHaveLength(1);
|
||||
expect(freshArtifact.timeline.some((item) => item.kind === "user_message")).toBe(false);
|
||||
expect(freshArtifact.diff.domains.every((domain) => !domain.changed)).toBe(true);
|
||||
expect(usedArtifact.turns.length).toBeGreaterThan(freshArtifact.turns.length);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays usable past the end of the script instead of failing the session", async () => {
|
||||
const session = await Phase7ChatSession.open(entryFor("ix-checkbox-01"));
|
||||
try {
|
||||
await session.replay();
|
||||
const artifact = await session.send("Is anything still blocking this?");
|
||||
expect(artifact.failure).toBeNull();
|
||||
expect(artifact.remainingScriptedTurns).toBe(0);
|
||||
const last = artifact.turns[artifact.turns.length - 1]!;
|
||||
expect(last.counts.calls).toBeGreaterThan(0);
|
||||
expect(last.parity.verdict).toBe("pass");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to run a turn after the session is closed", async () => {
|
||||
const session = await Phase7ChatSession.open(entryFor("ix-checkbox-01"));
|
||||
await session.close();
|
||||
await expect(session.send("hello")).rejects.toThrow(/closed/i);
|
||||
});
|
||||
|
||||
it("never lets a raw secret reach a chat artifact", async () => {
|
||||
const artifact = await phase7ReplayChatSession(entryFor("rs-secret-hygiene-01"));
|
||||
expect(JSON.stringify(artifact)).not.toContain("fixture-secret-value-not-a-real-credential");
|
||||
});
|
||||
|
||||
it("replays every corpus scenario in chat without a harness failure", async () => {
|
||||
for (const entry of index.entries) {
|
||||
const artifact = await phase7ReplayChatSession(entry);
|
||||
expect(artifact.failure, `${entry.id} failed in chat: ${artifact.failure?.message}`).toBeNull();
|
||||
expect(artifact.turns.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import type { Phase7FixtureState } from "../mock-core/phase7-control-plane-types.js";
|
||||
import { Phase7SemanticToolRuntime } from "../tools/phase7-semantic-tool-runtime.js";
|
||||
import type { Phase7AuthorizationRecord } from "../tools/phase7-semantic-tool-types.js";
|
||||
import {
|
||||
phase7ChatScript,
|
||||
type Phase7ChatScript,
|
||||
type Phase7ChatStep,
|
||||
} from "./chat-script.js";
|
||||
import { phase7ScenarioFixture } from "./scenario-fixtures.js";
|
||||
import {
|
||||
buildExposure,
|
||||
PHASE7_SECRET_PLACEHOLDER,
|
||||
Phase7ExecutionRecorder,
|
||||
phase7IsReconciliationAction,
|
||||
snapshotOf,
|
||||
} from "./scenario-execution.js";
|
||||
import { phase7ContinuationWakePayload } from "./scenario-runner.js";
|
||||
import {
|
||||
phase7ScenarioParity,
|
||||
phase7TurnParity,
|
||||
type Phase7EvalSuiteLookup,
|
||||
} from "./scenario-parity.js";
|
||||
import { phase7StateDiff } from "./state-diff.js";
|
||||
import type {
|
||||
Phase7ChatSessionArtifact,
|
||||
Phase7ChatTurn,
|
||||
Phase7Exposure,
|
||||
Phase7RunMode,
|
||||
Phase7ScenarioIndexEntry,
|
||||
Phase7TimelineEntry,
|
||||
} from "./scenario-explorer-types.js";
|
||||
|
||||
/**
|
||||
* Phase 7I chat session over the Phase 7C mock control plane.
|
||||
*
|
||||
* A session is one long-lived mock core that survives across turns, unlike the
|
||||
* 7F single-shot run: the board sends a prompt, the scripted agent works, and
|
||||
* the state it changed is still there for the next prompt. That accumulation is
|
||||
* the whole demo — it is what makes a per-turn diff mean anything.
|
||||
*
|
||||
* The session decides nothing. It records turn boundaries and asks the existing
|
||||
* runtime layers for exposure, authorization, diffs, and verdicts, so the chat
|
||||
* UI can group evidence by turn without deriving a single fact itself
|
||||
* (7I interaction map §10).
|
||||
*
|
||||
* No credential, no network, no Paperclip service: the adapter is the in-memory
|
||||
* fixture core, and the only prompt text that leaves the page is the text the
|
||||
* board typed into this same page.
|
||||
*/
|
||||
|
||||
export interface Phase7ChatSessionOptions {
|
||||
mode?: Phase7RunMode;
|
||||
evalSuite?: Phase7EvalSuiteLookup;
|
||||
}
|
||||
|
||||
export class Phase7ChatSession {
|
||||
readonly entry: Phase7ScenarioIndexEntry;
|
||||
readonly script: Phase7ChatScript;
|
||||
|
||||
private readonly adapter: Phase7MockControlPlaneAdapter;
|
||||
private readonly runtime: Phase7SemanticToolRuntime;
|
||||
private readonly recorder: Phase7ExecutionRecorder;
|
||||
private readonly options: Phase7ChatSessionOptions;
|
||||
private readonly context: Awaited<ReturnType<Phase7MockControlPlaneAdapter["openFixtureRun"]>>;
|
||||
private readonly seedState: Phase7FixtureState;
|
||||
private readonly turns: Phase7ChatTurn[] = [];
|
||||
|
||||
private readonly runId: string;
|
||||
private turnCursor = 0;
|
||||
private authorizationCursor = 0;
|
||||
private turnState: Phase7FixtureState;
|
||||
private failure: { message: string } | null = null;
|
||||
private closed = false;
|
||||
|
||||
private constructor(init: {
|
||||
entry: Phase7ScenarioIndexEntry;
|
||||
script: Phase7ChatScript;
|
||||
adapter: Phase7MockControlPlaneAdapter;
|
||||
runtime: Phase7SemanticToolRuntime;
|
||||
recorder: Phase7ExecutionRecorder;
|
||||
context: Awaited<ReturnType<Phase7MockControlPlaneAdapter["openFixtureRun"]>>;
|
||||
seedState: Phase7FixtureState;
|
||||
options: Phase7ChatSessionOptions;
|
||||
}) {
|
||||
this.entry = init.entry;
|
||||
this.script = init.script;
|
||||
this.adapter = init.adapter;
|
||||
this.runtime = init.runtime;
|
||||
this.recorder = init.recorder;
|
||||
this.context = init.context;
|
||||
this.seedState = init.seedState;
|
||||
this.turnState = init.seedState;
|
||||
this.options = init.options;
|
||||
this.runId = `run-phase7-chat-${init.entry.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a session: checkout, wake routing, and the first exposure decision
|
||||
* all happen here, as turn 0, before the board can say anything. The demo has
|
||||
* to show that work happened with no agent tool involved.
|
||||
*/
|
||||
static async open(
|
||||
entry: Phase7ScenarioIndexEntry,
|
||||
options: Phase7ChatSessionOptions = {},
|
||||
): Promise<Phase7ChatSession> {
|
||||
const mode = options.mode ?? "fake";
|
||||
const fixture = phase7ScenarioFixture(entry);
|
||||
const script = phase7ChatScript(entry, fixture);
|
||||
const runId = `run-phase7-chat-${entry.id}`;
|
||||
|
||||
const adapter = new Phase7MockControlPlaneAdapter(fixture.seed);
|
||||
await adapter.start();
|
||||
const beforeSeed = snapshotOf(adapter);
|
||||
|
||||
const context = await adapter.openFixtureRun({
|
||||
identity: {
|
||||
runId,
|
||||
sessionId: `session-phase7-chat-${entry.id}`,
|
||||
companyId: fixture.seed.company!.id!,
|
||||
issueId: fixture.taskId,
|
||||
agentId: fixture.actorId,
|
||||
},
|
||||
backendKind: "mock",
|
||||
sourceInstanceId: "phase7-scenario-chat",
|
||||
wake: {
|
||||
reason: entry.wakeReason,
|
||||
payload: phase7ContinuationWakePayload(entry, fixture, mode),
|
||||
},
|
||||
capabilities: entry.scenarioClaims,
|
||||
});
|
||||
|
||||
const runtime = new Phase7SemanticToolRuntime({
|
||||
adapter,
|
||||
runId,
|
||||
scenarioGrants: entry.scenarioClaims,
|
||||
policy: entry.policy ?? undefined,
|
||||
resolveSecretValue: () => PHASE7_SECRET_PLACEHOLDER,
|
||||
});
|
||||
|
||||
const recorder = new Phase7ExecutionRecorder({ entry, adapter, runtime });
|
||||
recorder.openTurn(0);
|
||||
recorder.drainControlPlane();
|
||||
const seedState = snapshotOf(adapter);
|
||||
|
||||
const session = new Phase7ChatSession({
|
||||
entry,
|
||||
script,
|
||||
adapter,
|
||||
runtime,
|
||||
recorder,
|
||||
context,
|
||||
seedState,
|
||||
options,
|
||||
});
|
||||
session.recordTurn({ turn: 0, prompt: null, before: beforeSeed, after: seedState });
|
||||
return session;
|
||||
}
|
||||
|
||||
get remainingScriptedTurns(): number {
|
||||
return Math.max(this.script.turns.length - this.turnCursor, 0);
|
||||
}
|
||||
|
||||
/** The next scripted prompt, offered to the composer as a starting point. */
|
||||
nextPrompt(): string | null {
|
||||
return this.script.turns[this.turnCursor]?.prompt ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one turn: the board's message, then the scripted agent work, then
|
||||
* whatever the control plane did in response. Every outcome comes from the
|
||||
* mock core — a prompt drives the turn, it never dictates the result.
|
||||
*/
|
||||
async send(prompt: string): Promise<Phase7ChatSessionArtifact> {
|
||||
if (this.closed) throw new Error("This chat session is closed.");
|
||||
const turn = this.turns.length === 0 ? 1 : this.turns[this.turns.length - 1]!.turn + 1;
|
||||
const before = snapshotOf(this.adapter);
|
||||
this.recorder.openTurn(turn);
|
||||
this.recorder.pushUserMessage(prompt);
|
||||
|
||||
const scripted = this.script.turns[this.turnCursor];
|
||||
let turnFailure: { message: string } | null = null;
|
||||
try {
|
||||
if (scripted === undefined) {
|
||||
// Past the end of the script the session stays usable rather than
|
||||
// pretending the conversation ended: the agent re-reads the task it is
|
||||
// checked out on, so the turn still shows real mock activity.
|
||||
await this.recorder.runStep({
|
||||
kind: "tool_call",
|
||||
operationId: "get_task_context",
|
||||
input: {},
|
||||
summary: "Re-read the task context for an unscripted follow-up turn.",
|
||||
});
|
||||
this.recorder.pushAgentMessage(
|
||||
"The recorded conversation for this scenario is finished. I re-read the task context so this turn still shows what the mock control plane holds; reset the session to replay the script from the seed.",
|
||||
);
|
||||
} else {
|
||||
this.turnCursor += 1;
|
||||
for (const step of scripted.steps) {
|
||||
await this.runStep(step);
|
||||
}
|
||||
if (this.script.restraint && this.remainingScriptedTurns === 0) {
|
||||
this.recorder.pushRestraintNote(this.entry.forbiddenSemantics.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
turnFailure = { message: error instanceof Error ? error.message : String(error) };
|
||||
this.failure = turnFailure;
|
||||
}
|
||||
|
||||
this.recorder.drainControlPlane();
|
||||
const after = snapshotOf(this.adapter);
|
||||
this.recordTurn({ turn, prompt, before, after, failure: turnFailure });
|
||||
return this.artifact();
|
||||
}
|
||||
|
||||
private async runStep(step: Phase7ChatStep): Promise<void> {
|
||||
if (step.kind === "control_plane_command") {
|
||||
await this.recorder.applyControlPlaneCommand({
|
||||
runId: this.runId,
|
||||
idempotencyKey: `phase7:chat:${this.entry.id}:${step.idempotencySuffix}`,
|
||||
command: step.command,
|
||||
summary: step.summary,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.recorder.runStep(step);
|
||||
}
|
||||
|
||||
/** Plays every remaining scripted turn — the deterministic `replay=fake` route. */
|
||||
async replay(): Promise<Phase7ChatSessionArtifact> {
|
||||
while (this.remainingScriptedTurns > 0) {
|
||||
const prompt = this.nextPrompt();
|
||||
if (prompt === null) break;
|
||||
await this.send(prompt);
|
||||
}
|
||||
return this.artifact();
|
||||
}
|
||||
|
||||
artifact(): Phase7ChatSessionArtifact {
|
||||
const timeline = [...this.recorder.timeline];
|
||||
const authorizationRecords = this.invocationRecords();
|
||||
const exposure = this.turns[0]?.exposure ?? this.exposureNow();
|
||||
const diff = phase7StateDiff(this.seedState, this.turnState);
|
||||
const parity = phase7ScenarioParity({
|
||||
entry: this.entry,
|
||||
timeline,
|
||||
authorizationRecords,
|
||||
diff,
|
||||
exposure,
|
||||
failure: this.failure,
|
||||
evalSuite: this.options.evalSuite,
|
||||
});
|
||||
return {
|
||||
schema: "paperclip.phase7.run-artifact.v1",
|
||||
scenarioId: this.entry.id,
|
||||
mode: this.options.mode ?? "fake",
|
||||
runId: this.runId,
|
||||
actor: {
|
||||
id: this.context.actor.id,
|
||||
name: this.context.actor.name,
|
||||
role: this.context.actor.role,
|
||||
capabilityGrants: [...this.context.actor.capabilityGrants],
|
||||
},
|
||||
task: {
|
||||
id: this.context.activeTask.id,
|
||||
identifier: this.context.activeTask.identifier,
|
||||
title: this.context.activeTask.title,
|
||||
workMode: this.context.activeTask.workMode,
|
||||
},
|
||||
wake: { reason: this.context.wake.reason, payload: this.context.wake.payload },
|
||||
budget: { ...this.context.budget },
|
||||
timeline,
|
||||
exposure,
|
||||
authorizationRecords,
|
||||
diff,
|
||||
parity,
|
||||
failure: this.failure,
|
||||
turns: [...this.turns],
|
||||
status:
|
||||
this.failure !== null
|
||||
? "failed"
|
||||
: this.turns.length <= 1
|
||||
? "idle"
|
||||
: "settled",
|
||||
remainingScriptedTurns: this.remainingScriptedTurns,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
await this.adapter.stop();
|
||||
}
|
||||
|
||||
private recordTurn(input: {
|
||||
turn: number;
|
||||
prompt: string | null;
|
||||
before: Phase7FixtureState;
|
||||
after: Phase7FixtureState;
|
||||
failure?: { message: string } | null;
|
||||
}): void {
|
||||
const entries = this.recorder.timeline.filter((item) => item.turn === input.turn);
|
||||
const authorizationRecords = this.invocationRecords().slice(this.authorizationCursor);
|
||||
this.authorizationCursor += authorizationRecords.length;
|
||||
const diff = phase7StateDiff(input.before, input.after);
|
||||
const failure = input.failure ?? null;
|
||||
const exposure = this.exposureNow();
|
||||
|
||||
this.turnState = input.after;
|
||||
this.turns.push({
|
||||
turn: input.turn,
|
||||
prompt: input.prompt,
|
||||
status: failure === null ? "settled" : "failed",
|
||||
firstSequence: entries[0]?.sequence ?? this.recorder.lastSequence,
|
||||
lastSequence: entries[entries.length - 1]?.sequence ?? this.recorder.lastSequence,
|
||||
exposure,
|
||||
authorizationRecords,
|
||||
diff,
|
||||
reconciliationEvents: reconciliationEvents(entries),
|
||||
parity: phase7TurnParity({
|
||||
entry: this.entry,
|
||||
timeline: entries,
|
||||
authorizationRecords,
|
||||
diff,
|
||||
exposure,
|
||||
failure,
|
||||
}),
|
||||
counts: {
|
||||
calls: entries.filter((item) => item.kind === "semantic_call").length,
|
||||
denied: entries.filter(
|
||||
(item) =>
|
||||
item.kind === "semantic_result" &&
|
||||
(item.outcome === "denied" || item.outcome === "absent"),
|
||||
).length,
|
||||
controlPlane: entries.filter((item) => item.kind === "control_plane_action").length,
|
||||
changedDomains: diff.domains.filter((domain) => domain.changed).length,
|
||||
},
|
||||
failure,
|
||||
});
|
||||
}
|
||||
|
||||
/** The exposure decision as it stands now, asked of the authorization engine. */
|
||||
private exposureNow(): Phase7Exposure {
|
||||
return buildExposure(
|
||||
this.runtime.visibleTools().authorizationRecords,
|
||||
this.entry.scenarioClaims,
|
||||
this.script.controlPlaneCapabilities,
|
||||
);
|
||||
}
|
||||
|
||||
private invocationRecords(): Phase7AuthorizationRecord[] {
|
||||
return [...this.runtime.authorizationRecords()].filter(
|
||||
(record) => record.phase === "invocation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function reconciliationEvents(
|
||||
entries: readonly Phase7TimelineEntry[],
|
||||
): Phase7ChatTurn["reconciliationEvents"] {
|
||||
return entries
|
||||
.filter(
|
||||
(item): item is Extract<Phase7TimelineEntry, { kind: "control_plane_action" }> =>
|
||||
item.kind === "control_plane_action" && phase7IsReconciliationAction(item.action),
|
||||
)
|
||||
.map((item) => ({
|
||||
sequence: item.sequence,
|
||||
action: item.action,
|
||||
summary: item.summary,
|
||||
stateRevision: item.stateRevision,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a session and plays its whole script — the deterministic artifact the
|
||||
* `replay=fake` screenshot routes and the conformance tests consume.
|
||||
*/
|
||||
export async function phase7ReplayChatSession(
|
||||
entry: Phase7ScenarioIndexEntry,
|
||||
options: Phase7ChatSessionOptions = {},
|
||||
): Promise<Phase7ChatSessionArtifact> {
|
||||
const session = await Phase7ChatSession.open(entry, options);
|
||||
try {
|
||||
return await session.replay();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,21 @@ export {
|
||||
type Phase7ScenarioPlan,
|
||||
} from "./scenario-plan.js";
|
||||
export { phase7RunScenario, type Phase7RunScenarioOptions } from "./scenario-runner.js";
|
||||
export {
|
||||
phase7ChatScript,
|
||||
type Phase7ChatScript,
|
||||
type Phase7ChatScriptTurn,
|
||||
} from "./chat-script.js";
|
||||
export {
|
||||
phase7ReplayChatSession,
|
||||
Phase7ChatSession,
|
||||
type Phase7ChatSessionOptions,
|
||||
} from "./chat-session.js";
|
||||
export { phase7StateDiff } from "./state-diff.js";
|
||||
export {
|
||||
phase7EvalSuiteLookup,
|
||||
phase7ScenarioParity,
|
||||
phase7TurnParity,
|
||||
type Phase7EvalSuiteCaseResult,
|
||||
type Phase7EvalSuiteLookup,
|
||||
} from "./scenario-parity.js";
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import type {
|
||||
Phase7AuditRecord,
|
||||
Phase7DecisionRecord,
|
||||
Phase7FixtureState,
|
||||
Phase7JsonValue,
|
||||
Phase7ScheduledWake,
|
||||
Phase7SemanticCommand,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import { phase7SemanticTool } from "../tools/phase7-semantic-tool-catalog.js";
|
||||
import type { Phase7SemanticToolRuntime } from "../tools/phase7-semantic-tool-runtime.js";
|
||||
import {
|
||||
PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS,
|
||||
type Phase7AuthorizationRecord,
|
||||
type Phase7SemanticToolDescriptor,
|
||||
type Phase7ToolInvocationResult,
|
||||
} from "../tools/phase7-semantic-tool-types.js";
|
||||
import {
|
||||
CREATED_INTERACTION_REF_PATTERN,
|
||||
CREATED_TASK_REF_PATTERN,
|
||||
OPERATION_RESULT_REF_PATTERN,
|
||||
type Phase7PlanStep,
|
||||
} from "./scenario-plan.js";
|
||||
import type {
|
||||
Phase7Exposure,
|
||||
Phase7ExposureEntry,
|
||||
Phase7RedactionChip,
|
||||
Phase7ScenarioIndexEntry,
|
||||
Phase7TimelineEntry,
|
||||
} from "./scenario-explorer-types.js";
|
||||
|
||||
/**
|
||||
* Shared scenario execution recorder.
|
||||
*
|
||||
* The Phase 7F single-shot runner and the Phase 7I chat session both drive the
|
||||
* same mock control plane and the same 7D tool runtime, and both have to record
|
||||
* the result identically — one artifact grammar, one redaction boundary, one
|
||||
* control-plane drain. That common machinery lives here so the two surfaces can
|
||||
* never drift into two different accounts of the same run.
|
||||
*
|
||||
* The recorder decides nothing. Exposure comes from the authorization engine,
|
||||
* control-plane entries come from the mock core's own audit and decision
|
||||
* records, and outcomes come from the tool runtime.
|
||||
*/
|
||||
|
||||
const SECRET_PLACEHOLDER = "fixture-secret-value-not-a-real-credential";
|
||||
|
||||
/**
|
||||
* A timeline entry before the recorder stamps its ordinal and turn. Distributed
|
||||
* over the union deliberately — a plain `Omit` would collapse the variants to
|
||||
* their shared keys and lose every payload field.
|
||||
*/
|
||||
type Phase7UnstampedEntry = Phase7TimelineEntry extends infer Variant
|
||||
? Variant extends Phase7TimelineEntry
|
||||
? Omit<Variant, "sequence" | "turn">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface Phase7RecorderOptions {
|
||||
entry: Phase7ScenarioIndexEntry;
|
||||
adapter: Phase7MockControlPlaneAdapter;
|
||||
runtime: Phase7SemanticToolRuntime;
|
||||
}
|
||||
|
||||
export class Phase7ExecutionRecorder {
|
||||
readonly timeline: Phase7TimelineEntry[] = [];
|
||||
|
||||
private readonly entry: Phase7ScenarioIndexEntry;
|
||||
private readonly adapter: Phase7MockControlPlaneAdapter;
|
||||
private readonly runtime: Phase7SemanticToolRuntime;
|
||||
private readonly createdTaskIds: string[] = [];
|
||||
private readonly createdInteractionIds: string[] = [];
|
||||
private readonly operationResultIds: string[] = [];
|
||||
private sequence = 0;
|
||||
private auditCursor = 0;
|
||||
private decisionCursor = 0;
|
||||
private wakeCursor = 0;
|
||||
private turn = 0;
|
||||
|
||||
constructor(options: Phase7RecorderOptions) {
|
||||
this.entry = options.entry;
|
||||
this.adapter = options.adapter;
|
||||
this.runtime = options.runtime;
|
||||
}
|
||||
|
||||
/** Entries recorded from here on belong to `turn`. */
|
||||
openTurn(turn: number): void {
|
||||
this.turn = turn;
|
||||
}
|
||||
|
||||
get currentTurn(): number {
|
||||
return this.turn;
|
||||
}
|
||||
|
||||
get nextSequence(): number {
|
||||
return this.sequence + 1;
|
||||
}
|
||||
|
||||
get lastSequence(): number {
|
||||
return this.sequence;
|
||||
}
|
||||
|
||||
snapshot(): Phase7FixtureState {
|
||||
return snapshotOf(this.adapter);
|
||||
}
|
||||
|
||||
instant(): string {
|
||||
return fixtureInstant(this.adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies everything the mock core recorded since the last drain into the
|
||||
* timeline. Allowed and duplicate decisions are omitted deliberately: the
|
||||
* semantic result already carries them, and repeating them would double-count
|
||||
* control-plane work that no agent tool performed.
|
||||
*/
|
||||
drainControlPlane(): void {
|
||||
const state = this.adapter.snapshot();
|
||||
for (const audit of state.audit.slice(this.auditCursor)) {
|
||||
if (audit.action === "semantic_command.applied") continue;
|
||||
this.timeline.push(this.stamp(controlPlaneEntry(audit, state.revision)));
|
||||
}
|
||||
this.auditCursor = state.audit.length;
|
||||
for (const decision of state.decisions.slice(this.decisionCursor)) {
|
||||
if (decision.outcome === "allowed" || decision.outcome === "duplicate") continue;
|
||||
this.timeline.push(this.stamp(controlPlaneDecisionEntry(decision)));
|
||||
}
|
||||
this.decisionCursor = state.decisions.length;
|
||||
for (const wake of state.wakes.slice(this.wakeCursor)) {
|
||||
this.timeline.push(this.stamp(controlPlaneWakeEntry(wake, state.revision)));
|
||||
}
|
||||
this.wakeCursor = state.wakes.length;
|
||||
}
|
||||
|
||||
pushUserMessage(text: string): void {
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "user_message",
|
||||
channel: "user",
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pushAgentMessage(text: string): void {
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "agent_message",
|
||||
channel: "agent",
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pushScriptedControlPlaneAction(
|
||||
step: Extract<Phase7PlanStep, { kind: "control_plane_action" }>,
|
||||
): void {
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: step.action,
|
||||
summary: step.summary,
|
||||
detail: step.detail,
|
||||
auditRef: null,
|
||||
decisionRef: null,
|
||||
stateRevision: this.adapter.snapshot().revision,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pushRestraintNote(forbiddenOperationCount: number): void {
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "system_note",
|
||||
channel: "system",
|
||||
note: `No further operations — ${forbiddenOperationCount} forbidden operations, none invoked.`,
|
||||
forbiddenOperationCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs one plan step and records everything it produced. */
|
||||
async runStep(step: Phase7PlanStep): Promise<void> {
|
||||
if (step.kind === "agent_message") {
|
||||
this.pushAgentMessage(step.text);
|
||||
return;
|
||||
}
|
||||
if (step.kind === "control_plane_action") {
|
||||
this.pushScriptedControlPlaneAction(step);
|
||||
return;
|
||||
}
|
||||
await this.runToolStep(step);
|
||||
this.drainControlPlane();
|
||||
}
|
||||
|
||||
private async runToolStep(step: Extract<Phase7PlanStep, { kind: "tool_call" }>): Promise<void> {
|
||||
const descriptor = phase7SemanticTool(step.operationId);
|
||||
const resolvedInput = this.resolveRefs(step.input);
|
||||
// The catalog, not the plan, decides which operations need an idempotency
|
||||
// key; plans only override the suffix when one step must differ from another.
|
||||
const suffix =
|
||||
step.idempotencySuffix ??
|
||||
(descriptor?.idempotency === "required" ? step.operationId : undefined);
|
||||
const idempotencyKey = suffix === undefined ? undefined : `phase7:${this.entry.id}:${suffix}`;
|
||||
const redactions = descriptor === undefined ? [] : redactionChips(descriptor);
|
||||
|
||||
const callSequence = this.sequence + 1;
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "semantic_call",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
disposition: descriptor?.disposition ?? "control_plane_owned",
|
||||
summary: step.summary,
|
||||
input: redactValue(resolvedInput, descriptor, "input"),
|
||||
requiredClaims: descriptor === undefined ? [] : [...descriptor.requiredClaims],
|
||||
idempotency: descriptor?.idempotency ?? "none",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
redactions,
|
||||
escapeHatch: descriptor?.sideEffectClass === "test_escape_hatch",
|
||||
authorizationSequence: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const result: Phase7ToolInvocationResult = await this.runtime.invoke({
|
||||
operationId: step.operationId,
|
||||
input: resolvedInput,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const call = this.timeline.find((candidate) => candidate.sequence === callSequence);
|
||||
if (call?.kind === "semantic_call") call.authorizationSequence = result.authorization.sequence;
|
||||
|
||||
if (result.ok) {
|
||||
this.operationResultIds.push(result.operationResultId);
|
||||
this.trackEntityRefs(result.commandResult?.entityRefs ?? []);
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "semantic_result",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
outcome:
|
||||
result.commandResult === null
|
||||
? "read"
|
||||
: result.commandResult.disposition === "duplicate"
|
||||
? "duplicate"
|
||||
: "committed",
|
||||
summary: resultSummary(step.operationId, result),
|
||||
value: redactValue(result.value, descriptor, "output"),
|
||||
failure: null,
|
||||
redactions,
|
||||
authorizationSequence: result.authorization.sequence,
|
||||
protocolEventRefs: (result.commandResult?.scheduledWakeIds ?? []).map((id) => `wake:${id}`),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "semantic_result",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
outcome: result.error.code === "operation_absent" ? "absent" : "denied",
|
||||
summary:
|
||||
result.error.code === "operation_absent"
|
||||
? `\`${step.operationId}\` is not exposed to this run.`
|
||||
: `\`${step.operationId}\` was denied — ${result.authorization.reason}.`,
|
||||
value: null,
|
||||
failure: { code: result.error.code, reason: result.authorization.reason },
|
||||
redactions,
|
||||
authorizationSequence: result.authorization.sequence,
|
||||
protocolEventRefs: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a control-plane-owned command — one the mock core performs with no
|
||||
* agent tool at all, because no semantic operation exposes it. The board
|
||||
* answering its own interaction is the canonical case, and the resulting wake
|
||||
* is scheduled by the control plane rather than requested by the agent.
|
||||
*/
|
||||
async applyControlPlaneCommand(input: {
|
||||
runId: string;
|
||||
idempotencyKey: string;
|
||||
command: Phase7SemanticCommand;
|
||||
summary: string;
|
||||
}): Promise<void> {
|
||||
const command = this.resolveRefs(
|
||||
input.command as unknown as Phase7JsonValue,
|
||||
) as unknown as Phase7SemanticCommand;
|
||||
const result = await this.adapter.applyCommand({
|
||||
runId: input.runId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
command,
|
||||
});
|
||||
this.trackEntityRefs(result.entityRefs);
|
||||
this.timeline.push(
|
||||
this.stamp({
|
||||
at: this.instant(),
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: `${command.kind}.applied`,
|
||||
summary: input.summary,
|
||||
detail: {
|
||||
kind: command.kind,
|
||||
entityRefs: [...result.entityRefs],
|
||||
scheduledWakeIds: [...result.scheduledWakeIds],
|
||||
},
|
||||
auditRef: null,
|
||||
decisionRef: null,
|
||||
stateRevision: result.stateRevision,
|
||||
}),
|
||||
);
|
||||
this.drainControlPlane();
|
||||
}
|
||||
|
||||
private resolveRefs(value: Phase7JsonValue): Phase7JsonValue {
|
||||
return resolvePlanRefs(
|
||||
value,
|
||||
this.createdTaskIds,
|
||||
this.operationResultIds,
|
||||
this.createdInteractionIds,
|
||||
);
|
||||
}
|
||||
|
||||
private trackEntityRefs(entityRefs: readonly string[]): void {
|
||||
for (const ref of entityRefs) {
|
||||
if (ref.startsWith("task:") && ref.slice(5) !== this.activeTaskId()) {
|
||||
this.createdTaskIds.push(ref.slice(5));
|
||||
}
|
||||
if (ref.startsWith("interaction:")) {
|
||||
this.createdInteractionIds.push(ref.slice("interaction:".length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private activeTaskId(): string {
|
||||
const state = this.adapter.snapshot();
|
||||
const run = state.runs.find((candidate) => candidate.id === state.activeRunId);
|
||||
return run?.taskId ?? "";
|
||||
}
|
||||
|
||||
private stamp(
|
||||
entry: Phase7UnstampedEntry,
|
||||
): Phase7TimelineEntry {
|
||||
return { ...entry, sequence: ++this.sequence, turn: this.turn } as Phase7TimelineEntry;
|
||||
}
|
||||
}
|
||||
|
||||
export const PHASE7_SECRET_PLACEHOLDER = SECRET_PLACEHOLDER;
|
||||
|
||||
export function resolvePlanRefs(
|
||||
value: Phase7JsonValue,
|
||||
createdTaskIds: readonly string[],
|
||||
operationResultIds: readonly string[],
|
||||
createdInteractionIds: readonly string[] = [],
|
||||
): Phase7JsonValue {
|
||||
const recurse = (child: Phase7JsonValue): Phase7JsonValue =>
|
||||
resolvePlanRefs(child, createdTaskIds, operationResultIds, createdInteractionIds);
|
||||
if (typeof value === "string") {
|
||||
const createdTask = CREATED_TASK_REF_PATTERN.exec(value);
|
||||
if (createdTask !== null) return createdTaskIds[Number(createdTask[1]) - 1] ?? value;
|
||||
const operationResult = OPERATION_RESULT_REF_PATTERN.exec(value);
|
||||
if (operationResult !== null) {
|
||||
return operationResultIds[Number(operationResult[1]) - 1] ?? value;
|
||||
}
|
||||
const createdInteraction = CREATED_INTERACTION_REF_PATTERN.exec(value);
|
||||
if (createdInteraction !== null) {
|
||||
return createdInteractionIds[Number(createdInteraction[1]) - 1] ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(recurse);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, recurse(child)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the descriptor's redaction rules at the artifact boundary so no raw
|
||||
* secret can reach a rendered record, even if an upstream layer returns one
|
||||
* (UX map §5: the explorer fails closed rather than receiving-and-hiding).
|
||||
*/
|
||||
export function redactValue(
|
||||
value: Phase7JsonValue,
|
||||
descriptor: Phase7SemanticToolDescriptor | undefined,
|
||||
channel: "input" | "output",
|
||||
): Phase7JsonValue {
|
||||
if (descriptor === undefined) return value;
|
||||
let redacted = value;
|
||||
for (const rule of descriptor.redaction) {
|
||||
if (!rule.appliesTo.includes(channel)) continue;
|
||||
redacted = applyRedactionPath(
|
||||
redacted,
|
||||
rule.path.replace(/^\$\.?/, "").split("."),
|
||||
rule.replacement,
|
||||
);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function applyRedactionPath(
|
||||
value: Phase7JsonValue,
|
||||
path: string[],
|
||||
replacement: string,
|
||||
): Phase7JsonValue {
|
||||
const [head, ...rest] = path;
|
||||
if (head === undefined) return replacement;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
if (!(head in value)) return value;
|
||||
return { ...value, [head]: applyRedactionPath(value[head]!, rest, replacement) };
|
||||
}
|
||||
|
||||
export function redactionChips(descriptor: Phase7SemanticToolDescriptor): Phase7RedactionChip[] {
|
||||
return descriptor.redaction.map((rule) => ({
|
||||
path: rule.path,
|
||||
replacement: rule.replacement,
|
||||
rule: `${descriptor.operationId}:${rule.path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function resultSummary(operationId: string, result: Phase7ToolInvocationResult): string {
|
||||
if (!result.ok) return `\`${operationId}\` produced no result.`;
|
||||
if (result.commandResult === null) return `\`${operationId}\` returned a read projection.`;
|
||||
const refs = result.commandResult.entityRefs.length;
|
||||
return `\`${operationId}\` ${result.commandResult.disposition} at revision ${result.commandResult.stateRevision}${
|
||||
refs === 0 ? "" : ` · ${refs} ${refs === 1 ? "entity" : "entities"}`
|
||||
}.`;
|
||||
}
|
||||
|
||||
export function buildExposure(
|
||||
records: readonly Phase7AuthorizationRecord[],
|
||||
grants: readonly string[],
|
||||
controlPlaneCapabilities: ReadonlyArray<{ operationId: string; reason: string }>,
|
||||
): Phase7Exposure {
|
||||
const always: Phase7ExposureEntry[] = [];
|
||||
const optional: Phase7ExposureEntry[] = [];
|
||||
const withheld: Phase7ExposureEntry[] = [];
|
||||
for (const record of records) {
|
||||
const descriptor = phase7SemanticTool(record.operationId);
|
||||
if (descriptor === undefined) continue;
|
||||
const entry: Phase7ExposureEntry = {
|
||||
operationId: descriptor.operationId,
|
||||
title: descriptor.title,
|
||||
requiredClaims: [...descriptor.requiredClaims],
|
||||
unlockedBy:
|
||||
descriptor.disposition === "optional_agent_tool"
|
||||
? (descriptor.requiredClaims.find((claim) => grants.includes(claim)) ?? null)
|
||||
: null,
|
||||
reason: record.reason,
|
||||
};
|
||||
if (record.outcome !== "exposed") withheld.push(entry);
|
||||
else if (descriptor.disposition === "always_agent_tool") always.push(entry);
|
||||
else optional.push(entry);
|
||||
}
|
||||
const controlPlane = controlPlaneCapabilities.filter((capability) =>
|
||||
(PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS as readonly string[]).includes(capability.operationId),
|
||||
);
|
||||
return {
|
||||
schema: "paperclip.phase7.exposure.v1",
|
||||
always,
|
||||
optional,
|
||||
controlPlane: [...controlPlane],
|
||||
withheld,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneEntry(
|
||||
audit: Phase7AuditRecord,
|
||||
stateRevision: number,
|
||||
): Phase7UnstampedEntry {
|
||||
return {
|
||||
at: audit.at,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: audit.action,
|
||||
summary: CONTROL_PLANE_COPY[audit.action] ?? `Control plane recorded \`${audit.action}\`.`,
|
||||
detail: audit.details,
|
||||
auditRef: audit.id,
|
||||
decisionRef: null,
|
||||
stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneDecisionEntry(
|
||||
decision: Phase7DecisionRecord,
|
||||
): Phase7UnstampedEntry {
|
||||
return {
|
||||
at: decision.at,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: `${decision.operation}.${decision.outcome}`,
|
||||
summary: `Control plane ${decision.outcome} \`${decision.operation}\` — ${decision.reason}. No agent tool exists for this.`,
|
||||
detail: {
|
||||
operation: decision.operation,
|
||||
reason: decision.reason,
|
||||
entityRefs: decision.entityRefs,
|
||||
},
|
||||
auditRef: null,
|
||||
decisionRef: decision.id,
|
||||
stateRevision: decision.stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneWakeEntry(
|
||||
wake: Phase7ScheduledWake,
|
||||
stateRevision: number,
|
||||
): Phase7UnstampedEntry {
|
||||
return {
|
||||
at: wake.createdAt,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: "wake.scheduled",
|
||||
summary: `Control plane scheduled a \`${wake.reason}\` wake for task ${wake.taskId} — no agent tool exists for this.`,
|
||||
detail: { id: wake.id, reason: wake.reason, dueAt: wake.dueAt, status: wake.status },
|
||||
auditRef: null,
|
||||
decisionRef: null,
|
||||
stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<string, string> = {
|
||||
"run.opened":
|
||||
"Control plane checked out the task and opened the run — no agent tool exists for this.",
|
||||
"run.event_appended": "Control plane appended a protocol event to the durable run log.",
|
||||
"run.session_checkpointed": "Control plane checkpointed the session for replay.",
|
||||
"run.completed": "Control plane reconciled the terminal run result.",
|
||||
"budget.hard_stop": "Control plane stopped the run on a hard budget limit.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Wake scheduling, blocker resolution, and terminal reconciliation, identified
|
||||
* from the recorded control-plane action rather than guessed at by the UI.
|
||||
*/
|
||||
export const PHASE7_RECONCILIATION_ACTIONS: readonly RegExp[] = [
|
||||
/^wake\./,
|
||||
/^run\.completed$/,
|
||||
/reconcile/,
|
||||
/blocker/,
|
||||
];
|
||||
|
||||
export function phase7IsReconciliationAction(action: string): boolean {
|
||||
return PHASE7_RECONCILIATION_ACTIONS.some((pattern) => pattern.test(action));
|
||||
}
|
||||
|
||||
export function snapshotOf(adapter: Phase7MockControlPlaneAdapter): Phase7FixtureState {
|
||||
return JSON.parse(adapter.serialize()) as Phase7FixtureState;
|
||||
}
|
||||
|
||||
export function fixtureInstant(adapter: Phase7MockControlPlaneAdapter): string {
|
||||
const clock = adapter.snapshot().clock;
|
||||
return new Date(clock.epochMs + clock.tick * 1_000).toISOString();
|
||||
}
|
||||
@@ -90,9 +90,21 @@ interface Phase7TimelineBase {
|
||||
sequence: number;
|
||||
/** Fixture-clock instant. Never a wall clock — see UX map §7. */
|
||||
at: string;
|
||||
/**
|
||||
* Turn this entry belongs to. Turn 0 is the control-plane session seed
|
||||
* (checkout, wake routing, initial exposure) that exists before any agent
|
||||
* or user input; turns count from 1 in artifact order. The UI groups on
|
||||
* this number and never derives turn boundaries itself (7I map §10).
|
||||
*/
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export type Phase7TimelineEntry =
|
||||
| (Phase7TimelineBase & {
|
||||
kind: "user_message";
|
||||
channel: "user";
|
||||
text: string;
|
||||
})
|
||||
| (Phase7TimelineBase & {
|
||||
kind: "agent_message";
|
||||
channel: "agent";
|
||||
@@ -263,6 +275,60 @@ export interface Phase7RunArtifact {
|
||||
failure: { message: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7I per-turn record.
|
||||
*
|
||||
* The chat surface groups evidence by turn, so every fact it groups has to be
|
||||
* produced here rather than derived in a component: the runtime owns turn
|
||||
* boundaries, per-turn exposure, the per-turn diff, and the per-turn verdict
|
||||
* (7I interaction map §10, "data contract extension").
|
||||
*/
|
||||
export interface Phase7ChatTurn {
|
||||
/** 0 is the control-plane session seed; user turns count from 1. */
|
||||
turn: number;
|
||||
/** The user text that opened the turn; null for the seed turn. */
|
||||
prompt: string | null;
|
||||
status: "running" | "settled" | "failed";
|
||||
/** Sequence range of this turn's timeline entries, inclusive. */
|
||||
firstSequence: number;
|
||||
lastSequence: number;
|
||||
exposure: Phase7Exposure;
|
||||
authorizationRecords: Phase7AuthorizationRecord[];
|
||||
diff: Phase7StateDiff;
|
||||
/**
|
||||
* Wake scheduling, blocker resolution, and terminal reconciliation recorded
|
||||
* during the turn. Always a subset of the turn's control-plane entries.
|
||||
*/
|
||||
reconciliationEvents: Array<{
|
||||
sequence: number;
|
||||
action: string;
|
||||
summary: string;
|
||||
stateRevision: number;
|
||||
}>;
|
||||
parity: Phase7ParityReport;
|
||||
counts: {
|
||||
calls: number;
|
||||
denied: number;
|
||||
controlPlane: number;
|
||||
changedDomains: number;
|
||||
};
|
||||
failure: { message: string } | null;
|
||||
}
|
||||
|
||||
export type Phase7ChatSessionStatus = "idle" | "running" | "settled" | "failed";
|
||||
|
||||
/**
|
||||
* A chat session artifact is a run artifact plus its turn decomposition. Every
|
||||
* session-level record keeps its 7F meaning so the existing explorer panels
|
||||
* render it unchanged.
|
||||
*/
|
||||
export interface Phase7ChatSessionArtifact extends Phase7RunArtifact {
|
||||
turns: Phase7ChatTurn[];
|
||||
status: Phase7ChatSessionStatus;
|
||||
/** Scripted turns still available to send, for the replay/primed hints. */
|
||||
remainingScriptedTurns: number;
|
||||
}
|
||||
|
||||
export interface Phase7SnapshotPair {
|
||||
before: Phase7FixtureState;
|
||||
after: Phase7FixtureState;
|
||||
|
||||
@@ -223,6 +223,132 @@ export function phase7ScenarioParity(input: Phase7ParityInput): Phase7ParityRepo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parity judgement for one chat turn (Phase 7I).
|
||||
*
|
||||
* A turn is judged on what that turn did, not on the scenario's whole
|
||||
* expectation set: a session-level assertion about an operation the board has
|
||||
* not asked for yet would read as a failure of turn 1 rather than as work still
|
||||
* to come. Turns that did no agent work carry no assertions and report
|
||||
* `not_run` — the seed turn is control-plane work, and saying "pass" about it
|
||||
* would be a verdict nobody made. The session verdict stays
|
||||
* `phase7ScenarioParity` over the cumulative timeline.
|
||||
*/
|
||||
export function phase7TurnParity(input: Phase7ParityInput): Phase7ParityReport {
|
||||
const { entry, timeline, authorizationRecords, diff, failure } = input;
|
||||
const calls = timeline.filter(
|
||||
(item): item is Extract<Phase7TimelineEntry, { kind: "semantic_call" }> =>
|
||||
item.kind === "semantic_call",
|
||||
);
|
||||
const results = timeline.filter(
|
||||
(item): item is Extract<Phase7TimelineEntry, { kind: "semantic_result" }> =>
|
||||
item.kind === "semantic_result",
|
||||
);
|
||||
const controlPlaneEntries = timeline.filter((item) => item.kind === "control_plane_action");
|
||||
|
||||
const forbidden = entry.forbiddenSemantics.map<Phase7ForbiddenCheck>((semantic) => {
|
||||
const candidates = FORBIDDEN_OPERATION_MAP.find(([pattern]) => pattern.test(semantic))?.[1] ?? [];
|
||||
const violating = calls.find((call) => candidates.includes(call.operationId));
|
||||
return {
|
||||
semantic,
|
||||
invoked: violating !== undefined,
|
||||
transcriptSequence: violating?.sequence ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const assertions: Phase7ParityAssertion[] = [];
|
||||
const push = (
|
||||
assertionClass: Phase7AssertionClass,
|
||||
expectation: string,
|
||||
ok: boolean,
|
||||
expected: string,
|
||||
observed: string,
|
||||
): void => {
|
||||
assertions.push({
|
||||
index: assertions.length + 1,
|
||||
assertionClass,
|
||||
expectation,
|
||||
status: ok ? "pass" : "fail",
|
||||
expected,
|
||||
observed,
|
||||
});
|
||||
};
|
||||
|
||||
if (calls.length > 0) {
|
||||
push(
|
||||
"agent_tool_contract",
|
||||
"Every semantic call in this turn returned a typed result.",
|
||||
results.length === calls.length,
|
||||
`${calls.length} typed results for ${calls.length} calls`,
|
||||
`${results.length} typed results for ${calls.length} calls`,
|
||||
);
|
||||
const covered = new Set(authorizationRecords.map((record) => record.operationId));
|
||||
const uncovered = calls.filter((call) => !covered.has(call.operationId));
|
||||
const consistent = authorizationRecords.every(
|
||||
(record) => record.outcome !== "allowed" || record.missingClaims.length === 0,
|
||||
);
|
||||
const denials = authorizationRecords.filter((record) => record.outcome !== "allowed").length;
|
||||
push(
|
||||
"authorization_policy",
|
||||
"Every invocation in this turn carries an authorization record and no allowed call had a missing claim.",
|
||||
consistent && uncovered.length === 0,
|
||||
`an authorization record for each of ${calls.length} calls, 0 allowed calls with missing claims`,
|
||||
`${authorizationRecords.length} authorization records for ${calls.length} calls · ${denials} not allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
if (controlPlaneEntries.length > 0) {
|
||||
push(
|
||||
"control_plane_invariant",
|
||||
"Control-plane work in this turn was performed by the runtime with no agent tool.",
|
||||
controlPlaneEntries.every((item) => item.channel === "control_plane"),
|
||||
`${controlPlaneEntries.length} control-plane actions attributed to the control plane`,
|
||||
`${controlPlaneEntries.length} control-plane actions attributed to the control plane`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.forbiddenSemantics.length > 0) {
|
||||
const violations = forbidden.filter((check) => check.invoked);
|
||||
push(
|
||||
"restraint_no_call",
|
||||
"No forbidden operation was invoked in this turn.",
|
||||
violations.length === 0,
|
||||
`0 of ${forbidden.length} forbidden operations invoked`,
|
||||
violations.length === 0
|
||||
? `0 of ${forbidden.length} forbidden operations invoked`
|
||||
: violations.map((check) => check.semantic).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
const changedDomains = diff.domains.filter((domain) => domain.changed).length;
|
||||
const verdict: Phase7ParityStatus =
|
||||
failure !== null || assertions.some((assertion) => assertion.status === "fail")
|
||||
? "fail"
|
||||
: assertions.length === 0
|
||||
? "not_run"
|
||||
: "pass";
|
||||
|
||||
return {
|
||||
schema: "paperclip.phase7.scenario-parity.v1",
|
||||
verdict,
|
||||
assertions,
|
||||
forbidden,
|
||||
intentionalGaps: [],
|
||||
legacyBaselineNote: `Turn-scoped verdict for ${entry.id} · ${calls.length} semantic ${
|
||||
calls.length === 1 ? "call" : "calls"
|
||||
} · ${changedDomains} changed ${
|
||||
changedDomains === 1 ? "domain" : "domains"
|
||||
}. The session verdict is judged over the whole transcript.`,
|
||||
evalSuite: {
|
||||
available: false,
|
||||
status: "not_run",
|
||||
semanticOperation: null,
|
||||
authorizationDecision: null,
|
||||
stateDiff: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an eval-suite lookup from a loaded Phase 7E parity report. */
|
||||
export function phase7EvalSuiteLookup(report: unknown): Phase7EvalSuiteLookup {
|
||||
const results =
|
||||
|
||||
@@ -46,6 +46,16 @@ export const OPERATION_RESULT_REF = (ordinal: number): string => `$operation-res
|
||||
|
||||
export const OPERATION_RESULT_REF_PATTERN = /^\$operation-result:(\d+)$/;
|
||||
|
||||
/**
|
||||
* Placeholder for an interaction the conversation itself opened. The Phase 7I
|
||||
* chat needs it so the board can answer, in a later turn, the very interaction
|
||||
* an earlier turn created — without a script ever naming a generated id.
|
||||
*/
|
||||
export const CREATED_INTERACTION_REF = (ordinal: number): string =>
|
||||
`$created-interaction:${ordinal}`;
|
||||
|
||||
export const CREATED_INTERACTION_REF_PATTERN = /^\$created-interaction:(\d+)$/;
|
||||
|
||||
export interface Phase7ScenarioPlan {
|
||||
steps: Phase7PlanStep[];
|
||||
/**
|
||||
|
||||
@@ -1,41 +1,23 @@
|
||||
import {
|
||||
Phase7MockControlPlaneAdapter,
|
||||
} from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import type {
|
||||
Phase7AuditRecord,
|
||||
Phase7DecisionRecord,
|
||||
Phase7FixtureState,
|
||||
Phase7JsonValue,
|
||||
Phase7ScheduledWake,
|
||||
} from "../mock-core/phase7-control-plane-types.js";
|
||||
import { PHASE7_SEMANTIC_TOOL_CATALOG, phase7SemanticTool } from "../tools/phase7-semantic-tool-catalog.js";
|
||||
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
|
||||
import type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js";
|
||||
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "../tools/phase7-semantic-tool-catalog.js";
|
||||
import { Phase7SemanticToolRuntime } from "../tools/phase7-semantic-tool-runtime.js";
|
||||
import {
|
||||
PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS,
|
||||
type Phase7AuthorizationRecord,
|
||||
type Phase7SemanticToolDescriptor,
|
||||
type Phase7ToolInvocationResult,
|
||||
} from "../tools/phase7-semantic-tool-types.js";
|
||||
import {
|
||||
phase7ScenarioFixture,
|
||||
type Phase7ScenarioFixture,
|
||||
} from "./scenario-fixtures.js";
|
||||
import {
|
||||
CREATED_TASK_REF_PATTERN,
|
||||
OPERATION_RESULT_REF_PATTERN,
|
||||
phase7ScenarioPlan,
|
||||
type Phase7PlanStep,
|
||||
type Phase7ScenarioPlan,
|
||||
} from "./scenario-plan.js";
|
||||
import { phase7ScenarioPlan } from "./scenario-plan.js";
|
||||
import type {
|
||||
Phase7Exposure,
|
||||
Phase7ExposureEntry,
|
||||
Phase7RedactionChip,
|
||||
Phase7RunArtifact,
|
||||
Phase7RunMode,
|
||||
Phase7ScenarioIndexEntry,
|
||||
Phase7TimelineEntry,
|
||||
} from "./scenario-explorer-types.js";
|
||||
import {
|
||||
buildExposure,
|
||||
PHASE7_SECRET_PLACEHOLDER,
|
||||
Phase7ExecutionRecorder,
|
||||
snapshotOf,
|
||||
} from "./scenario-execution.js";
|
||||
import { phase7StateDiff } from "./state-diff.js";
|
||||
import { phase7ScenarioParity, type Phase7EvalSuiteLookup } from "./scenario-parity.js";
|
||||
|
||||
@@ -48,6 +30,9 @@ import { phase7ScenarioParity, type Phase7EvalSuiteLookup } from "./scenario-par
|
||||
* mock core's own audit and decision records, the diff comes from immutable
|
||||
* fixture snapshots, and parity comes from the traceability expectations. The
|
||||
* browser is a read surface over this artifact and decides nothing.
|
||||
*
|
||||
* The recording machinery is shared with the Phase 7I chat session
|
||||
* (`scenario-execution.ts`) so a scenario reads the same either way.
|
||||
*/
|
||||
|
||||
export interface Phase7RunScenarioOptions {
|
||||
@@ -56,8 +41,6 @@ export interface Phase7RunScenarioOptions {
|
||||
evalSuite?: Phase7EvalSuiteLookup;
|
||||
}
|
||||
|
||||
const SECRET_PLACEHOLDER = "fixture-secret-value-not-a-real-credential";
|
||||
|
||||
export async function phase7RunScenario(
|
||||
entry: Phase7ScenarioIndexEntry,
|
||||
options: Phase7RunScenarioOptions = {},
|
||||
@@ -70,15 +53,10 @@ export async function phase7RunScenario(
|
||||
const adapter = new Phase7MockControlPlaneAdapter(fixture.seed);
|
||||
await adapter.start();
|
||||
|
||||
const timeline: Phase7TimelineEntry[] = [];
|
||||
let failure: Phase7RunArtifact["failure"] = null;
|
||||
let sequence = 0;
|
||||
let auditCursor = 0;
|
||||
let decisionCursor = 0;
|
||||
let wakeCursor = 0;
|
||||
const createdTaskIds: string[] = [];
|
||||
const operationResultIds: string[] = [];
|
||||
|
||||
// Snapshotted before checkout so the diff includes the control plane's own
|
||||
// seeding work, not just the agent's.
|
||||
const before = snapshotOf(adapter);
|
||||
const context = await adapter.openFixtureRun({
|
||||
identity: {
|
||||
@@ -92,7 +70,7 @@ export async function phase7RunScenario(
|
||||
sourceInstanceId: "phase7-scenario-explorer",
|
||||
wake: {
|
||||
reason: entry.wakeReason,
|
||||
payload: continuationWakePayload(entry, fixture, mode),
|
||||
payload: phase7ContinuationWakePayload(entry, fixture, mode),
|
||||
},
|
||||
capabilities: entry.scenarioClaims,
|
||||
});
|
||||
@@ -102,91 +80,43 @@ export async function phase7RunScenario(
|
||||
runId,
|
||||
scenarioGrants: entry.scenarioClaims,
|
||||
policy: entry.policy ?? undefined,
|
||||
resolveSecretValue: () => SECRET_PLACEHOLDER,
|
||||
resolveSecretValue: () => PHASE7_SECRET_PLACEHOLDER,
|
||||
});
|
||||
|
||||
const drainControlPlane = (): void => {
|
||||
const state = adapter.snapshot();
|
||||
for (const audit of state.audit.slice(auditCursor)) {
|
||||
if (audit.action === "semantic_command.applied") continue;
|
||||
timeline.push(controlPlaneEntry(++sequence, audit, state.revision));
|
||||
}
|
||||
auditCursor = state.audit.length;
|
||||
for (const decision of state.decisions.slice(decisionCursor)) {
|
||||
if (decision.outcome === "allowed" || decision.outcome === "duplicate") continue;
|
||||
timeline.push(controlPlaneDecisionEntry(++sequence, decision));
|
||||
}
|
||||
decisionCursor = state.decisions.length;
|
||||
for (const wake of state.wakes.slice(wakeCursor)) {
|
||||
timeline.push(controlPlaneWakeEntry(++sequence, wake, state.revision));
|
||||
}
|
||||
wakeCursor = state.wakes.length;
|
||||
};
|
||||
const recorder = new Phase7ExecutionRecorder({ entry, adapter, runtime });
|
||||
|
||||
drainControlPlane();
|
||||
// Turn 0 is the control-plane session seed: checkout, wake routing, and the
|
||||
// initial exposure decision all happen before the agent takes a turn.
|
||||
recorder.openTurn(0);
|
||||
recorder.drainControlPlane();
|
||||
|
||||
const visible = runtime.visibleTools();
|
||||
const exposure = buildExposure(visible.authorizationRecords, entry.scenarioClaims, plan);
|
||||
const exposure = buildExposure(
|
||||
visible.authorizationRecords,
|
||||
entry.scenarioClaims,
|
||||
plan.controlPlaneCapabilities,
|
||||
);
|
||||
|
||||
// A single-shot scenario is one agent turn over that seeded session.
|
||||
recorder.openTurn(1);
|
||||
try {
|
||||
for (const step of plan.steps) {
|
||||
if (step.kind === "agent_message") {
|
||||
timeline.push({
|
||||
sequence: ++sequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "agent_message",
|
||||
channel: "agent",
|
||||
text: step.text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (step.kind === "control_plane_action") {
|
||||
timeline.push({
|
||||
sequence: ++sequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: step.action,
|
||||
summary: step.summary,
|
||||
detail: step.detail,
|
||||
auditRef: null,
|
||||
decisionRef: null,
|
||||
stateRevision: adapter.snapshot().revision,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
sequence = await executeToolStep({
|
||||
step,
|
||||
entry,
|
||||
runtime,
|
||||
adapter,
|
||||
timeline,
|
||||
sequence,
|
||||
createdTaskIds,
|
||||
operationResultIds,
|
||||
});
|
||||
drainControlPlane();
|
||||
await recorder.runStep(step);
|
||||
}
|
||||
} catch (error) {
|
||||
failure = { message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
drainControlPlane();
|
||||
recorder.drainControlPlane();
|
||||
|
||||
if (plan.restraint) {
|
||||
timeline.push({
|
||||
sequence: ++sequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "system_note",
|
||||
channel: "system",
|
||||
note: `No further operations — ${entry.forbiddenSemantics.length} forbidden operations, none invoked.`,
|
||||
forbiddenOperationCount: entry.forbiddenSemantics.length,
|
||||
});
|
||||
recorder.pushRestraintNote(entry.forbiddenSemantics.length);
|
||||
}
|
||||
|
||||
const after = snapshotOf(adapter);
|
||||
const after = recorder.snapshot();
|
||||
await adapter.stop();
|
||||
|
||||
const timeline = recorder.timeline;
|
||||
const authorizationRecords = [...runtime.authorizationRecords()].filter(
|
||||
(record) => record.phase === "invocation",
|
||||
);
|
||||
@@ -229,140 +159,11 @@ export async function phase7RunScenario(
|
||||
});
|
||||
}
|
||||
|
||||
interface ToolStepInput {
|
||||
step: Extract<Phase7PlanStep, { kind: "tool_call" }>;
|
||||
entry: Phase7ScenarioIndexEntry;
|
||||
runtime: Phase7SemanticToolRuntime;
|
||||
adapter: Phase7MockControlPlaneAdapter;
|
||||
timeline: Phase7TimelineEntry[];
|
||||
sequence: number;
|
||||
createdTaskIds: string[];
|
||||
operationResultIds: string[];
|
||||
}
|
||||
|
||||
async function executeToolStep(input: ToolStepInput): Promise<number> {
|
||||
const { step, entry, runtime, adapter, timeline, createdTaskIds, operationResultIds } = input;
|
||||
let sequence = input.sequence;
|
||||
const descriptor = phase7SemanticTool(step.operationId);
|
||||
const resolvedInput = resolvePlanRefs(step.input, createdTaskIds, operationResultIds);
|
||||
// The catalog, not the plan, decides which operations need an idempotency
|
||||
// key; plans only override the suffix when one step must differ from another.
|
||||
const suffix =
|
||||
step.idempotencySuffix ?? (descriptor?.idempotency === "required" ? step.operationId : undefined);
|
||||
const idempotencyKey = suffix === undefined ? undefined : `phase7:${entry.id}:${suffix}`;
|
||||
const redactions = descriptor === undefined ? [] : redactionChips(descriptor);
|
||||
|
||||
const callSequence = ++sequence;
|
||||
timeline.push({
|
||||
sequence: callSequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "semantic_call",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
disposition: descriptor?.disposition ?? "control_plane_owned",
|
||||
summary: step.summary,
|
||||
input: redactValue(resolvedInput, descriptor, "input"),
|
||||
requiredClaims: descriptor === undefined ? [] : [...descriptor.requiredClaims],
|
||||
idempotency: descriptor?.idempotency ?? "none",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
redactions,
|
||||
escapeHatch: descriptor?.sideEffectClass === "test_escape_hatch",
|
||||
authorizationSequence: 0,
|
||||
});
|
||||
|
||||
const result: Phase7ToolInvocationResult = await runtime.invoke({
|
||||
operationId: step.operationId,
|
||||
input: resolvedInput,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const call = timeline.find((candidate) => candidate.sequence === callSequence);
|
||||
if (call?.kind === "semantic_call") call.authorizationSequence = result.authorization.sequence;
|
||||
|
||||
if (result.ok) {
|
||||
operationResultIds.push(result.operationResultId);
|
||||
for (const ref of result.commandResult?.entityRefs ?? []) {
|
||||
if (ref.startsWith("task:") && ref.slice(5) !== adapterActiveTaskId(adapter)) {
|
||||
createdTaskIds.push(ref.slice(5));
|
||||
}
|
||||
}
|
||||
timeline.push({
|
||||
sequence: ++sequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "semantic_result",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
outcome:
|
||||
result.commandResult === null
|
||||
? "read"
|
||||
: result.commandResult.disposition === "duplicate"
|
||||
? "duplicate"
|
||||
: "committed",
|
||||
summary: resultSummary(step.operationId, result),
|
||||
value: redactValue(result.value, descriptor, "output"),
|
||||
failure: null,
|
||||
redactions,
|
||||
authorizationSequence: result.authorization.sequence,
|
||||
protocolEventRefs: (result.commandResult?.scheduledWakeIds ?? []).map((id) => `wake:${id}`),
|
||||
});
|
||||
return sequence;
|
||||
}
|
||||
|
||||
timeline.push({
|
||||
sequence: ++sequence,
|
||||
at: fixtureInstant(adapter),
|
||||
kind: "semantic_result",
|
||||
channel: "agent",
|
||||
operationId: step.operationId,
|
||||
outcome: result.error.code === "operation_absent" ? "absent" : "denied",
|
||||
summary:
|
||||
result.error.code === "operation_absent"
|
||||
? `\`${step.operationId}\` is not exposed to this run.`
|
||||
: `\`${step.operationId}\` was denied — ${result.authorization.reason}.`,
|
||||
value: null,
|
||||
failure: { code: result.error.code, reason: result.authorization.reason },
|
||||
redactions,
|
||||
authorizationSequence: result.authorization.sequence,
|
||||
protocolEventRefs: [],
|
||||
});
|
||||
return sequence;
|
||||
}
|
||||
|
||||
function adapterActiveTaskId(adapter: Phase7MockControlPlaneAdapter): string {
|
||||
const state = adapter.snapshot();
|
||||
const run = state.runs.find((candidate) => candidate.id === state.activeRunId);
|
||||
return run?.taskId ?? "";
|
||||
}
|
||||
|
||||
function resolvePlanRefs(
|
||||
value: Phase7JsonValue,
|
||||
createdTaskIds: string[],
|
||||
operationResultIds: string[],
|
||||
): Phase7JsonValue {
|
||||
if (typeof value === "string") {
|
||||
const createdTask = CREATED_TASK_REF_PATTERN.exec(value);
|
||||
if (createdTask !== null) return createdTaskIds[Number(createdTask[1]) - 1] ?? value;
|
||||
const operationResult = OPERATION_RESULT_REF_PATTERN.exec(value);
|
||||
if (operationResult !== null) {
|
||||
return operationResultIds[Number(operationResult[1]) - 1] ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolvePlanRefs(item, createdTaskIds, operationResultIds));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
resolvePlanRefs(child, createdTaskIds, operationResultIds),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function continuationWakePayload(
|
||||
/**
|
||||
* The continuation scenario is woken with the interaction result the board
|
||||
* already supplied; every other scenario carries only its own identity.
|
||||
*/
|
||||
export function phase7ContinuationWakePayload(
|
||||
entry: Phase7ScenarioIndexEntry,
|
||||
fixture: Phase7ScenarioFixture,
|
||||
mode: Phase7RunMode,
|
||||
@@ -379,163 +180,4 @@ function continuationWakePayload(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the descriptor's redaction rules at the artifact boundary so no raw
|
||||
* secret can reach a rendered record, even if an upstream layer returns one
|
||||
* (UX map §5: the explorer fails closed rather than receiving-and-hiding).
|
||||
*/
|
||||
function redactValue(
|
||||
value: Phase7JsonValue,
|
||||
descriptor: Phase7SemanticToolDescriptor | undefined,
|
||||
channel: "input" | "output",
|
||||
): Phase7JsonValue {
|
||||
if (descriptor === undefined) return value;
|
||||
let redacted = value;
|
||||
for (const rule of descriptor.redaction) {
|
||||
if (!rule.appliesTo.includes(channel)) continue;
|
||||
redacted = applyRedactionPath(redacted, rule.path.replace(/^\$\.?/, "").split("."), rule.replacement);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function applyRedactionPath(
|
||||
value: Phase7JsonValue,
|
||||
path: string[],
|
||||
replacement: string,
|
||||
): Phase7JsonValue {
|
||||
const [head, ...rest] = path;
|
||||
if (head === undefined) return replacement;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
if (!(head in value)) return value;
|
||||
return { ...value, [head]: applyRedactionPath(value[head]!, rest, replacement) };
|
||||
}
|
||||
|
||||
function redactionChips(descriptor: Phase7SemanticToolDescriptor): Phase7RedactionChip[] {
|
||||
return descriptor.redaction.map((rule) => ({
|
||||
path: rule.path,
|
||||
replacement: rule.replacement,
|
||||
rule: `${descriptor.operationId}:${rule.path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function resultSummary(operationId: string, result: Phase7ToolInvocationResult): string {
|
||||
if (!result.ok) return `\`${operationId}\` produced no result.`;
|
||||
if (result.commandResult === null) return `\`${operationId}\` returned a read projection.`;
|
||||
const refs = result.commandResult.entityRefs.length;
|
||||
return `\`${operationId}\` ${result.commandResult.disposition} at revision ${result.commandResult.stateRevision}${
|
||||
refs === 0 ? "" : ` · ${refs} ${refs === 1 ? "entity" : "entities"}`
|
||||
}.`;
|
||||
}
|
||||
|
||||
function buildExposure(
|
||||
records: readonly Phase7AuthorizationRecord[],
|
||||
grants: readonly string[],
|
||||
plan: Phase7ScenarioPlan,
|
||||
): Phase7Exposure {
|
||||
const always: Phase7ExposureEntry[] = [];
|
||||
const optional: Phase7ExposureEntry[] = [];
|
||||
const withheld: Phase7ExposureEntry[] = [];
|
||||
for (const record of records) {
|
||||
const descriptor = phase7SemanticTool(record.operationId);
|
||||
if (descriptor === undefined) continue;
|
||||
const entry: Phase7ExposureEntry = {
|
||||
operationId: descriptor.operationId,
|
||||
title: descriptor.title,
|
||||
requiredClaims: [...descriptor.requiredClaims],
|
||||
unlockedBy:
|
||||
descriptor.disposition === "optional_agent_tool"
|
||||
? (descriptor.requiredClaims.find((claim) => grants.includes(claim)) ?? null)
|
||||
: null,
|
||||
reason: record.reason,
|
||||
};
|
||||
if (record.outcome !== "exposed") withheld.push(entry);
|
||||
else if (descriptor.disposition === "always_agent_tool") always.push(entry);
|
||||
else optional.push(entry);
|
||||
}
|
||||
const controlPlane = plan.controlPlaneCapabilities.filter((capability) =>
|
||||
(PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS as readonly string[]).includes(capability.operationId),
|
||||
);
|
||||
return {
|
||||
schema: "paperclip.phase7.exposure.v1",
|
||||
always,
|
||||
optional,
|
||||
controlPlane,
|
||||
withheld,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneEntry(
|
||||
sequence: number,
|
||||
audit: Phase7AuditRecord,
|
||||
stateRevision: number,
|
||||
): Phase7TimelineEntry {
|
||||
return {
|
||||
sequence,
|
||||
at: audit.at,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: audit.action,
|
||||
summary: CONTROL_PLANE_COPY[audit.action] ?? `Control plane recorded \`${audit.action}\`.`,
|
||||
detail: audit.details,
|
||||
auditRef: audit.id,
|
||||
decisionRef: null,
|
||||
stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneDecisionEntry(
|
||||
sequence: number,
|
||||
decision: Phase7DecisionRecord,
|
||||
): Phase7TimelineEntry {
|
||||
return {
|
||||
sequence,
|
||||
at: decision.at,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: `${decision.operation}.${decision.outcome}`,
|
||||
summary: `Control plane ${decision.outcome} \`${decision.operation}\` — ${decision.reason}. No agent tool exists for this.`,
|
||||
detail: { operation: decision.operation, reason: decision.reason, entityRefs: decision.entityRefs },
|
||||
auditRef: null,
|
||||
decisionRef: decision.id,
|
||||
stateRevision: decision.stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function controlPlaneWakeEntry(
|
||||
sequence: number,
|
||||
wake: Phase7ScheduledWake,
|
||||
stateRevision: number,
|
||||
): Phase7TimelineEntry {
|
||||
return {
|
||||
sequence,
|
||||
at: wake.createdAt,
|
||||
kind: "control_plane_action",
|
||||
channel: "control_plane",
|
||||
action: "wake.scheduled",
|
||||
summary: `Control plane scheduled a \`${wake.reason}\` wake for task ${wake.taskId} — no agent tool exists for this.`,
|
||||
detail: { id: wake.id, reason: wake.reason, dueAt: wake.dueAt, status: wake.status },
|
||||
auditRef: null,
|
||||
decisionRef: null,
|
||||
stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<string, string> = {
|
||||
"run.opened":
|
||||
"Control plane checked out the task and opened the run — no agent tool exists for this.",
|
||||
"run.event_appended": "Control plane appended a protocol event to the durable run log.",
|
||||
"run.session_checkpointed": "Control plane checkpointed the session for replay.",
|
||||
"run.completed": "Control plane reconciled the terminal run result.",
|
||||
"budget.hard_stop": "Control plane stopped the run on a hard budget limit.",
|
||||
};
|
||||
|
||||
function snapshotOf(adapter: Phase7MockControlPlaneAdapter): Phase7FixtureState {
|
||||
return JSON.parse(adapter.serialize()) as Phase7FixtureState;
|
||||
}
|
||||
|
||||
function fixtureInstant(adapter: Phase7MockControlPlaneAdapter): string {
|
||||
const clock = adapter.snapshot().clock;
|
||||
return new Date(clock.epochMs + clock.tick * 1_000).toISOString();
|
||||
}
|
||||
|
||||
export const PHASE7_CATALOG_SIZE = PHASE7_SEMANTIC_TOOL_CATALOG.length;
|
||||
@@ -53,6 +53,7 @@
|
||||
--pcr-rail-width: 17rem;
|
||||
--pcr-center-width: 48rem;
|
||||
--pcr-inspector-width: 24rem;
|
||||
--pcr-activity-width: 26rem;
|
||||
--pcr-transcript-height: 34rem;
|
||||
--pcr-touch-target: 2.75rem;
|
||||
--pcr-facet-height: 6rem;
|
||||
|
||||