feat(runner): extract Phase 5 browser SDK

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 07:42:57 +00:00
1 parent 30a5e3c38d
commit cc945e4c72
94 files changed
+11728 -28

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
/dist-browser/
/dist-phase5/
/playwright-report/
/test-results/
+19 -6
View File
@@ -20,9 +20,12 @@ keeps Codex authentication server-side. The Phase 4b live console adds the
browser tracer over that boundary: a live transcript and composer, same-turn
steering, three distinct interrupt races, inline request cards,
capability-gated goal controls, parent/child lineage, reconnect, refresh,
replay, and a redacted protocol inspector.
replay, and a redacted protocol inspector. Phase 5 extracts that accepted
transport, reducer projection, hook, components, styles, and reference console
into versioned public subpaths, then proves the surface with a second minimal
consumer.
## Phase 0–4b quick start
## Phase 0–5 quick start
From the repository root:
@@ -67,6 +70,9 @@ pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --p
# Phase 4b: chat with a live session in the browser.
pnpm --filter @paperclipai/paperclip-runner console:phase4b
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
# Phase 5: open the public-SDK reference console and mini consumer.
pnpm --filter @paperclipai/paperclip-runner console:phase5
```
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
@@ -98,9 +104,13 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
| `demo:phase4b` | Start the package-local HTTP/SSE server with server-only Codex authentication. |
| `console:phase4b` | Start the standalone browser devtool with the Phase 4b live console on `127.0.0.1:4180`. |
| `record:phase4b` | Run a safe real Codex task through the demo server and record reconnect/replay evidence. |
| `console:phase5` | Start the public-SDK reference console and mini consumer on `127.0.0.1:4181`. |
| `test:phase5` | Run targeted browser-client, reducer-projection, and React component contract tests. |
| `test:browser:phase5` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
| `record:phase5:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
| `browser:dev` | Start the standalone live/replay/recovery browser devtool. |
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
| `verify` | Run the complete deterministic Phase 0 through Phase 4b acceptance sequence. |
| `verify` | Run the complete deterministic Phase 0 through Phase 5 acceptance sequence. |
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
## Navigate
@@ -119,6 +129,9 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
- [Phase 4b protocol/server reference](docs/phase-04b-protocol-server.md)
- [Phase 4b live console tutorial](docs/tutorials/phase-04b-live-console.md)
- [Phase 4b live console reference](docs/phase-04b-live-console.md)
- [Phase 5 SDK console tutorial](docs/tutorials/phase-05-sdk-console.md)
- [Phase 5 browser SDK reference](docs/phase-05-sdk.md)
- [Phase 5 component decision record](docs/design/phase-5-component-decisions.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
- [Journal guide](docs/journal.md)
@@ -126,6 +139,6 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
Phase 4 adds the package-local real-model reference driver and Phase 4b adds
the package-local browser console over it. Production Paperclip integration and
Phase 5 remain deferred.
Phase 4 adds the package-local real-model reference driver, Phase 4b adds the
package-local browser console, and Phase 5 extracts a reusable public SDK plus
two standalone consumers. Production Paperclip integration remains deferred.
@@ -0,0 +1,70 @@
# Phase 5 SDK Extraction Decision Record
Date: 2026-08-08
Status: implemented from the approved [Phase 5 component plan](phase-5-component-plan.md)
## Outcome
The accepted Phase 4b transport and reducer contracts were copied into a
versioned `0.1.0` public surface. The reference console and a deliberately
small second consumer import only public package subpaths. Phase 4b and the
Phase 1–3 surfaces remain the comparison baseline.
## Promoted contracts
The implementation promotes the framework-free client, `useRunnerConsole`,
all approved primitives and protocol views, and `RunnerConsoleApp`. Reducer
state and canonical events remain the only rendering authority. File, tool,
plan, terminal, failure, request, goal, lineage, connection, and replay views
do not maintain private protocol state.
The package exports `.`, `./browser`, `./react`, and `./styles.css`. React and
React DOM are peers; the surface adds zero runtime dependencies.
## Extension surface
The implementation has exactly the five approved extension points:
1. item-body renderer;
2. request-detail renderer;
3. Composer leading and trailing slots;
4. scoped `--pcr-*` token overrides;
5. Fetch/EventSource/base-URL transport injection.
The mini consumer demonstrates all five. No new extension registry or
headless contract was added during implementation.
## Kept out
Markdown `Response`, highlighted `CodeBlock`, a persistent Context meter, a
command palette, headless distribution, dark mode, Tailwind/Radix, toasts,
and transcript virtualization remain rejected for the reasons in the approved
plan. Default text and `<pre>` output keeps protocol evidence inspectable;
consumers can opt into rich item bodies through the one renderer contract.
## Implementation findings
- Event IDs must not be deduplicated by the hook. Exact duplicate delivery is
reducer input, so the shared reducer remains the sole duplicate authority.
- Node types are required only for browser-test and Vite configuration. They
are not part of the browser runtime or public dependency surface.
- Token enforcement must inspect CSS custom-property definitions as well as
component usage. The `pcr-` namespace is enforced across SDK sources.
- The reference console renders one React tree selected at the 900px
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
live regions.
- A real provider may explicitly disable goals. The consumer gates buttons on
that capability and preserves the upstream diagnostic instead of emulating
a goal.
- Real-provider steering timing is not a stable visual fixture. Fake-driver
tests own race coverage; a safe real completion owns transport, identity,
redaction, reconnect, and replay evidence.
- Minimal hosts may lack Playwright shared libraries. The package rootless
helper runs the same browser commands from a run-owned cache.
## Compatibility rule
`0.1.0` is the first frozen SDK surface. Any later removal or semantic change
to an export, hook field, component prop, `data-slot`, extension point, or
token needs a versioned compatibility decision. Additive protocol fields stay
forward-compatible; the schema and shared reducer remain authoritative.
+5 -2
View File
@@ -12,6 +12,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
- [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.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
@@ -23,12 +24,14 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
- [Phase 4b live console](phase-04b-live-console.md)
- [Phase 5 browser SDK and reference console](phase-05-sdk.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)
- [Phase 4b component decision record (shadcn/ui, AI Elements)](design/phase-4b-component-decisions.md)
- [Phase 5 component and SDK surface plan](design/phase-5-component-plan.md)
- [Phase 5 SDK extraction decision record](design/phase-5-component-decisions.md)
- [Package README](../README.md)
Phase 4b is implemented and accepted. Phase 5 (SDK extraction and reference
console) has an approved UX component plan; implementation is pending.
Phase 5 is implemented under this standalone package boundary. Production
Paperclip UI integration remains deferred.
@@ -0,0 +1,153 @@
# Phase 5 Browser SDK and Reference Console
## Public package surface
Phase 5 freezes the first browser SDK as package version `0.1.0`.
| Import | Purpose |
| --- | --- |
| `@paperclipai/paperclip-runner` | Protocol, reducer, and existing package contracts |
| `@paperclipai/paperclip-runner/browser` | Framework-free HTTP/SSE client, protocol types, transcript projection |
| `@paperclipai/paperclip-runner/react` | Hook, reference console, and reusable React components |
| `@paperclipai/paperclip-runner/styles.css` | Scoped light-theme token and component styles |
React and React DOM are peer dependencies. The extracted surface adds no
runtime dependency. It is client-only: it uses `window`, `sessionStorage`,
Fetch, and EventSource, so applications must mount it in the browser rather
than render it on the server.
## Framework-free client
```ts
import { createRunnerClient } from "@paperclipai/paperclip-runner/browser";
const client = createRunnerClient({ baseUrl: "/api/phase4b" });
const manifests = await client.fetchManifests();
```
`RunnerClient` exposes manifest listing; session create/read/close; event
history and SSE streaming; turn start, steering, and interrupt; typed request
resolution; goal operations; and reconnect. `RunnerClientError` preserves the
HTTP status, stable error code, and redacted server message.
The protocol server remains authoritative. The browser client never creates a
second event model, invents capabilities, changes run/session/provider
identities, or removes duplicate events before the shared reducer sees them.
## React hook
```tsx
import { useRunnerConsole } from "@paperclipai/paperclip-runner/react";
function Console() {
const runner = useRunnerConsole({ baseUrl: "/api/phase4b" });
return <p>{runner.connection}: {runner.replayParity ? "match" : "waiting"}</p>;
}
```
`useRunnerConsole` returns manifests and selection, canonical server state,
the exact event list, reducer snapshot, transcript projection, connection and
retry state, composer state, steering acknowledgements, a single polite
announcement channel, errors, busy state, lineage selection, replay controls,
and verbs for every supported mutation. Durable history is re-read before a
reconnected stream opens. Replay uses the same reducer as live state.
## Components
The `./react` export includes:
- primitives: `Button`, `Badge`, `Card`, `Textarea`, `Tabs`, `Menu`, `Dialog`,
`Tooltip`, and `Banner`;
- protocol views: `Conversation`, `Message`, `ReasoningItem`, `ToolItem`,
`RequestCard`, `SessionTimeline`, `Inspector`, `ReplayControls`, and
`ConnectionBanner`;
- composition: `Composer`, `RunnerConsoleApp`, and `useRunnerConsole`.
Components take reducer/protocol shapes. Every component owns a stable
`data-slot` and `pcr-` class. Terminal, file, tool, plan, request, failure,
lineage, goal, connection, and replay states come only from canonical events
or public session state.
## Five extension points
These are the complete extension surface for `0.1.0`:
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
`renderItemBody(item)` for markdown, highlighting, or another item body.
2. `RequestCard` accepts `renderRequestDetail(request)` for its detail region.
3. `Composer` accepts `leadingActions` and `trailingActions`.
4. Consumers may override `--pcr-*` properties under `.pcr-root`.
5. `createRunnerClient` and `useRunnerConsole` accept `baseUrl`, `fetchImpl`,
and `eventSourceFactory` transport injection.
There is no headless distribution, component registry, render-prop shell,
dark theme, toast layer, or virtualized transcript in this version.
## Transport and credential boundary
```tsx
const runner = useRunnerConsole({
baseUrl: "/runner",
fetchImpl: (input, init) => fetch(input, {
...init,
headers: { ...Object.fromEntries(new Headers(init?.headers)), "x-app-session": appSession },
}),
eventSourceFactory: (url) => new EventSource(url),
});
```
Transport injection is for browser-to-protocol-server authentication, proxies,
and test doubles. Provider credentials stay on that server. Do not place a
provider bearer token in component props, manifest text, events, diagnostics,
browser storage, or an EventSource query string. The package demo fixes and
validates its workspace server-side and redacts provider paths and credentials.
## Tokens and contrast
`styles.css` declares a light-only `color-scheme` and all visual values under
`.pcr-root`. The shipped foreground/surface pairs are:
| Foreground | Surface | Contrast |
| --- | --- | ---: |
| `--pcr-foreground` | `--pcr-background` | 16.4:1 |
| `--pcr-card-foreground` | `--pcr-card` | 17.6:1 |
| `--pcr-primary-foreground` | `--pcr-primary` | 15.6:1 |
| `--pcr-muted-foreground` | `--pcr-muted` | 5.0:1 |
| `--pcr-success` | `--pcr-success-surface` | 6.5:1 |
| `--pcr-warning` | `--pcr-warning-surface` | 5.4:1 |
| `--pcr-danger` | `--pcr-danger-surface` | 6.2:1 |
| `--pcr-accent` | `--pcr-accent-surface` | 5.5:1 |
When a consumer overrides either side of a pair, that consumer owns a new
contrast check. Keep normal text at 4.5:1 or better. Motion respects
`prefers-reduced-motion`. All mobile controls use the `--pcr-touch-target`
minimum.
Minimum supported widths are 320px for `Conversation`, `Composer`, and
`Inspector`, and 280px for `RequestCard`. `RunnerConsoleApp` renders one
responsive tree: three panes above 900px and one selected pane below it.
## Keyboard and accessibility contract
- The transcript is a labelled `role="log"`; consumers render the hook's
`announcement` once in an `aria-live="polite"` region.
- Composer Enter sends or steers; Shift+Enter adds a line. Stop remains a
separate button.
- Tabs use arrow-key roving focus plus Home/End.
- Menu supports Enter/Space/ArrowDown, arrows, Home/End, and Escape with focus
restoration.
- The native dialog traps focus, closes on Escape, and restores its trigger.
- Reasoning and tool disclosures use native summary keyboard behavior.
- Replay Left/Right steps; Space toggles play from the scrubber.
- Status is always named in text, not communicated by color alone.
- Informational banners do not steal focus. A blocking reconnect failure may.
## Reference applications
`examples/reference-console/` composes `RunnerConsoleApp` only from public
exports. `examples/mini-consumer/` imports only the three public SDK subpaths
and visibly exercises every extension point. Both run against the package fake
driver or the real Codex driver through the same server-only adapter.
See the [hand-run tutorial](tutorials/phase-05-sdk-console.md) and the
[implementation decision record](design/phase-5-component-decisions.md).
@@ -3,7 +3,7 @@
## What this tutorial is
This tutorial combines each implemented Native Runner phase into one procedure.
It currently includes Phase 0 through the Phase 4b protocol/server layer.
It currently includes Phase 0 through the Phase 5 browser SDK layer.
## What this tutorial proves
@@ -18,6 +18,8 @@ replay modes. It also includes the Rust outbound WebSocket client, durable
outbox, package-local mock core, recovery CLI, and recovery browser view.
The final phase adds a skillless task envelope, direct app-server driver,
semantic completion tools, and the same reducer/replay proof used by fixtures.
Phase 5 freezes that browser transport and reducer projection as public SDK
subpaths, then proves them with a reference console and a second consumer.
## Current end-to-end path
@@ -54,6 +56,12 @@ semantic completion tools, and the same reducer/replay proof used by fixtures.
is rejected, and reconnect keeps the same run and session identities.
24. Open the [Phase 4b protocol/server journal](../../knowledge/journal/2026-08-08-phase-04b-protocol-server.md)
and its linked deterministic and real-Codex evidence.
25. Follow [Phase 5: Run the SDK Console and Mini Consumer](phase-05-sdk-console.md).
26. Run the fake lifecycle in both consumers, then confirm the mini consumer
reaches `Replay parity: match` after reconnect and replay.
27. Run the safe real-Codex browser smoke and inspect the Phase 5 screenshots.
28. Open the [Phase 5 journal](../../knowledge/journal/2026-08-08-phase-05-sdk.md)
and its linked package-acceptance evidence.
The one-command form after installation is:
@@ -134,6 +142,14 @@ pnpm --filter @paperclipai/paperclip-runner verify:rootless
transcript from the durable cursor;
- no provider credential reaches the browser DOM, and adapted components add no
new runtime dependency.
- the browser and React contracts are versioned package subpaths with React as
a peer and no new runtime dependency;
- the reference console and mini consumer import public APIs only;
- exactly five extension points cover item bodies, request details, Composer
actions, token theming, and transport injection;
- duplicate canonical events reach the shared reducer unchanged;
- both consumers preserve identity through reconnect and reduce replay to the
same final state.
## Step 6: Chat with a live session in the browser
@@ -147,5 +163,13 @@ state above from the eleven deterministic demo chats. Add
`PAPERCLIP_PHASE4B_DRIVER=codex` to run the identical screens against a real
Codex session.
Phase 5 remains gated on the complete Phase 4b review, QA, live-service,
and human-checkpoint evidence.
## Step 7: Run the reusable SDK consumers
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase5
```
Open `http://127.0.0.1:4181/reference-console/` and
`http://127.0.0.1:4181/mini-consumer/`. Follow the
[Phase 5 tutorial](phase-05-sdk-console.md) for the deterministic lifecycle,
real-Codex smoke, keyboard checks, and package acceptance command.
@@ -0,0 +1,131 @@
# Phase 5: Run the SDK Console and Mini Consumer
## What this phase is
Phase 5 makes the accepted browser tracer reusable. It has a browser client,
a React hook, reusable components, one reference console, and one small second
consumer.
The two apps use public package imports. They do not import the old demo UI.
Provider credentials stay in the local server.
## What this phase proves
This tutorial proves that a second app can use the SDK without a private
import. It also proves that live events and replay events reduce to the same
state. You can run every hard state with the fake driver. You can then run a
safe turn with your real local Codex login.
Run commands from the repository root.
## 1. Run the small SDK tests
```sh
pnpm --filter @paperclipai/paperclip-runner test:phase5
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
These tests check reducer parity, components, tokens, and package boundaries.
## 2. Open both apps with the fake driver
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase5
```
Open these pages:
- `http://127.0.0.1:4181/reference-console/`
- `http://127.0.0.1:4181/mini-consumer/`
The fake driver is the default. It does not need a provider login.
## 3. Check the reference console
Use the manifest list. Run these flows:
1. Run **Completion**. Read the streaming and terminal messages.
2. Run **Same-turn steering**. Type a new message while the turn is active.
The button says **Steer**. Check the acknowledgement.
3. Run each interrupt flow. Confirm that the visible outcome names the race.
4. Run **Command and file approvals**. Resolve one request. Confirm that the
card locks after the first click and later shows the canonical result.
5. Run **Goal lifecycle**. Set and clear the goal from the Goal menu.
6. Press **Drop connection**. Watch reconnect and gap recovery use the same
session.
7. Enter replay. Step with Left and Right. Confirm the inspector says the
replay matches live state.
8. Run **Item and turn failure**. Confirm that the exact server diagnostic is
part of the transcript.
Resize the browser to 390 by 844. The app must show one pane at a time. It must
not create a second hidden transcript or a horizontal page scrollbar.
## 4. Check the mini consumer
The mini app is intentionally small. Run a turn, steer it, stop it, resolve a
request, set and clear a goal, drop the connection, and enter replay.
Look for these proof points:
- **Mini renderer** shows the custom item-body renderer.
- **Mini detail** shows the custom request-detail renderer.
- Set goal and Clear goal use the two Composer slots.
- The blue accent is a local `--pcr-*` token override.
- Fetch and EventSource are injected by the app.
- **Replay parity: match** appears after durable history is complete.
If the real driver says goals are unavailable, the two goal buttons stay
disabled. This is correct. The SDK must not pretend the provider supports a
feature.
## 5. Run the browser acceptance tests
On a machine with Playwright system libraries:
```sh
pnpm --filter @paperclipai/paperclip-runner test:browser:phase5
```
On a minimal Debian or Ubuntu machine without root access:
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
```
This runs keyboard, accessibility, reconnect, gap-recovery, replay, fake
driver, viewport measurement, and screenshot checks.
## 6. Run a real Codex session
Use a machine where the local Codex CLI is already authenticated.
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
```
The test runs a safe completion through both public consumers. It checks that
the browser contains no bearer token or `auth.json` path. The mini consumer
then drops and reconnects the transport, enters replay, and confirms parity.
Real provider timing is not used to prove every race. The deterministic fake
driver proves steering, interrupt, requests, and goal branches. The real run
proves the same public transport and identity boundary against Codex.
## 7. Run the package acceptance command
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Use `verify:rootless` if the host needs package-local browser libraries.
## Expected result
All commands exit with status 0. The fake and real apps use public imports.
Reconnect keeps run, session, and provider identity. Replay reports `match`.
Provider credentials do not appear in browser state, DOM text, events, or
diagnostics.
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paperclip Runner mini consumer</title>
</head>
<body><div id="root"></div><script type="module" src="/mini-consumer/src/main.tsx"></script></body>
</html>
@@ -0,0 +1,97 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import type {
EventSourceLike,
SessionItemSnapshot,
} from "@paperclipai/paperclip-runner/browser";
import {
Composer,
ConnectionBanner,
Conversation,
ReplayControls,
RequestCard,
useRunnerConsole,
} from "@paperclipai/paperclip-runner/react";
import "@paperclipai/paperclip-runner/styles.css";
import "./mini.css";
const injectedFetch: typeof fetch = (input, init) => window.fetch(input, init);
const injectedEventSource = (url: string): EventSourceLike => new window.EventSource(url);
function MiniConsumer() {
// This pass-through wrapper is deliberate: the second consumer proves the
// fifth extension point without adding provider credentials to component
// props or browser state.
const console_ = useRunnerConsole({
baseUrl: "/api/phase4b",
fetchImpl: injectedFetch,
eventSourceFactory: injectedEventSource,
});
const [draft, setDraft] = React.useState("");
const requests = console_.transcript.filter((entry) => entry.kind === "request");
const conversation = console_.transcript.filter((entry) => entry.kind !== "request");
const customItemBody = (item: SessionItemSnapshot) => (
<p className="pcr-mini-custom-body"><strong>Mini renderer:</strong> {item.text}</p>
);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
return (
<main className="pcr-root pcr-mini-theme" data-slot="mini-consumer" data-testid="mini-consumer">
<header className="pcr-mini-header">
<p className="pcr-eyebrow">Second public-API consumer</p>
<h1>Runner mini consumer</h1>
<span className="pcr-mini-theme-proof" data-testid="mini-token-override">Mini blue token theme</span>
<select data-testid="mini-manifest" aria-label="Demo manifest" value={console_.selectedManifestId} onChange={(event) => console_.selectManifest(event.target.value)}>
{console_.manifests.map((manifest) => <option key={manifest.id} value={manifest.id}>{manifest.name}</option>)}
</select>
<button data-testid="mini-start" type="button" onClick={() => { const manifest = console_.manifests.find((entry) => entry.id === console_.selectedManifestId); void console_.start({ manifestId: console_.selectedManifestId, message: manifest?.prompt ?? "Run the SDK flow." }); }}>Start selected flow</button>
</header>
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
<Conversation entries={conversation} renderItemBody={customItemBody} />
<ul className="pcr-mini-steering" aria-label="Steering acknowledgements">
{console_.steeringChips.map((chip) => (
<li key={chip.id} data-testid="mini-steering-chip" data-status={chip.status}>
<strong>{chip.status}</strong> {chip.text}
</li>
))}
</ul>
<ol className="pcr-mini-requests" aria-label="Runtime requests">
{requests.map((request) => (
<RequestCard key={request.key} request={request} onResolve={(resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)} renderRequestDetail={(current) => <pre className="pcr-payload">Mini detail: {JSON.stringify(current.details, null, 2)}</pre>} />
))}
</ol>
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
leadingActions={<button data-testid="mini-goal-set" type="button" disabled={console_.state?.capabilities.goals !== true} onClick={() => void console_.goal("set", { objective: "Exercise the public SDK." })}>Set goal</button>}
trailingActions={<button data-testid="mini-goal-clear" type="button" disabled={console_.state?.capabilities.goals !== true || console_.state.goal === null} onClick={() => void console_.goal("clear")}>Clear goal</button>}
/>
<nav className="pcr-mini-actions" aria-label="SDK lifecycle controls">
<button data-testid="mini-drop-connection" type="button" onClick={console_.dropConnection}>Drop connection</button>
<button data-testid="mini-recover-gap" type="button" onClick={console_.retryNow}>Recover gap</button>
<button data-testid="mini-toggle-replay" type="button" disabled={console_.events.length === 0} onClick={console_.replay.active ? console_.replay.exit : console_.replay.enter}>{console_.replay.active ? "Exit replay" : "Enter replay"}</button>
</nav>
<p data-testid="mini-goal-state">
Goal: {console_.state?.goal?.objective ?? "none"}
</p>
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
<p className="pcr-mini-parity" data-testid="mini-replay-parity">Replay parity: {console_.replayParity === null ? "waiting" : console_.replayParity ? "match" : "mismatch"}</p>
<p className="pcr-visually-hidden" aria-live="polite" data-testid="mini-announcement">{console_.announcement}</p>
</main>
);
}
const root = document.getElementById("root");
if (root === null) throw new Error("Mini consumer root is missing.");
createRoot(root).render(<React.StrictMode><MiniConsumer /></React.StrictMode>);
@@ -0,0 +1,70 @@
.pcr-mini-theme {
--pcr-accent: oklch(0.52 0.14 250);
--pcr-accent-foreground: oklch(0.99 0 0);
max-width: 48rem;
min-height: 100vh;
margin: 0 auto;
padding: var(--pcr-space-5);
}
.pcr-mini-header,
.pcr-mini-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--pcr-space-3);
}
.pcr-mini-header button,
.pcr-mini-actions button,
.pcr-mini-theme button {
min-height: var(--pcr-touch-target);
padding: var(--pcr-space-2) var(--pcr-space-3);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-sm);
background: var(--pcr-card);
color: var(--pcr-foreground);
cursor: pointer;
}
.pcr-mini-theme button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pcr-mini-custom-body {
color: var(--pcr-accent);
}
.pcr-mini-theme-proof {
padding: var(--pcr-space-2) var(--pcr-space-3);
border-radius: var(--pcr-radius-sm);
background: var(--pcr-accent);
color: var(--pcr-accent-foreground);
font-weight: var(--pcr-weight-semibold);
}
.pcr-mini-requests {
display: grid;
gap: var(--pcr-space-3);
padding: 0;
list-style: none;
}
.pcr-mini-steering {
display: grid;
gap: var(--pcr-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.pcr-mini-steering li {
padding: var(--pcr-space-2);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-sm);
}
.pcr-mini-parity {
font-family: var(--pcr-font-mono);
}
@@ -0,0 +1,56 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
const evidenceRoot = resolve(
fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)),
);
mkdirSync(evidenceRoot, { recursive: true });
test("runs the reference console through the real Codex driver", async ({ page }) => {
await page.goto("/reference-console/");
await expect(page.getByTestId("manifest-completion")).toBeVisible();
await page.getByTestId("manifest-completion").click();
await page.getByTestId("run-manifest").click();
await expect(page.locator("body")).toContainText("Turn completed", { timeout: 180_000 });
await expect(page.getByText("Credentials in browser").locator("..")).toContainText("none");
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
expect(body).not.toContain("auth.json");
await page.screenshot({
path: resolve(evidenceRoot, "reference-live-codex-1440x900.png"),
});
});
test("runs the mini consumer through real session, goal, reconnect, and replay paths", async ({
page,
}) => {
await page.goto("/mini-consumer/");
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
await page.getByLabel("Demo manifest").selectOption("completion");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 180_000 });
if (await page.getByTestId("mini-goal-set").isEnabled()) {
await page.getByTestId("mini-goal-set").click();
await expect(page.getByTestId("mini-goal-state")).toContainText(
"Exercise the public SDK.",
{ timeout: 30_000 },
);
await page.getByTestId("mini-goal-clear").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("none", {
timeout: 30_000,
});
}
await page.getByTestId("mini-drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("mini-toggle-replay").click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByTestId("replay-position").press("ArrowRight");
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
await page.screenshot({ path: resolve(evidenceRoot, "mini-live-codex-1440x900.png") });
});
@@ -0,0 +1,222 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
const evidenceRoot = resolve(fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)));
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
mkdirSync(evidenceRoot, { recursive: true });
async function screenshotPair(page: Page, name: string, mobileSegment = "chat") {
await page.setViewportSize(DESKTOP);
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-1440x900.png`) });
await page.setViewportSize(MOBILE);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
const segment = page.getByRole("tab", { name: new RegExp(`^${mobileSegment}$`, "i") });
await segment.click();
await expect(page.locator('[data-slot="runner-console-app"]')).toBeVisible();
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-390x844.png`) });
await page.setViewportSize(DESKTOP);
}
async function miniScreenshotPair(page: Page, name: string) {
await page.setViewportSize(DESKTOP);
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-1440x900.png`) });
await page.setViewportSize(MOBILE);
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-390x844.png`) });
await page.setViewportSize(DESKTOP);
}
async function openReference(page: Page) {
await page.setViewportSize(DESKTOP);
await page.goto("/reference-console/");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByTestId("manifest-list")).toBeVisible();
}
async function runManifest(page: Page, id: string) {
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
}
test.describe("Phase 5 reference console evidence", () => {
test("captures idle and streaming states at both canonical viewports", async ({ page }) => {
await openReference(page);
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await screenshotPair(page, "idle-empty");
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await screenshotPair(page, "streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
expect(await page.locator("body").innerText()).not.toContain("Bearer ");
expect(await page.locator("body").innerText()).not.toContain("auth.json");
});
test("captures steering and interrupt states", async ({ page }) => {
await openReference(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await page.getByTestId("composer-input").fill("Keep it short.");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
await screenshotPair(page, "steering-acknowledged");
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "interrupt-during-generation");
await expect(page.getByTestId("runner-transcript")).toContainText("The tracer keeps", { timeout: 15_000 });
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await screenshotPair(page, "interrupt");
});
test("captures request, goal, reconnect, replay, and failure states", async ({ page }) => {
await openReference(page);
await runManifest(page, "approvals");
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await screenshotPair(page, "request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await screenshotPair(page, "request-resolved");
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "goals");
await page.getByRole("button", { name: "Goal", exact: true }).click();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await page.getByTestId("goal-objective").fill("Prove the SDK lifecycle.");
await page.getByTestId("goal-set-confirm").click();
await expect(page.getByTestId("goal-banner")).toContainText("Prove the SDK lifecycle.");
await screenshotPair(page, "goal-set", "session");
await page.setViewportSize(DESKTOP);
await page.getByTestId("drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await screenshotPair(page, "reconnect-banner");
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 10_000 });
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByRole("button", { name: "Step forward" }).click();
await screenshotPair(page, "replay-mode");
await page.setViewportSize(DESKTOP);
await page.getByTestId("toggle-replay").click();
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "failure");
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
await screenshotPair(page, "failed-turn");
});
});
test.describe("Phase 5 mini consumer evidence", () => {
test("uses public render, token, request, reconnect, and replay extension points", async ({ page }) => {
await page.goto("/mini-consumer/");
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
await miniScreenshotPair(page, "idle");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByText("Mini renderer:").first()).toBeVisible({ timeout: 15_000 });
await miniScreenshotPair(page, "streaming-custom-renderer-token");
await page.getByTestId("composer-input").fill("Keep the mini answer short.");
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("mini-steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByLabel("Demo manifest").selectOption("interrupt-during-generation");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Demo manifest").selectOption("approvals");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await miniScreenshotPair(page, "request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await page.getByTestId("mini-goal-set").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("Exercise the public SDK.", { timeout: 15_000 });
await page.getByTestId("mini-goal-clear").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("none", { timeout: 15_000 });
await page.getByRole("button", { name: "Drop connection" }).click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await miniScreenshotPair(page, "reconnect");
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 10_000 });
await page.getByRole("button", { name: "Enter replay" }).click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByTestId("replay-position").press("ArrowRight");
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
await miniScreenshotPair(page, "replay");
});
});
test("meets keyboard, accessibility, responsive, and grid contracts", async ({ page }) => {
await openReference(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("composer-input").fill("first");
await page.getByTestId("composer-input").press("Shift+Enter");
await expect(page.getByTestId("composer-input")).toHaveValue("first\n");
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Events" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
const goalMenu = page.getByRole("button", { name: "Goal", exact: true });
await goalMenu.focus();
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menu")).toBeVisible();
await expect(page.getByRole("menuitem").first()).toBeFocused();
await page.keyboard.press("Escape");
await expect(goalMenu).toBeFocused();
await expect(page.getByRole("menu")).toHaveCount(0);
await goalMenu.press("ArrowDown");
await expect(page.getByRole("menu")).toBeVisible();
await expect(page.getByRole("menuitem").first()).toBeFocused();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await expect(page.getByRole("dialog", { name: "Set goal" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Set goal" })).not.toBeVisible();
await page.getByTestId("toggle-replay").click();
await page.getByTestId("replay-position").focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByTestId("replay-position")).toHaveValue("1");
await page.keyboard.press("Space");
await expect(page.getByTestId("replay-controls").getByRole("button", { name: "Pause", exact: true })).toBeVisible();
expect(await page.getByRole("log").count()).toBe(1);
expect(await page.locator('[aria-live="polite"]').count()).toBe(1);
const unnamed = await page.locator("button").evaluateAll((buttons) => buttons.filter((button) => (button.getAttribute("aria-label") ?? button.textContent ?? "").trim().length === 0).length);
expect(unnamed).toBe(0);
const boxes = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('[data-slot="runner-console-app"]')!;
const rail = document.querySelector<HTMLElement>(".pcr-console-rail")!;
const center = document.querySelector<HTMLElement>(".pcr-console-center")!;
return { root: root.getBoundingClientRect(), rail: rail.getBoundingClientRect(), center: center.getBoundingClientRect() };
});
expect(boxes.rail.right).toBeLessThanOrEqual(boxes.center.left);
expect(boxes.center.right).toBeLessThanOrEqual(boxes.root.right);
await page.setViewportSize(MOBILE);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
expect(await page.getByRole("log").count()).toBe(1);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
@@ -0,0 +1,24 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "phase5.spec.ts",
fullyParallel: false,
workers: 1,
timeout: 120_000,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4192",
viewport: { width: 1440, height: 900 },
},
webServer: {
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4192",
env: {
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "250",
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
},
url: "http://127.0.0.1:4192/reference-console/",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "phase5-real.spec.ts",
fullyParallel: false,
workers: 1,
timeout: 240_000,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4193",
viewport: { width: 1440, height: 900 },
},
webServer: {
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4193",
env: {
PAPERCLIP_PHASE4B_DRIVER: "codex",
PAPERCLIP_PHASE4B_MAX_SESSIONS: "8",
// The browser middleware creates its disposable working directory below
// this run-owned root. Tell the nested Codex process about that exact
// assignment so its workspace-boundary guard remains fail closed.
PAPERCLIP_WORKSPACE_CWD:
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.cwd(),
},
url: "http://127.0.0.1:4193/reference-console/",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paperclip Runner reference console</title>
</head>
<body><div id="root"></div><script type="module" src="/reference-console/src/main.tsx"></script></body>
</html>
@@ -0,0 +1,9 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import { RunnerConsoleApp } from "@paperclipai/paperclip-runner/react";
import "@paperclipai/paperclip-runner/styles.css";
const root = document.getElementById("root");
if (root === null) throw new Error("Reference console root is missing.");
createRoot(root).render(<React.StrictMode><RunnerConsoleApp /></React.StrictMode>);
@@ -0,0 +1,142 @@
---
type: Verification Evidence
title: Phase 5 browser SDK verification
description: Targeted and package-acceptance evidence for the versioned SDK, two public consumers, fake/real drivers, accessibility, reconnect, and replay.
tags: [native-runner, phase-5, sdk, browser, react, codex, evidence]
status: stable
generated: { by: openai/codex-local, at: 2026-08-08T07:50:00Z }
---
# Scope
This record covers Phase 5 under `packages/paperclip-runner/`: the versioned
browser and React surface, scoped styles, reference console, mini consumer,
package boundaries, fake and real drivers, documentation, and visual evidence.
It does not change production `server/`, `ui/`, database, routes, persistence,
or legacy adapters.
# Targeted tests
```sh
pnpm --filter @paperclipai/paperclip-runner test:phase5
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Results:
- Phase 5 Vitest: **3 files, 20 tests passed**.
- TypeScript public and browser configurations: passed.
- Generated protocol source check: passed.
- SDK token gate: passed.
- Package/deep-import boundary gate: passed.
Coverage includes framework-free client errors and injection, exact duplicate
event delivery to the shared reducer, file/tool/plan projection, exact failure
diagnostics, component `data-slot` contracts, request locking, and the five
extension points.
# Fake-driver browser acceptance
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
```
Result: **5 Playwright tests passed** in 16.4 seconds after adding an explicit
responsive-tree readiness check before mobile evidence capture.
The tests cover both public consumers, every required fake lifecycle state,
Composer/menu/dialog/tabs/replay keyboard behavior, one polite live region,
semantic control names, status text, reduced-motion-safe styles, exact mobile
and desktop bounding-box measurements, reconnect and gap recovery, durable
cursor replay, and live/replay reducer parity.
# Real Codex evidence
Direct server trace:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4b -- \
knowledge/evidence/phase-05/phase5-real-codex-trace.json
```
Result: passed. The trace proves exact file output, a validated semantic
result, one terminal event, continuous source sequence, stable run/session and
provider identity after reconnect, replay, explicit unsupported-goal state,
and absent provider credentials and host paths.
Browser consumers:
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
```
Result: reference console passed and produced its live screenshot. The mini
consumer was then rerun independently after replacing a timing-sensitive real
steering visual with the safe completion manifest:
```sh
bash scripts/verify-rootless-linux.sh pnpm exec playwright test \
--config examples/playwright.real.config.ts --grep 'mini consumer'
```
Result: **1 test passed** in 14.1 seconds. Codex explicitly reported goals as
disabled; the consumer kept its goal controls disabled. The public consumer
completed a safe real turn, reconnected the same session, entered replay, and
reported parity. Provider credentials were not rendered.
# Screenshots
The [`phase-05/`](phase-05/) directory contains 33 files:
- Reference console at 1440x900 and 390x844: idle, streaming, steering,
interrupt, pending and resolved requests, goal, reconnect, replay, and
failure.
- Mini consumer at both viewports: idle, custom renderer and token override,
pending request with custom detail, reconnect, and replay.
- Live Codex reference-console and mini-consumer captures at 1440x900.
- The redacted real Codex trace described above.
Visual inspection covered a desktop idle surface, mobile pending request,
custom renderer/token override, mini real replay, and reference real Codex
completion. The mobile request controls fit the viewport; the live reference
shows a completed terminal tool and passed observations.
# Package acceptance
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run verify
```
Result: **passed with exit status 0**. Notable totals in the cumulative run:
- TypeScript: **18 files, 171 tests passed**;
- Rust: **37 unit tests and 2 supervisor integration tests passed**;
- boundary/recorder Node tests: **8 passed**;
- Phase 5 targeted suite: **20 passed**;
- accepted Phase 1–4b browser suite: **28 passed**;
- Phase 5 browser suite: **5 passed**;
- Phase 0 Rust/TypeScript and Phase 1 fixture/reducer parity: passed;
- Phase 0 tracer, Phase 1 replay, Phase 2 happy path, and Phase 3 lost-ACK
recovery: passed.
# Documentation
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: documentation links and OKF v0.2 validation pass.
# Known provider constraints
Real-provider timing is not the oracle for race screenshots. The deterministic
driver owns steering, interrupt, request, and goal branches. Real Codex owns
the provider authentication boundary, normalized identity, redaction, safe
completion, reconnect, and replay. This preserves upstream terminal and
capability truth instead of hiding a real failure or fabricating support.
@@ -11,3 +11,4 @@
* [Phase 4b protocol/server verification](2026-08-08-phase-04b-protocol-server-verification.md) - Browser-resolved requests, goals, lineage, controls, reconnect, redaction, and real Codex server evidence.
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
* [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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,83 @@
---
type: Engineering Journal Entry
title: Phase 5 browser SDK and second consumer
description: Versioned browser and React contracts, a reference console, a public-API-only mini consumer, and fake/real driver evidence.
tags: [native-runner, phase-5, browser, sdk, react, replay, accessibility]
status: stable
generated: { by: openai/codex-local, at: 2026-08-08T07:45:00Z }
entry_kind: phase
phase: "5"
---
# Context
The accepted Phase 4b console proved the protocol, interaction, and visual
contract in one package-local application. Phase 5 needed to extract that
surface without changing protocol authority or turning the accepted demo into
a hidden dependency. A second consumer had to prove that the public API was
sufficient.
# Decisions
1. Package version `0.1.0` freezes four public entries: `.`, `./browser`,
`./react`, and `./styles.css`. React and React DOM are peers. No runtime
dependency was added.
2. The framework-free client owns HTTP/SSE transport only. Canonical events
pass unchanged into the shared reducer; browser code does not manufacture
identities, capabilities, request outcomes, or replay state.
3. `useRunnerConsole` exposes the accepted hook state and verbs, restores the
session from a durable server cursor, and uses the same reducer for live and
replay projections.
4. The component surface keeps exactly five extension points: item body,
request detail, Composer action slots, scoped token overrides, and transport
injection.
5. `RunnerConsoleApp` reproduces the full accepted console. The mini consumer
is a single-column tracer bullet that imports public subpaths only and
visibly exercises all extension points.
6. `.pcr-root` owns a light-only token sheet. One React tree is selected at
900px; duplicate hidden mobile/desktop trees are forbidden.
7. Deterministic fake-driver tests own timing-sensitive steering, interrupt,
request, and goal branches. The real Codex smoke owns the provider boundary,
stable identities, redaction, safe completion, reconnect, and replay.
Full contracts are in the [SDK reference](../../docs/phase-05-sdk.md) and
[decision record](../../docs/design/phase-5-component-decisions.md).
# Failures and corrections
- The browser TypeScript configuration initially lacked Node types required by
Vite and Playwright configuration files. Node types were added to that
configuration only; they did not enter the browser runtime.
- An early hook implementation removed repeated source event IDs. That hid
reducer input and violated parity authority. The hook now preserves the exact
stream and lets the shared reducer classify duplicates.
- A real steering run produced an acknowledged steer and valid semantic result
but terminalized as a provider failure. The real browser smoke now uses the
safe completion manifest. Deterministic tests continue to prove all timing
races without weakening terminal truth.
- The real browser server first selected the repository worktree outside its
assigned runtime workspace. The test config now gives Codex the run-owned
workspace directory.
- Minimal-host Chromium lacked shared libraries. The existing rootless helper
was generalized to run a supplied package command against run-owned ARM64
libraries.
# Evidence
- Targeted client/reducer/component tests and public-import boundary tests.
- Browser flows at 1440x900 and 390x844, including keyboard, live-region,
measurement, reconnect, gap recovery, and replay checks.
- Fake-driver lifecycle screenshots plus live reference and mini Codex shots.
- A direct real-driver trace proving exact file output, semantic result,
terminal uniqueness, continuous sequence, stable identities, replay,
explicit goal capability, and absent credentials/host paths.
- Complete package acceptance results in
[Phase 5 verification](../evidence/2026-08-08-phase-05-verification.md).
# Remaining questions
- Dark mode and transcript virtualization remain data-gated future decisions.
- Production Paperclip UI integration is Phase 6 work and is outside this
standalone package boundary.
- An independent UX re-review owns final judgement of the attached canonical
screenshots.
@@ -12,3 +12,4 @@
* [Phase 4b protocol and demo-server layer](2026-08-08-phase-04b-protocol-server.md) - Browser-resolved provider requests, control races, goals, lineage, replay, and server-only credentials.
* [Phase 4b live console browser tracer](2026-08-08-phase-04b-live-console.md) - Reducer-only browser surfaces, adapted components with no new runtime dependencies, interrupt races, reconnect, replay, and the layout and stream failures found on the way.
* [Phase 5 UX gate — reusable console and SDK component plan](2026-08-08-phase-05-ux-plan.md) - Frozen consumer-facing component contracts, five extension points, pcr- token rules, and the mini-consumer tracer flow for the SDK extraction.
* [Phase 5 browser SDK and second consumer](2026-08-08-phase-05-sdk.md) - Versioned browser/React exports, the public reference console and mini consumer, exact reducer authority, and fake/real driver evidence.
+4 -1
View File
@@ -28,4 +28,7 @@
a deterministic demo driver, eleven demo chats, adapted shadcn/AI Elements
components with no new runtime dependencies, and a boundary check that keeps
it that way.
* **Phase 5 browser SDK**: Extracted versioned browser, React, and scoped-style
exports; added a public-API-only reference console and mini consumer; proved
the five approved extension points, keyboard/accessibility contracts,
reconnect and replay parity, package boundaries, and safe real-Codex use.
+23 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@paperclipai/paperclip-runner",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
@@ -8,18 +8,29 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"./browser": {
"types": "./dist/browser/index.d.ts",
"import": "./dist/browser/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js"
},
"./styles.css": "./styles.css"
},
"files": [
"dist",
"protocol",
"styles.css",
"README.md"
],
"scripts": {
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser",
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5",
"build:typescript": "pnpm run generate:protocol-types && tsc -p tsconfig.json",
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
"build:browser": "vite build --config vite.config.ts",
"build:phase5": "vite build --config vite.phase5.config.ts",
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust && pnpm run typecheck:browser",
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
@@ -28,6 +39,9 @@
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
"test:phase5": "vitest run src/browser/client.test.ts src/browser/transcript-model.test.ts src/react/public-contract.test.tsx",
"test:browser:phase5": "pnpm run build:typescript && playwright test --config examples/playwright.config.ts",
"record:phase5:codex": "pnpm run build:typescript && playwright test --config examples/playwright.real.config.ts",
"build:phase2-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
@@ -53,11 +67,16 @@
"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",
"console:phase4b": "pnpm run build:typescript && vite --config vite.config.ts --host 127.0.0.1 --port 4180",
"console:phase5": "pnpm run build:typescript && vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4181",
"browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
"browser:preview": "vite preview --config vite.config.ts",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run test:phase5 && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:phase5 && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
"verify:rootless": "bash scripts/verify-rootless-linux.sh"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.20.1",
@@ -3,7 +3,11 @@ import { dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = resolve(packageRoot, "devtools/browser/src");
const sourceRoots = [
resolve(packageRoot, "devtools/browser/src"),
resolve(packageRoot, "src/react"),
resolve(packageRoot, "examples"),
];
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
@@ -25,7 +29,7 @@ const componentRules = [
];
const violations = [];
for (const path of await sourceFiles(sourceRoot)) {
for (const path of (await Promise.all(sourceRoots.map(sourceFiles))).flat()) {
if (![".ts", ".tsx"].includes(extname(path))) {
continue;
}
@@ -37,7 +41,14 @@ for (const path of await sourceFiles(sourceRoot)) {
}
}
const styles = await readFile(resolve(sourceRoot, "styles.css"), "utf8");
for (const path of await sourceFiles(resolve(packageRoot, "src/react"))) {
if (![".ts", ".tsx"].includes(extname(path))) continue;
if (/\bui-[a-z-]+/.test(await readFile(path, "utf8"))) {
violations.push(`${path.slice(packageRoot.length + 1)}: legacy ui- class name`);
}
}
const styles = await readFile(resolve(packageRoot, "devtools/browser/src/styles.css"), "utf8");
for (const token of [
"--background",
"--foreground",
@@ -57,6 +68,58 @@ for (const token of [
}
}
const sdkStyles = await readFile(resolve(packageRoot, "styles.css"), "utf8");
for (const token of [
"--pcr-background",
"--pcr-foreground",
"--pcr-card",
"--pcr-primary",
"--pcr-muted",
"--pcr-border",
"--pcr-ring",
"--pcr-font-sans",
"--pcr-font-mono",
"--pcr-space-1",
"--pcr-radius-sm",
"--pcr-motion-fast",
"--pcr-touch-target",
]) {
if (!sdkStyles.includes(`${token}:`)) {
violations.push(`styles.css: missing ${token}`);
}
}
if (!sdkStyles.startsWith(".pcr-root {")) {
violations.push("styles.css: token sheet must be scoped to .pcr-root");
}
if (sdkStyles.includes(":root")) {
violations.push("styles.css: SDK tokens may not leak onto :root");
}
if (sdkStyles.includes("\\n")) {
violations.push("styles.css: contains a literal \\n escape sequence");
}
if (/--pcr---|\.pcr-[a-z-]+--pcr-/i.test(sdkStyles)) {
violations.push("styles.css: malformed repeated pcr prefix");
}
const sdkTokenNames = new Set([
...[...sdkStyles.matchAll(/--([a-z][a-z0-9-]*)\s*:/gi)].map((match) => match[1]),
...[...sdkStyles.matchAll(/var\(\s*--([a-z][a-z0-9-]*)/gi)].map(
(match) => match[1],
),
]);
for (const tokenName of sdkTokenNames) {
if (!tokenName.startsWith("pcr-")) {
violations.push(`styles.css: unprefixed SDK token --${tokenName}`);
}
}
const cssWithoutComments = sdkStyles.replaceAll(/\/\*[\s\S]*?\*\//g, "");
const openBraces = [...cssWithoutComments].filter((character) => character === "{").length;
const closeBraces = [...cssWithoutComments].filter((character) => character === "}").length;
if (openBraces !== closeBraces) {
violations.push(
`styles.css: unbalanced braces (${openBraces} open, ${closeBraces} close)`,
);
}
if (violations.length > 0) {
process.stderr.write(`Standalone browser token check failed:\n- ${violations.join("\n- ")}\n`);
process.exitCode = 1;
@@ -48,3 +48,14 @@ test("a negative fixture proves that a browser UI runtime import is rejected", a
assert.match(violations[0].reason, /adapts component source/);
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
});
test("a negative fixture proves that an SDK consumer deep import is rejected", async () => {
const violations = await checkForbiddenImports({
scanRoots: ["test-fixtures/forbidden-sdk-consumer/examples"],
cargoRoots: [],
checkManifest: false,
});
assert.equal(violations.length, 1);
assert.match(violations[0].reason, /may not deep-import/);
});
@@ -80,12 +80,32 @@ function isForbiddenBrowserPackage(specifier) {
}
function violationReason({ file, packageRoot, specifier }) {
if (specifier.startsWith("@paperclipai/") && specifier !== "@paperclipai/paperclip-runner") {
const relativeFile = relative(packageRoot, file).split(/[\\/]/).join("/");
const isExampleConsumer = relativeFile.startsWith("examples/");
const publicRunnerImports = new Set([
"@paperclipai/paperclip-runner/browser",
"@paperclipai/paperclip-runner/react",
"@paperclipai/paperclip-runner/styles.css",
]);
if (
specifier.startsWith("@paperclipai/paperclip-runner/") &&
!publicRunnerImports.has(specifier)
) {
return "runner consumers may import only declared public subpaths";
}
if (isExampleConsumer && specifier === "@paperclipai/paperclip-runner") {
return "runner consumers may import only browser, react, and styles.css public subpaths";
}
if (
specifier.startsWith("@paperclipai/") &&
specifier !== "@paperclipai/paperclip-runner" &&
!publicRunnerImports.has(specifier)
) {
return "Paperclip workspace packages are outside the standalone boundary";
}
if (
relative(packageRoot, file).split(/[\\/]/).join("/").includes("devtools/browser/") &&
["devtools/browser/", "src/react/", "examples/"].some((root) => relativeFile.includes(root)) &&
isForbiddenBrowserPackage(specifier)
) {
return "the standalone browser app adapts component source instead of adopting its runtime";
@@ -100,6 +120,17 @@ function violationReason({ file, packageRoot, specifier }) {
if (!isInside(packageRoot, resolvedImport)) {
return "relative imports may not escape packages/paperclip-runner";
}
const segments = relativeFile.split("/");
const examplesIndex = segments.indexOf("examples");
if (examplesIndex >= 0 && segments[examplesIndex + 1] !== undefined) {
const consumerRoot = resolve(
packageRoot,
segments.slice(0, examplesIndex + 2).join("/"),
);
if (!isInside(consumerRoot, resolvedImport)) {
return "example consumers may not deep-import package or demo internals";
}
}
}
return null;
@@ -172,7 +203,7 @@ async function cargoManifestViolations(packageRoot, cargoRoots) {
export async function checkForbiddenImports({
packageRoot = defaultPackageRoot,
scanRoots = ["src", "scripts", "runner"],
scanRoots = ["src", "scripts", "runner", "examples"],
cargoRoots = ["runner"],
checkManifest = true,
} = {}) {
@@ -78,5 +78,9 @@ case "$debian_architecture" in
esac
rootless_library_path="$rootless_deps_dir/usr/lib/$library_triplet:$rootless_deps_dir/lib/$library_triplet:$rootless_deps_dir/usr/lib"
printf 'Running Phase 2 verification with rootless browser libraries from %s.\n' "$rootless_deps_dir"
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" pnpm run verify
printf 'Running verification with rootless browser libraries from %s.\n' "$rootless_deps_dir"
if [[ $# -gt 0 ]]; then
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$@"
else
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" pnpm run verify
fi
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { PrpEvent } from "../protocol/phase1-contract.js";
import {
createRunnerClient,
RunnerClientError,
type EventSourceLike,
} from "./client.js";
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("public runner browser client", () => {
it("uses an injected fetch transport and normalizes the base URL", async () => {
const calls: Array<{ input: string; init?: RequestInit }> = [];
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
return json({ manifests: [{ id: "completion" }] });
}) as typeof fetch;
const client = createRunnerClient({ baseUrl: "https://runner.test/api/", fetchImpl });
await expect(client.fetchManifests()).resolves.toEqual([{ id: "completion" }]);
expect(calls[0]?.input).toBe("https://runner.test/api/manifests");
});
it("preserves structured status and code on server errors", async () => {
const fetchImpl = vi.fn(async () => json({ error: "stale_turn", message: "Turn ended." }, 409)) as unknown as typeof fetch;
const client = createRunnerClient({ fetchImpl });
await expect(client.startTurn("session", "hello")).rejects.toEqual(
expect.objectContaining<Partial<RunnerClientError>>({
name: "RunnerClientError",
status: 409,
code: "stale_turn",
message: "Turn ended.",
}),
);
});
it("injects EventSource without changing canonical events or reconnect cursor", () => {
let url = "";
let closed = false;
const source: EventSourceLike = {
onopen: null,
onmessage: null,
onerror: null,
close: () => { closed = true; },
};
const client = createRunnerClient({
fetchImpl: vi.fn() as unknown as typeof fetch,
eventSourceFactory: (nextUrl) => { url = nextUrl; return source; },
});
const event = {
schema: "paperclip.prp.event.v1",
sourceEventId: "event-9",
sourceKind: "harness",
sourceInstanceId: "driver",
sourceSeq: 9,
emittedAt: "2026-08-08T00:00:00.000Z",
eventType: "item.delta",
payload: { text: "exact" },
} as PrpEvent;
const onEvent = vi.fn();
const onError = vi.fn();
const handle = client.openEventStream("session/id", 8, { onEvent, onError });
expect(url).toBe("/api/phase4b/sessions/session%2Fid/stream?after=8");
source.onmessage?.({ data: JSON.stringify(event) } as MessageEvent<string>);
expect(onEvent).toHaveBeenCalledWith(event);
source.onmessage?.({ data: "not json" } as MessageEvent<string>);
expect(onError).toHaveBeenCalledOnce();
handle.close();
expect(closed).toBe(true);
});
});
@@ -0,0 +1,218 @@
import type { PrpEvent } from "../protocol/phase1-contract.js";
import type {
GoalOperation,
ManifestSummary,
RunnerSessionState,
} from "./protocol.js";
export interface RunnerClientErrorBody {
error?: unknown;
message?: unknown;
}
export class RunnerClientError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "RunnerClientError";
this.status = status;
this.code = code;
}
}
export interface EventSourceLike {
onopen: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent<string>) => void) | null;
onerror: ((event: Event) => void) | null;
close(): void;
}
export type EventSourceFactory = (url: string) => EventSourceLike;
export interface RunnerClientOptions {
/** Defaults to the package-local reference protocol server. */
baseUrl?: string;
/** Inject a credential-aware fetch wrapper without exposing credentials to components. */
fetchImpl?: typeof fetch;
/** Inject an EventSource-compatible transport for auth, proxies, or tests. */
eventSourceFactory?: EventSourceFactory;
}
export interface EventStreamHandle {
close(): void;
}
export interface RunnerClient {
fetchManifests(): Promise<ManifestSummary[]>;
createSession(input: {
manifest: string;
objective: string;
message?: string;
startTurn?: boolean;
}): Promise<RunnerSessionState>;
readSession(sessionId: string): Promise<RunnerSessionState>;
readEvents(
sessionId: string,
after?: number,
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }>;
startTurn(sessionId: string, text: string): Promise<RunnerSessionState>;
steerTurn(sessionId: string, turnId: string, text: string): Promise<RunnerSessionState>;
interruptTurn(sessionId: string, turnId: string | null): Promise<RunnerSessionState>;
resolveRequest(
sessionId: string,
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
): Promise<RunnerSessionState>;
goalOperation(
sessionId: string,
operation: GoalOperation,
body?: Record<string, unknown>,
): Promise<RunnerSessionState>;
reconnectSession(sessionId: string): Promise<RunnerSessionState>;
closeSession(sessionId: string): Promise<void>;
openEventStream(
sessionId: string,
after: number,
handlers: {
onEvent: (event: PrpEvent) => void;
onOpen?: () => void;
onError?: () => void;
},
): EventStreamHandle;
}
function normalizedBaseUrl(baseUrl: string): string {
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
}
function nativeFetch(): typeof fetch {
if (typeof globalThis.fetch !== "function") {
throw new Error("A fetch implementation is required in this environment.");
}
return globalThis.fetch.bind(globalThis);
}
function nativeEventSource(url: string): EventSourceLike {
if (typeof globalThis.EventSource !== "function") {
throw new Error("An EventSource factory is required in this environment.");
}
return new globalThis.EventSource(url);
}
/**
* Creates the framework-free browser client. Provider credentials remain on
* the protocol server; injected transports may carry browser-to-server auth
* but that auth is never copied into public session state or component props.
*/
export function createRunnerClient(options: RunnerClientOptions = {}): RunnerClient {
const baseUrl = normalizedBaseUrl(options.baseUrl ?? "/api/phase4b");
const fetchImpl = options.fetchImpl ?? nativeFetch();
const eventSourceFactory = options.eventSourceFactory ?? nativeEventSource;
async function send<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
if (init?.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const response = await fetchImpl(`${baseUrl}${path}`, { ...init, headers });
const text = await response.text();
let body: RunnerClientErrorBody = {};
if (text.length > 0) {
try {
body = JSON.parse(text) as RunnerClientErrorBody;
} catch {
if (!response.ok) {
throw new RunnerClientError(response.status, "invalid_response", "The runner returned invalid JSON.");
}
throw new RunnerClientError(response.status, "invalid_response", "The runner returned invalid JSON.");
}
}
if (!response.ok) {
throw new RunnerClientError(
response.status,
typeof body.error === "string" ? body.error : "request_failed",
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
);
}
return body as T;
}
return {
async fetchManifests() {
const body = await send<{ manifests: ManifestSummary[] }>("/manifests");
return body.manifests;
},
createSession(input) {
return send<RunnerSessionState>("/sessions", {
method: "POST",
body: JSON.stringify(input),
});
},
readSession(sessionId) {
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}`);
},
readEvents(sessionId, after = 0) {
return send(`/sessions/${encodeURIComponent(sessionId)}/events?after=${after}`);
},
startTurn(sessionId, text) {
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/turns`, {
method: "POST",
body: JSON.stringify({ text }),
});
},
steerTurn(sessionId, turnId, text) {
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/steer`, {
method: "POST",
body: JSON.stringify({ turnId, text }),
});
},
interruptTurn(sessionId, turnId) {
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/interrupt`, {
method: "POST",
body: JSON.stringify({ reason: "browser_operator", ...(turnId === null ? {} : { turnId }) }),
});
},
resolveRequest(sessionId, requestId, turnId, resolution) {
return send<RunnerSessionState>(
`/sessions/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}/resolve`,
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
);
},
goalOperation(sessionId, operation, body = {}) {
return send<RunnerSessionState>(
`/sessions/${encodeURIComponent(sessionId)}/goal/${operation}`,
{ method: "POST", body: JSON.stringify(body) },
);
},
reconnectSession(sessionId) {
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/reconnect`, {
method: "POST",
body: JSON.stringify({}),
});
},
async closeSession(sessionId) {
await send(`/sessions/${encodeURIComponent(sessionId)}/close`, {
method: "POST",
body: JSON.stringify({}),
});
},
openEventStream(sessionId, after, handlers) {
const source = eventSourceFactory(
`${baseUrl}/sessions/${encodeURIComponent(sessionId)}/stream?after=${after}`,
);
source.onopen = () => handlers.onOpen?.();
source.onmessage = (message) => {
try {
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
} catch {
handlers.onError?.();
}
};
source.onerror = () => handlers.onError?.();
return { close: () => source.close() };
},
};
}
@@ -0,0 +1,5 @@
export * from "../protocol/phase1-contract.js";
export * from "../reducer/session-reducer.js";
export * from "./client.js";
export * from "./protocol.js";
export * from "./transcript-model.js";
@@ -0,0 +1,114 @@
import type { PrpEvent } from "../protocol/phase1-contract.js";
import type { SessionSnapshot } from "../reducer/session-reducer.js";
/**
* The browser only ever sees the demo server's public state and the canonical
* PRP event stream. There is no second event model and no client-side session
* cache: every surface reads these shapes or the reducer snapshot they carry.
*/
export interface RunnerCapabilities {
resume: boolean;
typedEvents: boolean;
steering: boolean;
interruption: boolean;
structuredResult: boolean;
read?: boolean;
reconciliation?: boolean;
usage?: boolean;
runtimeRequestResolution?: boolean;
goals?: boolean;
threadLineage?: boolean;
unsupported?: string[];
}
export interface RunnerPendingRequest {
requestId: string;
requestKind: string;
method: string;
turnId: string;
itemId: string;
status: string;
prompt: string;
details: Record<string, unknown>;
}
export interface RunnerGoal {
threadId: string;
objective: string;
status: string;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export interface RunnerLineageEntry {
threadId: string;
providerSessionId: string | null;
parentThreadId: string | null;
depth: number;
nickname: string | null;
role: string | null;
status: string;
}
export interface RunnerSessionState {
sessionId: string;
runId: string;
normalizedSessionId: string;
manifest: string | null;
providerAuthentication: string;
credentialsExposed: boolean;
capabilities: RunnerCapabilities;
driverSession: {
driverSessionId: string;
providerSessionId?: string | null;
displayId?: string | null;
};
activeTurnId: string | null;
pendingRequests: RunnerPendingRequest[];
goal: RunnerGoal | null;
lineage: RunnerLineageEntry[];
cursor: number;
snapshot: SessionSnapshot;
}
export interface ManifestSummary {
id: string;
name: string;
purpose: string;
scenarios: string[];
objective: string;
prompt: string;
expectedObservations: string[];
}
export type ConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "terminal";
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
export type RunnerEventList = readonly PrpEvent[];
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
"set",
"get",
"pause",
"resume",
"clear",
];
/** Human labels for the five fixed goal verbs (interaction map §5). */
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
set: "Set goal…",
get: "View",
pause: "Pause",
resume: "Resume",
clear: "Clear",
};
@@ -0,0 +1,309 @@
import { describe, expect, it } from "vitest";
import { Phase4bScriptedDriver } from "../mock-core/phase4b-scripted-driver";
import {
parseHarnessRuntimeRequestResolution,
type HarnessSession,
} from "../contracts/harness-driver";
import type { PrpEvent } from "../protocol/phase1-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
reduceSessionEvents,
type SessionSnapshot,
} from "../reducer/session-reducer";
import type { RunnerCapabilities } from "./protocol.js";
import {
buildTranscript,
capabilityRows,
composerState,
goalAvailability,
runtimeRequestSubmission,
transcriptRole,
type TranscriptEntry,
} from "./transcript-model.js";
const FULL: RunnerCapabilities = {
resume: true,
typedEvents: true,
steering: true,
interruption: true,
structuredResult: true,
runtimeRequestResolution: true,
goals: true,
threadLineage: true,
};
function seed(runId: string, sessionId: string): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: "phase4b-demo",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "package-local-demo",
issueId: "phase4b-demo",
runId,
environmentLeaseId: "package-local",
runnerInstanceId: "phase4b-demo-server",
normalizedSessionId: sessionId,
driverSessionId: `thread-${sessionId}`,
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: "phase4b-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
},
});
}
async function drive(
manifestId: string,
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs: 0 });
const session = await driver.openSession({
runId: `run-${manifestId}`,
normalizedSessionId: `session-${manifestId}`,
workingDirectory: "/demo",
});
const events: PrpEvent[] = [];
const pump = (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
if (interact !== undefined) await interact(session, turnId);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
await session.close({ reason: "test" });
await pump;
return {
events,
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
};
}
function kinds(entries: readonly TranscriptEntry[]): string[] {
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
}
describe("transcriptRole", () => {
it("maps canonical item kinds onto transcript roles", () => {
expect(transcriptRole("reasoning")).toBe("reasoning");
expect(transcriptRole("agentMessage")).toBe("assistant");
expect(transcriptRole("commandExecution")).toBe("tool");
expect(transcriptRole("fileChange")).toBe("tool");
expect(transcriptRole("planUpdate")).toBe("tool");
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
expect(transcriptRole("thread_lineage")).toBe("lineage");
expect(transcriptRole("something_new")).toBe("system");
});
});
describe("buildTranscript", () => {
it("orders entries by the reducer timeline and shows the submitted message", async () => {
const { events, snapshot } = await drive("completion");
const entries = buildTranscript(snapshot, events);
expect(kinds(entries)).toEqual([
"user",
"item:reasoning",
"item:tool",
"item:assistant",
"turn",
]);
const user = entries[0];
expect(user.kind === "user" && user.text).toBe("Go.");
const tool = entries[2];
expect(tool.kind === "item" && tool.input).toBe("docs/phase-04b-protocol-server.md");
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
});
it("marks an interrupted item and never claims it completed cleanly", async () => {
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.interrupt?.({ turnId });
});
const entries = buildTranscript(snapshot, events);
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
expect(tool?.kind === "item" && tool.output).toBeNull();
const turn = entries.find((entry) => entry.kind === "turn");
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
});
it("keeps a resolved request in the record with the chosen action", async () => {
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.resolveRuntimeRequest?.({
requestId: "request-command",
turnId,
resolution: { action: "accept_for_session" },
});
});
const request = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "request" && entry.requestId === "request-command",
);
expect(request?.kind === "request" && request.status).toBe("resolved");
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
expect(request?.kind === "request" && request.actions).toEqual([
"accept",
"accept_for_session",
"decline",
"cancel",
]);
});
it("renders a failed item with its exact diagnostic", async () => {
const { events, snapshot } = await drive("failure");
const failed = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "item" && entry.failure !== null,
);
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
});
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
// Replay mode reduces events[0..n]. Stepping forward must only ever add
// entries in the same positions the live session produced.
const { events, snapshot } = await drive("completion");
const full = buildTranscript(snapshot, events);
for (let cut = 1; cut <= events.length; cut += 1) {
const prefix = events.slice(0, cut);
const stepped = buildTranscript(
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
prefix,
);
expect(stepped.length).toBeLessThanOrEqual(full.length);
stepped.forEach((entry, index) => {
expect(entry.key).toBe(full[index]?.key);
expect(entry.position).toBe(full[index]?.position);
});
}
expect(
buildTranscript(
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
events,
),
).toEqual(full);
});
it("keeps deliberate duplicate ids in reducer parity evidence", async () => {
const { events } = await drive("completion");
const duplicate = events.find((event) => event.eventType === "item.delta");
expect(duplicate).toBeDefined();
const replay = duplicate === undefined ? events : [...events, duplicate];
const snapshot = reduceSessionEvents(
seed("run-completion", "session-completion"),
replay,
);
expect(snapshot.duplicateEventIds).toContain(duplicate?.sourceEventId);
expect(snapshot.integrity).toBe("complete");
});
});
describe("composerState", () => {
const base = {
connection: "connected" as const,
submitting: false,
interrupting: false,
activeTurnId: null,
sessionState: "running" as const,
};
it("shows exactly one state per protocol situation", () => {
expect(composerState(base)).toBe("idle");
expect(composerState({ ...base, submitting: true })).toBe("submitting");
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
"interrupting",
);
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
});
it("puts a terminal session ahead of every other state", () => {
expect(
composerState({
...base,
sessionState: "failed",
activeTurnId: "turn-1",
connection: "disconnected",
}),
).toBe("terminal");
});
});
describe("goalAvailability", () => {
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
const rows = goalAvailability(
{
...FULL,
goals: false,
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
},
null,
);
expect(rows).toHaveLength(5);
expect(rows.every((row) => !row.enabled)).toBe(true);
expect(rows[0]?.reason).toContain("does not advertise goals");
});
it("disables only the verbs that are invalid for the current goal state", () => {
const active = goalAvailability(FULL, "active");
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
const paused = goalAvailability(FULL, "paused");
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
const none = goalAvailability(FULL, null);
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
});
});
describe("runtimeRequestSubmission", () => {
it("shapes each submit body the way its request kind is validated", async () => {
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
action: "submit",
answers: { answer: { answers: ["staging"] } },
});
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
action: "submit",
content: { answer: "stable" },
});
// The driver is the authority on shape, so both bodies are checked against
// the real validator rather than against this test's expectations alone.
for (const [requestKind, answer] of [
["user_input", "staging"],
["elicitation", "stable"],
] as const) {
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(requestKind, answer),
)).not.toThrow();
// ...and the other kind's body is rejected, which is what the browser
// used to send for elicitation.
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(wrongKind, answer),
)).toThrow(/rejected its resolution/);
}
});
});
describe("capabilityRows", () => {
it("gives every unsupported capability a diagnostic string", () => {
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
});
});
@@ -0,0 +1,404 @@
import type { PrpEvent } from "../protocol/phase1-contract.js";
import type {
SessionItemSnapshot,
SessionSnapshot,
} from "../reducer/session-reducer.js";
import type { GoalOperation, RunnerCapabilities } from "./protocol.js";
/**
* A projection of reducer state plus the canonical events that produced it.
* It never stores anything the protocol did not send, so a replayed session
* and a live session with the same events project identically.
*/
export type TranscriptRole =
| "user"
| "assistant"
| "reasoning"
| "tool"
| "steering"
| "interrupt"
| "goal"
| "lineage"
| "system";
export interface TranscriptItemEntry {
kind: "item";
key: string;
position: number;
role: TranscriptRole;
item: SessionItemSnapshot;
interrupted: boolean;
streaming: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
}
export interface TranscriptUserEntry {
kind: "user";
key: string;
position: number;
turnId: string | null;
text: string;
}
export interface TranscriptRequestEntry {
kind: "request";
key: string;
position: number;
requestId: string;
requestKind: string;
type: string;
prompt: string;
status: "pending" | "resolved" | "expired" | "cancelled";
actions: string[];
details: Record<string, unknown>;
resolvedAction: string | null;
resolvedAt: string | null;
turnId: string | null;
}
export interface TranscriptTurnEntry {
kind: "turn";
key: string;
position: number;
turnId: string | null;
state: "completed" | "failed" | "interrupted" | "cancelled";
detail: string;
}
export interface TranscriptDiagnosticEntry {
kind: "diagnostic";
key: string;
position: number;
message: string;
code: string | null;
}
export type TranscriptEntry =
| TranscriptItemEntry
| TranscriptUserEntry
| TranscriptRequestEntry
| TranscriptTurnEntry
| TranscriptDiagnosticEntry;
const TOOL_KINDS = new Set([
"commandExecution",
"command_execution",
"fileChange",
"file_change",
"mcpToolCall",
"tool",
"webSearch",
"patchApply",
"plan",
"planUpdate",
"update_plan",
]);
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
export function transcriptRole(kind: string): TranscriptRole {
if (kind === "reasoning") return "reasoning";
if (ASSISTANT_KINDS.has(kind)) return "assistant";
if (TOOL_KINDS.has(kind)) return "tool";
if (kind === "steering_acknowledgement") return "steering";
if (kind === "interrupt_acknowledgement") return "interrupt";
if (kind === "goal" || kind === "goal_cleared") return "goal";
if (kind === "thread_lineage") return "lineage";
return "system";
}
function payloadRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function payloadText(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
interface ItemFacts {
interrupted: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
terminal: boolean;
}
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
const facts = new Map<string, ItemFacts>();
for (const event of events) {
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
const payload = payloadRecord(event.payload);
const current = facts.get(event.itemId) ?? {
interrupted: false,
toolName: null,
input: null,
output: null,
failure: null,
terminal: false,
};
if (payload.interrupted === true || payload.status === "interrupted") {
current.interrupted = true;
}
current.toolName = payloadText(payload.toolName) ?? current.toolName;
current.input = payloadText(payload.input) ?? current.input;
current.output = payloadText(payload.output) ?? current.output;
if (event.eventType === "item.failed") {
current.failure = payloadText(payload.message) ?? "The item failed.";
}
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
current.terminal = true;
}
facts.set(event.itemId, current);
}
return facts;
}
/**
* Builds the submit body a request kind actually accepts. Elicitation answers
* travel as `content` and user-input answers as `answers`; the runner validates
* both against the request kind, so sending the wrong one fails closed rather
* than resolving the request with an empty response.
*/
export function runtimeRequestSubmission(
requestKind: string,
answer: string,
): Record<string, unknown> {
if (requestKind === "elicitation") {
return { action: "submit", content: { answer } };
}
return { action: "submit", answers: { answer: { answers: [answer] } } };
}
function requestActions(details: unknown): string[] {
const record = payloadRecord(details);
return Array.isArray(record.actions)
? record.actions.filter((action): action is string => typeof action === "string")
: [];
}
/**
* Builds the ordered transcript. Order comes from the reducer timeline, which
* is authoritative; nothing is re-sorted client-side.
*/
export function buildTranscript(
snapshot: SessionSnapshot,
events: readonly PrpEvent[],
): TranscriptEntry[] {
const facts = collectItemFacts(events);
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
const seenItems = new Set<string>();
const entries: TranscriptEntry[] = [];
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
for (const timelineEntry of snapshot.timeline) {
const event = eventsById.get(timelineEntry.sourceEventId);
const payload = payloadRecord(event?.payload);
const position = timelineEntry.position;
if (timelineEntry.eventType === "turn.submitted") {
entries.push({
kind: "user",
key: `user:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
});
continue;
}
if (
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
timelineEntry.eventType,
)
) {
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
entries.push({
kind: "turn",
key: `turn:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
state,
detail:
payloadText(payload.message) ??
payloadText(payload.reason) ??
timelineEntry.summary,
});
continue;
}
if (timelineEntry.eventType === "runtime_request.created") {
const request = payloadRecord(payload.request ?? payload);
const requestId = typeof request.requestId === "string" ? request.requestId : "";
const projected = snapshot.requests.find(
(candidate) => candidate.requestId === requestId,
);
const resolution = events.find(
(candidate) =>
candidate.eventType.startsWith("runtime_request.") &&
candidate.eventType !== "runtime_request.created" &&
payloadRecord(candidate.payload).requestId === requestId,
);
entries.push({
kind: "request",
key: `request:${requestId}`,
position,
requestId,
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
type: typeof request.type === "string" ? request.type : "runtime",
prompt: payloadText(request.prompt) ?? "Runtime request",
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
actions: requestActions(request),
details: payloadRecord(request.details),
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
resolvedAt: resolution?.emittedAt ?? null,
turnId: typeof request.turnId === "string" ? request.turnId : null,
});
continue;
}
if (
timelineEntry.eventType === "harness.diagnostic" ||
timelineEntry.eventType === "runner.diagnostic"
) {
entries.push({
kind: "diagnostic",
key: `diagnostic:${timelineEntry.sourceEventId}`,
position,
message: payloadText(payload.message) ?? timelineEntry.summary,
code: payloadText(payload.code),
});
continue;
}
const itemId = timelineEntry.itemId;
if (itemId === undefined || seenItems.has(itemId)) continue;
const item = items.get(itemId);
if (item === undefined) continue;
seenItems.add(itemId);
const itemFacts = facts.get(itemId);
entries.push({
kind: "item",
key: `item:${itemId}`,
position,
role: transcriptRole(item.kind),
item,
interrupted: itemFacts?.interrupted ?? false,
streaming: item.status === "running" && itemFacts?.terminal !== true,
toolName: itemFacts?.toolName ?? null,
input: itemFacts?.input ?? null,
output: itemFacts?.output ?? null,
failure: itemFacts?.failure ?? null,
});
}
return entries;
}
export type ComposerState =
| "idle"
| "submitting"
| "active-turn"
| "interrupting"
| "disconnected"
| "terminal";
export interface ComposerStateInput {
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
submitting: boolean;
interrupting: boolean;
activeTurnId: string | null;
sessionState: SessionSnapshot["sessionState"];
}
/** One state machine with exactly one visible primary action. */
export function composerState(input: ComposerStateInput): ComposerState {
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
if (input.connection === "disconnected" || input.connection === "reconnecting") {
return "disconnected";
}
if (input.interrupting) return "interrupting";
if (input.activeTurnId !== null) return "active-turn";
if (input.submitting) return "submitting";
return "idle";
}
export interface GoalOperationAvailability {
operation: GoalOperation;
enabled: boolean;
reason: string | null;
}
const GOAL_UNSUPPORTED_FALLBACK =
"Goal operations unsupported: this app-server does not advertise the goals capability";
/**
* Capability gating for the goal menu. An unsupported verb is disabled with
* the exact upstream diagnostic — never hidden and never emulated.
*/
export function goalAvailability(
capabilities: RunnerCapabilities,
goalStatus: string | null,
): GoalOperationAvailability[] {
const unsupported = capabilities.unsupported ?? [];
if (capabilities.goals !== true) {
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
operation,
enabled: false,
reason,
}));
}
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
if (operation === "set") return { operation, enabled: true, reason: null };
if (goalStatus === null) {
return {
operation,
enabled: false,
reason: "No goal is set on this thread yet.",
};
}
if (operation === "pause" && goalStatus !== "active") {
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
}
if (operation === "resume" && goalStatus === "active") {
return { operation, enabled: false, reason: "The goal is already active." };
}
return { operation, enabled: true, reason: null };
});
}
export interface CapabilityRow {
name: string;
supported: boolean;
diagnostic: string | null;
}
export function capabilityRows(capabilities: RunnerCapabilities): CapabilityRow[] {
const unsupported = capabilities.unsupported ?? [];
const rows: Array<[string, boolean | undefined]> = [
["steer", capabilities.steering],
["interrupt", capabilities.interruption],
["resume", capabilities.resume],
["runtime requests", capabilities.runtimeRequestResolution],
["goals", capabilities.goals],
["child threads", capabilities.threadLineage],
["structured result", capabilities.structuredResult],
["typed events", capabilities.typedEvents],
["usage", capabilities.usage],
];
return rows.map(([name, supported]) => ({
name,
supported: supported === true,
diagnostic:
supported === true
? null
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
`${name} is not advertised by this app-server`),
}));
}
@@ -0,0 +1,19 @@
import * as React from "react";
/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
export function Badge({
className = "",
tone = "neutral",
...props
}: React.ComponentProps<"span"> & {
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
}) {
return (
<span
data-slot="badge"
data-tone={tone}
className={`pcr-badge ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,39 @@
import * as React from "react";
/**
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
* is only used for standing conditions (reconnect, pending request, replay).
*/
export function Banner({
className = "",
tone = "neutral",
assertive = false,
...props
}: React.ComponentProps<"div"> & {
tone?: "neutral" | "info" | "accent" | "success" | "warning" | "danger";
assertive?: boolean;
}) {
return (
<div
data-slot="banner"
data-tone={tone}
{...(assertive ? { role: "alert", "aria-live": "assertive" } : {})}
className={`pcr-banner ${className}`.trim()}
{...props}
/>
);
}
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
return <p data-slot="banner-text" className={`pcr-banner-text ${className}`.trim()} {...props} />;
}
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="banner-actions"
className={`pcr-banner-actions ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,18 @@
import * as React from "react";
/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
export function Button({
className = "",
variant = "default",
...props
}: React.ComponentProps<"button"> & {
variant?: "default" | "danger" | "ghost";
}) {
return (
<button
data-slot="button"
className={`pcr-button pcr-button--${variant} ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,26 @@
import * as React from "react";
function slotClass(slot: string, className: string | undefined) {
return `${slot} ${className ?? ""}`.trim();
}
/** Source-adapted shadcn Card family for the standalone devtool. */
export function Card({ className, ...props }: React.ComponentProps<"section">) {
return <section data-slot="card" className={slotClass("pcr-card", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-header" className={slotClass("pcr-card-header", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <h2 data-slot="card-title" className={slotClass("pcr-card-title", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="card-description" className={slotClass("pcr-card-description", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={slotClass("pcr-card-content", className)} {...props} />;
}
@@ -0,0 +1,95 @@
import * as React from "react";
import type { ComposerState } from "../../browser/transcript-model.js";
import { Button } from "./button.js";
const MAX_ROWS = 8;
const PRIMARY_LABEL: Record<ComposerState, string> = {
idle: "Send",
submitting: "Sending…",
"active-turn": "Steer",
interrupting: "Stopping…",
disconnected: "Send",
terminal: "Send",
};
const HINT: Record<ComposerState, string> = {
idle: "Enter sends. Shift and Enter add a line.",
submitting: "Waiting for the turn to be accepted.",
"active-turn": "Enter steers the running turn. Escape moves focus to Stop.",
interrupting: "Stopping the turn. Waiting for the terminal turn event.",
disconnected: "The composer is disabled until the connection is restored.",
terminal: "This session is closed. Replay it or reset the session state.",
};
export function Composer({
state,
value,
onValueChange,
onSubmit,
onStop,
disabledReason = null,
label = "Message",
leadingActions,
trailingActions,
}: {
state: ComposerState;
value: string;
onValueChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
disabledReason?: string | null;
label?: string;
leadingActions?: React.ReactNode;
trailingActions?: React.ReactNode;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const stopRef = React.useRef<HTMLButtonElement>(null);
const inputId = React.useId();
const hintId = React.useId();
const reasonId = React.useId();
const locked = disabledReason !== null || ["submitting", "interrupting", "disconnected", "terminal"].includes(state);
const canStop = state === "active-turn" || state === "submitting";
React.useEffect(() => {
const textarea = textareaRef.current;
if (textarea === null) return;
textarea.rows = Math.max(Math.min(value.split("\n").length, MAX_ROWS), 2);
}, [value]);
return (
<div data-slot="composer" data-state={state} className="pcr-composer">
<label className="pcr-visually-hidden" htmlFor={inputId}>{label}</label>
<textarea
ref={textareaRef}
id={inputId}
className="pcr-composer-input"
data-testid="composer-input"
rows={2}
value={value}
disabled={locked}
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (!locked && value.trim().length > 0) onSubmit();
} else if (event.key === "Escape" && canStop) {
event.preventDefault();
stopRef.current?.focus();
}
}}
/>
<div className="pcr-composer-actions">
<div className="pcr-composer-leading">{leadingActions}</div>
<p id={hintId} className="pcr-composer-hint">{HINT[state]}</p>
{disabledReason === null ? null : <p id={reasonId} className="pcr-composer-reason">{disabledReason}</p>}
<div className="pcr-composer-buttons">
<Button ref={stopRef} type="button" variant="danger" data-testid="composer-stop" disabled={!canStop} onClick={onStop}>Stop</Button>
<Button type="button" data-testid="composer-send" disabled={locked || value.trim().length === 0} onClick={onSubmit}>{PRIMARY_LABEL[state]}</Button>
{trailingActions}
</div>
</div>
</div>
);
}
@@ -0,0 +1,178 @@
import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
import type {
TranscriptEntry,
TranscriptRequestEntry,
} from "../../browser/transcript-model.js";
import { RequestCard } from "../request-card.js";
import { Badge } from "./badge.js";
import { Message } from "./message.js";
import { ReasoningItem } from "./reasoning-item.js";
import { ToolItem, type ToolStatus } from "./tool-item.js";
const TURN_TONE = {
completed: "success",
failed: "danger",
interrupted: "warning",
cancelled: "warning",
} as const;
const TURN_LABEL = {
completed: "Turn completed",
failed: "Turn failed",
interrupted: "Turn interrupted",
cancelled: "Cancelled before start",
} as const;
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
if (entry.interrupted) return "interrupted";
if (entry.item.status === "failed") return "failed";
if (entry.item.status === "running") return "running";
return "completed";
}
export interface ConversationProps {
entries: readonly TranscriptEntry[];
label?: string;
unseenCount?: number;
highlightItemId?: string | null;
onResolveRequest?: (
request: TranscriptRequestEntry,
resolution: Record<string, unknown>,
) => void;
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
}
/**
* Canonical transcript renderer. Ordering comes from reducer-backed entries;
* the component owns only scroll following and the accepted render seams.
*/
export function Conversation({
entries,
label = "Session transcript",
unseenCount,
highlightItemId = null,
onResolveRequest,
renderItemBody,
renderRequestDetail,
}: ConversationProps) {
const viewportRef = React.useRef<HTMLDivElement>(null);
const [following, setFollowing] = React.useState(true);
const [seenCount, setSeenCount] = React.useState(entries.length);
const scrollToBottom = React.useCallback((smooth: boolean) => {
const viewport = viewportRef.current;
if (viewport === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: smooth && !reduceMotion ? "smooth" : "auto",
});
}, []);
React.useEffect(() => {
if (!following) return;
scrollToBottom(true);
setSeenCount(entries.length);
}, [entries.length, following, scrollToBottom]);
function reengage() {
setFollowing(true);
setSeenCount(entries.length);
scrollToBottom(true);
}
const unseen = unseenCount ?? Math.max(entries.length - seenCount, 0);
return (
<div data-slot="conversation" className="pcr-conversation">
<div
ref={viewportRef}
role="log"
aria-label={label}
aria-relevant="additions"
tabIndex={0}
className="pcr-conversation-viewport"
data-following={following}
data-testid="runner-transcript"
onScroll={() => {
const viewport = viewportRef.current;
if (viewport === null) return;
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
setFollowing(atBottom);
if (atBottom) setSeenCount(entries.length);
}}
onKeyDown={(event) => {
if (event.key === "End") {
event.preventDefault();
reengage();
} else if (event.key === "Home") {
event.preventDefault();
setFollowing(false);
viewportRef.current?.scrollTo({ top: 0 });
}
}}
>
{entries.length === 0 ? (
<div className="pcr-empty-transcript" data-testid="empty-transcript">
<p className="pcr-eyebrow">No session yet</p>
<h3>Pick a demo chat or send a message</h3>
<p>The browser holds no provider credential. The protocol server owns the driver and working directory.</p>
</div>
) : null}
<ol className="pcr-transcript" role="list">
{entries.map((entry) => {
if (entry.kind === "user") {
return <li key={entry.key}><Message role="user" text={entry.text} /></li>;
}
if (entry.kind === "turn") {
return (
<li key={entry.key} className="pcr-turn-marker" data-testid={`turn-${entry.state}`}>
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge><span>{entry.detail}</span>
</li>
);
}
if (entry.kind === "diagnostic") {
return <li key={entry.key}><Message role="system" text={entry.message} failed /></li>;
}
if (entry.kind === "request") {
return (
<RequestCard
key={entry.key}
request={entry}
onResolve={(resolution) => onResolveRequest?.(entry, resolution)}
{...(renderRequestDetail === undefined ? {} : { renderRequestDetail })}
/>
);
}
if (entry.role === "reasoning") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ReasoningItem item={entry.item} streaming={entry.streaming} interrupted={entry.interrupted} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
</li>
);
}
if (entry.role === "tool") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ToolItem item={entry.item} name={entry.toolName ?? entry.item.kind} status={toolStatus(entry)} input={entry.input} output={entry.output} failure={entry.failure} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
</li>
);
}
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<Message role={entry.role} item={entry.item} streaming={entry.streaming} interrupted={entry.interrupted} failed={entry.item.status === "failed"} failure={entry.failure} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
</li>
);
})}
</ol>
</div>
{following ? null : (
<button type="button" className="pcr-jump-to-latest" data-testid="jump-to-latest" onClick={reengage}>
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
</button>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import * as React from "react";
/**
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
* so no radix runtime and no focus-trap dependency are needed.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
footer: React.ReactNode;
}) {
const ref = React.useRef<HTMLDialogElement>(null);
const invokingControlRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const element = ref.current;
if (element === null) return;
if (open && !element.open) {
invokingControlRef.current = document.activeElement as HTMLElement | null;
element.showModal();
}
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={ref}
data-slot="dialog"
className="pcr-dialog"
aria-labelledby={titleId}
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClose={() => {
onClose();
invokingControlRef.current?.focus();
}}
>
<form method="dialog" className="pcr-dialog-body">
<h2 id={titleId} className="pcr-dialog-title">
{title}
</h2>
{description === undefined ? null : (
<p id={descriptionId} className="pcr-dialog-description">
{description}
</p>
)}
{children}
<div className="pcr-dialog-footer">{footer}</div>
</form>
</dialog>
);
}
@@ -0,0 +1,146 @@
import * as React from "react";
export interface MenuItemDefinition {
id: string;
label: string;
enabled: boolean;
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
reason: string | null;
}
/**
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
* goal surface needs. Five fixed verbs, no search, no radix portal.
*/
export function Menu({
label,
items,
onSelect,
disabled = false,
disabledReason = null,
triggerId,
}: {
label: string;
items: readonly MenuItemDefinition[];
onSelect: (id: string) => void;
disabled?: boolean;
disabledReason?: string | null;
triggerId: string;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const descriptionId = `${triggerId}-reason`;
React.useEffect(() => {
if (!open) return undefined;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function focusItem(index: number) {
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]',
);
if (buttons === undefined || buttons.length === 0) return;
buttons[(index + buttons.length) % buttons.length]?.focus();
}
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
setOpen(true);
window.setTimeout(() => focusItem(0), 0);
}
}
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const buttons = [
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
focusItem(index + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
focusItem(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
focusItem(0);
} else if (event.key === "End") {
event.preventDefault();
focusItem(buttons.length - 1);
} else if (event.key === "Tab") {
setOpen(false);
}
}
return (
<div ref={containerRef} data-slot="menu" className="pcr-menu">
<button
ref={triggerRef}
id={triggerId}
type="button"
className="pcr-button pcr-button--quiet"
aria-haspopup="menu"
aria-expanded={open}
aria-disabled={disabled}
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (disabled) return;
onTriggerKeyDown(event);
}}
>
{label}
<span aria-hidden="true"> ▾</span>
</button>
{disabled && disabledReason !== null ? (
<p id={descriptionId} className="pcr-menu-reason">
{disabledReason}
</p>
) : null}
{open && !disabled ? (
<div role="menu" aria-labelledby={triggerId} className="pcr-menu-list" onKeyDown={onMenuKeyDown}>
{items.map((item) => {
const itemReasonId = `${triggerId}-${item.id}-reason`;
return (
<React.Fragment key={item.id}>
<button
type="button"
role="menuitem"
className="pcr-menu-item"
aria-disabled={!item.enabled}
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
onClick={() => {
if (!item.enabled) return;
setOpen(false);
onSelect(item.id);
}}
>
{item.label}
</button>
{item.reason === null ? null : (
<p id={itemReasonId} className="pcr-menu-reason">
{item.reason}
</p>
)}
</React.Fragment>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,74 @@
import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
import type { TranscriptRole } from "../../browser/transcript-model.js";
const ROLE_LABELS: Record<TranscriptRole, string> = {
user: "You",
assistant: "Assistant",
reasoning: "Reasoning",
tool: "Tool",
steering: "Steering acknowledged",
interrupt: "Interrupt acknowledged",
goal: "Goal",
lineage: "Child thread",
system: "System",
};
export interface MessageProps {
role: TranscriptRole;
item?: SessionItemSnapshot;
text?: string;
interrupted?: boolean;
failed?: boolean;
failure?: string | null;
streaming?: boolean;
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
children?: React.ReactNode;
}
export function Message({
role,
item,
text,
interrupted = false,
failed = false,
failure = null,
streaming = false,
renderItemBody,
children,
}: MessageProps) {
const body = item === undefined
? (children ?? text ?? "")
: (renderItemBody?.(item) ?? <MessageText text={item.text} streaming={streaming} />);
return (
<article
data-slot="message"
data-role={role}
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
className="pcr-message"
>
<p className="pcr-message-role">{ROLE_LABELS[role]}</p>
<div className="pcr-message-body">{body}</div>
{failure === null ? null : (
<pre className="pcr-payload pcr-payload--danger" data-testid="item-failure">
{failure}
</pre>
)}
{interrupted ? (
<p className="pcr-message-divider" data-testid="interrupted-divider">
Interrupted — the text above is everything that arrived.
</p>
) : null}
</article>
);
}
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
return (
<p className="pcr-message-text">
{text}
{streaming ? <span className="pcr-stream-cursor" aria-hidden="true" /> : null}
</p>
);
}
@@ -0,0 +1,32 @@
import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
export function ReasoningItem({
item,
streaming,
interrupted,
renderItemBody,
}: {
item: SessionItemSnapshot;
streaming: boolean;
interrupted: boolean;
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
}) {
const summary = item.text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
return (
<details
data-slot="reasoning-item"
className="pcr-disclosure"
open={streaming}
data-testid="reasoning-item"
>
<summary className="pcr-disclosure-summary">
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
<span className="pcr-disclosure-preview">{summary}</span>
</summary>
{renderItemBody?.(item) ?? <pre className="pcr-payload">{item.text}</pre>}
{interrupted ? <p className="pcr-message-divider">Interrupted while reasoning.</p> : null}
</details>
);
}
@@ -0,0 +1,94 @@
import * as React from "react";
export interface TabDefinition {
id: string;
label: string;
}
/**
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
* enters the panel.
*/
export function Tabs({
items,
selected,
onSelect,
label,
className = "",
children,
}: {
items: readonly TabDefinition[];
selected: string;
onSelect: (value: string) => void;
label: string;
className?: string;
children: React.ReactNode;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
const idPrefix = React.useId();
function moveFocus(nextIndex: number) {
const normalizedIndex = (nextIndex + items.length) % items.length;
const next = items[normalizedIndex];
if (next === undefined) return;
onSelect(next.id);
const button = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[normalizedIndex];
button?.focus();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const index = items.findIndex((tab) => tab.id === selected);
if (event.key === "ArrowRight") {
event.preventDefault();
moveFocus(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
moveFocus(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
moveFocus(0);
} else if (event.key === "End") {
event.preventDefault();
moveFocus(items.length - 1);
}
}
return (
<div data-slot="tabs" className={`pcr-tabs ${className}`.trim()}>
<div
ref={listRef}
role="tablist"
aria-label={label}
className="pcr-tablist"
onKeyDown={onKeyDown}
>
{items.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
data-tab-id={tab.id}
id={`${idPrefix}-tab-${tab.id}`}
aria-selected={tab.id === selected}
aria-controls={`${idPrefix}-tabpanel-${tab.id}`}
tabIndex={tab.id === selected ? 0 : -1}
className="pcr-tab"
onClick={() => onSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`${idPrefix}-tabpanel-${selected}`}
aria-labelledby={`${idPrefix}-tab-${selected}`}
tabIndex={0}
className="pcr-tabpanel"
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import * as React from "react";
/** Source-adapted shadcn Textarea for editable fixture JSON. */
export function Textarea({
className = "",
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={`pcr-textarea ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,83 @@
import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
import { Badge } from "./badge.js";
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
const STATUS_TONE: Record<ToolStatus, "accent" | "success" | "danger" | "warning"> = {
running: "accent",
completed: "success",
failed: "danger",
interrupted: "warning",
};
const STATUS_LABEL: Record<ToolStatus, string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
};
function toolView(kind: string): string {
if (["commandExecution", "command_execution"].includes(kind)) return "Terminal";
if (["fileChange", "file_change", "patchApply"].includes(kind)) return "File change";
if (["plan", "planUpdate", "update_plan"].includes(kind)) return "Plan";
return "Tool";
}
export function ToolItem({
item,
name,
status,
input,
output,
failure,
renderItemBody,
}: {
item: SessionItemSnapshot;
name: string;
status: ToolStatus;
input: string | null;
output: string | null;
failure: string | null;
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
}) {
const view = toolView(item.kind);
return (
<details
data-slot="tool-item"
className="pcr-disclosure"
open={status === "running" || status === "failed"}
data-testid="tool-item"
data-status={status}
data-view={view.toLowerCase().replaceAll(" ", "-")}
>
<summary className="pcr-disclosure-summary">
<span>{view} <code>{name}</code></span>
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
</summary>
{renderItemBody?.(item) ?? (
<>
{input === null ? null : (
<><p className="pcr-disclosure-label">Input</p><pre className="pcr-payload">{input}</pre></>
)}
{output === null ? null : (
<><p className="pcr-disclosure-label">Output</p><pre className="pcr-payload">{output}</pre></>
)}
{input === null && output === null && item.text.length > 0
? <pre className="pcr-payload">{item.text}</pre>
: null}
</>
)}
{failure === null ? null : (
<><p className="pcr-disclosure-label">Diagnostic</p><pre className="pcr-payload pcr-payload--danger" data-testid="tool-failure">{failure}</pre></>
)}
{status === "interrupted" ? (
<p className="pcr-message-divider" data-testid="interrupted-divider">
Interrupted — the last known status is shown and no result was fabricated.
</p>
) : null}
</details>
);
}
@@ -0,0 +1,25 @@
import * as React from "react";
/**
* Source-adapted shadcn Tooltip. The content is always mirrored into a
* described-by element, so a tooltip is never the only channel for a
* diagnostic (interaction map A5).
*/
export function Tooltip({
content,
id,
children,
}: {
content: string;
id: string;
children: React.ReactElement<{ "aria-describedby"?: string }>;
}) {
return (
<span data-slot="tooltip" className="pcr-tooltip">
{React.cloneElement(children, { "aria-describedby": id })}
<span role="tooltip" id={id} className="pcr-tooltip-content">
{content}
</span>
</span>
);
}
@@ -0,0 +1,39 @@
import * as React from "react";
import type { ConnectionStatus } from "../browser/protocol.js";
import { Banner, BannerActions, BannerText } from "./components/banner.js";
import { Button } from "./components/button.js";
export function ConnectionBanner({
connection,
reconnectAttempt,
onRetry,
}: {
connection: ConnectionStatus;
reconnectAttempt: number;
onRetry: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const blocking = connection === "disconnected";
React.useEffect(() => {
if (blocking) ref.current?.focus();
}, [blocking]);
if (connection !== "disconnected" && connection !== "reconnecting") return null;
return (
<Banner
ref={ref}
data-slot="connection-banner"
tone="warning"
assertive={blocking}
tabIndex={blocking ? -1 : undefined}
data-testid="reconnect-banner"
>
<BannerText>
{connection === "reconnecting"
? `Connection lost — reconnecting (attempt ${reconnectAttempt})…`
: "Connection lost — the session is still on the server."}
</BannerText>
<BannerActions><Button type="button" onClick={onRetry}>Retry now</Button></BannerActions>
</Banner>
);
}
@@ -0,0 +1,32 @@
export { Badge } from "./components/badge.js";
export { Banner, BannerActions, BannerText } from "./components/banner.js";
export { Button } from "./components/button.js";
export {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./components/card.js";
export { Composer } from "./components/composer.js";
export { Conversation, type ConversationProps } from "./components/conversation.js";
export { Dialog } from "./components/dialog.js";
export { Menu, type MenuItemDefinition } from "./components/menu.js";
export { Message, type MessageProps } from "./components/message.js";
export { ReasoningItem } from "./components/reasoning-item.js";
export { Tabs, type TabDefinition } from "./components/tabs.js";
export { Textarea } from "./components/textarea.js";
export { ToolItem, type ToolStatus } from "./components/tool-item.js";
export { Tooltip } from "./components/tooltip.js";
export { ConnectionBanner } from "./connection-banner.js";
export { Inspector } from "./inspector.js";
export { ReplayControls } from "./replay-controls.js";
export { RequestCard } from "./request-card.js";
export { RunnerConsoleApp, type RunnerConsoleAppProps } from "./runner-console-app.js";
export { SessionTimeline } from "./session-timeline.js";
export {
useRunnerConsole,
type RunnerConsole,
type SteeringChip,
type SteeringChipStatus,
} from "./use-runner-console.js";
@@ -0,0 +1,232 @@
import * as React from "react";
import type { PrpEvent } from "../protocol/phase1-contract.js";
import type { SessionSnapshot } from "../reducer/session-reducer.js";
import { Badge } from "./components/badge.js";
import { Tabs } from "./components/tabs.js";
import type { ConnectionStatus, RunnerSessionState } from "../browser/protocol.js";
import { capabilityRows } from "../browser/transcript-model.js";
const TABS = [
{ id: "events", label: "Events" },
{ id: "requests", label: "Requests" },
{ id: "capabilities", label: "Capabilities" },
{ id: "session", label: "Session" },
];
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = React.useState(false);
return (
<button
type="button"
className="pcr-copy"
aria-label={label}
onClick={() => {
void navigator.clipboard?.writeText(value).then(
() => setCopied(true),
() => setCopied(false),
);
}}
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* The debugging surface. Redaction is the server's job: the inspector renders
* redaction markers verbatim and never tries to reconstruct a redacted value.
*/
export function Inspector({
events,
snapshot,
state,
connection,
reconnectAttempt,
replayParity,
highlightItemId,
onHighlightItem,
}: {
events: readonly PrpEvent[];
snapshot: SessionSnapshot | null;
state: RunnerSessionState | null;
connection: ConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
highlightItemId: string | null;
onHighlightItem: (itemId: string | null) => void;
}) {
const [tab, setTab] = React.useState("events");
const [filter, setFilter] = React.useState("");
const [facet, setFacet] = React.useState<string | null>(null);
const filterId = React.useId();
const facets = React.useMemo(
() => [...new Set(events.map((event) => event.eventType))].sort(),
[events],
);
const visible = React.useMemo(
() =>
events.filter((event) => {
if (facet !== null && event.eventType !== facet) return false;
if (filter.length === 0) return true;
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
}),
[events, facet, filter],
);
return (
<section data-slot="inspector" className="pcr-inspector">
<Tabs items={TABS} selected={tab} onSelect={setTab} label="Protocol inspector">
{tab === "events" ? (
<div className="pcr-inspector-events">
<label className="pcr-visually-hidden" htmlFor={filterId}>
Filter events
</label>
<input
id={filterId}
className="pcr-inspector-filter"
data-testid="inspector-filter"
placeholder="Filter events"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="pcr-facets">
<button
type="button"
className="pcr-facet"
aria-pressed={facet === null}
onClick={() => setFacet(null)}
>
All
</button>
{facets.map((candidate) => (
<button
key={candidate}
type="button"
className="pcr-facet"
aria-pressed={facet === candidate}
onClick={() => setFacet(candidate)}
>
{candidate}
</button>
))}
</div>
<ol className="pcr-inspector-list" data-testid="inspector-events">
{visible.map((event, index) => (
<li
key={`${event.sourceEventId}:${index}`}
data-highlighted={
highlightItemId !== null && event.itemId === highlightItemId
}
>
<details
onToggle={(toggle) =>
onHighlightItem(
(toggle.currentTarget as HTMLDetailsElement).open
? (event.itemId ?? null)
: null,
)
}
>
<summary className="pcr-inspector-row">
<code className="pcr-seq">{event.sourceSeq}</code>
<code>{event.eventType}</code>
<code className="pcr-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
</summary>
<pre className="pcr-payload">{JSON.stringify(event, null, 2)}</pre>
<CopyButton
label={`Copy event ${event.sourceSeq} payload`}
value={JSON.stringify(event, null, 2)}
/>
</details>
</li>
))}
</ol>
</div>
) : null}
{tab === "requests" ? (
<dl className="pcr-inspector-grid" data-testid="inspector-requests">
{(snapshot?.requests ?? []).map((request) => (
<div key={request.requestId}>
<dt>
<code>{request.requestId}</code>
</dt>
<dd>
{request.type} — {request.status}
</dd>
</div>
))}
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
</dl>
) : null}
{tab === "capabilities" ? (
<ul className="pcr-capability-list" data-testid="inspector-capabilities">
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
(row) => (
<li key={row.name}>
<span>{row.name}</span>
<Badge tone={row.supported ? "success" : "warning"}>
{row.supported ? "supported" : "unsupported"}
</Badge>
{row.diagnostic === null ? null : (
<p className="pcr-capability-diagnostic">{row.diagnostic}</p>
)}
</li>
),
)}
</ul>
) : null}
{tab === "session" ? (
<dl className="pcr-inspector-grid" data-testid="inspector-session">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Normalized session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
<div>
<dt>Driver session</dt>
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
</div>
<div>
<dt>Source cursors</dt>
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
</div>
<div>
<dt>Integrity</dt>
<dd>{snapshot?.integrity ?? "—"}</dd>
</div>
<div>
<dt>Gaps</dt>
<dd>{snapshot?.gaps.length ?? 0}</dd>
</div>
<div>
<dt>Connection</dt>
<dd>
{connection}
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
</dd>
</div>
<div>
<dt>Credentials in browser</dt>
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
</div>
<div>
<dt>Live and replay reducer</dt>
<dd data-testid="replay-parity">
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
</dd>
</div>
</dl>
) : null}
</Tabs>
</section>
);
}
@@ -0,0 +1,92 @@
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { SessionItemSnapshot } from "../reducer/session-reducer.js";
import type { TranscriptEntry } from "../browser/transcript-model.js";
import { Composer } from "./components/composer.js";
import { Conversation } from "./components/conversation.js";
import { RequestCard } from "./request-card.js";
import { SessionTimeline } from "./session-timeline.js";
const item: SessionItemSnapshot = {
itemId: "assistant-1",
kind: "agentMessage",
status: "completed",
text: "Canonical answer",
};
const entries: TranscriptEntry[] = [
{
kind: "item",
key: "item:assistant-1",
position: 1,
role: "assistant",
item,
interrupted: false,
streaming: false,
toolName: null,
input: null,
output: null,
failure: null,
},
];
describe("public React contracts", () => {
it("renders reducer-backed transcript semantics and the item renderer seam", () => {
const html = renderToStaticMarkup(
<Conversation entries={entries} renderItemBody={(entry) => <mark>{entry.text}</mark>} />,
);
expect(html).toContain('data-slot="conversation"');
expect(html).toContain('role="log"');
expect(html).toContain('class="pcr-message"');
expect(html).toContain("<mark>Canonical answer</mark>");
});
it("keeps request status controls around the request-detail renderer", () => {
const request = {
kind: "request" as const,
key: "request:r1",
position: 2,
requestId: "r1",
requestKind: "command_approval",
type: "approval",
prompt: "Run command?",
status: "pending" as const,
actions: ["accept", "decline"],
details: { command: "printf safe" },
resolvedAction: null,
resolvedAt: null,
turnId: "turn-1",
};
const html = renderToStaticMarkup(
<ol><RequestCard request={request} onResolve={() => undefined} renderRequestDetail={() => <code>custom detail</code>} /></ol>,
);
expect(html).toContain('data-slot="request-card"');
expect(html).toContain("custom detail");
expect(html).toContain("Approve");
expect(html).toContain("Reject");
});
it("exposes composer slots and canonical session timeline without a parallel model", () => {
const composer = renderToStaticMarkup(
<Composer state="idle" value="hello" onValueChange={() => undefined} onSubmit={() => undefined} onStop={() => undefined} leadingActions={<span>leading</span>} trailingActions={<span>trailing</span>} />,
);
const timeline = renderToStaticMarkup(<SessionTimeline snapshot={null} state={null} />);
expect(composer).toContain('data-slot="composer"');
expect(composer).toContain("leading");
expect(composer).toContain("trailing");
expect(timeline).toContain('data-slot="session-timeline"');
});
it("keeps exact failed-item diagnostics in the rendered record", () => {
const failedEntries: TranscriptEntry[] = [{
...entries[0]!,
item: { ...item, status: "failed", text: "Partial output" },
failure: "exact upstream diagnostic",
}];
const html = renderToStaticMarkup(<Conversation entries={failedEntries} />);
expect(html).toContain('data-testid="item-failure"');
expect(html).toContain("exact upstream diagnostic");
});
});
@@ -0,0 +1,38 @@
import * as React from "react";
import { Button } from "./components/button.js";
import type { RunnerConsole } from "./use-runner-console.js";
export function ReplayControls({ replay }: { replay: RunnerConsole["replay"] }) {
const positionId = React.useId();
return (
<div data-slot="replay-controls" className="pcr-replay-stepper" data-testid="replay-controls">
<Button type="button" onClick={replay.togglePlay}>{replay.playing ? "Pause" : "Play"}</Button>
<Button type="button" variant="ghost" onClick={() => replay.step(-1)}>Step back</Button>
<Button type="button" variant="ghost" onClick={() => replay.step(1)}>Step forward</Button>
<label htmlFor={positionId}>Position</label>
<input
id={positionId}
type="range"
min={0}
max={replay.length}
value={replay.position}
data-testid="replay-position"
onChange={(event) => replay.setPosition(Number(event.target.value))}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
replay.step(-1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
replay.step(1);
} else if (event.key === " ") {
event.preventDefault();
replay.togglePlay();
}
}}
/>
<span>{replay.position} / {replay.length}</span>
</div>
);
}
@@ -0,0 +1,174 @@
import * as React from "react";
import { Badge } from "./components/badge.js";
import { Button } from "./components/button.js";
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "../browser/transcript-model.js";
const KIND_LABELS: Record<string, string> = {
command_approval: "Command",
file_approval: "File change",
permission_approval: "Permission",
user_input: "Input",
elicitation: "Input",
};
const ACTION_LABELS: Record<string, string> = {
accept: "Approve",
accept_for_session: "Approve for session",
decline: "Reject",
cancel: "Cancel",
submit: "Submit",
};
const STATUS_TONE = {
pending: "accent",
resolved: "success",
expired: "warning",
cancelled: "warning",
} as const;
function detailBlock(details: Record<string, unknown>): string | null {
const command = details.command;
if (typeof command === "string") return command;
const diff = details.diff;
if (typeof diff === "string") {
const path = typeof details.path === "string" ? `${details.path}\n` : "";
return `${path}${diff}`;
}
return null;
}
/**
* One card renders all five request kinds. Actions come from the upstream
* request contract, so the card can never invent an affordance the runner
* cannot honour, and the first click locks the row until the canonical
* resolved event arrives.
*/
export function RequestCard({
request,
onResolve,
focusRef,
renderRequestDetail,
}: {
request: TranscriptRequestEntry;
onResolve: (resolution: Record<string, unknown>) => void;
focusRef?: React.Ref<HTMLLIElement>;
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
}) {
const [answer, setAnswer] = React.useState("");
const [resolving, setResolving] = React.useState(false);
const answerId = React.useId();
const kindLabel = KIND_LABELS[request.requestKind] ?? "Request";
const pending = request.status === "pending";
const isInput = request.requestKind === "user_input" || request.requestKind === "elicitation";
const payload = detailBlock(request.details);
const detail = renderRequestDetail?.(request)
?? (payload === null ? null : <pre className="pcr-payload">{payload}</pre>);
React.useEffect(() => {
if (!pending) setResolving(false);
}, [pending]);
function submit(resolution: Record<string, unknown>) {
setResolving(true);
onResolve(resolution);
}
if (!pending) {
return (
<li
ref={focusRef}
data-slot="request-card"
className="pcr-request pcr-request--settled"
data-status={request.status}
data-testid="request-card"
tabIndex={-1}
>
<details>
<summary className="pcr-request-summary">
<span>
{kindLabel} · {request.status === "resolved"
? `resolved — ${ACTION_LABELS[request.resolvedAction ?? ""] ?? request.resolvedAction ?? "unknown"}`
: request.status === "expired"
? "expired before response"
: "cancelled"}
</span>
<Badge tone={STATUS_TONE[request.status]}>{request.status}</Badge>
</summary>
<p className="pcr-request-prompt">{request.prompt}</p>
<p className="pcr-request-meta">
<code>{request.requestId}</code>
{request.resolvedAt === null ? null : <time dateTime={request.resolvedAt}>{request.resolvedAt}</time>}
</p>
{detail}
</details>
</li>
);
}
return (
<li
ref={focusRef}
data-slot="request-card"
className="pcr-request"
data-status="pending"
data-testid="request-card"
tabIndex={-1}
>
<div className="pcr-request-header">
<span className="pcr-request-kind">{kindLabel}</span>
<Badge tone="accent">pending</Badge>
</div>
<p className="pcr-request-prompt">{request.prompt}</p>
<p className="pcr-request-meta">
<code>{request.requestId}</code>
</p>
{detail}
{isInput ? (
<div className="pcr-request-input">
<label htmlFor={answerId}>Answer</label>
<input
id={answerId}
data-testid="request-answer"
value={answer}
disabled={resolving}
onChange={(event) => setAnswer(event.target.value)}
/>
</div>
) : null}
<div className="pcr-request-actions">
{request.actions.map((action) => (
<Button
key={action}
type="button"
className={
action === "decline"
? "pcr-button--danger"
: action === "cancel"
? "pcr-button--quiet"
: action === "accept" || action === "submit"
? ""
: "pcr-button--secondary"
}
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
data-testid={`request-action-${action}`}
onClick={() =>
submit(
action === "submit"
? runtimeRequestSubmission(request.requestKind, answer)
: { action },
)
}
>
{ACTION_LABELS[action] ?? action}
</Button>
))}
</div>
{resolving ? (
<p className="pcr-request-resolving" role="status">
Resolving — waiting for the canonical resolved event.
</p>
) : null}
</li>
);
}
@@ -0,0 +1,219 @@
import * as React from "react";
import type { RunnerClientOptions } from "../browser/client.js";
import type { SessionItemSnapshot } from "../reducer/session-reducer.js";
import type { TranscriptRequestEntry } from "../browser/transcript-model.js";
import { Badge } from "./components/badge.js";
import { Banner, BannerActions, BannerText } from "./components/banner.js";
import { Button } from "./components/button.js";
import { Composer } from "./components/composer.js";
import { Conversation } from "./components/conversation.js";
import { Dialog } from "./components/dialog.js";
import { Tabs } from "./components/tabs.js";
import { ConnectionBanner } from "./connection-banner.js";
import { Inspector } from "./inspector.js";
import { ReplayControls } from "./replay-controls.js";
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./session-timeline.js";
import { useRunnerConsole } from "./use-runner-console.js";
const MOBILE_SEGMENTS = [
{ id: "chat", label: "Chat" },
{ id: "session", label: "Session" },
{ id: "inspector", label: "Inspector" },
];
const COMPACT_QUERY = "(max-width: 56.25rem)";
function useCompactLayout(): boolean {
const [compact, setCompact] = React.useState(
() => typeof window !== "undefined" && window.matchMedia?.(COMPACT_QUERY).matches === true,
);
React.useEffect(() => {
const query = window.matchMedia?.(COMPACT_QUERY);
if (query === undefined) return undefined;
const onChange = () => setCompact(query.matches);
onChange();
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return compact;
}
export interface RunnerConsoleAppProps extends RunnerClientOptions {
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
}
export function RunnerConsoleApp({
baseUrl,
fetchImpl,
eventSourceFactory,
renderItemBody,
renderRequestDetail,
}: RunnerConsoleAppProps = {}) {
const console_ = useRunnerConsole({ baseUrl, fetchImpl, eventSourceFactory });
const [draft, setDraft] = React.useState("");
const [inspectorOpen, setInspectorOpen] = React.useState(false);
const [segment, setSegment] = React.useState("chat");
const [resetOpen, setResetOpen] = React.useState(false);
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
const compact = useCompactLayout();
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
const pendingRequests = console_.transcript.filter(
(entry): entry is TranscriptRequestEntry => entry.kind === "request" && entry.status === "pending",
);
const viewingChild = console_.selectedThreadId !== null;
React.useEffect(() => {
const stored = window.localStorage.getItem("paperclip-runner.sdk.v1.inspector");
if (stored === "open") setInspectorOpen(true);
}, []);
React.useEffect(() => {
window.localStorage.setItem(
"paperclip-runner.sdk.v1.inspector",
inspectorOpen ? "open" : "closed",
);
}, [inspectorOpen]);
React.useEffect(() => {
const snapshot = console_.snapshot;
if (snapshot === null || console_.state === null) return;
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
setOutcomes((current) => ({
...current,
[console_.state!.manifest ?? "unknown"]: snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
}));
}, [console_.snapshot, console_.state]);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
function runManifest(id: string) {
const manifest = console_.manifests.find((entry) => entry.id === id);
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
}
const transcript = (
<>
<Conversation
entries={console_.transcript}
highlightItemId={highlightItemId}
onResolveRequest={(request, resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)}
{...(renderItemBody === undefined ? {} : { renderItemBody })}
{...(renderRequestDetail === undefined ? {} : { renderRequestDetail })}
/>
<ul className="pcr-steering-list" aria-label="Steering messages">
{console_.steeringChips.map((chip) => (
<li key={chip.id} className="pcr-steering-chip" data-status={chip.status} data-testid="steering-chip">
<Badge tone={chip.status === "acknowledged" ? "success" : chip.status === "pending" ? "accent" : "danger"}>{chip.status}</Badge>
<span>{chip.text}</span>
{chip.status === "rejected" || chip.status === "failed" ? (
<Button
type="button"
variant="ghost"
data-testid="steer-as-new-message"
onClick={() => {
console_.dismissSteeringChip(chip.id);
void console_.send(chip.text);
}}
>
Send as new message
</Button>
) : null}
</li>
))}
</ul>
</>
);
const composer = (
<div className="pcr-composer-region">
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
{pendingRequests.length > 0 ? (
<Banner tone="accent" assertive data-testid="pending-request-banner">
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
<BannerActions><Button type="button" onClick={() => document.querySelector<HTMLElement>('[data-slot="request-card"]')?.focus()}>Review</Button></BannerActions>
</Banner>
) : null}
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
disabledReason={viewingChild
? "Direct steering of child threads is not supported by this app-server."
: null}
/>
</div>
);
const sessionRail = (
<>
<ManifestList manifests={console_.manifests} selectedId={console_.selectedManifestId} onSelect={console_.selectManifest} onRun={runManifest} lastOutcome={outcomes} disabled={console_.busy} />
<ConnectionBlock connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} state={console_.state} pendingRequestCount={pendingRequests.length} onDrop={console_.dropConnection} onRetry={console_.retryNow} onScrollToOldestRequest={() => document.querySelector<HTMLElement>('[data-slot="request-card"]')?.focus()} />
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
<LineageTree lineage={console_.state?.lineage ?? []} selectedThreadId={console_.selectedThreadId} onSelect={console_.selectThread} />
<section data-slot="observation-checklist" className="pcr-panel">
<h2 className="pcr-panel-title">Demo state</h2>
<Button type="button" variant="ghost" onClick={() => setResetOpen(true)}>Reset demo state</Button>
{activeManifest === undefined ? null : (
<ol className="pcr-observation-list" data-testid="observation-checklist">
{activeManifest.expectedObservations.map((observation) => (
<li key={observation}>
<label><input type="checkbox" /> {observation}</label>
</li>
))}
</ol>
)}
</section>
</>
);
const inspector = (
<Inspector events={console_.events} snapshot={console_.snapshot} state={console_.state} connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} replayParity={console_.replayParity} highlightItemId={highlightItemId} onHighlightItem={setHighlightItemId} />
);
return (
<div className="pcr-root pcr-console" data-slot="runner-console-app" data-inspector={inspectorOpen}>
<header className="pcr-console-header">
<div className="pcr-console-title">
{console_.replay.active ? <Badge tone="warning" data-testid="replay-badge">REPLAY</Badge> : null}
<h1>{activeManifest?.name ?? "Runner reference console"}</h1>
{viewingChild ? (
<p className="pcr-breadcrumb" data-testid="breadcrumb">
root ▸ {console_.selectedThreadId}
</p>
) : null}
</div>
<div className="pcr-console-header-actions">
<Button type="button" variant="ghost" data-testid="toggle-inspector" aria-pressed={inspectorOpen} onClick={() => setInspectorOpen((current) => !current)}>Protocol inspector</Button>
<Button type="button" variant="ghost" data-testid="toggle-replay" aria-pressed={console_.replay.active} disabled={console_.events.length === 0} onClick={() => console_.replay.active ? console_.replay.exit() : console_.replay.enter()}>{console_.replay.active ? "Exit replay" : "Replay"}</Button>
</div>
</header>
<p className="pcr-visually-hidden" aria-live="polite" data-testid="runner-announcement">{console_.announcement}</p>
{console_.error === null ? null : <Banner tone="danger" assertive><BannerText>{console_.error}</BannerText></Banner>}
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
{compact ? (
<div className="pcr-console-mobile">
<Tabs items={MOBILE_SEGMENTS} selected={segment} onSelect={setSegment} label="Console sections">
{segment === "chat" ? <>{transcript}{composer}</> : null}
{segment === "session" ? sessionRail : null}
{segment === "inspector" ? inspector : null}
</Tabs>
</div>
) : (
<div className="pcr-console-desktop">
<aside className="pcr-console-rail">{sessionRail}</aside>
<main className="pcr-console-center">{transcript}{composer}</main>
{inspectorOpen ? <aside className="pcr-console-inspector" aria-label="Protocol inspector">{inspector}</aside> : null}
</div>
)}
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} title="Reset demo state" description="Discards the current live session transcript. Recorded evidence files are not touched." footer={<><Button type="button" variant="ghost" onClick={() => setResetOpen(false)}>Keep the session</Button><Button type="button" variant="danger" data-testid="reset-confirm" onClick={() => { setResetOpen(false); setDraft(""); void console_.reset(); }}>Reset demo state</Button></>} />
</div>
);
}
@@ -0,0 +1,375 @@
import * as React from "react";
import type { SessionSnapshot } from "../reducer/session-reducer.js";
import { Badge } from "./components/badge.js";
import { Button } from "./components/button.js";
import { Dialog } from "./components/dialog.js";
import { Menu } from "./components/menu.js";
import type {
GoalOperation,
ConnectionStatus,
RunnerLineageEntry,
ManifestSummary,
RunnerSessionState,
} from "../browser/protocol.js";
import { GOAL_OPERATION_LABELS } from "../browser/protocol.js";
import { goalAvailability } from "../browser/transcript-model.js";
const CONNECTION_TONE = {
idle: "neutral",
connecting: "accent",
connected: "success",
reconnecting: "warning",
disconnected: "danger",
terminal: "neutral",
} as const;
const THREAD_STATE_LABEL: Record<string, string> = {
starting: "Starting",
running: "Running",
completed: "Completed",
failed: "Failed",
};
/** Reducer-authoritative timeline, lineage, and goal summary. */
export function SessionTimeline({
snapshot,
state,
}: {
snapshot: SessionSnapshot | null;
state: RunnerSessionState | null;
}) {
const headingId = React.useId();
return (
<section data-slot="session-timeline" className="pcr-panel" aria-labelledby={headingId}>
<h2 id={headingId} className="pcr-panel-title">Session timeline</h2>
<dl className="pcr-identity-grid">
<div><dt>Goal</dt><dd>{state?.goal?.objective ?? "No goal set"}</dd></div>
<div><dt>State</dt><dd>{snapshot?.sessionState ?? "not started"}</dd></div>
</dl>
<ol className="pcr-timeline" data-testid="session-timeline">
{(snapshot?.timeline ?? []).map((entry) => (
<li key={entry.sourceEventId}>
<code>{entry.sourceSeq}</code><span>{entry.summary}</span>
</li>
))}
</ol>
</section>
);
}
export function ManifestList({
manifests,
selectedId,
onSelect,
onRun,
lastOutcome,
disabled,
}: {
manifests: readonly ManifestSummary[];
selectedId: string;
onSelect: (id: string) => void;
onRun: (id: string) => void;
lastOutcome: Record<string, string>;
disabled: boolean;
}) {
return (
<section data-slot="manifest-list" className="pcr-panel" aria-labelledby="manifest-heading">
<h2 id="manifest-heading" className="pcr-panel-title">
Demo chats
</h2>
<ul className="pcr-manifest-list" data-testid="manifest-list">
{manifests.map((manifest) => (
<li key={manifest.id} data-selected={manifest.id === selectedId}>
<button
type="button"
className="pcr-manifest-row"
aria-pressed={manifest.id === selectedId}
data-testid={`manifest-${manifest.id}`}
onClick={() => onSelect(manifest.id)}
>
<span className="pcr-manifest-name">{manifest.name}</span>
<span className="pcr-manifest-purpose">{manifest.purpose}</span>
<span className="pcr-manifest-badges">
{manifest.scenarios.map((scenario) => (
<Badge key={scenario}>{scenario}</Badge>
))}
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
{lastOutcome[manifest.id] ?? "not run"}
</Badge>
</span>
</button>
{manifest.id === selectedId ? (
<>
<details className="pcr-disclosure" data-testid="expected-observations">
<summary className="pcr-disclosure-summary">Expected observations</summary>
<ol className="pcr-observation-list">
{manifest.expectedObservations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ol>
</details>
<Button
type="button"
disabled={disabled}
data-testid="run-manifest"
onClick={() => onRun(manifest.id)}
>
Run this demo chat
</Button>
</>
) : null}
</li>
))}
</ul>
</section>
);
}
export function ConnectionBlock({
connection,
reconnectAttempt,
state,
pendingRequestCount,
onDrop,
onRetry,
onScrollToOldestRequest,
}: {
connection: ConnectionStatus;
reconnectAttempt: number;
state: RunnerSessionState | null;
pendingRequestCount: number;
onDrop: () => void;
onRetry: () => void;
onScrollToOldestRequest: () => void;
}) {
return (
<section data-slot="connection-block" className="pcr-panel" aria-labelledby="connection-heading">
<h2 id="connection-heading" className="pcr-panel-title">
Session
</h2>
<div className="pcr-status-row">
<span>Connection</span>
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
</Badge>
</div>
<div className="pcr-status-row">
<span>Pending requests</span>
<button
type="button"
className="pcr-chip"
data-testid="pending-request-chip"
disabled={pendingRequestCount === 0}
onClick={onScrollToOldestRequest}
>
{pendingRequestCount}
</button>
</div>
<div className="pcr-status-row">
<span>Credentials in browser</span>
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
{state?.credentialsExposed === true ? "exposed" : "none"}
</Badge>
</div>
<dl className="pcr-identity-grid">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
</dl>
<div className="pcr-panel-actions">
<Button
type="button"
className="pcr-button--quiet"
data-testid="drop-connection"
disabled={state === null || connection === "disconnected"}
onClick={onDrop}
>
Simulate connection loss
</Button>
<Button
type="button"
className="pcr-button--quiet"
data-testid="resume-session"
disabled={state === null}
onClick={onRetry}
>
Resume this session
</Button>
</div>
</section>
);
}
export function GoalBlock({
state,
onGoal,
}: {
state: RunnerSessionState | null;
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [objective, setObjective] = React.useState("");
const objectiveId = React.useId();
const triggerId = React.useId();
const capabilities = state?.capabilities ?? {
resume: false,
typedEvents: false,
steering: false,
interruption: false,
structuredResult: false,
};
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
const unsupported = capabilities.goals !== true;
const unsupportedReason = availability[0]?.reason ?? null;
return (
<section data-slot="goal-block" className="pcr-panel" aria-labelledby="goal-heading">
<div className="pcr-panel-header">
<h2 id="goal-heading" className="pcr-panel-title">
Goal
</h2>
<Menu
triggerId={triggerId}
label="Goal"
disabled={unsupported || state === null}
disabledReason={
state === null ? "Start a session before changing the goal." : unsupportedReason
}
items={availability.map((entry) => ({
id: entry.operation,
label: GOAL_OPERATION_LABELS[entry.operation],
enabled: entry.enabled,
reason: entry.reason,
}))}
onSelect={(id) => {
if (id === "set") setDialogOpen(true);
else onGoal(id as GoalOperation);
}}
/>
</div>
{state === null ? null : state.goal === null ? (
<p className="pcr-goal-empty" data-testid="goal-banner">
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
</p>
) : (
<div className="pcr-goal-banner" data-testid="goal-banner">
<p className="pcr-goal-objective">{state.goal.objective}</p>
<div className="pcr-status-row">
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
{state.goal.status}
</Badge>
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
</time>
</div>
</div>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Set goal"
description="The goal is durable on the thread and every change is also a transcript event."
footer={
<>
<Button type="button" className="pcr-button--quiet" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
data-testid="goal-set-confirm"
disabled={objective.trim().length === 0}
onClick={() => {
onGoal("set", { objective: objective.trim() });
setObjective("");
setDialogOpen(false);
}}
>
Set goal
</Button>
</>
}
>
<label htmlFor={objectiveId}>Objective</label>
<textarea
id={objectiveId}
className="pcr-dialog-input"
data-testid="goal-objective"
rows={3}
value={objective}
onChange={(event) => setObjective(event.target.value)}
/>
</Dialog>
</section>
);
}
export function LineageTree({
lineage,
selectedThreadId,
onSelect,
}: {
lineage: readonly RunnerLineageEntry[];
selectedThreadId: string | null;
onSelect: (threadId: string | null) => void;
}) {
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
const current = selectedThreadId ?? rootId;
const treeRef = React.useRef<HTMLUListElement>(null);
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const rows = [
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
];
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowDown") {
event.preventDefault();
rows[Math.min(index + 1, rows.length - 1)]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
rows[Math.max(index - 1, 0)]?.focus();
}
}
return (
<section data-slot="lineage-tree" className="pcr-panel" aria-labelledby="lineage-heading">
<h2 id="lineage-heading" className="pcr-panel-title">
Threads
</h2>
<ul
ref={treeRef}
role="tree"
aria-label="Session thread lineage"
className="pcr-tree"
data-testid="lineage-tree"
onKeyDown={onKeyDown}
>
{lineage.length === 0 ? <li className="pcr-tree-empty">No threads yet.</li> : null}
{lineage.map((entry) => (
<li key={entry.threadId} role="none" data-depth={entry.depth}>
<button
type="button"
role="treeitem"
aria-current={entry.threadId === current}
aria-selected={entry.threadId === current}
className="pcr-tree-row"
data-testid={`thread-${entry.threadId}`}
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
>
<span className="pcr-state-dot" data-state={entry.status} aria-hidden="true" />
<code>{entry.nickname ?? entry.threadId}</code>
<span className="pcr-tree-state">
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
</span>
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,594 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PrpEvent } from "../protocol/phase1-contract.js";
import {
createSessionSnapshotFromMetadata,
reduceSessionEvents,
type SessionSnapshot,
} from "../reducer/session-reducer.js";
import {
createRunnerClient,
RunnerClientError,
type EventStreamHandle,
type RunnerClientOptions,
} from "../browser/client.js";
import type {
GoalOperation,
ConnectionStatus,
ManifestSummary,
RunnerSessionState,
} from "../browser/protocol.js";
import {
buildTranscript,
composerState,
type ComposerState,
type TranscriptEntry,
} from "../browser/transcript-model.js";
const SESSION_STORAGE_KEY = "paperclip-runner.sdk.v1.session";
const RECONNECT_DELAY_MS = 1500;
const MAX_RECONNECT_ATTEMPTS = 5;
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
export interface SteeringChip {
id: string;
text: string;
expectedTurnId: string;
status: SteeringChipStatus;
detail: string | null;
}
function reduceEvents(
state: RunnerSessionState | null,
events: readonly PrpEvent[],
): SessionSnapshot | null {
if (state === null) return null;
const seed = createSessionSnapshotFromMetadata({
fixtureName: state.snapshot.fixtureName,
identity: state.snapshot.identity,
capabilities: state.snapshot.capabilities,
});
return reduceSessionEvents(seed, events);
}
function errorMessage(cause: unknown): string {
if (cause instanceof RunnerClientError) return cause.message;
return cause instanceof Error ? cause.message : String(cause);
}
export interface RunnerConsole {
manifests: ManifestSummary[];
selectedManifestId: string;
selectManifest: (id: string) => void;
state: RunnerSessionState | null;
events: PrpEvent[];
snapshot: SessionSnapshot | null;
transcript: TranscriptEntry[];
connection: ConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
composer: ComposerState;
steeringChips: SteeringChip[];
announcement: string;
error: string | null;
busy: boolean;
selectedThreadId: string | null;
selectThread: (threadId: string | null) => void;
replay: {
active: boolean;
position: number;
length: number;
playing: boolean;
enter: () => void;
exit: () => void;
setPosition: (position: number) => void;
togglePlay: () => void;
step: (delta: number) => void;
};
start: (input: { manifestId: string; message: string }) => Promise<void>;
send: (text: string) => Promise<void>;
steer: (text: string) => Promise<void>;
interrupt: () => Promise<void>;
resolve: (
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
) => Promise<void>;
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
dropConnection: () => void;
retryNow: () => void;
reset: () => Promise<void>;
dismissSteeringChip: (id: string) => void;
}
export function useRunnerConsole(options: RunnerClientOptions = {}): RunnerConsole {
const runnerClient = useMemo(
() => createRunnerClient(options),
[options.baseUrl, options.eventSourceFactory, options.fetchImpl],
);
const [manifests, setManifests] = useState<ManifestSummary[]>([]);
const [selectedManifestId, setSelectedManifestId] = useState("completion");
const [state, setState] = useState<RunnerSessionState | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [connection, setConnection] = useState<ConnectionStatus>("idle");
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
const [announcement, setAnnouncement] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [interrupting, setInterrupting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [replayActive, setReplayActive] = useState(false);
const [replayPosition, setReplayPosition] = useState(0);
const [replayPlaying, setReplayPlaying] = useState(false);
const streamRef = useRef<EventStreamHandle | null>(null);
const streamGenerationRef = useRef(0);
const cursorRef = useRef(0);
const sessionIdRef = useRef<string | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeStream = useCallback(() => {
streamGenerationRef.current += 1;
streamRef.current?.close();
streamRef.current = null;
if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const subscribe = useCallback(
(sessionId: string, attempt = 0) => {
closeStream();
const generation = streamGenerationRef.current;
setConnection(attempt === 0 ? "connecting" : "reconnecting");
setReconnectAttempt(attempt);
const open = () => {
if (streamGenerationRef.current !== generation) return;
streamRef.current = runnerClient.openEventStream(sessionId, cursorRef.current, {
onOpen: () => {
setConnection("connected");
setReconnectAttempt(0);
setAnnouncement(attempt === 0 ? "Connected" : "Reconnected with gap recovery");
},
onEvent: (event) => {
cursorRef.current += 1;
// Preserve the exact canonical stream, including deliberate
// duplicate ids. The shared reducer, not the hook, owns duplicate
// detection and replay integrity.
setEvents((current) => [...current, event]);
},
onError: () => {
closeStream();
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setConnection("disconnected");
setAnnouncement("Disconnected");
return;
}
setConnection("reconnecting");
setReconnectAttempt(attempt + 1);
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
retryTimerRef.current = setTimeout(
() => subscribe(sessionId, attempt + 1),
RECONNECT_DELAY_MS,
);
},
});
};
if (attempt === 0) {
open();
return;
}
void runnerClient.readEvents(sessionId, cursorRef.current).then((history) => {
if (streamGenerationRef.current !== generation) return;
setEvents((current) => [...current, ...history.events]);
cursorRef.current = history.cursor;
open();
}).catch(open);
},
[closeStream, runnerClient],
);
const adopt = useCallback(
(next: RunnerSessionState) => {
setState(next);
setError(null);
},
[],
);
const attach = useCallback(
async (sessionId: string, resume: boolean) => {
const [next, history] = await Promise.all([
runnerClient.readSession(sessionId),
runnerClient.readEvents(sessionId, 0),
]);
sessionIdRef.current = sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
adopt(next);
setEvents(history.events);
cursorRef.current = history.cursor;
if (resume) setAnnouncement("Replayed the durable transcript");
subscribe(sessionId, 0);
},
[adopt, runnerClient, subscribe],
);
useEffect(() => {
let cancelled = false;
void runnerClient
.fetchManifests()
.then((list) => {
if (cancelled) return;
setManifests(list);
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
})
.catch((cause) => {
if (!cancelled) setError(errorMessage(cause));
});
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
if (stored !== null) {
void attach(stored, true).catch(() => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
});
}
return () => {
cancelled = true;
};
// Runs once: session restoration is a mount concern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => closeStream(), [closeStream]);
// Lineage, goal, pending requests, and the server cursor live in the public
// state, so a stream that only carries events would leave them stale.
useEffect(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null || events.length === 0) return undefined;
const timer = setTimeout(() => {
void runnerClient
.readSession(sessionId)
.then((next) => setState(next))
.catch(() => undefined);
}, 150);
return () => clearTimeout(timer);
}, [events.length, runnerClient]);
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
const visibleEvents = useMemo(
() => (replayActive ? events.slice(0, replayPosition) : events),
[events, replayActive, replayPosition],
);
const snapshot = useMemo(
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
[replayActive, state, visibleEvents, liveSnapshot],
);
const replayParity = useMemo(() => {
if (state === null || liveSnapshot === null) return null;
if (state.cursor !== events.length) return null;
return (
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
liveSnapshot.integrity === state.snapshot.integrity
);
}, [state, liveSnapshot, events.length]);
const transcript = useMemo(
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
[snapshot, visibleEvents],
);
useEffect(() => {
if (!replayPlaying) return undefined;
if (replayPosition >= events.length) {
setReplayPlaying(false);
return undefined;
}
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
return () => clearTimeout(timer);
}, [replayPlaying, replayPosition, events.length]);
// Steering chips resolve against canonical acknowledgements only.
useEffect(() => {
setSteeringChips((chips) => {
if (chips.every((chip) => chip.status !== "pending")) return chips;
const acknowledged = events.filter(
(event) =>
event.eventType === "item.completed" &&
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
);
let index = 0;
return chips.map((chip) => {
if (chip.status !== "pending") return chip;
const match = acknowledged[index];
index += 1;
return match === undefined
? chip
: { ...chip, status: "acknowledged" as const, detail: null };
});
});
}, [events]);
// The reducer is authoritative for the active turn. Falling back to the
// last public-state read would keep a finished turn alive in the composer.
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
// A submitted turn that has not been accepted yet is a protocol fact, not a
// local flag: it is what makes interrupt-before-start reachable.
const awaitingTurnStart = useMemo(() => {
let pending = false;
for (const entry of snapshot?.timeline ?? []) {
if (entry.eventType === "turn.submitted") pending = true;
else if (entry.eventType.startsWith("turn.")) pending = false;
}
return pending;
}, [snapshot]);
const composer = composerState({
connection,
submitting: submitting || awaitingTurnStart,
interrupting,
activeTurnId,
sessionState: snapshot?.sessionState ?? "not_started",
});
const guard = useCallback(async (task: () => Promise<void>) => {
setBusy(true);
try {
await task();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}, []);
const start = useCallback(
async (input: { manifestId: string; message: string }) => {
await guard(async () => {
closeStream();
setEvents([]);
cursorRef.current = 0;
setSteeringChips([]);
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setSubmitting(true);
const manifest = manifests.find((entry) => entry.id === input.manifestId);
try {
const created = await runnerClient.createSession({
manifest: input.manifestId,
objective: manifest?.objective ?? "Run the runner console demo.",
message: input.message,
});
sessionIdRef.current = created.sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
adopt(created);
setAnnouncement("Turn running");
subscribe(created.sessionId, 0);
} finally {
setSubmitting(false);
}
});
},
[adopt, closeStream, guard, manifests, runnerClient, subscribe],
);
const refreshState = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
adopt(await runnerClient.readSession(sessionId));
}, [adopt, runnerClient]);
const send = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) {
await start({ manifestId: selectedManifestId, message: text });
return;
}
await guard(async () => {
setSubmitting(true);
try {
adopt(await runnerClient.startTurn(sessionId, text));
setAnnouncement("Turn running");
} finally {
setSubmitting(false);
}
});
},
[adopt, guard, runnerClient, selectedManifestId, start],
);
const steer = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
const expectedTurnId = activeTurnId;
if (sessionId === null || expectedTurnId === null) return;
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
setSteeringChips((chips) => [
...chips,
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
]);
try {
adopt(await runnerClient.steerTurn(sessionId, expectedTurnId, text));
setAnnouncement("Steering sent");
} catch (cause) {
const stale =
cause instanceof RunnerClientError &&
(cause.code === "stale_turn" || cause.code === "already_terminal");
setSteeringChips((chips) =>
chips.map((chip) =>
chip.id === chipId
? {
...chip,
status: stale ? "rejected" : "failed",
detail: stale ? "turn already ended" : errorMessage(cause),
}
: chip,
),
);
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
await refreshState().catch(() => undefined);
}
},
[activeTurnId, adopt, refreshState, runnerClient, steeringChips.length],
);
const interrupt = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
setInterrupting(true);
setAnnouncement("Stopping");
try {
adopt(await runnerClient.interruptTurn(sessionId, activeTurnId));
setAnnouncement("Turn stopped");
} catch (cause) {
setError(errorMessage(cause));
} finally {
setInterrupting(false);
}
}, [activeTurnId, adopt, runnerClient]);
const resolve = useCallback(
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await runnerClient.resolveRequest(sessionId, requestId, turnId, resolution));
setAnnouncement("Request resolved");
});
},
[adopt, guard, runnerClient],
);
const goal = useCallback(
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await runnerClient.goalOperation(sessionId, operation, body));
setAnnouncement(`Goal ${operation}`);
});
},
[adopt, guard, runnerClient],
);
const dropConnection = useCallback(() => {
const sessionId = sessionIdRef.current;
closeStream();
setConnection("reconnecting");
setReconnectAttempt(1);
setAnnouncement("Connection lost; automatic gap recovery scheduled");
if (sessionId !== null) {
retryTimerRef.current = setTimeout(
() => subscribe(sessionId, 1),
RECONNECT_DELAY_MS,
);
}
}, [closeStream, subscribe]);
const retryNow = useCallback(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
void guard(async () => {
const resumed = await runnerClient.reconnectSession(sessionId);
adopt(resumed);
const history = await runnerClient.readEvents(sessionId, 0);
setEvents(history.events);
cursorRef.current = history.cursor;
subscribe(sessionId, 0);
setAnnouncement("Resumed the same session");
});
}, [adopt, guard, runnerClient, subscribe]);
const reset = useCallback(async () => {
const sessionId = sessionIdRef.current;
closeStream();
if (sessionId !== null) {
try {
await runnerClient.closeSession(sessionId);
} catch (cause) {
setConnection("disconnected");
setError(errorMessage(cause));
setAnnouncement("Demo reset failed");
return;
}
}
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
sessionIdRef.current = null;
cursorRef.current = 0;
setState(null);
setEvents([]);
setSteeringChips([]);
setConnection("idle");
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setError(null);
setAnnouncement("Demo state reset");
}, [closeStream, runnerClient]);
const replay = useMemo(
() => ({
active: replayActive,
position: replayPosition,
length: events.length,
playing: replayPlaying,
enter: () => {
setReplayActive(true);
setReplayPosition(0);
setReplayPlaying(false);
},
exit: () => {
setReplayActive(false);
setReplayPlaying(false);
},
setPosition: (position: number) =>
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
togglePlay: () => setReplayPlaying((current) => !current),
step: (delta: number) =>
setReplayPosition((current) =>
Math.min(Math.max(current + delta, 0), events.length),
),
}),
[events.length, replayActive, replayPlaying, replayPosition],
);
return {
manifests,
selectedManifestId,
selectManifest: setSelectedManifestId,
state,
events,
snapshot,
transcript,
connection,
reconnectAttempt,
replayParity,
composer,
steeringChips,
announcement,
error,
busy,
selectedThreadId,
selectThread: setSelectedThreadId,
replay,
start,
send,
steer,
interrupt,
resolve,
goal,
dropConnection,
retryNow,
reset,
dismissSteeringChip: (id: string) =>
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
import { useRunnerConsole } from "../../../../src/react/use-runner-console";
void useRunnerConsole;
@@ -6,7 +6,18 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": ".",
"types": ["vite/client"]
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@paperclipai/paperclip-runner/browser": ["src/browser/index.ts"],
"@paperclipai/paperclip-runner/react": ["src/react/index.ts"],
"@paperclipai/paperclip-runner/styles.css": ["styles.css"]
}
},
"include": ["devtools/browser/src/**/*.ts", "devtools/browser/src/**/*.tsx"]
"include": [
"devtools/browser/src/**/*.ts",
"devtools/browser/src/**/*.tsx",
"examples/*/src/**/*.ts",
"examples/*/src/**/*.tsx"
]
}
+4 -2
View File
@@ -1,9 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
@@ -0,0 +1,34 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { phase4bBrowserServerPlugin } from "./scripts/phase4b-browser-server.mjs";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "examples"),
plugins: [react(), phase4bBrowserServerPlugin()],
resolve: {
alias: [
{ find: "@paperclipai/paperclip-runner/browser", replacement: resolve(packageRoot, "src/browser/index.ts") },
{ find: "@paperclipai/paperclip-runner/react", replacement: resolve(packageRoot, "src/react/index.ts") },
{ find: "@paperclipai/paperclip-runner/styles.css", replacement: resolve(packageRoot, "styles.css") },
],
},
server: { host: "127.0.0.1" },
preview: { host: "127.0.0.1" },
build: {
outDir: resolve(packageRoot, "dist-phase5"),
emptyOutDir: true,
target: "esnext",
rollupOptions: {
input: {
reference: resolve(packageRoot, "examples/reference-console/index.html"),
mini: resolve(packageRoot, "examples/mini-consumer/index.html"),
},
},
},
});
@@ -5,6 +5,7 @@ export default defineConfig({
environment: "node",
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"devtools/browser/src/**/*.test.ts",
"scripts/phase2-browser-server.test.mjs",
"scripts/phase4b-browser-server.test.mjs",