diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx index 9d202fc5260..6044c2120d9 100644 --- a/packages/paperclip-runner/devtools/browser/src/App.tsx +++ b/packages/paperclip-runner/devtools/browser/src/App.tsx @@ -1,7 +1,19 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; -import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay"; +import type { PrpEvent, PrpRequest } from "../../../src/protocol/phase1-contract"; import type { SessionSnapshot } from "../../../src/reducer/session-reducer"; +import type { + Phase2RunMetadata, + Phase2RunTrace, + Phase2Scenario, +} from "../../../src/contracts/phase2"; +import { + applyPhase2LiveEvent, + createPhase2LiveSnapshot, + phase2SnapshotsMatch, + replayPhase2Events, +} from "../../../src/tracer/phase2-live"; +import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay"; import { Badge } from "./components/ui/badge"; import { Button } from "./components/ui/button"; import { @@ -28,12 +40,29 @@ const fixtureOrder = [ "unsupported-required-version", ]; +const liveScenarios: Array<{ key: Phase2Scenario; label: string }> = [ + { key: "happy-path", label: "Happy path" }, + { key: "permission-input", label: "Permission and input" }, + { key: "interrupted", label: "Interruption" }, + { key: "error", label: "Scripted error" }, + { key: "duplicate-terminal", label: "Duplicate terminal guard" }, +]; + interface BrowserFixture { key: string; label: string; source: string; } +type BrowserMode = "live" | "replay"; +type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error"; + +type Phase2StreamRecord = + | { kind: "event"; event: unknown } + | { kind: "diagnostic"; message: string } + | { kind: "trace"; trace: Phase2RunTrace } + | { kind: "error"; message: string }; + const browserFixtures = Object.entries(fixtureFiles) .map(([path, source]): BrowserFixture => { const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path; @@ -55,12 +84,18 @@ function terminalTone(snapshot: SessionSnapshot) { return "neutral" as const; } -function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) { +function SnapshotSummary({ + snapshot, + label = "Session snapshot", +}: { + snapshot: SessionSnapshot; + label?: string; +}) { return ( <>
-

Final session snapshot

+

{label}

{snapshot.fixtureName}

@@ -132,7 +167,297 @@ function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) { ); } -export function App() { +function pendingRuntimeRequest(events: readonly PrpEvent[]): PrpRequest | null { + const resolved = new Set( + events + .filter((event) => event.eventType === "runtime_request.resolved") + .map((event) => (event.payload as Record).requestId) + .filter((value): value is string => typeof value === "string"), + ); + for (const event of events.toReversed()) { + if (event.eventType !== "runtime_request.created") { + continue; + } + const request = (event.payload as Record).request; + if ( + typeof request === "object" && + request !== null && + "requestId" in request && + typeof request.requestId === "string" && + !resolved.has(request.requestId) + ) { + return request as PrpRequest; + } + } + return null; +} + +function LiveRunner() { + const [scenario, setScenario] = useState("happy-path"); + const [status, setStatus] = useState("idle"); + const [runId, setRunId] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [diagnostic, setDiagnostic] = useState(null); + const [input, setInput] = useState("phase-02-live-trace"); + const [trace, setTrace] = useState(null); + const [parity, setParity] = useState(null); + const snapshotRef = useRef(null); + const eventsRef = useRef([]); + + const request = useMemo(() => pendingRuntimeRequest(events), [events]); + + async function startRun() { + setStatus("starting"); + setError(null); + setDiagnostic(null); + setTrace(null); + setParity(null); + setRunId(null); + setEvents([]); + eventsRef.current = []; + setSnapshot(null); + snapshotRef.current = null; + + try { + const startResponse = await fetch("/api/phase2/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scenario }), + }); + const started = (await startResponse.json()) as { + id?: string; + metadata?: Phase2RunMetadata; + error?: string; + }; + if (!startResponse.ok || started.id === undefined || started.metadata === undefined) { + throw new Error(started.error ?? "The local runner did not start."); + } + setRunId(started.id); + const initial = createPhase2LiveSnapshot(started.metadata); + setSnapshot(initial); + snapshotRef.current = initial; + setStatus("running"); + + const streamResponse = await fetch(`/api/phase2/runs/${started.id}/events`); + if (!streamResponse.ok || streamResponse.body === null) { + throw new Error("The live event stream did not open."); + } + const reader = streamResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + while (true) { + const part = await reader.read(); + buffered += decoder.decode(part.value, { stream: !part.done }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim().length > 0) { + handleRecord(JSON.parse(line) as Phase2StreamRecord, started.metadata); + } + } + if (part.done) { + if (buffered.trim().length > 0) { + handleRecord(JSON.parse(buffered) as Phase2StreamRecord, started.metadata); + } + break; + } + } + } catch (cause) { + setStatus("error"); + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + function handleRecord(record: Phase2StreamRecord, metadata: Phase2RunMetadata) { + if (record.kind === "event") { + const current = snapshotRef.current; + if (current === null) { + return; + } + const applied = applyPhase2LiveEvent(current, record.event); + if (!applied.ok) { + setStatus("error"); + setError(applied.issues.join("; ")); + return; + } + snapshotRef.current = applied.snapshot; + setSnapshot(applied.snapshot); + eventsRef.current = [...eventsRef.current, applied.event]; + setEvents(eventsRef.current); + return; + } + if (record.kind === "diagnostic") { + setDiagnostic(record.message); + return; + } + if (record.kind === "error") { + setStatus("error"); + setError(record.message); + return; + } + setTrace(record.trace); + const replay = replayPhase2Events(metadata, record.trace.events); + setParity( + snapshotRef.current === null + ? false + : phase2SnapshotsMatch(snapshotRef.current, replay), + ); + setStatus("terminal"); + } + + async function postAction(action: "interrupt" | "resolve", body?: unknown) { + if (runId === null) { + return; + } + const response = await fetch(`/api/phase2/runs/${runId}/${action}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!response.ok) { + const failure = (await response.json()) as { error?: string }; + setError(failure.error ?? `The ${action} command failed.`); + setStatus("error"); + } + } + + return ( +
+ + + Local runner controls + + Start one Rust runner and one scripted fake harness. No network or model is used. + + + + + +
+ + +
+ +
+ Runner status + + {status} + +
+ + {request?.requestKind === "runtime" ? ( +
+
+

{request.type} request

+

{request.prompt}

+
+ {request.type === "permission" ? ( + + ) : ( + <> + + setInput(event.target.value)} + /> + + + )} +
+ ) : null} + + {trace?.harnessProcessExit ? ( +
+
+
Harness process exit
+
{trace.harnessProcessExit.exitCode ?? "signal"}
+
+
+
Semantic result
+
{trace.result?.reportedWorkDisposition ?? "missing"}
+
+
+ ) : null} + + {parity !== null ? ( +
+ Live and replay reducer output + + {parity ? "Match" : "Mismatch"} + +
+ ) : null} + {diagnostic ?

{diagnostic}

: null} + {error ?

{error}

: null} +
+
+ +
+ {snapshot ? ( + + ) : ( +
+

Validated live snapshot

+

Start a local run

+

Each incoming event will pass the Phase 1 validator before the reducer applies it.

+
+ )} +
+
+ ); +} + +function StaticReplay() { const defaultFixture = browserFixtures[0]; if (defaultFixture === undefined) { throw new Error("No Phase 1 fixtures were bundled"); @@ -155,75 +480,99 @@ export function App() { setReplay(replayPhase1FixtureText(fixture.source)); } + return ( +
+ + + Fixture input + + Choose a conformance case or edit the JSON, then validate and replay it. + + + + + + +