Files
PaperClipAI/ui/src/components/IssueThreadInteractionCard.test.tsx
T
DottaandPaperclip ac9a883f8b Expire ask-user questions superseded by comments (#8799)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue-thread interactions are how agents ask board users for typed
decisions and structured answers inside an issue thread
> - Confirmation interactions already become stale when a later
board/user comment supersedes the pending decision
> - Question interactions had the same workflow risk, because a
board/user could answer in a comment while the old question card stayed
pending
> - This pull request extends the supersede-by-comment lifecycle to
ask-user-question interactions and makes that status visible in the UI
> - The benefit is agents get a clear continuation signal and users do
not see stale question forms after the discussion has moved on

## Linked Issues or Issue Description

No exact public GitHub issue was found.

Bug report:

**Pre-submission checklist**

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip, or can reproduce
on `master`.
- [x] I have confirmed the error originates in Paperclip itself, not in
an agent adapter, API provider, or local configuration.

**What happened?**

Pending `ask_user_questions` interactions could remain open after a
later board/user comment changed or answered the request in-thread. That
left a stale form visible and kept the interaction in a pending state
even though the discussion had moved on.

**Expected behavior**

Question interactions should follow the same default
supersede-on-comment behavior as confirmation interactions, with an
explicit expired result that points to the superseding comment.

**Steps to reproduce**

1. Create an `ask_user_questions` interaction on an issue.
2. Add a board/user comment created at or after that interaction.
3. Observe that before this change, the question interaction stayed
pending instead of expiring as superseded by the comment.

**Paperclip version or commit**

Current `master` before this PR.

**Deployment mode**

Self-hosted server or local dev. The bug is in shared issue-thread
interaction lifecycle handling.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific. This is a core issue-thread interaction bug.

**Database mode**

Applies to the normal Paperclip database-backed interaction lifecycle.

**Access context**

Board user comments supersede agent-created questions.

**Relevant logs or output**

No crash output. The stale pending interaction was visible in the issue
thread state.

**Relevant config (if applicable)**

None.

**Additional context**

Confirmation-style interactions already supported this stale-by-comment
behavior. This PR brings question interactions into the same lifecycle
model.

**Privacy checklist**

- [x] I have reviewed all pasted output for PII and included no private
instance links, local ticket ids, secrets, logs, or screenshots.

## What Changed

- Added `supersedeOnUserComment` support to `ask_user_questions`
payloads, defaulting it to `true` during interaction creation.
- Expire pending question interactions when a later board/user comment
supersedes them, including a result with `expirationReason:
"superseded_by_comment"` and the superseding `commentId`.
- Updated interaction summaries and cards so expired question requests
show a clear amber state with a jump link to the comment and correct
singular/plural copy.
- Updated agent onboarding guidance to describe the new default and how
to opt out.
- Added shared, server, and UI test coverage for the new lifecycle
behavior.

## Verification

- `pnpm exec vitest run
packages/shared/src/issue-thread-interactions.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/lib/issue-thread-interactions.test.ts --reporter=dot` passed: 5
files, 73 tests.
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck` passed.
- `pnpm exec vitest run
server/src/__tests__/issue-thread-interactions-service.test.ts
--reporter=dot && pnpm --filter @paperclipai/server typecheck` passed
after the final type-safety cleanup.
- Confirmed the branch is rebased on current `origin/master`.
- Confirmed the diff does not touch `pnpm-lock.yaml`,
`.github/workflows`, or database migrations.

## Risks

- Low-to-medium risk: `ask_user_questions` now defaults to expiring
after later board/user comments. Existing callers that need questions to
stay open through discussion can set `supersedeOnUserComment: false`.
- Expired question interactions store an empty `answers` array, so
downstream consumers should treat the explicit `expirationReason` as the
meaningful outcome.

> 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, GPT-5-based coding agent in Paperclip CodexCoder runtime,
with terminal and repository tool use. Exact context window is not
exposed in this 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 (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>
2026-07-01 09:28:43 -07:00

487 lines
17 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,
disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} 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("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("labels accept-only continuation policies in the card header", () => {
const host = renderCard({
interaction: {
...pendingRequestConfirmationInteraction,
continuationPolicy: "wake_assignee_on_accept",
},
});
expect(host.textContent).toContain("Wakes on confirm");
});
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 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" }),
"![bug.png](https://cdn.example/shot.png)",
);
});
});