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>
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||