From 326fd2f6fbe0aaca78c082458e91b7ff0c9d95b2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 29 Apr 2026 11:54:37 -0500 Subject: [PATCH] Allow cancelling question interactions Co-Authored-By: Paperclip --- packages/shared/src/constants.ts | 1 + packages/shared/src/types/issue.ts | 2 + packages/shared/src/validators/issue.ts | 2 + .../issue-thread-interaction-routes.test.ts | 55 +++++++++++++ .../issue-thread-interactions-service.test.ts | 79 ++++++++++++++++++ server/src/routes/issues.ts | 44 ++++++++++ .../src/services/issue-thread-interactions.ts | 55 +++++++++++++ ui/src/api/issues.ts | 2 + ui/src/components/IssueChatThread.test.tsx | 43 ++++++++++ ui/src/components/IssueChatThread.tsx | 14 ++++ .../components/IssueThreadInteractionCard.tsx | 80 +++++++++++++++---- ui/src/lib/issue-thread-interactions.ts | 3 + ui/src/pages/IssueDetail.tsx | 29 +++++++ 13 files changed, 394 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 84ca86cac39..2a7a6ba5ed5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -160,6 +160,7 @@ export const ISSUE_THREAD_INTERACTION_STATUSES = [ "accepted", "rejected", "answered", + "cancelled", "expired", "failed", ] as const; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 76aba93b275..1ec6879a097 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -414,6 +414,8 @@ export interface AskUserQuestionsAnswer { export interface AskUserQuestionsResult { version: 1; answers: AskUserQuestionsAnswer[]; + cancelled?: true; + cancellationReason?: string | null; summaryMarkdown?: string | null; } diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 4658cd8d37a..9cf3ffddb3a 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -330,6 +330,8 @@ export const askUserQuestionsAnswerSchema = z.object({ export const askUserQuestionsResultSchema = z.object({ version: z.literal(1), answers: z.array(askUserQuestionsAnswerSchema).max(20), + cancelled: z.literal(true).optional(), + cancellationReason: z.string().trim().max(4000).nullable().optional(), summaryMarkdown: z.string().max(20000).nullable().optional(), }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 7f34a7a5fa0..f53e0c5a7a7 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -17,6 +17,7 @@ const mockInteractionService = vi.hoisted(() => ({ rejectInteraction: vi.fn(), rejectSuggestedTasks: vi.fn(), answerQuestions: vi.fn(), + cancelQuestions: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ @@ -249,6 +250,36 @@ describe.sequential("issue thread interaction routes", () => { updatedAt: "2026-04-20T12:06:00.000Z", resolvedAt: "2026-04-20T12:06:00.000Z", }); + mockInteractionService.cancelQuestions.mockResolvedValue({ + id: "interaction-2", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "ask_user_questions", + status: "cancelled", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-2", + sourceRunId: "run-2", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + result: { + version: 1, + answers: [], + cancelled: true, + cancellationReason: null, + summaryMarkdown: null, + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); }); it("lists and creates board-authored interactions", async () => { @@ -363,6 +394,30 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("cancels question interactions without emitting a continuation wake", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/cancel") + .send({}); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("cancelled"); + expect(mockInteractionService.cancelQuestions).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-2", + {}, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_cancelled", + }), + ); + }); + it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => { mockInteractionService.acceptInteraction.mockResolvedValueOnce({ interaction: { diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 6146f88c0b3..063d0eef1f3 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -427,6 +427,85 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).rejects.toThrow("Interaction has already been resolved"); }); + it("persists cancelled ask_user_questions interactions without answer data", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Cancel question answers", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Question parent", + status: "in_review", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }], + }, + }, { + userId: "local-board", + }); + + const cancelled = await interactionsSvc.cancelQuestions({ + id: issueId, + companyId, + }, created.id, { + reason: "Not needed anymore", + }, { + userId: "local-board", + }); + + expect(cancelled.status).toBe("cancelled"); + expect(cancelled.result).toEqual({ + version: 1, + answers: [], + cancelled: true, + cancellationReason: "Not needed anymore", + summaryMarkdown: null, + }); + + await expect(interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + }); + it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index d994b1042fe..9ce8447c0e9 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3335,6 +3335,50 @@ export function issueRoutes( }, ); + router.post( + "/issues/:id/interactions/:interactionId/cancel", + validate(rejectIssueThreadInteractionSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).cancelQuestions(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.thread_interaction_cancelled", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + cancellationReason: + interaction.kind === "ask_user_questions" + ? (interaction.result?.cancellationReason ?? null) + : null, + }, + }); + + res.json(interaction); + }, + ); + router.get("/issues/:id/comments/:commentId", async (req, res) => { const id = req.params.id as string; const commentId = req.params.commentId as string; diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index b5e216160fa..c234dcf7938 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1265,5 +1265,60 @@ export function issueThreadInteractionService(db: Db) { await touchIssue(db, issue.id); return hydrateInteraction(updated); }, + + cancelQuestions: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = rejectIssueThreadInteractionSchema.parse(input); + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "ask_user_questions") { + throw unprocessable("Only ask_user_questions interactions can be cancelled"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const reason = data.reason?.trim() || null; + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "cancelled", + result: { + version: 1, + answers: [], + cancelled: true, + cancellationReason: reason, + summaryMarkdown: null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, }; } diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 788ef6d9f45..027222a0911 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -160,6 +160,8 @@ export const issuesApi = { api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), + cancelInteraction: (id: string, interactionId: string, reason?: string) => + api.post(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}), respondToInteraction: ( id: string, interactionId: string, diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 622a202f787..0eef52867d5 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1357,6 +1357,49 @@ describe("IssueChatThread", () => { }); }); + it("invokes the cancel callback for pending question interactions", async () => { + const root = createRoot(container); + const onCancelInteraction = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onCancelInteraction={onCancelInteraction} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const cancelButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Cancel question"), + ); + expect(cancelButton).toBeTruthy(); + + await act(async () => { + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onCancelInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-question-1", + kind: "ask_user_questions", + }), + ); + + act(() => { + root.unmount(); + }); + }); + it("folds expired request confirmations into an activity row by default", async () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1b3633bf464..0bb619d7a24 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -146,6 +146,9 @@ interface IssueChatMessageContext { interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; } const IssueChatCtx = createContext({ @@ -319,6 +322,9 @@ interface IssueChatThreadProps { interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; composerRef?: Ref; /** * Hook for the parent to refetch comments when the user explicitly asks @@ -1812,6 +1818,7 @@ function ExpiredRequestConfirmationActivity({ userLabelMap, onAcceptInteraction, onRejectInteraction, + onCancelInteraction, } = useContext(IssueChatCtx); const [expanded, setExpanded] = useState(false); const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId); @@ -1890,6 +1897,7 @@ function ExpiredRequestConfirmationActivity({ userLabelMap={userLabelMap} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} + onCancelInteraction={onCancelInteraction} /> ) : null} @@ -1905,6 +1913,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, + onCancelInteraction, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1951,6 +1960,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} onSubmitInteractionAnswers={onSubmitInteractionAnswers} + onCancelInteraction={onCancelInteraction} /> @@ -3004,6 +3014,7 @@ export function IssueChatThread({ onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, + onCancelInteraction, composerRef, onRefreshLatestComments, }: IssueChatThreadProps) { @@ -3404,6 +3415,7 @@ export function IssueChatThread({ const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction); const stableOnRejectInteraction = useStableEvent(onRejectInteraction); const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers); + const stableOnCancelInteraction = useStableEvent(onCancelInteraction); const chatCtx = useMemo( () => ({ @@ -3423,6 +3435,7 @@ export function IssueChatThread({ onAcceptInteraction: stableOnAcceptInteraction, onRejectInteraction: stableOnRejectInteraction, onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers, + onCancelInteraction: stableOnCancelInteraction, }), [ feedbackDataSharingPreference, @@ -3441,6 +3454,7 @@ export function IssueChatThread({ stableOnAcceptInteraction, stableOnRejectInteraction, stableOnSubmitInteractionAnswers, + stableOnCancelInteraction, ], ); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index c65f8b4f2f9..cf5e037a13c 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps { interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; } function resolveActorLabel(args: { @@ -72,6 +75,8 @@ function statusLabel(status: IssueThreadInteraction["status"]) { return "Rejected"; case "answered": return "Answered"; + case "cancelled": + return "Cancelled"; case "expired": return "Expired"; case "failed": @@ -100,6 +105,7 @@ function statusIcon(status: IssueThreadInteraction["status"]) { case "answered": return CheckCircle2; case "rejected": + case "cancelled": case "failed": return XCircle; case "expired": @@ -118,6 +124,7 @@ function statusClasses(status: IssueThreadInteraction["status"]) { badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100", }; case "rejected": + case "cancelled": return { shell: "border-rose-400/70 bg-transparent", badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100", @@ -636,12 +643,16 @@ function QuestionOptionButton({ function AskUserQuestionsCard({ interaction, onSubmitInteractionAnswers, + onCancelInteraction, }: { interaction: AskUserQuestionsInteraction; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; }) { const [draftAnswers, setDraftAnswers] = useState>(() => Object.fromEntries( @@ -652,6 +663,7 @@ function AskUserQuestionsCard({ ), ); const [working, setWorking] = useState(false); + const [cancelling, setCancelling] = useState(false); useEffect(() => { setDraftAnswers( @@ -699,6 +711,16 @@ function AskUserQuestionsCard({ } } + async function handleCancel() { + if (!onCancelInteraction) return; + setCancelling(true); + try { + await onCancelInteraction(interaction); + } finally { + setCancelling(false); + } + } + return (
@@ -765,26 +787,52 @@ function AskUserQuestionsCard({
))} -
+
Submit once after you finish the full form.
- +
+ + +
+ ) : interaction.status === "cancelled" ? ( +
+
Question cancelled
+ {interaction.result?.cancellationReason ? ( +

{interaction.result.cancellationReason}

+ ) : ( +

No answer was recorded.

+ )} +
) : (
{questions.map((question) => { @@ -1162,6 +1210,7 @@ export function IssueThreadInteractionCard({ onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, + onCancelInteraction, }: IssueThreadInteractionCardProps) { const StatusIcon = statusIcon(interaction.status); const styles = statusClasses(interaction.status); @@ -1247,6 +1296,7 @@ export function IssueThreadInteractionCard({ ) : ( Promise; + onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise; }; const IssueDetailChatTab = memo(function IssueDetailChatTab({ @@ -669,6 +671,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, + onCancelInteraction, }: IssueDetailChatTabProps) { const { data: activity } = useQuery({ queryKey: queryKeys.issues.activity(issueId), @@ -871,6 +874,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onSubmitInteractionAnswers={(interaction, answers) => onSubmitInteractionAnswers(interaction, answers) } + onCancelInteraction={onCancelInteraction} onCancelRun={runningIssueRun && onPauseWorkRun ? async () => { await onPauseWorkRun(runningIssueRun.id); @@ -1977,6 +1981,27 @@ export function IssueDetail() { }, }); + const cancelInteraction = useMutation({ + mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) => + issuesApi.cancelInteraction(issueId!, interaction.id), + onSuccess: (interaction) => { + upsertInteractionInCache(interaction); + invalidateIssueDetail(); + invalidateIssueCollections(); + pushToast({ + title: "Question cancelled", + tone: "success", + }); + }, + onError: (err) => { + pushToast({ + title: "Cancel failed", + body: err instanceof Error ? err.message : "Unable to cancel the question", + tone: "error", + }); + }, + }); + const addCommentAndReassign = useMutation({ mutationFn: ({ body, @@ -2753,6 +2778,9 @@ export function IssueDetail() { ) => { await answerInteraction.mutateAsync({ interaction, answers }); }, [answerInteraction]); + const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => { + await cancelInteraction.mutateAsync({ interaction }); + }, [cancelInteraction]); const treePreviewAffectedIssues = useMemo( () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), @@ -3647,6 +3675,7 @@ export function IssueDetail() { onAcceptInteraction={handleAcceptInteraction} onRejectInteraction={handleRejectInteraction} onSubmitInteractionAnswers={handleSubmitInteractionAnswers} + onCancelInteraction={handleCancelInteraction} /> ) : null}