Files
PaperClipAI/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx
T
DottaandClaude Opus 5.5 b0fa2ef9bf 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>
2026-11-05 16:37:05 +00:00

417 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskChatThreadView } from "./TaskChatThreadView";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatExpansionState } from "./expansion-state";
import type {
TaskChatActivityPhaseItem,
TaskChatProviderActivityItem,
TaskChatToolItem,
} from "./task-chat-model";
const motion = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...(await original<typeof import("motion/react")>()),
useReducedMotion: () => motion.reduced,
}));
const tool = (
id: string,
status: TaskChatToolItem["status"] = "in_progress",
): TaskChatToolItem => ({
id,
kind: "tool",
name: "exec_command",
target: `command-${id}`,
status,
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;
let memory: Map<string, boolean>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
memory = new Map();
motion.reduced = false;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (
items: TaskChatActivityPhaseItem["items"],
host = "live",
active = true,
) =>
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<TaskChatRunnerActivityGroup
key={host}
item={{
id: "commentary:phase",
kind: "activity_phase",
items,
active,
summary: "",
}}
/>
</TaskChatExpansionState.Provider>,
),
);
const toggle = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-activity-phase-toggle"]',
)!;
const viewport = () =>
container.querySelector('[data-testid="task-chat-activity-viewport"]')!;
it("aligns runner commentary with the following agent reply", () => {
act(() => root.render(
<MemoryRouter>
<ThemeProvider>
<TaskChatRunnerActivityGroup item={{
id: "commentary:phase",
kind: "activity_phase",
interstitial: { id: "commentary", kind: "message", author: "agent", text: "First line" },
items: [],
active: false,
summary: "",
}} />
<TaskChatBubble item={{ id: "reply", kind: "message", author: "agent", text: "Later reply" }} />
</ThemeProvider>
</MemoryRouter>,
));
const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]');
const reply = container.querySelector('[data-testid="task-chat-agent-bubble"]');
expect(commentary?.classList.contains("px-1")).toBe(true);
expect(reply?.classList.contains("px-1")).toBe(true);
});
it("rolls to each new item once while status and token updates keep the current row mounted", () => {
render([tool("one")]);
const first = viewport().querySelector('[data-activity-row="one"]');
render([tool("one", "completed")]);
expect(viewport().querySelector('[data-activity-row="one"]')).toBe(first);
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(2);
const outgoing = container.querySelector(".runner-activity-roll-out")!;
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
const current = container.querySelector(".runner-activity-roll-in");
render([
tool("one", "completed"),
{ ...tool("two"), detail: "more output" },
]);
expect(container.querySelector(".runner-activity-roll-in")).toBe(current);
act(() =>
outgoing.dispatchEvent(
new Event("webkitAnimationEnd", { bubbles: true }),
),
);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(viewport().textContent).toContain("command-two");
});
it("updates provider reasoning text in place without restarting motion", () => {
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Check"],
streaming: true,
},
]);
const line = viewport().querySelector("[data-activity-row]");
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Checking the file"],
streaming: true,
},
]);
expect(viewport().querySelector("[data-activity-row]")).toBe(line);
expect(viewport().textContent).toContain("Checking the file");
expect(viewport().textContent).not.toContain("First");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
});
it("preserves expanded history and item detail across appends and live-to-history remount", () => {
render([tool("one", "completed")]);
act(() => toggle().click());
const row = container.querySelector<HTMLButtonElement>("li button")!;
act(() => row.click());
expect(container.textContent).toContain("output-one");
render([tool("one", "completed"), tool("two")]);
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.querySelector("li button")).toBe(row);
render(
[tool("one", "completed"), tool("two", "completed")],
"history",
false,
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
act(() => toggle().click());
expect(toggle().textContent).toContain("Ran commands");
expect(container.textContent).not.toContain("command-two");
});
it("keeps failures discoverable after later activity, with neutral detail and no X", () => {
render([tool("failed", "failed"), tool("next")]);
expect(toggle().textContent).not.toMatch(/\d+ failed/);
act(() => toggle().click());
expect(container.querySelector("li")?.textContent).toContain("failed");
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
expect(container.textContent).toContain("output-failed");
expect(container.querySelector(".lucide-x, .text-destructive")).toBeNull();
});
it("uses the same group and expansion memory in the real persisted thread renderer", () => {
render([tool("one", "failed"), tool("two")]);
act(() => toggle().click());
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<MemoryRouter>
<ThemeProvider>
<TaskChatThreadView
scroll={false}
items={[
{
id: "saved",
kind: "message",
author: "agent",
text: "Finished",
attachedTurn: {
id: "turn",
kind: "turn",
settled: true,
standaloneHeader: true,
summary: { toolCount: 2, added: 0, removed: 0 },
items: [
{
id: "commentary:phase",
kind: "activity_phase",
active: false,
summary: "Ran commands",
items: [
tool("one", "failed"),
tool("two", "completed"),
],
},
],
},
},
]}
/>
</ThemeProvider>
</MemoryRouter>
</TaskChatExpansionState.Provider>,
),
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
expect(container.textContent).toContain("Finished");
expect(container.querySelector(".text-destructive,.lucide-x")).toBeNull();
act(() => toggle().click());
expect(toggle().textContent).toContain("Ran commands");
expect(container.textContent).not.toContain("command-two");
expect(toggle().textContent).not.toMatch(/\d+ failed/);
});
it("uses the compact runner group for a legacy persisted turn", () => {
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<MemoryRouter>
<ThemeProvider>
<TaskChatThreadView
scroll={false}
items={[{
id: "legacy-turn",
kind: "turn",
settled: true,
summary: { toolCount: 1, added: 0, removed: 0 },
items: [{
id: "legacy-phase",
kind: "activity_phase",
active: false,
summary: "Ran a command",
items: [tool("legacy", "completed")],
}],
}]}
/>
</ThemeProvider>
</MemoryRouter>
</TaskChatExpansionState.Provider>,
),
);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]')!
.click(),
);
expect(container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-phase-summary"]')).toBeNull();
expect(container.textContent).toContain("Ran command");
expect(container.textContent).not.toContain("command-legacy");
});
it("does not offer empty disclosures for sparse activities", () => {
render([
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: true },
{ id: "tool-empty", kind: "tool", name: "read", status: "completed" },
{
id: "provider-empty",
kind: "protocol",
surface: "provider_activity",
family: "wait",
status: "running",
title: "Wait",
eventType: "wait.started",
details: [],
steps: [],
links: [],
children: [],
},
]);
act(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(
container.querySelectorAll(
"li button, li [aria-expanded], li [aria-controls]",
),
).toHaveLength(0);
expect(
container.querySelector(
'[data-testid="task-chat-runner-activity-detail"]',
),
).toBeNull();
});
it("settles to a summary and can resume without losing the current activity", () => {
const items = [tool("one", "failed"), tool("two", "completed")];
render(items);
expect(viewport().textContent).toContain("command-two");
render(items, "live", false);
expect(
container.querySelector('[data-testid="task-chat-activity-viewport"]'),
).toBeNull();
expect(toggle().textContent).toBe("Ran commands");
expect(toggle().getAttribute("aria-label")).toContain("ran commands");
expect(container.textContent).not.toContain("command-two");
act(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(toggle().textContent).not.toMatch(/\d+ failed/);
act(() => toggle().click());
render([...items, tool("three")]);
expect(viewport().textContent).toContain("command-three");
});
it("replaces immediately with reduced motion", () => {
motion.reduced = true;
render([tool("one")]);
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(
container.querySelector(
".runner-activity-roll-in, .runner-activity-roll-out",
),
).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$/);
});
});