diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx index 6044c2120d9..0ace32e779c 100644 --- a/packages/paperclip-runner/devtools/browser/src/App.tsx +++ b/packages/paperclip-runner/devtools/browser/src/App.tsx @@ -84,6 +84,27 @@ function terminalTone(snapshot: SessionSnapshot) { return "neutral" as const; } +function humanizeProtocolLabel(value: string): string { + const words = value.replaceAll("_", " "); + return words.length === 0 ? words : `${words[0]?.toUpperCase()}${words.slice(1)}`; +} + +function liveStatusTone(status: LiveStatus, snapshot: SessionSnapshot | null) { + if (status === "error") { + return "danger" as const; + } + if (status !== "terminal") { + return "neutral" as const; + } + if (snapshot?.terminal?.runTerminalState === "succeeded") { + return "success" as const; + } + if (snapshot?.terminal?.runTerminalState === "failed") { + return "danger" as const; + } + return "neutral" as const; +} + function SnapshotSummary({ snapshot, label = "Session snapshot", @@ -99,9 +120,11 @@ function SnapshotSummary({

{snapshot.fixtureName}

- {snapshot.integrity === "gap_detected" - ? "Gap detected" - : (snapshot.terminal?.runTerminalState ?? snapshot.runPhase)} + {humanizeProtocolLabel( + snapshot.integrity === "gap_detected" + ? "Gap detected" + : (snapshot.terminal?.runTerminalState ?? snapshot.runPhase), + )} @@ -126,7 +149,9 @@ function SnapshotSummary({ {snapshot.proposedResult ? (
- Reported {snapshot.proposedResult.reportedWorkDisposition} + + Reported {humanizeProtocolLabel(snapshot.proposedResult.reportedWorkDisposition)} +

{snapshot.proposedResult.summary}

) : null} @@ -366,10 +391,10 @@ function LiveRunner() {
Runner status - {status} + {humanizeProtocolLabel(status)}
diff --git a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts index 20ebe5d133a..3f25268f721 100644 --- a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts +++ b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts @@ -8,8 +8,9 @@ test("validates and renders the shared Phase 1 fixture", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Static replay" }).click(); await expect(page.getByRole("heading", { name: "Live local runner" })).toBeVisible(); - await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded"); + await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded"); await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9); + await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing"); await expect(page.getByTestId("result-summary")).toContainText( "The scripted run completed successfully.", ); @@ -34,8 +35,8 @@ test("shows duplicate and unsupported-version replay states", async ({ page }) = test("streams a live run and proves replay parity", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Start local run" }).click(); - await expect(page.getByTestId("live-status")).toHaveText("terminal"); - await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded"); + await expect(page.getByTestId("live-status")).toHaveText("Terminal"); + await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded"); await expect(page.getByTestId("process-facts")).toContainText("Harness process exit"); await expect(page.getByTestId("process-facts")).toContainText("done"); await expect(page.getByTestId("parity-result")).toContainText("Match"); @@ -50,15 +51,26 @@ test("resolves live permission and input requests", async ({ page }) => { await page.getByLabel("Scenario").selectOption("permission-input"); await page.getByRole("button", { name: "Start local run" }).click(); await expect(page.getByTestId("runtime-request")).toContainText("permission request"); + await expect(page.getByTestId("terminal-badge")).toHaveText("Executing"); + await expect( + page.getByTestId("timeline").getByRole("listitem").filter({ + hasText: "runtime_request.created", + }).first(), + ).toContainText("permission: Allow the fake driver to write its local fixture?"); await page.screenshot({ path: resolve(packageRoot, "knowledge/evidence/phase-02-live-permission.png"), fullPage: true, }); await page.getByRole("button", { name: "Allow" }).click(); await expect(page.getByTestId("runtime-request")).toContainText("input request"); + await expect( + page.getByTestId("timeline").getByRole("listitem").filter({ + hasText: "runtime_request.resolved", + }).first(), + ).toContainText("Resolved permission: Allow the fake driver to write its local fixture?"); await page.getByLabel("Response").fill("phase-02-browser-trace"); await page.getByRole("button", { name: "Send input" }).click(); - await expect(page.getByTestId("live-status")).toHaveText("terminal"); + await expect(page.getByTestId("live-status")).toHaveText("Terminal"); await expect(page.getByTestId("parity-result")).toContainText("Match"); }); @@ -69,11 +81,34 @@ test("interrupts a live turn without duplicating terminal state", async ({ page const interrupt = page.getByRole("button", { name: "Interrupt turn" }); await expect(interrupt).toBeEnabled(); await interrupt.click(); - await expect(page.getByTestId("live-status")).toHaveText("terminal"); - await expect(page.getByTestId("terminal-badge")).toHaveText("cancelled"); + await expect(page.getByTestId("live-status")).toHaveText("Terminal"); + await expect(page.getByTestId("terminal-badge")).toHaveText("Cancelled"); + await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "neutral"); + await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "neutral"); await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1); await page.screenshot({ path: resolve(packageRoot, "knowledge/evidence/phase-02-live-interrupted.png"), fullPage: true, }); }); + +test("shows failed outcomes as danger and names the duplicate-terminal guard", async ({ page }) => { + await page.goto("/"); + await page.getByLabel("Scenario").selectOption("error"); + await page.getByRole("button", { name: "Start local run" }).click(); + await expect(page.getByTestId("live-status")).toHaveText("Terminal"); + await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "danger"); + await expect(page.getByTestId("terminal-badge")).toHaveText("Failed"); + await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "danger"); + + await page.getByLabel("Scenario").selectOption("duplicate-terminal"); + await page.getByRole("button", { name: "Start local run" }).click(); + await expect(page.getByTestId("live-status")).toHaveText("Terminal"); + await expect( + page.getByTestId("timeline").getByRole("listitem").filter({ + hasText: "harness.diagnostic", + }), + ).toContainText( + "Duplicate terminal event ignored; the first terminal event remains authoritative.", + ); +}); diff --git a/packages/paperclip-runner/knowledge/evidence/phase-01-static-replay.png b/packages/paperclip-runner/knowledge/evidence/phase-01-static-replay.png index 9d6bdbfac75..b135d0db859 100644 Binary files a/packages/paperclip-runner/knowledge/evidence/phase-01-static-replay.png and b/packages/paperclip-runner/knowledge/evidence/phase-01-static-replay.png differ diff --git a/packages/paperclip-runner/knowledge/evidence/phase-02-live-complete.png b/packages/paperclip-runner/knowledge/evidence/phase-02-live-complete.png index d274127043e..94a7f9e0d8b 100644 Binary files a/packages/paperclip-runner/knowledge/evidence/phase-02-live-complete.png and b/packages/paperclip-runner/knowledge/evidence/phase-02-live-complete.png differ diff --git a/packages/paperclip-runner/knowledge/evidence/phase-02-live-interrupted.png b/packages/paperclip-runner/knowledge/evidence/phase-02-live-interrupted.png index 87a9b1bd929..56cb5e27e5f 100644 Binary files a/packages/paperclip-runner/knowledge/evidence/phase-02-live-interrupted.png and b/packages/paperclip-runner/knowledge/evidence/phase-02-live-interrupted.png differ diff --git a/packages/paperclip-runner/knowledge/evidence/phase-02-live-permission.png b/packages/paperclip-runner/knowledge/evidence/phase-02-live-permission.png index 785ee611a8f..9272fb65e5f 100644 Binary files a/packages/paperclip-runner/knowledge/evidence/phase-02-live-permission.png and b/packages/paperclip-runner/knowledge/evidence/phase-02-live-permission.png differ diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json index f19c4146659..05498282265 100644 --- a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json +++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json @@ -75,7 +75,7 @@ "sourceSeq": 1, "eventType": "runtime.phase.changed", "emittedAt": "2026-08-07T12:00:00.030Z", - "summary": "Phase changed to workspace_preparing" + "summary": "Phase changed to workspace preparing" }, { "position": 2, diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json index c2f9f55ecf2..5d2f2b5836f 100644 --- a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json +++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json @@ -34,6 +34,7 @@ { "requestId": "request_interrupted_permission", "requestKind": "runtime", + "type": "permission", "status": "pending", "prompt": "Allow the fake command?" } @@ -92,7 +93,7 @@ "sourceSeq": 3, "eventType": "runtime_request.created", "emittedAt": "2026-08-07T12:20:00.040Z", - "summary": "runtime_request created" + "summary": "permission: Allow the fake command?" }, { "position": 4, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs b/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs index 5f65be59ca9..89ab5295de7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs @@ -775,7 +775,10 @@ impl RunnerState { } else { self.emit_event( "harness.diagnostic", - json!({ "code": "duplicate_semantic_result_ignored" }), + json!({ + "code": "duplicate_semantic_result_ignored", + "message": "Duplicate semantic result ignored; the first result remains authoritative." + }), None, None, )?; @@ -791,7 +794,10 @@ impl RunnerState { } else { self.emit_event( "harness.diagnostic", - json!({ "code": "duplicate_terminal_ignored" }), + json!({ + "code": "duplicate_terminal_ignored", + "message": "Duplicate terminal event ignored; the first terminal event remains authoritative." + }), None, None, )?; @@ -1020,6 +1026,12 @@ pub fn run_fake_harness( None, json!({ "driverSessionId": "driver_phase2_fake" }), )?; + output.send_event( + "runtime.phase.changed", + None, + None, + json!({ "phase": "executing" }), + )?; let turn = receive_harness_command(&command_receiver, "turn.start", None)?; let turn_id = turn .payload diff --git a/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts b/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts index a5b8b154405..b4a82d06c1b 100644 --- a/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts +++ b/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts @@ -20,6 +20,14 @@ describe.sequential("Phase 2 local runner and fake harness", () => { expect(eventTypes).toContain("harness.ready"); expect(eventTypes).toContain("item.completed"); + const sessionStartedIndex = eventTypes.indexOf("session.started"); + const executingIndex = trace.events.findIndex( + (event) => + event.eventType === "runtime.phase.changed" && + (event.payload as Record).phase === "executing", + ); + expect(executingIndex).toBeGreaterThan(sessionStartedIndex); + expect(executingIndex).toBeLessThan(eventTypes.indexOf("turn.started")); expect(trace.events.some((event) => event.itemId === "item_phase2_file")).toBe(true); expect(trace.result?.reportedWorkDisposition).toBe("done"); expect(trace.harnessProcessExit).toEqual({ exitCode: 0, success: true, signal: null }); @@ -99,6 +107,13 @@ describe.sequential("Phase 2 local runner and fake harness", () => { "duplicate_terminal_ignored", ), ).toBe(true); + expect( + replayPhase2Events(trace, trace.events).timeline.find( + (event) => event.eventType === "harness.diagnostic", + )?.summary, + ).toBe( + "Duplicate terminal event ignored; the first terminal event remains authoritative.", + ); }); it("keeps an error process exit distinct from its semantic result", async () => { diff --git a/packages/paperclip-runner/src/reducer/session-reducer.test.ts b/packages/paperclip-runner/src/reducer/session-reducer.test.ts index 8d3c0c7624f..efbe337fa6c 100644 --- a/packages/paperclip-runner/src/reducer/session-reducer.test.ts +++ b/packages/paperclip-runner/src/reducer/session-reducer.test.ts @@ -2,8 +2,13 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -import { parsePrpFixtureText, type PrpFixture } from "../protocol/phase1-contract.js"; import { + parsePrpFixtureText, + type PrpEvent, + type PrpFixture, +} from "../protocol/phase1-contract.js"; +import { + createSessionSnapshot, reducePrpFixture, reduceSessionEvents, type SessionSnapshot, @@ -70,4 +75,34 @@ describe("deterministic PRP session reducer", () => { }, ]); }); + + it("summarizes runtime request creation and resolution with type and prompt", async () => { + const fixture = await loadFixture("interrupted-run"); + const fixtureRequest = fixture.events.find( + (event) => event.eventType === "runtime_request.created", + ); + if (fixtureRequest === undefined) { + throw new Error("interrupted-run fixture must create a runtime request"); + } + const created: PrpEvent = { + ...fixtureRequest, + sourceEventId: "event_request_summary_01", + sourceSeq: 1, + }; + const resolved: PrpEvent = { + ...fixtureRequest, + sourceEventId: "event_request_summary_02", + sourceSeq: 2, + eventType: "runtime_request.resolved", + payload: { requestId: "request_interrupted_permission" }, + }; + + const snapshot = reduceSessionEvents(createSessionSnapshot(fixture), [created, resolved]); + + expect(snapshot.requests[0]).toMatchObject({ type: "permission" }); + expect(snapshot.timeline.map((entry) => entry.summary)).toEqual([ + "permission: Allow the fake command?", + "Resolved permission: Allow the fake command?", + ]); + }); }); diff --git a/packages/paperclip-runner/src/reducer/session-reducer.ts b/packages/paperclip-runner/src/reducer/session-reducer.ts index f8de4c1076d..7fbe5e100b3 100644 --- a/packages/paperclip-runner/src/reducer/session-reducer.ts +++ b/packages/paperclip-runner/src/reducer/session-reducer.ts @@ -27,6 +27,7 @@ export interface SessionItemSnapshot { export interface SessionRequestSnapshot { requestId: string; requestKind: string; + type: string; status: string; prompt: string; } @@ -80,10 +81,23 @@ function sourceKey(event: PrpEvent): string { return `${event.sourceKind}:${event.sourceInstanceId}`; } -function timelineSummary(event: PrpEvent): string { +function humanizeProtocolValue(value: string): string { + return value.replaceAll("_", " "); +} + +function sentenceCase(value: string): string { + return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`; +} + +function requestSummary(type: string, prompt: string, action?: string): string { + const detail = prompt.length > 0 ? `${type}: ${prompt}` : `${type} request`; + return action === undefined ? detail : `${action} ${detail}`; +} + +function timelineSummary(snapshot: SessionSnapshot, event: PrpEvent): string { const payload = record(event.payload); if (event.eventType === "runtime.phase.changed") { - return `Phase changed to ${stringValue(payload.phase, "unknown")}`; + return `Phase changed to ${humanizeProtocolValue(stringValue(payload.phase, "unknown"))}`; } if (event.eventType === "item.started") { return `Started ${stringValue(payload.kind, "item")}`; @@ -103,6 +117,40 @@ function timelineSummary(event: PrpEvent): string { if (event.eventType === "run.terminal") { return `Run ${stringValue(payload.runTerminalState, "terminal")}`; } + if ( + event.eventType === "runner.diagnostic" || + event.eventType === "harness.diagnostic" + ) { + const message = stringValue(payload.message); + if (message.length > 0) { + return message; + } + const code = stringValue(payload.code); + if (code.length > 0) { + return sentenceCase(humanizeProtocolValue(code)); + } + } + if (event.eventType === "runtime_request.created") { + const request = record(payload.request ?? payload); + return requestSummary( + stringValue(request.type, "runtime"), + stringValue(request.prompt), + ); + } + if ( + event.eventType === "runtime_request.resolved" || + event.eventType === "runtime_request.expired" || + event.eventType === "runtime_request.cancelled" + ) { + const requestId = stringValue(payload.requestId); + const request = snapshot.requests.find((candidate) => candidate.requestId === requestId); + const action = sentenceCase( + humanizeProtocolValue(event.eventType.split(".").at(-1) ?? "updated"), + ); + return request === undefined + ? `${action} runtime request ${requestId}`.trim() + : requestSummary(request.type, request.prompt, action); + } return event.eventType.replaceAll(".", " "); } @@ -191,6 +239,7 @@ function applyProjection(snapshot: SessionSnapshot, event: PrpEvent): void { snapshot.requests.push({ requestId, requestKind: stringValue(request.requestKind, "runtime"), + type: stringValue(request.type, "runtime"), status: stringValue(request.status, "pending"), prompt: stringValue(request.prompt), }); @@ -307,7 +356,7 @@ export function applyPrpEvent( eventType: event.eventType, emittedAt: event.emittedAt, ...(event.itemId === undefined ? {} : { itemId: event.itemId }), - summary: timelineSummary(event), + summary: timelineSummary(snapshot, event), }); applyProjection(snapshot, event); snapshot.integrity =