mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
feat(runner): consolidate remaining Phase 5 and 6 work
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cf27ba2869
commit
ae80fe4d00
43 files changed
+1857
-553
No files matched your search
@@ -2,6 +2,7 @@
|
||||
/dist-phase5/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/dist-phase6/
|
||||
/dist-phase7/
|
||||
/dist-issue-thread/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user