mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## 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>
221 lines
7.7 KiB
TypeScript
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;
|
|
}
|