Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
T
DottaandPaperclip db72ad4c73 fix(ui): explain branch artifacts without remote links (#15051)
## 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>
2026-10-03 21:05:48 -05:00

1033 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Local image](/api/attachments/untrusted/content)\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);
});
});