Files
PaperClipAI/ui/src/lib/issue-thread-interactions.ts
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

221 lines
7.7 KiB
TypeScript

export type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
AskUserQuestionsPayload,
AskUserQuestionsQuestion,
AskUserQuestionsQuestionOption,
AskUserQuestionsResult,
IssueThreadInteraction,
IssueThreadInteractionActorFields,
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationTarget,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksPayload,
SuggestTasksResult,
SuggestTasksResultCreatedTask,
} from "@paperclipai/shared";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationTarget,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
} from "@paperclipai/shared";
export interface SuggestedTaskTreeNode {
task: SuggestedTaskDraft;
children: SuggestedTaskTreeNode[];
}
export function isIssueThreadInteraction(
value: unknown,
): value is IssueThreadInteraction {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<IssueThreadInteraction>;
return typeof candidate.id === "string"
&& typeof candidate.companyId === "string"
&& typeof candidate.issueId === "string"
&& (
candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
);
}
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}): string[] {
const { payload, result } = args;
const selectedIds = result?.selectedOptionIds ?? [];
const optionLabelById = new Map(
payload.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
export function normalizeRequestConfirmationTargetHref(href: string) {
const value = href.trim();
if (value.startsWith("#")) return value;
if (value.startsWith("/")) return value.startsWith("//") ? null : value;
return /^https?:\/\//i.test(value) ? value : null;
}
export function getRequestConfirmationTargetHref({
issueId,
target,
}: {
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) {
const safeHref = normalizeRequestConfirmationTargetHref(target.href);
if (safeHref) return safeHref;
}
if (target.type === "issue_document") {
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
if (interaction.kind === "suggest_tasks") {
const count = interaction.payload.tasks.length;
if (interaction.status === "accepted") {
const createdCount = interaction.result?.createdTasks?.length ?? 0;
const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0;
if (skippedCount > 0) {
return `Accepted ${createdCount} of ${count} tasks`;
}
return createdCount === 1 ? "Accepted 1 task" : `Accepted ${createdCount} tasks`;
}
if (interaction.status === "rejected") {
return count === 1 ? "Rejected 1 task" : `Rejected ${count} tasks`;
}
return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`;
}
if (interaction.kind === "request_confirmation") {
if (interaction.status === "accepted") return "Confirmed request";
if (interaction.status === "rejected") return "Declined request";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Confirmation expired after comment";
if (outcome === "stale_target") return "Confirmation expired after target changed";
return "Confirmation expired";
}
return "Requested confirmation";
}
if (interaction.kind === "request_checkbox_confirmation") {
const optionCount = interaction.payload.options.length;
if (interaction.status === "accepted") {
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
if (selectedCount === 0) return "Confirmed with no options selected";
return selectedCount === 1
? `Confirmed 1 of ${optionCount} options`
: `Confirmed ${selectedCount} of ${optionCount} options`;
}
if (interaction.status === "rejected") return "Declined selection";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Selection expired after comment";
if (outcome === "stale_target") return "Selection expired after target changed";
return "Selection expired";
}
return optionCount === 1
? "Requested a selection from 1 option"
: `Requested a selection from ${optionCount} options`;
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
}
if (interaction.status === "cancelled") {
return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`;
}
if (interaction.status === "expired") {
if (interaction.result?.expirationReason === "superseded_by_comment") {
return count === 1 ? "Question expired after comment" : "Questions expired after comment";
}
return count === 1 ? "Question expired" : "Questions expired";
}
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
export function buildSuggestedTaskTree(
tasks: readonly SuggestedTaskDraft[],
): SuggestedTaskTreeNode[] {
const nodes = new Map<string, SuggestedTaskTreeNode>();
for (const task of tasks) {
nodes.set(task.clientKey, { task, children: [] });
}
const roots: SuggestedTaskTreeNode[] = [];
for (const task of tasks) {
const node = nodes.get(task.clientKey);
if (!node) continue;
const parentNode = task.parentClientKey ? nodes.get(task.parentClientKey) : null;
if (parentNode) {
parentNode.children.push(node);
continue;
}
roots.push(node);
}
return roots;
}
export function countSuggestedTaskNodes(node: SuggestedTaskTreeNode): number {
return 1 + node.children.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0);
}
export function collectSuggestedTaskClientKeys(node: SuggestedTaskTreeNode): string[] {
return [
node.task.clientKey,
...node.children.flatMap((child) => collectSuggestedTaskClientKeys(child)),
];
}
export function getQuestionAnswerLabels(args: {
question: AskUserQuestionsQuestion;
answers: readonly AskUserQuestionsAnswer[];
}) {
const { question, answers } = args;
const answer = answers.find((candidate) => candidate.questionId === question.id);
const selectedIds = answer?.optionIds ?? [];
const optionLabelById = new Map(
question.options.map((option) => [option.id, option.label] as const),
);
const labels = selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
const otherText = answer?.otherText?.trim();
if (otherText) labels.push(`Other: ${otherText}`);
return labels;
}