mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip helps people manage agent work and inspect its results. > - The issue artifact list presents those results as work-product cards. > - A branch can have no remote URL or structured metadata. > - The current card hides its summary and has no action in that case. > - This pull request shows the summary and provides an accessible details action. > - The card labels the absent link without making a false link. ## Linked Issues or Issue Description **What happened?** A branch work product without a URL showed a short title and an icon. It did not show the saved summary or provide an action. **Expected behavior** The card must show what the branch contains. People must be able to open the saved details. It must not imply that a remote URL exists. **Steps to reproduce** 1. Open the artifact list of an issue with a branch work product that has `url: null` and `metadata: null`. 2. Find the branch card. Its summary and details action are missing before this change. Related work: #12717 introduced rich work-product cards. ## What Changed - Keep linkless branch cards concise: show the title and no-link state. Keep the full summary behind an optional Saved description toggle. - Label a branch with no URL as having no remote link. Let a person open its details with a keyboard-accessible button. - Add a focused interaction test and two Storybook states for the no-link branch. ## Verification - `pnpm --filter @paperclipai/ui typecheck` passed. - Focused Vitest tests passed: 45 tests in two files. - `pnpm --filter @paperclipai/ui build` passed. - `pnpm --filter @paperclipai/ui build-storybook` passed. - `pnpm check:token-gates` passed. - The Storybook collapsed and expanded states rendered in Chromium. Both screenshots were captured. - Full workspace typecheck could not finish: the runner Rust package requires `cargo`, which is not installed in this environment. ## Risks - Low risk. The change affects card text and the action for records without a URL. It does not change the saved data or routes. - Long saved summaries on other linkless work products expand the card height after a person selects Details. ## Model Used - OpenAI Codex coding agent. The execution environment does not expose the exact model ID or context window to this agent. It used code execution and a browser to validate the change. ## 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) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details (the managed execution workspace fixes this branch name) - [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 (Storybook examples) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1033 lines
38 KiB
TypeScript
1033 lines
38 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react";
|
||
import { flushSync } from "react-dom";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { ThemeProvider } from "@/context/ThemeContext";
|
||
import { MemoryRouter } from "@/lib/router";
|
||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||
import type {
|
||
TaskChatProtocolItem,
|
||
TaskChatProviderActivityFamily,
|
||
TaskChatRuntimeRequestDecision,
|
||
} from "./task-chat-model";
|
||
import type { HeartbeatRunEvent, IssueWorkProduct } from "@paperclipai/shared";
|
||
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||
import { transcriptToTaskChatItems } from "./transcript-adapter";
|
||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||
import { RichWorkProductCard } from "./RichWorkProductCard";
|
||
import { stateChipFor } from "./RichWorkProductCard";
|
||
|
||
function workProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
|
||
return {
|
||
id: "work-product-1",
|
||
companyId: "company-1",
|
||
projectId: null,
|
||
issueId: "issue-1",
|
||
executionWorkspaceId: null,
|
||
runtimeServiceId: null,
|
||
type: "pull_request",
|
||
provider: "github",
|
||
externalId: "42",
|
||
title: "Ship rich work-product cards",
|
||
url: "https://github.com/paperclipai/paperclip/pull/42",
|
||
status: "merged",
|
||
reviewState: "none",
|
||
isPrimary: true,
|
||
healthStatus: "healthy",
|
||
summary: null,
|
||
metadata: null,
|
||
createdByRunId: null,
|
||
createdAt: new Date("2026-09-02T00:00:00.000Z"),
|
||
updatedAt: new Date("2026-09-02T00:00:00.000Z"),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
vi.mock("@/components/MarkdownEditor", () => ({
|
||
MarkdownEditor: forwardRef(function MockMarkdownEditor(
|
||
{
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
imageUploadHandler,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
imageUploadHandler?: (file: File) => Promise<string>;
|
||
},
|
||
ref: ForwardedRef<unknown>,
|
||
) {
|
||
useImperativeHandle(ref, () => ({
|
||
insertMarkdown: (markdown: string) => onChange(`${value}${markdown}`),
|
||
focus: () => {},
|
||
}));
|
||
return (
|
||
<textarea
|
||
data-testid="mock-markdown-editor"
|
||
data-image-upload={imageUploadHandler ? "true" : "false"}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
);
|
||
}),
|
||
}));
|
||
|
||
function renderCard(
|
||
root: Root,
|
||
item: TaskChatProtocolItem,
|
||
onDecision?: (
|
||
decision: TaskChatRuntimeRequestDecision,
|
||
) => void | Promise<void>,
|
||
presentation: "timeline" | "takeover" = "timeline",
|
||
) {
|
||
flushSync(() =>
|
||
root.render(
|
||
<MemoryRouter>
|
||
<ThemeProvider>
|
||
<TaskChatProtocolCard
|
||
item={item}
|
||
presentation={presentation}
|
||
imageUploadHandler={async () => "blob:question-image"}
|
||
onRuntimeRequestDecision={
|
||
onDecision
|
||
? (_request, decision) => onDecision(decision)
|
||
: undefined
|
||
}
|
||
/>
|
||
</ThemeProvider>
|
||
</MemoryRouter>,
|
||
),
|
||
);
|
||
}
|
||
|
||
describe("TaskChatProtocolCard", () => {
|
||
let container: HTMLDivElement;
|
||
let root: Root;
|
||
|
||
beforeEach(() => {
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
});
|
||
|
||
afterEach(() => {
|
||
flushSync(() => root.unmount());
|
||
container.remove();
|
||
});
|
||
|
||
it("renders a rich deliverable card without a completed chip", () => {
|
||
const product = workProduct({
|
||
metadata: {
|
||
repo: "paperclipai/paperclip",
|
||
number: 42,
|
||
baseRef: "master",
|
||
headRef: "feat/rich-cards",
|
||
additions: 17,
|
||
deletions: 5,
|
||
changedFiles: 3,
|
||
state: "merged",
|
||
draft: false,
|
||
},
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "pull request · merged",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.querySelector('[data-testid="task-chat-rich-work-product-pull_request"]')).not.toBeNull();
|
||
expect(container.textContent).toContain("Open on GitHub");
|
||
expect(container.textContent).toContain("paperclipai/paperclip · #42 · master ← feat/rich-cards");
|
||
expect(container.textContent).toContain("+17 −5 · 3 files");
|
||
expect(container.textContent).toContain("Merged");
|
||
expect(container.textContent).not.toContain("Completed");
|
||
|
||
const action = container.querySelector('a[aria-label="Open on GitHub: Ship rich work-product cards"]');
|
||
expect(action?.querySelector("span")?.className).toContain("hidden @sm:inline");
|
||
const stats = Array.from(container.querySelectorAll("p")).find((node) => node.textContent === "+17 −5 · 3 files");
|
||
expect(stats?.className).toContain("whitespace-nowrap");
|
||
});
|
||
|
||
it("shows pending artifacts with a dashed Pending chip", () => {
|
||
const product = workProduct({
|
||
type: "artifact",
|
||
provider: "paperclip",
|
||
url: "/api/attachments/attachment-1/content",
|
||
status: "pending",
|
||
title: "demo.png",
|
||
metadata: { contentType: "image/png", byteSize: 2048 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "artifact · pending",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Pending");
|
||
expect(chip?.className).toContain("border-dashed");
|
||
expect(container.textContent).toContain("Image · 2.0 KB");
|
||
expect(container.textContent).toContain("Open gallery");
|
||
});
|
||
|
||
it.each([
|
||
["image/png", "compact"], ["video/webm", "compact"],
|
||
["image/png", "gallery"], ["video/webm", "gallery"],
|
||
] as const)("opens %s artifacts from the %s presentation in the task gallery", (contentType, variant) => {
|
||
const openGallery = vi.fn(() => true);
|
||
const contentPath = "/api/attachments/media/content";
|
||
flushSync(() => root.render(
|
||
<IssueGalleryContext.Provider value={openGallery}>
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
|
||
href={contentPath}
|
||
variant={variant}
|
||
/>
|
||
</IssueGalleryContext.Provider>,
|
||
));
|
||
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Open gallery:"]');
|
||
expect(button).not.toBeNull();
|
||
expect(container.querySelector("a")).toBeNull();
|
||
flushSync(() => button!.click());
|
||
expect(openGallery).toHaveBeenCalledWith(contentPath);
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
});
|
||
|
||
it("shows a branch summary and lets users inspect a work product without a remote URL", () => {
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({
|
||
type: "branch",
|
||
provider: "git",
|
||
title: "Company skill update entrypoint",
|
||
summary: "Uncommitted implementation changes are in the execution working tree on branch company-skill-update-entrypoint.",
|
||
url: null,
|
||
metadata: null,
|
||
status: "active",
|
||
})}
|
||
href={null}
|
||
/>,
|
||
));
|
||
expect(container.textContent).not.toContain("Uncommitted implementation changes");
|
||
expect(container.textContent).toContain("Branch · no remote link");
|
||
expect(container.querySelector("strong")?.className).toContain("line-clamp-2");
|
||
expect(container.querySelector("a")).toBeNull();
|
||
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Show details:"]');
|
||
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
||
flushSync(() => button!.click());
|
||
const savedDescription = container.querySelector<HTMLDetailsElement>("details");
|
||
expect(savedDescription?.open).toBe(false);
|
||
expect(savedDescription?.textContent).toContain("Uncommitted implementation changes");
|
||
flushSync(() => savedDescription!.querySelector("summary")!.click());
|
||
expect(savedDescription?.open).toBe(true);
|
||
flushSync(() => savedDescription!.querySelector("summary")!.click());
|
||
expect(container.textContent).toContain("git · active · Updated");
|
||
expect(container.querySelector('button[aria-label^="Hide details:"]')?.getAttribute("aria-expanded")).toBe("true");
|
||
expect(container.querySelector("strong")?.className).not.toContain("line-clamp-2");
|
||
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label^="Hide details:"]')!.click());
|
||
expect(container.querySelector("details")).toBeNull();
|
||
expect(container.textContent).toContain("Branch · no remote link");
|
||
expect(container.querySelector('button[aria-label^="Show details:"]')?.getAttribute("aria-expanded")).toBe("false");
|
||
});
|
||
|
||
it.each(["text/html", "application/zip"])("labels %s artifact links as downloads", (contentType) => {
|
||
const contentPath = "/api/attachments/file/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.textContent).toContain("Download");
|
||
expect(container.textContent).not.toContain("Open preview");
|
||
expect(container.querySelector("a")?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
});
|
||
|
||
it.each(["application/pdf", "text/plain", "text/markdown", "application/json", "text/csv"])("downloads %s artifacts through the download route", (contentType) => {
|
||
const contentPath = "/api/attachments/file/content";
|
||
const downloadPath = `${contentPath}?download=1`;
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath, openPath: contentPath, downloadPath } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(downloadPath);
|
||
});
|
||
|
||
it("downloads a legacy attachment with only a content href", () => {
|
||
const contentPath = "/api/attachments/legacy-file/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType: "application/pdf" } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
});
|
||
|
||
it("opens standalone artifact media in a modal with a download", async () => {
|
||
const contentPath = "/api/attachments/media/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", title: "Screenshot", metadata: { contentType: "image/png", contentPath, originalFilename: "proof.png", downloadPath: `${contentPath}?download=1` } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open gallery: Screenshot"]')!.click());
|
||
expect(document.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe(contentPath);
|
||
expect(document.querySelector('a[aria-label="Download proof.png"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
await act(async () => document.querySelector<HTMLButtonElement>('button[title="Close"]')!.click());
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
});
|
||
|
||
it("keeps completed and approved states out of the state-chip policy", () => {
|
||
expect(stateChipFor("commit", "completed", "none")).toBeNull();
|
||
expect(stateChipFor("document", "approved", "approved")).toBeNull();
|
||
expect(stateChipFor("pull_request", "open", "none")).toMatchObject({ label: "Open", tone: "progress" });
|
||
expect(stateChipFor("pull_request", "merged", "none")).toMatchObject({ label: "Merged", tone: "success" });
|
||
expect(stateChipFor("pull_request", "closed", "none")).toMatchObject({ label: "Closed", tone: "neutral" });
|
||
expect(stateChipFor("artifact", "pending", "none")).toMatchObject({ label: "Pending", dashed: true });
|
||
});
|
||
|
||
it("shows the live open pull request state while preserving explicit review-state precedence", () => {
|
||
const product = workProduct({
|
||
status: "ready_for_review",
|
||
metadata: { state: "open" },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "pull request · open",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Open");
|
||
expect(chip).toBeDefined();
|
||
expect(container.textContent).not.toContain("Review");
|
||
expect(stateChipFor("pull_request", "open", "needs_board_review")).toMatchObject({
|
||
label: "Review",
|
||
tone: "review",
|
||
});
|
||
});
|
||
|
||
it("shows an unhealthy active runtime as unhealthy", () => {
|
||
const product = workProduct({
|
||
type: "runtime_service",
|
||
provider: "paperclip",
|
||
status: "active",
|
||
healthStatus: "unhealthy",
|
||
title: "Storybook",
|
||
metadata: { service: "storybook", port: 6006 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "runtime service · active",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.textContent).toContain("Unhealthy");
|
||
expect(container.textContent).not.toContain("Running");
|
||
});
|
||
|
||
it("does not show running for an active runtime with unknown health", () => {
|
||
const product = workProduct({
|
||
type: "runtime_service",
|
||
provider: "paperclip",
|
||
status: "active",
|
||
healthStatus: "unknown",
|
||
title: "Storybook",
|
||
metadata: { service: "storybook", port: 6006 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "runtime service · active",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.textContent).not.toContain("Running");
|
||
expect(container.textContent).not.toContain("Unhealthy");
|
||
});
|
||
|
||
it("shows an unhealthy runtime with a non-standard open status as unhealthy", () => {
|
||
const product = workProduct({
|
||
type: "runtime_service",
|
||
provider: "paperclip",
|
||
status: "open",
|
||
healthStatus: "unhealthy",
|
||
title: "Storybook",
|
||
metadata: { service: "storybook", port: 6006 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "runtime service · open",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.textContent).toContain("Unhealthy");
|
||
expect(container.textContent).not.toContain("Running");
|
||
});
|
||
|
||
it("shows a closed runtime as stopped even when its last health check failed", () => {
|
||
const product = workProduct({
|
||
type: "runtime_service",
|
||
provider: "paperclip",
|
||
status: "closed",
|
||
healthStatus: "unhealthy",
|
||
title: "Storybook",
|
||
metadata: { service: "storybook", port: 6006 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "runtime service · closed",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.textContent).toContain("Stopped");
|
||
expect(container.textContent).not.toContain("Unhealthy");
|
||
});
|
||
|
||
it("renders provider plan steps and status", () => {
|
||
renderCard(root, {
|
||
id: "provider-plan",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "running",
|
||
title: "Plan",
|
||
summary: "Implement protocol surfaces",
|
||
details: [],
|
||
links: [],
|
||
children: [],
|
||
steps: [
|
||
{ id: "one", label: "Inventory events", status: "completed" },
|
||
{ id: "two", label: "Render widgets", status: "in_progress" },
|
||
],
|
||
});
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-provider-plan"]'),
|
||
).not.toBeNull();
|
||
expect(container.textContent).toContain("Inventory events");
|
||
expect(container.textContent).toContain("Render widgets");
|
||
});
|
||
|
||
it("renders a structured history card for every provider family", () => {
|
||
const families = [
|
||
"plan",
|
||
"tool_execution",
|
||
"research",
|
||
"delegation",
|
||
"model_identity",
|
||
"context",
|
||
"artifact",
|
||
"review",
|
||
"hook",
|
||
"memory",
|
||
"safety",
|
||
"terminal",
|
||
"wait",
|
||
"provider_notice",
|
||
] satisfies TaskChatProviderActivityFamily[];
|
||
for (const family of families) {
|
||
renderCard(root, {
|
||
id: `provider-${family}`,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family,
|
||
eventType: `${family}.fixture`,
|
||
status: "completed",
|
||
title: `Visible ${family}`,
|
||
summary: `Summary ${family}`,
|
||
details: [{ label: "Reference", value: `${family}-1` }],
|
||
links: [],
|
||
children: [],
|
||
steps: [],
|
||
});
|
||
expect(
|
||
container.querySelector(`[data-testid="task-chat-provider-${family}"]`),
|
||
family,
|
||
).not.toBeNull();
|
||
expect(container.textContent, family).toContain(`Visible ${family}`);
|
||
}
|
||
});
|
||
|
||
it("opens a workspace diff review dialog", async () => {
|
||
renderCard(root, {
|
||
id: "workspace",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "changes-1",
|
||
revision: 1,
|
||
source: "runner_verified",
|
||
complete: true,
|
||
files: [
|
||
{
|
||
path: "ui/src/App.tsx",
|
||
operation: "modify",
|
||
previousPath: null,
|
||
additions: 1,
|
||
deletions: 1,
|
||
binary: false,
|
||
diff: "-old\n+new",
|
||
},
|
||
],
|
||
totals: { files: 1, additions: 1, deletions: 1 },
|
||
patchArtifactRef: null,
|
||
});
|
||
const button = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Review diff",
|
||
);
|
||
expect(button).not.toBeUndefined();
|
||
expect(container.textContent).toContain("1 file changed · +1 −1");
|
||
await act(async () => button?.click());
|
||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||
"ui/src/App.tsx",
|
||
);
|
||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||
"old",
|
||
);
|
||
});
|
||
|
||
it("opens a bounded file preview", async () => {
|
||
renderCard(root, {
|
||
id: "file",
|
||
kind: "protocol",
|
||
surface: "workspace_file",
|
||
referenceId: "file-1",
|
||
source: "runner_verified",
|
||
path: "doc/protocol.md",
|
||
displayName: "protocol.md",
|
||
mediaType: "text/markdown",
|
||
presentation: "document",
|
||
line: 12,
|
||
preview: "# Protocol preview",
|
||
previewTruncated: false,
|
||
});
|
||
const button = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Preview",
|
||
);
|
||
await act(async () => button?.click());
|
||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||
"Protocol preview",
|
||
);
|
||
});
|
||
|
||
it("submits a runtime choice only when a resolver is supplied", async () => {
|
||
const onDecision = vi.fn();
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-1",
|
||
requestKind: "command_approval",
|
||
turnId: "turn-1",
|
||
requestType: "permission",
|
||
status: "pending",
|
||
prompt: "Allow command?",
|
||
choices: [{ key: "accept", label: "Allow once" }],
|
||
fields: [],
|
||
},
|
||
onDecision,
|
||
);
|
||
const button = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Allow once",
|
||
);
|
||
expect(button?.disabled).toBe(false);
|
||
await act(async () => button?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({ action: "accept" });
|
||
});
|
||
|
||
it("renders committed ACP permission choices and preserves its exact resolution binding", async () => {
|
||
const event: HeartbeatRunEvent = {
|
||
id: 1, seq: 1, companyId: "company-1", runId: "run-1", agentId: "agent-1",
|
||
eventType: "runtime_request.created", stream: "system", level: "info", color: null, message: null,
|
||
createdAt: new Date("2026-09-28T12:00:00Z"),
|
||
payload: { prpEvent: {
|
||
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceEventId: "permission-1", sourceSeq: 1,
|
||
sourceKind: "runner", sourceInstanceId: "runner-1", runId: "run-1", normalizedSessionId: "session-1",
|
||
turnId: "turn-1", itemId: "item-1", eventType: "runtime_request.created", priority: 0,
|
||
emittedAt: "2026-09-28T12:00:00Z", payload: { request: {
|
||
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission",
|
||
requestId: "permission-1", turnId: "turn-1", itemId: "item-1", status: "pending",
|
||
prompt: "Allow editing src/example.ts?",
|
||
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
|
||
details: { toolCallId: "tool-1" },
|
||
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
|
||
} },
|
||
} },
|
||
};
|
||
const item = transcriptToTaskChatItems(nativeRunEventsToTranscript([event]), {
|
||
runId: "run-1", agentName: "ACP", running: true,
|
||
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
|
||
if (item?.kind !== "protocol" || item.surface !== "runtime_request") throw new Error("permission card missing");
|
||
expect(item).toMatchObject({ runId: "run-1", requestId: "permission-1", turnId: "turn-1", requestKind: "permission_approval", status: "pending" });
|
||
expect(item.choices.map(choice => choice.key)).toEqual(["accept", "decline"]);
|
||
const resolve = vi.fn().mockResolvedValue(undefined);
|
||
flushSync(() => root.render(<MemoryRouter><ThemeProvider>
|
||
<TaskChatProtocolCard item={item} onRuntimeRequestDecision={resolve} />
|
||
</ThemeProvider></MemoryRouter>));
|
||
expect(container.textContent).toContain("Allow editing src/example.ts?");
|
||
const allow = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once");
|
||
expect(allow?.disabled).toBe(false);
|
||
expect(container.textContent).not.toContain("Allow for session");
|
||
await act(async () => allow?.click());
|
||
expect(resolve).toHaveBeenCalledExactlyOnceWith(item, { action: "accept" });
|
||
const delivered: HeartbeatRunEvent = { ...event, id: 2, seq: 2, eventType: "runtime_request.resolved", payload: { prpEvent: {
|
||
...(event.payload!.prpEvent as Record<string, unknown>), sourceEventId: "permission-delivered", sourceSeq: 2,
|
||
eventType: "runtime_request.resolved", payload: { requestId: "permission-1", requestKind: "permission_approval",
|
||
turnId: "turn-1", itemId: "item-1", status: "delivered", action: "accept" },
|
||
} } };
|
||
const settled = transcriptToTaskChatItems(nativeRunEventsToTranscript([event, delivered]), {
|
||
runId: "run-1", agentName: "ACP", running: true,
|
||
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
|
||
if (settled?.kind !== "protocol" || settled.surface !== "runtime_request") throw new Error("receipt missing");
|
||
expect(settled).toMatchObject({ requestId: "permission-1", status: "resolved", resolvedAction: "accept" });
|
||
renderCard(root, settled, resolve);
|
||
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once")).toBeUndefined();
|
||
});
|
||
|
||
it("submits structured runtime input through the production card", async () => {
|
||
const onDecision = vi.fn().mockResolvedValue(undefined);
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-input",
|
||
requestKind: "user_input",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Which environment should the run target?",
|
||
choices: [],
|
||
fields: [
|
||
{ name: "environment", label: "Environment", placeholder: "staging" },
|
||
],
|
||
},
|
||
onDecision,
|
||
);
|
||
const textarea = container.querySelector("textarea")!;
|
||
await act(async () => {
|
||
const setter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
setter?.call(textarea, "production");
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Submit response",
|
||
);
|
||
await act(async () => submit?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({
|
||
action: "submit",
|
||
values: { environment: "production" },
|
||
});
|
||
expect(container.textContent).toContain("Submitting…");
|
||
});
|
||
|
||
it("keeps complete provider plan Markdown while making image references inert", () => {
|
||
const prefix = '# Release plan\n\n**Preserve every instruction.**\n\n\n\n\n\n';
|
||
const suffix = '\n\n```mermaid\nflowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }\n```\n\nFINAL_PLAN_BOUNDARY';
|
||
const fullInstructions = "x".repeat(100_000 - prefix.length - suffix.length);
|
||
const description = prefix + fullInstructions + suffix;
|
||
expect(description).toHaveLength(100_000);
|
||
renderCard(root, {
|
||
id: "provider-plan", kind: "protocol", surface: "runtime_request", runId: "run-1",
|
||
requestId: "plan-revision-7", requestKind: "runtime", turnId: "turn-1",
|
||
requestType: "input", status: "pending", prompt: "Review the complete plan.", choices: [], fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1", description,
|
||
questions: [{ id: "decision", prompt: "Accept this plan?", required: true, answerMode: "single_select",
|
||
options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }] }],
|
||
},
|
||
});
|
||
|
||
const context = container.querySelector('[role="region"][aria-label="Question context"]');
|
||
expect(context?.querySelector("h1")?.textContent).toBe("Release plan");
|
||
expect(context?.querySelector("strong")?.textContent).toBe("Preserve every instruction.");
|
||
expect(context?.textContent).toContain(fullInstructions);
|
||
expect(context?.textContent).toContain("FINAL_PLAN_BOUNDARY");
|
||
expect(context?.textContent).toContain("Evidence");
|
||
expect(context?.textContent).toContain("https://provider.invalid/track.png");
|
||
expect(context?.querySelector("img, video, audio, iframe, object, embed, image, link")).toBeNull();
|
||
expect(context?.querySelector('.language-mermaid')?.textContent).toContain('https://provider.invalid/diagram.png');
|
||
expect(context?.querySelector('.paperclip-mermaid')).toBeNull();
|
||
});
|
||
|
||
it("submits the canonical response from a v2 harness question set", async () => {
|
||
const onDecision = vi.fn().mockResolvedValue(undefined);
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "canonical-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-canonical-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Deployment input",
|
||
submitLabel: "Continue",
|
||
questions: [
|
||
{
|
||
id: "environment",
|
||
header: "Environment",
|
||
prompt: "Where should we deploy?",
|
||
required: true,
|
||
answerMode: "single_select",
|
||
options: [
|
||
{ id: "staging", label: "Staging" },
|
||
{ id: "production", label: "Production" },
|
||
],
|
||
},
|
||
{
|
||
id: "regions",
|
||
header: "Regions",
|
||
prompt: "Which regions should receive the release?",
|
||
required: false,
|
||
answerMode: "multi_select",
|
||
options: [
|
||
{ id: "us", label: "US" },
|
||
{ id: "eu", label: "EU" },
|
||
],
|
||
},
|
||
{
|
||
id: "notes",
|
||
header: "Notes",
|
||
prompt: "Anything else we should know?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
onDecision,
|
||
);
|
||
expect(container.textContent).toContain("Deployment input");
|
||
expect(container.textContent).toContain("1 of 3");
|
||
const production = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.includes("Production"),
|
||
);
|
||
await act(async () => production?.click());
|
||
// Selecting answers the question; every page waits for Next.
|
||
const nextButton = () =>
|
||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||
(button) => button.textContent?.trim() === "Next",
|
||
);
|
||
expect(container.textContent).toContain("1 of 3");
|
||
await act(async () => nextButton()?.click());
|
||
expect(container.textContent).toContain("2 of 3");
|
||
expect(onDecision).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain(
|
||
"Which regions should receive the release?",
|
||
);
|
||
expect(
|
||
Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Continue",
|
||
),
|
||
).toBeUndefined();
|
||
await act(async () => nextButton()?.click());
|
||
expect(container.textContent).toContain("Anything else we should know?");
|
||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Continue",
|
||
);
|
||
await act(async () => submit?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({
|
||
action: "submit",
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { environment: { selectedOptionIds: ["production"] } },
|
||
},
|
||
});
|
||
});
|
||
|
||
it("renders structured runtime questions directly in the takeover without duplicate request copy", () => {
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "canonical-takeover-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-canonical-takeover",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Codex requests user input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Codex needs your input",
|
||
questions: [
|
||
{
|
||
id: "environment",
|
||
header: "Environment",
|
||
prompt: "Where should we deploy?",
|
||
required: false,
|
||
answerMode: "single_select",
|
||
options: [{ id: "staging", label: "Staging" }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
vi.fn(),
|
||
"takeover",
|
||
);
|
||
|
||
expect(container.textContent).not.toContain("Codex needs your input");
|
||
expect(container.textContent).not.toContain("Codex requests user input.");
|
||
expect(container.textContent).not.toContain("Choose one");
|
||
expect(container.textContent).toContain("Where should we deploy?");
|
||
});
|
||
|
||
it("collapses resolved runtime questions to one expandable timeline row", () => {
|
||
renderCard(root, {
|
||
id: "resolved-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-resolved-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "resolved",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
resolvedAction: "submit",
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Server setup",
|
||
questions: [
|
||
{
|
||
id: "style",
|
||
header: "Code style",
|
||
prompt: "Which module style should the server use?",
|
||
required: true,
|
||
answerMode: "single_select",
|
||
options: [{ id: "esm", label: "TypeScript ESM" }],
|
||
},
|
||
],
|
||
},
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { style: { selectedOptionIds: ["esm"] } },
|
||
},
|
||
});
|
||
|
||
const receipt = container.querySelector<HTMLDetailsElement>(
|
||
'[data-testid="task-chat-runtime-request"]',
|
||
);
|
||
expect(receipt?.open).toBe(false);
|
||
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
||
"Questions answered",
|
||
);
|
||
expect(receipt?.querySelectorAll("summary svg")).toHaveLength(2);
|
||
expect(receipt?.className).not.toContain("border");
|
||
expect(receipt?.className).not.toContain("bg-card");
|
||
expect(receipt?.querySelector("summary")?.className).toContain("px-1");
|
||
expect(receipt?.querySelector("summary")?.className).toContain(
|
||
"text-muted-foreground",
|
||
);
|
||
expect(receipt?.querySelector("summary")?.className).toContain("leading-5");
|
||
expect(receipt?.querySelector("summary span")?.className).not.toContain(
|
||
"font-medium",
|
||
);
|
||
expect(
|
||
receipt
|
||
?.querySelector('[data-testid="task-chat-runtime-request-icon"]')
|
||
?.classList.contains("h-3.5"),
|
||
).toBe(true);
|
||
const caret = receipt?.querySelector(
|
||
'[data-testid="task-chat-runtime-request-caret"]',
|
||
);
|
||
expect(caret?.classList.contains("h-3")).toBe(true);
|
||
expect(caret?.classList.contains("opacity-0")).toBe(true);
|
||
expect(caret?.getAttribute("class")).toContain("group-hover:opacity-70");
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-runtime-request-history"]',
|
||
),
|
||
).not.toBeNull();
|
||
expect(container.textContent).toContain(
|
||
"Which module style should the server use?",
|
||
);
|
||
expect(container.textContent).toContain("TypeScript ESM");
|
||
expect(container.querySelector("textarea")).toBeNull();
|
||
});
|
||
|
||
it("collapses a cancelled runtime question with its original prompt expandable", () => {
|
||
renderCard(root, {
|
||
id: "cancelled-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-cancelled-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "cancelled",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
resolvedAction: "cancel",
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{
|
||
id: "goal",
|
||
prompt: "What should the server do?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const receipt = container.querySelector<HTMLDetailsElement>(
|
||
'[data-testid="task-chat-runtime-request"]',
|
||
);
|
||
expect(receipt?.open).toBe(false);
|
||
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
||
"Questions cancelled",
|
||
);
|
||
const iconSlot = receipt?.querySelector(
|
||
'[data-testid="task-chat-runtime-request-icon-slot"]',
|
||
);
|
||
expect(iconSlot?.classList.contains("w-6")).toBe(true);
|
||
expect(iconSlot?.classList.contains("items-center")).toBe(true);
|
||
expect(iconSlot?.classList.contains("justify-center")).toBe(true);
|
||
expect(container.textContent).toContain(
|
||
"No answers were submitted; this request was cancelled.",
|
||
);
|
||
});
|
||
|
||
it("requires text when an explicit custom multi-select answer is active", async () => {
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "custom-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-custom-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Choose regions.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{
|
||
id: "regions",
|
||
prompt: "Which regions?",
|
||
required: true,
|
||
answerMode: "multi_select",
|
||
options: [{ id: "us", label: "US" }],
|
||
customAnswer: { enabled: true, label: "Another region" },
|
||
},
|
||
{
|
||
id: "notes",
|
||
prompt: "Notes?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
vi.fn(),
|
||
);
|
||
|
||
const us = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "US",
|
||
);
|
||
const custom = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Another region",
|
||
);
|
||
await act(async () => us?.click());
|
||
await act(async () => custom?.click());
|
||
|
||
const richInput = container.querySelector(
|
||
'[data-testid="question-other-answer-composer"]',
|
||
);
|
||
expect(richInput).not.toBeNull();
|
||
expect(richInput?.classList.contains("border")).toBe(false);
|
||
expect(
|
||
richInput?.querySelector('[data-image-upload="true"]'),
|
||
).not.toBeNull();
|
||
|
||
const next = Array.from(
|
||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||
).find((button) => button.getAttribute("aria-label") === "Next question");
|
||
expect(next?.disabled).toBe(false);
|
||
expect(container.textContent).toContain("Enter a custom answer.");
|
||
|
||
const textarea = container.querySelector("textarea")!;
|
||
await act(async () => {
|
||
const setter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
setter?.call(textarea, "ap-south");
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
expect(next?.disabled).toBe(false);
|
||
});
|
||
});
|