diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 66ca57ea3a..fea5c7e592 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { acceptIssueThreadInteractionSchema, + askUserQuestionsResultSchema, createIssueThreadInteractionSchema, } from "./validators/issue.js"; @@ -103,6 +104,44 @@ describe("issue thread interaction schemas", () => { } }); + it("parses ask_user_questions supersede flags and expired results", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "ask_user_questions", + payload: { + version: 1, + title: "Choose scope", + supersedeOnUserComment: false, + questions: [ + { + id: "scope", + prompt: "Which scope should I use?", + selectionMode: "single", + options: [{ id: "small", label: "Small" }], + }, + ], + }, + }); + + expect(parsed).toMatchObject({ + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + supersedeOnUserComment: false, + }, + }); + + expect(askUserQuestionsResultSchema.parse({ + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + summaryMarkdown: null, + })).toMatchObject({ + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + }); + }); + it("rejects unsafe request_confirmation target hrefs", () => { const base = { kind: "request_confirmation", diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 2cc8d8b7c3..1fed1d34d0 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -760,6 +760,7 @@ export interface AskUserQuestionsPayload { version: 1; title?: string | null; submitLabel?: string | null; + supersedeOnUserComment?: boolean; questions: AskUserQuestionsQuestion[]; } @@ -774,6 +775,8 @@ export interface AskUserQuestionsResult { answers: AskUserQuestionsAnswer[]; cancelled?: true; cancellationReason?: string | null; + expirationReason?: "superseded_by_comment"; + commentId?: string | null; summaryMarkdown?: string | null; } diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index f1e8fcb95e..b22092ef0d 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -657,6 +657,7 @@ export const askUserQuestionsPayloadSchema = z.object({ version: z.literal(1), title: z.string().trim().max(240).nullable().optional(), submitLabel: z.string().trim().max(120).nullable().optional(), + supersedeOnUserComment: z.boolean().optional(), questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), }).superRefine((value, ctx) => { const seenQuestionIds = new Set(); @@ -695,6 +696,8 @@ export const askUserQuestionsResultSchema = z.object({ answers: z.array(askUserQuestionsAnswerSchema).max(20), cancelled: z.literal(true).optional(), cancellationReason: z.string().trim().max(4000).nullable().optional(), + expirationReason: z.literal("superseded_by_comment").optional(), + commentId: z.string().uuid().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 e383235c96..d3574f7903 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -326,12 +326,14 @@ describe.sequential("issue thread interaction routes", () => { mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([ { id: "interaction-expired", - kind: "request_confirmation", + kind: "ask_user_questions", status: "expired", result: { version: 1, - outcome: "superseded_by_comment", + answers: [], + expirationReason: "superseded_by_comment", commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + summaryMarkdown: null, }, }, ]); @@ -354,10 +356,10 @@ describe.sequential("issue thread interaction routes", () => { action: "issue.thread_interaction_expired", details: expect.objectContaining({ interactionId: "interaction-expired", - interactionKind: "request_confirmation", + interactionKind: "ask_user_questions", source: "issue.interactions.catchup_superseded_by_comment", result: expect.objectContaining({ - outcome: "superseded_by_comment", + expirationReason: "superseded_by_comment", commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }), }), diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index ba8e529f6f..5be080a5aa 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -566,6 +566,233 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).rejects.toThrow("Interaction has already been resolved"); }); + it("expires ask_user_questions interactions by default when a user comments after creation", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede"); + const commentId = randomUUID(); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + + expect(created).toMatchObject({ + kind: "ask_user_questions", + payload: { + supersedeOnUserComment: true, + }, + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "ask_user_questions", + status: "expired", + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + }, + resolvedByUserId: "local-board", + }); + }); + + it("keeps ask_user_questions pending when user-comment supersede is explicitly disabled", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out"); + + await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + supersedeOnUserComment: false, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(Date.now() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(0); + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("does not supersede ask_user_questions for agent, system, or older user comments", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Question supersede exclusions"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + const createdAtMs = new Date(created.createdAt).getTime(); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, { + agentId: randomUUID(), + })).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, {})).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs - 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + })).resolves.toHaveLength(0); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("repairs historical ask_user_questions superseded by later user comments idempotently", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Historical question supersede"); + const commentId = randomUUID(); + const createdAt = new Date("2026-05-18T12:00:00.000Z"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, { + userId: "local-board", + }); + await db + .update(issueThreadInteractions) + .set({ createdAt, updatedAt: createdAt }) + .where(eq(issueThreadInteractions.id, created.id)); + + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorType: "system", + body: "System-side progress note.", + createdAt: new Date("2026-05-18T12:00:30.000Z"), + updatedAt: new Date("2026-05-18T12:00:30.000Z"), + }); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + body: "Please revise this first.", + createdAt: new Date("2026-05-18T12:01:00.000Z"), + updatedAt: new Date("2026-05-18T12:01:00.000Z"), + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + kind: "ask_user_questions", + status: "expired", + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + }, + resolvedByAgentId: null, + resolvedByUserId: "local-board", + }); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + })).resolves.toEqual([]); + }); + 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/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 655ddc6057..dcdc6f85df 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -50,7 +50,7 @@ Status quick guide: - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you. - For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it. -- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 3876da6607..26d11b4a72 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -11,7 +11,7 @@ You are an agent at Paperclip company. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. - Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`. - Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks. -- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed. - If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action. - Respect budget, pause/cancel, approval gates, and company boundaries. diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index fd81e5a80f..ef05fa4bf8 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -82,10 +82,23 @@ type RequestConfirmationLikeInteraction = | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; +const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [ + ...REQUEST_CONFIRMATION_INTERACTION_KINDS, + "ask_user_questions", +] as const; +type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number]; +type UserCommentSupersedableInteraction = + | RequestConfirmationLikeInteraction + | AskUserQuestionsInteraction; + function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind { return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind); } +function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind { + return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind); +} + function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const err = error as { code?: string; constraint?: string; constraint_name?: string }; @@ -181,12 +194,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: { return true; } -function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) { +function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) { return interaction.payload.supersedeOnUserComment === true; } function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction { switch (input.kind) { + case "ask_user_questions": + return { + ...input, + payload: { + ...input.payload, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + }, + }; case "request_confirmation": return { ...input, @@ -208,6 +229,24 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C } } +function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) { + if (row.kind === "ask_user_questions") { + return { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId, + summaryMarkdown: null, + } as const; + } + + return { + version: 1, + outcome: "superseded_by_comment", + commentId, + } as const; +} + function isCommentAtOrAfterInteraction(args: { commentCreatedAt: Date | string; interactionCreatedAt: Date | string; @@ -1134,14 +1173,15 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const superseded = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; + if (!isUserCommentSupersedableKind(row.kind)) return false; + const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction; return ( - shouldSupersedeRequestConfirmationOnUserComment(interaction) + shouldSupersedeInteractionOnUserComment(interaction) && isCommentAtOrAfterInteraction({ commentCreatedAt: comment.createdAt, interactionCreatedAt: row.createdAt, @@ -1158,11 +1198,7 @@ export function issueThreadInteractionService(db: Db) { .update(issueThreadInteractions) .set({ status: "expired", - result: { - version: 1, - outcome: "superseded_by_comment", - commentId: comment.id, - }, + result: buildSupersededByCommentResult(row, comment.id), resolvedByAgentId: actor.agentId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: now, @@ -1192,7 +1228,7 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), + inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )), db @@ -1218,8 +1254,9 @@ export function issueThreadInteractionService(db: Db) { } >(); for (const row of rows) { - const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; - if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue; + if (!isUserCommentSupersedableKind(row.kind)) continue; + const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction; + if (!shouldSupersedeInteractionOnUserComment(interaction)) continue; const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({ commentCreatedAt: comment.createdAt, @@ -1238,27 +1275,59 @@ export function issueThreadInteractionService(db: Db) { } } + const rowById = new Map(rows.map((row) => [row.id, row] as const)); for (const { comment, rowIds } of supersededByComment.values()) { - const updatedRows = await db - .update(issueThreadInteractions) - .set({ - status: "expired", - result: { - version: 1, - outcome: "superseded_by_comment", - commentId: comment.id, - }, - resolvedByAgentId: null, - resolvedByUserId: comment.authorUserId, - resolvedAt: now, - updatedAt: now, - }) - .where(and( - inArray(issueThreadInteractions.id, rowIds), - eq(issueThreadInteractions.status, "pending"), - )) - .returning(); - expired.push(...updatedRows.map(hydrateInteraction)); + const commentRows = rowIds + .map((rowId) => rowById.get(rowId)) + .filter((row): row is IssueThreadInteractionRow => Boolean(row)); + const questionRowIds = commentRows + .filter((row) => row.kind === "ask_user_questions") + .map((row) => row.id); + const confirmationRowIds = commentRows + .filter((row) => isRequestConfirmationLikeKind(row.kind)) + .map((row) => row.id); + + if (questionRowIds.length > 0) { + const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions"); + if (!sampleQuestionRow) continue; + const updatedRows = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(sampleQuestionRow, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + inArray(issueThreadInteractions.id, questionRowIds), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + expired.push(...updatedRows.map(hydrateInteraction)); + } + + if (confirmationRowIds.length > 0) { + const sampleConfirmationRow = commentRows.find((row) => isRequestConfirmationLikeKind(row.kind)); + if (!sampleConfirmationRow) continue; + const updatedRows = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByCommentResult(sampleConfirmationRow, comment.id), + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + inArray(issueThreadInteractions.id, confirmationRowIds), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + expired.push(...updatedRows.map(hydrateInteraction)); + } } if (expired.length > 0) { diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 1042d96a34..5aebdc8c3a 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; -import type { ComponentProps, ReactNode } from "react"; +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"; @@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext"; import { TooltipProvider } from "./ui/tooltip"; import { pendingAskUserQuestionsInteraction, + commentExpiredAskUserQuestionsInteraction, commentExpiredRequestConfirmationInteraction, disabledDeclineReasonRequestConfirmationInteraction, failedRequestConfirmationInteraction, @@ -23,6 +24,20 @@ let container: HTMLDivElement | null = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + if (typeof reactAct === "function") { + await reactAct(callback); + return; + } + + let result: void | Promise = 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 }) => ( {children} @@ -174,6 +189,42 @@ describe("IssueThreadInteractionCard", () => { 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, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 508caf133d..b26f2bb89e 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -983,6 +983,24 @@ function AskUserQuestionsCard({

No answer was recorded.

)} + ) : interaction.status === "expired" ? ( +
+
+ + {questions.length === 1 ? "Question expired by comment" : "Questions expired by comment"} +
+

+ A later board/user comment superseded this question request. Create a fresh request if answers are still needed. +

+ {interaction.result?.commentId ? ( + + Jump to comment + + ) : null} +
) : (
{questions.map((question) => { diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index dc1b0dbcf6..057341574c 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -370,6 +370,21 @@ export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInterac }, }); +export const commentExpiredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({ + id: "interaction-questions-expired-comment", + status: "expired", + resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId, + resolvedAt: new Date("2026-04-20T14:25:00.000Z"), + updatedAt: new Date("2026-04-20T14:25:00.000Z"), + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "22222222-2222-4222-8222-222222222222", + summaryMarkdown: null, + }, +}); + export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({}); export const genericPendingRequestConfirmationInteraction = createRequestConfirmationInteraction({ diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index 6e64993bc8..9aa4ace0ef 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -127,6 +127,34 @@ describe("issue thread interaction helpers", () => { answers: [{ questionId: "question-1", optionIds: ["option-1"] }], }, })).toBe("Answered 1 question"); + + expect(buildIssueThreadInteractionSummary({ + id: "interaction-expired", + companyId: "company-1", + issueId: "issue-1", + kind: "ask_user_questions", + status: "expired", + continuationPolicy: "wake_assignee", + createdAt: "2026-04-06T12:00:00.000Z", + updatedAt: "2026-04-06T12:05:00.000Z", + payload: { + version: 1, + questions: [ + { + id: "question-1", + prompt: "Pick one", + selectionMode: "single", + options: [{ id: "option-1", label: "Option 1" }], + }, + ], + }, + result: { + version: 1, + answers: [], + expirationReason: "superseded_by_comment", + commentId: "11111111-1111-4111-8111-111111111111", + }, + })).toBe("Question expired after comment"); }); it("summarizes checkbox confirmation interactions by count", () => { diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 5da5b05081..6d782259ed 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -158,6 +158,12 @@ export function buildIssueThreadInteractionSummary( 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`; }