mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## 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>
417 lines
16 KiB
TypeScript
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$/);
|
|
});
|
|
});
|