Allow cancelling question interactions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-29 11:54:37 -05:00
1 parent 0b85f56d27
commit 326fd2f6fb
13 files changed
+394 -15

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ export const ISSUE_THREAD_INTERACTION_STATUSES = [
"accepted",
"rejected",
"answered",
"cancelled",
"expired",
"failed",
] as const;
+2
View File
@@ -414,6 +414,8 @@ export interface AskUserQuestionsAnswer {
export interface AskUserQuestionsResult {
version: 1;
answers: AskUserQuestionsAnswer[];
cancelled?: true;
cancellationReason?: string | null;
summaryMarkdown?: string | null;
}
+2
View File
@@ -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(),
});
@@ -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: {
@@ -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();
+44
View File
@@ -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;
@@ -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);
},
};
}
+2
View File
@@ -160,6 +160,8 @@ export const issuesApi = {
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
cancelInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}),
respondToInteraction: (
id: string,
interactionId: string,
@@ -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(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createQuestionInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onCancelInteraction={onCancelInteraction}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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);
+14
View File
@@ -146,6 +146,9 @@ interface IssueChatMessageContext {
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@@ -319,6 +322,9 @@ interface IssueChatThreadProps {
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
/**
* 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}
/>
</div>
) : null}
@@ -1905,6 +1913,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
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}
/>
</div>
</div>
@@ -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<IssueChatMessageContext>(
() => ({
@@ -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,
],
);
@@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps {
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | 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> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -765,26 +787,52 @@ function AskUserQuestionsCard({
</div>
))}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="text-sm text-muted-foreground">
Submit once after you finish the full form.
</div>
<Button
size="sm"
disabled={!onSubmitInteractionAnswers || !canSubmit || working}
onClick={() => void handleSubmit()}
>
{working ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Submitting...
</>
) : (
interaction.payload.submitLabel ?? "Submit answers"
)}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={!onCancelInteraction || working || cancelling}
onClick={() => void handleCancel()}
>
{cancelling ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Cancelling...
</>
) : (
"Cancel question"
)}
</Button>
<Button
size="sm"
disabled={!onSubmitInteractionAnswers || !canSubmit || working || cancelling}
onClick={() => void handleSubmit()}
>
{working ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Submitting...
</>
) : (
interaction.payload.submitLabel ?? "Submit answers"
)}
</Button>
</div>
</div>
</div>
) : interaction.status === "cancelled" ? (
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 p-4 text-sm leading-6 text-rose-950 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
<div className="font-semibold">Question cancelled</div>
{interaction.result?.cancellationReason ? (
<p className="mt-1">{interaction.result.cancellationReason}</p>
) : (
<p className="mt-1">No answer was recorded.</p>
)}
</div>
) : (
<div className="space-y-3">
{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({
<AskUserQuestionsCard
interaction={interaction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
) : (
<RequestConfirmationCard
+3
View File
@@ -87,6 +87,9 @@ export function buildIssueThreadInteractionSummary(
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`;
}
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
+29
View File
@@ -131,6 +131,7 @@ import {
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type ActivityEvent,
type Agent,
type FeedbackVote,
@@ -622,6 +623,7 @@ type IssueDetailChatTabProps = {
interaction: IssueThreadInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
};
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}
</TabsContent>