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:
parent
a8f0ebaa80
commit
ac9a883f8b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue