feat(task-chat): show web searches, model switches, waits and verification checks as dedicated rows (#19262)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through the native runner. The runner turns harness
activity into typed canonical events such as `research.*`,
`model.route.changed`, `wait.*` and the run's own verification checks.
> - The task chat flattened these events into generic tool rows. A web
search showed as "Ran Research eventType: research.started, summary: …".
The run's verification checks were dropped completely.
> - Live tests found a second break: the Rust runner core forwarded
Codex `webSearch` items without their query, action or results. The
canonical `research.*` events therefore had `query: null`.
> - This pull request keeps the full payload for these families (as
#19252 did for plans), adds small dedicated rows, and keeps the
web-search fields through the durable runner.
> - The benefit is that users see what the agent searched for, which
model actually ran, how long a wait lasts, and whether each check
passed, without opening the run log.

## Linked Issues or Issue Description

Refs #19252 (same pattern for the plan card)

**Subsystem affected**
Task chat activity rows (UI), the native run-event transcript mapping,
and the Rust runner core Codex normalizer.

**Problem or motivation**
Provider activity that the runner already maps to canonical events
reaches the task chat as unreadable generic rows. Verification checks
never appear. For Codex, the web-search query and action are lost before
they reach the canonical event.

**Proposed solution**
Keep `research`, `model_identity`, `artifact`, `review` and `wait`
events as `provider_activity` entries with their full payload. Project
the runner's `verification` items as a new `verification` family. Render
dedicated rows: a web search with its quoted query and source-domain
chips, a model switch as `from → to · reason`, a wait with a live
countdown, and green/red check lines. Keep bounded web-search and sleep
fields in the Rust normalizer.

**Alternatives considered**
A separate "Checks" block beside the final answer. We kept the checks in
the activity group, because the group summary already leads with "Check
failed" when a check fails.

## What Changed

- `ui/src/components/transcript/native-run-events.ts`: keep research,
model, artifact, review and wait events as `provider_activity` with the
full payload. Map `item.completed` items of kind `verification` to the
new `verification` family. Context, hook, safety and delegation stay on
the generic path, because other work items own them.
- `packages/adapter-utils/src/types.ts`,
`ui/src/components/task-chat/task-chat-model.ts`,
`ui/src/lib/issue-chat-messages.ts`: add the `verification`
provider-activity family.
- `ui/src/components/task-chat/task-chat-activity-presentation.ts`: add
labels: "Searched the web “query”", "Opened a web page host/path",
"Switched models a → b · reason", "Waiting 30s" / "Waited 30s", and
"Check passed" / "Check failed" / "Check not run".
- `ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx` and
`TaskChatProtocolActivityRow.tsx`: show up to three source-domain chips
under a finished search, a live countdown for a running wait, and a
check line with a tinted icon and its detail.
- `ui/src/components/task-chat/completed-activity-summary.ts`: the group
summary leads with "Check failed" when a check fails, and says "Switched
models" for a reroute.
- `ui/src/components/task-chat/transcript-adapter.ts`: verification
detail keys, and a `startedAt` timestamp that stays across lifecycle
merges (used by the countdown).
-
`packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs`:
keep bounded `query`, `action` and http(s) `results` for Codex
`webSearch` items, and `durationMs` for `sleep` items. Other item types
do not change.
- `ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx`:
new story. Its fixtures are persisted run events that go through the
production transcript path.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/transcript src/components/task-chat
src/components/TaskChatThread.test.tsx src/adapters/paperclip-runner
src/lib`: 275 files and 3669 tests pass.
- `cargo test -p paperclip-runner-core`: all tests pass, including the
new `keeps_bounded_web_search_and_sleep_identity`.
- UI typecheck is clean. `pnpm check:token-gates` is clean. `cargo fmt
--check` is clean.
- Storybook: open "Tasks and conversations / … / Task chat / Provider
activity". It shows a settled run, a live search, a live wait countdown
and a narrow layout.
- Live test on a local worktree instance with a real Codex agent
(gpt-5.6-sol, Codex app-server 0.160). The task asked for a web search,
a parser with tests, a pause, and two checks. The task chat showed
"Searched the web “ISO 8601 week date first week Thursday conversion
calendar date”" and two green check rows ("node --test week-date.test.js
· 8 tests passed; 0 failed.", "node --check week-date.js"). The group
summary read "Ran checks". Before the runner fix, the same search had
`query: null`.

Not verified live:
- The source-domain chips. Codex 0.160 sends `results: null`, because it
keeps search results on the server. The chips appear only when a
provider reports sources. Unit tests and the story cover them.
- The wait countdown and the model-switch row. In the live run Codex
paused with a shell `sleep`, not a native `sleep` item, and no reroute
occurred. Unit tests and the story cover both.
- Claude. Its WebSearch and WebFetch calls arrive as generic tool
executions, because ACPX drops the tool metadata. They show as tool
rows, as before. The ACPX `_meta` work is separate.

## Risks

- Low risk. The change only affects how canonical events display. The
generic path stays for the families that this PR does not change.
- The Rust normalizer now persists the web-search query and the source
URLs/titles in the run events. This data was already intended for the
canonical `research.v1` event, and it is bounded (4,000 characters per
text field, 64 results, http(s) URLs only).
- A search that Codex reports with `action: other` and no query shows as
"Searched the web" with no subject.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edit, browser automation) and extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 16:37:05 +00:00
1 parent 053ae3920f
commit b0fa2ef9bf
16 files changed
+909 -32

No files matched your search

@@ -61,7 +61,10 @@ describe("TaskChatProtocolActivityRow", () => {
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-protocol-activity-row"] button');
expect(container.querySelector("article")).toBeNull();
expect(row?.textContent).toContain("Searched the web");
expect(row?.textContent).toContain("smoked pork shoulder");
expect(row?.textContent).toContain("“site:nextjs.org/docs/app/api-reference/config/eslint");
const strip = container.querySelector('[data-testid="task-chat-research-sources"]');
expect([...strip!.querySelectorAll("a")].map((link) => link.textContent)).toEqual(["nextjs.org"]);
expect(strip?.textContent).toContain("+5 more");
expect(
row
?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')
@@ -69,7 +72,7 @@ describe("TaskChatProtocolActivityRow", () => {
).toBe(true);
expect(
row?.querySelector(".task-chat-collapsed-line-fade")?.textContent,
).toContain("smoked pork shoulder");
).toContain("site:nextjs.org");
expect(row?.getAttribute("aria-expanded")).toBe("false");
expect(row?.getAttribute("aria-controls")).toMatch(/^task-chat-protocol-activity-/);
@@ -9,6 +9,7 @@ import {
} from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import { useTaskChatShimmer } from "./TaskChatShimmer";
import type {
@@ -20,6 +21,7 @@ import type {
TaskChatWorkspaceFileItem,
} from "./task-chat-model";
import {
durationLabel,
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
@@ -28,6 +30,7 @@ import {
const COMPACT_RESEARCH_RESULT_LIMIT = 5;
const COMPACT_WORKSPACE_FILE_LIMIT = 8;
const RESEARCH_SOURCE_PREVIEW_LIMIT = 3;
function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" | "informational" }) {
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-(--status-agent-running)" aria-hidden />;
@@ -228,9 +231,29 @@ function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
);
}
/** One-line outcome for a check: its detail, or why it did not run. */
export function verificationOutcome(item: TaskChatProviderActivityItem): string | undefined {
const detail = item.details.find((entry) => entry.label === "Detail")?.value;
if (detail) return detail;
const reason = item.details.find((entry) => entry.label === "Reason Code")?.value;
return reason ? reason.replaceAll("_", " ").replace(/^\w/, (letter) => letter.toUpperCase()) : undefined;
}
/** The full check: command and untruncated outcome, wrapped. */
function VerificationDetails({ item }: { item: TaskChatProviderActivityItem }) {
const outcome = verificationOutcome(item);
return (
<div className="flex min-w-0 flex-col gap-1.5" data-testid="task-chat-verification-details">
{item.summary ? <p className="min-w-0 break-all font-mono text-foreground">{item.summary}</p> : null}
{outcome ? <p className="min-w-0 whitespace-pre-wrap break-words">{outcome}</p> : null}
</div>
);
}
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
switch (item.surface) {
case "provider_activity": {
if (item.family === "verification") return verificationOutcome(item) ? <VerificationDetails item={item} /> : null;
const expandable = item.details.length > 0 || item.steps.length > 0 || item.links.length > 0 || item.children.length > 0 || Boolean(item.output) || Boolean(item.outputTruncated);
return expandable ? <ProviderDetails item={item} neutral={neutral} /> : null;
}
@@ -257,6 +280,8 @@ function safeActivityHref(value: string | null | undefined): string | undefined
}
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
// A check expands only when it has an outcome that may not fit on one line.
if (item.surface === "provider_activity" && item.family === "verification") return detailContent(item) !== null;
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
return detailContent(item) !== null || (item.surface === "provider_activity" && Boolean(item.summary?.trim()));
}
@@ -267,7 +292,55 @@ export function TaskChatProtocolActivityDetails({ item, neutral = false }: { ite
const href = safeActivityHref(item.href);
return href ? <a href={href} className="text-foreground underline">{item.title}</a> : <p>{item.title}</p>;
}
return <>{item.surface === "provider_activity" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
return <>{item.surface === "provider_activity" && item.family !== "verification" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
}
/** Source domains under a finished web search, so results are visible without expanding. */
export function ResearchSourceStrip({ item }: { item: TaskChatProviderActivityItem }) {
const seen = new Set<string>();
const sources = item.links.flatMap((link) => {
const host = sourceHostname(link.href);
if (seen.has(host)) return [];
seen.add(host);
return [{ ...link, host }];
});
if (sources.length === 0) return null;
const visible = sources.slice(0, RESEARCH_SOURCE_PREVIEW_LIMIT);
const hidden = item.links.length - visible.length;
return (
<div className="ml-7 flex min-w-0 flex-wrap items-center gap-1 pb-0.5" data-testid="task-chat-research-sources">
{visible.map((source) => (
<a
key={source.href}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-(length:--text-nano) text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
href={source.href}
target="_blank"
rel="noreferrer"
title={source.label}
>
<span className="truncate">{source.host}</span>
</a>
))}
{hidden > 0 ? <span className="text-(length:--text-nano) text-muted-foreground">+{hidden} more</span> : null}
</div>
);
}
/** Live "12s left" for an intentional wait with a planned duration. */
export function WaitCountdown({ item }: { item: TaskChatProviderActivityItem }) {
useSecondTick(true);
const planned = Number(item.details.find((detail) => detail.label === "Planned Duration Ms")?.value);
const startedAtMs = item.startedAt ? Date.parse(item.startedAt) : Number.NaN;
if (!Number.isFinite(planned) || !Number.isFinite(startedAtMs)) return null;
const remaining = Math.max(0, planned - (Date.now() - startedAtMs));
return <span className="shrink-0 tabular-nums" data-testid="task-chat-wait-countdown">{durationLabel(String(remaining))} left</span>;
}
export function verificationTone(item: TaskChatProviderActivityItem): string | undefined {
const status = item.details.find((detail) => detail.label === "Status")?.value;
if (status === "passed") return "text-(--status-task-icon-done)";
if (status === "failed") return "text-destructive";
return undefined;
}
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
@@ -304,6 +377,9 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
</div>
);
}
const verification = item.surface === "provider_activity" && item.family === "verification" ? item : null;
const runningWait = item.surface === "provider_activity" && item.family === "wait" && item.status === "running" ? item : null;
const research = item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? item : null;
const detail = detailContent(item);
const expandable = detail !== null;
const Icon = presentation.icon;
@@ -313,11 +389,17 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
const row = (
<>
<span className="flex w-5 shrink-0 items-center justify-center">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
<Icon className={cn("h-3.5 w-3.5 shrink-0", verification && verificationTone(verification))} aria-hidden data-testid="task-chat-protocol-activity-icon" />
</span>
<span data-conversation-tool-running={active && shimmer && item.surface === "provider_activity" && item.family === "tool_execution" ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className={cn("shrink-0 font-medium", active && shimmer && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
{presentation.detail ? <span className="task-chat-collapsed-line-fade min-w-0 flex-1 font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
{verification && verificationOutcome(verification) ? (
<span className="min-w-0 shrink truncate" data-testid="task-chat-verification-detail">
{verificationOutcome(verification)}
</span>
) : null}
{runningWait ? <WaitCountdown item={runningWait} /> : null}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
{expandable ? (
@@ -326,7 +408,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
aria-hidden
/>
) : null}
<StatusIcon status={status} />
{verification ? null : <StatusIcon status={status} />}
</span>
</>
);
@@ -359,6 +441,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
return (
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family={item.surface === "provider_activity" ? item.family : item.surface}>
{activityRow}
{research && !open ? <ResearchSourceStrip item={research} /> : null}
{open && detail ? (
<div id={detailId} className="ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5" data-testid="task-chat-protocol-activity-detail">
{detail}
@@ -6,6 +6,7 @@ import {
ChevronDown,
ChevronRight,
Circle,
CircleCheck,
CircleHelp,
Clock3,
ExternalLink,
@@ -76,6 +77,7 @@ const FAMILY_ICON = {
safety: ShieldCheck,
terminal: TerminalSquare,
wait: Clock3,
verification: CircleCheck,
provider_notice: AlertTriangle,
} as const;
@@ -10,6 +10,7 @@ import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatExpansionState } from "./expansion-state";
import type {
TaskChatActivityPhaseItem,
TaskChatProviderActivityItem,
TaskChatToolItem,
} from "./task-chat-model";
@@ -31,6 +32,27 @@ const tool = (
detail: `output-${id}`,
});
const providerItem = (
id: string,
family: TaskChatProviderActivityItem["family"],
status: TaskChatProviderActivityItem["status"],
details: TaskChatProviderActivityItem["details"],
extra: Partial<TaskChatProviderActivityItem> = {},
): TaskChatProviderActivityItem => ({
id,
kind: "protocol",
surface: "provider_activity",
family,
eventType: `${family}.activity`,
status,
title: family,
details,
steps: [],
links: [],
children: [],
...extra,
});
describe("TaskChatRunnerActivityGroup", () => {
let container: HTMLDivElement;
let root: Root;
@@ -334,4 +356,61 @@ describe("TaskChatRunnerActivityGroup", () => {
),
).toBeNull();
});
it("shows research sources, check outcomes and a wait countdown in expanded history", () => {
const search = providerItem("search", "research", "completed", [
{ label: "Action", value: "search" },
{ label: "Query", value: "temporal week dates" },
], {
links: [
{ label: "MDN", href: "https://developer.mozilla.org/a" },
{ label: "MDN again", href: "https://developer.mozilla.org/b" },
{ label: "TC39", href: "https://tc39.es/c" },
],
});
const passed = providerItem("check-1", "verification", "completed", [
{ label: "Status", value: "passed" },
{ label: "Detail", value: "6 tests passed" },
], { summary: "node --test" });
const failed = providerItem("check-2", "verification", "failed", [
{ label: "Status", value: "failed" },
], { summary: "pnpm typecheck" });
const skipped = providerItem("check-3", "verification", "informational", [
{ label: "Status", value: "not_run" },
{ label: "Reason Code", value: "policy_restricted" },
], { summary: "pnpm test:e2e" });
const longFailure = "x".repeat(300);
const failedLong = providerItem("check-4", "verification", "failed", [
{ label: "Status", value: "failed" },
{ label: "Detail", value: longFailure },
], { summary: "pnpm typecheck" });
const wait = providerItem("wait", "wait", "running", [
{ label: "Planned Duration Ms", value: "30000" },
], { startedAt: new Date(Date.now() - 12_000).toISOString() });
render([search, passed, failed, skipped, failedLong, wait], "live", false);
act(() => toggle().click());
const strip = container.querySelector('[data-testid="task-chat-research-sources"]');
expect([...strip!.querySelectorAll("a")].map((link) => link.textContent)).toEqual(["developer.mozilla.org", "tc39.es"]);
expect(strip?.textContent).toContain("+1 more");
expect(container.textContent).toContain("Searched the web“temporal week dates”");
const passedRow = container.querySelector('[data-activity-row="check-1"]')!;
expect(passedRow.textContent).toContain("Check passed");
expect(passedRow.textContent).toContain("6 tests passed");
expect(passedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("--status-task-icon-done");
const failedRow = container.querySelector('[data-activity-row="check-2"]')!;
expect(failedRow.textContent).toContain("Check failed");
expect(failedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("text-destructive");
expect(failedRow.closest("button")).toBeNull();
expect(container.querySelector('[data-activity-row="check-3"]')?.textContent).toContain("Policy restricted");
const longRow = container.querySelector<HTMLButtonElement>('[data-activity-item-id="check-4"] button')!;
act(() => longRow.click());
const detail = container.querySelector('[data-activity-item-id="check-4"] [data-testid="task-chat-verification-details"]');
expect(detail?.textContent).toContain("pnpm typecheck");
expect(detail?.textContent).toContain(longFailure);
expect(container.querySelector('[data-activity-row="wait"]')?.textContent).toContain("Waiting 30s");
expect(container.querySelector('[data-testid="task-chat-wait-countdown"]')?.textContent).toMatch(/^1[78]s left$/);
});
});
@@ -1,4 +1,4 @@
import { useId, useState } from "react";
import { useId, useState, type ReactNode } from "react";
import { completedActivitySummary } from "./completed-activity-summary";
import {
Brain,
@@ -18,16 +18,46 @@ import {
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
import {
ResearchSourceStrip,
TaskChatProtocolActivityDetails,
WaitCountdown,
hasTaskChatProtocolActivityDetails,
verificationOutcome,
verificationTone,
} from "./TaskChatProtocolActivityRow";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { toolActivityPresentation } from "./tool-taxonomy";
import { toolActivityPresentation, type ToolIcon } from "./tool-taxonomy";
import { useTaskChatShimmer } from "./TaskChatShimmer";
type Activity = TaskChatActivityPhaseItem["items"][number];
function presentation(item: Activity, active: boolean) {
interface ActivityRowPresentation {
icon: ToolIcon;
label: string;
target?: string;
mono: boolean;
running: boolean;
/** Colors the leading icon, e.g. green or red for a verification check. */
iconClassName?: string;
/** Short plain-text outcome after the target, e.g. "6 tests passed". */
trailing?: ReactNode;
}
function providerTrailing(item: Extract<Activity, { kind: "protocol" }>): Pick<ActivityRowPresentation, "iconClassName" | "trailing"> {
if (item.surface !== "provider_activity") return {};
if (item.family === "verification") {
return {
iconClassName: verificationTone(item),
trailing: verificationOutcome(item),
};
}
if (item.family === "wait" && item.status === "running") {
return { trailing: <WaitCountdown item={item} /> };
}
return {};
}
function presentation(item: Activity, active: boolean): ActivityRowPresentation | null {
if (item.kind === "tool") {
const tool = toolActivityPresentation({
name: item.rawName ?? item.name,
@@ -72,6 +102,7 @@ function presentation(item: Activity, active: boolean) {
target: protocol.detail,
mono: true,
running: active && protocolActivityIsRunning(item),
...providerTrailing(item),
};
}
if (item.kind === "marker")
@@ -130,7 +161,7 @@ function ActivityContent({
className="flex size-5 shrink-0 items-center justify-center"
data-activity-icon
>
<Icon className="size-3.5" aria-hidden="true" />
<Icon className={cn("size-3.5", row.iconClassName)} aria-hidden="true" />
</span>
<span data-conversation-tool-running={row.running && shimmer && (item.kind === "tool" || (item.kind === "protocol" && item.surface === "provider_activity" && item.family === "tool_execution")) ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
<span
@@ -153,6 +184,11 @@ function ActivityContent({
{row.target}
</span>
) : null}
{row.trailing ? (
<span className="min-w-0 truncate text-xs text-muted-foreground" data-testid="task-chat-activity-trailing">
{row.trailing}
</span>
) : null}
</span>
</span>
);
@@ -294,6 +330,9 @@ function ExpandedActivity({
{content}
</div>
)}
{!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? (
<ResearchSourceStrip item={item} />
) : null}
{expandable && open ? (
<div
id={detailId}
@@ -186,4 +186,13 @@ describe("completedActivitySummary", () => {
expect(result.label).not.toContain("Distinct provider message");
});
it("leads with a failed check and names model switches", () => {
const route = { ...provider("model_identity"), eventType: "model.route.changed" };
const passed = { ...provider("verification"), id: "check-1" };
const failed = { ...provider("verification", "failed"), id: "check-2" };
expect(completedActivitySummary([route, passed]).fullLabel).toBe("Switched models, ran checks");
expect(completedActivitySummary([route, passed, failed]).fullLabel).toBe("Check failed, switched models, ran checks");
const skipped = { ...provider("verification", "informational"), id: "check-3", details: [{ label: "Status", value: "not_run" }] };
expect(completedActivitySummary([skipped]).fullLabel).toBe("Skipped checks");
});
});
@@ -80,6 +80,17 @@ export function completedActivitySummary(items: Activity[]) {
notices.push({ summary, severity: providerNoticeSeverity(item), icon: p.icon, order });
continue;
}
if (item.surface === "provider_activity" && item.family === "verification") {
// A failed check stays visible even when the compact label truncates.
if (item.status === "failed") add("Check failed", p.icon, -1);
else if (item.details.find((entry) => entry.label === "Status")?.value === "not_run") add("Skipped checks", p.icon, order);
else add("Ran checks", p.icon, order);
continue;
}
if (item.surface === "provider_activity" && item.eventType === "model.route.changed") {
add("Switched models", p.icon, order);
continue;
}
const family =
item.surface === "provider_activity" ? item.family : item.surface;
const label =
@@ -97,6 +108,7 @@ export function completedActivitySummary(items: Activity[]) {
safety: "Reviewed safety",
terminal: "Ran commands",
wait: "Waited",
verification: "Ran checks",
workspace_change: "Worked on files",
workspace_file: "Referenced files",
resource: "Added resources",
@@ -2,6 +2,9 @@ import {
AlertTriangle,
BookOpen,
Bot,
CircleCheck,
CircleDashed,
CircleX,
Box,
Clock3,
Database,
@@ -9,6 +12,7 @@ import {
FilePenLine,
FileText,
GitBranch,
Globe,
Info,
ListChecks,
PackageCheck,
@@ -56,19 +60,54 @@ function meaningfulToolDetail(value: string | undefined): string | undefined {
return value;
}
/** Host and path without scheme or query, for one-line web activity. */
export function compactUrl(href: string): string {
try {
const url = new URL(href);
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return `${url.hostname.replace(/^www\./, "")}${path}`;
} catch {
return href;
}
}
/** Whole-second duration such as "30s" or "2m 5s" from a millisecond detail. */
export function durationLabel(value: string | undefined): string | undefined {
if (!value) return undefined;
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return undefined;
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
function modelRouteDetail(item: TaskChatProviderActivityItem): string | undefined {
const from = providerDetail(item, "From Model") ?? providerDetail(item, "Requested Model");
const to = providerDetail(item, "Effective Model");
if (!to || to === "unknown") return undefined;
const reason = providerDetail(item, "Reason");
const route = from && from !== "unknown" && from !== to ? `${from} → ${to}` : to;
return reason && reason !== "provider reroute" ? `${route} · ${reason}` : route;
}
export function providerActivityPresentation(item: TaskChatProviderActivityItem): TaskChatActivityPresentation {
const detail = item.summary
?? providerDetail(item, "Query", "URL", "Target", "Name", "Reference", "Reason", "Summary");
switch (item.family) {
case "research": {
const action = providerDetail(item, "Action")?.toLowerCase();
const query = providerDetail(item, "Query");
const url = providerDetail(item, "Url");
if (action === "open_page") {
return { icon: Search, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail };
return { icon: Globe, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail: url ? compactUrl(url) : detail };
}
if (action === "find_in_page") {
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail };
const pattern = providerDetail(item, "Pattern");
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail: [pattern ? `“${pattern}”` : undefined, url ? compactUrl(url) : undefined].filter(Boolean).join(" in ") || detail };
}
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail };
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail: query ? `“${query}”` : detail };
}
case "tool_execution": {
const name = providerDetail(item, "Name");
@@ -115,7 +154,7 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
}
case "model_identity":
return item.eventType === "model.route.changed"
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail }
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail: modelRouteDetail(item) ?? detail }
: { icon: Bot, runningLabel: "Verifying the model", completedLabel: "Verified the model", failedLabel: "Model verification failed", interruptedLabel: "Model verification stopped", detail };
case "context":
return { icon: Database, runningLabel: "Compacting context", completedLabel: "Compacted context", failedLabel: "Context compaction failed", interruptedLabel: "Context compaction stopped", detail };
@@ -139,8 +178,28 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
return { icon: ShieldCheck, runningLabel: "Reviewing safety", completedLabel: "Reviewed safety", failedLabel: "Safety review failed", interruptedLabel: "Safety review stopped", detail };
case "terminal":
return { icon: TerminalSquare, runningLabel: "Sending terminal input", completedLabel: "Sent terminal input", failedLabel: "Terminal input failed", interruptedLabel: "Terminal input stopped", detail };
case "wait":
return { icon: Clock3, runningLabel: "Waiting", completedLabel: "Finished waiting", failedLabel: "Wait failed", interruptedLabel: "Wait stopped", detail };
case "wait": {
const planned = durationLabel(providerDetail(item, "Planned Duration Ms"));
const elapsed = durationLabel(providerDetail(item, "Elapsed Duration Ms"));
return {
icon: Clock3,
runningLabel: planned ? `Waiting ${planned}` : "Waiting",
completedLabel: elapsed ?? planned ? `Waited ${elapsed ?? planned}` : "Finished waiting",
failedLabel: "Wait failed",
interruptedLabel: elapsed ? `Stopped waiting after ${elapsed}` : "Wait stopped",
};
}
case "verification": {
const status = providerDetail(item, "Status");
return {
icon: status === "passed" ? CircleCheck : status === "failed" ? CircleX : CircleDashed,
runningLabel: "Running a check",
completedLabel: status === "not_run" ? "Check not run" : "Check passed",
failedLabel: "Check failed",
interruptedLabel: "Check not run",
detail: item.summary,
};
}
case "provider_notice":
return { icon: providerNoticeSeverity(item) === "info" ? Info : AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
}
@@ -46,6 +46,7 @@ export type TaskChatProviderActivityFamily =
| "safety"
| "terminal"
| "wait"
| "verification"
| "provider_notice";
export type TaskChatProviderActivityStatus =
@@ -381,6 +382,8 @@ export interface TaskChatProviderActivityItem {
outputTruncated?: boolean;
/** Index of the latest provider event coalesced into this activity. */
transcriptIndex?: number;
/** When the provider first reported this activity (ISO timestamp). */
startedAt?: string;
}
export interface TaskChatWorkspaceChangeItem {
@@ -317,6 +317,7 @@ const PROVIDER_DETAIL_KEYS: Record<
safety: ["status", "decision", "targetExecutionId", "summary"],
terminal: ["origin", "inputClass", "byteCount"],
wait: ["reason", "status", "plannedDurationMs", "elapsedDurationMs"],
verification: ["status", "detail", "reasonCode"],
provider_notice: [
"severity",
"category",
@@ -377,7 +378,8 @@ function providerActivityItem(
label: titleCaseKey(key),
value: clip(
value,
entry.family === "provider_notice" && (key === "summary" || key === "message")
(entry.family === "provider_notice" && (key === "summary" || key === "message")) ||
(entry.family === "verification" && key === "detail")
? 4000
: key === "message" || key === "summary" || key === "reason" ? 320 : 160,
),
@@ -484,6 +486,7 @@ function providerActivityItem(
output: rawOutput ? rawOutput.slice(0, PROTOCOL_OUTPUT_MAX) : undefined,
outputTruncated,
transcriptIndex: index,
startedAt: entry.ts,
};
}
@@ -513,7 +516,7 @@ function mergeProviderActivityItem(
previous.family !== "tool_execution" ||
incoming.family !== "tool_execution"
) {
return { ...incoming, id: previous.id };
return { ...incoming, id: previous.id, startedAt: previous.startedAt ?? incoming.startedAt };
}
const details = new Map(
previous.details.map((detail) => [detail.label, detail]),
@@ -547,6 +550,7 @@ function mergeProviderActivityItem(
return {
...incoming,
id: previous.id,
startedAt: previous.startedAt ?? incoming.startedAt,
summary: meaningfulProviderToolSummary(incoming.summary)
? incoming.summary
: previous.summary,
@@ -876,31 +876,138 @@ describe("nativeRunEventsToTranscript", () => {
]);
});
it("projects provider-neutral activity without exposing provider envelopes", () => {
expect(nativeRunEventsToTranscript([
it("keeps web research as a provider activity with its sources", () => {
const transcript = nativeRunEventsToTranscript([
event(1, "research.started", {
schema: "paperclip.research.v1",
researchId: "research-1",
action: "search",
query: "current behavior",
status: "running",
sources: [],
}),
event(2, "research.completed", {
schema: "paperclip.research.v1",
researchId: "research-1",
action: "search",
query: "current behavior",
status: "completed",
sources: [{ sourceId: "s1", url: "https://example.com/a", title: "A", snippet: null }],
}),
]);
expect(transcript).toEqual([
expect.objectContaining({ kind: "provider_activity", family: "research", status: "running", summary: "current behavior" }),
expect.objectContaining({
kind: "provider_activity",
family: "research",
status: "completed",
payload: expect.objectContaining({ sources: [expect.objectContaining({ url: "https://example.com/a" })] }),
}),
]);
expect(transcript.some((entry) => entry.kind === "tool_call" || entry.kind === "tool_result")).toBe(false);
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false });
const research = items.filter((item) => item.kind === "protocol" && item.surface === "provider_activity");
expect(research).toHaveLength(1);
expect(research[0]).toEqual(expect.objectContaining({
status: "completed",
links: [expect.objectContaining({ href: "https://example.com/a" })],
startedAt: "2026-08-25T18:00:01.000Z",
}));
});
it("does not invent a subject for a search the provider reported without a query", () => {
expect(nativeRunEventsToTranscript([
event(1, "research.completed", {
schema: "paperclip.research.v1",
researchId: "research-2",
action: "other",
query: null,
url: null,
pattern: null,
status: "completed",
sources: [],
}),
])).toEqual([expect.objectContaining({ kind: "provider_activity", family: "research", summary: "" })]);
});
it("keeps model reroutes and waits as provider activity with their payload", () => {
const transcript = nativeRunEventsToTranscript([
event(1, "model.route.changed", {
schema: "paperclip.model.route_changed.v1",
routeId: "turn-1",
provider: "openai",
requestedModel: "gpt-a",
fromModel: "gpt-a",
effectiveModel: "gpt-b",
reason: "capacity",
}),
event(2, "wait.started", {
schema: "paperclip.wait.v1",
waitId: "sleep-1",
reason: "timer",
status: "running",
plannedDurationMs: 30000,
elapsedDurationMs: null,
}),
]);
expect(transcript).toEqual([
expect.objectContaining({ kind: "provider_activity", family: "model_identity", status: "completed", payload: expect.objectContaining({ effectiveModel: "gpt-b" }) }),
expect.objectContaining({ kind: "provider_activity", family: "wait", status: "running", payload: expect.objectContaining({ plannedDurationMs: 30000 }) }),
]);
});
it("projects the runner's verification items as check activity", () => {
const transcript = nativeRunEventsToTranscript([
itemEvent(1, "item.completed", "result:verification:1", {
kind: "verification",
text: "passed: node --test",
verification: { commandOrCheck: "node --test", status: "passed", detail: "6 tests passed" },
}),
itemEvent(2, "item.completed", "result:verification:2", {
kind: "verification",
text: "failed: pnpm typecheck",
verification: { commandOrCheck: "pnpm typecheck", status: "failed" },
}),
itemEvent(3, "item.completed", "result:verification:3", { kind: "verification", text: "malformed" }),
itemEvent(4, "item.completed", "result:verification:4", {
kind: "verification",
text: "not_run: pnpm test:e2e",
verification: { commandOrCheck: "pnpm test:e2e", status: "not_run", reasonCode: "policy_restricted", detail: "x".repeat(300) },
}),
]);
expect(transcript).toEqual([
expect.objectContaining({
kind: "provider_activity",
family: "verification",
status: "completed",
summary: "node --test",
payload: expect.objectContaining({ verificationId: "result:verification:1", status: "passed", detail: "6 tests passed" }),
}),
expect.objectContaining({ kind: "provider_activity", family: "verification", status: "failed", summary: "pnpm typecheck" }),
expect.objectContaining({ family: "verification", status: "informational", payload: expect.objectContaining({ reasonCode: "policy_restricted" }) }),
]);
const checks = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false })
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity");
expect(checks).toHaveLength(3);
const skipped = checks[2];
expect(skipped.kind === "protocol" && skipped.surface === "provider_activity" && skipped.details).toEqual(expect.arrayContaining([
{ label: "Reason Code", value: "policy_restricted", mono: true },
{ label: "Detail", value: "x".repeat(300), mono: false },
]));
});
it("leaves families owned by other surfaces on the generic activity path", () => {
expect(nativeRunEventsToTranscript([
event(1, "hook.completed", {
schema: "paperclip.hook.v1",
hookId: "hook-1",
event: "PreToolUse",
status: "completed",
summary: "lint hook",
}),
])).toEqual([
expect.objectContaining({
kind: "tool_call",
name: "research",
toolUseId: "research:research-1",
}),
expect.objectContaining({
kind: "tool_result",
toolUseId: "research:research-1",
content: "current behavior",
}),
expect.objectContaining({ kind: "tool_call", toolUseId: "hook:hook-1" }),
expect.objectContaining({ kind: "tool_result", toolUseId: "hook:hook-1" }),
]);
});
@@ -916,8 +1023,9 @@ describe("nativeRunEventsToTranscript", () => {
}),
])).toEqual([
expect.objectContaining({
kind: "tool_call",
toolUseId: "artifact:artifact-1",
kind: "provider_activity",
family: "artifact",
status: "running",
}),
]);
},
@@ -504,6 +504,92 @@ function providerActivityPresentation(
};
}
/**
* Activity families that render as dedicated task-chat cards. They keep the
* full canonical payload (sources, models, durations) instead of collapsing
* into a one-line tool row. Families owned by other surfaces stay on the
* generic path until they get their own presentation.
*/
const CARD_PROVIDER_ACTIVITY_FAMILIES: Partial<Record<ProviderActivityEventType, {
family: Extract<TranscriptEntry, { kind: "provider_activity" }>["family"];
title: string;
}>> = {
"research.started": { family: "research", title: "Research" },
"research.progressed": { family: "research", title: "Research" },
"research.completed": { family: "research", title: "Research" },
"model.route.changed": { family: "model_identity", title: "Model" },
"model.verification.updated": { family: "model_identity", title: "Model" },
"artifact.viewed": { family: "artifact", title: "Artifact" },
"artifact.generated": { family: "artifact", title: "Artifact" },
"review.mode.changed": { family: "review", title: "Review mode" },
"wait.started": { family: "wait", title: "Intentional wait" },
"wait.completed": { family: "wait", title: "Intentional wait" },
};
function cardProviderActivityEntry(
event: HeartbeatRunEvent,
payload: Record<string, unknown>,
ts: string,
): Extract<TranscriptEntry, { kind: "provider_activity" }> | null {
const card = CARD_PROVIDER_ACTIVITY_FAMILIES[event.eventType as ProviderActivityEventType];
if (!card) return null;
const presentation = providerActivityPresentation(event, payload);
if (!presentation) return null;
const rawStatus = text(payload.status);
const status: Extract<TranscriptEntry, { kind: "provider_activity" }>["status"] = presentation.failed
? "failed"
: rawStatus === "interrupted" || rawStatus === "cancelled"
? "interrupted"
: !presentation.terminal
? "running"
: "completed";
return {
kind: "provider_activity",
ts,
family: card.family,
eventType: event.eventType,
status,
title: card.title,
// No query, URL or reason: leave the row's label to speak for itself
// rather than echoing a generic family title as if it were a subject.
summary: presentation.summary === event.eventType ? "" : presentation.summary,
payload,
};
}
/**
* The runner's own verification checks (one item per check in the proposed
* run result) become check rows, so a failed check is visible where it ran.
*/
function verificationActivityEntry(
payload: Record<string, unknown>,
itemId: string | null,
ts: string,
): Extract<TranscriptEntry, { kind: "provider_activity" }> | null {
const check = record(payload.verification);
const commandOrCheck = text(check?.commandOrCheck)?.trim();
if (!check || !commandOrCheck) return null;
const status = verificationStatus(check.status);
const detail = text(check.detail)?.trim() || null;
const reasonCode = status === "not_run" ? text(check.reasonCode)?.trim() || null : null;
return {
kind: "provider_activity",
ts,
family: "verification",
eventType: "verification.recorded",
status: status === "passed" ? "completed" : status === "failed" ? "failed" : "informational",
title: "Verification",
summary: commandOrCheck,
payload: {
verificationId: itemId ?? `${commandOrCheck}:${ts}`,
commandOrCheck,
status,
detail,
reasonCode,
},
};
}
function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>): string {
const emittedAt = text(envelope.emittedAt);
if (emittedAt) return emittedAt;
@@ -780,6 +866,12 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
if (event.eventType === "item.completed" && payload.kind === "verification") {
const entry = verificationActivityEntry(payload, itemId, ts);
if (entry) entries.push(entry);
continue;
}
// Some provider transports expose their complete dynamic-tool lifecycle
// directly as item.started/item.completed events and do not emit the
// parallel tool.execution.* activity stream. Project those canonical
@@ -864,6 +956,12 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
const cardActivity = cardProviderActivityEntry(event, payload, ts);
if (cardActivity) {
entries.push(cardActivity);
continue;
}
const providerActivity = providerActivityPresentation(event, payload);
if (providerActivity) {
if (!startedToolIds.has(providerActivity.id)) {