feat(runner): consolidate remaining Phase 5 and 6 work

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-11 11:45:45 +00:00
1 parent cf27ba2869
commit ae80fe4d00
43 files changed
+1857 -553

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
/dist-phase5/
/playwright-report/
/test-results/
/dist-phase6/
/dist-phase7/
/dist-issue-thread/
+19 -9
View File
@@ -24,13 +24,13 @@ 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 6 adds a default-off Paperclip adapter at the public control-plane/session
boundary, durable PRP replay and result finalization, and a kill switch that
keeps the legacy adapter path as the default.
Its audit-only attention path also terminates through the public port/finalizer
call graph: duplicate and stale requests commit exact interaction receipts with
no status decision, wake, notification, or issue status/version change, and a
committed coordinator makes replay a no-op.
Phase 6 currently adds a package-local standalone adapter demo at the public
control-plane/session boundary. It proves PRP replay, reducer projection,
result finalization, a default legacy path, a feature-flagged native demo path,
and a kill switch without contacting or modifying a Paperclip instance.
Consumers import this demo contract through the declared
`@paperclipai/paperclip-runner/standalone` subpath instead of package source
files.
## Phase 0–5 quick start
@@ -81,8 +81,12 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
# Phase 5: open the public-SDK reference console and mini consumer.
pnpm --filter @paperclipai/paperclip-runner console:phase5
# Phase 6: prove the mock-side integration contract.
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --target mock --scenario happy-path
# Phase 6: run the standalone legacy/native/kill-switch tracer and page.
pnpm --filter @paperclipai/paperclip-runner trace:phase6
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled --kill-switch enabled
pnpm --filter @paperclipai/paperclip-runner demo:phase6
```
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
@@ -164,3 +168,9 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
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.
The Phase 5 reference console opens in direct chat mode. Enter a normal prompt,
then open the protocol inspector to review events and reducer state. Expand a
Terminal row and its nested **Debug details** disclosure to inspect every
canonical event retained for that command. The header marker `🖇️ v0.1.2`
identifies the current console iteration.
@@ -6,7 +6,7 @@ Status: implemented from the approved [Phase 5 component plan](phase-5-component
## 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
versioned `0.1.2` 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.
@@ -53,6 +53,9 @@ consumers can opt into rich item bodies through the one renderer contract.
- The reference console renders one React tree selected at the 900px
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
live regions.
- The compact layout keeps the transcript and composer as the primary view.
A single accessible Menu opens session controls or the protocol inspector in
a modal panel; secondary controls never replace or duplicate the chat tree.
- A real provider may explicitly disable goals. The consumer gates buttons on
that capability and preserves the upstream diagnostic instead of emulating
a goal.
@@ -61,10 +64,43 @@ consumers can opt into rich item bodies through the one renderer contract.
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.
- Assistant text uses the canonical reducer text but reveals queued additions
progressively at the character level. Reduced-motion clients receive the
complete current text immediately; protocol events and replay stay unchanged.
- A completed assistant item that contains a semantic result JSON object shows
its summary and disposition as the primary agent response. Completion checks,
remaining work, and the exact protocol JSON stay behind a `Completion details`
disclosure, while the protocol inspector continues to expose the full event
record.
- Transcript follow mode anchors the latest settled assistant response instead
of terminal or diagnostic events appended after it. The complete ordered
transcript remains available below the response, and manual scrolling still
disengages follow mode.
## Compatibility rule
`0.1.0` is the first frozen SDK surface. Any later removal or semantic change
`0.1.2` is the current 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.
## Direct chat presentation
The reference console opens in a normal Codex chat mode. This mode sends plain
user text and permits follow-up turns in the same provider thread. It does not
add the Phase 4 task envelope, semantic completion tools, or output schema.
The workspace sandbox and server-only provider authentication stay active.
The deterministic Phase 4 fixtures remain selectable for protocol tests. The
chat layout removes decorative panel outlines and keeps the protocol inspector
available on demand. The header includes a package version and an iteration
emoji so a reviewer can identify the deployed build.
## Terminal diagnostics and responsive wrapping
`TranscriptItemEntry.debugEvents` is an additive, optional projection of the
canonical item events already retained by the client. `ToolItem` shows this
data in a nested folded disclosure rather than inventing a second diagnostic
model. Long commands, payloads, and assistant text wrap within the center
column; mobile layouts keep every transcript descendant intrinsically
shrinkable so the page does not gain a horizontal scrollbar.
@@ -1,5 +1,11 @@
# Phase 6 Thin Paperclip Adapter Boundary
> **Scope correction — 2026-08-09:** The board superseded real Paperclip
> installation and live-instance execution for the current checkpoint. Active
> Phase 6 work is limited to the standalone demo runner and demo page under
> `packages/paperclip-runner/`. Product/server/database sections below are
> retained as historical design context, not current execution instructions.
Status: design approved; remediation 8 documentation reconciliation complete; final CTO gate pending
Date: 2026-08-09
Decision scope: the first feature-flagged Paperclip native run, including the
+20 -2
View File
@@ -2,7 +2,19 @@
## Public package surface
Phase 5 freezes the first browser SDK as package version `0.1.0`.
Phase 5 freezes the browser SDK as package version `0.1.2`.
Version `0.1.1` adds direct chat mode. The browser sends the operator message
as plain Codex input. The protocol inspector still shows the event stream,
reducer state, requests, and replay data. The Phase 4 task fixtures remain
available from the session controls.
Version `0.1.2` adds the canonical events for each transcript item to the
optional `debugEvents` projection. `ToolItem` renders these events inside a
nested `Debug details` disclosure. Completed Terminal rows stay folded by
default, while operators can inspect command input, output, provider items,
delta updates, event ids, sequence numbers, and timestamps. Long chat and
command content wraps at mobile widths without horizontal page scrolling.
| Import | Purpose |
| --- | --- |
@@ -68,9 +80,15 @@ Components take reducer/protocol shapes. Every component owns a stable
lineage, goal, connection, and replay states come only from canonical events
or public session state.
The reference server has two driver modes behind the same HTTP/SSE contract.
Deterministic manifests use `Phase4bScriptedDriver`. Real chat mode uses
`CodexAppServerDriver`, which starts a real local `codex app-server` process.
The Node server owns provider authentication and the disposable working
directory; browser code receives only canonical runner events.
## Five extension points
These are the complete extension surface for `0.1.0`:
These are the complete extension surface for `0.1.2`:
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
`renderItemBody(item)` for markdown, highlighting, or another item body.
@@ -1,174 +1,58 @@
# Phase 6 Thin Paperclip Adapter
# Phase 6 Standalone Adapter Demo
Phase 6 adds one production integration seam without moving runner behavior
into Paperclip core. Paperclip owns workspace lifecycle, company/auth scope,
budgets, approvals, audit, cancellation, durable persistence, and issue status.
The package owns normalized sessions, provider-driver construction, PRP event
production, and semantic result production.
Phase 6 currently proves the thin adapter boundary as a standalone package demo.
It does not install or enable the runner in a Paperclip instance. All active
implementation, documentation, tutorial, and evidence work remains under
`packages/paperclip-runner/`.
## Selection and rollback
Native mode requires both `experimental.enableNativeRunner=true` and an
eligible agent profile:
The standalone resolver has two inputs:
```json
{
"nativeRunner": {
"mode": "native",
"backend": "codex_app_server",
"protocolVersion": 1
}
}
- the native demo feature flag, which defaults off;
- the kill switch, which always resolves to legacy.
The default path is legacy. Native mode is selected only when the demo flag is
enabled and the kill switch is disabled. Both paths use the public
`ControlPlanePort` contract and the canonical PRP reducer.
```sh
# Default legacy path
pnpm --filter @paperclipai/paperclip-runner trace:phase6
# Native standalone path
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled
# Kill-switch rollback
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled --kill-switch enabled
```
Only active local `codex_local` agents running standard, workspace-backed
issues are eligible. The default and the kill switch are legacy. An explicit
but ineligible native profile fails closed; there is no same-run fallback.
Selection, resolver version/reason, profile, runner instance, and completion
contract are persisted before provider execution.
## Standalone page
## Public boundary
Run:
`PaperclipControlPlanePort` implements the package's `ControlPlanePort`:
```sh
pnpm --filter @paperclipai/paperclip-runner demo:phase6
```
- `openRun` verifies company/run/issue/agent/contract binding;
- `loadSessionCheckpoint` and `checkpointSession` bind restart state to the
same company/run/issue/agent/session identity;
- `appendEvent` validates PRP, commits through the shared per-run allocator,
deduplicates source identity by canonical digest, and acknowledges afterward;
- `replayEvents` uses an exclusive source cursor;
- `completeRun` validates and idempotently persists the immutable structured
result plus terminal fact.
Then open `http://127.0.0.1:4182/phase6-standalone/`. The page exposes mode
selection, contract conformance, reducer projection, replay, finalization, and
native/legacy invocation counters. It makes no network call to Paperclip.
The package-owned Codex native backend is the production
`NativeSessionBackend`; the harness backend remains the deterministic test
adapter. Core passes only the closed `NativeExecutionInputV1`.
The model receives the smaller `NativeModelEnvelopeV1`; bindings and credential
references do not cross that boundary.
## Verified boundary
## Persistence and authority
The package-local tracer verifies:
Migration 0211 adds immutable completion contracts/results/assessments,
restart-safe finalization coordinators, status decisions/effects, issue status
versions, native run metadata, and native source identity on heartbeat events.
All event writers allocate from the row-locked `heartbeat_runs.next_event_seq`.
- identical public-port conformance for the selected demo adapter;
- ordered replay with duplicate idempotency and mutation rejection;
- canonical reducer projection through terminal success;
- idempotent result and terminal finalization;
- legacy default behavior and deterministic kill-switch rollback.
Finalization runs only after result persistence and workspace finalization. The
server verifies cited evidence against company- and issue-scoped durable
events, work products, approvals, interactions, or attachments; model claims
alone cannot complete an issue. The pure arbiter marks `done` only when the
terminal state succeeded, every criterion and verification has accepted
durable evidence, and no blocking work remains. It marks `blocked` only for a
task-wide first-class blocker with a named owner and action. Review, retry,
continuation, blocker, and recovery effects materialize atomically with the
status-version CAS and audit record. The committer has no catch-all delivery:
all native effect kinds must create or mutate their named target, and unknown
effects fail the transaction closed.
Company auth, budgets, approvals, audit, workspaces, and product persistence are
not claimed by this standalone checkpoint. Testing those concerns in a real
Paperclip instance is explicitly deferred until the board authorizes a separate
integration phase.
## Recovery and safety
Recovery selects persisted `runtime_mode=native` coordinators and does not
consult the current flag. Result-less transport loss retains the original run,
closed native input, provider checkpoint, retry attempt, and recovery action;
a database lease dispatches that same run after restart. Missing checkpoints,
cancellation, and retry exhaustion fail closed without opening a new provider
session or falling into legacy. Result-bearing rows continue through the
workspace/finalization reconciler. A recovered package session inspects the
provider snapshot before starting a turn, so an active provider turn is not
duplicated. Cancellation uses a run-scoped normalized session handle before the
existing process cleanup. Native execution does not construct a Paperclip JWT,
managed MCP access, legacy context, or raw adapter environment.
Resolved `request_confirmation` and `ask_user_questions` wakes are projected
through the existing authorized issue-interaction service and bound to the
company, issue, run, agent, and interaction identities in the persisted input.
Governed tool actions, unsupported kinds, unresolved rows, and self-resolution
fail closed; no credentials enter the model envelope.
## Verification status
The deterministic package suite and the embedded-PostgreSQL Paperclip suite
prove selection, replay/conflict handling, authoritative evidence,
status/liveness atomicity, migration repair, bounded cancellation/retry, and a
byte-equivalent legacy read snapshot. The thirteen-file database gate contains
46 tests with zero skips. The database suite is the internal
native canary: it executes one selected task through the public package session
contract and applies one server-owned decision. Its post-kill-switch legacy
case has zero native history rows.
The Section 18.13 database test executes fixture-specific production consumers
for all 52 fixtures. It derives and checks all eleven expected fields from
consumer return values and persisted production rows: run status,
status/preserve action, reason, required and forbidden effects, live-path kind,
claim preservation, native-record behavior, decision count, maximum wake
count, and maximum notification count. Each of the 70 unique matrix rows names
the responsible finalizer, terminal projection, attention, cancellation,
committer, reconciliation, compatibility, or migration consumer and fails if
that consumer did not execute or returned different semantics. A per-fixture
mutation check independently changes every expected field and proves the
comparison rejects it; there is no test-owned policy table supplying observed
semantics. Finalizer observations come from the live fact-based arbiter rather
than a fixture-state switch. Every delivered effect is joined to an actual
persisted target, replay is required to retain the same decision with one
delivery attempt, and an unknown-effect test proves the transaction leaves no
decision, ledger, issue-version, or coordinator mutation.
Operational rows use the production call graph, not resolver calls as a proxy.
Cancellation is committed and audited by the normalized session cancellation
entrypoint. Accepted attention starts at the immutable package-result row:
`finalizeNativeRun` calls `routePersistedNativeResultAttention`, records a
request-specific assessment, and then delegates an eligible same-company
target through `issueService`, creates a human issue-thread interaction, or
persists a cross-company rejection decision/recovery/audit trail.
Duplicate and stale targets finish as committed audit-only coordinators with
zero decisions, wakes, notifications, or issue status/version changes. Their
run receipt names the exact interaction target, and replay neither adds an
assessment nor updates that interaction again. Missing and cross-company audit
targets fail closed. `routePersistedNativeResultAttention` and
`routeNativeAttention` are internal helpers, not operational test ingresses.
Reconciliation selects the persisted
status/evidence/policy branches and executes workspace recovery through the
workspace operation recorder. The rollout kill switch is only the global
`experimental.enableNativeRunner` flag. Persisted native runs keep their mode
and finish/reconcile natively after flag-off; fresh unresolved runs for the
same agent persist legacy mode with `instance_flag_disabled`. The opted-in
agent profile remains unchanged, so re-enabling the flag does not require a
profile reset. Pending replay
only acknowledges its original decision-scoped target after verifying that the
target still exists.
Direct resolver and persisted-router calls are explicitly policy/internal
helper tests. They do not
satisfy an operational fixture without a runtime-reachable entrypoint receipt
and concrete durable target. Sabotage removes the finalizer-owned accepted-
attention path for the duplicate and stale fixtures, changes the real global-
flag input, removes cancellation/reconciliation actions, and deletes a pending
replay target; mapped fixtures fail while their pure resolver labels remain
unchanged.
Result-less same-run recovery is also proven through the production heartbeat
seam: a persisted envelope/checkpoint is leased by `reapOrphanedRuns`, enters
the original `executeRun`, recovers an already-active provider turn, and reaches
one persisted result, assessment, decision/effect set, and terminal heartbeat
projection. The test runs with the native flag disabled and asserts one run,
no new provider session or turn, no legacy adapter execution, lease-race
exclusion, cancellation/exhaustion exclusion, and missing-checkpoint
fail-closed behavior. The provider itself is scripted; this is not live Codex
evidence.
No new live provider task was dispatched during the remediation review. A live
Codex canary remains an operator-run rollout check because it requires changing
the instance flag and one agent profile. The package `trace:phase6` command is a
mock contract tracer; real Paperclip proof is the database-backed server matrix
and the explicitly gated procedure in the tutorial.
Deferred boundaries are unchanged: there is no attention UI or public
attention API, no external-system action execution, no credential delegation,
and no auto-approval. The Codex v1 structured-output schema authors compact
`kind`/`summary` attention requests; richer canonical PRP target metadata is
accepted and company-validated at the server boundary but does not yet have
provider authoring UX.
See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md), the
[verification record](../knowledge/evidence/2026-08-09-phase-06-verification.md),
and the [approved design](design/phase-6-thin-paperclip-adapter.md).
See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md) and the
[verification record](../knowledge/evidence/2026-08-09-phase-06-verification.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 5 browser SDK layer.
It currently includes Phase 0 through the Phase 6 Paperclip adapter tracer.
## What this tutorial proves
@@ -20,6 +20,8 @@ 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.
Phase 6 consumes the public runner contract from Paperclip behind a default-off
flag while preserving server-owned workspace, governance, and status authority.
## Current end-to-end path
@@ -62,6 +64,13 @@ subpaths, then proves them with a reference console and a second consumer.
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.
29. Follow [Phase 6: Run the Thin Paperclip Adapter](phase-06-thin-paperclip-adapter.md).
30. Run the unchanged port conformance suite against mock and database-backed
Paperclip ports, then inspect one local feature-flagged task.
31. Disable the flag and confirm a fresh task selects legacy while persisted
native finalization remains native.
32. Open the [Phase 6 implementation journal](../../knowledge/journal/2026-08-09-phase-06-implementation.md)
and its linked verification evidence.
The one-command form after installation is:
@@ -9,6 +9,11 @@ consumer.
The two apps use public package imports. They do not import the old demo UI.
Provider credentials stay in the local server.
The reference console starts in **Regular chat** mode. Type a message in the
composer to create a direct Codex session. Open **Protocol inspector** to see
the event stream and reducer state. Select another flow only when you want a
deterministic protocol fixture.
## What this phase proves
This tutorial proves that a second app can use the SDK without a private
@@ -39,6 +44,8 @@ Open these pages:
- `http://127.0.0.1:4181/reference-console/`
- `http://127.0.0.1:4181/mini-consumer/`
The page header shows `🖇️ v0.1.2`. Use this marker to confirm this iteration.
The fake driver is the default. It does not need a provider login.
## 3. Check the reference console
@@ -46,21 +53,32 @@ The fake driver is the default. It does not need a provider login.
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.
2. Expand a **Terminal** row, then expand **Debug details**. Confirm the folded
section contains canonical event metadata and provider payloads for the
command without leaving the chat transcript.
Assistant text should reveal progressively. For a real Codex semantic
completion, read the clear summary first and expand **Debug payload** to
inspect the exact structured JSON.
3. 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
4. Run each interrupt flow. Confirm that the visible outcome names the race.
5. 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
6. Run **Goal lifecycle**. Set and clear the goal from the Goal menu.
7. 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
8. 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
9. 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.
The clear response is a presentation of canonical provider output, not a
second result model. The raw event stream remains in **Protocol inspector**.
Resize the browser to 390 by 844. Chat and the composer must remain the primary
view. Open **Menu** to reach session controls, the protocol inspector, and
replay. The app must not create a second hidden transcript or a horizontal page
scrollbar.
## 4. Check the mini consumer
@@ -1,258 +1,74 @@
# Phase 6: Run the Thin Paperclip Adapter
# Phase 6 Standalone Adapter Demo
Phase 6 connects one local, issue-bound `codex_local` task to the runner's
public `ControlPlanePort`/`NativeSessionBackend` contract. Selection is
default-off, explicit per agent, and persisted before provider execution. The
runner never receives a Paperclip API key, local JWT, managed MCP token, raw
environment, wake payload, or skill instructions.
This tutorial exercises the Phase 6 tracer entirely inside
`packages/paperclip-runner/`. It does not install the runner into Paperclip,
call a Paperclip API, change an instance flag, edit an agent profile, create a
Paperclip task, or use the repository server/database integration.
## Prerequisites
## 1. Run the default legacy path
- a local Paperclip instance built from this branch;
- a board/operator API key in `PAPERCLIP_API_KEY` (never print it);
- `PAPERCLIP_API_URL`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_TASK_ID`;
- an active `codex_local` agent, a standard issue assigned to it, and a
realized local execution workspace;
- Codex already authenticated on the host.
Normalize the API base once:
From the repository root:
```sh
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
pnpm --filter @paperclipai/paperclip-runner trace:phase6
```
## 1. Prove the mock package contract
The JSON trace must report:
- `resolvedMode: "legacy"`;
- `resolutionReason: "feature_flag_disabled"`;
- `nativeAdapterInvocationCount: 0`;
- `legacyAdapterInvocationCount: 1`;
- complete replay, reducer, and finalization summaries.
## 2. Enable the native standalone path
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled
```
The trace must report `resolvedMode: "native"`. Both paths execute the same
public `ControlPlanePort` conformance suite and reduce the same canonical PRP
events. The adapter label and invocation counters make the selected path
inspectable.
## 3. Prove the kill switch
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled --kill-switch enabled
```
The trace must return to `resolvedMode: "legacy"` with
`resolutionReason: "kill_switch_enabled"`. No native adapter invocation is
allowed after the kill switch resolves the path.
## 4. Open the standalone demo page
```sh
pnpm --filter @paperclipai/paperclip-runner demo:phase6
```
Open `http://127.0.0.1:4182/phase6-standalone/`. The page starts on the legacy
path. Toggle the native demo flag, inspect contract/reducer/finalization state,
then enable the kill switch and confirm the page returns to legacy. Expand the
trace JSON to inspect replay and idempotency details.
## 5. Run the package-local checks
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/conformance/control-plane-port.test.ts \
src/backends/harness-driver-backend.test.ts \
src/contracts/native-execution.test.ts \
src/native-session-runtime.test.ts
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
--target mock --scenario happy-path
src/standalone/phase6-demo.test.ts \
src/conformance/control-plane-port.test.ts
pnpm --filter @paperclipai/paperclip-runner run build:phase6
```
Expected: four targeted files and seven tests pass, and the trace reports
`resolvedMode` as `native`, three replay-stable events, a successful workspace
barrier, and one mock server-owned `done` decision. This tracer does not connect
to Paperclip.
These checks require no running Paperclip service or credentials.
## 2. Prove the database-backed Paperclip port
## Human checkpoint
```sh
pnpm --filter @paperclipai/server exec vitest run \
src/__tests__/native-runner-phase6.integration.test.ts \
src/__tests__/heartbeat-native-runner-selection.test.ts \
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
src/__tests__/heartbeat-run-event-sequencing.test.ts \
src/__tests__/native-runner-input-boundary.test.ts \
src/__tests__/native-run-finalizer.test.ts \
src/__tests__/native-status-arbiter-corpus.test.ts \
src/__tests__/native-interaction-bridge.test.ts \
src/__tests__/native-session-resumption.test.ts \
src/__tests__/native-finalization-recovery.test.ts \
src/__tests__/native-finalization-migration.test.ts \
src/__tests__/legacy-finalization-regression.test.ts \
src/services/native-runtime/native-session-executor.test.ts
```
This runs the unchanged package conformance suite against the real Paperclip
port. Forty-six targeted tests check company binding, duplicate/gap replay,
checkpoint recovery, immutable result ingestion, durable-evidence authority,
workspace-gated finalization, atomic liveness rollback, status-version CAS,
migration repair, legacy byte equivalence, typed interaction materialization,
same-run pre-result resumption, lease races, and audit output. All database tests
start embedded PostgreSQL and do not skip when a developer database URL is
absent.
The corpus cases dispatch fixture-specific database shapes through live
cancellation, persisted-result attention, reconciliation, and heartbeat
selection entrypoints whenever an operational effect is claimed. Workspace
recovery records the actual operation result. Accepted attention is read back
from `native_run_results`, records a request-specific assessment, delegates an
eligible same-company agent through the issue service, routes human authority
through an issue-thread interaction, and rejects a cross-company target with a
durable decision/recovery/audit trail. The kill-switch case uses the global
instance flag: an active persisted native run remains native while a fresh run
for the same unchanged agent profile selects legacy. Pure resolver and
read-model calls remain useful policy tests but cannot satisfy these rows.
Each native effect must have a delivered, company-bound target or concrete
target-state mutation, and pending replay may only acknowledge its original
decision-scoped target. Sabotage tests remove each live action and the replay
target and require failure even while the pure resolver label remains correct.
Duplicate and stale audit-only cases start at
`PaperclipControlPlanePort.completeRun`, continue through `finalizeNativeRun`,
and commit the coordinator with no status decision. The run succeeds, the issue
status/version remains unchanged, and the receipt names the exact mutated
interaction. Replaying the finalizer does not create an assessment or update
the interaction again. A mixed duplicate/stale list has the same behavior;
missing and cross-company interaction bindings fail closed.
All eleven expected fields and all 70 matrix-row joins are still checked, and
an unknown-effect case proves the status transaction fails closed without
partial rows. The recovery case uses a scripted provider only at the backend
boundary while the real heartbeat reaper, lease claim, original run execution,
persistence port, finalizer, and terminal projection execute.
The command above is the repeatable scripted internal canary and actual
flag-off legacy-heartbeat proof. It is not a live-provider canary. The remaining
sections are an optional live-provider rollout procedure;
they were not run during remediation and require explicit operator authority to
change an instance flag and agent profile.
## 3. Enable one isolated agent
Save the agent's current runtime profile, merge the native profile, then enable
the instance flag. These endpoints require board/operator authority.
```sh
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" \
| jq '.runtimeConfig' > "$PHASE6_SCRATCH/phase6-runtime-before.json"
jq -n \
--slurpfile current "$PHASE6_SCRATCH/phase6-runtime-before.json" \
'{runtimeConfig: ($current[0] * {nativeRunner: {mode: "native", backend: "codex_app_server", protocolVersion: 1}})}' \
| curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enableNativeRunner":true}' \
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
```
## 4. Run one local native Paperclip task
```sh
PAPERCLIP_NATIVE_RUN_ID="$({
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\"},\"forceFreshSession\":true}" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
} | jq -r '.id')"
while :; do
PAPERCLIP_NATIVE_STATUS="$({
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID"
} | jq -r '.status')"
case "$PAPERCLIP_NATIVE_STATUS" in
succeeded|failed|cancelled|timed_out) break ;;
esac
sleep 2
done
test "$PAPERCLIP_NATIVE_STATUS" = succeeded
```
The expected run has `runtimeMode=native`, a persisted completion contract and
runner instance, no legacy adapter invocation, and a typed terminal fact.
## 5. Inspect replay and finalization
```sh
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/events?afterSeq=0&limit=200" \
| jq '[.[] | select(.payload.prpEvent != null)] | {count: length, events: map({seq, sourceSeq, sourceEventId, eventType})}'
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID" \
| jq '{runtimeMode, nativePhase, runnerInstanceId, completionContractId, resultJson}'
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID" \
| jq '{status, statusVersion, lastStatusDecisionId, unblockDescriptor}'
```
Repeat the events request with `afterSeq=<seq>`. Replay is exclusive and stable;
native result/finalization rows remain authoritative even if the flag changes.
## 6. Exercise cancellation and budget hard-stop
Start a deliberately long native task, then use the existing cancellation API:
```sh
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{}' \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/cancel" >/dev/null
```
The same run-scoped native cancel handle is invoked by explicit cancellation,
agent pause, and budget-pause enforcement. Resource, environment, scratch, and
workspace cleanup remain owned by the existing heartbeat path.
## 7. Disable the flag and prove legacy fallback
Set `PAPERCLIP_LEGACY_TASK_ID` to a fresh eligible issue assigned to the same
agent; the native proof issue may already be terminal.
```sh
curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enableNativeRunner":false}' \
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
PAPERCLIP_LEGACY_RUN_ID="$({
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_LEGACY_TASK_ID\"},\"forceFreshSession\":true}" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
} | jq -r '.id')"
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_LEGACY_RUN_ID" \
| jq '{runtimeMode, runtimeModeReason}'
```
Expected: the new run reports `legacy` and `instance_flag_disabled`; an already
selected native run still reconciles from its persisted native coordinator and
never falls through to the legacy adapter. The agent's `nativeRunner` profile
must still say `mode=native`; flag-off must not persist a second per-agent
disable bit.
Restore the saved profile after the proof:
```sh
jq -n \
--slurpfile saved "$PHASE6_SCRATCH/phase6-runtime-before.json" \
'{runtimeConfig: $saved[0]}' \
| curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
```
## Stop conditions
Stop if a native failure invokes legacy, credentials appear in model input or
stored PRP bytes, a workspace-finalization failure reports success, replay
changes bytes, a model-reported disposition bypasses the server arbiter, or
flag-off rewrites the agent profile.
This procedure does not exercise an attention UI or public attention endpoint;
neither exists in Phase 6. The Codex v1 result tool authors compact
`kind`/`summary` attention requests. Rich target metadata is a canonical PRP
input accepted and company-validated by the server, but provider authoring UX,
external-system action execution, credential delegation, and auto-approval are
deferred.
1. Confirm the page defaults to legacy.
2. Enable the native demo flag and compare the contract and reducer panels.
3. Inspect the replay and finalization fields in the JSON trace.
4. Enable the kill switch and confirm native invocations return to zero.
5. Confirm no Paperclip instance or repository path outside
`packages/paperclip-runner/` was needed.
@@ -9,12 +9,47 @@ const evidenceRoot = resolve(
);
mkdirSync(evidenceRoot, { recursive: true });
test("runs a direct multi-turn chat through the real Codex driver", async ({ page }) => {
await page.goto("/reference-console/");
await expect(page.getByTestId("console-version")).toHaveText("🖇️ v0.1.2");
await page.getByTestId("composer-input").fill("Reply with exactly: direct chat works");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("runner-transcript")).toContainText("direct chat works", {
timeout: 180_000,
});
await expect(page.getByTestId("composer-send")).toHaveText("Send", { timeout: 30_000 });
await page.getByTestId("composer-input").fill("Reply with exactly: follow-up works");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("runner-transcript")).toContainText("follow-up works", {
timeout: 180_000,
});
const body = await page.locator("body").innerText();
expect(body).not.toContain("Phase 4b boundary: operate only");
expect(body).not.toContain("Do not discover or invoke skills");
await page.getByTestId("toggle-inspector").click();
await expect(page.getByRole("complementary", { name: "Protocol inspector" })).toBeVisible();
await page.screenshot({
path: resolve(evidenceRoot, "reference-direct-chat-codex-1440x900.png"),
});
});
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.getByTestId("structured-response")).toBeVisible();
await expect(page.getByTestId("structured-response")).toContainText("Agent response");
await expect(page.getByText("Completion details", { exact: true })).toBeVisible();
const responseVisibility = await page.getByTestId("structured-response").evaluate((response) => {
const viewport = response.closest('[data-testid="runner-transcript"]');
if (!(viewport instanceof HTMLElement)) return false;
const responseBox = response.getBoundingClientRect();
const viewportBox = viewport.getBoundingClientRect();
return responseBox.top >= viewportBox.top && responseBox.top < viewportBox.bottom;
});
expect(responseVisibility).toBe(true);
await expect(page.getByText("Credentials in browser").locator("..")).toContainText("none");
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
@@ -9,13 +9,15 @@ 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") {
async function screenshotPair(page: Page, name: string, mobilePanel = "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.getByTestId("composer-input")).toBeVisible();
if (mobilePanel !== "chat") {
await page.getByRole("button", { name: "Menu" }).click();
await page.getByRole("menuitem", { name: mobilePanel === "session" ? "Session controls" : "Protocol inspector" }).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);
@@ -33,9 +35,24 @@ 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("console-version")).toHaveText("🖇️ v0.1.2");
await expect(page.getByTestId("manifest-list")).toBeVisible();
}
test("starts as a regular chat while keeping protocol debug available", async ({ page }) => {
await openReference(page);
await expect(page.getByRole("heading", { name: "Codex protocol chat" })).toBeVisible();
await page.getByTestId("composer-input").fill("Hello through the protocol");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("runner-transcript")).toContainText(
"normal chat carried through the runner protocol",
{ timeout: 15_000 },
);
await expect(page.locator("body")).not.toContainText("Phase 4b boundary: operate only");
await page.getByTestId("toggle-inspector").click();
await expect(page.getByRole("complementary", { name: "Protocol inspector" })).toBeVisible();
});
async function runManifest(page: Page, id: string) {
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
@@ -51,10 +68,32 @@ test.describe("Phase 5 reference console evidence", () => {
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
const streamingAnswer = page.locator('.pcr-message[data-role="assistant"] .pcr-message-text');
await expect(streamingAnswer).toBeVisible({ timeout: 15_000 });
const firstFrame = await streamingAnswer.innerText();
await expect.poll(async () => (await streamingAnswer.innerText()).length).toBeGreaterThan(firstFrame.length);
await expect(page.locator('.pcr-message[data-role="assistant"]')).toHaveAttribute("data-state", /streaming|settled/);
await screenshotPair(page, "streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const responseVisibility = await page.locator('.pcr-message[data-role="assistant"]').last().evaluate((response) => {
const viewport = response.closest('[data-testid="runner-transcript"]');
if (!(viewport instanceof HTMLElement)) return false;
const responseBox = response.getBoundingClientRect();
const viewportBox = viewport.getBoundingClientRect();
return responseBox.top >= viewportBox.top && responseBox.top < viewportBox.bottom;
});
expect(responseVisibility).toBe(true);
expect(await page.locator("body").innerText()).not.toContain("Bearer ");
expect(await page.locator("body").innerText()).not.toContain("auth.json");
const terminal = page.locator('[data-testid="tool-item"][data-view="terminal"]').first();
await terminal.locator(":scope > summary").click();
await expect(terminal).toContainText("Input");
await expect(terminal).toContainText("Output");
const debugDetails = terminal.getByTestId("tool-debug-details");
await debugDetails.locator("summary").click();
await expect(debugDetails).toContainText("sourceEventId");
await expect(debugDetails).toContainText("item.completed");
await screenshotPair(page, "terminal-debug");
});
test("captures steering and interrupt states", async ({ page }) => {
@@ -199,6 +238,10 @@ test("meets keyboard, accessibility, responsive, and grid contracts", async ({ p
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();
await page.getByTestId("toggle-replay").click();
const terminal = page.locator('[data-testid="tool-item"][data-view="terminal"]').first();
await terminal.locator(":scope > summary").click();
await terminal.getByTestId("tool-debug-details").locator("summary").click();
expect(await page.getByRole("log").count()).toBe(1);
expect(await page.locator('[aria-live="polite"]').count()).toBe(1);
@@ -215,8 +258,14 @@ test("meets keyboard, accessibility, responsive, and grid contracts", async ({ p
expect(boxes.center.right).toBeLessThanOrEqual(boxes.root.right);
await page.setViewportSize(MOBILE);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
await expect(page.getByTestId("composer-input")).toBeVisible();
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await page.getByRole("button", { name: "Menu" }).click();
await expect(page.getByRole("menuitem", { name: "Session controls" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Protocol inspector" })).toBeVisible();
await page.keyboard.press("Escape");
expect(await page.getByRole("log").count()).toBe(1);
await expect(terminal.getByTestId("tool-debug-details")).toContainText("sourceEventId");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
@@ -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 Phase 6 standalone demo</title>
</head>
<body><div id="root"></div><script type="module" src="/phase6-standalone/src/main.tsx"></script></body>
</html>
@@ -0,0 +1,66 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import {
runPhase6StandaloneDemo,
type Phase6StandaloneDemoResult,
} from "@paperclipai/paperclip-runner/standalone";
import "./styles.css";
function App() {
const [featureFlagEnabled, setFeatureFlagEnabled] = React.useState(false);
const [killSwitchEnabled, setKillSwitchEnabled] = React.useState(false);
const [result, setResult] = React.useState<Phase6StandaloneDemoResult | null>(null);
const [running, setRunning] = React.useState(false);
const run = React.useCallback(async () => {
setRunning(true);
try {
setResult(await runPhase6StandaloneDemo({ featureFlagEnabled, killSwitchEnabled }));
} finally {
setRunning(false);
}
}, [featureFlagEnabled, killSwitchEnabled]);
React.useEffect(() => { void run(); }, [run]);
return (
<main>
<header>
<p className="eyebrow">Package-local tracer</p>
<h1>Phase 6 standalone adapter demo</h1>
<p>No Paperclip instance, database, agent profile, or experimental setting is contacted or changed.</p>
</header>
<section className="controls" aria-label="Adapter controls">
<label><input type="checkbox" checked={featureFlagEnabled} onChange={(event) => setFeatureFlagEnabled(event.target.checked)} /> Enable native demo flag</label>
<label><input type="checkbox" checked={killSwitchEnabled} onChange={(event) => setKillSwitchEnabled(event.target.checked)} /> Enable kill switch</label>
<button type="button" onClick={() => void run()} disabled={running}>{running ? "Running…" : "Run tracer"}</button>
</section>
{result === null ? null : (
<>
<section className={`mode mode-${result.resolvedMode}`}>
<div><span>Resolved path</span><strong>{result.resolvedMode}</strong></div>
<div><span>Reason</span><strong>{result.resolutionReason.replaceAll("_", " ")}</strong></div>
<div><span>Adapter</span><strong>{result.adapter.replaceAll("_", " ")}</strong></div>
</section>
<section className="grid">
<article><h2>Contract</h2><p>{result.conformance.eventCount} events; duplicate replay {result.conformance.duplicateDisposition}.</p><p>Mutated event and result replays are rejected.</p></article>
<article><h2>Reducer</h2><p>{result.reducer.timelineCount} timeline entries; integrity {result.reducer.integrity}.</p><p>Terminal state: {result.reducer.runTerminalState}.</p></article>
<article><h2>Finalization</h2><p>Result and terminal fact persisted idempotently.</p><p>Disposition: {result.finalization.reportedWorkDisposition}.</p></article>
<article><h2>Rollback</h2><p>Native calls: {result.nativeAdapterInvocationCount}.</p><p>Legacy calls: {result.legacyAdapterInvocationCount}.</p></article>
</section>
<details><summary>Inspectable trace JSON</summary><pre>{JSON.stringify(result, null, 2)}</pre></details>
</>
)}
</main>
);
}
const root = document.getElementById("root");
if (root === null) throw new Error("Phase 6 standalone demo root is missing.");
createRoot(root).render(<React.StrictMode><App /></React.StrictMode>);
@@ -0,0 +1,28 @@
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #0b1020; color: #edf2ff; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #1a2850 0, #0b1020 48%); }
main { width: min(1080px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; }
header { max-width: 760px; }
.eyebrow { color: #93c5fd; font-size: 13px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(36px, 7vw, 68px); line-height: .98; }
header > p:last-child { color: #bec9e6; font-size: 18px; line-height: 1.6; }
.controls, .mode, article, details { border: 1px solid #304266; background: rgba(12, 20, 39, .88); box-shadow: 0 20px 60px rgba(0, 0, 0, .24); }
.controls { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin: 32px 0 20px; padding: 18px; border-radius: 16px; }
label { display: flex; gap: 9px; align-items: center; color: #dbe7ff; }
input { width: 18px; height: 18px; accent-color: #60a5fa; }
button { margin-left: auto; border: 0; border-radius: 10px; padding: 11px 18px; background: #60a5fa; color: #07101f; font: inherit; font-weight: 800; cursor: pointer; }
button:disabled { cursor: wait; opacity: .7; }
.mode { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; overflow: hidden; border-radius: 16px; }
.mode > div { display: grid; gap: 8px; padding: 22px; background: #101a31; }
.mode span { color: #8fa1c2; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; }
.mode strong { font-size: 20px; text-transform: capitalize; }
.mode-native { border-color: #3b82f6; }
.mode-legacy { border-color: #a78bfa; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; }
article { border-radius: 16px; padding: 22px; }
h2 { margin: 0 0 14px; font-size: 18px; }
article p { margin: 8px 0; color: #bdc9e4; line-height: 1.5; }
details { border-radius: 16px; padding: 18px 20px; }
summary { cursor: pointer; font-weight: 800; }
pre { overflow: auto; margin: 18px 0 0; padding: 16px; border-radius: 10px; background: #070b15; color: #b9e6ff; font-size: 12px; }
@media (max-width: 720px) { main { padding: 36px 0; } .mode, .grid { grid-template-columns: 1fr; } button { width: 100%; margin-left: 0; } }
@@ -27,7 +27,7 @@ pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
Results:
- Phase 5 Vitest: **3 files, 20 tests passed**.
- Phase 5 Vitest: **3 files, 21 tests passed**.
- TypeScript public and browser configurations: passed.
- Generated protocol source check: passed.
- SDK token gate: passed.
@@ -36,7 +36,8 @@ Results:
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.
extension points. The React contract also verifies that semantic Codex JSON is
rendered as a clear response while the exact payload remains inspectable.
# Fake-driver browser acceptance
@@ -45,14 +46,16 @@ 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
Result: **5 Playwright tests passed** in 16.3 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.
cursor replay, and live/replay reducer parity. The completion flow also checks
that the visible assistant answer grows between frames, proving progressive
character-level presentation over canonical streamed deltas.
# Real Codex evidence
@@ -89,9 +92,18 @@ 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.
The reviewer-facing tailnet preview was rebuilt with `build:phase5`, and the
watchdog-managed `vite preview` process was restarted so it served the new
bundle rather than the prior build. After reviewer feedback that protocol
events visually buried the answer, the console now anchors the latest settled
assistant response, presents the semantic summary as the dominant content, and
keeps checks plus raw JSON behind `Completion details`. A fresh real Codex turn
passed the viewport assertion at `/reference-console/`; a DOM scan again found
no bearer token or `auth.json`.
# Screenshots
The [`phase-05/`](phase-05/) directory contains 33 files:
The [`phase-05/`](phase-05/) directory contains 34 files:
- Reference console at 1440x900 and 390x844: idle, streaming, steering,
interrupt, pending and resolved requests, goal, reconnect, replay, and
@@ -99,6 +111,8 @@ The [`phase-05/`](phase-05/) directory contains 33 files:
- 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.
- A separate reviewer-facing live-preview capture at 1440x900 after the
production bundle rebuild.
- The redacted real Codex trace described above.
Visual inspection covered a desktop idle surface, mobile pending request,
@@ -118,7 +132,7 @@ 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**;
- Phase 5 targeted suite: **21 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;
@@ -140,3 +154,67 @@ 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.
# Direct chat iteration — 🪁 v0.1.1
The reviewer requested a normal Codex chat with protocol debug information.
Version `0.1.1` adds an explicit direct conversation mode. The mode sends plain
user text to Codex. It does not add the Phase 4 task envelope, semantic result
tools, or output schema. The safe disposable workspace and server-only provider
authentication remain active.
Verification:
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/react/public-contract.test.tsx \
src/browser/client.test.ts \
src/browser/transcript-model.test.ts
bash packages/paperclip-runner/scripts/verify-rootless-linux.sh \
pnpm --filter @paperclipai/paperclip-runner exec playwright test \
--config examples/playwright.config.ts
bash packages/paperclip-runner/scripts/verify-rootless-linux.sh \
pnpm --filter @paperclipai/paperclip-runner exec playwright test \
--config examples/playwright.real.config.ts --grep 'direct multi-turn'
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Results: TypeScript passed; 61 Vitest tests passed; 6 fake-driver browser tests
passed; the real Codex direct-chat test passed for two turns; documentation and
OKF validation passed. The real-chat screenshot is
`phase-05/reference-direct-chat-codex-1440x900.png`.
The refreshed preview returns HTTP 200 at
`http://paperclip-dev.tail29c1aa.ts.net:4195/reference-console/`. The page
header marker is `🪁 v0.1.1`.
# Review refresh — 🖇️ v0.1.2
Date: 2026-08-09
The package-local reference console now wraps long chat, command, and payload
content at mobile widths. Terminal rows expose command input and output, plus
every canonical item event retained by the transcript projection, inside a
nested folded `Debug details` disclosure. The browser still receives no Codex
credential: real chat is backed by the server-owned `CodexAppServerDriver` and
a real local `codex app-server` subprocess; deterministic manifests use the
scripted driver behind the same routes.
Focused verification:
```sh
pnpm --filter @paperclipai/paperclip-runner test:phase5
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
```
Results: 22 targeted Vitest tests passed; browser and package TypeScript
typechecks passed; browser tokens and documentation validation passed; all six
rootless Playwright flows passed. The suite explicitly opens Terminal and
`Debug details`, verifies canonical event fields, captures
`reference-terminal-debug-1440x900.png` and
`reference-terminal-debug-390x844.png`, and confirms no horizontal page
overflow at 390 by 844. The unchanged public preview returned HTTP 200 with
marker `🖇️ v0.1.2` and measured zero pixels of horizontal overflow.
@@ -285,3 +285,87 @@ legacy selection, and restoring the agent profile. Those commands intentionally
do not pass credentials to the package or model. They were not run during this
remediation because changing live instance and agent rollout state requires
operator approval; no new live-provider evidence is claimed here.
On 2026-08-09 the board accepted the issue-thread confirmation for the bounded
live checkpoint at commit `f0eb3d5c18`. The resumed heartbeat captured the
agent profile and verified that no rollout mutation had occurred, but its
run-scoped agent JWT received HTTP `403` from
`GET /api/instance/settings/experimental`. The checkpoint stopped before
patching either the agent profile or the instance flag. A board/operator-authenticated
execution context is still required to run tutorial sections 3 through 7; the
acceptance interaction authorizes the operation but does not elevate the
heartbeat credential.
# Standalone scope correction
On 2026-08-09 the board superseded the live-instance checkpoint. No runner may
be installed into a Paperclip instance, no product experimental setting or
agent profile may be changed, and no new work for this checkpoint may occur
outside `packages/paperclip-runner/`.
The replacement package-local tracer adds:
- `src/standalone/phase6-demo.ts`, which resolves native-demo versus legacy,
runs the selected path through the public `ControlPlanePort` conformance
suite, and reduces the same canonical PRP events;
- a default legacy path, an explicit native demo flag, and a kill switch that
deterministically returns to legacy;
- `examples/phase6-standalone/`, which displays selection, replay, reducer,
finalization, invocation counters, and inspectable JSON without network calls;
- a standalone tutorial with no Paperclip service or credential prerequisite.
Targeted verification:
```sh
pnpm --filter @paperclipai/paperclip-runner exec tsc -p tsconfig.json --noEmit
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/standalone/phase6-demo.test.ts src/conformance/control-plane-port.test.ts
pnpm --filter @paperclipai/paperclip-runner run build:phase6
```
Result: TypeScript passed; 2 test files and 5 tests passed; Vite built the
standalone page successfully. No Paperclip API or instance was used by these
checks.
## CTO review remediation
The first standalone CTO review found two defects: the example deep-imported a
source file, and both modes constructed the same adapter while reporting
synthetic selection counters. The remediation adds the declared
`@paperclipai/paperclip-runner/standalone` export with matching TypeScript and
Vite aliases and guard allowlist. It also adds distinct native-demo and
legacy-demo factories typed by `ControlPlanePort`. Only the resolved factory is
constructed, and invocation evidence is read from the selected adapter's
lifecycle counter.
Injected-spy tests prove that default mode constructs and invokes only legacy,
flag-enabled mode constructs and invokes only native, and flag plus kill switch
constructs and invokes only legacy. Each selected adapter still completes the
same conformance, replay, reducer, and finalization path.
Remediation verification:
```sh
pnpm --filter @paperclipai/paperclip-runner exec tsc -p tsconfig.json --noEmit
pnpm --filter @paperclipai/paperclip-runner exec tsc -p tsconfig.browser.json --noEmit
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/standalone/phase6-demo.test.ts src/conformance/control-plane-port.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner build:phase6
pnpm --filter @paperclipai/paperclip-runner docs:validate
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag disabled --kill-switch disabled
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled --kill-switch disabled
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --feature-flag enabled --kill-switch enabled
git diff --check
```
Result: both typechecks passed; 2 files and 5 tests passed; the standalone
boundary check passed; Vite built 209 modules; documentation validation passed
58 links and 25 OKF concepts across 4 indexes; all three traces reported only
the selected adapter with one invocation; and `git diff --check` passed.
## Screenshot handoff
The standalone page production build passed. Automated screenshot capture was
attempted twice but Chromium could not start in this runner because the host is
missing `libatk-1.0.so.0`. No screenshot is claimed. QA must open the documented
loopback page in a browser-capable environment and attach native-demo and
kill-switch screenshots during review.
@@ -9,6 +9,21 @@ entry_kind: phase
phase: "6"
---
# Superseding scope correction
The board clarified on 2026-08-09 that the current checkpoint must not install
or execute the runner in any Paperclip instance. Active implementation is now
the package-local standalone demo runner and demo page. Historical product
integration notes below remain evidence of prior work, not instructions for the
current checkpoint.
The standalone example consumes a declared
`@paperclipai/paperclip-runner/standalone` entrypoint. Native-demo and
legacy-demo are distinct injectable `ControlPlanePort` adapter factories. The
resolver constructs only the selected factory, and the selected adapter owns
the invocation evidence. This keeps the kill-switch claim observable instead
of deriving it from resolver metadata.
# Context
The approved Phase 6 design permits one narrow Paperclip integration at the
+9 -3
View File
@@ -41,9 +41,9 @@
runtime producing tool exposure, control-plane actions, authorization records,
a ten-domain immutable state diff, and a parity verdict that carries Phase 7E
output through without re-judging it; the read-only explorer over the frozen
SDK with hash routing, both transcript channels, and four inspector tabs; 45
unit and 19 browser tests; and the twenty-four-image screenshot acceptance
set.
0.1.2 SDK with hash routing, both transcript channels, and four inspector
tabs; 45 unit and 19 browser tests; and the twenty-four-image screenshot
acceptance set.
* **Phase 6 integration design**: Specified the default-off thin Paperclip
adapter seam, public port reconciliation, first-class persisted mode/result
@@ -61,6 +61,12 @@
## 2026-08-10
* **Phase 6 scope correction**: Superseded live-instance installation and
rollout work. Added a package-local native/legacy simulator, standalone demo
page, default-legacy selection, kill-switch rollback, shared public-port
conformance, reducer/replay/finalization inspection, and a no-instance
tutorial under `packages/paperclip-runner/`.
* **Phase 7M clean-room live chat**: Added a second primary path beside the
preset scenario explorer: a blank `#/chat` surface that mints a fresh mock
tenant (company, agent, one blank issue), drives real Codex through real
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@paperclipai/paperclip-runner",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -17,6 +17,10 @@
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js"
},
"./standalone": {
"types": "./dist/standalone/index.d.ts",
"import": "./dist/standalone/index.js"
},
"./styles.css": "./styles.css"
},
"files": [
@@ -26,11 +30,12 @@
"README.md"
],
"scripts": {
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5 && pnpm run build:phase7 && pnpm run build:issue-thread",
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5 && pnpm run build:phase6 && pnpm run build:phase7 && pnpm run build:issue-thread",
"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",
"build:phase6": "vite build --config vite.phase6.config.ts",
"build:phase7": "vite build --config vite.phase7.config.ts && node scripts/check-phase7-csp-bundle.mjs",
"build:issue-thread": "vite build --config vite.issue-thread.config.ts",
"generate:phase-07-contract": "node scripts/generate-phase-07-contract.mjs",
@@ -78,6 +83,8 @@
"trace:phase4": "pnpm run build:typescript && node dist/cli/phase4-codex.js",
"trace:phase6": "pnpm run build:typescript && node dist/cli/phase6-paperclip.js",
"trace:phase7": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node dist/cli/phase7-live.js",
"demo:phase6": "pnpm run build:typescript && vite --config vite.phase6.config.ts --host 127.0.0.1 --port 4182",
"demo:phase7": "pnpm run build:typescript && vite --config vite.phase7.config.ts --host 127.0.0.1 --port 4183",
"console:issue-thread": "pnpm run build:typescript && vite --config vite.issue-thread.config.ts --host 127.0.0.1 --port 4184",
"demo:phase4b": "pnpm run build:typescript && node scripts/phase4b-demo-server.mjs",
"record:phase4b": "pnpm run build:typescript && node scripts/record-phase4b-evidence.mjs",
@@ -95,7 +102,6 @@
"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",
"demo:phase7": "pnpm run build:typescript && vite --config vite.phase7.config.ts --host 127.0.0.1 --port 4183",
"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 test:phase5 && pnpm run test:phase7 && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:phase5 && pnpm run test:browser:phase7 && pnpm run check:forbidden-imports && pnpm run check:tracked-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",
@@ -85,6 +85,7 @@ function violationReason({ file, packageRoot, specifier }) {
const publicRunnerImports = new Set([
"@paperclipai/paperclip-runner/browser",
"@paperclipai/paperclip-runner/react",
"@paperclipai/paperclip-runner/standalone",
"@paperclipai/paperclip-runner/styles.css",
]);
if (
@@ -94,7 +95,7 @@ function violationReason({ file, packageRoot, 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";
return "runner consumers may import only declared public subpaths";
}
if (
specifier.startsWith("@paperclipai/") &&
@@ -55,6 +55,7 @@ export function createPhase4bBrowserMiddleware(options = {}) {
driverMode === "codex"
? new runner.CodexAppServerDriver({
taskEnvelope,
conversationMode: manifestId === "chat" ? "direct" : "task",
onDiagnostic: (message) => process.stderr.write(`[phase4b] ${message}\n`),
})
: new runner.Phase4bScriptedDriver({
@@ -405,11 +405,17 @@ conditionally authorized, but its issue graph remains uncreated until the Phase
## Phase 6 — Thin Paperclip integration adapter
**Purpose:** prove integration with the product without moving runner responsibilities into the core or changing legacy behavior.
**Scope correction (2026-08-09):** the board explicitly withdrew real-instance
installation and execution from the current phase. Phase 6 now proves the
adapter selection, public contract, reducer, replay, finalization, legacy
default, and kill switch in the standalone package demo only. Do not change a
Paperclip instance, server, database, agent profile, or product feature flag.
**Tracer bullet:** behind a feature flag, one Paperclip task uses the same package contract and reducer already proven against the mock core; legacy tasks continue on the current adapter path.
**Purpose:** prove the thin integration boundary in a standalone demo without moving runner responsibilities into the core or changing legacy behavior.
**Precondition:** explicit board acceptance of Phases 0–5 and CTO approval of the integration design. No production integration issue starts before this gate.
**Tracer bullet:** behind a package-local feature flag, one standalone demo run uses the same package contract and reducer already proven against the mock core; the default and kill-switch paths remain legacy.
**Precondition:** explicit board acceptance of Phases 0–5 and CTO approval of the standalone adapter design. Production integration remains separately gated.
**Gate status (2026-08-09):** the board accepted Phase 5 and authorized Phase 6
to start. Create the Phase 6 issue graph now, but keep implementation blocked on
@@ -425,31 +431,120 @@ No Phase 6 implementation code starts until the CTO gate accepts that record.
**Deliverables:**
- a package-owned `NativeSessionBackend`, a server-bound Paperclip
`ControlPlanePort`, and a narrow core seam that only composes them;
- feature-flagged runtime selection;
- mapping to existing workspace preparation/finalization, cancellation, budgets, approvals, audit, and issue status authority;
- native event persistence/replay adapter using the proven schema;
- a package-owned standalone native/legacy adapter simulator over `ControlPlanePort`;
- package-local feature-flagged runtime selection;
- canonical event replay, reducer projection, and finalization summaries;
- legacy compatibility tests and kill switch;
- integration tutorial that starts with mock mode, then enables one local Paperclip native run.
- a standalone browser demo and tutorial that require no Paperclip service.
**Tests/evidence:** contract suite runs against mock and real Paperclip adapters, legacy regression suite is unchanged, company scoping, cancellation, finalization, approval, audit, budget, and workspace tests pass.
**Tests/evidence:** the contract suite runs against the standalone selected adapter, reducer/replay/finalization assertions pass, the legacy default remains unchanged, and the kill switch returns selection to legacy. Product company/auth/governance checks are deferred with real integration.
**Owners/review:** CodexCoder implementation; SecurityEngineer mandatory company/auth/governance review; CTO mandatory architecture review; QA validates both native and legacy paths.
**Owners/review:** CodexCoder implementation; CTO reviews the package boundary; QA validates native-demo, legacy, and kill-switch paths. Security review is required only when real product auth/governance integration is separately authorized.
**Depends on:** Phases 4 and 5.
**Human checkpoint:** enable the feature flag for one test task, compare mock and product behavior, inspect replay and finalization, then disable it and confirm legacy behavior remains intact.
**Human checkpoint:** use the standalone page to compare native-demo and legacy behavior, inspect replay and finalization, enable the kill switch, and confirm legacy behavior remains intact without contacting Paperclip.
## Phase 7 — Portability, provider simulation, and release-quality reference kit
## Phase 7 — Mock Paperclip control plane, agent tools, and eval parity
**Purpose:** prove that the package is an SDK/reference implementation rather than a Codex-only product feature.
**Scope correction (2026-08-09):** do not bridge the runner into the real
Paperclip control plane yet. Phase 7 remains entirely under
`packages/paperclip-runner/` and proves that a skillless agent can still operate
Paperclip semantics through a mock adapter and a complete, testable tool suite.
**Tracer bullet:** run the same conformance scenario through a second driver or fake hosted-provider backend without changing protocol, reducer, browser, or tutorial structure.
**Purpose:** account for every behavior taught by the current Paperclip skill
and exercised by Paperclip Evals, then prove which behavior moves into the
control plane, which behavior always needs an agent tool, and which behavior
needs an optional role- or situation-specific tool.
**Tracer bullet:** open the package-local Web UI, choose an eval-derived
scenario, run a skillless fake or Codex-backed agent against the mock Paperclip
adapter, inspect every tool request and control-plane-owned action, and finish
with the same observable issue, comment, document, interaction, approval,
artifact, blocker, and run state expected by the corresponding eval case.
**Normative capability disposition:** Phase 7 produces a machine-readable
inventory with exactly one primary disposition for every skill/eval capability:
| Disposition | Meaning | Initial capability families |
| --- | --- | --- |
| `control_plane_owned` | The runner or mock control plane performs the action. The model does not receive a tool for the normal path. | identity and run credential injection; wake routing; inbox polling and work selection; atomic checkout, lock, release, retry, and deduplication; budget hard stops; audit recording; persistence/replay; blocker wake scheduling; final run reconciliation |
| `always_agent_tool` | A skillless agent needs a typed semantic operation when the task calls for the behavior. | read current task/context; report progress; finish, block, or request review with a durable explanation; read/write task documents and plans; request human input or confirmation; attach or register inspectable deliverables; inspect the result of an agent-initiated operation |
| `optional_agent_tool` | The capability is exposed only when role, task mode, policy, or explicit configuration permits it. | search/list issues; create or reassign subtasks; edit priority/status outside semantic completion; manage dependencies; inspect agents/projects/goals; request or decide approvals; control workspace services; manage routines, company skills, secrets, imports/exports, and other administrative surfaces; generic API escape hatch |
The detailed inventory must cover all 16 existing Paperclip Evals groups
(`hb`, `co`, `st`, `cm`, `se`, `su`, `bl`, `dp`, `ix`, `ap`, `ar`, `er`,
`rf`, `mh`, `rs`, and `wk`), every current Paperclip skill workflow/reference
section, and every tool in the existing 41-tool `packages/mcp-server` surface.
Each row records the source behavior, disposition, mock owner, proposed tool or
reason no tool exists, authorization policy, test cases, UI evidence, and any
intentional gap. The inventory may split one broad legacy function into a
control-plane action plus a narrower agent tool, but no capability may be
silently omitted.
**Deliverables:**
- ACP/acpx driver proof or another approved second driver;
- a package-local mock Paperclip `ControlPlanePort` adapter with deterministic
company, agent, issue, comment, document, interaction, approval, artifact,
blocker, workspace, budget, and run state;
- a transport-neutral semantic tool catalog generated from the capability
inventory, with explicit always/optional exposure policy and no raw
credential available to the model;
- a traceability manifest that maps the Paperclip Evals corpus, skill sections,
and existing MCP tools to mock scenarios and conformance assertions;
- an eval-compatible fixture/assertion importer or checked-in package-local
derivative, so Phase 7 does not depend on the `paperclip-evals` repository at
runtime;
- a browser scenario explorer using the established runner components to show
mock state, agent-visible tools, control-plane-owned actions, authorization
decisions, request/response history, and final parity verdict;
- reference documentation, a hand-run Phase 7 tutorial, cumulative tutorial
updates, screenshots, exact command evidence, and an OKF journal entry, all
under `packages/paperclip-runner/`.
**Tests/evidence:** inventory completeness checks; one-to-one eval traceability;
tool-schema and authorization tests; always/optional exposure tests; negative
tests proving control-plane-owned actions are unavailable to the model;
scenario tests for all 16 eval groups; multi-hop and restraint/no-call cases;
mock state snapshots; browser component/accessibility tests; fake-agent full
suite; a bounded real-Codex sample; clean-start tutorial execution by QA.
The suite must preserve the useful structure of the existing Paperclip skill
evals rather than merely copy their prompts. Each migrated case must state
whether it validates a control-plane invariant, an agent tool, a policy gate,
or a combined multi-hop flow. Skill-present and skill-absent results are
reference baselines; the Phase 7 pass condition is semantic parity through the
new runner contract, not imitation of legacy `curl` traffic.
**Owners/review:** Senior Planning Engineer prepares the detailed Phase 7 child
plan; EvalsEngineer reviews the inventory and corpus mapping; UXDesigner reviews
the browser scenario explorer before implementation; CodexCoder implements;
SecurityEngineer reviews tool authorization, credential isolation, approvals,
secrets, workspace controls, and the generic escape hatch; QA runs the clean
suite and tutorial; CTO approves the capability boundary before implementation.
**Depends on:** the Phase 6 standalone adapter checkpoint. It does not depend on
or authorize a real Paperclip bridge.
**Human checkpoint:** choose representative heartbeat, planning, blocker,
approval, artifact, interaction, and manager scenarios in the browser; inspect
the capability disposition and tool/control-plane transcript; run the complete
package-local suite; and approve the inventory before ACPX or real Paperclip
integration starts.
## Phase 8 — ACPX portability, provider simulation, and release-quality reference kit
**Purpose:** prove that the package is an SDK/reference implementation rather
than a Codex-only product feature, starting with a first-class ACPX
implementation that consumes the Phase 7 tool and control-plane contracts.
**Tracer bullet:** run the same Phase 7 conformance scenario through ACPX and a
fake hosted-provider backend without changing protocol, reducer, browser,
capability inventory, or tutorial structure.
**Deliverables:**
- first-class ACPX driver and tool-binding implementation;
- fake remote backend and provider capability matrix;
- standalone MCP binding and credential-plan fixtures;
- channel/media boundary fixtures that terminate at the mock core;
@@ -461,9 +556,9 @@ No Phase 6 implementation code starts until the CTO gate accepts that record.
**Owners/review:** CodexCoder implementation; EvalsEngineer/Performance Analyst for conformance and benchmarks when available; SecurityEngineer review; DevRel documentation review; QA full clean-room validation; CTO final review.
**Depends on:** Phase 6 for product integration evidence, while standalone provider fixtures may begin after Phase 5.
**Depends on:** Phase 7 capability inventory, semantic tool contract, mock adapter, and eval-parity checkpoint. A real Paperclip bridge remains separately gated.
**Human checkpoint:** follow the cumulative tutorial from a clean checkout, compare two backend/driver paths, browse the journal and screenshots, and decide whether to proceed from spike/reference kit to production rollout.
**Human checkpoint:** follow the cumulative tutorial from a clean checkout, compare Codex and ACPX paths, browse the journal and screenshots, and decide whether to proceed from spike/reference kit to a separately planned production bridge.
## Phase dependency graph and task-creation policy
@@ -475,11 +570,15 @@ PAP-16717 Plan and phase governance
├─ Phase 3 Durable transport/recovery complete
├─ Phase 4 Skillless Codex driver implemented; review pending
├─ Phase 5 Browser SDK/reference console deferred; blocks on Phase 3
├─ Phase 6 Paperclip integration adapter deferred; blocks on Phases 4 and 5 + board gate
└─ Phase 7 Portability/release reference kit deferred; blocks on Phase 6 for final closure
├─ Phase 6 Standalone adapter simulator active; blocks on Phases 4 and 5 + board gate
├─ Phase 7 Mock Paperclip/tools/eval parity planning; blocks on Phase 6 checkpoint
└─ Phase 8 ACPX/portability reference kit deferred; blocks on Phase 7 checkpoint
```
Create child issues for Phase 0 and Phase 1 only. Do not create Phase 2 through Phase 7 issues until the Phase 0–1 human checkpoint is complete and this plan and the spike specification have been revised with measured findings.
Create one Phase 7 planning-mode child issue now. Do not create Phase 7
implementation/review/QA grandchildren until its detailed plan is approved.
Do not create Phase 8 tasks until the Phase 7 human checkpoint is complete and
the specs have been revised with measured findings.
An active phase can contain these grandchildren when needed:
@@ -495,12 +594,16 @@ To honor the one-branch requirement, do not run multiple code-writing grandchild
## Risks and controls
- **Risk: standalone code quietly imports core internals.** Control: forbidden-import test and dependency review in Phase 0.
- **Risk: mock behavior diverges from Paperclip.** Control: one shared port contract and the same conformance suite against mock and real adapters in Phase 6.
- **Risk: mock behavior diverges from Paperclip.** Control: Phase 7 imports the
eval taxonomy and expected state transitions, records one disposition for
every skill/MCP capability, and requires explicit evidence for intentional
differences. Real-adapter parity remains a later, separately gated proof.
- **Risk: UI library assumptions conflict with Vite/design tokens.** Control: source-level compatibility spike, selective adoption, UX review, token gates, no Next.js migration.
- **Risk: journal becomes write-only ceremony.** Control: OKF index pages, backlinks, decision/evidence fields, tutorial for querying it, and phase acceptance requiring retrieval of prior decisions.
- **Risk: real model work obscures runner defects.** Control: fake driver remains the primary deterministic suite; real Codex begins only in Phase 4.
- **Risk: one branch creates merge conflicts.** Control: dependency-ordered writers, disjoint scopes only, phase checkpoint commits, and no parallel overlapping implementation.
- **Risk: core integration expands into a rewrite.** Control: explicit Phase 6 gate, thin adapter, feature flag, kill switch, legacy regression tests, CTO review.
- **Risk: core integration expands into a rewrite.** Control: Phases 6–8 remain
package-local; any real bridge requires a new explicit plan and board gate.
- **Risk: humans cannot exercise early layers.** Control: every phase includes a small executable tutorial and browser/CLI evidence, starting at Phase 0.
## Plan maintenance
@@ -514,7 +617,11 @@ Keep this plan and the spike specification current as implementation produces ne
## Future work retained from the original implementation breakdown
The eight tracer phases above are the active sequencing model. The following issue-sized backlog is retained from the original spike specification so that detailed implementation scope is not lost. Treat these items as future work and planning input, not as authorization to create Phase 2–7 tasks before the Phase 0–1 checkpoint.
The nine tracer phases above are the active sequencing model. The following
issue-sized backlog is retained from the original spike specification so that
detailed implementation scope is not lost. Treat these items as future work and
planning input, not as authorization to create implementation tasks outside the
current board-approved phase gate.
The issue identifiers below are placeholders. Each issue is intended to be assignable to a Paperclip worker with a narrow contract.
@@ -6687,14 +6687,20 @@ authorized that transition on 2026-08-08, so no additional planning approval is
required after those gates pass. Phase 5 generalizes the accepted browser proof
into a stable TypeScript SDK, reusable component contracts, and a second minimal
consumer application. The board accepted Phase 5 on 2026-08-09 and authorized
Phase 6 to start. Phase 6 is the first Paperclip control-plane integration phase
and must remain a thin, feature-flagged adapter over the package contracts proven
by the standalone mock-core path. It must not move runner responsibilities into
the core or change the default legacy adapter path.
Phase 6 to start. Later on 2026-08-09, the board narrowed the active Phase 6
checkpoint to the standalone demo runner and demo page only. The current phase
must remain a thin, package-local, feature-flagged adapter simulation over the
contracts proven by the mock-core path. It must not install into or mutate a
Paperclip instance, move runner responsibilities into the core, or change the
default legacy path. The board then redirected Phase 7 away from a real
Paperclip bridge. Phase 7 must instead provide a package-local mock Paperclip
control plane, a complete semantic agent-tool suite, a browser scenario
explorer, and an eval-parity suite derived from the Paperclip skill and
Paperclip Evals. ACPX/provider portability moves to Phase 8.
## 30. Dependency sequencing
The phase dependency graph, task-creation gates, owner guidance, per-phase completion contract, and retained issue-sized future backlog are maintained in [`paperclip-native-runner-implementation-plan.md`](./paperclip-native-runner-implementation-plan.md). Phases 0–5 are complete and accepted. The board authorized Phase 6 on 2026-08-09. The current order is Phase 6 integration design, thin feature-flagged adapter implementation, mandatory Security and CTO reviews, QA of native and legacy paths, and the Phase 6 human checkpoint. Phase 7 remains gated until that complete checkpoint passes.
The phase dependency graph, task-creation gates, owner guidance, per-phase completion contract, and retained issue-sized future backlog are maintained in [`paperclip-native-runner-implementation-plan.md`](./paperclip-native-runner-implementation-plan.md). Phases 0–5 are complete and accepted. The board authorized Phase 6 on 2026-08-09, then restricted its active scope to the package-local standalone demo. The current order is the Phase 6 standalone checkpoint; detailed planning and approval for Phase 7 mock Paperclip/tool/eval parity; then Phase 7 implementation and human validation. Phase 8 begins the ACPX/provider portability work only after Phase 7 passes. A real Paperclip bridge is not authorized by these phases and requires a separate future plan.
---
@@ -119,6 +119,10 @@ describe("buildTranscript", () => {
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");
expect(tool.kind === "item" && tool.debugEvents?.map((event) => event.eventType)).toEqual([
"item.started",
"item.completed",
]);
});
it("marks an interrupted item and never claims it completed cleanly", async () => {
@@ -33,6 +33,18 @@ export interface TranscriptItemEntry {
input: string | null;
output: string | null;
failure: string | null;
debugEvents?: readonly TranscriptDebugEvent[];
}
export interface TranscriptDebugEvent {
source: string;
sourceSeq: number;
sourceEventId: string;
eventType: string;
emittedAt: string;
turnId: string | null;
itemId: string | null;
payload: unknown;
}
export interface TranscriptUserEntry {
@@ -127,6 +139,14 @@ interface ItemFacts {
output: string | null;
failure: string | null;
terminal: boolean;
debugEvents: TranscriptDebugEvent[];
}
function displayValue(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
if (Array.isArray(value) && value.length > 0) return value.map(String).join(" ");
if (value !== null && typeof value === "object") return JSON.stringify(value, null, 2);
return null;
}
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
@@ -141,19 +161,41 @@ function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
output: null,
failure: null,
terminal: false,
debugEvents: [],
};
const providerItem = payloadRecord(payload.item);
const providerUpdate = payloadRecord(payload.update);
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;
current.toolName = payloadText(payload.toolName)
?? displayValue(providerItem.command)
?? payloadText(providerItem.name)
?? current.toolName;
current.input = payloadText(payload.input)
?? displayValue(providerItem.command)
?? displayValue(providerItem.arguments)
?? current.input;
current.output = payloadText(payload.output)
?? payloadText(providerItem.aggregatedOutput)
?? payloadText(providerUpdate.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;
}
current.debugEvents.push({
source: typeof event.source === "string" ? event.source : "unknown",
sourceSeq: event.sourceSeq,
sourceEventId: event.sourceEventId,
eventType: event.eventType,
emittedAt: event.emittedAt,
turnId: event.turnId ?? null,
itemId: event.itemId ?? null,
payload: event.payload,
});
facts.set(event.itemId, current);
}
return facts;
@@ -294,6 +336,7 @@ export function buildTranscript(
input: itemFacts?.input ?? null,
output: itemFacts?.output ?? null,
failure: itemFacts?.failure ?? null,
debugEvents: itemFacts?.debugEvents ?? [],
});
}
@@ -1,47 +1,15 @@
import { runControlPlanePortConformance } from "../conformance/control-plane-port.js";
import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js";
import { runPhase6StandaloneDemo } from "../standalone/phase6-demo.js";
function option(name: string): string | null {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] ?? null : null;
}
const target = option("--target") ?? "mock";
const scenario = option("--scenario") ?? "happy-path";
const featureFlagEnabled = option("--feature-flag") === "enabled";
const killSwitchEnabled = option("--kill-switch") === "enabled";
if (target !== "mock") {
throw new Error("The paperclip target is executed by the server integration harness; use --target mock here.");
}
if (scenario !== "happy-path") throw new Error(`Unsupported Phase 6 mock scenario: ${scenario}`);
const adapter = new MockControlPlaneAdapter();
const summary = await runControlPlanePortConformance({
port: adapter,
start: () => adapter.start(),
stop: () => adapter.stop(),
});
process.stdout.write(`${JSON.stringify({
schema: "paperclip.runner.phase6.trace.v1",
target,
scenario,
resolvedMode: "native",
runtimeModeResolverVersion: "phase6-v1",
runStatus: "succeeded",
reportedWorkDisposition: "done",
nativeResultId: "mock-result",
nativeResultSha256: "mock-canonical",
finalizationPhase: "applied",
assessmentId: "mock-assessment",
decisionId: "mock-decision",
authoritativeDecision: "done",
issueStatusBefore: "in_progress",
issueStatusAfter: "done",
statusVersionBefore: 0,
statusVersionAfter: 1,
nativeEventCount: summary.eventCount,
highestContiguousSourceSeq: summary.highestContiguousSourceSeq,
nextEventSeq: summary.eventCount + 1,
workspaceFinalizeStatus: "succeeded",
legacyAdapterInvocationCount: 0,
})}\n`);
const result = await runPhase6StandaloneDemo({ featureFlagEnabled, killSwitchEnabled });
process.stdout.write(`${JSON.stringify({ ...result, scenario })}\n`);
@@ -40,7 +40,7 @@ export interface Phase4ModelContextSnapshot {
workingDirectory: string;
sandbox: unknown;
approvalPolicy: unknown;
baseInstructions: typeof PHASE4_SKILLLESS_BASE_INSTRUCTIONS;
baseInstructions: string;
instructionSources: string[];
instructionPolicy: {
skillInstructions: false;
@@ -51,6 +51,7 @@ export interface Phase4ModelContextSnapshot {
dynamicToolNames: string[];
modelInputKinds: ["text"];
phase4b?: {
conversationMode?: "task" | "direct";
runtimeRequestResolution: boolean;
goals: boolean;
threadLineage: boolean;
@@ -272,6 +272,43 @@ async function traceCompletedProposal(
}
describe("Codex app-server Phase 4 driver", () => {
it("sends direct chat as plain text and permits a follow-up turn", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], { conversationMode: "direct" });
const session = await driver.openSession({
runId: "run-chat",
normalizedSessionId: "normalized-chat",
workingDirectory: "/workspace",
});
const threadStart = transport.calls.find((call) => call.method === "thread/start");
expect(threadStart?.params).not.toHaveProperty("baseInstructions");
expect(threadStart?.params.dynamicTools).toEqual([]);
const first = await session.startTurn({ message: { role: "user", text: "Hello Codex" } });
const firstStart = transport.calls.find((call) => call.method === "turn/start");
expect(firstStart?.params).not.toHaveProperty("outputSchema");
expect(firstStart?.params.input).toEqual([
{ type: "text", text: "Hello Codex", text_elements: [] },
]);
transport.push("turn/started", {
threadId: "thread-1",
turn: { id: first.turnId, status: "inProgress" },
});
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: first.turnId, status: "completed", items: [] },
});
await collectUntilTerminal(session.events());
transport.turnStartResponse = Promise.resolve({
turn: { id: "turn-2", status: "inProgress", items: [] },
});
await expect(
session.startTurn({ message: { role: "user", text: "And a follow-up" } }),
).resolves.toEqual({ turnId: "turn-2" });
});
it("passes the common typed-event contract and reports one provider turn terminal", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport]);
@@ -89,6 +89,7 @@ class AsyncQueue<T> implements AsyncIterable<T> {
export interface CodexAppServerDriverOptions {
taskEnvelope: Phase4TaskEnvelope;
conversationMode?: "task" | "direct";
transportFactory?: () => CodexAppServerTransport;
environment?: NodeJS.ProcessEnv;
now?: () => Date;
@@ -309,6 +310,10 @@ export class CodexAppServerDriver implements HarnessDriver {
if (!this.#caps.read) this.#caps.reconciliation = false;
}
#direct(): boolean {
return this.#options.conversationMode === "direct";
}
async descriptor(): Promise<HarnessDriverDescriptor> {
const unsupported = Object.entries(this.#caps)
.filter(([, supported]) => !supported)
@@ -344,8 +349,10 @@ export class CodexAppServerDriver implements HarnessDriver {
const response = await transport.request("thread/start", {
...securedThreadParams(workingDirectory, this.#options.environment),
approvalPolicy: "never",
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
dynamicTools: this.#caps.dynamicTools ? [finishToolSpec(), blockToolSpec()] : [],
...(this.#direct() ? {} : { baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS }),
dynamicTools: this.#direct()
? []
: this.#caps.dynamicTools ? [finishToolSpec(), blockToolSpec()] : [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
@@ -398,7 +405,7 @@ export class CodexAppServerDriver implements HarnessDriver {
const response = await transport.request("thread/resume", {
threadId: snapshot.driverSessionId,
...securedThreadParams(workingDirectory, this.#options.environment),
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
baseInstructions: this.#direct() ? "" : PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, workingDirectory);
@@ -548,9 +555,12 @@ export class CodexAppServerDriver implements HarnessDriver {
environmentKeys: Object.keys(
commandEnvironment(this.#options.environment),
).sort(),
dynamicToolNames: this.#caps.dynamicTools ? [...PHASE4_SEMANTIC_TOOL_NAMES] : [],
dynamicToolNames: this.#direct()
? []
: this.#caps.dynamicTools ? [...PHASE4_SEMANTIC_TOOL_NAMES] : [],
modelInputKinds: ["text"],
phase4b: {
conversationMode: this.#direct() ? "direct" : "task",
runtimeRequestResolution: this.#caps.runtimeRequestResolution,
goals: this.#caps.goals,
threadLineage: this.#caps.threadLineage,
@@ -578,6 +588,7 @@ export class CodexAppServerDriver implements HarnessDriver {
return new CodexHarnessSession({
...input,
taskEnvelope: this.#options.taskEnvelope,
conversationMode: this.#direct() ? "direct" : "task",
now: this.#options.now ?? (() => new Date()),
runnerInstanceId: this.#options.runnerInstanceId ?? "runner-phase4",
capabilities: this.#caps,
@@ -591,6 +602,7 @@ class CodexHarnessSession implements HarnessSession {
readonly #normalizedSessionId: string;
readonly #opened: OpenedCodexThread;
readonly #taskEnvelope: Phase4TaskEnvelope;
readonly #conversationMode: "task" | "direct";
readonly #now: () => Date;
readonly #runnerInstanceId: string;
readonly #capabilities: CodexCapabilities;
@@ -622,6 +634,7 @@ class CodexHarnessSession implements HarnessSession {
normalizedSessionId: string;
opened: OpenedCodexThread;
taskEnvelope: Phase4TaskEnvelope;
conversationMode: "task" | "direct";
resumed: boolean;
activeTurnId?: string | null;
semanticResult?: PersistedHarnessSemanticResult | null;
@@ -639,6 +652,7 @@ class CodexHarnessSession implements HarnessSession {
this.#normalizedSessionId = input.normalizedSessionId;
this.#opened = input.opened;
this.#taskEnvelope = input.taskEnvelope;
this.#conversationMode = input.conversationMode;
this.#activeTurnId = input.activeTurnId ?? null;
this.#sourceSequence = input.sourceSequence;
this.#now = input.now;
@@ -667,7 +681,7 @@ class CodexHarnessSession implements HarnessSession {
}
this.#terminalTurns.set(terminal.turnId, terminal.fingerprint);
}
this.#terminal = this.#terminalTurns.size > 0;
this.#terminal = this.#conversationMode === "task" && this.#terminalTurns.size > 0;
this.#transport.setServerRequestHandler((request) => this.#handleServerRequest(request));
this.#emit(input.resumed ? "session.resumed" : "session.started", {
driverSessionId: input.opened.threadId,
@@ -723,7 +737,9 @@ class CodexHarnessSession implements HarnessSession {
: `session failed protocol validation: ${this.#protocolFailureCode} (${this.#protocolFailureMessage ?? "no detail"})`,
);
}
const taskText = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
const taskText = this.#conversationMode === "direct"
? input.message.text
: JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
// The submitted text is part of the canonical record so a tracer can show
// the operator's own message without keeping shadow state next to the
// reducer.
@@ -740,7 +756,7 @@ class CodexHarnessSession implements HarnessSession {
permissions: SKILLLESS_PERMISSION_PROFILE,
runtimeWorkspaceRoots: [this.#opened.context.workingDirectory],
input: [userInput({ role: "user", text: taskText })],
outputSchema: PHASE4_RESULT_OUTPUT_SCHEMA,
...(this.#conversationMode === "direct" ? {} : { outputSchema: PHASE4_RESULT_OUTPUT_SCHEMA }),
});
} finally {
this.#turnStartPending = false;
@@ -1318,6 +1334,7 @@ class CodexHarnessSession implements HarnessSession {
}
#captureResultFromItem(item: Record<string, unknown>, turnId: string): boolean {
if (this.#conversationMode === "direct") return true;
if (text(item.type) !== "agentMessage" || !isRetainableCodexPayload(item.text)) return true;
const result = tryParseResult(item.text);
if (result !== null && isRetainableCodexPayload(result)) {
@@ -1364,6 +1381,7 @@ class CodexHarnessSession implements HarnessSession {
}
#finalize(turnStatus: string): void {
if (this.#conversationMode === "direct") return;
if (this.#terminal) return;
if (this.#result === null) {
this.#emit("harness.diagnostic", {
@@ -1429,6 +1447,7 @@ class CodexHarnessSession implements HarnessSession {
error: turn.error ?? null,
}), { turnId });
this.#activeTurnId = null;
this.#turnStarted = false;
this.#finalize(status);
}
+1
View File
@@ -15,6 +15,7 @@ export * from "./drivers/codex/codex-app-server-driver.js";
export * from "./mock-core/mock-control-plane-adapter.js";
export * from "./mock-core/phase7-control-plane-types.js";
export * from "./mock-core/phase7-mock-control-plane-adapter.js";
export * from "./standalone/phase6-demo.js";
export * from "./mock-core/phase4-codex-runner.js";
export * from "./mock-core/phase4b-demo-server.js";
export * from "./mock-core/phase4b-demo-manifests.js";
@@ -87,6 +87,36 @@ const SHORT_ANSWER: readonly string[] = [
];
export const PHASE4B_DEMO_MANIFESTS: readonly Phase4bDemoManifest[] = [
{
id: "chat",
name: "Regular chat",
purpose: "Send normal chat messages while the protocol inspector shows the raw lifecycle.",
scenarios: ["direct-chat", "transcript", "debug"],
objective: "Run a normal Codex conversation through the browser protocol.",
prompt: "Say hello and explain that this is a normal chat through the runner protocol.",
capabilities: capabilities({ structuredResult: false }),
startDelayMs: 0,
beats: [
{
kind: "assistant",
itemId: "assistant-chat-1",
chunks: ["Hello. This is a normal chat carried through the runner protocol."],
},
],
followUpBeats: [
{
kind: "assistant",
itemId: "assistant-chat-follow-up",
chunks: ["I received your follow-up message through the same session."],
},
],
expectedObservations: [
"The prompt reaches the driver as plain text without a Phase 4 task envelope.",
"The answer appears as a normal assistant message.",
"The protocol inspector still shows the event stream and reducer state.",
],
disposition: "completed",
},
{
id: "completion",
name: "Completion",
@@ -71,11 +71,29 @@ export function Conversation({
});
}, []);
const scrollToLatestResponse = React.useCallback((smooth: boolean): boolean => {
const viewport = viewportRef.current;
if (viewport === null) return false;
const responses = viewport.querySelectorAll<HTMLElement>(
'.pcr-message[data-role="assistant"]:not([data-state="streaming"])',
);
const response = responses.item(responses.length - 1);
if (response === null) return false;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
const viewportTop = viewport.getBoundingClientRect().top;
const responseTop = response.getBoundingClientRect().top;
viewport.scrollTo({
top: Math.max(viewport.scrollTop + responseTop - viewportTop - 16, 0),
behavior: smooth && !reduceMotion ? "smooth" : "auto",
});
return true;
}, []);
React.useEffect(() => {
if (!following) return;
scrollToBottom(true);
if (!scrollToLatestResponse(false)) scrollToBottom(false);
setSeenCount(entries.length);
}, [entries.length, following, scrollToBottom]);
}, [entries, following, scrollToBottom, scrollToLatestResponse]);
function reengage() {
setFollowing(true);
@@ -156,7 +174,7 @@ export function Conversation({
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 })} />
<ToolItem item={entry.item} name={entry.toolName ?? entry.item.kind} status={toolStatus(entry)} input={entry.input} output={entry.output} failure={entry.failure} debugEvents={entry.debugEvents} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
</li>
);
}
@@ -2,6 +2,7 @@ import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
import type { TranscriptRole } from "../../browser/transcript-model.js";
import { Badge } from "./badge.js";
const ROLE_LABELS: Record<TranscriptRole, string> = {
user: "You",
@@ -27,6 +28,148 @@ export interface MessageProps {
children?: React.ReactNode;
}
interface StructuredResponse {
summary: string;
disposition: string | null;
objectiveSatisfied: boolean | null;
remainingWork: string[];
verification: Array<{ label: string; status: string | null }>;
raw: Record<string, unknown>;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function sentenceCase(value: string): string {
const spaced = value.replaceAll("_", " ").replace(/([a-z])([A-Z])/g, "$1 $2");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export function parseStructuredResponse(text: string): StructuredResponse | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
const raw = record(JSON.parse(trimmed));
const summary = stringValue(raw.summary);
if (summary === null) return null;
const claim = record(raw.completionClaim);
const remainingWork = Array.isArray(claim.remainingWork)
? claim.remainingWork
.map((entry) => stringValue(entry) ?? stringValue(record(entry).summary) ?? stringValue(record(entry).description))
.filter((entry): entry is string => entry !== null)
: [];
const verification = Array.isArray(raw.verification)
? raw.verification.flatMap((entry) => {
const current = record(entry);
const label = stringValue(current.commandOrCheck) ?? stringValue(current.summary);
return label === null ? [] : [{ label, status: stringValue(current.status) }];
})
: [];
return {
summary,
disposition: stringValue(raw.reportedWorkDisposition),
objectiveSatisfied: typeof claim.objectiveSatisfied === "boolean" ? claim.objectiveSatisfied : null,
remainingWork,
verification,
raw,
};
} catch {
return null;
}
}
function useProgressiveText(text: string, streaming: boolean): { text: string; active: boolean } {
const progressiveRef = React.useRef(streaming);
if (streaming) progressiveRef.current = true;
const targetRef = React.useRef(text);
targetRef.current = text;
const [displayed, setDisplayed] = React.useState(() => streaming ? "" : text);
React.useEffect(() => {
if (!targetRef.current.startsWith(displayed)) {
setDisplayed(streaming ? "" : targetRef.current);
}
}, [displayed, streaming, text]);
React.useEffect(() => {
if (!progressiveRef.current) {
setDisplayed(targetRef.current);
return undefined;
}
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true) {
setDisplayed(targetRef.current);
return undefined;
}
const timer = window.setInterval(() => {
setDisplayed((current) => {
const target = targetRef.current;
if (!target.startsWith(current)) return target;
if (current.length >= target.length) return current;
return target.slice(0, current.length + 1);
});
}, 12);
return () => window.clearInterval(timer);
}, []);
return { text: displayed, active: streaming || displayed.length < text.length };
}
function StructuredResponseBody({ response }: { response: StructuredResponse }) {
const positive = response.objectiveSatisfied === true || response.disposition === "done";
const hasCompletionDetails = response.verification.length > 0 || response.remainingWork.length > 0;
return (
<div className="pcr-response" data-slot="structured-response" data-testid="structured-response">
<div className="pcr-response-heading">
<div>
<p className="pcr-eyebrow">Agent response</p>
<p className="pcr-response-summary">{response.summary}</p>
</div>
{response.disposition === null ? null : (
<Badge tone={positive ? "success" : "accent"}>{sentenceCase(response.disposition)}</Badge>
)}
</div>
<details className="pcr-response-details">
<summary>Completion details</summary>
<div className="pcr-response-details-body">
{response.verification.length === 0 ? null : (
<div className="pcr-response-section">
<p className="pcr-response-label">Checks</p>
<ul className="pcr-response-checks">
{response.verification.map((entry, index) => (
<li key={`${entry.label}:${index}`}>
<span aria-hidden="true">{entry.status === "passed" ? "✓" : "•"}</span>
<span>{entry.label}</span>
</li>
))}
</ul>
</div>
)}
{response.remainingWork.length === 0 ? null : (
<div className="pcr-response-section">
<p className="pcr-response-label">Remaining</p>
<ul className="pcr-response-list">
{response.remainingWork.map((entry, index) => <li key={`${entry}:${index}`}>{entry}</li>)}
</ul>
</div>
)}
{hasCompletionDetails ? null : <p className="pcr-response-empty-detail">No additional completion notes.</p>}
<details className="pcr-response-debug">
<summary>Raw protocol payload</summary>
<pre className="pcr-payload">{JSON.stringify(response.raw, null, 2)}</pre>
</details>
</div>
</details>
</div>
);
}
export function Message({
role,
item,
@@ -38,9 +181,14 @@ export function Message({
renderItemBody,
children,
}: MessageProps) {
const structured = role === "assistant" && item !== undefined && !streaming
? parseStructuredResponse(item.text)
: null;
const body = item === undefined
? (children ?? text ?? "")
: (renderItemBody?.(item) ?? <MessageText text={item.text} streaming={streaming} />);
: (renderItemBody?.(item) ?? (structured === null
? <MessageText text={item.text} streaming={streaming} />
: <StructuredResponseBody response={structured} />));
return (
<article
data-slot="message"
@@ -65,10 +213,11 @@ export function Message({
}
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
const progressive = useProgressiveText(text, streaming);
return (
<p className="pcr-message-text">
{text}
{streaming ? <span className="pcr-stream-cursor" aria-hidden="true" /> : null}
{progressive.text}
{progressive.active ? <span className="pcr-stream-cursor" aria-hidden="true" /> : null}
</p>
);
}
@@ -1,6 +1,7 @@
import * as React from "react";
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
import type { TranscriptDebugEvent } from "../../browser/transcript-model.js";
import { Badge } from "./badge.js";
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
@@ -33,6 +34,7 @@ export function ToolItem({
input,
output,
failure,
debugEvents = [],
renderItemBody,
}: {
item: SessionItemSnapshot;
@@ -41,6 +43,7 @@ export function ToolItem({
input: string | null;
output: string | null;
failure: string | null;
debugEvents?: readonly TranscriptDebugEvent[];
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
}) {
const view = toolView(item.kind);
@@ -73,6 +76,12 @@ export function ToolItem({
{failure === null ? null : (
<><p className="pcr-disclosure-label">Diagnostic</p><pre className="pcr-payload pcr-payload--danger" data-testid="tool-failure">{failure}</pre></>
)}
{debugEvents.length === 0 ? null : (
<details className="pcr-tool-debug" data-testid="tool-debug-details">
<summary>Debug details ({debugEvents.length} {debugEvents.length === 1 ? "event" : "events"})</summary>
<pre className="pcr-payload">{JSON.stringify(debugEvents, null, 2)}</pre>
</details>
)}
{status === "interrupted" ? (
<p className="pcr-message-divider" data-testid="interrupted-divider">
Interrupted — the last known status is shown and no result was fabricated.
@@ -6,6 +6,7 @@ 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 { parseStructuredResponse } from "./components/message.js";
import { RequestCard } from "./request-card.js";
import { SessionTimeline } from "./session-timeline.js";
@@ -29,9 +30,34 @@ const entries: TranscriptEntry[] = [
input: null,
output: null,
failure: null,
debugEvents: [],
},
];
const toolEntry: TranscriptEntry = {
kind: "item",
key: "item:command-1",
position: 2,
role: "tool",
item: { itemId: "command-1", kind: "commandExecution", status: "completed", text: "hello" },
interrupted: false,
streaming: false,
toolName: "printf hello",
input: "printf hello",
output: "hello",
failure: null,
debugEvents: [{
source: "codex-app-server",
sourceSeq: 3,
sourceEventId: "event-3",
eventType: "item.completed",
emittedAt: "2026-08-09T00:00:00.000Z",
turnId: "turn-1",
itemId: "command-1",
payload: { item: { type: "commandExecution", command: "printf hello", aggregatedOutput: "hello" } },
}],
};
describe("public React contracts", () => {
it("renders reducer-backed transcript semantics and the item renderer seam", () => {
const html = renderToStaticMarkup(
@@ -68,6 +94,15 @@ describe("public React contracts", () => {
expect(html).toContain("Reject");
});
it("renders Terminal diagnostics inside folded debug details", () => {
const html = renderToStaticMarkup(<Conversation entries={[toolEntry]} />);
expect(html).toContain("Terminal");
expect(html).toContain("printf hello");
expect(html).toContain("Debug details (1 event)");
expect(html).toContain("codex-app-server");
expect(html).toContain("aggregatedOutput");
});
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>} />,
@@ -89,4 +124,27 @@ describe("public React contracts", () => {
expect(html).toContain('data-testid="item-failure"');
expect(html).toContain("exact upstream diagnostic");
});
it("renders structured Codex completion as a response-first card with optional protocol details", () => {
const response = JSON.stringify({
schema: "paperclip.run_result.v1",
summary: "Created the requested file and verified its contents.",
completionClaim: { objectiveSatisfied: true, remainingWork: [] },
verification: [{ commandOrCheck: "cat result.txt", status: "passed" }],
reportedWorkDisposition: "done",
});
const html = renderToStaticMarkup(
<Conversation entries={[{
...entries[0]!,
item: { ...item, text: response },
}]} />,
);
expect(parseStructuredResponse(response)?.summary).toBe("Created the requested file and verified its contents.");
expect(html).toContain('data-testid="structured-response"');
expect(html).toContain("Agent response");
expect(html).toContain("Created the requested file and verified its contents.");
expect(html).toContain("Completion details");
expect(html).toContain("Raw protocol payload");
expect(html).toContain("cat result.txt");
});
});
@@ -9,19 +9,16 @@ 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 { Menu } from "./components/menu.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)";
const CONSOLE_VERSION = "0.1.2";
const CONSOLE_VERSION_EMOJI = "🖇️";
function useCompactLayout(): boolean {
const [compact, setCompact] = React.useState(
@@ -53,7 +50,7 @@ export function RunnerConsoleApp({
const console_ = useRunnerConsole({ baseUrl, fetchImpl, eventSourceFactory });
const [draft, setDraft] = React.useState("");
const [inspectorOpen, setInspectorOpen] = React.useState(false);
const [segment, setSegment] = React.useState("chat");
const [mobilePanel, setMobilePanel] = React.useState<"session" | "inspector" | null>(null);
const [resetOpen, setResetOpen] = React.useState(false);
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
@@ -77,6 +74,10 @@ export function RunnerConsoleApp({
);
}, [inspectorOpen]);
React.useEffect(() => {
if (!compact) setMobilePanel(null);
}, [compact]);
React.useEffect(() => {
const snapshot = console_.snapshot;
if (snapshot === null || console_.state === null) return;
@@ -91,7 +92,9 @@ export function RunnerConsoleApp({
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
if (console_.state === null) {
void console_.start({ manifestId: "chat", message: text });
} else if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
@@ -177,13 +180,24 @@ export function RunnerConsoleApp({
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} />
);
const mobileMenuItems = [
{ id: "session", label: "Session controls", enabled: true, reason: null },
{ id: "inspector", label: "Protocol inspector", enabled: true, reason: null },
{
id: "replay",
label: console_.replay.active ? "Exit replay" : "Enter replay",
enabled: console_.events.length > 0,
reason: console_.events.length > 0 ? null : "Replay is available after the session receives events.",
},
] as const;
return (
<div className="pcr-root pcr-console" data-slot="runner-console-app" data-inspector={inspectorOpen}>
<div className="pcr-root pcr-console" data-slot="runner-console-app" data-inspector={inspectorOpen} data-mode={manifestId === "chat" ? "chat" : "fixture"}>
<header className="pcr-console-header">
<div className="pcr-console-title">
<span className="pcr-console-version" data-testid="console-version">{CONSOLE_VERSION_EMOJI} v{CONSOLE_VERSION}</span>
{console_.replay.active ? <Badge tone="warning" data-testid="replay-badge">REPLAY</Badge> : null}
<h1>{activeManifest?.name ?? "Runner reference console"}</h1>
<h1>{manifestId === "chat" ? "Codex protocol chat" : activeManifest?.name ?? "Runner reference console"}</h1>
{viewingChild ? (
<p className="pcr-breadcrumb" data-testid="breadcrumb">
root ▸ {console_.selectedThreadId}
@@ -191,8 +205,26 @@ export function RunnerConsoleApp({
) : 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>
{compact ? (
<Menu
label="Menu"
triggerId="mobile-console-menu"
items={mobileMenuItems}
onSelect={(id) => {
if (id === "replay") {
if (console_.replay.active) console_.replay.exit();
else console_.replay.enter();
return;
}
if (id === "session" || id === "inspector") setMobilePanel(id);
}}
/>
) : (
<>
<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>
@@ -200,11 +232,7 @@ export function RunnerConsoleApp({
{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>
<main className="pcr-console-center">{transcript}{composer}</main>
</div>
) : (
<div className="pcr-console-desktop">
@@ -213,6 +241,21 @@ export function RunnerConsoleApp({
{inspectorOpen ? <aside className="pcr-console-inspector" aria-label="Protocol inspector">{inspector}</aside> : null}
</div>
)}
<Dialog
open={compact && mobilePanel !== null}
onClose={() => setMobilePanel(null)}
title={mobilePanel === "inspector" ? "Protocol inspector" : "Session controls"}
description={mobilePanel === "inspector"
? "Inspect protocol state without replacing the primary chat surface."
: "Run manifests and manage the current session. Close this panel to return to chat."}
footer={<Button type="button" onClick={() => setMobilePanel(null)}>Back to chat</Button>}
>
{compact && mobilePanel !== null ? (
<div className="pcr-mobile-panel-content">
{mobilePanel === "inspector" ? inspector : sessionRail}
</div>
) : null}
</Dialog>
<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>
);
@@ -108,7 +108,7 @@ export function useRunnerConsole(options: RunnerClientOptions = {}): RunnerConso
[options.baseUrl, options.eventSourceFactory, options.fetchImpl],
);
const [manifests, setManifests] = useState<ManifestSummary[]>([]);
const [selectedManifestId, setSelectedManifestId] = useState("completion");
const [selectedManifestId, setSelectedManifestId] = useState("chat");
const [state, setState] = useState<RunnerSessionState | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [connection, setConnection] = useState<ConnectionStatus>("idle");
@@ -0,0 +1 @@
export * from "./phase6-demo.js";
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
createLegacyPhase6StandaloneAdapter,
createNativePhase6StandaloneAdapter,
resolvePhase6StandaloneMode,
runPhase6StandaloneDemo,
type Phase6StandaloneAdapter,
} from "./phase6-demo.js";
function adapterSpy(adapter: Phase6StandaloneAdapter) {
return {
adapter,
start: vi.spyOn(adapter, "start"),
stop: vi.spyOn(adapter, "stop"),
};
}
describe("Phase 6 standalone demo", () => {
it("keeps the legacy path as the default", async () => {
const legacy = adapterSpy(createLegacyPhase6StandaloneAdapter());
const createLegacyAdapter = vi.fn(() => legacy.adapter);
const createNativeAdapter = vi.fn(() => createNativePhase6StandaloneAdapter());
const result = await runPhase6StandaloneDemo({
featureFlagEnabled: false,
killSwitchEnabled: false,
}, {
createLegacyAdapter,
createNativeAdapter,
});
expect(createLegacyAdapter).toHaveBeenCalledOnce();
expect(createNativeAdapter).not.toHaveBeenCalled();
expect(legacy.start).toHaveBeenCalledOnce();
expect(legacy.stop).toHaveBeenCalledOnce();
expect(result).toMatchObject({
adapter: "legacy_demo_adapter",
resolvedMode: "legacy",
resolutionReason: "feature_flag_disabled",
nativeAdapterInvocationCount: 0,
legacyAdapterInvocationCount: 1,
replay: { eventCount: 3, highestContiguousSourceSeq: 3 },
reducer: { integrity: "complete", runPhase: "terminal", runTerminalState: "succeeded" },
finalization: { resultPersisted: true, terminalPersisted: true },
});
});
it("runs the native demo behind the feature flag", async () => {
const native = adapterSpy(createNativePhase6StandaloneAdapter());
const createNativeAdapter = vi.fn(() => native.adapter);
const createLegacyAdapter = vi.fn(() => createLegacyPhase6StandaloneAdapter());
const result = await runPhase6StandaloneDemo({
featureFlagEnabled: true,
killSwitchEnabled: false,
}, {
createLegacyAdapter,
createNativeAdapter,
});
expect(createNativeAdapter).toHaveBeenCalledOnce();
expect(createLegacyAdapter).not.toHaveBeenCalled();
expect(native.start).toHaveBeenCalledOnce();
expect(native.stop).toHaveBeenCalledOnce();
expect(result).toMatchObject({
adapter: "native_demo_adapter",
resolvedMode: "native",
resolutionReason: "native_demo_enabled",
nativeAdapterInvocationCount: 1,
legacyAdapterInvocationCount: 0,
});
expect(result.conformance).toMatchObject({
duplicateDisposition: "duplicate",
terminalReplayIdempotent: true,
resultMutationRejected: true,
});
});
it("constructs and invokes only legacy when the kill switch is enabled", async () => {
expect(resolvePhase6StandaloneMode({
featureFlagEnabled: true,
killSwitchEnabled: true,
})).toEqual({
resolvedMode: "legacy",
resolutionReason: "kill_switch_enabled",
});
const legacy = adapterSpy(createLegacyPhase6StandaloneAdapter());
const createLegacyAdapter = vi.fn(() => legacy.adapter);
const createNativeAdapter = vi.fn(() => createNativePhase6StandaloneAdapter());
const result = await runPhase6StandaloneDemo({
featureFlagEnabled: true,
killSwitchEnabled: true,
}, {
createLegacyAdapter,
createNativeAdapter,
});
expect(createLegacyAdapter).toHaveBeenCalledOnce();
expect(createNativeAdapter).not.toHaveBeenCalled();
expect(legacy.start).toHaveBeenCalledOnce();
expect(legacy.stop).toHaveBeenCalledOnce();
expect(result).toMatchObject({
adapter: "legacy_demo_adapter",
resolvedMode: "legacy",
resolutionReason: "kill_switch_enabled",
nativeAdapterInvocationCount: 0,
legacyAdapterInvocationCount: 1,
conformance: {
duplicateDisposition: "duplicate",
terminalReplayIdempotent: true,
resultMutationRejected: true,
},
reducer: { integrity: "complete", runPhase: "terminal", runTerminalState: "succeeded" },
finalization: { resultPersisted: true, terminalPersisted: true },
});
});
});
@@ -0,0 +1,198 @@
import {
CONTROL_PLANE_CONFORMANCE_EVENTS,
CONTROL_PLANE_CONFORMANCE_OPEN,
runControlPlanePortConformance,
type ControlPlanePortConformanceSnapshot,
} from "../conformance/control-plane-port.js";
import type {
AppendedEventReceipt,
CompleteControlPlaneRunInput,
ControlPlanePort,
OpenControlPlaneRunInput,
ReplayControlPlaneEventsInput,
ReplayedControlPlaneEvents,
} from "../contracts/control-plane-port.js";
import type { NativeRunEvent, NativeRunResult } from "../contracts/types.js";
import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js";
import type { PrpEvent } from "../protocol/phase1-contract.js";
import {
createSessionSnapshotFromMetadata,
reduceSessionEvents,
type SessionSnapshot,
} from "../reducer/session-reducer.js";
export interface Phase6StandaloneDemoInput {
featureFlagEnabled: boolean;
killSwitchEnabled: boolean;
}
export type Phase6StandaloneAdapterKind = "native_demo_adapter" | "legacy_demo_adapter";
export interface Phase6StandaloneAdapter extends ControlPlanePort {
readonly kind: Phase6StandaloneAdapterKind;
start(): Promise<void>;
stop(): Promise<void>;
invocationCount(): number;
}
export interface Phase6StandaloneAdapterFactories {
createNativeAdapter(): Phase6StandaloneAdapter;
createLegacyAdapter(): Phase6StandaloneAdapter;
}
export interface Phase6StandaloneDemoResult {
schema: "paperclip.runner.phase6.standalone-demo.v1";
resolvedMode: "native" | "legacy";
resolutionReason: "feature_flag_disabled" | "kill_switch_enabled" | "native_demo_enabled";
adapter: "native_demo_adapter" | "legacy_demo_adapter";
nativeAdapterInvocationCount: number;
legacyAdapterInvocationCount: number;
conformance: ControlPlanePortConformanceSnapshot;
replay: {
eventCount: number;
highestContiguousSourceSeq: number;
eventTypes: string[];
};
reducer: Pick<SessionSnapshot, "integrity" | "sessionState" | "turnState" | "runPhase"> & {
timelineCount: number;
runTerminalState: string | null;
};
finalization: {
resultPersisted: true;
terminalPersisted: true;
reportedWorkDisposition: "done";
};
}
export function resolvePhase6StandaloneMode(input: Phase6StandaloneDemoInput): Pick<
Phase6StandaloneDemoResult,
"resolvedMode" | "resolutionReason"
> {
if (input.killSwitchEnabled) {
return { resolvedMode: "legacy", resolutionReason: "kill_switch_enabled" };
}
if (!input.featureFlagEnabled) {
return { resolvedMode: "legacy", resolutionReason: "feature_flag_disabled" };
}
return { resolvedMode: "native", resolutionReason: "native_demo_enabled" };
}
class Phase6DemoAdapter implements Phase6StandaloneAdapter {
readonly #adapter = new MockControlPlaneAdapter();
#invocationCount = 0;
constructor(readonly kind: Phase6StandaloneAdapterKind) {}
async start(): Promise<void> {
this.#invocationCount += 1;
this.#adapter.start();
}
async stop(): Promise<void> {
this.#adapter.stop();
}
invocationCount(): number {
return this.#invocationCount;
}
openRun(input: OpenControlPlaneRunInput): Promise<void> {
return this.#adapter.openRun(input);
}
appendEvent(event: NativeRunEvent | PrpEvent): Promise<AppendedEventReceipt> {
return this.#adapter.appendEvent(event);
}
replayEvents(input: ReplayControlPlaneEventsInput): Promise<ReplayedControlPlaneEvents> {
return this.#adapter.replayEvents(input);
}
completeRun(result: NativeRunResult | CompleteControlPlaneRunInput): Promise<void> {
return this.#adapter.completeRun(result);
}
}
export function createNativePhase6StandaloneAdapter(): Phase6StandaloneAdapter {
return new Phase6DemoAdapter("native_demo_adapter");
}
export function createLegacyPhase6StandaloneAdapter(): Phase6StandaloneAdapter {
return new Phase6DemoAdapter("legacy_demo_adapter");
}
const DEFAULT_ADAPTER_FACTORIES: Phase6StandaloneAdapterFactories = {
createNativeAdapter: createNativePhase6StandaloneAdapter,
createLegacyAdapter: createLegacyPhase6StandaloneAdapter,
};
export async function runPhase6StandaloneDemo(
input: Phase6StandaloneDemoInput,
factories: Phase6StandaloneAdapterFactories = DEFAULT_ADAPTER_FACTORIES,
): Promise<Phase6StandaloneDemoResult> {
const resolution = resolvePhase6StandaloneMode(input);
const adapter = resolution.resolvedMode === "native"
? factories.createNativeAdapter()
: factories.createLegacyAdapter();
const conformance = await runControlPlanePortConformance({
port: adapter,
start: () => adapter.start(),
stop: () => adapter.stop(),
});
const snapshot = reduceSessionEvents(
createSessionSnapshotFromMetadata({
fixtureName: `phase6-${resolution.resolvedMode}-standalone-demo`,
identity: {
schema: "paperclip.prp.identity.v1",
companyId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.companyId,
issueId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.issueId,
runId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId,
environmentLeaseId: "standalone-demo-lease",
runnerInstanceId: "standalone-demo-runner",
normalizedSessionId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.sessionId,
driverSessionId: `${resolution.resolvedMode}-demo-driver`,
providerSessionId: `${resolution.resolvedMode}-demo-provider`,
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "new_per_run",
driver: { kind: resolution.resolvedMode === "native" ? "native-demo" : "legacy-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
unsupported: [],
},
}),
CONTROL_PLANE_CONFORMANCE_EVENTS,
);
return {
schema: "paperclip.runner.phase6.standalone-demo.v1",
...resolution,
adapter: adapter.kind,
nativeAdapterInvocationCount: adapter.kind === "native_demo_adapter" ? adapter.invocationCount() : 0,
legacyAdapterInvocationCount: adapter.kind === "legacy_demo_adapter" ? adapter.invocationCount() : 0,
conformance,
replay: {
eventCount: CONTROL_PLANE_CONFORMANCE_EVENTS.length,
highestContiguousSourceSeq: conformance.highestContiguousSourceSeq,
eventTypes: CONTROL_PLANE_CONFORMANCE_EVENTS.map((event) => event.eventType),
},
reducer: {
integrity: snapshot.integrity,
sessionState: snapshot.sessionState,
turnState: snapshot.turnState,
runPhase: snapshot.runPhase,
timelineCount: snapshot.timeline.length,
runTerminalState: snapshot.terminal?.runTerminalState ?? null,
},
finalization: {
resultPersisted: true,
terminalPersisted: true,
reportedWorkDisposition: "done",
},
};
}
+268
View File
@@ -272,6 +272,9 @@
.pcr-console {
display: grid;
gap: var(--pcr-space-4);
width: 100%;
max-width: 100%;
overflow-wrap: anywhere;
}
.pcr-console-header,
@@ -292,6 +295,29 @@
font-weight: var(--pcr-weight-semibold);
}
.pcr-console-version {
color: var(--pcr-muted-foreground);
font-family: var(--pcr-font-mono);
font-size: var(--pcr-font-size-xs);
font-weight: var(--pcr-weight-semibold);
white-space: nowrap;
}
.pcr-console[data-mode="chat"] .pcr-panel,
.pcr-console[data-mode="chat"] .pcr-conversation,
.pcr-console[data-mode="chat"] .pcr-composer,
.pcr-console[data-mode="chat"] .pcr-message,
.pcr-console[data-mode="chat"] .pcr-disclosure {
border-color: transparent;
box-shadow: none;
}
.pcr-console[data-mode="chat"] .pcr-panel,
.pcr-console[data-mode="chat"] .pcr-disclosure {
padding-inline: 0;
background: transparent;
}
.pcr-breadcrumb {
color: var(--pcr-muted-foreground);
font-family: var(--pcr-font-mono);
@@ -323,6 +349,20 @@
gap: var(--pcr-space-3);
min-width: 0;
max-width: var(--pcr-center-width);
width: 100%;
}
.pcr-console-mobile .pcr-console-center {
margin-inline: auto;
}
.pcr-mobile-panel-content {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--pcr-space-4);
min-width: 0;
max-height: calc(100vh - var(--pcr-space-10));
overflow-y: auto;
}
.pcr-panel {
@@ -501,10 +541,15 @@
.pcr-conversation {
position: relative;
min-width: 0;
max-width: 100%;
}
.pcr-conversation-viewport {
min-width: 0;
max-width: 100%;
max-height: var(--pcr-transcript-height);
overflow-x: hidden;
overflow-y: auto;
padding: var(--pcr-space-4);
background: var(--pcr-card);
@@ -548,6 +593,8 @@
.pcr-message {
display: grid;
gap: var(--pcr-space-1);
min-width: 0;
max-width: 100%;
padding: var(--pcr-space-3);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-md);
@@ -559,6 +606,17 @@
background: var(--pcr-muted);
}
.pcr-message[data-role="assistant"] {
margin-right: var(--pcr-space-6);
border-color: color-mix(in srgb, var(--pcr-accent) 38%, var(--pcr-border));
background: linear-gradient(135deg, var(--pcr-card), var(--pcr-accent-surface));
box-shadow: var(--pcr-shadow-sm);
}
.pcr-message[data-role="assistant"] .pcr-message-role {
color: var(--pcr-accent);
}
.pcr-message[data-state="failed"] {
border-color: var(--pcr-danger);
background: var(--pcr-danger-surface);
@@ -579,6 +637,101 @@
.pcr-message-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.pcr-message-body {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.pcr-response {
display: grid;
gap: var(--pcr-space-4);
}
.pcr-response-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--pcr-space-4);
}
.pcr-response-summary {
margin-top: var(--pcr-space-1);
max-width: var(--pcr-center-width);
font-size: var(--pcr-font-size-xl);
font-weight: var(--pcr-weight-semibold);
line-height: var(--pcr-line-normal);
}
.pcr-response-section {
display: grid;
gap: var(--pcr-space-2);
padding-top: var(--pcr-space-3);
border-top: 0.0625rem solid var(--pcr-border);
}
.pcr-response-label {
color: var(--pcr-muted-foreground);
font-size: var(--pcr-font-size-xs);
font-weight: var(--pcr-weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pcr-response-checks,
.pcr-response-list {
display: grid;
gap: var(--pcr-space-2);
}
.pcr-response-checks li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--pcr-space-2);
align-items: start;
}
.pcr-response-checks li > span:first-child {
color: var(--pcr-success);
font-weight: var(--pcr-weight-semibold);
}
.pcr-response-list {
padding-left: var(--pcr-space-5);
list-style: disc;
}
.pcr-response-details {
padding-top: var(--pcr-space-2);
border-top: 0.0625rem dashed var(--pcr-border);
color: var(--pcr-muted-foreground);
font-size: var(--pcr-font-size-xs);
}
.pcr-response-details > summary,
.pcr-response-debug summary {
width: fit-content;
cursor: pointer;
font-weight: var(--pcr-weight-semibold);
}
.pcr-response-details-body {
display: grid;
gap: var(--pcr-space-3);
margin-top: var(--pcr-space-3);
}
.pcr-response-empty-detail {
color: var(--pcr-muted-foreground);
}
.pcr-response-debug {
color: var(--pcr-muted-foreground);
}
.pcr-message-divider {
@@ -628,6 +781,8 @@
/* Disclosures ---------------------------------------------------------------- */
.pcr-disclosure {
min-width: 0;
max-width: 100%;
padding: var(--pcr-space-2) var(--pcr-space-3);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-md);
@@ -644,6 +799,12 @@
font-weight: var(--pcr-weight-medium);
}
.pcr-disclosure-summary > span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.pcr-disclosure-preview,
.pcr-disclosure-label {
color: var(--pcr-muted-foreground);
@@ -659,6 +820,8 @@
}
.pcr-payload {
min-width: 0;
max-width: 100%;
max-height: var(--pcr-transcript-height);
margin: var(--pcr-space-2) 0 0;
overflow: auto;
@@ -671,6 +834,22 @@
word-break: break-word;
}
.pcr-tool-debug {
min-width: 0;
max-width: 100%;
margin-top: var(--pcr-space-3);
color: var(--pcr-muted-foreground);
}
.pcr-tool-debug > summary {
width: fit-content;
max-width: 100%;
cursor: pointer;
font-size: var(--pcr-font-size-xs);
font-weight: var(--pcr-weight-semibold);
overflow-wrap: anywhere;
}
.pcr-payload--danger {
background: var(--pcr-danger-surface);
color: var(--pcr-danger);
@@ -1130,9 +1309,98 @@
}
@media (max-width: 56.25rem) {
.pcr-console {
min-height: 100dvh;
padding: var(--pcr-space-3);
}
.pcr-console-header {
align-items: flex-start;
flex-wrap: nowrap;
}
.pcr-console-title {
min-width: 0;
align-items: flex-start;
flex-direction: column;
gap: var(--pcr-space-1);
}
.pcr-console-title h1 {
font-size: var(--pcr-font-size-lg);
}
.pcr-console-header-actions {
flex: none;
}
.pcr-console-mobile,
.pcr-console-mobile .pcr-console-center {
min-width: 0;
width: 100%;
}
.pcr-conversation,
.pcr-conversation-viewport,
.pcr-transcript,
.pcr-transcript > li,
.pcr-message,
.pcr-message-body,
.pcr-disclosure,
.pcr-disclosure-summary,
.pcr-disclosure-summary > span,
.pcr-composer,
.pcr-inspector,
.pcr-inspector-events,
.pcr-request {
min-width: 0;
max-width: 100%;
}
.pcr-conversation-viewport {
max-height: calc(100dvh - var(--pcr-space-10) - var(--pcr-space-10) - var(--pcr-space-10));
padding: var(--pcr-space-3);
}
.pcr-composer-region {
position: sticky;
bottom: 0;
z-index: var(--pcr-z-banner);
padding-top: var(--pcr-space-2);
padding-bottom: max(var(--pcr-space-2), env(safe-area-inset-bottom));
background: var(--pcr-background);
}
.pcr-menu-list {
position: fixed;
top: var(--pcr-space-10);
right: var(--pcr-space-3);
left: var(--pcr-space-3);
min-width: 0;
}
.pcr-dialog {
width: calc(100% - var(--pcr-space-6));
max-height: calc(100dvh - var(--pcr-space-6));
}
.pcr-dialog-body {
padding: var(--pcr-space-4);
}
.pcr-message[data-role="user"] {
margin-left: var(--pcr-space-5);
}
.pcr-message[data-role="assistant"] {
margin-right: 0;
}
.pcr-response-heading {
align-items: flex-start;
flex-direction: column;
gap: var(--pcr-space-2);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -2,17 +2,34 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": ".",
"types": ["vite/client"],
"types": [
"vite/client"
],
"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"],
"@paperclip-runner-local/phase7": ["src/phase7/browser-index.ts"]
"@paperclipai/paperclip-runner/browser": [
"src/browser/index.ts"
],
"@paperclipai/paperclip-runner/react": [
"src/react/index.ts"
],
"@paperclipai/paperclip-runner/standalone": [
"src/standalone/index.ts"
],
"@paperclipai/paperclip-runner/styles.css": [
"styles.css"
],
"@paperclip-runner-local/phase7": [
"src/phase7/browser-index.ts"
]
}
},
"include": [
@@ -21,7 +38,11 @@
"devtools/issue-thread/src/**/*.ts",
"devtools/issue-thread/src/**/*.tsx",
"examples/*/src/**/*.ts",
"examples/*/src/**/*.tsx"
"examples/*/src/**/*.tsx",
"src/standalone/**/*.ts"
],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
"exclude": [
"**/*.test.ts",
"**/*.test.tsx"
]
}
@@ -0,0 +1,30 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "examples"),
plugins: [react()],
resolve: {
alias: [
{
find: "@paperclipai/paperclip-runner/standalone",
replacement: resolve(packageRoot, "src/standalone/index.ts"),
},
],
},
server: { host: "127.0.0.1" },
preview: { host: "127.0.0.1" },
build: {
outDir: resolve(packageRoot, "dist-phase6"),
emptyOutDir: true,
target: "esnext",
rollupOptions: {
input: resolve(packageRoot, "examples/phase6-standalone/index.html"),
},
},
});