From 3165f057a3e790e790e42dffaf2f5bfb887fc9f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 01:54:47 +0000 Subject: [PATCH] feat(runner): surface live chat progress Co-Authored-By: Paperclip --- .../devtools/issue-thread/index.html | 2 +- .../issue-thread/issue-thread.spec.ts | 80 ++++++- .../devtools/issue-thread/src/App.tsx | 10 +- .../issue-thread/src/EvidencePanel.test.ts | 56 +++++ .../issue-thread/src/EvidencePanel.tsx | 90 +++++++- .../devtools/issue-thread/src/ThreadItems.tsx | 98 +++++--- .../issue-thread/src/issue-thread.css | 212 ++++++++++++++++++ .../issue-thread/stream-fixture-transport.mjs | 12 + .../src/issue-thread/fixtures.ts | 16 ++ .../src/issue-thread/issue-thread.test.ts | 28 ++- .../src/issue-thread/live-projection.ts | 72 ++++++ .../src/issue-thread/public-view.test.ts | 24 ++ .../src/issue-thread/public-view.ts | 15 ++ .../src/issue-thread/types.ts | 12 + .../src/phase7/evidence-redaction.ts | 109 ++++++++- .../src/phase7/live-session.ts | 12 + 16 files changed, 798 insertions(+), 50 deletions(-) create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts diff --git a/packages/paperclip-runner/devtools/issue-thread/index.html b/packages/paperclip-runner/devtools/issue-thread/index.html index d59893e1f19..8c344f1635e 100644 --- a/packages/paperclip-runner/devtools/issue-thread/index.html +++ b/packages/paperclip-runner/devtools/issue-thread/index.html @@ -4,7 +4,7 @@ - Mock Paperclip · Issue thread + 🫧 Mock Paperclip · Issue thread
diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index 7d14f078b6b..a21a4bfa3cf 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -125,10 +125,10 @@ test.describe("Phase 7G issue thread", () => { test("tool strips are collapsed disclosures that deep-link into Evidence", async ({ page }) => { await open(page, "thread-baseline"); - const strip = page.locator('[data-tool-strip="get_task_context"] button').first(); - await expect(strip).toHaveAttribute("aria-expanded", "false"); - await strip.click(); - await expect(strip).toHaveAttribute("aria-expanded", "true"); + const strip = page.locator('[data-tool-strip="get_task_context"]').first(); + await expect(strip).not.toHaveAttribute("open", ""); + await strip.locator("summary").click(); + await expect(strip).toHaveAttribute("open", ""); await page.getByRole("button", { name: "View in Evidence" }).first().click(); const panel = page.getByTestId("evidence-panel"); @@ -629,6 +629,7 @@ test.describe("Phase 7M clean-room chat", () => { test("a new chat opens a blank live thread with no scenario controls", async ({ page }) => { await openCleanRoom(page); + await expect(page).toHaveTitle("🫧 Mock Paperclip · Issue thread"); await expect(page.locator('[data-surface="chat"]')).toBeVisible(); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); await expect(page.locator("[data-turn-id]")).toHaveCount(0); @@ -655,6 +656,56 @@ test.describe("Phase 7M clean-room chat", () => { await expect(page.getByText("Real Paperclip API requests: 0")).toBeVisible(); }); + test("Runner events expand completely while stream deltas stay grouped", async ({ page }) => { + await page.addInitScript(() => window.localStorage.clear()); + await page.route(CLEAN_ROOM_API, async (route) => { + const payload = cleanRoomPayload("MCK-1000"); + const view = payload.view as Phase7IssueThreadSnapshot; + view.evidence.runner = [ + { + id: "session-1", + turnId: "turn-0", + kind: "session", + ordinal: 1, + detail: "session · started", + details: [ + { label: "Action", value: "started" }, + { label: "Runner", value: "paperclip-runnerd" }, + ], + }, + ...[2, 3].map((ordinal) => ({ + id: `delta-${ordinal}`, + turnId: "turn-1", + kind: "provider_event", + ordinal, + detail: "provider event · assistant_delta", + details: [{ label: "Event", value: "assistant_delta" }], + })), + ]; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(payload), + }); + }); + await page.goto("/#/chat"); + await expect(page.locator('[data-thread-state="settled"]')).toBeVisible(); + + await page.getByTestId("evidence-toggle").click(); + await page.getByRole("button", { name: /Runner & events/ }).click(); + + const session = page.locator('[data-record-id="session-1"]'); + await session.locator("summary").click(); + await expect(session).toContainText("paperclip-runnerd"); + + const group = page.locator('[data-runner-delta-group="assistant_delta"]'); + await expect(group).toContainText("2 streamed updates"); + await group.locator(":scope > summary").click(); + await expect(group.locator(".pit-runner-event")).toHaveCount(2); + await group.locator(".pit-runner-event").first().locator("summary").click(); + await expect(group.locator(".pit-runner-event").first()).toContainText("assistant_delta"); + }); + test("a sent message renders the live turn it produced", async ({ page }) => { await openCleanRoom(page); await page.route("**/api/phase7/ui/message", async (route) => { @@ -962,6 +1013,27 @@ async function openStreamingCleanRoom(page: Page): Promise { } test.describe("Phase 7Q streamed live turn", () => { + test("shows sanitized thinking progress before assistant text arrives", async ({ page }) => { + await openStreamingCleanRoom(page); + + await page.locator("#composer-input").fill("Show progress while you inspect this issue."); + await page.getByTestId("composer-send").click(); + + const progress = page.locator('[data-thread-item="progress_activity"][data-activity="thinking"]'); + await expect(progress).toBeVisible({ timeout: 30_000 }); + await expect(progress).toHaveAttribute("data-status", "running"); + await expect(progress).toContainText("Thinking"); + await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(0); + await expect(page.locator("body")).not.toContainText( + "PRIVATE reasoning text must never reach the browser.", + ); + + await expect(page.locator('[data-thread-item="agent_message"]')).toContainText(STREAM_REPLY, { + timeout: 60_000, + }); + await expect(progress).toHaveAttribute("data-status", "complete"); + }); + test("one assistant card grows while the turn POST is still open", async ({ page }) => { await openStreamingCleanRoom(page); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index 287a875362d..8cd7d8bb717 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -244,6 +244,8 @@ export function App() { // faces before measuring: a host fallback can otherwise move the retained // scroll position by a pixel even after the final webfonts replace it. if (route.panel === null || snapshot === null) return; + const panel = route.panel; + const record = route.record; let cancelled = false; void (async () => { if (typeof document.fonts?.ready?.then === "function") { @@ -251,11 +253,11 @@ export function App() { } if (cancelled) return; const target = - route.record === null - ? document.querySelector(`[data-evidence-section="${CSS.escape(route.panel)}"]`) - : document.querySelector(`[data-record-id="${CSS.escape(route.record)}"]`); + record === null + ? document.querySelector(`[data-evidence-section="${CSS.escape(panel)}"]`) + : document.querySelector(`[data-record-id="${CSS.escape(record)}"]`); if (target !== null) { - scrollEvidenceIntoView(target, route.record === null ? "start" : "center"); + scrollEvidenceIntoView(target, record === null ? "start" : "center"); } })(); return () => { diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts new file mode 100644 index 00000000000..59f5055ac35 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import type { Phase7EvidenceRunnerRecord } from "../../../src/issue-thread/types.js"; +import { groupPhase7RunnerEvents } from "./EvidencePanel.js"; + +function record( + ordinal: number, + event: string, + turnId = "turn-1", +): Phase7EvidenceRunnerRecord { + return { + id: `event-${ordinal}`, + turnId, + kind: "provider_event", + ordinal, + detail: `provider event · ${event}`, + details: [{ label: "Event", value: event }], + }; +} + +describe("Runner event grouping", () => { + it("aggregates each delta category per turn at its first position", () => { + const session: Phase7EvidenceRunnerRecord = { + id: "event-1", + turnId: "turn-0", + kind: "session", + ordinal: 1, + detail: "session · started", + details: [{ label: "Action", value: "started" }], + }; + const groups = groupPhase7RunnerEvents([ + session, + record(2, "assistant_delta"), + record(3, "reasoning_delta"), + record(4, "assistant_delta"), + record(5, "assistant_delta", "turn-2"), + ]); + + expect(groups.map((group) => [group.event, group.records.map((entry) => entry.ordinal)])).toEqual([ + [null, [1]], + ["assistant_delta", [2, 4]], + ["reasoning_delta", [3]], + ["assistant_delta", [5]], + ]); + }); + + it("leaves non-delta events individually expandable", () => { + const groups = groupPhase7RunnerEvents([ + record(1, "turn_started"), + record(2, "tool_result"), + ]); + + expect(groups).toHaveLength(2); + expect(groups.every((group) => group.event === null && group.records.length === 1)).toBe(true); + }); +}); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx index 9198e4b1e1d..fe7efd37d68 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import type { Phase7EvidenceModel, + Phase7EvidenceRunnerRecord, Phase7EvidenceSectionId, Phase7IssueThreadSnapshot, Phase7ToolDisposition, @@ -23,6 +24,86 @@ const DISPOSITION_ORDER: Phase7ToolDisposition[] = [ "control_plane_owned", ]; +export interface Phase7RunnerEventGroup { + id: string; + event: string | null; + records: Phase7EvidenceRunnerRecord[]; +} + +/** + * Stream deltas are useful evidence but terrible default reading material. + * Aggregate each sanitized delta category within a turn at its first position; + * expanding the group still exposes every individual event in ordinal order. + */ +export function groupPhase7RunnerEvents( + records: readonly Phase7EvidenceRunnerRecord[], +): Phase7RunnerEventGroup[] { + const groups: Phase7RunnerEventGroup[] = []; + const deltaGroups = new Map(); + for (const record of records) { + const event = record.details.find((entry) => entry.label === "Event")?.value ?? null; + if (event === null || !event.endsWith("_delta")) { + groups.push({ id: record.id, event: null, records: [record] }); + continue; + } + const key = `${record.turnId}:${event}`; + const existing = deltaGroups.get(key); + if (existing !== undefined) { + existing.records.push(record); + continue; + } + const group = { id: `delta:${key}`, event, records: [record] }; + deltaGroups.set(key, group); + groups.push(group); + } + return groups; +} + +function RunnerEvent({ record }: { record: Phase7EvidenceRunnerRecord }) { + return ( +
+ + #{record.ordinal} {record.kind} + {record.detail} + + +
+ {record.details.length === 0 ? ( + <> +
Detail
+
No additional sanitized fields were retained.
+ + ) : ( + record.details.map((entry, index) => ( +
+
{entry.label}
+
{entry.value}
+
+ )) + )} +
+
+ ); +} + +function RunnerEventGroup({ group }: { group: Phase7RunnerEventGroup }) { + if (group.event === null) return ; + return ( +
+ + + {group.event} + + {group.records.length} streamed update{group.records.length === 1 ? "" : "s"} + + +
+ {group.records.map((record) => )} +
+
+ ); +} + export interface EvidencePanelProps { snapshot: Phase7IssueThreadSnapshot; layout: "side" | "overlay" | "segment"; @@ -317,13 +398,8 @@ export function EvidencePanel(props: EvidencePanelProps) { open={isOpen("runner")} onToggle={() => onToggleSection("runner")} > - {filterByTurn(evidence.runner, selectedTurnId).map((record) => ( -
- - #{record.ordinal} {record.kind} - - {record.detail} -
+ {groupPhase7RunnerEvents(filterByTurn(evidence.runner, selectedTurnId)).map((group) => ( + ))} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx index 34011feefc1..d1563a42bdc 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx @@ -26,47 +26,72 @@ function ToolStrip({ item: Extract; callbacks: ThreadCallbacks; }) { - const [expanded, setExpanded] = useState(false); return ( -
- - {expanded ? ( -
+ {item.operationId} + {item.summary} + + +
+

Sanitized tool input and result

+
{JSON.stringify(item.input, null, 2)}
{JSON.stringify(item.result, null, 2)}
-
- ) : null} -
+ +
+ + ); +} + +function ProgressItem({ + item, +}: { + item: Extract; +}) { + return ( +
+ + + {item.label} + {item.summary} + + +
+

+ {item.eventCount} sanitized progress update{item.eventCount === 1 ? "" : "s"} observed. + Raw chain-of-thought and provider text are not exposed. +

+
+
); } @@ -80,7 +105,12 @@ function ThreadItemView({ switch (item.kind) { case "user_message": return ( -
+
{item.author} @@ -93,7 +123,8 @@ function ThreadItemView({ return (
@@ -141,6 +172,9 @@ function ThreadItemView({ case "tool_activity": return ; + case "progress_activity": + return ; + case "denial": return (
@@ -311,7 +345,7 @@ export function TurnGroup({ return (

- + Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call {turn.toolCallCount === 1 ? "" : "s"} ·{" "} {new Date(turn.at).toISOString().slice(11, 19)} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css index 0e61e04906c..bbba3a05ddd 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css +++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css @@ -559,6 +559,37 @@ textarea { min-width: 0; } +/* + * Chat messages follow the familiar assistant flow: the board's message is a + * compact bubble, while the assistant answer sits directly on the page. The + * heavier card remains available for durable control-plane records below. + */ +.pit-message { + min-width: 0; +} + +.pit-message[data-role="user"] { + align-self: flex-end; + width: fit-content; + max-width: 85%; + padding: var(--pit-space-3) var(--pit-space-4); + border-radius: var(--pit-radius-lg); + background: var(--pit-surface-raised); +} + +.pit-message[data-role="assistant"] { + padding: var(--pit-space-2) 0; +} + +.pit-message .pit-card-head { + color: var(--pit-muted-foreground); +} + +.pit-app[data-surface="chat"] .pit-turn-label, +.pit-app[data-surface="chat"] .pit-turn-header::after { + display: none; +} + .pit-card-head { display: flex; align-items: baseline; @@ -664,6 +695,100 @@ textarea { gap: var(--pit-space-2); } +/* Lightweight assistant-style reasoning/tool disclosures. */ +.pit-activity-item { + min-width: 0; + color: var(--pit-muted-foreground); +} + +.pit-activity-summary { + display: flex; + align-items: center; + gap: var(--pit-space-2); + min-height: var(--pit-touch-target); + padding: var(--pit-space-1) 0; + cursor: pointer; + list-style: none; +} + +.pit-activity-summary::-webkit-details-marker { + display: none; +} + +.pit-activity-glyph { + flex: none; + width: var(--pit-space-5); + color: var(--pit-muted-foreground); + font-family: var(--pit-font-mono); + text-align: center; +} + +.pit-activity-item[data-status="ok"] .pit-activity-glyph, +.pit-activity-item[data-status="complete"] .pit-activity-glyph { + color: var(--pit-success); +} + +.pit-activity-item[data-status="denied"] .pit-activity-glyph { + color: var(--pit-danger); +} + +.pit-activity-item[data-status="running"] .pit-activity-glyph { + color: var(--pit-live); + animation: pit-pulse 1.6s ease-in-out infinite; +} + +.pit-activity-operation { + flex: none; + color: var(--pit-foreground); + font-family: var(--pit-font-mono); + font-size: var(--pit-text-xs); + font-weight: 600; +} + +.pit-progress-activity .pit-activity-operation { + font-family: var(--pit-font-sans); +} + +.pit-activity-description { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--pit-muted-foreground); + font-size: var(--pit-text-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pit-activity-caret { + flex: none; + color: var(--pit-faint-foreground); + transition: transform var(--pit-motion) ease-out; +} + +.pit-activity-item[open] > .pit-activity-summary .pit-activity-caret { + transform: rotate(90deg); +} + +.pit-activity-detail { + display: flex; + flex-direction: column; + gap: var(--pit-space-2); + margin-left: calc(var(--pit-space-5) + var(--pit-space-2)); + padding: 0 0 var(--pit-space-3) var(--pit-space-3); + border-left: 1px solid var(--pit-border); + font-size: var(--pit-text-xs); +} + +.pit-activity-detail p { + margin: 0; +} + +.pit-activity-payloads { + display: grid; + gap: var(--pit-space-2); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .pit-code { margin: 0; padding: var(--pit-space-2); @@ -1058,6 +1183,81 @@ textarea { overflow-wrap: anywhere; } +.pit-runner-event, +.pit-runner-group { + min-width: 0; + border: 1px solid var(--pit-border); + border-radius: var(--pit-radius-sm); + background: var(--pit-surface); + font-size: var(--pit-text-xs); +} + +.pit-runner-event-summary, +.pit-runner-group-summary { + display: flex; + align-items: center; + gap: var(--pit-space-2); + min-height: var(--pit-touch-target); + padding: var(--pit-space-2); + cursor: pointer; + list-style: none; + min-width: 0; +} + +.pit-runner-event-summary::-webkit-details-marker, +.pit-runner-group-summary::-webkit-details-marker { + display: none; +} + +.pit-runner-event-summary .pit-record-detail, +.pit-runner-group-summary .pit-record-detail { + flex: 1; + min-width: 0; +} + +.pit-runner-event[open] > .pit-runner-event-summary .pit-activity-caret, +.pit-runner-group[open] > .pit-runner-group-summary .pit-activity-caret { + transform: rotate(90deg); +} + +.pit-runner-event-details { + display: grid; + gap: var(--pit-space-2); + margin: 0; + padding: 0 var(--pit-space-2) var(--pit-space-2); +} + +.pit-runner-event-details > div { + display: grid; + grid-template-columns: minmax(0, 7rem) minmax(0, 1fr); + gap: var(--pit-space-2); +} + +.pit-runner-event-details dt, +.pit-runner-event-details dd { + margin: 0; + overflow-wrap: anywhere; +} + +.pit-runner-event-details dt { + color: var(--pit-faint-foreground); +} + +.pit-runner-event-details dd { + color: var(--pit-muted-foreground); + font-family: var(--pit-font-mono); +} + +.pit-runner-group-events { + display: grid; + gap: var(--pit-space-2); + padding: 0 var(--pit-space-2) var(--pit-space-2); +} + +.pit-runner-group-events .pit-runner-event { + background: var(--pit-surface-sunken); +} + .pit-tool-group-label { font-size: var(--pit-text-xs); font-weight: 600; @@ -1300,6 +1500,18 @@ textarea { padding: var(--pit-space-4) var(--pit-space-3); } + .pit-message[data-role="user"] { + max-width: 92%; + } + + .pit-activity-payloads { + grid-template-columns: minmax(0, 1fr); + } + + .pit-activity-description { + white-space: normal; + } + /* * Three transport controls plus a progress bar do not fit on one 390px row — * squeezing them leaves the bar as an unreadable stub. Wrap so the bar keeps diff --git a/packages/paperclip-runner/devtools/issue-thread/stream-fixture-transport.mjs b/packages/paperclip-runner/devtools/issue-thread/stream-fixture-transport.mjs index 97c48bec702..dcd932c07e9 100644 --- a/packages/paperclip-runner/devtools/issue-thread/stream-fixture-transport.mjs +++ b/packages/paperclip-runner/devtools/issue-thread/stream-fixture-transport.mjs @@ -15,6 +15,8 @@ */ const DELTA_INTERVAL_MS = 220; +export const PHASE7_STREAM_FIXTURE_PRIVATE_REASONING = + "PRIVATE reasoning text must never reach the browser."; /** Four deltas: enough for a browser to observe growth twice over, and short. */ export const PHASE7_STREAM_FIXTURE_DELTAS = [ @@ -113,6 +115,16 @@ class StreamFixtureTransport { method: "turn/started", params: { threadId: "stream-fixture-thread", turn: { id: turnId, status: "inProgress" } }, }); + await sleep(Math.floor(DELTA_INTERVAL_MS / 2)); + if (this.#closed || this.#interrupted.has(turnId)) return; + this.#queue.push({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "stream-fixture-thread", + turnId, + delta: PHASE7_STREAM_FIXTURE_PRIVATE_REASONING, + }, + }); for (const delta of PHASE7_STREAM_FIXTURE_DELTAS) { await sleep(DELTA_INTERVAL_MS); if (this.#closed || this.#interrupted.has(turnId)) return; diff --git a/packages/paperclip-runner/src/issue-thread/fixtures.ts b/packages/paperclip-runner/src/issue-thread/fixtures.ts index c2469475a78..85306f920ac 100644 --- a/packages/paperclip-runner/src/issue-thread/fixtures.ts +++ b/packages/paperclip-runner/src/issue-thread/fixtures.ts @@ -246,6 +246,10 @@ function baseEvidence(): Phase7EvidenceModel { kind: "session.started", ordinal: 1, detail: "runnerd pid 4101 · process group 4101 · thread thr-mock-7g", + details: [ + { label: "Runner", value: "runnerd" }, + { label: "State", value: "started" }, + ], }, { id: "ev-2", @@ -253,6 +257,10 @@ function baseEvidence(): Phase7EvidenceModel { kind: "turn.started", ordinal: 2, detail: "turn-1 · dynamic tools 8 exposed / 3 withheld", + details: [ + { label: "State", value: "started" }, + { label: "Tools", value: "8 exposed, 3 withheld" }, + ], }, { id: "ev-3", @@ -260,6 +268,10 @@ function baseEvidence(): Phase7EvidenceModel { kind: "tool.dispatched", ordinal: 5, detail: "write_document → typed result revision 4", + details: [ + { label: "Operation", value: "write_document" }, + { label: "Result", value: "typed result revision 4" }, + ], }, { id: "ev-4", @@ -267,6 +279,10 @@ function baseEvidence(): Phase7EvidenceModel { kind: "turn.completed", ordinal: 9, detail: "turn-3 · status completed · 1 tool call", + details: [ + { label: "State", value: "completed" }, + { label: "Tool calls", value: "1" }, + ], }, ], state: [ diff --git a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts index f3bce91c159..de331abfd1a 100644 --- a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts +++ b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts @@ -276,6 +276,20 @@ describe("Phase 7 live projection", () => { }, { id: "evidence-000002", + kind: "provider_event", + at: "2026-08-09T09:00:10.000Z", + turnId: "turn-1", + data: { event: "reasoning_delta" }, + }, + { + id: "evidence-000003", + kind: "provider_event", + at: "2026-08-09T09:00:11.000Z", + turnId: "turn-1", + data: { event: "reasoning_delta" }, + }, + { + id: "evidence-000004", kind: "tool_call", at: "2026-08-09T09:00:30.000Z", turnId: "turn-1", @@ -287,7 +301,7 @@ describe("Phase 7 live projection", () => { }, }, { - id: "evidence-000003", + id: "evidence-000005", kind: "tool_result", at: "2026-08-09T09:00:31.000Z", turnId: "turn-1", @@ -344,6 +358,18 @@ describe("Phase 7 live projection", () => { expect(items.some((item) => item.kind === "agent_message")).toBe(true); expect(items.some((item) => item.kind === "tool_activity")).toBe(true); + const progress = items.find((item) => item.kind === "progress_activity"); + expect(progress).toMatchObject({ + kind: "progress_activity", + activity: "thinking", + status: "complete", + label: "Thinking", + eventCount: 2, + }); + expect(view.evidence.runner.filter( + (record) => record.details.some((entry) => entry.value === "reasoning_delta"), + )).toHaveLength(2); + // The durable comment comes from the mock record the command created, // not from the model's prose. const durable = items.find((item) => item.kind === "durable_comment"); diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index 4a57bb5615a..2dd21f9b5d9 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -15,6 +15,7 @@ import type { } from "../mock-core/phase7-control-plane-types.js"; import { phase7EvidenceDetail, + phase7EvidenceDetails, redactPhase7ToolResult, } from "../phase7/evidence-redaction.js"; import type { @@ -54,6 +55,33 @@ const DISPOSITION_OPERATIONS = new Set([ "request_review", ]); +const PROGRESS_EVENTS = { + reasoning_delta: { + activity: "thinking", + label: "Thinking", + running: "Reasoning activity is arriving from Codex.", + complete: "Reasoning activity completed.", + }, + plan_delta: { + activity: "planning", + label: "Planning", + running: "Codex is updating its plan.", + complete: "Plan activity completed.", + }, + command_output_delta: { + activity: "command", + label: "Running a command", + running: "Command progress is arriving from Codex.", + complete: "Command activity completed.", + }, + file_change_delta: { + activity: "file_change", + label: "Reviewing a file change", + running: "File-change progress is arriving from Codex.", + complete: "File-change activity completed.", + }, +} as const; + export interface Phase7LiveProjectionInput { snapshot: Phase7LiveSessionSnapshot; /** Transport state owned by the server, not inferred in the browser. */ @@ -629,6 +657,7 @@ function evidenceModel( kind: entry.kind, ordinal: index + 1, detail: phase7EvidenceDetail(entry.kind, entry.data), + details: phase7EvidenceDetails(entry.kind, entry.data), })), state: calls .filter((pair) => pair.afterRevision !== null && pair.afterRevision !== pair.beforeRevision) @@ -725,6 +754,49 @@ export function projectPhase7IssueThread( turn.items.push(...derivedRecordItems(pair, state)); } + const progressGroups = new Map< + string, + { + turnId: string; + event: keyof typeof PROGRESS_EVENTS; + at: string; + count: number; + } + >(); + for (const entry of snapshot.evidence) { + if (entry.kind !== "provider_event" || entry.turnId === null) continue; + const event = readString(entry.data.event) as keyof typeof PROGRESS_EVENTS; + if (!(event in PROGRESS_EVENTS)) continue; + const key = `${entry.turnId}:${event}`; + const group = progressGroups.get(key); + if (group === undefined) { + progressGroups.set(key, { turnId: entry.turnId, event, at: entry.at, count: 1 }); + } else { + group.count += 1; + } + } + for (const group of progressGroups.values()) { + const copy = PROGRESS_EVENTS[group.event]; + const running = snapshot.activeTurnId === group.turnId; + turnFor(group.turnId, group.at).items.push({ + kind: "progress_activity", + id: `item-progress-${group.turnId}-${group.event}`, + at: group.at, + activity: copy.activity, + status: running ? "running" : "complete", + label: copy.label, + summary: running ? copy.running : copy.complete, + eventCount: group.count, + }); + } + + // The transcript, provider progress, and semantic calls are collected from + // separate durable record sets. Put them back into time order for a chat-like + // flow while relying on stable sort order for records sharing a timestamp. + for (const turn of turns.values()) { + turn.items.sort((left, right) => left.at.localeCompare(right.at)); + } + const stopRequested = snapshot.evidence.some( (entry) => entry.kind === "session" && readString(entry.data.action) === "stop_requested", ); diff --git a/packages/paperclip-runner/src/issue-thread/public-view.test.ts b/packages/paperclip-runner/src/issue-thread/public-view.test.ts index 00e5d1aa4a2..f97678e34dd 100644 --- a/packages/paperclip-runner/src/issue-thread/public-view.test.ts +++ b/packages/paperclip-runner/src/issue-thread/public-view.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { phase7AllowlistedInputFields, phase7EvidenceDetail, + phase7EvidenceDetails, redactPhase7EvidenceData, redactPhase7ToolResult, } from "../phase7/evidence-redaction.js"; @@ -96,6 +97,10 @@ function projectedView(): Phase7IssueThreadSnapshot { kind: "provider_event", ordinal: 1, detail: `provider event · assistant_delta (${PROVIDER_THREAD})`, + details: [ + { label: "Event", value: "assistant_delta" }, + { label: "Provider", value: PROVIDER_THREAD }, + ], }, ], state: [], @@ -179,6 +184,25 @@ describe("Phase 7 evidence redaction", () => { expect(phase7EvidenceDetail("provider_event", redacted)).toBe("provider event · assistant_delta"); }); + it("recognizes progress notifications without retaining their text", () => { + for (const method of [ + "item/reasoning/summaryTextDelta", + "item/plan/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + ]) { + const redacted = redactPhase7EvidenceData("provider_event", { + method, + params: { delta: CANARY }, + }); + expect(String(redacted.event)).toMatch(/_delta$/); + expect(JSON.stringify(redacted)).not.toContain(CANARY); + expect(phase7EvidenceDetails("provider_event", redacted)).toEqual([ + { label: "Event", value: redacted.event }, + ]); + } + }); + it("collapses an unknown provider method rather than echoing it", () => { expect(redactPhase7EvidenceData("provider_event", { method: `x/${CANARY}` })).toEqual({ event: "other", diff --git a/packages/paperclip-runner/src/issue-thread/public-view.ts b/packages/paperclip-runner/src/issue-thread/public-view.ts index c5ec88b6c00..effa4c47e9d 100644 --- a/packages/paperclip-runner/src/issue-thread/public-view.ts +++ b/packages/paperclip-runner/src/issue-thread/public-view.ts @@ -286,6 +286,17 @@ function publicItem(item: Phase7ThreadItem, aliases: Aliases): Phase7ThreadItem result: publicToolResult(item.result), evidenceRef: publicRef(item.evidenceRef, aliases), }; + case "progress_activity": + return { + kind: "progress_activity", + id, + at: item.at, + activity: item.activity, + status: item.status, + label: clamp(item.label), + summary: clamp(item.summary), + eventCount: item.eventCount, + }; case "interaction": return { kind: "interaction", @@ -446,6 +457,10 @@ function publicRunner( kind: record.kind, ordinal: record.ordinal, detail: clamp(aliases.text(record.detail)), + details: record.details.map((entry) => ({ + label: clamp(entry.label), + value: clamp(aliases.text(entry.value), MAX_BODY_CHARS), + })), }; } diff --git a/packages/paperclip-runner/src/issue-thread/types.ts b/packages/paperclip-runner/src/issue-thread/types.ts index ab7058295fe..b37917ef92d 100644 --- a/packages/paperclip-runner/src/issue-thread/types.ts +++ b/packages/paperclip-runner/src/issue-thread/types.ts @@ -197,6 +197,16 @@ export type Phase7ThreadItem = result: Phase7JsonValue; evidenceRef: Phase7EvidenceRef; } + | { + kind: "progress_activity"; + id: string; + at: string; + activity: "thinking" | "planning" | "command" | "file_change"; + status: "running" | "complete"; + label: string; + summary: string; + eventCount: number; + } | ({ kind: "interaction"; id: string; at: string } & Phase7ThreadInteractionCard) | { kind: "document"; @@ -348,6 +358,8 @@ export interface Phase7EvidenceRunnerRecord { kind: string; ordinal: number; detail: string; + /** Complete allowlisted detail; raw provider payloads never enter this DTO. */ + details: Array<{ label: string; value: string }>; } export interface Phase7EvidenceStateDiffRow { diff --git a/packages/paperclip-runner/src/phase7/evidence-redaction.ts b/packages/paperclip-runner/src/phase7/evidence-redaction.ts index d26ca70724a..d3511ed2587 100644 --- a/packages/paperclip-runner/src/phase7/evidence-redaction.ts +++ b/packages/paperclip-runner/src/phase7/evidence-redaction.ts @@ -57,8 +57,23 @@ const PROVIDER_EVENT_CATEGORIES: Readonly> = Object.freez "item/completed": "item_completed", "item/agentMessage/delta": "assistant_delta", "item/reasoning/delta": "reasoning_delta", + "item/reasoning/summaryTextDelta": "reasoning_delta", + "item/reasoning/textDelta": "reasoning_delta", + "item/plan/delta": "plan_delta", + "item/commandExecution/outputDelta": "command_output_delta", + "item/fileChange/outputDelta": "file_change_delta", }); +/** + * Provider notifications are reduced to this fixed vocabulary at ingestion. + * Exporting the classifier lets the live turn pump decide which sanitized + * progress events deserve an immediate browser frame without duplicating the + * protocol-method allowlist. + */ +export function phase7ProviderEventCategory(method: string): string { + return PROVIDER_EVENT_CATEGORIES[method] ?? "other"; +} + /** Operations whose input summary is the rendered disposition body (§9). */ const DISPOSITION_OPERATIONS: ReadonlySet = new Set([ "finish_task", @@ -179,7 +194,7 @@ export function redactPhase7EvidenceData( ): Record { switch (kind) { case "provider_event": - return { event: PROVIDER_EVENT_CATEGORIES[asString(data.method)] ?? "other" }; + return { event: phase7ProviderEventCategory(asString(data.method)) }; case "diagnostic": // A provider diagnostic is operator surface, not board surface. Only the // fact that one was recorded survives. @@ -276,3 +291,95 @@ export function phase7EvidenceDetail( return kind; } } + +export interface Phase7EvidenceDetailRow { + label: string; + value: string; +} + +/** + * Complete browser-safe detail for one retained event. + * + * This intentionally enumerates each field rather than stringifying `data`. + * The retained record is already redacted, but the public diagnostics surface + * remains fail-closed when a future ingestion field is added. + */ +export function phase7EvidenceDetails( + kind: Phase7EvidenceKind, + data: Record, +): Phase7EvidenceDetailRow[] { + const rows: Phase7EvidenceDetailRow[] = []; + const add = (label: string, value: Phase7JsonValue | undefined): void => { + if (typeof value === "string" && value.length > 0) rows.push({ label, value }); + else if (typeof value === "number" || typeof value === "boolean") { + rows.push({ label, value: String(value) }); + } + }; + const addList = (label: string, value: Phase7JsonValue | undefined): void => { + if (!Array.isArray(value)) return; + const entries = value.filter((entry): entry is string => typeof entry === "string"); + rows.push({ label, value: entries.length === 0 ? "none" : entries.join(", ") }); + }; + + switch (kind) { + case "provider_event": + add("Event", data.event); + break; + case "diagnostic": + add("Visibility", data.diagnostic); + break; + case "session": + add("Action", data.action); + add("Session", data.sessionId); + add("Mode", data.mode); + add("Runner", data.runner); + add("Control plane", data.controlPlane); + add("Reason", data.reason); + break; + case "process": + add("Action", data.action); + add("Reason", data.reason); + add("Runner exited", data.runnerExited); + break; + case "cleanup": + add("Reason", data.reason); + add("Authority cleared", data.authorityCleared); + add("Mock stopped", data.mockStopped); + add("Process exited", data.processExited); + break; + case "tool_exposure": + add("Scenario", data.scenarioId); + addList("Operations", data.operationIds); + break; + case "tool_call": { + const input = asRecord(data.input); + add("Call", data.callId); + add("Operation", data.operationId); + add("Before revision", data.beforeRevision); + addList("Input fields", input.fields as Phase7JsonValue | undefined); + add("Disposition summary", input.dispositionSummary as Phase7JsonValue | undefined); + break; + } + case "tool_result": { + const result = asRecord(data.result); + const value = asRecord(result.result); + add("Call", data.callId); + add("Operation", data.operationId); + add("Before revision", data.beforeRevision); + add("After revision", data.afterRevision); + add("Outcome", result.ok === true ? "ok" : "denied"); + add("State revision", result.stateRevision as Phase7JsonValue | undefined); + add("Command", value.commandId as Phase7JsonValue | undefined); + add("Command kind", value.commandKind as Phase7JsonValue | undefined); + add("Disposition", value.disposition as Phase7JsonValue | undefined); + add("Result revision", value.revision as Phase7JsonValue | undefined); + break; + } + case "interaction": + add("Kind", data.interactionKind); + add("Outcome", data.outcome); + add("State revision", data.stateRevision); + break; + } + return rows; +} diff --git a/packages/paperclip-runner/src/phase7/live-session.ts b/packages/paperclip-runner/src/phase7/live-session.ts index a8c4efb09e4..ccf524ca23e 100644 --- a/packages/paperclip-runner/src/phase7/live-session.ts +++ b/packages/paperclip-runner/src/phase7/live-session.ts @@ -24,6 +24,7 @@ import type { Phase7SemanticToolResult, } from "../semantic-tools/types.js"; import { + phase7ProviderEventCategory, redactPhase7EvidenceData, type Phase7EvidenceKind, } from "./evidence-redaction.js"; @@ -945,6 +946,7 @@ export class Phase7LiveSession { const turn = record(params.turn); const item = record(params.item); const turnId = text(params.turnId, text(turn.id)); + const providerEvent = phase7ProviderEventCategory(notification.method); this.#appendEvidence("provider_event", turnId || null, { method: notification.method, params: jsonValue(params), @@ -957,6 +959,16 @@ export class Phase7LiveSession { this.#turnWaiter.assistantText += text(params.delta); this.#publishAssistantProgress(turnId || this.#activeTurnId); } + } else if (providerEvent.endsWith("_delta")) { + // Reasoning, planning, command-output, and file-change progress used to + // be retained only for diagnostics. Emitting an activity frame here lets + // the thread acknowledge that work immediately, while the redaction gate + // still withholds provider text and chain-of-thought content. + this.#emit({ + turnId: turnId || this.#activeTurnId, + kind: "activity", + reason: providerEvent, + }); } else if (notification.method === "item/completed" && text(item.type) === "agentMessage") { const assistantText = text(item.text); if (assistantText.length > 0 && this.#turnWaiter !== null) {