mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(runner): surface live chat progress
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user