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>
This commit is contained in:
Dotta 2026-07-01 11:28:43 -05:00 committed by GitHub
parent a8f0ebaa80
commit ac9a883f8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 501 additions and 40 deletions

View File

@ -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",

View File

@ -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;
}

View File

@ -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<string>();
@ -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(),
});

View File

@ -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",
}),
}),

View File

@ -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();

View File

@ -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.

View File

@ -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.

View File

@ -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) {

View File

@ -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<void>) {
if (typeof reactAct === "function") {
await reactAct(callback);
return;
}
let result: void | Promise<void> = 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 }) => (
<a href={to} className={className}>{children}</a>
@ -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,

View File

@ -983,6 +983,24 @@ function AskUserQuestionsCard({
<p className="mt-1">No answer was recorded.</p>
)}
</div>
) : interaction.status === "expired" ? (
<div className="rounded-2xl border border-amber-300/70 bg-amber-50/85 p-4 text-sm leading-6 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4" />
{questions.length === 1 ? "Question expired by comment" : "Questions expired by comment"}
</div>
<p className="mt-1">
A later board/user comment superseded this question request. Create a fresh request if answers are still needed.
</p>
{interaction.result?.commentId ? (
<a
href={`#comment-${interaction.result.commentId}`}
className="mt-3 inline-flex text-sm font-medium underline underline-offset-4"
>
Jump to comment
</a>
) : null}
</div>
) : (
<div className="space-y-3">
{questions.map((question) => {

View File

@ -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({

View File

@ -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", () => {

View File

@ -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`;
}