feat(runner): surface live chat progress

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-11 01:54:47 +00:00
1 parent 6394ecfd01
commit 3165f057a3
16 files changed
+798 -50

No files matched your search

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>Mock Paperclip · Issue thread</title>
<title>🫧 Mock Paperclip · Issue thread</title>
</head>
<body>
<div id="root"></div>
@@ -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<void> {
}
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);
@@ -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 () => {
@@ -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);
});
});
@@ -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<string, Phase7RunnerEventGroup>();
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 (
<details className="pit-runner-event" data-record-id={record.id}>
<summary className="pit-runner-event-summary">
<span className="pit-record-title">#{record.ordinal} {record.kind}</span>
<span className="pit-record-detail">{record.detail}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<dl className="pit-runner-event-details">
{record.details.length === 0 ? (
<>
<dt>Detail</dt>
<dd>No additional sanitized fields were retained.</dd>
</>
) : (
record.details.map((entry, index) => (
<div key={`${entry.label}:${index}`}>
<dt>{entry.label}</dt>
<dd>{entry.value}</dd>
</div>
))
)}
</dl>
</details>
);
}
function RunnerEventGroup({ group }: { group: Phase7RunnerEventGroup }) {
if (group.event === null) return <RunnerEvent record={group.records[0]!} />;
return (
<details className="pit-runner-group" data-runner-delta-group={group.event}>
<summary className="pit-runner-group-summary">
<span className="pit-activity-caret" aria-hidden="true">›</span>
<span className="pit-record-title">{group.event}</span>
<span className="pit-record-detail">
{group.records.length} streamed update{group.records.length === 1 ? "" : "s"}
</span>
</summary>
<div className="pit-runner-group-events">
{group.records.map((record) => <RunnerEvent key={record.id} record={record} />)}
</div>
</details>
);
}
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) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-record-title">
#{record.ordinal} {record.kind}
</span>
<span className="pit-record-detail">{record.detail}</span>
</div>
{groupPhase7RunnerEvents(filterByTurn(evidence.runner, selectedTurnId)).map((group) => (
<RunnerEventGroup key={group.id} group={group} />
))}
</Section>
@@ -26,47 +26,72 @@ function ToolStrip({
item: Extract<Phase7ThreadItem, { kind: "tool_activity" }>;
callbacks: ThreadCallbacks;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div
<details
id={item.id}
className="pit-strip"
className="pit-activity-item"
data-thread-item="tool_activity"
data-status={item.status}
data-tool-strip={item.operationId}
>
<button
type="button"
className="pit-strip-button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<span className="pit-strip-glyph" aria-hidden="true">
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{STATUS_GLYPH[item.status]}
</span>
<span className="pit-visually-hidden">{item.status}</span>
<span className="pit-strip-operation">{item.operationId}</span>
<span className="pit-strip-summary">{item.summary}</span>
<span className="pit-strip-caret" aria-hidden="true">
{expanded ? "⌄" : "›"}
</span>
</button>
{expanded ? (
<div className="pit-strip-detail">
<span className="pit-activity-operation">{item.operationId}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
<p className="pit-card-meta">Sanitized tool input and result</p>
<div className="pit-activity-payloads">
<pre className="pit-code">{JSON.stringify(item.input, null, 2)}</pre>
<pre className="pit-code">{JSON.stringify(item.result, null, 2)}</pre>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
) : null}
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</details>
);
}
function ProgressItem({
item,
}: {
item: Extract<Phase7ThreadItem, { kind: "progress_activity" }>;
}) {
return (
<details
id={item.id}
className="pit-activity-item pit-progress-activity"
data-thread-item="progress_activity"
data-activity={item.activity}
data-status={item.status}
open={item.status === "running"}
>
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{item.status === "running" ? "⏳" : "✓"}
</span>
<span className="pit-activity-operation">{item.label}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
<p>
{item.eventCount} sanitized progress update{item.eventCount === 1 ? "" : "s"} observed.
Raw chain-of-thought and provider text are not exposed.
</p>
</div>
</details>
);
}
@@ -80,7 +105,12 @@ function ThreadItemView({
switch (item.kind) {
case "user_message":
return (
<article id={item.id} className="pit-card" data-thread-item="user_message">
<article
id={item.id}
className="pit-message"
data-role="user"
data-thread-item="user_message"
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
@@ -93,7 +123,8 @@ function ThreadItemView({
return (
<article
id={item.id}
className="pit-card"
className="pit-message"
data-role="assistant"
data-thread-item="agent_message"
data-streaming={item.streaming}
>
@@ -141,6 +172,9 @@ function ThreadItemView({
case "tool_activity":
return <ToolStrip item={item} callbacks={callbacks} />;
case "progress_activity":
return <ProgressItem item={item} />;
case "denial":
return (
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
@@ -311,7 +345,7 @@ export function TurnGroup({
return (
<section className="pit-turn" data-turn-id={turn.id} aria-label={`Turn ${turn.ordinal}`}>
<h2 className="pit-turn-header">
<span>
<span className="pit-turn-label">
Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call
{turn.toolCallCount === 1 ? "" : "s"} ·{" "}
{new Date(turn.at).toISOString().slice(11, 19)}
@@ -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
@@ -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;
@@ -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: [
@@ -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");
@@ -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<Phase7SemanticOperationId>([
"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",
);
@@ -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",
@@ -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),
})),
};
}
@@ -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 {
@@ -57,8 +57,23 @@ const PROVIDER_EVENT_CATEGORIES: Readonly<Record<string, string>> = 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<string> = new Set([
"finish_task",
@@ -179,7 +194,7 @@ export function redactPhase7EvidenceData(
): Record<string, Phase7JsonValue> {
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<string, Phase7JsonValue>,
): 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;
}
@@ -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) {