feat(runner): add phase 4b live codex protocol console

Add the package-local browser tracer over the Phase 4b protocol boundary as a
new Live console mode in the standalone devtool.

Surfaces derive only from canonical PRP events and the shared reducer: live
transcript and composer, same-turn steering with stale-turn recovery, three
distinct interrupt races, inline runtime request cards, capability-gated goal
controls, parent/child lineage, reconnect, refresh, replay, and a redacted
protocol inspector.

Add a deterministic scripted driver and eleven demo chats behind the same demo
server routes the real Codex driver uses, so every protocol state is reachable
without a provider account and PAPERCLIP_PHASE4B_DRIVER=codex swaps in the real
session.

Adapt shadcn/ui and Vercel AI Elements source with zero new runtime
dependencies, and extend the boundary check to keep it that way.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed 2026-08-08 05:26:45 +00:00
1 parent 62d384b383
commit 96092b832d
70 files changed
+7218 -33

No files matched your search

+16 -5
View File
@@ -16,9 +16,13 @@ and a real-model example against the mock core. These phases do not import or
start Paperclip's server, UI, CLI, or production database. The Phase 4b lower
layer adds browser-resolved provider requests, goal capability detection,
subagent lineage, control-race semantics, and a package-local demo server that
keeps Codex authentication server-side.
keeps Codex authentication server-side. The Phase 4b live console adds the
browser tracer over that boundary: a live transcript and composer, same-turn
steering, three distinct interrupt races, inline request cards,
capability-gated goal controls, parent/child lineage, reconnect, refresh,
replay, and a redacted protocol inspector.
## Phase 0–4 quick start
## Phase 0–4b quick start
From the repository root:
@@ -59,6 +63,9 @@ pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-pat
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
pnpm --filter @paperclipai/paperclip-runner trace:phase4
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --port 4174
# Phase 4b: chat with a live session in the browser.
pnpm --filter @paperclipai/paperclip-runner console:phase4b
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
```
@@ -85,10 +92,11 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
| `trace:phase4` | Run the mock core with a real, local skillless Codex app-server session. |
| `record:phase4` | Run the safe Codex task and record its validated, normalized trace. |
| `demo:phase4b` | Start the package-local HTTP/SSE server with server-only Codex authentication. |
| `console:phase4b` | Start the standalone browser devtool with the Phase 4b live console on `127.0.0.1:4180`. |
| `record:phase4b` | Run a safe real Codex task through the demo server and record reconnect/replay evidence. |
| `browser:dev` | Start the standalone live/replay/recovery browser devtool. |
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
| `verify` | Run the complete deterministic Phase 0 through Phase 4 acceptance sequence. |
| `verify` | Run the complete deterministic Phase 0 through Phase 4b acceptance sequence. |
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
## Navigate
@@ -105,6 +113,8 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
- [Phase 4 skillless Codex driver reference](docs/phase-04-skillless-codex-driver.md)
- [Phase 4b protocol/server tutorial](docs/tutorials/phase-04b-protocol-server.md)
- [Phase 4b protocol/server reference](docs/phase-04b-protocol-server.md)
- [Phase 4b live console tutorial](docs/tutorials/phase-04b-live-console.md)
- [Phase 4b live console reference](docs/phase-04b-live-console.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
- [Journal guide](docs/journal.md)
@@ -112,5 +122,6 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
Phase 4 adds the package-local real-model reference driver. Production
Paperclip integration and Phase 5 remain deferred.
Phase 4 adds the package-local real-model reference driver and Phase 4b adds
the package-local browser console over it. Production Paperclip integration and
Phase 5 remain deferred.
@@ -0,0 +1,395 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
const evidenceRoot = resolve(
fileURLToPath(new URL("../../knowledge/evidence/phase-04b", import.meta.url)),
);
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
function shot(page: Page, name: string) {
return page.screenshot({ path: resolve(evidenceRoot, `${name}.png`), fullPage: true });
}
async function openConsole(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByRole("heading", { name: "Live Codex protocol console" })).toBeVisible();
}
async function runManifest(page: Page, id: string) {
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
}
test.describe("Phase 4b live console", () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(DESKTOP);
});
test("shows the empty state and runs a completed turn", async ({ page }) => {
await openConsole(page);
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await expect(page.getByTestId("composer-send")).toBeDisabled();
await shot(page, "desktop-01-idle-empty");
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("tool-item")).toBeVisible();
await shot(page, "desktop-02-streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("live-transcript")).toContainText("finished the task");
await expect(page.getByTestId("live-transcript")).toContainText(
"Summarize what the Phase 4b demo server proves.",
);
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "completed");
await shot(page, "desktop-03-turn-completed");
// The browser must never receive a provider credential.
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
expect(body).not.toContain("auth.json");
});
test("acknowledges steering and keeps rejected text recoverable", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Keep it to one sentence.");
await page.getByTestId("composer-send").click();
const chip = page.getByTestId("steering-chip").first();
await expect(chip).toContainText("acknowledged", { timeout: 15_000 });
await shot(page, "desktop-04-steering-acknowledged");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
// The composer state machine now offers Send again; force a stale steer.
await page.evaluate(() => window.history.replaceState({}, "", "/"));
});
test("rejects a stale steer and offers to resend it", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Typed while the turn was running.");
// Let the turn finish before the steer is sent: the classic stale race.
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
await expect(page.getByTestId("composer-send")).toHaveText("Send");
});
test("cancels a turn interrupted before it starts", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-before-start");
await expect(page.getByTestId("composer-stop")).toBeEnabled();
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-cancelled")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("turn-cancelled")).toContainText("Cancelled before start");
await shot(page, "desktop-05-interrupt-before-start");
});
test("keeps partial text when a streaming turn is stopped", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-generation");
await expect(page.getByTestId("live-transcript")).toContainText("The tracer keeps", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("interrupted-divider").first()).toBeVisible();
await expect(page.getByTestId("live-transcript")).not.toContainText("And this final one.");
await shot(page, "desktop-06-interrupt-during-generation");
});
test("keeps an interrupted tool item without a fabricated result", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-tool");
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "running", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "interrupted", {
timeout: 15_000,
});
await expect(page.getByTestId("turn-interrupted")).toBeVisible();
await shot(page, "desktop-07-interrupt-during-tool");
});
test("resolves approvals exactly once and collapses the resolved card", async ({ page }) => {
await openConsole(page);
await runManifest(page, "approvals");
const card = page.getByTestId("request-card").first();
await expect(card).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(page.getByTestId("pending-request-banner")).toBeVisible();
await expect(page.getByTestId("request-action-accept_for_session")).toBeVisible();
await shot(page, "desktop-08-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await expect(page.getByTestId("request-card").first()).toContainText("resolved — Approve");
await shot(page, "desktop-09-request-resolved");
// Second request only offers accept and decline.
const second = page.getByTestId("request-card").nth(1);
await expect(second).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0);
});
test("submits a user-input answer and lets an unanswered request expire", async ({ page }) => {
test.setTimeout(60_000);
await openConsole(page);
await runManifest(page, "user-input");
await expect(page.getByTestId("request-answer")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("request-answer").fill("staging");
await page.getByTestId("request-action-submit").click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await expect(page.getByTestId("request-card").nth(1)).toHaveAttribute(
"data-status",
"expired",
{ timeout: 25_000 },
);
await expect(page.getByTestId("request-card").nth(1)).toContainText("expired before response");
await shot(page, "desktop-10-request-expired");
});
test("moves a goal through its lifecycle", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals");
await expect(page.getByTestId("goal-banner")).toHaveText("No goal set", { timeout: 15_000 });
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await page.getByTestId("goal-objective").fill("Keep the demo goal visible");
await page.getByTestId("goal-set-confirm").click();
await expect(page.getByTestId("goal-banner")).toContainText("Keep the demo goal visible", {
timeout: 15_000,
});
await expect(page.getByTestId("goal-banner")).toContainText("active");
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Pause" }).click();
await expect(page.getByTestId("goal-banner")).toContainText("paused", { timeout: 15_000 });
await shot(page, "desktop-11-goal-supported");
});
test("disables the goal menu with the exact upstream diagnostic", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals-unsupported");
const trigger = page.locator("#goal-menu");
await expect(trigger).toHaveAttribute("aria-disabled", "true", { timeout: 15_000 });
const describedBy = await trigger.getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
await expect(page.locator(`#${describedBy}`)).toHaveText(
"Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability",
);
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Capabilities" }).click();
await expect(page.getByTestId("inspector-capabilities")).toContainText(
"does not advertise the goals capability",
);
await shot(page, "desktop-12-goal-unsupported");
});
test("shows child threads and refuses to emulate child steering", async ({ page }) => {
await openConsole(page);
await runManifest(page, "subagents");
await expect(page.getByTestId("thread-thread-child-b")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("thread-thread-child-a")).toContainText("Completed");
await expect(page.getByTestId("thread-thread-child-b")).toContainText("Failed");
await page.getByTestId("thread-thread-child-a").click();
await expect(page.getByTestId("breadcrumb")).toContainText("thread-child-a");
await expect(page.getByTestId("composer-reason")).toHaveText(
"Direct steering of child threads is not supported by this app-server.",
);
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-13-lineage-child");
});
test("shows the failed item and diagnostic in the record", async ({ page }) => {
await openConsole(page);
await runManifest(page, "failure");
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("tool-failure")).toContainText("cannot find module");
await shot(page, "desktop-14-failed-turn");
});
test("resumes the same session after a transport drop", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.getByTestId("drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-15-reconnect-banner");
await page.getByTestId("retry-now").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", {
timeout: 20_000,
});
// Live and resumed transcripts must be identical, not merely similar.
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
});
test("restores the same session across a page refresh", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.reload();
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByTestId("live-transcript")).toContainText("finished the task", {
timeout: 20_000,
});
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
await shot(page, "desktop-16-refresh-replay");
});
test("replays a recorded session with a stepper", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toBeVisible();
await expect(page.getByTestId("replay-stepper")).toBeVisible();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await page.getByRole("button", { name: "Step forward" }).click();
await page.getByRole("button", { name: "Step forward" }).click();
await expect(page.getByTestId("empty-transcript")).toHaveCount(0);
await shot(page, "desktop-17-replay-mode");
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toHaveCount(0);
await expect(page.getByTestId("turn-completed")).toBeVisible();
});
test("inspects canonical events and reports reducer parity", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-inspector").click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await page.getByTestId("inspector-filter").fill("turn.completed");
await expect(page.getByTestId("inspector-events").getByRole("listitem")).toHaveCount(1);
await shot(page, "desktop-18-inspector-events");
await page.getByTestId("inspector-filter").fill("");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("inspector-session")).toContainText("Credentials in browser");
await expect(page.getByTestId("replay-parity")).toHaveText("match");
await shot(page, "desktop-19-inspector-session");
});
test("meets the keyboard contract", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
// K2: Escape moves focus to Stop and never interrupts directly.
await page.getByTestId("composer-input").focus();
await page.keyboard.press("Escape");
await expect(page.getByTestId("composer-stop")).toBeFocused();
await expect(page.getByTestId("turn-interrupted")).toHaveCount(0);
// K2: Enter steers the running turn.
await page.getByTestId("composer-input").fill("Steered with the keyboard.");
await page.getByTestId("composer-input").press("Enter");
await expect(page.getByTestId("steering-chip").first()).toContainText("acknowledged", {
timeout: 15_000,
});
// K7: inspector tabs follow the arrow-key tabs pattern.
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Events" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
await expect(page.getByRole("tab", { name: "Requests" })).toHaveAttribute(
"aria-selected",
"true",
);
// K5: lineage tree responds to arrow keys.
await page.getByTestId("lineage-tree").getByRole("treeitem").first().focus();
await page.keyboard.press("ArrowDown");
// K6/A1: the transcript is a labelled log landmark.
await expect(page.getByTestId("live-transcript")).toHaveAttribute("role", "log");
await expect(page.getByTestId("live-transcript")).toHaveAttribute("aria-live", "polite");
});
test("resets demo state behind a naming confirmation", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: "Reset demo state", exact: true }).first().click();
await expect(page.getByRole("dialog")).toContainText(
"Recorded evidence files are not touched.",
);
await page.getByTestId("reset-confirm").click();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
});
});
test.describe("Phase 4b live console on a small screen", () => {
test("keeps the transcript and composer usable at 390 by 844", async ({ page }) => {
await page.setViewportSize(MOBILE);
await openConsole(page);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
await expect(page.getByTestId("manifest-list")).toHaveCount(0);
await shot(page, "mobile-01-idle-empty");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId("manifest-approvals").click();
await page.getByTestId("run-manifest").click();
await shot(page, "mobile-02-session-segment");
await page.getByRole("tab", { name: "Chat" }).click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"pending",
{ timeout: 20_000 },
);
await shot(page, "mobile-03-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await page.getByRole("tab", { name: "Inspector" }).click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await shot(page, "mobile-04-inspector");
// WCAG 1.4.10 reflow: no horizontal scrolling of the page itself.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
});
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "static-replay.spec.ts",
testMatch: ["static-replay.spec.ts", "live-console.spec.ts"],
fullyParallel: false,
workers: 1,
reporter: "line",
@@ -13,6 +13,11 @@ export default defineConfig({
webServer: {
command:
"pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179",
env: {
// Keep streamed chunks slow enough that interrupt races are reachable
// from a test, and fast enough that the suite stays quick.
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "60",
},
url: "http://127.0.0.1:4179",
reuseExistingServer: false,
timeout: 30_000,
@@ -29,6 +29,7 @@ import {
CardTitle,
} from "./components/ui/card";
import { Textarea } from "./components/ui/textarea";
import { LiveConsole } from "./live/LiveConsole";
const fixtureFiles = import.meta.glob(
"../../../protocol/fixtures/phase-01/*.json",
@@ -53,7 +54,7 @@ const liveScenarios: Array<{ key: Phase2Scenario; label: string }> = [
{ key: "duplicate-terminal", label: "Duplicate terminal guard" },
];
type BrowserMode = "recovery" | "live" | "replay";
type BrowserMode = "console" | "recovery" | "live" | "replay";
type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
const phase3FaultLabels: Record<Phase3Fault, string> = {
@@ -74,6 +75,12 @@ const modeCopy: Record<
BrowserMode,
{ eyebrow: string; title: string; description: string }
> = {
console: {
eyebrow: "Paperclip Runner Protocol · Phase 4b",
title: "Live Codex protocol console",
description:
"Chat with a live session, steer it, stop it, answer its requests, and inspect the canonical protocol behind every surface.",
},
recovery: {
eyebrow: "Paperclip Runner Protocol · Phase 3",
title: "Durable recovery diagnostics",
@@ -821,6 +828,8 @@ function StaticReplay() {
}
export function App() {
// Phase 1-3 surfaces keep their landing mode: their screenshots are frozen
// QA evidence and their specs open on the Phase 2 runner.
const [mode, setMode] = useState<BrowserMode>("live");
const header = modeCopy[mode];
@@ -836,6 +845,13 @@ export function App() {
</header>
<nav className="mode-switch" aria-label="Runner devtool mode">
<Button
type="button"
aria-pressed={mode === "console"}
onClick={() => setMode("console")}
>
Live console
</Button>
<Button
type="button"
aria-pressed={mode === "recovery"}
@@ -859,7 +875,15 @@ export function App() {
</Button>
</nav>
{mode === "recovery" ? <RecoveryDiagnostics /> : mode === "live" ? <LiveRunner /> : <StaticReplay />}
{mode === "console" ? (
<LiveConsole />
) : mode === "recovery" ? (
<RecoveryDiagnostics />
) : mode === "live" ? (
<LiveRunner />
) : (
<StaticReplay />
)}
</main>
);
}
@@ -6,7 +6,7 @@ export function Badge({
tone = "neutral",
...props
}: React.ComponentProps<"span"> & {
tone?: "neutral" | "success" | "warning" | "danger";
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
}) {
return (
<span
@@ -0,0 +1,40 @@
import * as React from "react";
/**
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
* is only used for standing conditions (reconnect, pending request, replay).
*/
export function Banner({
className = "",
tone = "neutral",
assertive = false,
...props
}: React.ComponentProps<"div"> & {
tone?: "neutral" | "accent" | "warning" | "danger";
assertive?: boolean;
}) {
return (
<div
data-slot="banner"
data-tone={tone}
role={assertive ? "alert" : "status"}
aria-live={assertive ? "assertive" : "polite"}
className={`ui-banner ${className}`.trim()}
{...props}
/>
);
}
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
return <p data-slot="banner-text" className={`ui-banner-text ${className}`.trim()} {...props} />;
}
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="banner-actions"
className={`ui-banner-actions ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,133 @@
import * as React from "react";
import type { ComposerState } from "../../live/transcript-model";
import { Button } from "./button";
const MAX_ROWS = 8;
const PRIMARY_LABEL: Record<ComposerState, string> = {
idle: "Send",
submitting: "Sending…",
"active-turn": "Steer",
interrupting: "Stopping…",
disconnected: "Send",
terminal: "Send",
};
const HINT: Record<ComposerState, string> = {
idle: "Enter sends. Shift and Enter add a line.",
submitting: "Waiting for the turn to be accepted.",
"active-turn": "Enter steers the running turn. Escape moves focus to Stop.",
interrupting: "Stopping the turn. Waiting for the terminal turn event.",
disconnected: "The composer is disabled until the connection is restored.",
terminal: "This session is closed. Replay it or reset the demo state.",
};
/**
* Source-adapted AI Elements `PromptInput`. The textarea plus action-row
* anatomy and submit-on-Enter are kept; the Send/Steer/Stop tri-state comes
* from the runner protocol, and there are no attachments or model pickers.
*/
export function Composer({
state,
value,
onValueChange,
onSubmit,
onStop,
disabledReason = null,
label = "Message",
}: {
state: ComposerState;
value: string;
onValueChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
disabledReason?: string | null;
label?: string;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const stopRef = React.useRef<HTMLButtonElement>(null);
const inputId = React.useId();
const hintId = React.useId();
const reasonId = React.useId();
const locked =
disabledReason !== null ||
state === "submitting" ||
state === "interrupting" ||
state === "disconnected" ||
state === "terminal";
const canStop = state === "active-turn" || state === "submitting";
React.useEffect(() => {
const textarea = textareaRef.current;
if (textarea === null) return;
textarea.rows = 1;
const rows = Math.min(value.split("\n").length, MAX_ROWS);
textarea.rows = Math.max(rows, 2);
}, [value]);
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (!locked && value.trim().length > 0) onSubmit();
return;
}
// Escape never interrupts directly; it only moves focus to Stop.
if (event.key === "Escape" && canStop) {
event.preventDefault();
stopRef.current?.focus();
}
}
return (
<div data-slot="composer" data-state={state} className="ui-composer">
<label className="ui-visually-hidden" htmlFor={inputId}>
{label}
</label>
<textarea
ref={textareaRef}
id={inputId}
className="ui-composer-input"
data-testid="composer-input"
rows={2}
value={value}
disabled={locked}
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="ui-composer-actions">
<p id={hintId} className="ui-composer-hint">
{HINT[state]}
</p>
{disabledReason === null ? null : (
<p id={reasonId} className="ui-composer-reason" data-testid="composer-reason">
{disabledReason}
</p>
)}
<div className="ui-composer-buttons">
<Button
ref={stopRef}
type="button"
className="ui-button--danger"
data-testid="composer-stop"
disabled={!canStop}
onClick={onStop}
>
Stop
</Button>
<Button
type="button"
data-testid="composer-send"
disabled={locked || value.trim().length === 0}
onClick={onSubmit}
>
{PRIMARY_LABEL[state]}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Conversation`. The stick-to-bottom semantics and
* the "jump to latest" affordance are kept; the `use-stick-to-bottom`
* dependency is replaced by a scroll listener, and the unseen count comes from
* the reducer timeline length rather than a message store.
*/
export function Conversation({
itemCount,
label,
children,
}: {
itemCount: number;
label: string;
children: React.ReactNode;
}) {
const viewportRef = React.useRef<HTMLDivElement>(null);
const [following, setFollowing] = React.useState(true);
const [seenCount, setSeenCount] = React.useState(itemCount);
const scrollToBottom = React.useCallback((smooth: boolean) => {
const viewport = viewportRef.current;
if (viewport === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: smooth && !reduceMotion ? "smooth" : "auto",
});
}, []);
React.useEffect(() => {
if (!following) return;
scrollToBottom(true);
setSeenCount(itemCount);
}, [itemCount, following, scrollToBottom]);
function onScroll() {
const viewport = viewportRef.current;
if (viewport === null) return;
const atBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
setFollowing(atBottom);
if (atBottom) setSeenCount(itemCount);
}
function reengage() {
setFollowing(true);
setSeenCount(itemCount);
scrollToBottom(true);
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "End") {
event.preventDefault();
reengage();
} else if (event.key === "Home") {
event.preventDefault();
setFollowing(false);
viewportRef.current?.scrollTo({ top: 0 });
}
}
const unseen = Math.max(itemCount - seenCount, 0);
return (
<div data-slot="conversation" className="ui-conversation">
<div
ref={viewportRef}
role="log"
aria-label={label}
aria-live="polite"
aria-relevant="additions"
tabIndex={0}
className="ui-conversation-viewport"
data-following={following}
data-testid="live-transcript"
onScroll={onScroll}
onKeyDown={onKeyDown}
>
{children}
</div>
{following ? null : (
<button
type="button"
className="ui-jump-to-latest"
data-testid="jump-to-latest"
onClick={reengage}
>
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
</button>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
* so no radix runtime and no focus-trap dependency are needed.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
footer: React.ReactNode;
}) {
const ref = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const element = ref.current;
if (element === null) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={ref}
data-slot="dialog"
className="ui-dialog"
aria-labelledby={titleId}
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClose={onClose}
>
<form method="dialog" className="ui-dialog-body">
<h2 id={titleId} className="ui-dialog-title">
{title}
</h2>
{description === undefined ? null : (
<p id={descriptionId} className="ui-dialog-description">
{description}
</p>
)}
{children}
<div className="ui-dialog-footer">{footer}</div>
</form>
</dialog>
);
}
@@ -0,0 +1,138 @@
import * as React from "react";
export interface MenuItemDefinition {
id: string;
label: string;
enabled: boolean;
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
reason: string | null;
}
/**
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
* goal surface needs. Five fixed verbs, no search, no radix portal.
*/
export function Menu({
label,
items,
onSelect,
disabled = false,
disabledReason = null,
triggerId,
}: {
label: string;
items: readonly MenuItemDefinition[];
onSelect: (id: string) => void;
disabled?: boolean;
disabledReason?: string | null;
triggerId: string;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const descriptionId = `${triggerId}-reason`;
React.useEffect(() => {
if (!open) return undefined;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function focusItem(index: number) {
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]',
);
if (buttons === undefined || buttons.length === 0) return;
buttons[(index + buttons.length) % buttons.length]?.focus();
}
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
setOpen(true);
window.setTimeout(() => focusItem(0), 0);
}
}
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const buttons = [
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
focusItem(index + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
focusItem(index - 1);
}
}
return (
<div ref={containerRef} data-slot="menu" className="ui-menu">
<button
ref={triggerRef}
id={triggerId}
type="button"
className="ui-button ui-button--quiet"
aria-haspopup="menu"
aria-expanded={open}
aria-disabled={disabled}
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (disabled) return;
onTriggerKeyDown(event);
}}
>
{label}
<span aria-hidden="true"> ▾</span>
</button>
{disabled && disabledReason !== null ? (
<p id={descriptionId} className="ui-menu-reason">
{disabledReason}
</p>
) : null}
{open && !disabled ? (
<div role="menu" aria-labelledby={triggerId} className="ui-menu-list" onKeyDown={onMenuKeyDown}>
{items.map((item) => {
const itemReasonId = `${triggerId}-${item.id}-reason`;
return (
<React.Fragment key={item.id}>
<button
type="button"
role="menuitem"
className="ui-menu-item"
aria-disabled={!item.enabled}
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
onClick={() => {
if (!item.enabled) return;
setOpen(false);
onSelect(item.id);
}}
>
{item.label}
</button>
{item.reason === null ? null : (
<p id={itemReasonId} className="ui-menu-reason">
{item.reason}
</p>
)}
</React.Fragment>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,60 @@
import * as React from "react";
import type { TranscriptRole } from "../../live/transcript-model";
const ROLE_LABELS: Record<TranscriptRole, string> = {
user: "You",
assistant: "Assistant",
reasoning: "Reasoning",
tool: "Tool",
steering: "Steering acknowledged",
interrupt: "Interrupt acknowledged",
goal: "Goal",
lineage: "Child thread",
system: "System",
};
/**
* Source-adapted AI Elements `Message`. The role-based alignment and the
* compact avatar-less variant are kept; the props are reducer item snapshots
* rather than AI SDK message parts.
*/
export function Message({
role,
children,
interrupted = false,
failed = false,
streaming = false,
}: {
role: TranscriptRole;
children: React.ReactNode;
interrupted?: boolean;
failed?: boolean;
streaming?: boolean;
}) {
return (
<article
data-slot="message"
data-role={role}
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
className="ui-message"
>
<p className="ui-message-role">{ROLE_LABELS[role]}</p>
<div className="ui-message-body">{children}</div>
{interrupted ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the text above is everything that arrived.
</p>
) : null}
</article>
);
}
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
return (
<p className="ui-message-text">
{text}
{streaming ? <span className="ui-stream-cursor" aria-hidden="true" /> : null}
</p>
);
}
@@ -0,0 +1,33 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Reasoning`. The collapsible block that opens
* while streaming is kept; it is rebuilt on native `<details>` and renders
* exact streamed text with no markdown pipeline.
*/
export function ReasoningItem({
text,
streaming,
interrupted,
}: {
text: string;
streaming: boolean;
interrupted: boolean;
}) {
const summary = text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
return (
<details
data-slot="reasoning"
className="ui-disclosure"
open={streaming}
data-testid="reasoning-item"
>
<summary className="ui-disclosure-summary">
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
<span className="ui-disclosure-preview">{summary}</span>
</summary>
<pre className="ui-payload">{text}</pre>
{interrupted ? <p className="ui-message-divider">Interrupted while reasoning.</p> : null}
</details>
);
}
@@ -0,0 +1,94 @@
import * as React from "react";
export interface TabDefinition {
id: string;
label: string;
}
/**
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
* enters the panel.
*/
export function Tabs({
tabs,
value,
onValueChange,
label,
className = "",
children,
}: {
tabs: readonly TabDefinition[];
value: string;
onValueChange: (value: string) => void;
label: string;
className?: string;
children: React.ReactNode;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
function moveFocus(nextIndex: number) {
const next = tabs[(nextIndex + tabs.length) % tabs.length];
if (next === undefined) return;
onValueChange(next.id);
const button = listRef.current?.querySelector<HTMLButtonElement>(
`[data-tab-id="${next.id}"]`,
);
button?.focus();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const index = tabs.findIndex((tab) => tab.id === value);
if (event.key === "ArrowRight") {
event.preventDefault();
moveFocus(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
moveFocus(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
moveFocus(0);
} else if (event.key === "End") {
event.preventDefault();
moveFocus(tabs.length - 1);
}
}
return (
<div data-slot="tabs" className={`ui-tabs ${className}`.trim()}>
<div
ref={listRef}
role="tablist"
aria-label={label}
className="ui-tablist"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
data-tab-id={tab.id}
id={`tab-${tab.id}`}
aria-selected={tab.id === value}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={tab.id === value ? 0 : -1}
className="ui-tab"
onClick={() => onValueChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
tabIndex={0}
className="ui-tabpanel"
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import * as React from "react";
import { Badge } from "./badge";
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
const STATUS_TONE: Record<ToolStatus, "neutral" | "accent" | "success" | "danger" | "warning"> = {
running: "accent",
completed: "success",
failed: "danger",
interrupted: "warning",
};
const STATUS_LABEL: Record<ToolStatus, string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
};
/**
* Source-adapted AI Elements `Tool`. The header/input/output anatomy is kept;
* the status enum comes from canonical item events instead of an AI SDK tool
* part, and payloads stay in `<pre>` so nothing is prettified away.
*/
export function ToolItem({
name,
status,
input,
output,
failure,
}: {
name: string;
status: ToolStatus;
input: string | null;
output: string | null;
failure: string | null;
}) {
return (
<details
data-slot="tool"
className="ui-disclosure"
open={status === "running" || status === "failed"}
data-testid="tool-item"
data-status={status}
>
<summary className="ui-disclosure-summary">
<span>
Tool <code>{name}</code>
</span>
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
</summary>
{input === null ? null : (
<>
<p className="ui-disclosure-label">Input</p>
<pre className="ui-payload">{input}</pre>
</>
)}
{output === null ? null : (
<>
<p className="ui-disclosure-label">Output</p>
<pre className="ui-payload">{output}</pre>
</>
)}
{failure === null ? null : (
<>
<p className="ui-disclosure-label">Diagnostic</p>
<pre className="ui-payload ui-payload--danger" data-testid="tool-failure">
{failure}
</pre>
</>
)}
{status === "interrupted" ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the last known status is shown and no result was fabricated.
</p>
) : null}
</details>
);
}
@@ -0,0 +1,25 @@
import * as React from "react";
/**
* Source-adapted shadcn Tooltip. The content is always mirrored into a
* described-by element, so a tooltip is never the only channel for a
* diagnostic (interaction map A5).
*/
export function Tooltip({
content,
id,
children,
}: {
content: string;
id: string;
children: React.ReactElement<{ "aria-describedby"?: string }>;
}) {
return (
<span data-slot="tooltip" className="ui-tooltip">
{React.cloneElement(children, { "aria-describedby": id })}
<span role="tooltip" id={id} className="ui-tooltip-content">
{content}
</span>
</span>
);
}
@@ -0,0 +1,229 @@
import * as React from "react";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
import { Badge } from "../components/ui/badge";
import { Tabs } from "../components/ui/tabs";
import type { LiveConnectionStatus, LiveSessionState } from "./protocol";
import { capabilityRows } from "./transcript-model";
const TABS = [
{ id: "events", label: "Events" },
{ id: "requests", label: "Requests" },
{ id: "capabilities", label: "Capabilities" },
{ id: "session", label: "Session" },
];
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = React.useState(false);
return (
<button
type="button"
className="ui-copy"
aria-label={label}
onClick={() => {
void navigator.clipboard?.writeText(value).then(
() => setCopied(true),
() => setCopied(false),
);
}}
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* The debugging surface. Redaction is the server's job: the inspector renders
* redaction markers verbatim and never tries to reconstruct a redacted value.
*/
export function Inspector({
events,
snapshot,
state,
connection,
reconnectAttempt,
replayParity,
highlightItemId,
onHighlightItem,
}: {
events: readonly PrpEvent[];
snapshot: SessionSnapshot | null;
state: LiveSessionState | null;
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
highlightItemId: string | null;
onHighlightItem: (itemId: string | null) => void;
}) {
const [tab, setTab] = React.useState("events");
const [filter, setFilter] = React.useState("");
const [facet, setFacet] = React.useState<string | null>(null);
const facets = React.useMemo(
() => [...new Set(events.map((event) => event.eventType))].sort(),
[events],
);
const visible = React.useMemo(
() =>
events.filter((event) => {
if (facet !== null && event.eventType !== facet) return false;
if (filter.length === 0) return true;
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
}),
[events, facet, filter],
);
return (
<Tabs tabs={TABS} value={tab} onValueChange={setTab} label="Protocol inspector">
{tab === "events" ? (
<div className="ui-inspector-events">
<label className="ui-visually-hidden" htmlFor="inspector-filter">
Filter events
</label>
<input
id="inspector-filter"
className="ui-inspector-filter"
data-testid="inspector-filter"
placeholder="Filter events"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="ui-facets">
<button
type="button"
className="ui-facet"
aria-pressed={facet === null}
onClick={() => setFacet(null)}
>
All
</button>
{facets.map((candidate) => (
<button
key={candidate}
type="button"
className="ui-facet"
aria-pressed={facet === candidate}
onClick={() => setFacet(candidate)}
>
{candidate}
</button>
))}
</div>
<ol className="ui-inspector-list" data-testid="inspector-events">
{visible.map((event) => (
<li
key={event.sourceEventId}
data-highlighted={
highlightItemId !== null && event.itemId === highlightItemId
}
>
<details
onToggle={(toggle) =>
onHighlightItem(
(toggle.currentTarget as HTMLDetailsElement).open
? (event.itemId ?? null)
: null,
)
}
>
<summary className="ui-inspector-row">
<code className="ui-seq">{event.sourceSeq}</code>
<code>{event.eventType}</code>
<code className="ui-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
</summary>
<pre className="ui-payload">{JSON.stringify(event, null, 2)}</pre>
<CopyButton
label={`Copy event ${event.sourceSeq} payload`}
value={JSON.stringify(event, null, 2)}
/>
</details>
</li>
))}
</ol>
</div>
) : null}
{tab === "requests" ? (
<dl className="ui-inspector-grid" data-testid="inspector-requests">
{(snapshot?.requests ?? []).map((request) => (
<div key={request.requestId}>
<dt>
<code>{request.requestId}</code>
</dt>
<dd>
{request.type} — {request.status}
</dd>
</div>
))}
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
</dl>
) : null}
{tab === "capabilities" ? (
<ul className="ui-capability-list" data-testid="inspector-capabilities">
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
(row) => (
<li key={row.name}>
<span>{row.name}</span>
<Badge tone={row.supported ? "success" : "warning"}>
{row.supported ? "supported" : "unsupported"}
</Badge>
{row.diagnostic === null ? null : (
<p className="ui-capability-diagnostic">{row.diagnostic}</p>
)}
</li>
),
)}
</ul>
) : null}
{tab === "session" ? (
<dl className="ui-inspector-grid" data-testid="inspector-session">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Normalized session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
<div>
<dt>Driver session</dt>
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
</div>
<div>
<dt>Source cursors</dt>
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
</div>
<div>
<dt>Integrity</dt>
<dd>{snapshot?.integrity ?? "—"}</dd>
</div>
<div>
<dt>Gaps</dt>
<dd>{snapshot?.gaps.length ?? 0}</dd>
</div>
<div>
<dt>Connection</dt>
<dd>
{connection}
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
</dd>
</div>
<div>
<dt>Credentials in browser</dt>
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
</div>
<div>
<dt>Live and replay reducer</dt>
<dd data-testid="replay-parity">
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
</dd>
</div>
</dl>
) : null}
</Tabs>
);
}
@@ -0,0 +1,487 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Banner, BannerActions, BannerText } from "../components/ui/banner";
import { Button } from "../components/ui/button";
import { Composer } from "../components/ui/composer";
import { Conversation } from "../components/ui/conversation";
import { Dialog } from "../components/ui/dialog";
import { Message, MessageText } from "../components/ui/message";
import { ReasoningItem } from "../components/ui/reasoning-item";
import { Tabs } from "../components/ui/tabs";
import { ToolItem, type ToolStatus } from "../components/ui/tool-item";
import { Inspector } from "./Inspector";
import { RequestCard } from "./RequestCard";
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./SessionPanel";
import { useLiveConsole } from "./use-live-console";
import type { TranscriptEntry } from "./transcript-model";
const MOBILE_SEGMENTS = [
{ id: "chat", label: "Chat" },
{ id: "session", label: "Session" },
{ id: "inspector", label: "Inspector" },
];
const TURN_TONE = {
completed: "success",
failed: "danger",
interrupted: "warning",
cancelled: "warning",
} as const;
const TURN_LABEL = {
completed: "Turn completed",
failed: "Turn failed",
interrupted: "Turn interrupted",
cancelled: "Cancelled before start",
} as const;
/**
* Renders exactly one layout. Hiding a second copy with CSS would duplicate
* the transcript log landmark and every control in the DOM.
*/
function useCompactLayout(): boolean {
const [compact, setCompact] = React.useState(
() => window.matchMedia?.("(max-width: 64rem)").matches === true,
);
React.useEffect(() => {
const query = window.matchMedia?.("(max-width: 64rem)");
if (query === undefined) return undefined;
const onChange = () => setCompact(query.matches);
onChange();
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return compact;
}
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
if (entry.interrupted) return "interrupted";
if (entry.item.status === "failed") return "failed";
if (entry.item.status === "running") return "running";
return "completed";
}
export function LiveConsole() {
const console_ = useLiveConsole();
const [draft, setDraft] = React.useState("");
const [inspectorOpen, setInspectorOpen] = React.useState(false);
const [segment, setSegment] = React.useState("chat");
const [resetOpen, setResetOpen] = React.useState(false);
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
const oldestPendingRef = React.useRef<HTMLLIElement>(null);
const compact = useCompactLayout();
React.useEffect(() => {
const stored = window.localStorage.getItem("paperclip-runner.phase4b.inspector");
if (stored === "open") setInspectorOpen(true);
}, []);
React.useEffect(() => {
window.localStorage.setItem(
"paperclip-runner.phase4b.inspector",
inspectorOpen ? "open" : "closed",
);
}, [inspectorOpen]);
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
const pendingRequests = console_.transcript.filter(
(entry) => entry.kind === "request" && entry.status === "pending",
);
const viewingChild = console_.selectedThreadId !== null;
const childSteeringSupported = false;
React.useEffect(() => {
const snapshot = console_.snapshot;
if (snapshot === null || console_.state === null) return;
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
setOutcomes((current) => ({
...current,
[console_.state!.manifest ?? "unknown"]:
snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
}));
}, [console_.snapshot, console_.state]);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
function runManifest(id: string) {
const manifest = console_.manifests.find((entry) => entry.id === id);
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
}
const composerReason = viewingChild && !childSteeringSupported
? "Direct steering of child threads is not supported by this app-server."
: null;
const transcriptBody = (
<Conversation itemCount={console_.transcript.length} label="Session transcript">
{console_.transcript.length === 0 ? (
<div className="ui-empty-transcript" data-testid="empty-transcript">
<p className="eyebrow">No session yet</p>
<h3>Pick a demo chat or start a blank session</h3>
<p>
The browser never holds a provider credential. Only the demo server starts a driver
and owns the working directory.
</p>
</div>
) : null}
<ol className="ui-transcript" role="list">
{console_.transcript.map((entry) => {
if (entry.kind === "user") {
return (
<li key={entry.key}>
<Message role="user">
<MessageText text={entry.text} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "turn") {
return (
<li key={entry.key} className="ui-turn-marker" data-testid={`turn-${entry.state}`}>
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge>
<span>{entry.detail}</span>
</li>
);
}
if (entry.kind === "diagnostic") {
return (
<li key={entry.key}>
<Message role="system" failed>
<MessageText text={entry.message} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "request") {
const isOldestPending =
entry.status === "pending" && pendingRequests[0]?.key === entry.key;
return (
<RequestCard
key={entry.key}
entry={entry}
{...(isOldestPending ? { focusRef: oldestPendingRef } : {})}
onResolve={(resolution) =>
void console_.resolve(entry.requestId, entry.turnId ?? "", resolution)
}
/>
);
}
if (entry.role === "reasoning") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ReasoningItem
text={entry.item.text}
streaming={entry.streaming}
interrupted={entry.interrupted}
/>
</li>
);
}
if (entry.role === "tool") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ToolItem
name={entry.toolName ?? entry.item.kind}
status={toolStatus(entry)}
input={entry.input}
output={entry.output}
failure={entry.failure}
/>
</li>
);
}
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<Message
role={entry.role}
streaming={entry.streaming}
interrupted={entry.interrupted}
failed={entry.item.status === "failed"}
>
<MessageText text={entry.item.text} streaming={entry.streaming} />
{entry.failure === null ? null : (
<pre className="ui-payload ui-payload--danger">{entry.failure}</pre>
)}
</Message>
</li>
);
})}
{console_.steeringChips.map((chip) => (
<li key={chip.id} className="ui-steering-chip" data-status={chip.status} data-testid="steering-chip">
<Badge
tone={
chip.status === "acknowledged"
? "success"
: chip.status === "pending"
? "accent"
: "danger"
}
>
{chip.status === "rejected"
? `rejected — ${chip.detail ?? "turn already ended"}`
: chip.status}
</Badge>
<span>{chip.text}</span>
{chip.status === "rejected" || chip.status === "failed" ? (
<Button
type="button"
className="ui-button--quiet"
data-testid="steer-as-new-message"
onClick={() => {
console_.dismissSteeringChip(chip.id);
void console_.send(chip.text);
}}
>
Send as new message
</Button>
) : null}
</li>
))}
</ol>
</Conversation>
);
const composerBlock = (
<div className="ui-composer-region">
{console_.connection === "disconnected" || console_.connection === "reconnecting" ? (
<Banner tone="warning" data-testid="reconnect-banner">
<BannerText>
{console_.connection === "reconnecting"
? `Connection lost — reconnecting (attempt ${console_.reconnectAttempt})…`
: "Connection lost — the session is still on the server."}
</BannerText>
<BannerActions>
<Button type="button" data-testid="retry-now" onClick={console_.retryNow}>
Retry now
</Button>
</BannerActions>
</Banner>
) : null}
{pendingRequests.length > 0 ? (
<Banner tone="accent" assertive data-testid="pending-request-banner">
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
<BannerActions>
<Button
type="button"
onClick={() => oldestPendingRef.current?.focus()}
data-testid="review-request"
>
Review
</Button>
</BannerActions>
</Banner>
) : null}
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
disabledReason={composerReason}
/>
</div>
);
const sessionRail = (
<>
<ManifestList
manifests={console_.manifests}
selectedId={console_.selectedManifestId}
onSelect={console_.selectManifest}
onRun={runManifest}
lastOutcome={outcomes}
disabled={console_.busy}
/>
<ConnectionBlock
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
state={console_.state}
pendingRequestCount={pendingRequests.length}
onDrop={console_.dropConnection}
onRetry={console_.retryNow}
onScrollToOldestRequest={() => oldestPendingRef.current?.focus()}
/>
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
<LineageTree
lineage={console_.state?.lineage ?? []}
selectedThreadId={console_.selectedThreadId}
onSelect={console_.selectThread}
/>
<section className="ui-panel">
<h2 className="ui-panel-title">Demo state</h2>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(true)}>
Reset demo state
</Button>
{activeManifest === undefined ? null : (
<ol className="ui-observation-list" data-testid="observation-checklist">
{activeManifest.expectedObservations.map((observation) => (
<li key={observation}>
<label>
<input type="checkbox" /> {observation}
</label>
</li>
))}
</ol>
)}
</section>
</>
);
const inspectorPanel = (
<Inspector
events={console_.events}
snapshot={console_.snapshot}
state={console_.state}
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
replayParity={console_.replayParity}
highlightItemId={highlightItemId}
onHighlightItem={setHighlightItemId}
/>
);
return (
<div className="live-console" data-inspector={inspectorOpen}>
<div className="live-console-header">
<div className="live-console-title">
{console_.replay.active ? (
<Badge tone="warning" data-testid="replay-badge">
REPLAY
</Badge>
) : null}
<h2>{activeManifest?.name ?? "Live console"}</h2>
{viewingChild ? (
<p className="ui-breadcrumb" data-testid="breadcrumb">
root ▸ {console_.selectedThreadId}
</p>
) : null}
</div>
<div className="live-console-header-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="toggle-inspector"
aria-pressed={inspectorOpen}
onClick={() => setInspectorOpen((current) => !current)}
>
Protocol inspector
</Button>
<Button
type="button"
className="ui-button--quiet"
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>
</div>
<p className="ui-visually-hidden" aria-live="polite" data-testid="live-announcement">
{console_.announcement}
</p>
{console_.error === null ? null : (
<Banner tone="danger" assertive data-testid="console-error">
<BannerText>{console_.error}</BannerText>
</Banner>
)}
{console_.replay.active ? (
<div className="ui-replay-stepper" data-testid="replay-stepper">
<Button type="button" onClick={console_.replay.togglePlay}>
{console_.replay.playing ? "Pause" : "Play"}
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(-1)}>
Step back
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(1)}>
Step forward
</Button>
<label htmlFor="replay-position">Position</label>
<input
id="replay-position"
type="range"
min={0}
max={console_.replay.length}
value={console_.replay.position}
data-testid="replay-position"
onChange={(event) => console_.replay.setPosition(Number(event.target.value))}
/>
<span>
{console_.replay.position} / {console_.replay.length}
</span>
</div>
) : null}
{compact ? (
<div className="live-console-mobile">
<Tabs
tabs={MOBILE_SEGMENTS}
value={segment}
onValueChange={setSegment}
label="Console sections"
>
{segment === "chat" ? (
<>
{transcriptBody}
{composerBlock}
</>
) : null}
{segment === "session" ? sessionRail : null}
{segment === "inspector" ? inspectorPanel : null}
</Tabs>
</div>
) : (
<div className="live-console-desktop">
<aside className="live-console-rail">{sessionRail}</aside>
<main className="live-console-center">
{transcriptBody}
{composerBlock}
</main>
{inspectorOpen ? (
<aside className="live-console-inspector" aria-label="Protocol inspector">
{inspectorPanel}
</aside>
) : null}
</div>
)}
<Dialog
open={resetOpen}
onClose={() => setResetOpen(false)}
title="Reset demo state"
description="Discards the current live session transcript. Recorded evidence files are not touched."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(false)}>
Keep the session
</Button>
<Button
type="button"
className="ui-button--danger"
data-testid="reset-confirm"
onClick={() => {
setResetOpen(false);
setDraft("");
void console_.reset();
}}
>
Reset demo state
</Button>
</>
}
/>
</div>
);
}
@@ -0,0 +1,168 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import type { TranscriptRequestEntry } from "./transcript-model";
const KIND_LABELS: Record<string, string> = {
command_approval: "Command",
file_approval: "File change",
permission_approval: "Permission",
user_input: "Input",
elicitation: "Input",
};
const ACTION_LABELS: Record<string, string> = {
accept: "Approve",
accept_for_session: "Approve for session",
decline: "Reject",
cancel: "Cancel",
submit: "Submit",
};
const STATUS_TONE = {
pending: "accent",
resolved: "success",
expired: "warning",
cancelled: "warning",
} as const;
function detailBlock(details: Record<string, unknown>): string | null {
const command = details.command;
if (typeof command === "string") return command;
const diff = details.diff;
if (typeof diff === "string") {
const path = typeof details.path === "string" ? `${details.path}\n` : "";
return `${path}${diff}`;
}
return null;
}
/**
* One card renders all five request kinds. Actions come from the upstream
* request contract, so the card can never invent an affordance the runner
* cannot honour, and the first click locks the row until the canonical
* resolved event arrives.
*/
export function RequestCard({
entry,
onResolve,
focusRef,
}: {
entry: TranscriptRequestEntry;
onResolve: (resolution: Record<string, unknown>) => void;
focusRef?: React.Ref<HTMLLIElement>;
}) {
const [answer, setAnswer] = React.useState("");
const [resolving, setResolving] = React.useState(false);
const answerId = React.useId();
const kindLabel = KIND_LABELS[entry.requestKind] ?? "Request";
const pending = entry.status === "pending";
const isInput = entry.requestKind === "user_input" || entry.requestKind === "elicitation";
const payload = detailBlock(entry.details);
React.useEffect(() => {
if (!pending) setResolving(false);
}, [pending]);
function submit(resolution: Record<string, unknown>) {
setResolving(true);
onResolve(resolution);
}
if (!pending) {
return (
<li
ref={focusRef}
className="ui-request ui-request--settled"
data-status={entry.status}
data-testid="request-card"
tabIndex={-1}
>
<details>
<summary className="ui-request-summary">
<span>
{kindLabel} · {entry.status === "resolved"
? `resolved — ${ACTION_LABELS[entry.resolvedAction ?? ""] ?? entry.resolvedAction ?? "unknown"}`
: entry.status === "expired"
? "expired before response"
: "cancelled"}
</span>
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
</summary>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
{entry.resolvedAt === null ? null : <time dateTime={entry.resolvedAt}>{entry.resolvedAt}</time>}
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
</details>
</li>
);
}
return (
<li
ref={focusRef}
className="ui-request"
data-status="pending"
data-testid="request-card"
tabIndex={-1}
>
<div className="ui-request-header">
<span className="ui-request-kind">{kindLabel}</span>
<Badge tone="accent">pending</Badge>
</div>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
{isInput ? (
<div className="ui-request-input">
<label htmlFor={answerId}>Answer</label>
<input
id={answerId}
data-testid="request-answer"
value={answer}
disabled={resolving}
onChange={(event) => setAnswer(event.target.value)}
/>
</div>
) : null}
<div className="ui-request-actions">
{entry.actions.map((action) => (
<Button
key={action}
type="button"
className={
action === "decline"
? "ui-button--danger"
: action === "cancel"
? "ui-button--quiet"
: action === "accept" || action === "submit"
? ""
: "ui-button--secondary"
}
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
data-testid={`request-action-${action}`}
onClick={() =>
submit(
action === "submit"
? { action: "submit", answers: { answer: { answers: [answer] } } }
: { action },
)
}
>
{ACTION_LABELS[action] ?? action}
</Button>
))}
</div>
{resolving ? (
<p className="ui-request-resolving" role="status">
Resolving — waiting for the canonical resolved event.
</p>
) : null}
</li>
);
}
@@ -0,0 +1,346 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Dialog } from "../components/ui/dialog";
import { Menu } from "../components/ui/menu";
import type {
GoalOperation,
LiveConnectionStatus,
LiveLineageEntry,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import { GOAL_OPERATION_LABELS } from "./protocol";
import { goalAvailability } from "./transcript-model";
const CONNECTION_TONE = {
idle: "neutral",
connecting: "accent",
connected: "success",
reconnecting: "warning",
disconnected: "danger",
terminal: "neutral",
} as const;
const THREAD_STATE_LABEL: Record<string, string> = {
starting: "Starting",
running: "Running",
completed: "Completed",
failed: "Failed",
};
export function ManifestList({
manifests,
selectedId,
onSelect,
onRun,
lastOutcome,
disabled,
}: {
manifests: readonly LiveManifestSummary[];
selectedId: string;
onSelect: (id: string) => void;
onRun: (id: string) => void;
lastOutcome: Record<string, string>;
disabled: boolean;
}) {
return (
<section className="ui-panel" aria-labelledby="manifest-heading">
<h2 id="manifest-heading" className="ui-panel-title">
Demo chats
</h2>
<ul className="ui-manifest-list" data-testid="manifest-list">
{manifests.map((manifest) => (
<li key={manifest.id} data-selected={manifest.id === selectedId}>
<button
type="button"
className="ui-manifest-row"
aria-pressed={manifest.id === selectedId}
data-testid={`manifest-${manifest.id}`}
onClick={() => onSelect(manifest.id)}
>
<span className="ui-manifest-name">{manifest.name}</span>
<span className="ui-manifest-purpose">{manifest.purpose}</span>
<span className="ui-manifest-badges">
{manifest.scenarios.map((scenario) => (
<Badge key={scenario}>{scenario}</Badge>
))}
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
{lastOutcome[manifest.id] ?? "not run"}
</Badge>
</span>
</button>
{manifest.id === selectedId ? (
<>
<details className="ui-disclosure" data-testid="expected-observations">
<summary className="ui-disclosure-summary">Expected observations</summary>
<ol className="ui-observation-list">
{manifest.expectedObservations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ol>
</details>
<Button
type="button"
disabled={disabled}
data-testid="run-manifest"
onClick={() => onRun(manifest.id)}
>
Run this demo chat
</Button>
</>
) : null}
</li>
))}
</ul>
</section>
);
}
export function ConnectionBlock({
connection,
reconnectAttempt,
state,
pendingRequestCount,
onDrop,
onRetry,
onScrollToOldestRequest,
}: {
connection: LiveConnectionStatus;
reconnectAttempt: number;
state: LiveSessionState | null;
pendingRequestCount: number;
onDrop: () => void;
onRetry: () => void;
onScrollToOldestRequest: () => void;
}) {
return (
<section className="ui-panel" aria-labelledby="connection-heading">
<h2 id="connection-heading" className="ui-panel-title">
Session
</h2>
<div className="ui-status-row">
<span>Connection</span>
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
</Badge>
</div>
<div className="ui-status-row">
<span>Pending requests</span>
<button
type="button"
className="ui-chip"
data-testid="pending-request-chip"
disabled={pendingRequestCount === 0}
onClick={onScrollToOldestRequest}
>
{pendingRequestCount}
</button>
</div>
<div className="ui-status-row">
<span>Credentials in browser</span>
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
{state?.credentialsExposed === true ? "exposed" : "none"}
</Badge>
</div>
<dl className="ui-identity-grid">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
</dl>
<div className="ui-panel-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="drop-connection"
disabled={state === null || connection === "disconnected"}
onClick={onDrop}
>
Simulate connection loss
</Button>
<Button
type="button"
className="ui-button--quiet"
data-testid="resume-session"
disabled={state === null}
onClick={onRetry}
>
Resume this session
</Button>
</div>
</section>
);
}
export function GoalBlock({
state,
onGoal,
}: {
state: LiveSessionState | null;
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [objective, setObjective] = React.useState("");
const objectiveId = React.useId();
const capabilities = state?.capabilities ?? {
resume: false,
typedEvents: false,
steering: false,
interruption: false,
structuredResult: false,
};
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
const unsupported = capabilities.goals !== true;
const unsupportedReason = availability[0]?.reason ?? null;
return (
<section className="ui-panel" aria-labelledby="goal-heading">
<div className="ui-panel-header">
<h2 id="goal-heading" className="ui-panel-title">
Goal
</h2>
<Menu
triggerId="goal-menu"
label="Goal"
disabled={unsupported || state === null}
disabledReason={
state === null ? "Start a session before changing the goal." : unsupportedReason
}
items={availability.map((entry) => ({
id: entry.operation,
label: GOAL_OPERATION_LABELS[entry.operation],
enabled: entry.enabled,
reason: entry.reason,
}))}
onSelect={(id) => {
if (id === "set") setDialogOpen(true);
else onGoal(id as GoalOperation);
}}
/>
</div>
{state === null ? null : state.goal === null ? (
<p className="ui-goal-empty" data-testid="goal-banner">
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
</p>
) : (
<div className="ui-goal-banner" data-testid="goal-banner">
<p className="ui-goal-objective">{state.goal.objective}</p>
<div className="ui-status-row">
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
{state.goal.status}
</Badge>
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
</time>
</div>
</div>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Set goal"
description="The goal is durable on the thread and every change is also a transcript event."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
data-testid="goal-set-confirm"
disabled={objective.trim().length === 0}
onClick={() => {
onGoal("set", { objective: objective.trim() });
setObjective("");
setDialogOpen(false);
}}
>
Set goal
</Button>
</>
}
>
<label htmlFor={objectiveId}>Objective</label>
<textarea
id={objectiveId}
className="ui-dialog-input"
data-testid="goal-objective"
rows={3}
value={objective}
onChange={(event) => setObjective(event.target.value)}
/>
</Dialog>
</section>
);
}
export function LineageTree({
lineage,
selectedThreadId,
onSelect,
}: {
lineage: readonly LiveLineageEntry[];
selectedThreadId: string | null;
onSelect: (threadId: string | null) => void;
}) {
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
const current = selectedThreadId ?? rootId;
const treeRef = React.useRef<HTMLUListElement>(null);
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const rows = [
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
];
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowDown") {
event.preventDefault();
rows[Math.min(index + 1, rows.length - 1)]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
rows[Math.max(index - 1, 0)]?.focus();
}
}
return (
<section className="ui-panel" aria-labelledby="lineage-heading">
<h2 id="lineage-heading" className="ui-panel-title">
Threads
</h2>
<ul
ref={treeRef}
role="tree"
aria-label="Session thread lineage"
className="ui-tree"
data-testid="lineage-tree"
onKeyDown={onKeyDown}
>
{lineage.length === 0 ? <li className="ui-tree-empty">No threads yet.</li> : null}
{lineage.map((entry) => (
<li key={entry.threadId} role="none" data-depth={entry.depth}>
<button
type="button"
role="treeitem"
aria-current={entry.threadId === current}
aria-selected={entry.threadId === current}
className="ui-tree-row"
data-testid={`thread-${entry.threadId}`}
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
>
<span className="ui-state-dot" data-state={entry.status} aria-hidden="true" />
<code>{entry.nickname ?? entry.threadId}</code>
<span className="ui-tree-state">
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
</span>
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,158 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type {
GoalOperation,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
const BASE = "/api/phase4b";
export class LiveConsoleError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "LiveConsoleError";
this.status = status;
this.code = code;
}
}
async function send<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
});
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new LiveConsoleError(
response.status,
typeof body.error === "string" ? body.error : "request_failed",
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
);
}
return body as T;
}
export async function fetchManifests(): Promise<LiveManifestSummary[]> {
const body = await send<{ manifests: LiveManifestSummary[] }>("/manifests");
return body.manifests;
}
export async function createSession(input: {
manifest: string;
objective: string;
message?: string;
startTurn?: boolean;
}): Promise<LiveSessionState> {
return send<LiveSessionState>("/sessions", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function readSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}`);
}
export async function readEvents(
sessionId: string,
after = 0,
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }> {
return send(`/sessions/${sessionId}/events?after=${after}`);
}
export async function startTurn(
sessionId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/turns`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export async function steerTurn(
sessionId: string,
turnId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/steer`, {
method: "POST",
body: JSON.stringify({ turnId, text }),
});
}
export async function interruptTurn(
sessionId: string,
turnId: string | null,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/interrupt`, {
method: "POST",
body: JSON.stringify({
reason: "browser_operator",
...(turnId === null ? {} : { turnId }),
}),
});
}
export async function resolveRequest(
sessionId: string,
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
): Promise<LiveSessionState> {
return send<LiveSessionState>(
`/sessions/${sessionId}/requests/${encodeURIComponent(requestId)}/resolve`,
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
);
}
export async function goalOperation(
sessionId: string,
operation: GoalOperation,
body: Record<string, unknown> = {},
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/goal/${operation}`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function reconnectSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/reconnect`, {
method: "POST",
body: JSON.stringify({}),
});
}
export interface EventStreamHandle {
close(): void;
}
/**
* Subscribes to the canonical event stream from a durable cursor. The stream
* never carries provider credentials; the server redacts every frame.
*/
export function openEventStream(
sessionId: string,
after: number,
handlers: {
onEvent: (event: PrpEvent) => void;
onOpen?: () => void;
onError?: () => void;
},
): EventStreamHandle {
const source = new EventSource(`${BASE}/sessions/${sessionId}/stream?after=${after}`);
source.onopen = () => handlers.onOpen?.();
source.onmessage = (message) => {
try {
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
} catch {
handlers.onError?.();
}
};
source.onerror = () => handlers.onError?.();
return { close: () => source.close() };
}
@@ -0,0 +1,114 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
/**
* The browser only ever sees the demo server's public state and the canonical
* PRP event stream. There is no second event model and no client-side session
* cache: every surface reads these shapes or the reducer snapshot they carry.
*/
export interface LiveCapabilities {
resume: boolean;
typedEvents: boolean;
steering: boolean;
interruption: boolean;
structuredResult: boolean;
read?: boolean;
reconciliation?: boolean;
usage?: boolean;
runtimeRequestResolution?: boolean;
goals?: boolean;
threadLineage?: boolean;
unsupported?: string[];
}
export interface LivePendingRequest {
requestId: string;
requestKind: string;
method: string;
turnId: string;
itemId: string;
status: string;
prompt: string;
details: Record<string, unknown>;
}
export interface LiveGoal {
threadId: string;
objective: string;
status: string;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export interface LiveLineageEntry {
threadId: string;
providerSessionId: string | null;
parentThreadId: string | null;
depth: number;
nickname: string | null;
role: string | null;
status: string;
}
export interface LiveSessionState {
sessionId: string;
runId: string;
normalizedSessionId: string;
manifest: string | null;
providerAuthentication: string;
credentialsExposed: boolean;
capabilities: LiveCapabilities;
driverSession: {
driverSessionId: string;
providerSessionId?: string | null;
displayId?: string | null;
};
activeTurnId: string | null;
pendingRequests: LivePendingRequest[];
goal: LiveGoal | null;
lineage: LiveLineageEntry[];
cursor: number;
snapshot: SessionSnapshot;
}
export interface LiveManifestSummary {
id: string;
name: string;
purpose: string;
scenarios: string[];
objective: string;
prompt: string;
expectedObservations: string[];
}
export type LiveConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "terminal";
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
export type LiveEventList = readonly PrpEvent[];
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
"set",
"get",
"pause",
"resume",
"clear",
];
/** Human labels for the five fixed goal verbs (interaction map §5). */
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
set: "Set goal…",
get: "View",
pause: "Pause",
resume: "Resume",
clear: "Clear",
};
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import { Phase4bScriptedDriver } from "../../../../src/mock-core/phase4b-scripted-driver";
import type { HarnessSession } from "../../../../src/contracts/harness-driver";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { LiveCapabilities } from "./protocol";
import {
buildTranscript,
capabilityRows,
composerState,
goalAvailability,
transcriptRole,
type TranscriptEntry,
} from "./transcript-model";
const FULL: LiveCapabilities = {
resume: true,
typedEvents: true,
steering: true,
interruption: true,
structuredResult: true,
runtimeRequestResolution: true,
goals: true,
threadLineage: true,
};
function seed(runId: string, sessionId: string): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: "phase4b-demo",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "package-local-demo",
issueId: "phase4b-demo",
runId,
environmentLeaseId: "package-local",
runnerInstanceId: "phase4b-demo-server",
normalizedSessionId: sessionId,
driverSessionId: `thread-${sessionId}`,
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: "phase4b-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
},
});
}
async function drive(
manifestId: string,
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs: 0 });
const session = await driver.openSession({
runId: `run-${manifestId}`,
normalizedSessionId: `session-${manifestId}`,
workingDirectory: "/demo",
});
const events: PrpEvent[] = [];
const pump = (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
if (interact !== undefined) await interact(session, turnId);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
await session.close({ reason: "test" });
await pump;
return {
events,
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
};
}
function kinds(entries: readonly TranscriptEntry[]): string[] {
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
}
describe("transcriptRole", () => {
it("maps canonical item kinds onto transcript roles", () => {
expect(transcriptRole("reasoning")).toBe("reasoning");
expect(transcriptRole("agentMessage")).toBe("assistant");
expect(transcriptRole("commandExecution")).toBe("tool");
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
expect(transcriptRole("thread_lineage")).toBe("lineage");
expect(transcriptRole("something_new")).toBe("system");
});
});
describe("buildTranscript", () => {
it("orders entries by the reducer timeline and shows the submitted message", async () => {
const { events, snapshot } = await drive("completion");
const entries = buildTranscript(snapshot, events);
expect(kinds(entries)).toEqual([
"user",
"item:reasoning",
"item:tool",
"item:assistant",
"turn",
]);
const user = entries[0];
expect(user.kind === "user" && user.text).toBe("Go.");
const tool = entries[2];
expect(tool.kind === "item" && tool.input).toBe("docs/phase-04b-protocol-server.md");
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
});
it("marks an interrupted item and never claims it completed cleanly", async () => {
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.interrupt?.({ turnId });
});
const entries = buildTranscript(snapshot, events);
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
expect(tool?.kind === "item" && tool.output).toBeNull();
const turn = entries.find((entry) => entry.kind === "turn");
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
});
it("keeps a resolved request in the record with the chosen action", async () => {
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.resolveRuntimeRequest?.({
requestId: "request-command",
turnId,
resolution: { action: "accept_for_session" },
});
});
const request = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "request" && entry.requestId === "request-command",
);
expect(request?.kind === "request" && request.status).toBe("resolved");
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
expect(request?.kind === "request" && request.actions).toEqual([
"accept",
"accept_for_session",
"decline",
"cancel",
]);
});
it("renders a failed item with its exact diagnostic", async () => {
const { events, snapshot } = await drive("failure");
const failed = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "item" && entry.failure !== null,
);
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
});
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
// Replay mode reduces events[0..n]. Stepping forward must only ever add
// entries in the same positions the live session produced.
const { events, snapshot } = await drive("completion");
const full = buildTranscript(snapshot, events);
for (let cut = 1; cut <= events.length; cut += 1) {
const prefix = events.slice(0, cut);
const stepped = buildTranscript(
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
prefix,
);
expect(stepped.length).toBeLessThanOrEqual(full.length);
stepped.forEach((entry, index) => {
expect(entry.key).toBe(full[index]?.key);
expect(entry.position).toBe(full[index]?.position);
});
}
expect(
buildTranscript(
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
events,
),
).toEqual(full);
});
});
describe("composerState", () => {
const base = {
connection: "connected" as const,
submitting: false,
interrupting: false,
activeTurnId: null,
sessionState: "running" as const,
};
it("shows exactly one state per protocol situation", () => {
expect(composerState(base)).toBe("idle");
expect(composerState({ ...base, submitting: true })).toBe("submitting");
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
"interrupting",
);
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
});
it("puts a terminal session ahead of every other state", () => {
expect(
composerState({
...base,
sessionState: "failed",
activeTurnId: "turn-1",
connection: "disconnected",
}),
).toBe("terminal");
});
});
describe("goalAvailability", () => {
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
const rows = goalAvailability(
{
...FULL,
goals: false,
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
},
null,
);
expect(rows).toHaveLength(5);
expect(rows.every((row) => !row.enabled)).toBe(true);
expect(rows[0]?.reason).toContain("does not advertise goals");
});
it("disables only the verbs that are invalid for the current goal state", () => {
const active = goalAvailability(FULL, "active");
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
const paused = goalAvailability(FULL, "paused");
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
const none = goalAvailability(FULL, null);
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
});
});
describe("capabilityRows", () => {
it("gives every unsupported capability a diagnostic string", () => {
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
});
});
@@ -0,0 +1,385 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type {
SessionItemSnapshot,
SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { GoalOperation, LiveCapabilities } from "./protocol";
/**
* A projection of reducer state plus the canonical events that produced it.
* It never stores anything the protocol did not send, so a replayed session
* and a live session with the same events project identically.
*/
export type TranscriptRole =
| "user"
| "assistant"
| "reasoning"
| "tool"
| "steering"
| "interrupt"
| "goal"
| "lineage"
| "system";
export interface TranscriptItemEntry {
kind: "item";
key: string;
position: number;
role: TranscriptRole;
item: SessionItemSnapshot;
interrupted: boolean;
streaming: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
}
export interface TranscriptUserEntry {
kind: "user";
key: string;
position: number;
turnId: string | null;
text: string;
}
export interface TranscriptRequestEntry {
kind: "request";
key: string;
position: number;
requestId: string;
requestKind: string;
type: string;
prompt: string;
status: "pending" | "resolved" | "expired" | "cancelled";
actions: string[];
details: Record<string, unknown>;
resolvedAction: string | null;
resolvedAt: string | null;
turnId: string | null;
}
export interface TranscriptTurnEntry {
kind: "turn";
key: string;
position: number;
turnId: string | null;
state: "completed" | "failed" | "interrupted" | "cancelled";
detail: string;
}
export interface TranscriptDiagnosticEntry {
kind: "diagnostic";
key: string;
position: number;
message: string;
code: string | null;
}
export type TranscriptEntry =
| TranscriptItemEntry
| TranscriptUserEntry
| TranscriptRequestEntry
| TranscriptTurnEntry
| TranscriptDiagnosticEntry;
const TOOL_KINDS = new Set([
"commandExecution",
"command_execution",
"fileChange",
"file_change",
"mcpToolCall",
"tool",
"webSearch",
"patchApply",
]);
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
export function transcriptRole(kind: string): TranscriptRole {
if (kind === "reasoning") return "reasoning";
if (ASSISTANT_KINDS.has(kind)) return "assistant";
if (TOOL_KINDS.has(kind)) return "tool";
if (kind === "steering_acknowledgement") return "steering";
if (kind === "interrupt_acknowledgement") return "interrupt";
if (kind === "goal" || kind === "goal_cleared") return "goal";
if (kind === "thread_lineage") return "lineage";
return "system";
}
function payloadRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function payloadText(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
interface ItemFacts {
interrupted: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
terminal: boolean;
}
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
const facts = new Map<string, ItemFacts>();
for (const event of events) {
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
const payload = payloadRecord(event.payload);
const current = facts.get(event.itemId) ?? {
interrupted: false,
toolName: null,
input: null,
output: null,
failure: null,
terminal: false,
};
if (payload.interrupted === true || payload.status === "interrupted") {
current.interrupted = true;
}
current.toolName = payloadText(payload.toolName) ?? current.toolName;
current.input = payloadText(payload.input) ?? current.input;
current.output = payloadText(payload.output) ?? current.output;
if (event.eventType === "item.failed") {
current.failure = payloadText(payload.message) ?? "The item failed.";
}
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
current.terminal = true;
}
facts.set(event.itemId, current);
}
return facts;
}
function requestActions(details: unknown): string[] {
const record = payloadRecord(details);
return Array.isArray(record.actions)
? record.actions.filter((action): action is string => typeof action === "string")
: [];
}
/**
* Builds the ordered transcript. Order comes from the reducer timeline, which
* is authoritative; nothing is re-sorted client-side.
*/
export function buildTranscript(
snapshot: SessionSnapshot,
events: readonly PrpEvent[],
): TranscriptEntry[] {
const facts = collectItemFacts(events);
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
const seenItems = new Set<string>();
const entries: TranscriptEntry[] = [];
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
for (const timelineEntry of snapshot.timeline) {
const event = eventsById.get(timelineEntry.sourceEventId);
const payload = payloadRecord(event?.payload);
const position = timelineEntry.position;
if (timelineEntry.eventType === "turn.submitted") {
entries.push({
kind: "user",
key: `user:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
});
continue;
}
if (
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
timelineEntry.eventType,
)
) {
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
entries.push({
kind: "turn",
key: `turn:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
state,
detail:
payloadText(payload.message) ??
payloadText(payload.reason) ??
timelineEntry.summary,
});
continue;
}
if (timelineEntry.eventType === "runtime_request.created") {
const request = payloadRecord(payload.request ?? payload);
const requestId = typeof request.requestId === "string" ? request.requestId : "";
const projected = snapshot.requests.find(
(candidate) => candidate.requestId === requestId,
);
const resolution = events.find(
(candidate) =>
candidate.eventType.startsWith("runtime_request.") &&
candidate.eventType !== "runtime_request.created" &&
payloadRecord(candidate.payload).requestId === requestId,
);
entries.push({
kind: "request",
key: `request:${requestId}`,
position,
requestId,
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
type: typeof request.type === "string" ? request.type : "runtime",
prompt: payloadText(request.prompt) ?? "Runtime request",
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
actions: requestActions(request),
details: payloadRecord(request.details),
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
resolvedAt: resolution?.emittedAt ?? null,
turnId: typeof request.turnId === "string" ? request.turnId : null,
});
continue;
}
if (
timelineEntry.eventType === "harness.diagnostic" ||
timelineEntry.eventType === "runner.diagnostic"
) {
entries.push({
kind: "diagnostic",
key: `diagnostic:${timelineEntry.sourceEventId}`,
position,
message: payloadText(payload.message) ?? timelineEntry.summary,
code: payloadText(payload.code),
});
continue;
}
const itemId = timelineEntry.itemId;
if (itemId === undefined || seenItems.has(itemId)) continue;
const item = items.get(itemId);
if (item === undefined) continue;
seenItems.add(itemId);
const itemFacts = facts.get(itemId);
entries.push({
kind: "item",
key: `item:${itemId}`,
position,
role: transcriptRole(item.kind),
item,
interrupted: itemFacts?.interrupted ?? false,
streaming: item.status === "running" && itemFacts?.terminal !== true,
toolName: itemFacts?.toolName ?? null,
input: itemFacts?.input ?? null,
output: itemFacts?.output ?? null,
failure: itemFacts?.failure ?? null,
});
}
return entries;
}
export type ComposerState =
| "idle"
| "submitting"
| "active-turn"
| "interrupting"
| "disconnected"
| "terminal";
export interface ComposerStateInput {
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
submitting: boolean;
interrupting: boolean;
activeTurnId: string | null;
sessionState: SessionSnapshot["sessionState"];
}
/** One state machine with exactly one visible primary action. */
export function composerState(input: ComposerStateInput): ComposerState {
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
if (input.connection === "disconnected" || input.connection === "reconnecting") {
return "disconnected";
}
if (input.interrupting) return "interrupting";
if (input.activeTurnId !== null) return "active-turn";
if (input.submitting) return "submitting";
return "idle";
}
export interface GoalOperationAvailability {
operation: GoalOperation;
enabled: boolean;
reason: string | null;
}
const GOAL_UNSUPPORTED_FALLBACK =
"Goal operations unsupported: this app-server does not advertise the goals capability";
/**
* Capability gating for the goal menu. An unsupported verb is disabled with
* the exact upstream diagnostic — never hidden and never emulated.
*/
export function goalAvailability(
capabilities: LiveCapabilities,
goalStatus: string | null,
): GoalOperationAvailability[] {
const unsupported = capabilities.unsupported ?? [];
if (capabilities.goals !== true) {
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
operation,
enabled: false,
reason,
}));
}
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
if (operation === "set") return { operation, enabled: true, reason: null };
if (goalStatus === null) {
return {
operation,
enabled: false,
reason: "No goal is set on this thread yet.",
};
}
if (operation === "pause" && goalStatus !== "active") {
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
}
if (operation === "resume" && goalStatus === "active") {
return { operation, enabled: false, reason: "The goal is already active." };
}
return { operation, enabled: true, reason: null };
});
}
export interface CapabilityRow {
name: string;
supported: boolean;
diagnostic: string | null;
}
export function capabilityRows(capabilities: LiveCapabilities): CapabilityRow[] {
const unsupported = capabilities.unsupported ?? [];
const rows: Array<[string, boolean | undefined]> = [
["steer", capabilities.steering],
["interrupt", capabilities.interruption],
["resume", capabilities.resume],
["runtime requests", capabilities.runtimeRequestResolution],
["goals", capabilities.goals],
["child threads", capabilities.threadLineage],
["structured result", capabilities.structuredResult],
["typed events", capabilities.typedEvents],
["usage", capabilities.usage],
];
return rows.map(([name, supported]) => ({
name,
supported: supported === true,
diagnostic:
supported === true
? null
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
`${name} is not advertised by this app-server`),
}));
}
@@ -0,0 +1,550 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import * as client from "./client";
import type {
GoalOperation,
LiveConnectionStatus,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import {
buildTranscript,
composerState,
type ComposerState,
type TranscriptEntry,
} from "./transcript-model";
const SESSION_STORAGE_KEY = "paperclip-runner.phase4b.session";
const RECONNECT_DELAY_MS = 900;
const MAX_RECONNECT_ATTEMPTS = 5;
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
export interface SteeringChip {
id: string;
text: string;
expectedTurnId: string;
status: SteeringChipStatus;
detail: string | null;
}
function reduceEvents(
state: LiveSessionState | null,
events: readonly PrpEvent[],
): SessionSnapshot | null {
if (state === null) return null;
const seed = createSessionSnapshotFromMetadata({
fixtureName: state.snapshot.fixtureName,
identity: state.snapshot.identity,
capabilities: state.snapshot.capabilities,
});
return events.reduce(applyPrpEvent, seed);
}
function errorMessage(cause: unknown): string {
if (cause instanceof client.LiveConsoleError) return cause.message;
return cause instanceof Error ? cause.message : String(cause);
}
export interface LiveConsole {
manifests: LiveManifestSummary[];
selectedManifestId: string;
selectManifest: (id: string) => void;
state: LiveSessionState | null;
events: PrpEvent[];
snapshot: SessionSnapshot | null;
transcript: TranscriptEntry[];
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
composer: ComposerState;
steeringChips: SteeringChip[];
announcement: string;
error: string | null;
busy: boolean;
selectedThreadId: string | null;
selectThread: (threadId: string | null) => void;
replay: {
active: boolean;
position: number;
length: number;
playing: boolean;
enter: () => void;
exit: () => void;
setPosition: (position: number) => void;
togglePlay: () => void;
step: (delta: number) => void;
};
start: (input: { manifestId: string; message: string }) => Promise<void>;
send: (text: string) => Promise<void>;
steer: (text: string) => Promise<void>;
interrupt: () => Promise<void>;
resolve: (
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
) => Promise<void>;
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
dropConnection: () => void;
retryNow: () => void;
reset: () => Promise<void>;
dismissSteeringChip: (id: string) => void;
}
export function useLiveConsole(): LiveConsole {
const [manifests, setManifests] = useState<LiveManifestSummary[]>([]);
const [selectedManifestId, setSelectedManifestId] = useState("completion");
const [state, setState] = useState<LiveSessionState | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [connection, setConnection] = useState<LiveConnectionStatus>("idle");
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
const [announcement, setAnnouncement] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [interrupting, setInterrupting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [replayActive, setReplayActive] = useState(false);
const [replayPosition, setReplayPosition] = useState(0);
const [replayPlaying, setReplayPlaying] = useState(false);
const streamRef = useRef<client.EventStreamHandle | null>(null);
const cursorRef = useRef(0);
const sessionIdRef = useRef<string | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeStream = useCallback(() => {
streamRef.current?.close();
streamRef.current = null;
if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const subscribe = useCallback(
(sessionId: string, attempt = 0) => {
closeStream();
setConnection(attempt === 0 ? "connecting" : "reconnecting");
setReconnectAttempt(attempt);
streamRef.current = client.openEventStream(sessionId, cursorRef.current, {
onOpen: () => {
setConnection("connected");
setReconnectAttempt(0);
setAnnouncement("Connected");
},
onEvent: (event) => {
cursorRef.current += 1;
setEvents((current) =>
current.some((candidate) => candidate.sourceEventId === event.sourceEventId)
? current
: [...current, event],
);
},
onError: () => {
closeStream();
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setConnection("disconnected");
setAnnouncement("Disconnected");
return;
}
setConnection("reconnecting");
setReconnectAttempt(attempt + 1);
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
retryTimerRef.current = setTimeout(
() => subscribe(sessionId, attempt + 1),
RECONNECT_DELAY_MS,
);
},
});
},
[closeStream],
);
const adopt = useCallback(
(next: LiveSessionState) => {
setState(next);
setError(null);
},
[],
);
const attach = useCallback(
async (sessionId: string, resume: boolean) => {
const [next, history] = await Promise.all([
client.readSession(sessionId),
client.readEvents(sessionId, 0),
]);
sessionIdRef.current = sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
adopt(next);
setEvents(history.events);
cursorRef.current = history.cursor;
if (resume) setAnnouncement("Replayed the durable transcript");
subscribe(sessionId, 0);
},
[adopt, subscribe],
);
useEffect(() => {
let cancelled = false;
void client
.fetchManifests()
.then((list) => {
if (cancelled) return;
setManifests(list);
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
})
.catch((cause) => {
if (!cancelled) setError(errorMessage(cause));
});
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
if (stored !== null) {
void attach(stored, true).catch(() => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
});
}
return () => {
cancelled = true;
};
// Runs once: session restoration is a mount concern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => closeStream(), [closeStream]);
// Lineage, goal, pending requests, and the server cursor live in the public
// state, so a stream that only carries events would leave them stale.
useEffect(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null || events.length === 0) return undefined;
const timer = setTimeout(() => {
void client
.readSession(sessionId)
.then((next) => setState(next))
.catch(() => undefined);
}, 150);
return () => clearTimeout(timer);
}, [events.length]);
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
const visibleEvents = useMemo(
() => (replayActive ? events.slice(0, replayPosition) : events),
[events, replayActive, replayPosition],
);
const snapshot = useMemo(
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
[replayActive, state, visibleEvents, liveSnapshot],
);
const replayParity = useMemo(() => {
if (state === null || liveSnapshot === null) return null;
if (state.cursor !== events.length) return null;
return (
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
liveSnapshot.integrity === state.snapshot.integrity
);
}, [state, liveSnapshot, events.length]);
const transcript = useMemo(
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
[snapshot, visibleEvents],
);
useEffect(() => {
if (!replayPlaying) return undefined;
if (replayPosition >= events.length) {
setReplayPlaying(false);
return undefined;
}
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
return () => clearTimeout(timer);
}, [replayPlaying, replayPosition, events.length]);
// Steering chips resolve against canonical acknowledgements only.
useEffect(() => {
setSteeringChips((chips) => {
if (chips.every((chip) => chip.status !== "pending")) return chips;
const acknowledged = events.filter(
(event) =>
event.eventType === "item.completed" &&
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
);
let index = 0;
return chips.map((chip) => {
if (chip.status !== "pending") return chip;
const match = acknowledged[index];
index += 1;
return match === undefined
? chip
: { ...chip, status: "acknowledged" as const, detail: null };
});
});
}, [events]);
// The reducer is authoritative for the active turn. Falling back to the
// last public-state read would keep a finished turn alive in the composer.
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
// A submitted turn that has not been accepted yet is a protocol fact, not a
// local flag: it is what makes interrupt-before-start reachable.
const awaitingTurnStart = useMemo(() => {
let pending = false;
for (const entry of snapshot?.timeline ?? []) {
if (entry.eventType === "turn.submitted") pending = true;
else if (entry.eventType.startsWith("turn.")) pending = false;
}
return pending;
}, [snapshot]);
const composer = composerState({
connection,
submitting: submitting || awaitingTurnStart,
interrupting,
activeTurnId,
sessionState: snapshot?.sessionState ?? "not_started",
});
const guard = useCallback(async (task: () => Promise<void>) => {
setBusy(true);
try {
await task();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}, []);
const start = useCallback(
async (input: { manifestId: string; message: string }) => {
await guard(async () => {
closeStream();
setEvents([]);
cursorRef.current = 0;
setSteeringChips([]);
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setSubmitting(true);
const manifest = manifests.find((entry) => entry.id === input.manifestId);
try {
const created = await client.createSession({
manifest: input.manifestId,
objective: manifest?.objective ?? "Run the Phase 4b demo.",
message: input.message,
});
sessionIdRef.current = created.sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
adopt(created);
setAnnouncement("Turn running");
subscribe(created.sessionId, 0);
} finally {
setSubmitting(false);
}
});
},
[adopt, closeStream, guard, manifests, subscribe],
);
const refreshState = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
adopt(await client.readSession(sessionId));
}, [adopt]);
const send = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) {
await start({ manifestId: selectedManifestId, message: text });
return;
}
await guard(async () => {
setSubmitting(true);
try {
adopt(await client.startTurn(sessionId, text));
setAnnouncement("Turn running");
} finally {
setSubmitting(false);
}
});
},
[adopt, guard, selectedManifestId, start],
);
const steer = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
const expectedTurnId = activeTurnId;
if (sessionId === null || expectedTurnId === null) return;
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
setSteeringChips((chips) => [
...chips,
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
]);
try {
adopt(await client.steerTurn(sessionId, expectedTurnId, text));
setAnnouncement("Steering sent");
} catch (cause) {
const stale =
cause instanceof client.LiveConsoleError &&
(cause.code === "stale_turn" || cause.code === "already_terminal");
setSteeringChips((chips) =>
chips.map((chip) =>
chip.id === chipId
? {
...chip,
status: stale ? "rejected" : "failed",
detail: stale ? "turn already ended" : errorMessage(cause),
}
: chip,
),
);
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
await refreshState().catch(() => undefined);
}
},
[activeTurnId, adopt, refreshState, steeringChips.length],
);
const interrupt = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
setInterrupting(true);
setAnnouncement("Stopping");
try {
adopt(await client.interruptTurn(sessionId, activeTurnId));
setAnnouncement("Turn stopped");
} catch (cause) {
setError(errorMessage(cause));
} finally {
setInterrupting(false);
}
}, [activeTurnId, adopt]);
const resolve = useCallback(
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.resolveRequest(sessionId, requestId, turnId, resolution));
setAnnouncement("Request resolved");
});
},
[adopt, guard],
);
const goal = useCallback(
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.goalOperation(sessionId, operation, body));
setAnnouncement(`Goal ${operation}`);
});
},
[adopt, guard],
);
const dropConnection = useCallback(() => {
closeStream();
setConnection("disconnected");
setAnnouncement("Connection lost");
}, [closeStream]);
const retryNow = useCallback(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
void guard(async () => {
const resumed = await client.reconnectSession(sessionId);
adopt(resumed);
const history = await client.readEvents(sessionId, 0);
setEvents(history.events);
cursorRef.current = history.cursor;
subscribe(sessionId, 0);
setAnnouncement("Resumed the same session");
});
}, [adopt, guard, subscribe]);
const reset = useCallback(async () => {
closeStream();
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
sessionIdRef.current = null;
cursorRef.current = 0;
setState(null);
setEvents([]);
setSteeringChips([]);
setConnection("idle");
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setError(null);
setAnnouncement("Demo state reset");
}, [closeStream]);
const replay = useMemo(
() => ({
active: replayActive,
position: replayPosition,
length: events.length,
playing: replayPlaying,
enter: () => {
setReplayActive(true);
setReplayPosition(0);
setReplayPlaying(false);
},
exit: () => {
setReplayActive(false);
setReplayPlaying(false);
},
setPosition: (position: number) =>
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
togglePlay: () => setReplayPlaying((current) => !current),
step: (delta: number) =>
setReplayPosition((current) =>
Math.min(Math.max(current + delta, 0), events.length),
),
}),
[events.length, replayActive, replayPlaying, replayPosition],
);
return {
manifests,
selectedManifestId,
selectManifest: setSelectedManifestId,
state,
events,
snapshot,
transcript,
connection,
reconnectAttempt,
replayParity,
composer,
steeringChips,
announcement,
error,
busy,
selectedThreadId,
selectThread: setSelectedThreadId,
replay,
start,
send,
steer,
interrupt,
resolve,
goal,
dropConnection,
retryNow,
reset,
dismissSteeringChip: (id: string) =>
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
};
}
@@ -15,6 +15,10 @@
--warning-surface: #fff4dc;
--danger: #a02f2f;
--danger-surface: #fceaea;
/* Pending and streaming are not warnings; Phase 4b needs their own pair. */
--accent: #2f5fa8;
--accent-surface: #e9f0fa;
--surface-raised: #fcfcfa;
--ring: #66665f;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
@@ -43,6 +47,15 @@
--control-height: 2.5rem;
--timeline-column: 2rem;
--motion-fast: 120ms;
--motion-medium: 200ms;
--z-banner: 10;
--z-dialog: 20;
--rail-width: 17rem;
--center-width: 48rem;
--inspector-width: 24rem;
--transcript-height: 34rem;
--touch-target: 2.75rem;
--facet-height: 6rem;
}
* {
@@ -545,3 +558,910 @@ dd {
--motion-fast: 0ms;
}
}
/* ---------------------------------------------------------------------------
Phase 4b live console
Every value below comes from the token layer above. Adapted shadcn/ui and
AI Elements components carry no visual values of their own.
--------------------------------------------------------------------------- */
.ui-badge[data-tone="accent"] {
background: var(--accent-surface);
color: var(--accent);
}
.ui-button--danger {
background: var(--danger);
color: var(--primary-foreground);
}
.ui-button--secondary {
background: var(--muted);
color: var(--foreground);
}
.ui-button--quiet {
background: transparent;
color: var(--foreground);
border: 0.0625rem solid var(--border);
}
.ui-visually-hidden {
position: absolute;
width: 0.0625rem;
height: 0.0625rem;
margin: -0.0625rem;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.live-console {
display: grid;
gap: var(--space-4);
}
.live-console-header,
.live-console-title,
.live-console-header-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.live-console-header {
justify-content: space-between;
flex-wrap: wrap;
}
.live-console-title h2 {
font-size: var(--font-size-lg);
font-weight: var(--weight-semibold);
}
.ui-breadcrumb {
color: var(--muted-foreground);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
}
.live-console-desktop {
display: grid;
grid-template-columns: var(--rail-width) minmax(0, 1fr);
gap: var(--space-5);
align-items: start;
}
.live-console[data-inspector="true"] .live-console-desktop {
grid-template-columns: var(--rail-width) minmax(0, 1fr) var(--inspector-width);
}
.live-console-rail,
.live-console-inspector {
display: grid;
/* An auto track would size to the widest identifier and overflow the rail. */
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
min-width: 0;
}
.live-console-center {
display: grid;
gap: var(--space-3);
min-width: 0;
max-width: var(--center-width);
}
.ui-panel {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--space-3);
padding: var(--space-4);
background: var(--card);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
}
.ui-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.ui-panel-title {
font-size: var(--font-size-sm);
font-weight: var(--weight-semibold);
}
.ui-panel-actions {
display: grid;
gap: var(--space-2);
}
.ui-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.ui-status-row > span:first-child {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--weight-semibold);
}
.ui-identity-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-2);
margin: 0;
min-width: 0;
}
.ui-identity-grid > div,
.ui-inspector-grid > div,
.ui-status-row,
.ui-tree-row,
.ui-capability-list li {
min-width: 0;
}
.ui-identity-grid dd,
.ui-inspector-grid dd {
overflow-wrap: anywhere;
white-space: normal;
}
/* Manifest list ---------------------------------------------------------- */
.ui-manifest-list,
.ui-tree,
.ui-transcript,
.ui-inspector-list,
.ui-observation-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.ui-manifest-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
width: 100%;
min-width: 0;
min-height: var(--touch-target);
padding: var(--space-2);
border: 0.0625rem solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.ui-manifest-row[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-surface);
}
.ui-manifest-name {
font-weight: var(--weight-semibold);
}
.ui-manifest-purpose {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
overflow-wrap: anywhere;
}
.ui-manifest-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.ui-observation-list {
gap: var(--space-1);
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
/* Lineage tree ----------------------------------------------------------- */
.ui-tree-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: var(--touch-target);
padding: var(--space-2);
border: 0.0625rem solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
}
.ui-tree li[data-depth="1"] .ui-tree-row {
padding-left: var(--space-5);
}
.ui-tree-row[aria-current="true"] {
border-color: var(--border);
background: var(--muted);
}
.ui-tree-state,
.ui-tree-empty {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.ui-state-dot {
display: inline-block;
width: var(--space-2);
height: var(--space-2);
border-radius: 999rem;
background: var(--muted-foreground);
}
.ui-state-dot[data-state="running"] {
background: var(--accent);
}
.ui-state-dot[data-state="completed"] {
background: var(--success);
}
.ui-state-dot[data-state="failed"] {
background: var(--danger);
}
/* Conversation ----------------------------------------------------------- */
.ui-conversation {
position: relative;
}
.ui-conversation-viewport {
max-height: var(--transcript-height);
overflow-y: auto;
padding: var(--space-4);
background: var(--card);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
}
.ui-jump-to-latest {
position: absolute;
bottom: var(--space-3);
left: 50%;
translate: -50% 0;
z-index: var(--z-banner);
min-height: var(--touch-target);
padding-inline: var(--space-4);
border: 0.0625rem solid var(--border);
border-radius: 999rem;
background: var(--card);
color: var(--foreground);
cursor: pointer;
}
.ui-empty-transcript {
display: grid;
gap: var(--space-2);
padding-block: var(--space-6);
}
.ui-empty-transcript h3 {
font-size: var(--font-size-md);
}
.ui-empty-transcript p:last-child {
color: var(--muted-foreground);
}
.ui-transcript {
gap: var(--space-3);
}
.ui-message {
display: grid;
gap: var(--space-1);
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
}
.ui-message[data-role="user"] {
margin-left: var(--space-10);
background: var(--muted);
}
.ui-message[data-state="failed"] {
border-color: var(--danger);
background: var(--danger-surface);
color: var(--danger);
}
.ui-message[data-state="interrupted"] {
border-color: var(--warning);
}
.ui-message-role {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ui-message-text {
white-space: pre-wrap;
}
.ui-message-divider {
padding-top: var(--space-2);
border-top: 0.0625rem solid var(--warning);
color: var(--warning);
font-size: var(--font-size-xs);
}
.ui-stream-cursor {
display: inline-block;
width: var(--space-2);
height: var(--font-size-md);
margin-left: var(--space-1);
background: var(--accent);
vertical-align: text-bottom;
animation: ui-pulse 1s steps(2, end) infinite;
}
@keyframes ui-pulse {
50% {
opacity: 0.2;
}
}
.ui-turn-marker {
display: flex;
align-items: center;
gap: var(--space-2);
padding-block: var(--space-2);
border-top: 0.0625rem solid var(--border);
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.ui-steering-chip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border: 0.0625rem dashed var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
/* Disclosures ------------------------------------------------------------ */
.ui-disclosure {
padding: var(--space-2) var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
}
.ui-disclosure-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: var(--touch-target);
cursor: pointer;
font-weight: var(--weight-medium);
}
.ui-disclosure-preview,
.ui-disclosure-label {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--weight-semibold);
}
.ui-disclosure-preview {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-payload {
max-height: var(--transcript-height);
margin: var(--space-2) 0 0;
overflow: auto;
padding: var(--space-3);
border-radius: var(--radius-sm);
background: var(--muted);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
white-space: pre-wrap;
word-break: break-word;
}
.ui-payload--danger {
background: var(--danger-surface);
color: var(--danger);
}
/* Request cards ---------------------------------------------------------- */
.ui-request {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-left: var(--space-1) solid var(--accent);
border-radius: var(--radius-md);
background: var(--card);
}
.ui-request--settled {
border-left-color: var(--border);
background: var(--surface-raised);
}
.ui-request[data-status="resolved"] {
border-left-color: var(--success);
}
.ui-request-header,
.ui-request-summary,
.ui-request-actions,
.ui-request-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.ui-request-summary {
justify-content: space-between;
min-height: var(--touch-target);
cursor: pointer;
}
.ui-request-kind {
font-weight: var(--weight-semibold);
}
.ui-request-meta,
.ui-request-resolving {
color: var(--muted-foreground);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
}
.ui-request-input {
display: grid;
gap: var(--space-1);
}
.ui-request-input input {
min-height: var(--control-height);
padding: var(--space-2) var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--card-foreground);
}
.ui-request-actions .ui-button {
min-height: var(--touch-target);
}
/* Composer --------------------------------------------------------------- */
.ui-composer-region {
display: grid;
gap: var(--space-2);
}
.ui-composer {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.ui-composer[data-state="active-turn"] {
border-color: var(--accent);
}
.ui-composer-input {
width: 100%;
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--card-foreground);
resize: none;
}
.ui-composer-actions {
display: grid;
gap: var(--space-2);
}
.ui-composer-hint,
.ui-composer-reason,
.ui-menu-reason,
.ui-capability-diagnostic {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.ui-composer-reason,
.ui-capability-diagnostic {
color: var(--warning);
}
.ui-composer-buttons {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.ui-composer-buttons .ui-button {
min-width: var(--touch-target);
min-height: var(--touch-target);
}
/* Banners, menus, dialogs, tooltips -------------------------------------- */
.ui-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
z-index: var(--z-banner);
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
transition: opacity var(--motion-medium) ease;
}
.ui-banner[data-tone="accent"] {
border-color: var(--accent);
background: var(--accent-surface);
color: var(--accent);
}
.ui-banner[data-tone="warning"] {
border-color: var(--warning);
background: var(--warning-surface);
color: var(--warning);
}
.ui-banner[data-tone="danger"] {
border-color: var(--danger);
background: var(--danger-surface);
color: var(--danger);
}
.ui-banner-actions {
display: flex;
gap: var(--space-2);
}
.ui-menu {
position: relative;
}
.ui-menu-list {
position: absolute;
right: 0;
z-index: var(--z-banner);
display: grid;
gap: var(--space-1);
min-width: var(--rail-width);
padding: var(--space-2);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow-sm);
}
.ui-menu-item {
min-height: var(--touch-target);
padding: var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.ui-menu-item[aria-disabled="true"] {
color: var(--muted-foreground);
cursor: not-allowed;
}
.ui-menu-item:hover:not([aria-disabled="true"]) {
background: var(--muted);
}
.ui-dialog {
z-index: var(--z-dialog);
max-width: var(--center-width);
padding: 0;
border: 0.0625rem solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--card-foreground);
}
.ui-dialog-body {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
}
.ui-dialog-title {
font-size: var(--font-size-lg);
font-weight: var(--weight-semibold);
}
.ui-dialog-description {
color: var(--muted-foreground);
}
.ui-dialog-input {
width: 100%;
padding: var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--card-foreground);
}
.ui-dialog-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.ui-tooltip {
position: relative;
}
.ui-tooltip-content {
position: absolute;
left: 0;
top: 100%;
z-index: var(--z-banner);
display: none;
min-width: var(--rail-width);
padding: var(--space-2);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
font-size: var(--font-size-xs);
}
.ui-tooltip:hover .ui-tooltip-content,
.ui-tooltip:focus-within .ui-tooltip-content {
display: block;
}
/* Tabs and inspector ----------------------------------------------------- */
.ui-tabs {
display: grid;
gap: var(--space-3);
}
.ui-tablist {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
padding: var(--space-1);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--muted);
}
.ui-tab {
min-height: var(--touch-target);
padding-inline: var(--space-3);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
}
.ui-tab[aria-selected="true"] {
background: var(--card);
color: var(--foreground);
font-weight: var(--weight-semibold);
}
.ui-inspector-events {
display: grid;
gap: var(--space-2);
}
.ui-inspector-filter {
width: 100%;
min-height: var(--control-height);
padding: var(--space-2) var(--space-3);
border: 0.0625rem solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--card-foreground);
}
.ui-facets {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
max-height: var(--facet-height);
overflow-y: auto;
padding-bottom: var(--space-1);
}
.ui-facet,
.ui-chip,
.ui-copy {
min-height: var(--space-6);
padding-inline: var(--space-2);
border: 0.0625rem solid var(--border);
border-radius: 999rem;
background: var(--card);
color: var(--muted-foreground);
font-size: var(--font-size-xs);
cursor: pointer;
}
.ui-facet[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-surface);
color: var(--accent);
}
.ui-inspector-list {
max-height: var(--transcript-height);
overflow-y: auto;
}
.ui-inspector-list li[data-highlighted="true"] {
background: var(--accent-surface);
}
.ui-inspector-row {
display: grid;
grid-template-columns: var(--timeline-column) minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-2);
align-items: center;
min-height: var(--touch-target);
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
}
.ui-inspector-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-seq {
color: var(--muted-foreground);
}
.ui-inspector-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
margin: 0;
min-width: 0;
}
.ui-capability-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.ui-capability-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
padding-block: var(--space-2);
border-bottom: 0.0625rem solid var(--border);
}
.ui-capability-list p {
grid-column: 1 / -1;
}
.ui-replay-stepper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: 0.0625rem solid var(--warning);
border-radius: var(--radius-md);
background: var(--warning-surface);
}
.ui-goal-banner {
display: grid;
gap: var(--space-2);
}
.ui-goal-empty {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.ui-transcript li[data-highlighted="true"] {
outline: 0.125rem solid var(--accent);
outline-offset: 0.125rem;
}
.ui-tab:focus-visible,
.ui-menu-item:focus-visible,
.ui-tree-row:focus-visible,
.ui-manifest-row:focus-visible,
.ui-facet:focus-visible,
.ui-chip:focus-visible,
.ui-copy:focus-visible,
.ui-jump-to-latest:focus-visible,
.ui-request:focus-visible,
.ui-conversation-viewport:focus-visible,
.ui-composer-input:focus-visible,
.ui-inspector-filter:focus-visible,
.ui-dialog-input:focus-visible,
.ui-request-input input:focus-visible,
.ui-tabpanel:focus-visible,
summary:focus-visible {
outline: 0.125rem solid var(--ring);
outline-offset: 0.125rem;
}
@media (max-width: 64rem) {
.ui-message[data-role="user"] {
margin-left: var(--space-5);
}
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-medium: 0ms;
}
.ui-stream-cursor {
animation: none;
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 3: break recovery on purpose](tutorials/phase-03-break-recovery.md) — lose an ACK, drop the socket, restart the runner side, replay durable events, and inspect recovery diagnostics.
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
@@ -21,11 +22,12 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 3 durable transport and recovery](phase-03-durable-transport.md)
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
- [Phase 4b live console](phase-04b-live-console.md)
- [Engineering journal guide](journal.md)
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
- [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md)
- [Phase 4b component decision record (shadcn/ui, AI Elements)](design/phase-4b-component-decisions.md)
- [Package README](../README.md)
Phase 4 is implemented and ready for Security, CTO, QA, and human review.
Phase 4b is implemented and ready for Security, CTO, QA, and human review.
Phase 5 remains uncreated.
@@ -0,0 +1,138 @@
# Phase 4b Live Console
## Scope
This is the browser layer of Phase 4b. It adds a **Live console** mode to the
existing standalone devtool in `devtools/browser/`. It builds on the
[protocol and demo server](phase-04b-protocol-server.md) and follows the
approved [interaction map](design/phase-4b-interaction-map.md) and
[component decision record](design/phase-4b-component-decisions.md).
Every file lives under `packages/paperclip-runner/`. The console does not
import or change Paperclip server, UI, database, or control-plane code.
## Start it
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Then open `http://127.0.0.1:4180/` and choose **Live console**.
To reach it from another host, pass a bind address to the same script:
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b -- --host 0.0.0.0
```
Two environment variables change the driver behind the console:
| Variable | Default | Effect |
| --- | --- | --- |
| `PAPERCLIP_PHASE4B_DRIVER` | `demo` | `codex` starts the real `codex app-server` driver behind the same routes |
| `PAPERCLIP_PHASE4B_CHUNK_DELAY_MS` | `45` | Milliseconds between streamed chunks in the demo driver |
## Where state comes from
The console has one data contract: the canonical PRP event stream and the
`SessionSnapshot` the shared reducer produces from it. There is no second event
model, no message store, and no client-side session cache.
- The browser reduces the events it receives with the same
`applyPrpEvent` the server and the CLI use, so a live session and a replayed
session project identically. The inspector's **Session** tab reports that
comparison as `match` or `mismatch`.
- Transcript order is the reducer timeline order. Nothing is re-sorted.
- Lineage, goal state, pending requests, and the durable cursor come from the
demo server's public state, which is refreshed whenever new events arrive.
- The steering chip is the only local UI intent. It resolves to
`acknowledged`, `rejected`, or `failed` from canonical facts and never
merges text into the transcript before an acknowledgement.
## Surfaces
| Surface | Source of truth | Notes |
| --- | --- | --- |
| Transcript | reducer timeline and items | user messages come from `turn.submitted` payload text |
| Composer | composer state machine | one visible primary action: Send, Steer, or Stop |
| Steering chip | `steering_acknowledgement` item or a rejection code | rejected text stays recoverable with **Send as new message** |
| Request card | `runtime_request.*` events | renders only the actions the upstream request offers |
| Goal banner and menu | capability snapshot and goal events | unsupported verbs are disabled with the exact diagnostic |
| Lineage tree | driver thread lineage | child steering is shown as unsupported, never emulated |
| Connection block | transport status and durable cursor | includes a deliberate connection-loss control |
| Inspector | raw canonical events | four tabs: Events, Requests, Capabilities, Session |
| Replay stepper | recorded canonical events | marked with a `REPLAY` eyebrow badge |
## Interruption races
The console distinguishes all three races the spec requires:
| Race | What the console shows |
| --- | --- |
| interrupt before start | the pending turn resolves to `Cancelled before start`; no assistant item is ever created |
| interrupt during generation | the stream stops in place, the partial text stays, and an interrupted divider is added |
| interrupt during a tool call | the tool item keeps its last known status with no fabricated result |
`Escape` in the composer moves focus to **Stop**. It never interrupts
directly, because a destructive action needs an explicit activation.
## Demo chats
The demo server owns the manifest catalogue and serves it at
`GET /api/phase4b/manifests`. Each manifest names the scenario it proves and
the observations a human should confirm; the console renders that list as a
checklist rather than restating it in the UI.
| Manifest | Proves |
| --- | --- |
| `completion` | streaming, reasoning, tool, answer, structured result |
| `steering` | same-turn steering acknowledgement and stale-turn rejection |
| `interrupt-before-start` | cancellation before the provider accepts the turn |
| `interrupt-during-generation` | preserved partial text |
| `interrupt-during-tool` | preserved tool status with no fabricated result |
| `approvals` | command and file-change approvals, single-response guarantee |
| `user-input` | typed answers and request expiry |
| `subagents` | parent/child lineage and unsupported child steering |
| `goals` | set, view, pause, resume, clear |
| `goals-unsupported` | exact capability diagnostic instead of a hidden control |
| `failure` | failed item and failed turn inside the record |
Manifests run in any order. **Reset demo state** names exactly what it
discards and leaves recorded evidence files untouched.
## Credentials
No provider or Paperclip credential reaches the browser. The Node process owns
the driver, the working directory, and any provider login. Every JSON and event
frame passes the demo server's redaction layer, and the inspector renders
redaction markers verbatim without attempting to reconstruct them.
## Components
Adapted from shadcn/ui and Vercel AI Elements **source**, rewritten in the
package's local idiom, with zero new runtime dependencies:
- AI Elements: `conversation`, `message`, `composer`, `reasoning-item`,
`tool-item`.
- shadcn/ui: `tabs`, `menu`, `dialog`, `tooltip`, `banner`.
`scripts/check-forbidden-imports.mjs` fails the build if anything under
`devtools/browser/` imports `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
`streamdown`, `shiki`, `use-stick-to-bottom`, `class-variance-authority`,
`tailwindcss`, or `nanoid`. `scripts/check-browser-tokens.mjs` fails if a
component file carries a raw colour, pixel, or font value.
## Verification
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/mock-core/phase4b-scripted-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts \
devtools/browser/src/live/transcript-model.test.ts
pnpm --filter @paperclipai/paperclip-runner test:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
See the [verification record](../knowledge/evidence/2026-08-08-phase-04b-live-console-verification.md)
and the [tutorial](tutorials/phase-04b-live-console.md).
@@ -120,6 +120,32 @@ pnpm --filter @paperclipai/paperclip-runner verify:rootless
browser JSON, events, and diagnostics;
- refresh/reconnect replays canonical events and resumes the exact persisted
provider thread.
- the browser console renders only reducer state and canonical events, with no
second event model and no client-side session cache;
- steering resolves to exactly one of acknowledged, stale-rejected, or failed,
and rejected text stays recoverable;
- interrupt before start, during generation, and during a tool call each end in
a distinct visible state, and the session is never replaced;
- request cards offer only the actions the upstream request offers and lock on
the first click until the canonical resolved event arrives;
- unsupported capabilities render disabled controls carrying the exact upstream
diagnostic, never hidden and never emulated;
- a transport drop, a page refresh, and replay all reproduce the same
transcript from the durable cursor;
- no provider credential reaches the browser DOM, and adapted components add no
new runtime dependency.
Phase 5 remains gated on the complete Phase 4b browser, review, QA, live-service,
## Step 6: Chat with a live session in the browser
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Open `http://127.0.0.1:4180/` and press **Live console**. Work through the
[Phase 4b live console tutorial](phase-04b-live-console.md) to reach every
state above from the eleven deterministic demo chats. Add
`PAPERCLIP_PHASE4B_DRIVER=codex` to run the identical screens against a real
Codex session.
Phase 5 remains gated on the complete Phase 4b review, QA, live-service,
and human-checkpoint evidence.
@@ -0,0 +1,203 @@
# Phase 4b: Run the Live Codex Protocol Console
## What this is
This tutorial runs the Phase 4b browser console. The
[protocol demo server](phase-04b-protocol-server.md) tutorial used `curl`. This
one uses the real user interface.
## What this proves
The console shows one live session. You can chat with it, steer it, stop it,
answer its requests, change its goal, watch its child threads, break its
connection, refresh the page, and replay the whole record. Every screen reads
the canonical protocol. Nothing in the browser holds a provider login.
## Before you start
Run every command from the repository root. You do not need a Codex account
for this tutorial. The default driver replays deterministic demo chats.
## Step 1: Run the deterministic checks
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/mock-core/phase4b-scripted-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts \
devtools/browser/src/live/transcript-model.test.ts
```
Expected result: all three files pass. They cover the demo driver, the server
routes, and the pure transcript model the console renders.
## Step 2: Start the console
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Keep this terminal open. Open `http://127.0.0.1:4180/` in a browser and press
**Live console**.
Expected screen: an empty transcript that says
`Pick a demo chat or start a blank session`, a list of demo chats on the left,
and a disabled **Send** button.
## Step 3: Run one clean turn
1. Press **Completion** in the demo chat list.
2. Press **Run this demo chat**.
Expected facts:
- The connection badge says `connected`.
- Your own message appears first.
- A collapsed **Reasoning** block appears and opens while it streams.
- A **Tool** block shows its input and then its output.
- The answer streams into view with a blinking cursor.
- The turn ends with a `Turn completed` badge.
## Step 4: Steer a running turn
1. Press **Same-turn steering**, then **Run this demo chat**.
2. While the turn runs, the primary button says **Steer**.
3. Type `Keep it to one sentence.` and press Enter.
Expected facts:
- A steering chip appears as `pending` and then becomes `acknowledged`.
- The chip never merges into the answer before the acknowledgement.
Now type text while the turn is running, wait for `Turn completed`, and press
the button. The chip becomes `rejected — turn already ended` and offers
**Send as new message**. Your text is never discarded.
## Step 5: Stop a turn in three ways
Run each demo chat and press **Stop**:
| Demo chat | When to press Stop | Expected result |
| --- | --- | --- |
| Interrupt before start | right away | `Cancelled before start`, and no answer item is created |
| Interrupt during generation | while text streams | the stream stops, the partial text stays, and an interrupted divider appears |
| Interrupt during a tool call | while the tool runs | the tool keeps its last status and shows no result |
The session is never replaced. After a stop you can simply send again.
## Step 6: Answer requests
1. Press **Command and file approvals**, then **Run this demo chat**.
Expected facts:
- A blue banner above the composer says `1 request waiting — Review`.
- The command card offers **Approve**, **Approve for session**, **Reject**,
and **Cancel**, because the request offers those four actions.
- After you press **Approve**, the whole action row locks until the resolved
event arrives. A second click cannot answer twice.
- The resolved card collapses to one line that names the action you chose.
- The next card offers only **Approve** and **Reject**, because that request
offers only two actions.
2. Press **User input and expiry**, then **Run this demo chat**.
Type an answer and press **Submit**. Leave the second request alone. After a
few seconds it becomes `expired before response` and stays in the record.
## Step 7: Change the goal
1. Press **Goal lifecycle**, then **Run this demo chat**.
2. Press **Goal**, then **Set goal…**, type an objective, and confirm.
3. Press **Goal**, then **Pause**.
Expected facts: the banner shows the objective, the state, and the time of the
last change. **Resume** is disabled while the goal is active, and **Pause** is
disabled while it is paused.
Now press **Goals unsupported** and run it. The **Goal** button is disabled,
not hidden, and it names the exact upstream reason:
`Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability`.
Open the **Protocol inspector** and the **Capabilities** tab. The same string
appears there.
## Step 8: Watch child threads
Press **Parent and child threads**, then **Run this demo chat**.
Expected facts:
- The **Threads** panel lists the root session and two child threads.
- One child ends `Completed` and one ends `Failed`. Both stay in the tree.
- Selecting a child shows a breadcrumb and a disabled composer that says
`Direct steering of child threads is not supported by this app-server.`
## Step 9: Break the connection and recover
1. Run any demo chat and wait for it to finish.
2. Press **Simulate connection loss**.
Expected facts: a banner says the connection was lost, and the composer is
disabled.
3. Press **Retry now**.
Expected facts: the badge returns to `connected` and the transcript is exactly
what it was before the drop. Nothing is lost and nothing is duplicated.
Now reload the page and press **Live console** again. The same session returns
with its full transcript.
## Step 10: Replay the record
Press **Replay**.
Expected facts: a `REPLAY` badge appears, the transcript resets, and a stepper
appears. Press **Step forward** to walk the session forward one event at a
time. Press **Play** to run it. Press **Exit replay** to return to live.
## Step 11: Inspect the protocol
Press **Protocol inspector**.
- **Events**: every canonical event with its sequence, type, and full payload.
Filter by text or by event type.
- **Requests**: every runtime request with its lifecycle.
- **Capabilities**: what this app-server supports, with a reason for each
unsupported row.
- **Session**: identities, cursors, gap state, and the live-versus-replay
reducer comparison. It should read `match`.
Check the **Credentials in browser** row. It must read `no`.
## Step 12: Check the keyboard
- `Tab` reaches every control, including the inspector and the dialogs.
- `Enter` sends. `Shift` and `Enter` add a line.
- `Escape` while a turn runs moves focus to **Stop**. It never stops the turn
on its own.
- Arrow keys move between inspector tabs and between threads.
- `End` jumps the transcript to the newest item.
## Step 13: Use the real Codex driver
This step needs a working Codex login on the machine.
```sh
PAPERCLIP_PHASE4B_DRIVER=codex \
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
The routes and the screens are the same. The demo chat list still appears, but
the manifest only supplies the first message; the real model produces the rest.
The Codex login stays on the server.
## Step 14: Clean up
Stop the console with `Ctrl+C`. The server-owned working directory is a
temporary directory and is not part of your repository.
## Where to go next
- [Cumulative end-to-end tutorial](end-to-end.md)
- [Phase 4b live console reference](../phase-04b-live-console.md)
- [Phase 4b interaction map](../design/phase-4b-interaction-map.md)
@@ -0,0 +1,126 @@
---
type: Verification Evidence
title: Phase 4b live console verification
description: Deterministic driver, transcript-model, browser, boundary, and screenshot evidence for the package-local live Codex protocol console.
tags: [native-runner, phase-4b, browser, console, evidence]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-08T05:25:00Z }
---
# Scope
This record covers the Phase 4b browser layer: the deterministic demo driver,
the demo-chat manifests, the additive demo-server routes, and the **Live
console** mode in `devtools/browser/`. Every file is under
`packages/paperclip-runner/`. No Paperclip server, UI, database, or
control-plane file is imported or changed.
# Deterministic evidence
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
pnpm --filter @paperclipai/paperclip-runner exec vitest run
```
Result: typecheck clean; **14 test files, 133 tests passed**.
New deterministic coverage:
| File | Tests | Proves |
| --- | --- | --- |
| `src/mock-core/phase4b-scripted-driver.test.ts` | 15 | schema-valid canonical events, steering acknowledgement, stale-turn and already-terminal rejection, interrupt before start, preserved partial text, interrupted tool with no fabricated result, single-response requests, answers absent from acknowledgements, goal lifecycle, exact unsupported-goal diagnostic, child lineage terminal states, failed turn, resumed identity and continued source sequence |
| `devtools/browser/src/live/transcript-model.test.ts` | 11 | role mapping, timeline-ordered transcript, submitted-message rendering, interrupted projection, resolved-request record, exact failure diagnostic, live/replay projection equality, composer state machine, goal capability gating, capability diagnostics |
# Browser evidence
```sh
pnpm --filter @paperclipai/paperclip-runner test:browser
```
Result: **28 tests passed** — 19 new live-console tests plus the 9 pre-existing
Phase 1-3 tests, which are unchanged and still pass.
The live-console tests assert, at both evidence viewports where relevant:
1. empty state, streamed turn, and a completed turn with a structured result;
2. steering acknowledgement, and a stale steer that keeps its text recoverable;
3. all three interrupt races, each with a distinct visible outcome;
4. approval cards that render only the offered actions, lock on first click,
and collapse to a named resolution;
5. a submitted user-input answer and an unanswered request that expires;
6. the goal lifecycle, and the exact upstream diagnostic on a disabled menu
mirrored in the inspector's Capabilities tab;
7. child threads with persistent terminal states and an explicitly disabled
child composer carrying the exact diagnostic;
8. a failed item with its exact diagnostic in the transcript record;
9. a transport drop, a resume to a byte-identical transcript, and a page
refresh that restores the same session;
10. replay mode with its `REPLAY` badge and stepper;
11. the inspector, including a live-versus-replay reducer comparison reading
`match`;
12. the keyboard contract: `Escape` focuses Stop without interrupting, `Enter`
steers, inspector tabs and the lineage tree respond to arrow keys, and the
transcript is a labelled `role="log"` region with `aria-live="polite"`;
13. a reset dialog that names exactly what it discards;
14. a 390x844 pass with no horizontal page overflow.
Two negative assertions run against the rendered DOM: no `Bearer ` string and
no `auth.json` reference reaches the browser.
# Boundary and token evidence
```sh
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
node --test scripts/check-forbidden-imports.test.mjs
```
Result: all pass (4 boundary tests).
The boundary check now rejects `ai`, `@ai-sdk/*`, `zod`, `radix-ui`,
`@radix-ui/*`, `cmdk`, `streamdown`, `shiki`, `use-stick-to-bottom`,
`class-variance-authority`, `tailwindcss`, and `nanoid` anywhere under
`devtools/browser/`. A negative fixture in
`test-fixtures/forbidden-ui-runtime/` proves the rule fires. The console adds
**zero new runtime dependencies**.
# Screenshots
23 screenshots in [`phase-04b/`](phase-04b/), captured by the browser suite:
- Desktop (1440x900): idle and empty, streaming turn, completed turn, steering
acknowledged, interrupt before start, interrupt during generation, interrupt
during a tool call, pending request, resolved request, expired request, goal
supported, goal unsupported, lineage with a child selected, failed turn,
reconnect banner, refresh replay, replay mode, inspector events, inspector
session.
- Mobile (390x844): idle and empty, session segment, pending request,
inspector.
# Documentation evidence
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: documentation link validation and OKF v0.2 validation both pass.
# Known gaps
1. Real-Codex evidence for the console screens is a QA step. The scripted
driver proves every surface; `PAPERCLIP_PHASE4B_DRIVER=codex` runs the same
routes against a real session and needs a machine with a Codex login.
2. An assistive-technology pass (screen reader) is specified in the interaction
map and asserted here only at the semantics level.
3. Child-thread steering is unsupported upstream, so the console shows the
disabled composer rather than a live one.
# Startup command for QA
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Then open `http://127.0.0.1:4180/` and press **Live console**. Add
`-- --host 0.0.0.0` to reach it from another host.
@@ -9,3 +9,4 @@
* [Phase 3 verification](2026-08-07-phase-03-verification.md) - Lost-ACK replay, restart recovery, command deduplication, storage, and redacted diagnostics.
* [Phase 4 verification](2026-08-08-phase-04-verification.md) - Direct Codex conformance, exact skillless context, semantic completion, recovery, and real-session replay.
* [Phase 4b protocol/server verification](2026-08-08-phase-04b-protocol-server-verification.md) - Browser-resolved requests, goals, lineage, controls, reconnect, redaction, and real Codex server evidence.
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,96 @@
---
type: Engineering Journal Entry
title: Phase 4b live console browser tracer
description: Decisions, failures, fixes, and evidence for the package-local browser console over the canonical runner protocol.
tags: [native-runner, phase-4b, browser, console, reducer]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-08T05:25:00Z }
entry_kind: phase
phase: "4b"
---
# Context
The [protocol and demo-server layer](2026-08-08-phase-04b-protocol-server.md)
gave the browser typed routes and a server-only credential boundary. The
[UX gate](2026-08-08-phase-04b-ux.md) gave it an approved interaction map and a
component decision record. This entry records building the console itself.
# Decisions
1. Extend the existing devtool with a **Live console** mode. Do not build a
second app shell.
2. Keep one data contract: canonical PRP events plus the shared reducer. The
browser runs `applyPrpEvent`, the same function the server and CLI run, so
live output and replayed output cannot drift.
3. Add a deterministic scripted `HarnessDriver` behind the same demo-server
routes the real Codex driver uses. The console can then reach every
protocol state without a provider account, and the real driver is a single
environment variable away.
4. Let the manifest, not the UI, own the list of expected observations. The
human checkpoint script lives in the data, not in hand-written copy.
5. Adapt shadcn/ui and AI Elements source with zero new runtime dependencies.
Rebuild the radix-backed patterns on native primitives: `<dialog>` for
modals, `<details>` for disclosures, the WAI-ARIA tabs and menu-button
patterns by hand.
6. Put the submitted message text into the `turn.submitted` payload so the
transcript can show the operator's own message from a canonical event
instead of a shadow store.
7. Render exactly one layout. A second CSS-hidden copy would duplicate the
transcript log landmark and every control in the DOM.
# Failures and fixes
1. **The mobile and desktop trees both rendered.** The first build hid one with
a media query. That duplicated every `data-testid`, every interactive
control, and the `role="log"` landmark. Fixed with a `matchMedia` hook that
renders one tree.
2. **A resumed stream never opened.** The SSE route wrote its headers and then
waited. When a subscriber resumed past the last event there were no bytes to
send, so Node held the response open and the browser stayed in `CONNECTING`
forever. Fixed by writing an SSE comment immediately after the headers.
This only appeared after a reconnect, which is exactly the case the phase is
about.
3. **A finished turn stayed steerable.** The composer read
`snapshot.activeTurnId ?? state.activeTurnId`. When the reducer correctly
set the active turn to `null`, the nullish fallback reached back to a stale
public-state read and the primary action stayed **Steer**. Fixed by making
the reducer authoritative whenever a snapshot exists.
4. **Interrupt before start was unreachable.** The composer left `submitting`
as soon as the create call returned, so **Stop** was disabled during the
exact window the race needs. Fixed by deriving the pending-turn state from
the timeline: a `turn.submitted` with no later `turn.*` event.
5. **Lineage, goals, and pending requests went stale.** The event stream
carries events; those fields live in the server's public state. Fixed with a
debounced state refresh whenever new events arrive.
6. **The left rail overflowed its track.** Long run and session identifiers
gave the rail panels a min-content width of 398px inside a 272px track, and
the panels slid under the transcript. Fixed with `minmax(0, 1fr)` tracks and
wrapping identifiers. Found by measuring bounding boxes in the browser, not
by reading the CSS.
7. **The new mode broke five frozen Phase 1-3 browser tests.** Landing on the
console changed the entry path those specs rely on. Reverted the landing
mode and navigated explicitly in the new spec instead.
# Evidence
- 15 driver and manifest tests, 11 transcript-model tests, 19 live-console
browser tests, and the 9 pre-existing browser tests all pass.
- 23 screenshots at 1440x900 and 390x844 in
[`evidence/phase-04b/`](../evidence/phase-04b/).
- The boundary check now fails on any AI SDK, radix, Tailwind, or markdown
runtime import under `devtools/browser/`, with a negative fixture proving it.
- Full record: [Phase 4b live console verification](../evidence/2026-08-08-phase-04b-live-console-verification.md).
# Gaps and next questions
1. Child threads are read-only because the app-server does not advertise child
steering. If a later Codex release adds it, the disabled composer becomes a
capability-gated live one; the diagnostic string is already the seam.
2. Real-Codex evidence for the console UI is a QA step, not an automated one.
The scripted driver proves the surfaces; a human still has to confirm the
same screens against a real session.
3. Screen-reader passes are specified in the interaction map and asserted at
the semantics level here. An assistive-technology pass remains a QA task.
4. Markdown rendering and syntax highlighting stay rejected. If Phase 5 turns
this into an SDK console, both become dependency decisions to revisit.
@@ -10,3 +10,4 @@
* [Phase 4 skillless Codex app-server driver](2026-08-08-phase-04.md) - Direct app-server mapping, context isolation, semantic completion, real-model failures, and fixes.
* [Phase 4b UX gate — interaction map and component decisions](2026-08-08-phase-04b-ux.md) - Approved live-console interaction contract, adapt/reject component record, and keyboard/accessibility acceptance criteria.
* [Phase 4b protocol and demo-server layer](2026-08-08-phase-04b-protocol-server.md) - Browser-resolved provider requests, control races, goals, lineage, replay, and server-only credentials.
* [Phase 4b live console browser tracer](2026-08-08-phase-04b-live-console.md) - Reducer-only browser surfaces, adapted components with no new runtime dependencies, interrupt races, reconnect, replay, and the layout and stream failures found on the way.
@@ -21,3 +21,11 @@
* **Phase 4b protocol/server layer**: Added deterministic Codex request, goal,
lineage, control-race, reconnect, replay, and redaction fixtures; a typed
server-only demo boundary; and a passing real Codex server trace.
* **Phase 4b live console**: Added the package-local browser tracer over the
canonical protocol — transcript, composer, same-turn steering, three
interrupt races, runtime request cards, capability-gated goals, parent/child
lineage, reconnect, refresh, replay, and a redacted protocol inspector — plus
a deterministic demo driver, eleven demo chats, adapted shadcn/AI Elements
components with no new runtime dependencies, and a boundary check that keeps
it that way.
+1
View File
@@ -52,6 +52,7 @@
"diagnose:phase3": "pnpm run trace:phase3",
"record:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node scripts/record-phase3-evidence.mjs",
"record:phase3:lost-ack": "pnpm run trace:phase3 -- --fault lost-ack --output knowledge/evidence/phase-03-lost-ack.json",
"console:phase4b": "pnpm run build:typescript && vite --config vite.config.ts --host 127.0.0.1 --port 4180",
"browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
"browser:preview": "vite preview --config vite.config.ts",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
@@ -35,3 +35,16 @@ test("a negative Cargo fixture proves that a core path dependency is rejected",
assert.match(violations[0].reason, /Cargo path dependencies may not escape/);
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
});
test("a negative fixture proves that a browser UI runtime import is rejected", async () => {
const violations = await checkForbiddenImports({
scanRoots: ["devtools/browser", "test-fixtures/forbidden-ui-runtime/devtools/browser"],
cargoRoots: [],
checkManifest: false,
});
assert.equal(violations.length, 1);
assert.equal(violations[0].specifier, "@ai-sdk/react");
assert.match(violations[0].reason, /adapts component source/);
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
});
@@ -55,11 +55,42 @@ function findSpecifiers(source) {
return found;
}
// The Phase 4b component decision record adapts shadcn/ui and AI Elements
// source rather than adopting their runtimes. These packages would reintroduce
// a second message model or a Tailwind/radix dependency for the demo app.
const BROWSER_FORBIDDEN_PACKAGES = [
"ai",
"@ai-sdk",
"zod",
"radix-ui",
"@radix-ui",
"cmdk",
"streamdown",
"shiki",
"use-stick-to-bottom",
"class-variance-authority",
"tailwindcss",
"nanoid",
];
function isForbiddenBrowserPackage(specifier) {
return BROWSER_FORBIDDEN_PACKAGES.some(
(name) => specifier === name || specifier.startsWith(`${name}/`),
);
}
function violationReason({ file, packageRoot, specifier }) {
if (specifier.startsWith("@paperclipai/") && specifier !== "@paperclipai/paperclip-runner") {
return "Paperclip workspace packages are outside the standalone boundary";
}
if (
relative(packageRoot, file).split(/[\\/]/).join("/").includes("devtools/browser/") &&
isForbiddenBrowserPackage(specifier)
) {
return "the standalone browser app adapts component source instead of adopting its runtime";
}
if (["server", "ui", "cli"].some((root) => specifier === root || specifier.startsWith(`${root}/`))) {
return "Paperclip application internals are outside the standalone boundary";
}
@@ -0,0 +1,140 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
/**
* Mounts the Phase 4b demo-server routes inside the standalone Vite devtool.
*
* The browser never talks to a provider. Only this Node process starts a
* driver, owns the working directory, and reads any provider login. The
* default driver replays deterministic demo manifests; setting
* `PAPERCLIP_PHASE4B_DRIVER=codex` swaps in the real Codex app-server driver
* behind exactly the same routes.
*/
const LOOPBACK_ONLY = "Phase 4b is available only over loopback";
function isLoopbackAddress(address) {
if (typeof address !== "string") return false;
const normalized = address.toLowerCase().replace(/^::ffff:/, "");
if (normalized === "::1" || normalized === "[::1]" || normalized === "localhost") return true;
const octets = normalized.split(".");
return (
octets.length === 4 &&
octets[0] === "127" &&
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
);
}
function rejectUntrusted(request, response) {
if (
!isLoopbackAddress(request.socket.localAddress) ||
!isLoopbackAddress(request.socket.remoteAddress)
) {
response.statusCode = 403;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(JSON.stringify({ error: "forbidden", message: LOOPBACK_ONLY }));
return true;
}
const fetchSite = request.headers["sec-fetch-site"];
if (
Array.isArray(fetchSite) ||
(typeof fetchSite === "string" && !["same-origin", "none"].includes(fetchSite))
) {
response.statusCode = 403;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(JSON.stringify({
error: "forbidden",
message: "Phase 4b cross-site requests are forbidden",
}));
return true;
}
return false;
}
async function loadRunner() {
return import(new URL("../dist/index.js", import.meta.url).href);
}
async function createWorkingDirectory() {
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.PAPERCLIP_SCRATCH_DIR ?? tmpdir();
return mkdtemp(resolve(scratchRoot, "phase4b-browser-"));
}
export function createPhase4bBrowserMiddleware(options = {}) {
const load = options.loadRunner ?? loadRunner;
const driverMode = options.driverMode ?? process.env.PAPERCLIP_PHASE4B_DRIVER ?? "demo";
const chunkDelayMs = Number.parseInt(
options.chunkDelayMs ?? process.env.PAPERCLIP_PHASE4B_CHUNK_DELAY_MS ?? "45",
10,
);
let bootstrap = null;
async function ready() {
if (bootstrap !== null) return bootstrap;
bootstrap = (async () => {
const runner = await load();
const workingDirectory =
options.workingDirectory ?? (await createWorkingDirectory());
const server = new runner.Phase4bDemoServer({
workingDirectory,
manifests: runner.phase4bDemoManifestCatalogue(),
driverFactory: (taskEnvelope, manifestId) =>
driverMode === "codex"
? new runner.CodexAppServerDriver({
taskEnvelope,
onDiagnostic: (message) => process.stderr.write(`[phase4b] ${message}\n`),
})
: new runner.Phase4bScriptedDriver({
manifestId: manifestId ?? "completion",
chunkDelayMs: Number.isInteger(chunkDelayMs) ? chunkDelayMs : 45,
}),
});
return { handle: server.middleware(), server, workingDirectory };
})();
return bootstrap;
}
const middleware = async function phase4bMiddleware(request, response, next) {
const url = new URL(request.url ?? "/", "http://phase4b.local");
if (!url.pathname.startsWith("/api/phase4b/")) {
next();
return;
}
if (rejectUntrusted(request, response)) return;
try {
const { handle } = await ready();
handle(request, response);
} catch (error) {
response.statusCode = 500;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(JSON.stringify({
error: "phase4b_unavailable",
message: String(error instanceof Error ? error.message : error),
}));
}
};
middleware.close = async () => {
if (bootstrap === null) return;
const { server } = await bootstrap;
await server.close();
};
return middleware;
}
export function phase4bBrowserServerPlugin(options = {}) {
const middleware = createPhase4bBrowserMiddleware(options);
return {
name: "paperclip-runner-phase4b-live-console-server",
configureServer(server) {
server.middlewares.use(middleware);
server.httpServer?.once("close", () => void middleware.close());
},
configurePreviewServer(server) {
server.middlewares.use(middleware);
server.httpServer?.once("close", () => void middleware.close());
},
};
}
export const phase4bBrowserServerInternals = { isLoopbackAddress };
@@ -709,7 +709,13 @@ class CodexHarnessSession implements HarnessSession {
);
}
const taskText = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema });
// 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.
this.#emit("turn.submitted", {
envelopeSchema: this.#taskEnvelope.schema,
text: input.message.text,
});
this.#turnStartPending = true;
let response: Record<string, unknown>;
try {
+2
View File
@@ -10,6 +10,8 @@ export * from "./drivers/codex/codex-app-server-driver.js";
export * from "./mock-core/mock-control-plane-adapter.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";
export * from "./mock-core/phase4b-scripted-driver.js";
export * from "./mock-core/phase2-local-runner.js";
export * from "./mock-core/phase3-recovery.js";
export * from "./protocol/phase0-fixture.js";
@@ -0,0 +1,448 @@
import type { HarnessRuntimeRequestKind } from "../contracts/harness-driver.js";
import type { NativeSessionCapabilities } from "../contracts/types.js";
/**
* A demo chat manifest is a deterministic script for the Phase 4b browser
* tracer. Every state named in the interaction map has to be reachable from
* one of these manifests, and the manifest — not the browser — owns the list
* of observations a human is expected to confirm.
*/
export type Phase4bDemoBeat =
| { kind: "reasoning"; itemId: string; chunks: readonly string[] }
| { kind: "assistant"; itemId: string; chunks: readonly string[] }
| {
kind: "tool";
itemId: string;
name: string;
input: string;
output?: string;
failure?: string;
/** Stay in flight until an interrupt or the bounded demo timeout. */
hold?: boolean;
}
| {
kind: "request";
requestId: string;
requestKind: HarnessRuntimeRequestKind;
method: string;
itemId: string;
prompt: string;
details: Record<string, unknown>;
/** Resolution actions the upstream request actually offers. */
actions: readonly string[];
/** Expire the request when nobody answers within the bounded window. */
expiresAfterMs?: number;
}
| {
kind: "child";
threadId: string;
nickname: string;
role: string;
statuses: readonly string[];
}
| { kind: "hold"; note: string }
| { kind: "diagnostic"; message: string };
export interface Phase4bDemoManifest {
id: string;
name: string;
purpose: string;
scenarios: readonly string[];
objective: string;
prompt: string;
capabilities: NativeSessionCapabilities;
/** Milliseconds between `turn.submitted` and `turn.accepted`. */
startDelayMs: number;
beats: readonly Phase4bDemoBeat[];
/** Beats used by every turn after the first one. */
followUpBeats?: readonly Phase4bDemoBeat[];
expectedObservations: readonly string[];
disposition: "completed" | "failed";
}
const FULL_CAPABILITIES: NativeSessionCapabilities = {
resume: true,
typedEvents: true,
steering: true,
interruption: true,
structuredResult: true,
read: true,
reconciliation: true,
usage: true,
runtimeRequestResolution: true,
goals: true,
threadLineage: true,
};
function capabilities(
overrides: Partial<NativeSessionCapabilities> = {},
): NativeSessionCapabilities {
return { ...FULL_CAPABILITIES, ...overrides };
}
const SHORT_ANSWER: readonly string[] = [
"I read the objective, ",
"checked the working directory, ",
"and finished the task.",
];
export const PHASE4B_DEMO_MANIFESTS: readonly Phase4bDemoManifest[] = [
{
id: "completion",
name: "Completion",
purpose: "One clean turn: reasoning, a tool call, an answer, and a terminal result.",
scenarios: ["transcript", "streaming", "terminal"],
objective: "Summarize the Phase 4b boundary.",
prompt: "Summarize what the Phase 4b demo server proves.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "reasoning",
itemId: "reasoning-1",
chunks: ["Reading the objective. ", "The task is a summary, so no writes are needed."],
},
{
kind: "tool",
itemId: "tool-1",
name: "read_file",
input: "docs/phase-04b-protocol-server.md",
output: "The browser boundary is HTTP plus server-sent events.",
},
{ kind: "assistant", itemId: "assistant-1", chunks: SHORT_ANSWER },
],
followUpBeats: [
{ kind: "assistant", itemId: "assistant-follow", chunks: ["Follow-up turns reuse the same session."] },
],
expectedObservations: [
"The transcript shows reasoning collapsed and the answer expanded.",
"The tool item shows its input and output in a monospace block.",
"The turn ends with a completed badge and a structured result.",
],
disposition: "completed",
},
{
id: "steering",
name: "Same-turn steering",
purpose: "Steer a running turn, watch the acknowledgement, then watch a stale steer get rejected.",
scenarios: ["steering", "stale-turn"],
objective: "Keep the answer short.",
prompt: "Explain the steering contract, then wait for further direction.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "reasoning",
itemId: "reasoning-1",
chunks: ["Starting a long answer so there is time to steer."],
},
{
kind: "assistant",
itemId: "assistant-1",
chunks: [
"Steering is bound to the turn that was active when the message was composed. ",
"The driver compares the expected turn id before it accepts the text. ",
"A mismatch is rejected instead of being applied to a different turn.",
],
},
],
expectedObservations: [
"A steering chip appears as pending and then becomes acknowledged.",
"Steering sent after the turn ends is rejected as a stale turn.",
"The rejected chip keeps the typed text and offers to send it as a new message.",
],
disposition: "completed",
},
{
id: "interrupt-before-start",
name: "Interrupt before start",
purpose: "Press stop while the turn is still waiting for a provider turn id.",
scenarios: ["interrupt"],
objective: "Cancel before the provider starts.",
prompt: "Start a slow turn so it can be cancelled before it starts.",
capabilities: capabilities(),
startDelayMs: 4_000,
beats: [{ kind: "assistant", itemId: "assistant-1", chunks: ["The turn started after all."] }],
expectedObservations: [
"The composer sits in the submitting state while the turn waits to start.",
"Stop resolves the pending turn to `Cancelled before start`.",
"No assistant item is ever created for the cancelled turn.",
],
disposition: "completed",
},
{
id: "interrupt-during-generation",
name: "Interrupt during generation",
purpose: "Stop a streaming answer and keep the partial text.",
scenarios: ["interrupt", "streaming"],
objective: "Stop mid-answer.",
prompt: "Write a long answer so it can be interrupted while streaming.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "assistant",
itemId: "assistant-1",
chunks: [
"The tracer keeps every partial token that arrived before the stop. ",
"It never blanks an interrupted item, ",
"because the partial record is the point of a tracer. ",
"This sentence exists so the stream lasts long enough to interrupt. ",
"And this one too. ",
"And this final one.",
],
},
],
expectedObservations: [
"The stream stops in place and the partial text stays visible.",
"The item shows an interrupted divider instead of a completed state.",
"The turn group shows an interrupted badge.",
],
disposition: "completed",
},
{
id: "interrupt-during-tool",
name: "Interrupt during a tool call",
purpose: "Stop while a tool call is running and keep its last known status.",
scenarios: ["interrupt", "tools"],
objective: "Stop during a tool call.",
prompt: "Run a slow tool so it can be interrupted.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "tool",
itemId: "tool-1",
name: "run_command",
input: "sleep 30 && echo done",
hold: true,
},
{ kind: "assistant", itemId: "assistant-1", chunks: ["The tool finished."] },
],
expectedObservations: [
"The tool item stays running until the stop is pressed.",
"The interrupted tool item keeps its last known status and no fabricated result.",
"The turn group shows an interrupted badge.",
],
disposition: "completed",
},
{
id: "approvals",
name: "Command and file approvals",
purpose: "Resolve a command approval and a file-change approval from the transcript.",
scenarios: ["requests", "approvals"],
objective: "Approve the demo command and file change.",
prompt: "Ask for approval before running the command and writing the file.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "request",
requestId: "request-command",
requestKind: "command_approval",
method: "item/commandExecution/requestApproval",
itemId: "command-1",
prompt: "Run the deterministic demo command?",
details: { command: "node scripts/phase4b-demo-check.mjs --dry-run", cwd: "<server-path>" },
actions: ["accept", "accept_for_session", "decline", "cancel"],
},
{
kind: "request",
requestId: "request-file",
requestKind: "file_approval",
method: "item/fileChange/requestApproval",
itemId: "file-1",
prompt: "Write phase4b-demo.txt?",
details: {
path: "phase4b-demo.txt",
diff: "+phase4b demo evidence\n",
changeKind: "add",
},
actions: ["accept", "decline"],
},
{ kind: "assistant", itemId: "assistant-1", chunks: ["Both approvals were honoured exactly once."] },
],
expectedObservations: [
"Each request card renders only the actions the request offers.",
"The first click disables the whole action row until the resolved event arrives.",
"A resolved card collapses to one summary line that names the chosen action.",
],
disposition: "completed",
},
{
id: "user-input",
name: "User input and expiry",
purpose: "Answer a typed user-input request, then watch an unanswered request expire.",
scenarios: ["requests", "user-input", "expiry"],
objective: "Collect the deployment target, then let a request expire.",
prompt: "Ask which environment to use, then ask a question nobody answers.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "request",
requestId: "request-input",
requestKind: "user_input",
method: "item/tool/requestUserInput",
itemId: "input-1",
prompt: "Which environment should the demo target?",
details: { fields: [{ name: "environment", label: "Environment" }] },
actions: ["submit", "decline", "cancel"],
},
{
kind: "request",
requestId: "request-expiring",
requestKind: "elicitation",
method: "mcpServer/elicitation/request",
itemId: "input-2",
prompt: "Optional detail — this request expires if nobody answers.",
details: { fields: [{ name: "detail", label: "Detail" }] },
actions: ["submit", "decline", "cancel"],
expiresAfterMs: 8_000,
},
{ kind: "assistant", itemId: "assistant-1", chunks: ["The expired request stays in the record."] },
],
expectedObservations: [
"The user-input card renders a text field and a submit action.",
"The submitted answer never appears in the resolution acknowledgement.",
"The unanswered request resolves to `expired before response` on its own.",
],
disposition: "completed",
},
{
id: "subagents",
name: "Parent and child threads",
purpose: "Watch child threads start, report, and reach a terminal state.",
scenarios: ["lineage", "subagents"],
objective: "Delegate two checks to child threads.",
prompt: "Spawn two child threads and report what they found.",
capabilities: capabilities({ steering: true }),
startDelayMs: 0,
beats: [
{
kind: "child",
threadId: "thread-child-a",
nickname: "docs-check",
role: "reviewer",
statuses: ["starting", "running", "completed"],
},
{
kind: "child",
threadId: "thread-child-b",
nickname: "test-check",
role: "reviewer",
statuses: ["starting", "running", "failed"],
},
{ kind: "assistant", itemId: "assistant-1", chunks: ["One child finished and one child failed."] },
],
expectedObservations: [
"The lineage tree lists the root session and both child threads.",
"Child terminal states stay in the tree after the turn ends.",
"Selecting a child shows an explicitly disabled composer with the exact diagnostic.",
],
disposition: "completed",
},
{
id: "goals",
name: "Goal lifecycle",
purpose: "Set, pause, resume, and clear a durable goal.",
scenarios: ["goals", "capabilities"],
objective: "Keep the demo goal visible.",
prompt: "Work under a durable goal.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{ kind: "assistant", itemId: "assistant-1", chunks: ["Use the goal menu to change the goal state."] },
],
expectedObservations: [
"The goal banner shows the active goal and its last transition time.",
"Pause and resume are disabled when they are invalid for the current state.",
"Every goal change is also a transcript event, so replay shows the same history.",
],
disposition: "completed",
},
{
id: "goals-unsupported",
name: "Goals unsupported",
purpose: "Show the exact capability diagnostic instead of hiding the control.",
scenarios: ["goals", "capabilities"],
objective: "Prove unsupported capabilities are visible.",
prompt: "Run against an app-server that does not advertise goals.",
capabilities: capabilities({
goals: false,
unsupported: [
"Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability",
],
}),
startDelayMs: 0,
beats: [
{
kind: "assistant",
itemId: "assistant-1",
chunks: ["The goal menu stays visible and disabled with the upstream diagnostic."],
},
],
expectedObservations: [
"The goal menu is disabled, not hidden.",
"The exact capability diagnostic is readable without hovering.",
"The inspector capabilities tab shows the same unsupported string.",
],
disposition: "completed",
},
{
id: "failure",
name: "Item and turn failure",
purpose: "Show a failed item and a failed turn as part of the record.",
scenarios: ["failure", "diagnostics"],
objective: "Fail on purpose.",
prompt: "Run a command that fails.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
{
kind: "tool",
itemId: "tool-1",
name: "run_command",
input: "node scripts/missing.mjs",
failure: "Command exited with status 1: cannot find module scripts/missing.mjs",
},
{ kind: "diagnostic", message: "The demo task cannot continue after the failed command." },
],
expectedObservations: [
"The failed item renders in place with the exact diagnostic string.",
"No failure is reported only as a transient notification.",
"The turn ends in a failed state and the run result reports the failure.",
],
disposition: "failed",
},
];
export function findPhase4bDemoManifest(id: string): Phase4bDemoManifest {
const manifest = PHASE4B_DEMO_MANIFESTS.find((candidate) => candidate.id === id);
if (manifest === undefined) {
throw new Error(`unknown Phase 4b demo manifest ${id}`);
}
return manifest;
}
export interface Phase4bDemoManifestSummary {
id: string;
name: string;
purpose: string;
scenarios: readonly string[];
objective: string;
prompt: string;
expectedObservations: readonly string[];
}
export function phase4bDemoManifestCatalogue(): Phase4bDemoManifestSummary[] {
return PHASE4B_DEMO_MANIFESTS.map((manifest) => ({
id: manifest.id,
name: manifest.name,
purpose: manifest.purpose,
scenarios: [...manifest.scenarios],
objective: manifest.objective,
prompt: manifest.prompt,
expectedObservations: [...manifest.expectedObservations],
}));
}
@@ -33,15 +33,21 @@ const MAX_BROWSER_EVENTS = 4096;
export interface Phase4bDemoServerOptions {
workingDirectory: string;
driverFactory: (envelope: Phase4TaskEnvelope) => HarnessDriver;
driverFactory: (
envelope: Phase4TaskEnvelope,
manifestId?: string,
) => HarnessDriver;
host?: string;
port?: number;
/** Demo-chat manifests the browser lists; the server owns the catalogue. */
manifests?: readonly unknown[];
}
interface DemoEntry {
id: string;
runId: string;
normalizedSessionId: string;
manifestId: string | null;
envelope: Phase4TaskEnvelope;
driver: HarnessDriver;
session: HarnessSession;
@@ -54,6 +60,8 @@ interface DemoEntry {
interface BrowserCreateBody {
objective?: string;
message?: string;
manifest?: string;
startTurn?: boolean;
}
function record(value: unknown): Record<string, unknown> {
@@ -160,18 +168,7 @@ export class Phase4bDemoServer {
async start(): Promise<{ host: string; port: number; url: string }> {
if (this.#server !== null) throw new Error("Phase 4b demo server is already started");
this.#server = createServer((request, response) => {
void this.#handle(request, response).catch((error) => {
if (!response.headersSent) {
json(response, errorStatus(error), {
error: record(error).code ?? "invalid_request",
message: redactCodexDiagnostic(String(error)),
});
} else {
response.end();
}
});
});
this.#server = createServer(this.middleware());
await new Promise<void>((resolve, reject) => {
this.#server!.once("error", reject);
this.#server!.listen(this.#options.port, this.#options.host, () => resolve());
@@ -187,6 +184,25 @@ export class Phase4bDemoServer {
};
}
/**
* The same handler `start()` binds, so an embedding dev server (the Vite
* plugin) can mount the identical routes without a second implementation.
*/
middleware(): (request: IncomingMessage, response: ServerResponse) => void {
return (request, response) => {
void this.#handle(request, response).catch((error) => {
if (!response.headersSent) {
json(response, errorStatus(error), {
error: record(error).code ?? "invalid_request",
message: redactCodexDiagnostic(String(error)),
});
} else {
response.end();
}
});
};
}
async close(): Promise<void> {
const entries = [...this.#entries.values()];
this.#entries.clear();
@@ -215,13 +231,20 @@ export class Phase4bDemoServer {
});
return;
}
if (request.method === "GET" && parts.join("/") === "api/phase4b/manifests") {
json(response, 200, { manifests: this.#options.manifests ?? [] });
return;
}
if (request.method === "POST" && parts.join("/") === "api/phase4b/sessions") {
const body = await readJson(request) as BrowserCreateBody;
const objective = typeof body.objective === "string" && body.objective.trim().length > 0
? body.objective.trim()
: "Complete the Phase 4b demo task safely.";
const message = typeof body.message === "string" ? body.message : objective;
const entry = await this.#create(objective, message);
const manifestId = typeof body.manifest === "string" && body.manifest.length > 0
? body.manifest
: null;
const entry = await this.#create(objective, message, manifestId, body.startTurn !== false);
json(response, 201, await this.#publicState(entry));
return;
}
@@ -257,6 +280,10 @@ export class Phase4bDemoServer {
"cache-control": "no-store",
connection: "keep-alive",
});
// Flush the headers immediately. A resumed subscriber can start past the
// last event, and Node would otherwise hold the response open with no
// bytes sent, leaving the browser stuck in CONNECTING.
response.write(": stream open\n\n");
for (const event of entry.events.slice(after)) {
response.write(`data: ${JSON.stringify(browserSafe(event))}\n\n`);
}
@@ -269,7 +296,12 @@ export class Phase4bDemoServer {
return;
}
const body = await readJson(request);
if (action === "steer") {
if (action === "turns") {
await entry.session.startTurn({
message: { role: "user", text: String(body.text ?? "") },
});
await new Promise<void>((resolve) => setImmediate(resolve));
} else if (action === "steer") {
await entry.session.steer?.({
turnId: String(body.turnId ?? ""),
message: { role: "user", text: String(body.text ?? "") },
@@ -296,13 +328,18 @@ export class Phase4bDemoServer {
json(response, 200, await this.#publicState(entry));
}
async #create(objective: string, message: string): Promise<DemoEntry> {
async #create(
objective: string,
message: string,
manifestId: string | null = null,
startTurn = true,
): Promise<DemoEntry> {
const envelope = createPhase4TaskEnvelope({
objective,
contractRevision: "phase4b-demo-v1",
criteria: [{ id: "objective", requirement: objective }],
});
const driver = this.#options.driverFactory(envelope);
const driver = this.#options.driverFactory(envelope, manifestId ?? undefined);
const id = randomUUID();
const runId = `phase4b-run-${id}`;
const normalizedSessionId = `phase4b-session-${id}`;
@@ -316,6 +353,7 @@ export class Phase4bDemoServer {
id,
runId,
normalizedSessionId,
manifestId,
envelope,
driver,
session,
@@ -326,8 +364,10 @@ export class Phase4bDemoServer {
};
this.#entries.set(id, entry);
entry.consumeTask = this.#consume(entry, session);
await session.startTurn({ message: { role: "user", text: message } });
await new Promise<void>((resolve) => setImmediate(resolve));
if (startTurn) {
await session.startTurn({ message: { role: "user", text: message } });
await new Promise<void>((resolve) => setImmediate(resolve));
}
return entry;
}
@@ -383,6 +423,7 @@ export class Phase4bDemoServer {
sessionId: entry.id,
runId: entry.runId,
normalizedSessionId: entry.normalizedSessionId,
manifest: entry.manifestId,
providerAuthentication: "server-side",
credentialsExposed: false,
capabilities: entry.capabilities,
@@ -0,0 +1,331 @@
import { describe, expect, it } from "vitest";
import type { HarnessSession } from "../contracts/harness-driver.js";
import { validatePrpEvent, type PrpEvent } from "../protocol/phase1-contract.js";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../reducer/session-reducer.js";
import {
PHASE4B_DEMO_MANIFESTS,
findPhase4bDemoManifest,
phase4bDemoManifestCatalogue,
} from "./phase4b-demo-manifests.js";
import { Phase4bScriptedDriver } from "./phase4b-scripted-driver.js";
/** Lets the collector loop drain events the driver already pushed. */
async function tick(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
function collector(session: HarnessSession): {
events: PrpEvent[];
waitFor: (eventType: string, timeoutMs?: number) => Promise<PrpEvent>;
done: Promise<void>;
} {
const events: PrpEvent[] = [];
const waiters: Array<{ eventType: string; resolve: (event: PrpEvent) => void }> = [];
const done = (async () => {
for await (const event of session.events()) {
events.push(event);
for (let index = waiters.length - 1; index >= 0; index -= 1) {
if (waiters[index]!.eventType === event.eventType) {
waiters.splice(index, 1)[0]!.resolve(event);
}
}
}
})();
return {
events,
done,
waitFor(eventType, timeoutMs = 5_000) {
const existing = events.find((event) => event.eventType === eventType);
if (existing !== undefined) return Promise.resolve(existing);
return new Promise<PrpEvent>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`timed out waiting for ${eventType}`)),
timeoutMs,
);
waiters.push({
eventType,
resolve: (event) => {
clearTimeout(timer);
resolve(event);
},
});
});
},
};
}
async function openSession(manifestId: string, chunkDelayMs = 0) {
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs });
const session = await driver.openSession({
runId: `run-${manifestId}`,
normalizedSessionId: `session-${manifestId}`,
workingDirectory: "/demo",
});
return { driver, session, stream: collector(session) };
}
function reduce(events: readonly PrpEvent[]): SessionSnapshot {
return events.reduce(
applyPrpEvent,
createSessionSnapshotFromMetadata({
fixtureName: "phase4b-demo",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "package-local-demo",
issueId: "phase4b-demo",
runId: events[0]?.runId ?? "run",
environmentLeaseId: "package-local",
runnerInstanceId: "phase4b-demo-server",
normalizedSessionId: events[0]?.normalizedSessionId ?? "session",
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: "phase4b-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
},
}),
);
}
describe("Phase 4b demo manifests", () => {
it("gives every manifest a unique id and at least one expected observation", () => {
const ids = PHASE4B_DEMO_MANIFESTS.map((manifest) => manifest.id);
expect(new Set(ids).size).toBe(ids.length);
for (const manifest of PHASE4B_DEMO_MANIFESTS) {
expect(manifest.expectedObservations.length).toBeGreaterThan(0);
expect(manifest.prompt.length).toBeGreaterThan(0);
}
});
it("publishes a catalogue that carries no beat scripts to the browser", () => {
const catalogue = phase4bDemoManifestCatalogue();
expect(catalogue).toHaveLength(PHASE4B_DEMO_MANIFESTS.length);
expect(JSON.stringify(catalogue)).not.toContain("beats");
});
it("rejects an unknown manifest id", () => {
expect(() => findPhase4bDemoManifest("nope")).toThrow(/unknown Phase 4b demo manifest/);
});
});
describe("Phase 4b scripted driver", () => {
it("emits schema-valid canonical events for a completed turn", async () => {
const { session, stream } = await openSession("completion");
await session.startTurn({ message: { role: "user", text: "Summarize." } });
await stream.waitFor("turn.completed");
for (const event of stream.events) {
expect(validatePrpEvent(event).ok).toBe(true);
}
const snapshot = reduce(stream.events);
expect(snapshot.integrity).toBe("complete");
expect(snapshot.turnState).toBe("completed");
expect(snapshot.proposedResult?.reportedWorkDisposition).toBe("done");
expect(snapshot.items.map((item) => item.kind)).toEqual([
"reasoning",
"commandExecution",
"agentMessage",
]);
const submitted = stream.events.find((event) => event.eventType === "turn.submitted");
expect((submitted?.payload as { text?: string }).text).toBe("Summarize.");
await session.close({ reason: "test" });
});
it("acknowledges same-turn steering and rejects a stale turn", async () => {
const { session, stream } = await openSession("steering", 20);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
await stream.waitFor("turn.started");
await session.steer?.({ turnId, message: { role: "user", text: "Be brief." } });
await tick();
const ack = stream.events.find(
(event) => (event.payload as { kind?: string }).kind === "steering_acknowledgement",
);
expect((ack?.payload as { text?: string }).text).toBe("Be brief.");
await expect(
session.steer?.({ turnId: "turn-999", message: { role: "user", text: "Stale." } }),
).rejects.toMatchObject({ code: "stale_turn" });
expect(
stream.events.some(
(event) => (event.payload as { code?: string }).code === "stale_turn_rejected",
),
).toBe(true);
await stream.waitFor("turn.completed");
await expect(
session.steer?.({ turnId, message: { role: "user", text: "Too late." } }),
).rejects.toMatchObject({ code: "already_terminal" });
await session.close({ reason: "test" });
});
it("cancels a turn interrupted before the provider accepts it", async () => {
const { session, stream } = await openSession("interrupt-before-start");
const { turnId } = await session.startTurn({ message: { role: "user", text: "Slow." } });
await session.interrupt?.({ turnId, reason: "browser_operator" });
await stream.waitFor("turn.cancelled");
expect(stream.events.some((event) => event.eventType === "turn.started")).toBe(false);
expect(reduce(stream.events).turnState).toBe("cancelled");
await session.close({ reason: "test" });
});
it("keeps partial text when a streaming item is interrupted", async () => {
const { session, stream } = await openSession("interrupt-during-generation", 40);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Long." } });
await stream.waitFor("item.delta");
await session.interrupt?.({ turnId });
await stream.waitFor("turn.interrupted");
const snapshot = reduce(stream.events);
expect(snapshot.turnState).toBe("interrupted");
const item = snapshot.items.find((candidate) => candidate.itemId === "assistant-1");
expect(item?.text.length).toBeGreaterThan(0);
expect(item?.text).not.toContain("And this final one.");
await session.close({ reason: "test" });
});
it("keeps an interrupted tool item without fabricating a result", async () => {
const { session, stream } = await openSession("interrupt-during-tool", 5);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Tool." } });
await stream.waitFor("item.started");
await session.interrupt?.({ turnId });
const interrupted = await stream.waitFor("turn.interrupted");
expect(interrupted.eventType).toBe("turn.interrupted");
const completion = stream.events.find(
(event) => event.eventType === "item.completed" && event.itemId === "tool-1",
);
expect((completion?.payload as { status?: string }).status).toBe("interrupted");
expect(completion?.payload).not.toHaveProperty("output");
await session.close({ reason: "test" });
});
it("resolves each runtime request exactly once", async () => {
const { session, stream } = await openSession("approvals");
const { turnId } = await session.startTurn({ message: { role: "user", text: "Approve." } });
const created = await stream.waitFor("runtime_request.created");
const request = (created.payload as { request: { requestId: string; actions: string[] } }).request;
expect(request.actions).toContain("accept_for_session");
await session.resolveRuntimeRequest?.({
requestId: request.requestId,
turnId,
resolution: { action: "accept" },
});
await expect(
session.resolveRuntimeRequest?.({
requestId: request.requestId,
turnId,
resolution: { action: "decline" },
}),
).rejects.toMatchObject({ code: "already_terminal" });
await tick();
const resolved = stream.events.find(
(event) => event.eventType === "runtime_request.resolved",
);
expect((resolved?.payload as { action?: string }).action).toBe("accept");
await session.close({ reason: "test" });
});
it("never puts a submitted answer into the resolution acknowledgement", async () => {
const { session, stream } = await openSession("user-input");
const { turnId } = await session.startTurn({ message: { role: "user", text: "Ask." } });
const created = await stream.waitFor("runtime_request.created");
const request = (created.payload as { request: { requestId: string } }).request;
await session.resolveRuntimeRequest?.({
requestId: request.requestId,
turnId,
resolution: { action: "submit", answers: { environment: { answers: ["staging"] } } },
});
await tick();
const resolved = stream.events.find((event) => event.eventType === "runtime_request.resolved");
expect(JSON.stringify(resolved?.payload)).not.toContain("staging");
await session.close({ reason: "test" });
});
it("reports the exact diagnostic when goals are unsupported", async () => {
const { session } = await openSession("goals-unsupported");
await expect(session.goal?.({ action: "get" })).rejects.toThrow(
/does not advertise the goals capability/,
);
await session.close({ reason: "test" });
});
it("moves a goal through set, pause, resume, and clear", async () => {
const { session, stream } = await openSession("goals");
expect(await session.goal?.({ action: "get" })).toBeNull();
expect((await session.goal?.({ action: "set", objective: "Ship it" }))?.status).toBe("active");
expect((await session.goal?.({ action: "pause" }))?.status).toBe("paused");
expect((await session.goal?.({ action: "resume" }))?.status).toBe("active");
expect(await session.goal?.({ action: "clear" })).toBeNull();
await tick();
const goalEvents = stream.events.filter((event) =>
["goal", "goal_cleared"].includes((event.payload as { kind?: string }).kind ?? ""),
);
expect(goalEvents).toHaveLength(4);
await session.close({ reason: "test" });
});
it("tracks child threads and keeps their terminal states", async () => {
const { session, stream } = await openSession("subagents");
await session.startTurn({ message: { role: "user", text: "Delegate." } });
await stream.waitFor("turn.completed");
const lineage = session.lineage?.() ?? [];
expect(lineage.map((entry) => entry.threadId)).toEqual([
"thread-session-subagents",
"thread-child-a",
"thread-child-b",
]);
expect(lineage[1]?.status).toBe("completed");
expect(lineage[2]?.status).toBe("failed");
await session.close({ reason: "test" });
});
it("reports a failed turn and a blocked disposition", async () => {
const { session, stream } = await openSession("failure");
await session.startTurn({ message: { role: "user", text: "Fail." } });
await stream.waitFor("turn.failed");
const snapshot = reduce(stream.events);
expect(snapshot.turnState).toBe("failed");
expect(snapshot.items[0]?.status).toBe("failed");
expect(snapshot.proposedResult?.reportedWorkDisposition).toBe("blocked");
await session.close({ reason: "test" });
});
it("resumes the same session identity and continues the source sequence", async () => {
const { driver, session, stream } = await openSession("completion");
await session.startTurn({ message: { role: "user", text: "First." } });
await stream.waitFor("turn.completed");
const persisted = await session.snapshot();
await session.close({ reason: "browser_reconnect" });
const recovery = await driver.recoverSession(persisted);
expect(recovery.recovered).toBe(true);
const resumed = recovery.session!;
const resumedStream = collector(resumed);
await resumedStream.waitFor("session.resumed");
const combined = [...stream.events, ...resumedStream.events];
const snapshot = reduce(combined);
expect(snapshot.integrity).toBe("complete");
expect(snapshot.identity.normalizedSessionId).toBe("session-completion");
expect(resumed.ids().driverSessionId).toBe(session.ids().driverSessionId);
await resumed.close({ reason: "test" });
});
});
@@ -0,0 +1,756 @@
import {
HarnessCapabilityUnavailableError,
HarnessOperationAlreadyTerminalError,
HarnessStaleTurnError,
type HarnessDriver,
type HarnessDriverDescriptor,
type HarnessGoalOperation,
type HarnessRuntimeRequest,
type HarnessRuntimeRequestResolution,
type HarnessSession,
type HarnessSessionRecoveryResult,
type HarnessThreadGoal,
type HarnessThreadLineageEntry,
type OpenHarnessSessionInput,
type PersistedHarnessSession,
} from "../contracts/harness-driver.js";
import type { NativeUserMessage } from "../contracts/types.js";
import type {
PrpEvent,
PrpStructuredRunResult,
} from "../protocol/phase1-contract.js";
import {
findPhase4bDemoManifest,
type Phase4bDemoBeat,
type Phase4bDemoManifest,
} from "./phase4b-demo-manifests.js";
const DRIVER_KIND = "phase4b-scripted-demo";
/**
* `setTimeout` is typed as a DOM timer when this module is compiled under the
* browser tsconfig for the transcript-model tests, so `unref` is guarded
* rather than assumed.
*/
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
const handle = timer as unknown as { unref?: () => void };
if (typeof handle.unref === "function") handle.unref();
}
const HOLD_TIMEOUT_MS = 45_000;
export interface Phase4bScriptedDriverOptions {
manifestId?: string;
manifest?: Phase4bDemoManifest;
/** Milliseconds between streamed chunks. Tests set this to zero. */
chunkDelayMs?: number;
now?: () => Date;
}
class EventQueue implements AsyncIterable<PrpEvent> {
#values: PrpEvent[] = [];
#waiters: Array<(result: IteratorResult<PrpEvent>) => void> = [];
#closed = false;
push(value: PrpEvent): void {
if (this.#closed) return;
const waiter = this.#waiters.shift();
if (waiter !== undefined) waiter({ value, done: false });
else this.#values.push(value);
}
close(): void {
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) {
waiter({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<PrpEvent> {
return {
next: async () => {
const value = this.#values.shift();
if (value !== undefined) return { value, done: false };
if (this.#closed) return { value: undefined, done: true };
return new Promise<IteratorResult<PrpEvent>>((resolve) => {
this.#waiters.push(resolve);
});
},
};
}
}
/** A promise that a later event can settle early. */
class Gate {
#resolve: (() => void) | null = null;
#settled = false;
readonly promise: Promise<void>;
constructor() {
this.promise = new Promise<void>((resolve) => {
this.#resolve = resolve;
});
}
open(): void {
if (this.#settled) return;
this.#settled = true;
this.#resolve?.();
}
get settled(): boolean {
return this.#settled;
}
}
interface PendingRequestEntry {
request: HarnessRuntimeRequest;
gate: Gate;
outcome: "pending" | "resolved" | "expired" | "cancelled";
action: string | null;
timer: ReturnType<typeof setTimeout> | null;
}
interface ScriptedSessionInput extends OpenHarnessSessionInput {
manifest: Phase4bDemoManifest;
chunkDelayMs: number;
now: () => Date;
resumed: boolean;
restoredSequence: number;
restoredGoal: HarnessThreadGoal | null;
restoredLineage: HarnessThreadLineageEntry[];
turnCounter: number;
}
class Phase4bScriptedSession implements HarnessSession {
readonly #queue = new EventQueue();
readonly #input: ScriptedSessionInput;
readonly #pending = new Map<string, PendingRequestEntry>();
readonly #lineage: HarnessThreadLineageEntry[];
readonly #terminalTurns: Array<{ turnId: string; fingerprint: string }> = [];
#sequence: number;
#turnCounter: number;
#activeTurnId: string | null = null;
#turnStartPending = false;
#interruptGate: Gate | null = null;
#goal: HarnessThreadGoal | null;
#semanticResult: PrpStructuredRunResult | null = null;
#closed = false;
#turnRunner: Promise<void> = Promise.resolve();
constructor(input: ScriptedSessionInput) {
this.#input = input;
this.#sequence = input.restoredSequence;
this.#turnCounter = input.turnCounter;
this.#goal = input.restoredGoal;
this.#lineage = [
{
threadId: this.#threadId,
providerSessionId: input.normalizedSessionId,
parentThreadId: null,
depth: 0,
nickname: "root",
role: "primary",
status: "running",
},
...input.restoredLineage.filter((entry) => entry.depth > 0),
];
this.#emit(input.resumed ? "session.resumed" : "session.started", {
driverSessionId: this.#threadId,
providerSessionId: input.normalizedSessionId,
manifest: input.manifest.id,
workingDirectory: input.workingDirectory,
providerAuthentication: "server-side",
});
}
get #threadId(): string {
return `thread-${this.#input.normalizedSessionId}`;
}
ids(): { driverSessionId: string; providerSessionId?: string | null; displayId?: string | null } {
return {
driverSessionId: this.#threadId,
providerSessionId: this.#input.normalizedSessionId,
displayId: this.#input.manifest.name,
};
}
events(): AsyncIterable<PrpEvent> {
return this.#queue;
}
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
if (this.#closed) throw new Error("session is closed");
if (this.#activeTurnId !== null || this.#turnStartPending) {
throw new Error("a turn is already active");
}
this.#turnCounter += 1;
const turnId = `turn-${this.#turnCounter}`;
const beats = this.#turnCounter === 1
? this.#input.manifest.beats
: (this.#input.manifest.followUpBeats ?? this.#input.manifest.beats);
this.#turnStartPending = true;
this.#interruptGate = new Gate();
this.#emit("turn.submitted", {
turnId,
text: input.message.text,
envelopeSchema: "paperclip.task_envelope.v1",
}, { turnId });
this.#turnRunner = this.#runTurn(turnId, beats).catch((error) => {
this.#emit("harness.diagnostic", { message: `demo turn failed: ${String(error)}` });
});
return { turnId };
}
async steer(input: { turnId: string; message: NativeUserMessage }): Promise<void> {
if (!this.#input.manifest.capabilities.steering) {
throw new HarnessCapabilityUnavailableError(
"steering",
"this demo manifest does not advertise steering",
);
}
if (this.#activeTurnId === null && !this.#turnStartPending) {
throw new HarnessOperationAlreadyTerminalError("steer");
}
if (input.turnId !== this.#activeTurnId) {
this.#emit("harness.diagnostic", {
code: "stale_turn_rejected",
message: `steering rejected: turn ${input.turnId} is not the active turn`,
});
throw new HarnessStaleTurnError(input.turnId);
}
this.#emit("item.completed", {
kind: "steering_acknowledgement",
status: "acknowledged",
text: input.message.text,
}, { turnId: input.turnId, itemId: `steer-${input.turnId}-${this.#sequence + 1}` });
}
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
if (!this.#input.manifest.capabilities.interruption) {
throw new HarnessCapabilityUnavailableError(
"interruption",
"this demo manifest does not advertise interruption",
);
}
if (this.#activeTurnId === null && !this.#turnStartPending) {
throw new HarnessOperationAlreadyTerminalError("interrupt");
}
if (
input.turnId !== undefined &&
this.#activeTurnId !== null &&
input.turnId !== this.#activeTurnId
) {
throw new HarnessStaleTurnError(input.turnId);
}
this.#emit("item.completed", {
kind: "interrupt_acknowledgement",
status: "acknowledged",
text: input.reason ?? "browser_operator",
}, {
...(this.#activeTurnId === null ? {} : { turnId: this.#activeTurnId }),
itemId: `interrupt-${this.#sequence + 1}`,
});
this.#interruptGate?.open();
}
pendingRuntimeRequests(): HarnessRuntimeRequest[] {
return [...this.#pending.values()]
.filter((entry) => entry.outcome === "pending")
.map((entry) => structuredClone(entry.request));
}
async resolveRuntimeRequest(input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
const entry = this.#pending.get(input.requestId);
if (entry === undefined) {
throw new Error(`unknown runtime request ${input.requestId}`);
}
if (entry.outcome !== "pending") {
throw new HarnessOperationAlreadyTerminalError(`resolve ${input.requestId}`);
}
if (input.turnId.length > 0 && input.turnId !== entry.request.turnId) {
throw new HarnessStaleTurnError(input.turnId);
}
entry.outcome = "resolved";
entry.action = input.resolution.action;
this.#settleRequest(entry, "runtime_request.resolved");
}
async goal(input: HarnessGoalOperation): Promise<HarnessThreadGoal | null> {
if (this.#input.manifest.capabilities.goals !== true) {
throw new HarnessCapabilityUnavailableError(
"goal",
this.#input.manifest.capabilities.unsupported?.[0] ??
"this app-server does not advertise the goals capability",
);
}
const at = this.#input.now().getTime();
if (input.action === "get") return this.#goal === null ? null : structuredClone(this.#goal);
if (input.action === "set") {
this.#goal = {
threadId: this.#threadId,
objective: input.objective,
status: "active",
tokenBudget: input.tokenBudget ?? null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: this.#goal?.createdAt ?? at,
updatedAt: at,
};
} else if (input.action === "clear") {
this.#goal = null;
} else if (this.#goal === null) {
throw new HarnessCapabilityUnavailableError(
`goal ${input.action}`,
"no goal is set on this thread",
);
} else {
this.#goal = {
...this.#goal,
status: input.action === "pause" ? "paused" : "active",
updatedAt: at,
};
}
this.#emit("item.completed", {
kind: this.#goal === null ? "goal_cleared" : "goal",
text: this.#goal === null
? "Goal cleared."
: `Goal ${input.action === "set" ? "set" : input.action}: ${this.#goal.objective}`,
operation: input.action,
...(this.#goal === null ? {} : { goal: structuredClone(this.#goal) }),
}, { itemId: `goal-${this.#sequence + 1}` });
return this.#goal === null ? null : structuredClone(this.#goal);
}
lineage(): HarnessThreadLineageEntry[] {
return structuredClone(this.#lineage);
}
async usage(): Promise<Record<string, unknown> | null> {
return { inputTokens: this.#sequence * 12, outputTokens: this.#sequence * 7 };
}
async snapshot(): Promise<PersistedHarnessSession> {
return {
driverKind: DRIVER_KIND,
driverSessionId: this.#threadId,
providerSessionId: this.#input.normalizedSessionId,
runId: this.#input.runId,
normalizedSessionId: this.#input.normalizedSessionId,
activeTurnId: this.#activeTurnId,
...(this.#semanticResult === null
? {}
: {
semanticResult: {
result: structuredClone(this.#semanticResult),
fingerprint: `${this.#input.manifest.id}:${this.#turnCounter}`,
turnId: `turn-${this.#turnCounter}`,
},
}),
terminalTurns: structuredClone(this.#terminalTurns),
pendingRuntimeRequests: this.pendingRuntimeRequests(),
goal: this.#goal === null ? null : structuredClone(this.#goal),
lineage: this.lineage(),
lastSourceSequence: this.#sequence,
};
}
async close(input: { reason: string; force?: boolean }): Promise<void> {
if (this.#closed) return;
this.#closed = true;
for (const entry of this.#pending.values()) {
if (entry.outcome !== "pending") continue;
entry.outcome = "cancelled";
this.#settleRequest(entry, "runtime_request.cancelled");
}
this.#interruptGate?.open();
await this.#turnRunner.catch(() => undefined);
this.#emit("session.closed", { reason: input.reason });
this.#queue.close();
}
/** Test and reconnect seam: the demo server uses it to keep the cursor. */
get sequence(): number {
return this.#sequence;
}
get turnCounter(): number {
return this.#turnCounter;
}
#emit(
eventType: PrpEvent["eventType"],
payload: Record<string, unknown>,
binding: { turnId?: string; itemId?: string } = {},
): void {
this.#sequence += 1;
this.#queue.push({
schema: "paperclip.prp.event.v1",
sourceEventId: `${this.#input.runId}:${this.#sequence}`,
sourceSeq: this.#sequence,
sourceInstanceId: `${DRIVER_KIND}:${this.#input.normalizedSessionId}`,
sourceKind: "runner",
runId: this.#input.runId,
normalizedSessionId: this.#input.normalizedSessionId,
...(binding.turnId === undefined ? {} : { turnId: binding.turnId }),
...(binding.itemId === undefined ? {} : { itemId: binding.itemId }),
eventType,
schemaVersion: 1,
priority: 1,
emittedAt: this.#input.now().toISOString(),
payload,
} as PrpEvent);
}
#settleRequest(entry: PendingRequestEntry, eventType: PrpEvent["eventType"]): void {
if (entry.timer !== null) {
clearTimeout(entry.timer);
entry.timer = null;
}
this.#emit(eventType, {
requestId: entry.request.requestId,
requestKind: entry.request.requestKind,
...(entry.action === null ? {} : { action: entry.action }),
}, { turnId: entry.request.turnId, itemId: entry.request.itemId });
entry.gate.open();
}
async #sleep(ms: number): Promise<"elapsed" | "interrupted"> {
if (ms <= 0) return this.#interruptGate?.settled === true ? "interrupted" : "elapsed";
const gate = this.#interruptGate;
return await new Promise<"elapsed" | "interrupted">((resolve) => {
const timer = setTimeout(() => resolve("elapsed"), ms);
unrefTimer(timer);
void gate?.promise.then(() => {
clearTimeout(timer);
resolve("interrupted");
});
});
}
get #interrupted(): boolean {
return this.#interruptGate?.settled === true;
}
async #runTurn(turnId: string, beats: readonly Phase4bDemoBeat[]): Promise<void> {
const manifest = this.#input.manifest;
if (manifest.startDelayMs > 0) {
const outcome = await this.#sleep(manifest.startDelayMs);
if (outcome === "interrupted" || this.#closed) {
this.#turnStartPending = false;
this.#recordTerminalTurn(turnId, "cancelled");
this.#emit("turn.cancelled", {
turnId,
reason: "interrupted before the provider accepted the turn",
}, { turnId });
return;
}
}
if (this.#closed) {
this.#turnStartPending = false;
return;
}
this.#turnStartPending = false;
this.#activeTurnId = turnId;
this.#emit("turn.accepted", { turnId }, { turnId });
this.#emit("turn.started", { turnId, status: "inProgress" }, { turnId });
this.#emit("runtime.phase.changed", { phase: "harness_running" });
let failed = false;
for (const beat of beats) {
if (this.#interrupted || this.#closed) break;
failed = (await this.#runBeat(turnId, beat)) || failed;
}
this.#activeTurnId = null;
if (this.#interrupted) {
this.#recordTerminalTurn(turnId, "interrupted");
this.#emit("turn.interrupted", { turnId, reason: "browser_operator" }, { turnId });
return;
}
if (this.#closed) return;
const disposition = failed || manifest.disposition === "failed" ? "failed" : "completed";
this.#recordTerminalTurn(turnId, disposition);
this.#emitResult(turnId, disposition === "failed");
this.#emit(disposition === "failed" ? "turn.failed" : "turn.completed", {
turnId,
...(disposition === "failed"
? { message: "The demo manifest ends in a failed turn." }
: {}),
}, { turnId });
}
#recordTerminalTurn(turnId: string, state: string): void {
this.#terminalTurns.push({ turnId, fingerprint: `${state}:${turnId}` });
}
async #runBeat(turnId: string, beat: Phase4bDemoBeat): Promise<boolean> {
const delay = this.#input.chunkDelayMs;
if (beat.kind === "reasoning" || beat.kind === "assistant") {
const itemKind = beat.kind === "reasoning" ? "reasoning" : "agentMessage";
this.#emit("item.started", { kind: itemKind, text: "" }, { turnId, itemId: beat.itemId });
let text = "";
for (const chunk of beat.chunks) {
if ((await this.#sleep(delay)) === "interrupted" || this.#closed) {
this.#emit("item.completed", {
kind: itemKind,
text,
status: "interrupted",
interrupted: true,
}, { turnId, itemId: beat.itemId });
return false;
}
text += chunk;
this.#emit("item.delta", { kind: itemKind, text: chunk }, { turnId, itemId: beat.itemId });
}
this.#emit("item.completed", { kind: itemKind, text, status: "completed" }, {
turnId,
itemId: beat.itemId,
});
return false;
}
if (beat.kind === "tool") {
this.#emit("item.started", {
kind: "commandExecution",
text: `${beat.name}: ${beat.input}`,
toolName: beat.name,
input: beat.input,
status: "running",
}, { turnId, itemId: beat.itemId });
const wait = beat.hold === true ? HOLD_TIMEOUT_MS : Math.max(delay, 0) * 2;
if ((await this.#sleep(wait)) === "interrupted" || this.#closed) {
this.#emit("item.completed", {
kind: "commandExecution",
text: `${beat.name}: ${beat.input}`,
toolName: beat.name,
input: beat.input,
status: "interrupted",
interrupted: true,
}, { turnId, itemId: beat.itemId });
return false;
}
if (beat.failure !== undefined) {
this.#emit("item.failed", {
kind: "commandExecution",
text: `${beat.name}: ${beat.input}`,
toolName: beat.name,
input: beat.input,
message: beat.failure,
status: "failed",
}, { turnId, itemId: beat.itemId });
return true;
}
this.#emit("item.completed", {
kind: "commandExecution",
text: `${beat.name}: ${beat.input}`,
toolName: beat.name,
input: beat.input,
output: beat.output ?? "",
status: "completed",
}, { turnId, itemId: beat.itemId });
return false;
}
if (beat.kind === "request") {
const request: HarnessRuntimeRequest = {
requestId: beat.requestId,
requestKind: beat.requestKind,
method: beat.method,
turnId,
itemId: beat.itemId,
status: "pending",
prompt: beat.prompt,
details: structuredClone(beat.details),
};
const entry: PendingRequestEntry = {
request,
gate: new Gate(),
outcome: "pending",
action: null,
timer: null,
};
this.#pending.set(request.requestId, entry);
if (beat.expiresAfterMs !== undefined) {
entry.timer = setTimeout(() => {
if (entry.outcome !== "pending") return;
entry.outcome = "expired";
this.#settleRequest(entry, "runtime_request.expired");
}, beat.expiresAfterMs);
unrefTimer(entry.timer);
}
this.#emit("runtime_request.created", {
request: {
...request,
type: request.method,
actions: [...beat.actions],
},
}, { turnId, itemId: beat.itemId });
await Promise.race([
entry.gate.promise,
this.#interruptGate?.promise ?? new Promise<void>(() => undefined),
]);
if (entry.outcome === "pending") {
entry.outcome = "cancelled";
this.#settleRequest(entry, "runtime_request.cancelled");
return false;
}
return false;
}
if (beat.kind === "child") {
const child: HarnessThreadLineageEntry = {
threadId: beat.threadId,
providerSessionId: `${beat.threadId}-provider`,
parentThreadId: this.#threadId,
depth: 1,
nickname: beat.nickname,
role: beat.role,
status: "starting",
};
this.#lineage.push(child);
for (const status of beat.statuses) {
if ((await this.#sleep(delay)) === "interrupted" || this.#closed) return false;
child.status = status;
this.#emit("item.completed", {
kind: "thread_lineage",
text: `Child thread ${beat.nickname} is ${status}.`,
thread: structuredClone(child),
}, { turnId, itemId: `${beat.threadId}-${status}` });
}
return false;
}
if (beat.kind === "diagnostic") {
this.#emit("harness.diagnostic", { message: beat.message });
return true;
}
this.#emit("harness.diagnostic", { message: beat.note });
await this.#sleep(HOLD_TIMEOUT_MS);
return false;
}
#emitResult(turnId: string, failed: boolean): void {
const manifest = this.#input.manifest;
const result: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: failed ? "blocked" : "done",
summary: failed
? `The ${manifest.name} demo ended in a failed turn on purpose.`
: `The ${manifest.name} demo turn completed.`,
completionClaim: {
contractRevision: "phase4b-demo-v1",
objectiveSatisfied: !failed,
criteria: [
{
criterionId: "objective",
status: failed ? "not_satisfied" : "satisfied",
evidenceRefs: [`manifest:${manifest.id}`],
},
],
remainingWork: failed
? [
{
description: "Investigate the scripted command failure.",
blocksCompletion: true,
},
]
: [],
},
evidence: [{ ref: `manifest:${manifest.id}` }],
verification: [
{
commandOrCheck: `phase4b demo manifest ${manifest.id}`,
status: failed ? "failed" : "passed",
},
],
attentionRequests: [],
artifacts: [],
};
this.#semanticResult = result;
this.#emit("run.result.proposed", result as unknown as Record<string, unknown>, { turnId });
}
}
/**
* A package-local `HarnessDriver` that replays a deterministic demo manifest.
* It exists so the browser tracer can reach every protocol state without a
* provider account, using exactly the routes the real Codex boundary uses.
*/
export class Phase4bScriptedDriver implements HarnessDriver {
readonly #manifest: Phase4bDemoManifest;
readonly #chunkDelayMs: number;
readonly #now: () => Date;
#session: Phase4bScriptedSession | null = null;
constructor(options: Phase4bScriptedDriverOptions = {}) {
this.#manifest = options.manifest ??
findPhase4bDemoManifest(options.manifestId ?? "completion");
this.#chunkDelayMs = options.chunkDelayMs ?? 45;
this.#now = options.now ?? (() => new Date());
}
get manifest(): Phase4bDemoManifest {
return this.#manifest;
}
async descriptor(): Promise<HarnessDriverDescriptor> {
return {
kind: DRIVER_KIND,
displayName: `Phase 4b demo — ${this.#manifest.name}`,
version: "1",
protocolVersion: "phase4b-demo-v1",
capabilities: structuredClone(this.#manifest.capabilities),
};
}
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
const session = new Phase4bScriptedSession({
...input,
manifest: this.#manifest,
chunkDelayMs: this.#chunkDelayMs,
now: this.#now,
resumed: false,
restoredSequence: 0,
restoredGoal: null,
restoredLineage: [],
turnCounter: 0,
});
this.#session = session;
return session;
}
async recoverSession(
snapshot: PersistedHarnessSession,
): Promise<HarnessSessionRecoveryResult> {
if (snapshot.driverKind !== DRIVER_KIND) {
return { recovered: false, reason: `cannot resume driver kind ${snapshot.driverKind}` };
}
const runId = snapshot.runId;
const normalizedSessionId = snapshot.normalizedSessionId;
if (runId === undefined || normalizedSessionId === undefined) {
return { recovered: false, reason: "snapshot is missing its runner identity" };
}
const session = new Phase4bScriptedSession({
runId,
normalizedSessionId,
workingDirectory: "<server-owned>",
manifest: this.#manifest,
chunkDelayMs: this.#chunkDelayMs,
now: this.#now,
resumed: true,
restoredSequence: snapshot.lastSourceSequence ?? 0,
restoredGoal: snapshot.goal ?? null,
restoredLineage: snapshot.lineage ?? [],
turnCounter: this.#session?.turnCounter ?? 0,
});
this.#session = session;
return { recovered: true, session };
}
}
@@ -0,0 +1,4 @@
// Negative fixture: adapting AI Elements source must not pull the AI SDK.
import { useChat } from "@ai-sdk/react";
export const chat = useChat;
+2 -1
View File
@@ -5,12 +5,13 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { phase2BrowserServerPlugin } from "./scripts/phase2-browser-server.mjs";
import { phase4bBrowserServerPlugin } from "./scripts/phase4b-browser-server.mjs";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "devtools/browser"),
plugins: [react(), phase2BrowserServerPlugin()],
plugins: [react(), phase2BrowserServerPlugin(), phase4bBrowserServerPlugin()],
server: {
host: "127.0.0.1",
},
+5 -1
View File
@@ -3,6 +3,10 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts", "scripts/phase2-browser-server.test.mjs"],
include: [
"src/**/*.test.ts",
"devtools/browser/src/**/*.test.ts",
"scripts/phase2-browser-server.test.mjs",
],
},
});