mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
## Thinking Path
> - Paperclip helps people manage AI agents and review their actions.
> - Ask first tool actions require a person to inspect the proposed
operation.
> - The task and Apps review surfaces share the pending tool-action
card.
> - That card shows a short preview but omits the available redacted
argument body.
> - A valid preview can omit important text, such as the date at the end
of a proposal.
> - This pull request adds optional argument inspection to the shared
card.
> - The person can inspect the proposed content before using the
existing decision controls.
## Linked Issues or Issue Description
Refs: #16540.
Related PRs: #16571 exposes reviewed arguments on formal board approvals
and scrolls the task review into view. #16716 routes tool actions to
their required formal board approvals. This change addresses the
separate shared pending tool-action card used by the task takeover and
linked Apps reviews. It does not close the other reported paths in
#16540.
## What Changed
- Add the existing Arguments and audit hash disclosure pattern to the
shared pending card.
- Show only the bounded argument summary already redacted by the server
and its audit hash. Show an unavailable message for missing, malformed,
or oversized fields.
- Keep the current approval, decline, and remembered-scope callbacks.
- Add a long-proposal story and update the tool-review feature recipe.
- Add React coverage for the real task review transition, the linked
Apps review, plain text rendering, invalid fields, and unchanged
decision calls.
## Verification
- The new task-review test failed on the parent because the disclosure
was absent. It opens Review request, inspects the proposal tail and
hash, then approves the same interaction.
- Oversized-whitespace controls fail on the previous guard ordering
because trimming runs before the length bound. The guard now checks
length first for both fields.
- Passed: 117 React tests across TaskChatInteractionCard,
IssueThreadInteractionCard, and ReviewQueueCard. The tests use the
repository UI config with the existing installed Vitest 5.0.3 dependency
tree.
- Passed: token gates, map checks, Storybook catalog checks, and git
diff checks.
- Local full UI typecheck did not pass. The linked dependency tree lacks
adapter and json-schema-to-ts declarations and has stale shared exports.
It reports no diagnostics in the changed files. This is not a successful
full typecheck.
- Full workspace build, browser, and provider checks were not run
locally. Current head 50600786f7 passed
all 48 normal CI jobs with one expected skip. Greptile and Paperclip
Review both report 5/5 at this head; all review threads are resolved. No
tool action or model call was run for this change.
- Inspect the new ToolActionLongProposal story. Open Arguments and audit
hash and read the final launch date. Inspection must not submit a
decision. Existing destructive, running, failed, declined, and expired
states keep their controls.
## Risks
- This changes the pending review presentation in tasks and linked Apps
reviews.
- The UI displays the existing server-redacted projection. It does not
request or reconstruct signed execution arguments. The existing server
schema caps the summary at 20,000 characters and the hash at 255
characters. The UI uses the same limits and escapes both as text.
- The server still owns redaction, scope checks, approval, and
execution. Inspection makes no API mutation. The current callbacks
retain the same request identity and permission scope.
- The PR will remain unmerged for separate review and candidate-source
integration.
## Model Used
OpenAI `gpt-6.1-sol` (Codex), with source inspection, code editing,
React tests, and Git/GitHub tools. The context window size is not
exposed in this session.
## 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
- [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>
1148 lines
42 KiB
TypeScript
1148 lines
42 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, forwardRef, useImperativeHandle, useState, 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 type { IssueDocument } from "@paperclipai/shared";
|
|
import type {
|
|
IssueThreadInteraction,
|
|
RequestConfirmationInteraction,
|
|
} from "@/lib/issue-thread-interactions";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import {
|
|
expiredSecretProposalInteraction,
|
|
pendingToolActionLongProposalInteraction,
|
|
pendingAskUserQuestionsInteraction,
|
|
pendingRequestCheckboxConfirmationInteraction,
|
|
pendingRequestItemVerdictsInteraction,
|
|
} from "@/fixtures/issueThreadInteractionFixtures";
|
|
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
|
vi.mock("@/lib/router", async importOriginal => ({
|
|
...await importOriginal<typeof import("@/lib/router")>(),
|
|
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
|
}));
|
|
import { TaskChatThreadView } from "./TaskChatThreadView";
|
|
import type { TaskChatInteractionItem } from "./task-chat-model";
|
|
|
|
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)}
|
|
/>
|
|
);
|
|
}),
|
|
}));
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createRequestConfirmation(
|
|
overrides: Partial<RequestConfirmationInteraction> = {},
|
|
): RequestConfirmationInteraction {
|
|
return {
|
|
id: "confirmation-1",
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
kind: "request_confirmation",
|
|
title: "Approve the plan",
|
|
summary: "Review and approve the latest plan.",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
resolverPolicy: "anyone",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
resolverPolicyProvenance: "inherited",
|
|
effectiveResolverPolicySource: "requested",
|
|
legacyResolverPolicyAliases: {
|
|
requested: "board_or_agents",
|
|
effective: "board_or_agents",
|
|
},
|
|
createdByAgentId: "agent-1",
|
|
createdByUserId: null,
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: null,
|
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
|
resolvedAt: null,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the plan?",
|
|
},
|
|
result: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function interactionItem(
|
|
interaction: IssueThreadInteraction,
|
|
): TaskChatInteractionItem {
|
|
return {
|
|
id: `interaction:${interaction.id}`,
|
|
kind: "interaction",
|
|
interaction,
|
|
};
|
|
}
|
|
|
|
describe("TaskChatInteractionCard", () => {
|
|
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("opens the real tool review and reveals the proposal tail before approval", async () => {
|
|
const accept = vi.fn();
|
|
const reject = vi.fn();
|
|
const interaction = structuredClone(pendingToolActionLongProposalInteraction);
|
|
function ReviewSurface() {
|
|
const [reviewing, setReviewing] = useState(false);
|
|
return <TaskChatInteractionCard item={interactionItem(interaction)}
|
|
presentation={reviewing ? "takeover" : "timeline"}
|
|
onReviewRequest={() => setReviewing(true)}
|
|
onAcceptInteraction={accept} onRejectInteraction={reject} />;
|
|
}
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><ReviewSurface /></ThemeProvider></TooltipProvider>));
|
|
expect(container.textContent).not.toContain("November 8, 2030");
|
|
await act(async () => {
|
|
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Review request")!.click();
|
|
});
|
|
const details = container.querySelector("details")!;
|
|
expect(details).not.toBeNull();
|
|
expect(details.open).toBe(false);
|
|
expect(container.querySelector("pre")?.textContent).toBe(interaction.payload.toolAction!.argumentsSummaryJson);
|
|
expect(details.textContent).toContain("November 8, 2030");
|
|
expect(details.textContent).toContain("[redacted]");
|
|
expect(details.textContent).toContain(interaction.payload.toolAction!.argumentsHash);
|
|
await act(async () => details.querySelector("summary")!.click());
|
|
expect(details.open).toBe(true);
|
|
expect(accept).not.toHaveBeenCalled();
|
|
expect(reject).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Approve & run")!.click();
|
|
});
|
|
expect(accept).toHaveBeenCalledExactlyOnceWith(interaction, undefined, undefined, false);
|
|
expect(reject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render a pending confirmation in the timeline", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(createRequestConfirmation())}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const card = container.querySelector(
|
|
'[data-testid="task-chat-interaction"]',
|
|
);
|
|
expect(card).toBeNull();
|
|
expect(container.textContent).not.toContain("Requested confirmation");
|
|
expect(container.textContent).not.toContain("waiting for response");
|
|
expect(container.textContent).not.toContain("Approve the plan?");
|
|
});
|
|
|
|
it("renders a plan confirmation as a linked document preview", () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
title: "Review plan revision 3",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 3?",
|
|
acceptLabel: "Approve plan",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-3",
|
|
revisionNumber: 3,
|
|
label: "Plan v3",
|
|
},
|
|
},
|
|
});
|
|
const planDocument = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Health-check endpoint\n1. Define the health response contract.\n2. Test readiness before and after startup.\n3. Document the verification request.",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={planDocument}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const preview = container.querySelector(
|
|
'[data-testid="plan-review-preview"]',
|
|
);
|
|
expect(preview?.getAttribute("href")).toBe("#document-plan");
|
|
expect(preview?.getAttribute("aria-label")).toBe("Open Plan revision 3");
|
|
expect(container.textContent).toContain("Health-check endpoint");
|
|
expect(container.textContent).toContain(
|
|
"Test readiness before and after startup.",
|
|
);
|
|
expect(container.textContent).not.toContain("Approve plan revision 3?");
|
|
expect(container.textContent).not.toContain("Approve plan");
|
|
expect(container.textContent).not.toContain("Requested plan review");
|
|
});
|
|
|
|
it("renders a runner-owned expired plan review as a receipt without a second preview", () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
status: "expired",
|
|
title: "Review plan revision 3",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 3?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-3",
|
|
label: "Plan v3",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId: "comment-1",
|
|
},
|
|
});
|
|
const planDocument = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Health-check endpoint",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={planDocument}
|
|
showPlanPreview={false}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="plan-review-preview"]'),
|
|
).toBeNull();
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).toContain(
|
|
"Confirmation expired after comment",
|
|
);
|
|
});
|
|
|
|
it("does not put the latest plan body into an older revision receipt", () => {
|
|
const interaction = createRequestConfirmation({
|
|
status: "expired",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 2?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-2",
|
|
label: "Plan revision 2",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId: "comment-1",
|
|
},
|
|
});
|
|
const currentPlan = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Revision 3 only\nThis must not appear in the revision 2 receipt.",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={currentPlan}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="plan-review-preview"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Revision 3 only");
|
|
expect(container.textContent).toContain(
|
|
"Confirmation expired after comment",
|
|
);
|
|
});
|
|
|
|
it.each([createRequestConfirmation(), pendingRequestCheckboxConfirmationInteraction])(
|
|
"enables $kind acceptance when preparation clears on the same mounted card",
|
|
async (fixture) => {
|
|
const onAcceptInteraction = vi.fn();
|
|
const onRejectInteraction = vi.fn();
|
|
const render = (preparing: boolean) => flushSync(() => root.render(
|
|
<TooltipProvider><ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem({ ...fixture, acceptanceBlocker: preparing ? "workspace_sync_pending" : undefined })}
|
|
presentation="takeover"
|
|
onAcceptInteraction={onAcceptInteraction}
|
|
onRejectInteraction={onRejectInteraction}
|
|
/>
|
|
</ThemeProvider></TooltipProvider>,
|
|
));
|
|
const approve = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
|
|
button.textContent === (fixture.payload.acceptLabel ?? "Approve"),
|
|
)!;
|
|
render(true);
|
|
expect(container.querySelector('[role="status"]')?.textContent).toContain("Preparing approval…");
|
|
expect(approve().disabled).toBe(true);
|
|
await act(async () => approve().click());
|
|
expect(onAcceptInteraction).not.toHaveBeenCalled();
|
|
expect([...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
|
|
button.textContent === (fixture.payload.rejectLabel ?? "Reject"),
|
|
)?.disabled).toBe(false);
|
|
render(false);
|
|
expect(container.textContent).not.toContain("Preparing approval…");
|
|
expect(approve().disabled).toBe(false);
|
|
await act(async () => approve().click());
|
|
expect(onAcceptInteraction).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("puts the primary CTA at the right edge of the compact action row", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(createRequestConfirmation())}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
const approve = buttons.find((button) => button.textContent === "Approve");
|
|
const reject = buttons.find((button) => button.textContent === "Reject");
|
|
expect(approve).not.toBeUndefined();
|
|
expect(reject).not.toBeUndefined();
|
|
const row = approve?.parentElement;
|
|
expect(row).toBe(reject?.parentElement);
|
|
expect(Array.from(row?.querySelectorAll("button") ?? []).at(-1)).toBe(
|
|
approve,
|
|
);
|
|
expect(container.textContent).not.toContain("proposed by");
|
|
expect(container.textContent).not.toContain(
|
|
"Review and approve the latest plan.",
|
|
);
|
|
expect(container.textContent).not.toContain("Needs response");
|
|
expect(container.textContent).not.toContain("Anyone can respond");
|
|
expect(
|
|
container.querySelector('[data-testid="interaction-status-badge"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("stacks the confirmation prompt full-width and hides unlabeled custom target keys", () => {
|
|
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(
|
|
createRequestConfirmation({
|
|
payload: {
|
|
version: 1,
|
|
prompt: "The change is open as PR #16.\n\nMay the PR merge?",
|
|
target,
|
|
},
|
|
}),
|
|
)}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
|
|
node.textContent?.startsWith("The change is open"),
|
|
);
|
|
expect(prompt?.className).not.toContain("flex-1");
|
|
expect(prompt?.className).toContain("whitespace-pre-line");
|
|
expect(prompt?.parentElement?.className).not.toContain("justify-between");
|
|
|
|
render({
|
|
type: "custom",
|
|
key: "github:paperclipai/paperclip-desktop:pull:16",
|
|
label: "PR #16",
|
|
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
|
|
});
|
|
const link = container.querySelector(
|
|
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
|
|
);
|
|
expect(link?.textContent).toBe("PR #16");
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
});
|
|
|
|
it("stacks the checkbox confirmation prompt full-width and hides unlabeled custom target keys", () => {
|
|
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem({
|
|
...pendingRequestCheckboxConfirmationInteraction,
|
|
payload: {
|
|
...pendingRequestCheckboxConfirmationInteraction.payload,
|
|
prompt: "Pick the drafts to delete.\n\nUnchecked drafts stay.",
|
|
target,
|
|
},
|
|
})}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
|
|
node.textContent?.startsWith("Pick the drafts"),
|
|
);
|
|
expect(prompt?.className).not.toContain("flex-1");
|
|
expect(prompt?.className).toContain("whitespace-pre-line");
|
|
expect(prompt?.parentElement?.className).not.toContain("justify-between");
|
|
|
|
render({
|
|
type: "custom",
|
|
key: "github:paperclipai/paperclip-desktop:pull:16",
|
|
label: "PR #16",
|
|
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
|
|
});
|
|
const link = container.querySelector(
|
|
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
|
|
);
|
|
expect(link?.textContent).toBe("PR #16");
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
});
|
|
|
|
it("keeps the plan revision in the header and hides the prompt while requesting changes", async () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
title: "Review the proposed plan",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Do you accept this plan?",
|
|
acceptLabel: "Approve plan",
|
|
rejectLabel: "Request changes",
|
|
rejectRequiresReason: true,
|
|
rejectReasonLabel: "What should change?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-4",
|
|
revisionNumber: 4,
|
|
},
|
|
},
|
|
});
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
presentation="takeover"
|
|
onAcceptInteraction={vi.fn()}
|
|
onRejectInteraction={vi.fn()}
|
|
onUploadImage={vi.fn().mockResolvedValue("/uploads/boat.png")}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const title = Array.from(container.querySelectorAll("strong")).find(
|
|
(candidate) => candidate.textContent === "Review the proposed plan",
|
|
);
|
|
const revision = Array.from(container.querySelectorAll("span")).find(
|
|
(candidate) => candidate.textContent === "plan · v4",
|
|
);
|
|
expect(title?.parentElement).toBe(revision?.parentElement);
|
|
expect(container.textContent).toContain("Do you accept this plan?");
|
|
expect(container.querySelectorAll(".border-t")).toHaveLength(0);
|
|
|
|
const requestChanges = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>("button"),
|
|
).find((button) => button.textContent?.trim() === "Request changes");
|
|
flushSync(() => requestChanges?.click());
|
|
|
|
expect(container.textContent).not.toContain("Do you accept this plan?");
|
|
expect(container.textContent).toContain("What should change?");
|
|
expect(container.textContent).toContain("plan · v4");
|
|
expect(
|
|
container.querySelector('[data-testid="plan-revision-composer"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
container
|
|
.querySelector('[data-testid="mock-markdown-editor"]')
|
|
?.getAttribute("data-image-upload"),
|
|
).toBe("true");
|
|
expect(container.textContent).not.toContain("Attach image");
|
|
expect(container.textContent).not.toContain("drop/paste an image");
|
|
const reasonGroup = container.querySelector(
|
|
'[data-testid="plan-revision-composer"]',
|
|
)?.parentElement;
|
|
expect(reasonGroup?.className).toContain("space-y-2");
|
|
expect(reasonGroup?.className).not.toContain("mt-3");
|
|
});
|
|
|
|
it("shows one question at a time and preserves answers across pages", async () => {
|
|
const submit = vi.fn();
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(pendingAskUserQuestionsInteraction)}
|
|
presentation="takeover"
|
|
onSubmitInteractionAnswers={submit}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("1 of 2");
|
|
expect(container.textContent).not.toContain("Choose one");
|
|
expect(container.textContent).toContain("How aggressive should");
|
|
expect(container.textContent).not.toContain(
|
|
"What should the answered-state card emphasize",
|
|
);
|
|
|
|
const firstAnswer = Array.from(container.querySelectorAll("button")).find(
|
|
(button) =>
|
|
button.textContent?.includes("Only collapse hidden descendants"),
|
|
);
|
|
await act(async () => firstAnswer?.click());
|
|
expect(submit).not.toHaveBeenCalled();
|
|
// Answering stays on the question; Next is what moves on.
|
|
expect(container.textContent).toContain("1 of 2");
|
|
await act(async () =>
|
|
Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
|
|
.find((button) => button.textContent?.trim() === "Next")
|
|
?.click(),
|
|
);
|
|
|
|
expect(container.textContent).toContain("2 of 2");
|
|
expect(container.textContent).toContain(
|
|
"What should the answered-state card emphasize",
|
|
);
|
|
expect(container.textContent).not.toContain("How aggressive should");
|
|
|
|
const secondAnswer = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Inline answer pills"),
|
|
);
|
|
await act(async () => secondAnswer?.click());
|
|
const send = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Send answers",
|
|
);
|
|
await act(async () => send?.click());
|
|
|
|
expect(submit).toHaveBeenCalledOnce();
|
|
expect(submit.mock.calls[0]?.[1]).toEqual([
|
|
{ questionId: "collapse-depth", optionIds: ["visible-root"] },
|
|
{ questionId: "post-submit-summary", optionIds: ["answers-inline"] },
|
|
]);
|
|
});
|
|
|
|
it("collapses answered questions to one expandable timeline row", () => {
|
|
const answered = structuredClone(pendingAskUserQuestionsInteraction);
|
|
answered.status = "answered";
|
|
answered.resolvedAt = new Date("2026-08-24T13:30:00.000Z");
|
|
answered.result = {
|
|
version: 1,
|
|
answers: [{ questionId: "collapse-depth", optionIds: ["visible-root"] }],
|
|
};
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard item={interactionItem(answered)} />
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-answered-questions-receipt"]',
|
|
);
|
|
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-interaction-receipt-icon"]')
|
|
?.classList.contains("h-3.5"),
|
|
).toBe(true);
|
|
const iconSlot = receipt?.querySelector(
|
|
'[data-testid="task-chat-interaction-receipt-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);
|
|
const caret = receipt?.querySelector(
|
|
'[data-testid="task-chat-interaction-receipt-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.textContent).not.toContain("Answered 2 questions");
|
|
expect(container.textContent).not.toContain(
|
|
"View original request and resolution",
|
|
);
|
|
expect(container.textContent).not.toContain("Options:");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved by:");
|
|
});
|
|
|
|
it("collapses resolved confirmations and selections to one borderless row", () => {
|
|
const acceptedConfirmation = createRequestConfirmation({
|
|
status: "accepted",
|
|
resolvedAt: new Date("2026-08-24T13:30:00.000Z"),
|
|
result: { version: 1, outcome: "accepted" },
|
|
});
|
|
const continuedConfirmation = createRequestConfirmation({
|
|
id: "confirmation-continued",
|
|
status: "rejected",
|
|
resolvedAt: new Date("2026-08-24T13:30:30.000Z"),
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Is this task ready to complete?",
|
|
rejectLabel: "Continue work",
|
|
},
|
|
result: { version: 1, outcome: "rejected" },
|
|
});
|
|
const acceptedSelection = structuredClone(
|
|
pendingRequestCheckboxConfirmationInteraction,
|
|
);
|
|
acceptedSelection.status = "accepted";
|
|
acceptedSelection.resolvedAt = new Date("2026-08-24T13:31:00.000Z");
|
|
acceptedSelection.result = {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
selectedOptionIds: [],
|
|
};
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(acceptedConfirmation)}
|
|
/>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(continuedConfirmation)}
|
|
/>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(acceptedSelection)}
|
|
/>
|
|
</>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipts = container.querySelectorAll<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipts).toHaveLength(3);
|
|
expect(receipts[0]?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmed request",
|
|
);
|
|
expect(receipts[1]?.querySelector("summary")?.textContent).toBe(
|
|
"Selected “Continue work”",
|
|
);
|
|
expect(receipts[2]?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmed with no options selected",
|
|
);
|
|
for (const receipt of receipts) {
|
|
expect(receipt.open).toBe(false);
|
|
expect(receipt.className).not.toContain("border");
|
|
expect(receipt.className).not.toContain("bg-card");
|
|
}
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
});
|
|
|
|
it("reuses the question form for a recovered harness text request", async () => {
|
|
const submit = vi.fn();
|
|
const recovered = structuredClone(pendingAskUserQuestionsInteraction);
|
|
recovered.id = "recovered-runtime-question";
|
|
recovered.title = "Configure deployment";
|
|
recovered.payload = {
|
|
version: 1,
|
|
supersedeOnUserComment: false,
|
|
submitLabel: "Continue",
|
|
questions: [
|
|
{
|
|
id: "replicas",
|
|
prompt: "How many replicas?",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [
|
|
{
|
|
id: "__paperclip_text__",
|
|
label: "Enter an integer",
|
|
freeText: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
questionSet: {
|
|
schema: "paperclip.question_set.v1",
|
|
title: "Configure deployment",
|
|
description: "The provider disconnected while waiting for this answer.",
|
|
submitLabel: "Continue",
|
|
questions: [
|
|
{
|
|
id: "replicas",
|
|
header: "Replicas",
|
|
prompt: "How many replicas?",
|
|
required: true,
|
|
answerMode: "text",
|
|
textValidation: { inputType: "integer", minimum: 1, maximum: 10 },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(recovered)}
|
|
presentation="takeover"
|
|
onSubmitInteractionAnswers={submit}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain(
|
|
"The provider disconnected while waiting for this answer.",
|
|
);
|
|
expect(container.textContent).toContain("Write an answer");
|
|
expect(container.textContent).not.toContain("Required");
|
|
const textarea = container.querySelector("textarea")!;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
setter?.call(textarea, "3");
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
const send = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Continue",
|
|
);
|
|
await act(async () => send?.click());
|
|
|
|
expect(submit).toHaveBeenCalledWith(recovered, [
|
|
{
|
|
questionId: "replicas",
|
|
optionIds: [],
|
|
otherText: "3",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each(["canonical_text", "canonical_custom", "legacy_custom"])("preserves the %s answer mapping", async (mode) => {
|
|
const submit = vi.fn();
|
|
const interaction = structuredClone(pendingAskUserQuestionsInteraction);
|
|
interaction.payload.questions = [{
|
|
id: "draft", prompt: "Edit", selectionMode: "single", required: true,
|
|
options: [{ id: "preset", label: "Preset" }, { id: "other", label: "Custom answer", freeText: true }],
|
|
}];
|
|
interaction.payload.questionSet = mode === "legacy_custom" ? undefined : {
|
|
schema: "paperclip.question_set.v1",
|
|
questions: [mode === "canonical_text"
|
|
? { id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText: "Provider draft" }
|
|
: { id: "draft", prompt: "Edit", required: true, answerMode: "single_select", options: [{ id: "preset", label: "Preset" }], customAnswer: { enabled: true, label: "Custom answer" } }],
|
|
};
|
|
await act(async () => root.render(
|
|
<TooltipProvider><ThemeProvider>
|
|
<TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionAnswers={submit} />
|
|
</ThemeProvider></TooltipProvider>,
|
|
));
|
|
if (mode !== "canonical_text") {
|
|
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Custom answer"))!.click());
|
|
}
|
|
const edited = "\n Operator 漢字 edit\nLiteral \\n stays literal. \n";
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(container.querySelector("textarea")!, edited);
|
|
container.querySelector("textarea")!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Send answers" || button.textContent?.trim() === "Submit answers")!.click());
|
|
expect(submit).toHaveBeenCalledExactlyOnceWith(interaction, [{
|
|
questionId: "draft", optionIds: [], otherText: mode === "canonical_text" ? edited : edited.trim(),
|
|
}]);
|
|
});
|
|
|
|
it("opens a compact attachment item through the authenticated file endpoint", () => {
|
|
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
|
items: [{ id: "file", label: "Reviewed file", target: { type: "issue_attachment" as const, attachmentId: "immutable-file", assetId: "asset", sha256: "hash" } }],
|
|
} };
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it.each([undefined, "/stale-display-link"])("opens a compact confirmation's bound attachment when href is %s", href => {
|
|
const interaction = createRequestConfirmation({ payload: { version: 1, prompt: "Review the attached copy", target: {
|
|
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash", href,
|
|
} } });
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onAcceptInteraction={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it("opens the bound output revision from a compact review item", () => {
|
|
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
|
items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }],
|
|
} };
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
|
|
.toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`);
|
|
});
|
|
|
|
it("paginates item verdicts instead of expanding the whole review set", async () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(pendingRequestItemVerdictsInteraction)}
|
|
presentation="takeover"
|
|
onSubmitInteractionVerdicts={() => undefined}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("1 of 5");
|
|
expect(container.textContent).toContain("Spring launch recap");
|
|
expect(container.textContent).not.toContain("Monthly changelog digest");
|
|
expect(
|
|
container.querySelector('[aria-label="Item pagination"]'),
|
|
).not.toBeNull();
|
|
|
|
const approve = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "approve",
|
|
);
|
|
const rejectOnFirstItem = Array.from(
|
|
container.querySelectorAll("button"),
|
|
).find((button) => button.textContent?.trim() === "reject");
|
|
expect(approve?.className).toContain("text-emerald-700");
|
|
expect(rejectOnFirstItem?.className).toContain("text-destructive");
|
|
expect(approve?.querySelector("svg")).not.toBeNull();
|
|
expect(rejectOnFirstItem?.querySelector("svg")).not.toBeNull();
|
|
await act(async () => approve?.click());
|
|
|
|
expect(container.textContent).toContain("2 of 5");
|
|
expect(container.textContent).toContain("Monthly changelog digest");
|
|
expect(container.textContent).not.toContain("Spring launch recap");
|
|
|
|
const reject = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "reject",
|
|
);
|
|
await act(async () => reject?.click());
|
|
expect(container.textContent).toContain("Why reject?");
|
|
expect(container.textContent).toContain("Submit rejection reason");
|
|
expect(container.textContent).toContain("2 of 5");
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
|
|
?.disabled,
|
|
).toBe(true);
|
|
|
|
const textarea = container.querySelector("textarea")!;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
setter?.call(textarea, "Needs a warmer introduction.");
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
|
|
?.disabled,
|
|
).toBe(true);
|
|
const submitReason = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Submit rejection reason",
|
|
);
|
|
await act(async () => submitReason?.click());
|
|
expect(container.textContent).toContain("3 of 5");
|
|
expect(container.textContent).toContain("Founder's note on reliability");
|
|
});
|
|
|
|
it("collapses an expired confirmation receipt at the request position", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(
|
|
createRequestConfirmation({ status: "expired" }),
|
|
)}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt?.open).toBe(false);
|
|
expect(container.textContent).toContain("Approve the plan");
|
|
expect(container.textContent).toContain("expired");
|
|
expect(container.textContent).toContain("Approve the plan?");
|
|
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmation expired",
|
|
);
|
|
expect(container.textContent).not.toContain(
|
|
"View original request and resolution",
|
|
);
|
|
});
|
|
|
|
it("keeps security details inside an expandable expired receipt", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(expiredSecretProposalInteraction)}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).toContain("Confirmation expired");
|
|
expect(container.textContent).toContain("OpenAI API key");
|
|
expect(container.textContent).toContain("access.evals_openai_api_key");
|
|
expect(container.textContent).toContain("EvalsEngineer");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
});
|
|
|
|
it("keeps skipped confirmation context, actor metadata, and terminal reason expandable", () => {
|
|
const skipped = createRequestConfirmation({
|
|
status: "cancelled",
|
|
resolvedByUserId: "user-1",
|
|
resolvedAt: new Date("2026-04-06T12:05:00.000Z"),
|
|
result: {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason: "I want to write a normal message.",
|
|
},
|
|
});
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(skipped)}
|
|
agentMap={
|
|
new Map([
|
|
["agent-1", { id: "agent-1", name: "Planner" }],
|
|
]) as never
|
|
}
|
|
userLabelMap={new Map([["user-1", "Riley"]])}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipt?.open).toBe(false);
|
|
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
|
"Skipped interaction",
|
|
);
|
|
expect(container.textContent).toContain("Approve the plan?");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved by:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
expect(container.textContent).toContain(
|
|
"I want to write a normal message.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("TaskChatThreadView interaction items", () => {
|
|
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 interaction items through renderInteraction", () => {
|
|
const item = interactionItem(
|
|
createRequestConfirmation({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
}),
|
|
);
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatThreadView
|
|
items={[
|
|
{
|
|
id: "turn-boundary",
|
|
kind: "marker",
|
|
variant: "turn_boundary",
|
|
label: "Earlier activity",
|
|
},
|
|
item,
|
|
]}
|
|
scroll={false}
|
|
renderInteraction={(it) => <TaskChatInteractionCard item={it} />}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
container
|
|
.querySelector('[data-testid="task-chat-interaction"]')
|
|
?.parentElement?.classList.contains("mt-3"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("renders nothing for interaction items without renderInteraction", () => {
|
|
const item = interactionItem(createRequestConfirmation());
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatThreadView items={[item]} scroll={false} />
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction"]'),
|
|
).toBeNull();
|
|
});
|
|
});
|