mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the control plane that lets humans govern companies of AI agents. > - Issue-thread interactions are the structured handoff point for confirmations, questions, suggested tasks, and other governed decisions. > - Those interactions previously assumed that only board users could resolve them, preventing one agent from explicitly addressing another agent for a response. > - Agent resolution needs company-level governance, auditable resolver identity, safe terminal-state handling, and attention routing so authorization is enforced server-side rather than inferred from UI behavior. > - This pull request adds governed agent resolution, withdrawal and terminal expiry semantics, explicit agent addressees, lifecycle reconciliation, and attention-feed filtering. > - The benefit is that agents can participate in structured decisions without weakening board control, company isolation, wake behavior, or audit invariants. ## Linked Issues or Issue Description ### Subsystem affected Issue-thread interactions across database, shared contracts, server authorization/services, adapter callbacks, agent skill guidance, API docs, and UI governance surfaces. ### Problem or motivation Structured interactions were board-only, had no explicit agent addressee, and lacked durable withdrawal/terminal-expiry semantics. That made peer-agent decisions impossible to authorize and audit safely. ### Proposed solution Persist requested/effective resolver policy and addressee identity, enforce company governance and eligible agent resolution, reconcile addressee lifecycle changes, expose withdrawal and terminal expiry, and route attention to the intended active agent with board fallback. ### Alternatives considered Implicitly authorizing the issue assignee or mentioned agents was rejected as ambiguous and difficult to audit. Using comments alone was rejected because it loses structured outcomes and continuation behavior. ### Roadmap alignment Supports the ROADMAP direction for lightweight leadership-agent communication that still resolves into governed decisions and work objects. ### Additional context Public GitHub issue/PR search found no duplicate implementation; open PR search for interaction resolver governance and agent addressees only returned this PR. ## What Changed - Add company-scoped interaction resolver governance contracts and persistence. - Add requested/effective resolver policy, resolver identity, withdrawal, and terminal-expiry behavior. - Add explicit `addresseeAgentId` validation, authorization, persistence, lifecycle reconciliation, API documentation, and skill guidance. - Route pending addressed interactions to the intended invokable agent and fall back to board attention when that agent becomes ineligible or is deleted. - Preserve sandbox callback identity fields required by governed resolution paths. - Add migrations `0193` and `0194` plus route, service, attention, adapter, CLI, and UI coverage. - Add governance state and company settings UI, including responsive mobile behavior and distinct withdrawn/expired audit presentation. ## Verification - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed, including migration numbering and safety checks. - `pnpm test:run` — feature/server and UI workspace suites passed; one unrelated CLI AWS doctor test observed injected static AWS credentials and warned instead of passing. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts --project paperclipai` — 8 tests passed, confirming the failure was environment-sensitive. - `pnpm build` — passed. - Latest rebased head `e24cece6be9f1877bdbac7691bcb44fd583c0161` completed all GitHub CI jobs successfully. ## Risks - Migrations add interaction and company-governance fields; numbering is conflict-free on current `master`, additive statements are idempotent, and migration safety checks pass. - Agent authorization behavior expands beyond board-only resolution, but defaults remain board-only and coverage exercises company boundaries, resolver eligibility, lifecycle invalidation, wake behavior, withdrawal, expiry, and attention fallback. - Attention routing depends on current agent invokability; reconciliation and read-time filtering prevent stale addressees from retaining visibility or resolution authority. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using `gpt-5.6-sol` with reasoning, terminal tool use, code execution, Git/GitHub integration, and Paperclip control-plane tools. Context-window metadata was not reported by the runtime. ## 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 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> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
871 lines
33 KiB
TypeScript
871 lines
33 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
|
import { ThemeProvider } from "../context/ThemeContext";
|
|
import { TooltipProvider } from "./ui/tooltip";
|
|
import {
|
|
pendingAskUserQuestionsInteraction,
|
|
commentExpiredAskUserQuestionsInteraction,
|
|
commentExpiredRequestConfirmationInteraction,
|
|
declinedToolActionInteraction,
|
|
disabledDeclineReasonRequestConfirmationInteraction,
|
|
executedToolActionInteraction,
|
|
expiredToolActionInteraction,
|
|
failedRequestConfirmationInteraction,
|
|
failedToolActionInteraction,
|
|
pendingRequestConfirmationInteraction,
|
|
pendingToolActionDestructiveInteraction,
|
|
pendingToolActionWriteInteraction,
|
|
planApprovalResumeFailedRequestConfirmationInteraction,
|
|
pendingRequestItemVerdictsInteraction,
|
|
pendingSuggestedTasksInteraction,
|
|
runningToolActionInteraction,
|
|
completeRequestItemVerdictsInteraction,
|
|
supersededRequestItemVerdictsInteraction,
|
|
staleTargetRequestConfirmationInteraction,
|
|
rejectedSuggestedTasksInteraction,
|
|
agentAddressedRequestConfirmationInteraction,
|
|
agentResolvedRequestConfirmationInteraction,
|
|
withdrawnRequestConfirmationInteraction,
|
|
issueClosedRequestConfirmationInteraction,
|
|
} from "../fixtures/issueThreadInteractionFixtures";
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
if (typeof reactAct === "function") {
|
|
await reactAct(callback);
|
|
return;
|
|
}
|
|
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
|
|
<a href={to} className={className}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
function renderCard(
|
|
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
|
|
) {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
act(() => {
|
|
root?.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<IssueThreadInteractionCard
|
|
interaction={pendingAskUserQuestionsInteraction}
|
|
{...props}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => root?.unmount());
|
|
}
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
describe("IssueThreadInteractionCard", () => {
|
|
it("exposes pending question options as selectable radio and checkbox controls", () => {
|
|
const host = renderCard({
|
|
interaction: pendingAskUserQuestionsInteraction,
|
|
onSubmitInteractionAnswers: vi.fn(),
|
|
});
|
|
|
|
const singleGroup = host.querySelector('[role="radiogroup"]');
|
|
expect(singleGroup?.getAttribute("aria-labelledby")).toBe(
|
|
"interaction-questions-default-collapse-depth-prompt",
|
|
);
|
|
|
|
const radios = [...host.querySelectorAll('[role="radio"]')];
|
|
expect(radios).toHaveLength(2);
|
|
expect(radios[0]?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
act(() => {
|
|
(radios[0] as HTMLButtonElement).click();
|
|
});
|
|
|
|
expect(radios[0]?.getAttribute("aria-checked")).toBe("true");
|
|
expect(radios[1]?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
const multiGroup = host.querySelector('[role="group"]');
|
|
expect(multiGroup?.getAttribute("aria-labelledby")).toBe(
|
|
"interaction-questions-default-post-submit-summary-prompt",
|
|
);
|
|
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
|
|
|
|
const otherLink = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent === "Other",
|
|
);
|
|
expect(otherLink?.getAttribute("role")).toBeNull();
|
|
expect(otherLink?.className).toContain("underline");
|
|
});
|
|
|
|
it("submits written Other answers for pending questions", async () => {
|
|
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: pendingAskUserQuestionsInteraction,
|
|
onSubmitInteractionAnswers,
|
|
});
|
|
|
|
const otherButtons = Array.from(host.querySelectorAll("button")).filter((button) =>
|
|
button.textContent?.includes("Other"),
|
|
);
|
|
expect(otherButtons.length).toBeGreaterThan(0);
|
|
|
|
await act(async () => {
|
|
otherButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
|
expect(textarea).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
valueSetter?.call(textarea, "Keep only the root item open");
|
|
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
const summaryCheckbox = Array.from(host.querySelectorAll('[role="checkbox"]')).find((button) =>
|
|
button.textContent?.includes("Inline answer pills"),
|
|
);
|
|
await act(async () => {
|
|
summaryCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Send answers"),
|
|
);
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "ask_user_questions" }),
|
|
[
|
|
{
|
|
questionId: "collapse-depth",
|
|
optionIds: [],
|
|
otherText: "Keep only the root item open",
|
|
},
|
|
{
|
|
questionId: "post-submit-summary",
|
|
optionIds: ["answers-inline"],
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
it("only shows question cancellation when a cancel handler is wired", () => {
|
|
const withoutHandler = renderCard({
|
|
interaction: pendingAskUserQuestionsInteraction,
|
|
onSubmitInteractionAnswers: vi.fn(),
|
|
});
|
|
expect(withoutHandler.textContent).not.toContain("Cancel question");
|
|
|
|
act(() => root?.unmount());
|
|
withoutHandler.remove();
|
|
root = null;
|
|
|
|
const withHandler = renderCard({
|
|
interaction: pendingAskUserQuestionsInteraction,
|
|
onCancelInteraction: vi.fn(),
|
|
onSubmitInteractionAnswers: vi.fn(),
|
|
});
|
|
expect(withHandler.textContent).toContain("Cancel question");
|
|
});
|
|
|
|
it("renders expired question interactions as resolved and non-actionable", () => {
|
|
const host = renderCard({
|
|
interaction: commentExpiredAskUserQuestionsInteraction,
|
|
onSubmitInteractionAnswers: vi.fn(),
|
|
onCancelInteraction: vi.fn(),
|
|
});
|
|
|
|
expect(host.textContent).toContain("Questions expired by comment");
|
|
expect(host.textContent).toContain("A later board/user comment superseded this question request.");
|
|
expect(host.textContent).not.toContain("Send answers");
|
|
expect(host.textContent).not.toContain("Cancel question");
|
|
|
|
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
|
link.textContent?.includes("Jump to comment"),
|
|
);
|
|
expect(jumpLink?.getAttribute("href")).toBe(
|
|
"#comment-22222222-2222-4222-8222-222222222222",
|
|
);
|
|
});
|
|
|
|
it("uses singular copy for expired single-question interactions", () => {
|
|
const [question] = commentExpiredAskUserQuestionsInteraction.payload.questions;
|
|
const host = renderCard({
|
|
interaction: {
|
|
...commentExpiredAskUserQuestionsInteraction,
|
|
payload: {
|
|
...commentExpiredAskUserQuestionsInteraction.payload,
|
|
questions: [question],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain("Question expired by comment");
|
|
expect(host.textContent).not.toContain("Questions expired by comment");
|
|
});
|
|
|
|
it("renders withdrawn confirmations with the withdraw reason", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingRequestConfirmationInteraction,
|
|
status: "cancelled",
|
|
result: { version: 1, outcome: "withdrawn", reason: "Superseded by the hotfix plan." },
|
|
},
|
|
onAcceptInteraction: vi.fn(),
|
|
onRejectInteraction: vi.fn(),
|
|
});
|
|
|
|
expect(host.textContent).toContain("Withdrawn");
|
|
expect(host.textContent).toContain("Superseded by the hotfix plan.");
|
|
expect(host.textContent).not.toContain("Decline");
|
|
});
|
|
|
|
it("renders confirmations expired by issue closure with dedicated copy", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingRequestConfirmationInteraction,
|
|
status: "expired",
|
|
result: { version: 1, outcome: "issue_closed", reason: null },
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain("Expired · issue closed");
|
|
expect(host.textContent).toContain("This confirmation expired automatically when the issue reached a terminal state.");
|
|
expect(host.textContent).not.toContain("Expired by target change");
|
|
});
|
|
|
|
it("renders withdrawn question interactions with the withdraw reason", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingAskUserQuestionsInteraction,
|
|
status: "cancelled",
|
|
result: {
|
|
version: 1,
|
|
outcome: "withdrawn",
|
|
reason: "Scope was decided on the parent issue.",
|
|
answers: [],
|
|
summaryMarkdown: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain("Questions withdrawn");
|
|
expect(host.textContent).toContain("Scope was decided on the parent issue.");
|
|
expect(host.textContent).not.toContain("Question cancelled");
|
|
});
|
|
|
|
it("renders question interactions expired by issue closure with dedicated copy", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingAskUserQuestionsInteraction,
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "issue_closed",
|
|
reason: null,
|
|
answers: [],
|
|
summaryMarkdown: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain("Questions expired when the issue closed");
|
|
expect(host.textContent).toContain("This question request expired automatically when the issue reached a terminal state.");
|
|
expect(host.textContent).not.toContain("expired by comment");
|
|
});
|
|
|
|
it("makes child tasks explicit in suggested task trees", () => {
|
|
const host = renderCard({
|
|
interaction: pendingSuggestedTasksInteraction,
|
|
});
|
|
|
|
expect(host.textContent).toContain("Child task");
|
|
});
|
|
|
|
it("shows an explicit placeholder when a rejected interaction has no reason", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...rejectedSuggestedTasksInteraction,
|
|
result: { version: 1 },
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain("No reason provided.");
|
|
});
|
|
|
|
it("requires a decline reason when the request confirmation payload asks for one", async () => {
|
|
const onRejectInteraction = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: pendingRequestConfirmationInteraction,
|
|
onRejectInteraction,
|
|
});
|
|
|
|
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Request revisions"),
|
|
);
|
|
expect(declineButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
|
button.textContent?.includes("Request revisions"),
|
|
).at(-1);
|
|
expect(saveButton?.hasAttribute("disabled")).toBe(false);
|
|
|
|
await act(async () => {
|
|
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(host.textContent).toContain("A decline reason is required.");
|
|
|
|
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
|
expect(textarea).toBeTruthy();
|
|
expect(textarea?.getAttribute("aria-invalid")).toBe("true");
|
|
|
|
await act(async () => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
valueSetter?.call(textarea, "Needs a smaller phase split");
|
|
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
|
button.textContent?.includes("Request revisions"),
|
|
).at(-1);
|
|
expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
|
|
await act(async () => {
|
|
enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onRejectInteraction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "request_confirmation" }),
|
|
"Needs a smaller phase split",
|
|
);
|
|
});
|
|
|
|
it("invokes the confirm callback with pending request confirmations", async () => {
|
|
const onAcceptInteraction = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: pendingRequestConfirmationInteraction,
|
|
onAcceptInteraction,
|
|
});
|
|
|
|
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Approve plan"),
|
|
);
|
|
expect(confirmButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onAcceptInteraction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "request_confirmation" }),
|
|
);
|
|
});
|
|
|
|
it("does not expose continuation wake policy labels in the card header", () => {
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingRequestConfirmationInteraction,
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).not.toContain("Wakes on confirm");
|
|
expect(host.textContent).not.toContain("Wakes assignee");
|
|
});
|
|
|
|
it("renders request confirmation target links and stale-target expiry", () => {
|
|
const host = renderCard({
|
|
interaction: staleTargetRequestConfirmationInteraction,
|
|
});
|
|
|
|
const targetLinks = host.querySelectorAll("a");
|
|
expect(host.textContent).toContain("Expired by target change");
|
|
expect(host.textContent).toContain("Plan v3");
|
|
expect(host.textContent).toContain("Plan v4");
|
|
expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan");
|
|
expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan");
|
|
expect(host.textContent).not.toContain("Approve plan");
|
|
});
|
|
|
|
it("renders a jump link for confirmations expired by comment", () => {
|
|
const host = renderCard({
|
|
interaction: commentExpiredRequestConfirmationInteraction,
|
|
});
|
|
|
|
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
|
link.textContent?.includes("Jump to comment"),
|
|
);
|
|
|
|
expect(jumpLink?.getAttribute("href")).toBe(
|
|
"#comment-22222222-2222-4222-8222-222222222222",
|
|
);
|
|
});
|
|
|
|
it("declines immediately when decline reasons are disabled", async () => {
|
|
const onRejectInteraction = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: disabledDeclineReasonRequestConfirmationInteraction,
|
|
onRejectInteraction,
|
|
});
|
|
|
|
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Keep it"),
|
|
);
|
|
expect(declineButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(host.querySelector("textarea")).toBeNull();
|
|
expect(onRejectInteraction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "request_confirmation" }),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("renders explicit copy for failed request confirmations", () => {
|
|
const host = renderCard({
|
|
interaction: failedRequestConfirmationInteraction,
|
|
});
|
|
|
|
expect(host.textContent).toContain(
|
|
"This request could not be resolved. Try again or create a new request.",
|
|
);
|
|
});
|
|
|
|
it("renders a plan confirmation as a distinct state-coloured plan card", () => {
|
|
const pending = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
|
const pendingShell = pending.firstElementChild as HTMLElement;
|
|
expect(pendingShell.className).toContain("border-violet-500/80");
|
|
expect(pendingShell.className).not.toContain("border-l-");
|
|
expect(pending.textContent).toContain("Plan");
|
|
expect(pending.textContent).toContain("In review");
|
|
// Approve is a neutral CTA (foreground/background), not the blue primary.
|
|
const approve = Array.from(pending.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Approve plan"),
|
|
);
|
|
expect(approve?.className).toContain("bg-foreground");
|
|
expect(approve?.className).not.toContain("bg-primary");
|
|
|
|
act(() => root?.unmount());
|
|
pending.remove();
|
|
root = null;
|
|
|
|
const accepted = renderCard({
|
|
interaction: { ...pendingRequestConfirmationInteraction, status: "accepted" },
|
|
});
|
|
expect((accepted.firstElementChild as HTMLElement).className).toContain("border-green-500/80");
|
|
expect(accepted.textContent).toContain("Approved");
|
|
|
|
act(() => root?.unmount());
|
|
accepted.remove();
|
|
root = null;
|
|
|
|
const resumeFailed = renderCard({
|
|
interaction: planApprovalResumeFailedRequestConfirmationInteraction,
|
|
});
|
|
expect((resumeFailed.firstElementChild as HTMLElement).className).toContain("border-amber-500/70");
|
|
expect(resumeFailed.textContent).toContain("Approved — agent resume failed");
|
|
expect(resumeFailed.textContent).toContain("Agent resume failed");
|
|
expect(resumeFailed.textContent).toContain("Paperclip needs attention before the agent can resume this approved work.");
|
|
expect(resumeFailed.textContent).toContain("adapter_failed");
|
|
|
|
act(() => root?.unmount());
|
|
resumeFailed.remove();
|
|
root = null;
|
|
|
|
const rejected = renderCard({
|
|
interaction: {
|
|
...pendingRequestConfirmationInteraction,
|
|
status: "rejected",
|
|
result: { version: 1, outcome: "rejected", reason: "Tighten the spacing" },
|
|
},
|
|
});
|
|
expect((rejected.firstElementChild as HTMLElement).className).toContain("border-red-500/80");
|
|
expect(rejected.textContent).toContain("Changes requested");
|
|
});
|
|
|
|
it("attaches screenshots to a plan request-changes reason as markdown images", async () => {
|
|
const onRejectInteraction = vi.fn(async () => undefined);
|
|
const onUploadImage = vi.fn(async () => "https://cdn.example/shot.png");
|
|
const host = renderCard({
|
|
interaction: {
|
|
...pendingRequestConfirmationInteraction,
|
|
payload: {
|
|
...pendingRequestConfirmationInteraction.payload,
|
|
rejectRequiresReason: false,
|
|
},
|
|
},
|
|
onRejectInteraction,
|
|
onUploadImage,
|
|
});
|
|
|
|
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Request revisions"),
|
|
);
|
|
await act(async () => {
|
|
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const attachButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Attach screenshots"),
|
|
);
|
|
expect(attachButton).toBeTruthy();
|
|
|
|
const fileInput = host.querySelector('input[type="file"]') as HTMLInputElement | null;
|
|
expect(fileInput).toBeTruthy();
|
|
const file = new File(["x"], "bug.png", { type: "image/png" });
|
|
Object.defineProperty(fileInput!, "files", { value: [file], configurable: true });
|
|
Object.defineProperty(fileInput!, "value", {
|
|
value: "C:/fake/bug.png",
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
await act(async () => {
|
|
fileInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
expect(onUploadImage).toHaveBeenCalledTimes(1);
|
|
|
|
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
|
button.textContent?.includes("Request revisions"),
|
|
).at(-1);
|
|
await act(async () => {
|
|
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onRejectInteraction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "request_confirmation" }),
|
|
"",
|
|
);
|
|
});
|
|
|
|
it("submits an approve verdict once a draft is marked and applied", async () => {
|
|
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: pendingRequestItemVerdictsInteraction,
|
|
onSubmitInteractionVerdicts,
|
|
});
|
|
|
|
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
|
const approveButton = Array.from(
|
|
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="approve"]`),
|
|
)[0];
|
|
expect(approveButton).toBeTruthy();
|
|
// 44px minimum target (a11y).
|
|
expect(approveButton?.className).toContain("min-h-11");
|
|
|
|
await act(async () => {
|
|
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Apply 1 decision"),
|
|
);
|
|
expect(applyButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(onSubmitInteractionVerdicts).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "request_item_verdicts" }),
|
|
[{ id: firstItemId, verdict: "approve", reason: undefined }],
|
|
);
|
|
});
|
|
|
|
it("blocks apply for a rejected item until a reason is entered", async () => {
|
|
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
|
const host = renderCard({
|
|
interaction: pendingRequestItemVerdictsInteraction,
|
|
onSubmitInteractionVerdicts,
|
|
});
|
|
|
|
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
|
const rejectButton = Array.from(
|
|
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="reject"]`),
|
|
)[0];
|
|
await act(async () => {
|
|
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
// Reject reveals a required reason field.
|
|
const reasonField = host.querySelector<HTMLTextAreaElement>(
|
|
`textarea[id="${pendingRequestItemVerdictsInteraction.id}-${firstItemId}-reason"]`,
|
|
);
|
|
expect(reasonField).toBeTruthy();
|
|
|
|
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Apply 1 decision"),
|
|
);
|
|
// Attempting to apply without a reason does not submit.
|
|
await act(async () => {
|
|
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
expect(onSubmitInteractionVerdicts).not.toHaveBeenCalled();
|
|
expect(host.textContent).toContain("A reason is required to reject this item.");
|
|
});
|
|
|
|
it("renders resolved verdicts as terminal chips with reason echo", () => {
|
|
const host = renderCard({ interaction: completeRequestItemVerdictsInteraction });
|
|
expect(host.textContent).toContain("Approved");
|
|
expect(host.textContent).toContain("Rejected");
|
|
expect(host.textContent).toContain("Tone is off-brand");
|
|
// S5 summary chip.
|
|
expect(host.textContent).toContain("3 approved");
|
|
// No actionable verdict buttons once terminal.
|
|
expect(host.querySelector("button[data-verdict]")).toBeNull();
|
|
});
|
|
|
|
it("shows an already-applied, cannot-revert notice when superseded", () => {
|
|
const host = renderCard({ interaction: supersededRequestItemVerdictsInteraction });
|
|
expect(host.textContent).toContain("expired after a later comment");
|
|
expect(host.textContent).toContain("cannot be");
|
|
expect(host.textContent?.toLowerCase()).toContain("revert");
|
|
});
|
|
});
|
|
|
|
describe("IssueThreadInteractionCard tool-action card", () => {
|
|
it("selects the pending state with the Approve & run affordance and identity header", () => {
|
|
const host = renderCard({
|
|
interaction: pendingToolActionWriteInteraction,
|
|
onAcceptInteraction: vi.fn(),
|
|
onRejectInteraction: vi.fn(),
|
|
});
|
|
|
|
// Pending eyebrow, never a bare "Accepted".
|
|
expect(host.textContent).toContain("Awaiting approval");
|
|
// Identity header: tool display name + WRITE risk badge + app/tool sub-line.
|
|
expect(host.textContent).toContain("Append row to spreadsheet");
|
|
expect(host.textContent).toContain("WRITE");
|
|
expect(host.textContent).toContain("Google Sheets");
|
|
// Primary CTA is "Approve & run" (approve = run), plus the hint + countdown.
|
|
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Approve & run"),
|
|
);
|
|
expect(approve).toBeTruthy();
|
|
expect(host.textContent).toContain("Approving runs this action now.");
|
|
expect(host.textContent).toContain("Approval expires in");
|
|
// Technical details drawer is present but collapsed by default (hash hidden).
|
|
expect(host.textContent).toContain("Technical details");
|
|
expect(host.textContent).not.toContain("args hash");
|
|
});
|
|
|
|
it("uses the destructive risk badge and a destructive primary button", () => {
|
|
const host = renderCard({
|
|
interaction: pendingToolActionDestructiveInteraction,
|
|
onAcceptInteraction: vi.fn(),
|
|
onRejectInteraction: vi.fn(),
|
|
});
|
|
|
|
expect(host.textContent).toContain("DESTRUCTIVE");
|
|
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Approve & run"),
|
|
);
|
|
expect(approve?.getAttribute("data-variant")).toBe("destructive");
|
|
});
|
|
|
|
it("reveals redacted args and the hash when the technical drawer is opened", () => {
|
|
const host = renderCard({
|
|
interaction: pendingToolActionWriteInteraction,
|
|
onAcceptInteraction: vi.fn(),
|
|
onRejectInteraction: vi.fn(),
|
|
});
|
|
|
|
const trigger = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Technical details"),
|
|
);
|
|
act(() => {
|
|
(trigger as HTMLButtonElement).click();
|
|
});
|
|
|
|
expect(host.textContent).toContain("args hash");
|
|
expect(host.textContent).toContain("sha256:9f2c1a7be4d0c8a3");
|
|
// Redacted arguments render verbatim, never raw secrets.
|
|
expect(host.textContent).toContain("[redacted]");
|
|
});
|
|
|
|
it("renders the approved-running state with a spinner and no action buttons", () => {
|
|
const host = renderCard({ interaction: runningToolActionInteraction });
|
|
|
|
expect(host.textContent).toContain("Running…");
|
|
expect(host.textContent).toContain("running the action now");
|
|
expect(host.textContent).not.toContain("Approve & run");
|
|
expect(host.querySelector(".animate-spin")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the executed state with a result summary and never reads Accepted", () => {
|
|
const host = renderCard({ interaction: executedToolActionInteraction });
|
|
|
|
expect(host.textContent).toContain("Executed");
|
|
expect(host.textContent).toContain("Row 42 added");
|
|
expect(host.textContent).not.toContain("Accepted");
|
|
const link = Array.from(host.querySelectorAll("a")).find((a) =>
|
|
a.textContent?.includes("View result"),
|
|
);
|
|
expect(link?.getAttribute("href")).toContain("docs.google.com");
|
|
});
|
|
|
|
it("distinguishes failed (ran + connector error) from declined (did not run)", () => {
|
|
const failed = renderCard({ interaction: failedToolActionInteraction });
|
|
expect(failed.textContent).toContain("Failed");
|
|
expect(failed.textContent).toContain("insufficient_permission");
|
|
expect(failed.textContent).toContain("but the connector returned an error");
|
|
|
|
act(() => root?.unmount());
|
|
failed.remove();
|
|
root = null;
|
|
|
|
const declined = renderCard({ interaction: declinedToolActionInteraction });
|
|
expect(declined.textContent).toContain("Declined");
|
|
expect(declined.textContent).toContain("did");
|
|
expect(declined.textContent).toContain("not");
|
|
expect(declined.textContent).toContain("run");
|
|
expect(declined.textContent).toContain("use the CRM sync instead");
|
|
expect(declined.textContent).not.toContain("Approve & run");
|
|
});
|
|
|
|
it("renders the expired state with the 60-minute rule and a recovery path", () => {
|
|
const host = renderCard({ interaction: expiredToolActionInteraction });
|
|
|
|
expect(host.textContent).toContain("Expired");
|
|
expect(host.textContent).toContain("no one responded within 60 minutes");
|
|
expect(host.textContent).toContain("the agent can request approval again");
|
|
expect(host.textContent).not.toContain("Approve & run");
|
|
});
|
|
|
|
it("keeps the generic confirmation rendering for cards without a toolAction", () => {
|
|
const host = renderCard({
|
|
interaction: pendingRequestConfirmationInteraction,
|
|
onAcceptInteraction: vi.fn(),
|
|
onRejectInteraction: vi.fn(),
|
|
});
|
|
|
|
// Legacy confirmation keeps its own prompt + labels, no tool-action surface.
|
|
expect(host.textContent).toContain("Approve the plan and let the responsible start implementation?");
|
|
expect(host.textContent).not.toContain("Approve & run");
|
|
expect(host.textContent).not.toContain("Technical details");
|
|
});
|
|
|
|
it("renders the agents-may-resolve policy badge and addressee chip", () => {
|
|
const host = renderCard({
|
|
interaction: agentAddressedRequestConfirmationInteraction,
|
|
});
|
|
|
|
const policyBadge = host.querySelector('[data-testid="interaction-policy-badge"]');
|
|
expect(policyBadge?.textContent).toContain("Agents may resolve");
|
|
|
|
const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]');
|
|
expect(addresseeBadge?.textContent).toContain("For ");
|
|
});
|
|
|
|
it("omits the policy and addressee badges for a board-only interaction", () => {
|
|
const host = renderCard({
|
|
interaction: pendingRequestConfirmationInteraction,
|
|
});
|
|
|
|
expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
|
|
expect(host.querySelector('[data-testid="interaction-addressee-badge"]')).toBeNull();
|
|
});
|
|
|
|
it("marks agent resolution with an audit chip in the resolved footer", () => {
|
|
const host = renderCard({
|
|
interaction: agentResolvedRequestConfirmationInteraction,
|
|
});
|
|
|
|
const footer = host.querySelector('[data-testid="interaction-resolved-footer"]');
|
|
expect(footer?.textContent).toContain("Resolved by");
|
|
expect(
|
|
host.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("renders a withdrawn footer with the withdrawer, reason, and agent chip", () => {
|
|
const host = renderCard({
|
|
interaction: withdrawnRequestConfirmationInteraction,
|
|
});
|
|
|
|
// Header status reads "Withdrawn", not the raw "Cancelled" status.
|
|
expect(host.textContent).toContain("Withdrawn");
|
|
// Withdrawn is a neutral administrative retraction — it must NOT wear the
|
|
// cancelled/rejected costume (rose/red border + XCircle). The shell is muted
|
|
// (border-border), never a rose/red alarm colour (design review R2).
|
|
const cardRoot = host.querySelector("div.rounded-lg.p-5.shadow-none");
|
|
expect(cardRoot?.className).toContain("border-border");
|
|
expect(cardRoot?.className).not.toMatch(/border-(rose|red)/);
|
|
// The header status icon is MinusCircle ("retracted"), never XCircle ("denied").
|
|
const statusIcon = cardRoot?.querySelector("svg");
|
|
expect(statusIcon?.getAttribute("class")).toContain("lucide-circle-minus");
|
|
expect(statusIcon?.getAttribute("class")).not.toContain("lucide-circle-x");
|
|
const footer = host.querySelector('[data-testid="interaction-withdrawn-footer"]');
|
|
expect(footer?.textContent).toContain("Withdrawn by");
|
|
expect(footer?.textContent).toContain("Plan superseded by a newer revision");
|
|
expect(
|
|
footer?.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
|
).not.toBeNull();
|
|
// The generic "Resolved by" footer must not double-render.
|
|
expect(host.querySelector('[data-testid="interaction-resolved-footer"]')).toBeNull();
|
|
});
|
|
|
|
it("renders an issue-closed expiry footer for terminal auto-expiry", () => {
|
|
const host = renderCard({
|
|
interaction: issueClosedRequestConfirmationInteraction,
|
|
});
|
|
|
|
// Footer is trimmed to just the audit timestamp — the header status badge
|
|
// already carries the "Expired · issue closed" label, so the footer must
|
|
// not restate it.
|
|
const footer = host.querySelector('[data-testid="interaction-issue-closed-footer"]');
|
|
expect(footer?.textContent).toContain("Apr 20");
|
|
expect(footer?.textContent).not.toContain("Expired when the issue closed");
|
|
// The "Expired · issue closed" label survives exactly once (the header
|
|
// status badge); the duplicate body eyebrow was dropped.
|
|
const label = "Expired · issue closed";
|
|
const occurrences = (host.textContent ?? "").split(label).length - 1;
|
|
expect(occurrences).toBe(1);
|
|
});
|
|
});
|