fix(runner): polish live browser diagnostics
Co-Authored-By: Paperclip <noreply@paperclip.ing>
No files matched your search
@@ -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({
|
||||
<h2>{snapshot.fixtureName}</h2>
|
||||
</div>
|
||||
<Badge tone={terminalTone(snapshot)} data-testid="terminal-badge">
|
||||
{snapshot.integrity === "gap_detected"
|
||||
? "Gap detected"
|
||||
: (snapshot.terminal?.runTerminalState ?? snapshot.runPhase)}
|
||||
{humanizeProtocolLabel(
|
||||
snapshot.integrity === "gap_detected"
|
||||
? "Gap detected"
|
||||
: (snapshot.terminal?.runTerminalState ?? snapshot.runPhase),
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +149,9 @@ function SnapshotSummary({
|
||||
|
||||
{snapshot.proposedResult ? (
|
||||
<div className="result-summary" data-testid="result-summary">
|
||||
<span>Reported {snapshot.proposedResult.reportedWorkDisposition}</span>
|
||||
<span>
|
||||
Reported {humanizeProtocolLabel(snapshot.proposedResult.reportedWorkDisposition)}
|
||||
</span>
|
||||
<p>{snapshot.proposedResult.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -366,10 +391,10 @@ function LiveRunner() {
|
||||
<div className="live-status" aria-live="polite">
|
||||
<span>Runner status</span>
|
||||
<Badge
|
||||
tone={status === "error" ? "danger" : status === "terminal" ? "success" : "neutral"}
|
||||
tone={liveStatusTone(status, snapshot)}
|
||||
data-testid="live-status"
|
||||
>
|
||||
{status}
|
||||
{humanizeProtocolLabel(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 120 KiB |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>).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 () => {
|
||||
|
||||
@@ -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?",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||