mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +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>
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { MemoryRouter } from "@/lib/router";
|
|
import type { TaskChatProtocolItem, TaskChatProviderActivityItem } from "./task-chat-model";
|
|
import { TaskChatProtocolActivityRow, TaskChatProtocolActivityDetails } from "./TaskChatProtocolActivityRow";
|
|
|
|
describe("TaskChatProtocolActivityRow", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
function render(item: TaskChatProtocolItem) {
|
|
act(() => root.render(
|
|
<MemoryRouter>
|
|
<ThemeProvider>
|
|
<TaskChatProtocolActivityRow item={item} />
|
|
</ThemeProvider>
|
|
</MemoryRouter>,
|
|
));
|
|
}
|
|
|
|
it("renders long research results as a bounded readable list", () => {
|
|
const item: TaskChatProviderActivityItem = {
|
|
id: "research",
|
|
kind: "protocol",
|
|
surface: "provider_activity",
|
|
family: "research",
|
|
eventType: "research.completed",
|
|
status: "completed",
|
|
title: "Research",
|
|
summary: "smoked pork shoulder",
|
|
details: [
|
|
{ label: "Action", value: "search" },
|
|
{ label: "Status", value: "completed" },
|
|
{ label: "Query", value: "site:nextjs.org/docs/app/api-reference/config/eslint Next.js 16 eslint.config.mjs core-web-vitals typescript", mono: true },
|
|
],
|
|
steps: [],
|
|
links: Array.from({ length: 6 }, (_, index) => ({
|
|
label: `Next.js configuration result ${index + 1}`,
|
|
href: `https://nextjs.org/docs/result-${index + 1}`,
|
|
description: "A long provider-authored snippet that stays on one compact line in the activity feed.",
|
|
})),
|
|
children: [],
|
|
};
|
|
render(item);
|
|
|
|
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("“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"]')
|
|
?.parentElement?.classList.contains("justify-center"),
|
|
).toBe(true);
|
|
expect(
|
|
row?.querySelector(".task-chat-collapsed-line-fade")?.textContent,
|
|
).toContain("site:nextjs.org");
|
|
expect(row?.getAttribute("aria-expanded")).toBe("false");
|
|
expect(row?.getAttribute("aria-controls")).toMatch(/^task-chat-protocol-activity-/);
|
|
|
|
act(() => row?.click());
|
|
|
|
expect(row?.getAttribute("aria-expanded")).toBe("true");
|
|
const detail = container.querySelector('[data-testid="task-chat-protocol-activity-detail"]');
|
|
expect(detail?.id).toBe(row?.getAttribute("aria-controls"));
|
|
expect(detail?.querySelector('[data-testid="task-chat-research-query"]')?.textContent).toContain("eslint.config.mjs");
|
|
expect(detail?.querySelectorAll('ol[aria-label="Research sources"] li')).toHaveLength(5);
|
|
expect(detail?.textContent).toContain("Show 1 more result");
|
|
expect(detail?.textContent).not.toContain("Actionsearch");
|
|
|
|
const showMore = Array.from(detail?.querySelectorAll("button") ?? []).find((button) => button.textContent?.includes("Show 1 more result"));
|
|
act(() => showMore?.click());
|
|
expect(detail?.querySelectorAll('ol[aria-label="Research sources"] li')).toHaveLength(6);
|
|
});
|
|
|
|
it("summarizes workspace files without rendering inline diff bodies", () => {
|
|
render({
|
|
id: "workspace",
|
|
kind: "protocol",
|
|
surface: "workspace_change",
|
|
changeSetId: "change-1",
|
|
revision: 1,
|
|
source: "runner_verified",
|
|
complete: true,
|
|
files: Array.from({ length: 10 }, (_, index) => ({
|
|
path: `ui/src/file-${index + 1}.tsx`,
|
|
operation: "modify" as const,
|
|
previousPath: null,
|
|
additions: 1,
|
|
deletions: 1,
|
|
binary: false,
|
|
diff: `-old-${index + 1}\n+new-${index + 1}`,
|
|
})),
|
|
totals: { files: 10, additions: 10, deletions: 10 },
|
|
patchArtifactRef: null,
|
|
});
|
|
|
|
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-protocol-activity-row"] button');
|
|
expect(row?.textContent).toContain("Edited files");
|
|
act(() => row?.click());
|
|
expect(container.textContent).toContain("ui/src/file-1.tsx");
|
|
expect(container.textContent).toContain("2 more files not shown");
|
|
expect(container.textContent).not.toContain("new-1");
|
|
expect(container.querySelector('[data-testid="task-chat-workspace-change-details"] pre')).toBeNull();
|
|
});
|
|
|
|
it.each(["javascript:alert(1)", "java\nscript:alert(1)", "data:text/html,unsafe", "file:///etc/passwd", "vbscript:unsafe"])("rejects unsafe resource URLs in rows and details: %s", (href) => {
|
|
const item: TaskChatProtocolItem = { id: "unsafe-resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href };
|
|
render(item);
|
|
expect(container.querySelector("a")).toBeNull();
|
|
act(() => root.render(<TaskChatProtocolActivityDetails item={item} />));
|
|
expect(container.querySelector("a")).toBeNull();
|
|
expect(container.textContent).toContain("Notes");
|
|
});
|
|
|
|
it.each(["https://example.com/report", "http://example.com/report", "/documents/notes", "#notes"])("allows safe resource links in details: %s", (href) => {
|
|
act(() => root.render(<TaskChatProtocolActivityDetails item={{ id: "resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href }} />));
|
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(href);
|
|
});
|
|
|
|
it("renders durable resources as direct compact links", () => {
|
|
render({
|
|
id: "resource",
|
|
kind: "protocol",
|
|
surface: "resource",
|
|
resourceKind: "document",
|
|
title: "Implementation notes",
|
|
subtitle: "markdown · ready",
|
|
href: "/documents/notes",
|
|
});
|
|
|
|
const link = container.querySelector<HTMLAnchorElement>('[data-testid="task-chat-protocol-activity-row"]');
|
|
expect(link?.getAttribute("href")).toBe("/documents/notes");
|
|
expect(link?.textContent).toContain("Added a document");
|
|
expect(link?.textContent).toContain("Implementation notes");
|
|
});
|
|
|
|
it("renders MCP executions with semantic copy, source metadata, and the MCP icon", () => {
|
|
render({
|
|
id: "tool",
|
|
kind: "protocol",
|
|
surface: "provider_activity",
|
|
family: "tool_execution",
|
|
eventType: "tool.execution.completed",
|
|
status: "completed",
|
|
title: "Tool execution",
|
|
summary: "Searching the task index",
|
|
details: [
|
|
{ label: "Transport", value: "mcp" },
|
|
{ label: "Namespace", value: "paperclip", mono: true },
|
|
{ label: "Operation", value: "search" },
|
|
{ label: "Name", value: "search_tasks", mono: true },
|
|
],
|
|
steps: [],
|
|
links: [],
|
|
children: [],
|
|
});
|
|
|
|
const row = container.querySelector('[data-testid="task-chat-protocol-activity-row"]');
|
|
expect(row?.textContent).toContain("Searched tasks");
|
|
expect(row?.textContent).toContain("Searching the task index · Paperclip · search_tasks");
|
|
expect(row?.textContent).not.toMatch(/Ran a tool|Tool execution|tool call/i);
|
|
expect(row?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.querySelectorAll("path")).toHaveLength(3);
|
|
});
|
|
|
|
it("keeps a notice readable and preserves metadata in a collapsed disclosure", () => {
|
|
const summary = "Project-local configuration is disabled.\nTrust the repository to load its hooks.";
|
|
render({
|
|
id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice",
|
|
eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary,
|
|
details: [
|
|
{ label: "Category", value: "configWarning" },
|
|
{ label: "Recoverable", value: "Yes" },
|
|
{ label: "Provider", value: "pi" },
|
|
{ label: "Provenance", value: "provider_native" },
|
|
{ label: "Summary", value: summary },
|
|
], steps: [], links: [], children: [],
|
|
});
|
|
expect(container.textContent).toContain(`Warning${summary}`);
|
|
expect(container.querySelector("p")?.textContent).toBe(summary);
|
|
const disclosure = container.querySelector("details");
|
|
expect(disclosure).not.toBeNull();
|
|
expect(disclosure?.open).toBe(false);
|
|
expect(disclosure?.querySelector("summary")?.textContent).toBe("Details");
|
|
expect(Array.from(disclosure?.querySelectorAll("dt") ?? [], (term) => term.textContent)).toEqual([
|
|
"Category", "Recoverable", "Provider", "Provenance",
|
|
]);
|
|
expect(Array.from(disclosure?.querySelectorAll("dd") ?? [], (value) => value.textContent)).toEqual([
|
|
"configWarning", "Yes", "pi", "provider_native",
|
|
]);
|
|
expect(disclosure?.textContent).not.toContain(summary);
|
|
act(() => disclosure?.querySelector("summary")?.click());
|
|
expect(disclosure?.open).toBe(true);
|
|
expect(container.querySelector("button")).toBeNull();
|
|
expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')).not.toBeNull();
|
|
});
|
|
|
|
it("does not make an informational row focusable when it has no details", () => {
|
|
render({
|
|
id: "notice",
|
|
kind: "protocol",
|
|
surface: "provider_activity",
|
|
family: "provider_notice",
|
|
eventType: "provider.notice.recorded",
|
|
status: "informational",
|
|
title: "Provider notice",
|
|
summary: "Retrying automatically",
|
|
details: [],
|
|
steps: [],
|
|
links: [],
|
|
children: [],
|
|
});
|
|
|
|
const row = container.querySelector('[data-testid="task-chat-protocol-activity-row"]');
|
|
expect(row?.querySelector("button")).toBeNull();
|
|
expect(row?.querySelector("details")).toBeNull();
|
|
expect(row?.querySelector('[aria-expanded]')).toBeNull();
|
|
});
|
|
it.each([
|
|
["info", "informational", "Provider update", "lucide-info"],
|
|
["warning", "informational", "Warning", "lucide-triangle-alert"],
|
|
["error", "failed", "Error", "lucide-triangle-alert"],
|
|
["error", "informational", "Error", "lucide-triangle-alert"],
|
|
["info", "failed", "Error", "lucide-triangle-alert"],
|
|
] as const)("renders %s/%s notice severity without hiding its summary", (severity, status, label, icon) => {
|
|
const summary = "Pi estimates this turn at $0.000617. Billing cost is unverified.";
|
|
render({
|
|
id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice",
|
|
eventType: "provider.notice.recorded", status, title: "Provider notice", summary,
|
|
details: [{ label: "Severity", value: severity }], steps: [], links: [], children: [],
|
|
});
|
|
expect(container.textContent).toContain(label);
|
|
expect(container.querySelector("p")?.textContent).toBe(summary);
|
|
expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.classList.contains(icon)).toBe(true);
|
|
});
|
|
|
|
});
|