import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, companies, createDb, documentRevisions, documents, executionWorkspaces, goals, heartbeatRuns, issueComments, issueDocuments, instanceSettings, issueRelations, issueThreadInteractions, issues, projectWorkspaces, projects, workspaceOperations, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { issueService } from "../services/issues.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; describeEmbeddedPostgres("issueThreadInteractionService", () => { let db!: ReturnType; let issuesSvc!: ReturnType; let interactionsSvc!: ReturnType; let tempDb: Awaited> | null = null; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-"); db = createDb(tempDb.connectionString); issuesSvc = issueService(db); interactionsSvc = issueThreadInteractionService(db); }, 20_000); afterEach(async () => { await db.delete(issueThreadInteractions); await db.delete(issueComments); await db.delete(issueDocuments); await db.delete(documentRevisions); await db.delete(documents); await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(workspaceOperations); await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); await db.delete(companies); }); afterAll(async () => { await tempDb?.cleanup(); }); async function seedConfirmationIssue(title = "Comment supersede") { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title, level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }); return { companyId, goalId, issueId }; } it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const assigneeAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Persist thread interactions", level: "task", status: "active", }); await db.insert(agents).values({ id: assigneeAgentId, companyId, name: "CodexCoder", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", requestDepth: 2, }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "suggest_tasks", continuationPolicy: "wake_assignee", payload: { version: 1, tasks: [ { clientKey: "root", title: "Create the root follow-up", workMode: "planning", assigneeAgentId, }, { clientKey: "child", parentClientKey: "root", title: "Create the nested follow-up", }, ], }, }, { userId: "local-board", }); expect(created.status).toBe("pending"); const accepted = await interactionsSvc.acceptSuggestedTasks({ id: issueId, companyId, goalId, projectId: null, }, created.id, {}, { userId: "local-board", }); expect(accepted.interaction.kind).toBe("suggest_tasks"); expect(accepted.interaction.status).toBe("accepted"); expect(accepted.interaction.result).toMatchObject({ version: 1, createdTasks: [ expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), expect.objectContaining({ clientKey: "child" }), ], }); expect(accepted.createdIssues).toEqual([ expect.objectContaining({ assigneeAgentId, status: "todo", }), expect.objectContaining({ assigneeAgentId: null, status: "todo", }), ]); const createdIssueRows = await db .select({ title: issues.title, workMode: issues.workMode, }) .from(issues) .where(eq(issues.companyId, companyId)); expect(createdIssueRows).toEqual( expect.arrayContaining([ expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }), expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }), ]), ); const children = await issuesSvc.list(companyId, { parentId: issueId }); expect(children).toHaveLength(1); expect(children[0]?.title).toBe("Create the root follow-up"); const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id }); expect(nestedChildren).toHaveLength(1); expect(nestedChildren[0]?.title).toBe("Create the nested follow-up"); expect(nestedChildren[0]?.requestDepth).toBe(4); const listed = await interactionsSvc.listForIssue(issueId); expect(listed).toHaveLength(1); expect(listed[0]?.status).toBe("accepted"); await expect(interactionsSvc.acceptSuggestedTasks({ id: issueId, companyId, goalId, projectId: null, }, created.id, {}, { userId: "local-board", })).rejects.toThrow("Interaction has already been resolved"); const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId }); expect(childrenAfterDuplicateAccept).toHaveLength(1); }); it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Selectively persist thread interactions", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", requestDepth: 2, }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "suggest_tasks", continuationPolicy: "wake_assignee", payload: { version: 1, tasks: [ { clientKey: "root", title: "Create the root follow-up", }, { clientKey: "child", parentClientKey: "root", title: "Create the nested follow-up", }, { clientKey: "sibling", title: "Create the sibling follow-up", }, ], }, }, { userId: "local-board", }); const accepted = await interactionsSvc.acceptSuggestedTasks({ id: issueId, companyId, goalId, projectId: null, }, created.id, { selectedClientKeys: ["root"], }, { userId: "local-board", }); expect(accepted.interaction.result).toMatchObject({ version: 1, createdTasks: [ expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), ], skippedClientKeys: ["child", "sibling"], }); const children = await issuesSvc.list(companyId, { parentId: issueId }); expect(children).toHaveLength(1); expect(children[0]?.title).toBe("Create the root follow-up"); }); it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Validate selective acceptance", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "suggest_tasks", continuationPolicy: "wake_assignee", payload: { version: 1, tasks: [ { clientKey: "root", title: "Create the root follow-up", }, { clientKey: "child", parentClientKey: "root", title: "Create the nested follow-up", }, ], }, }, { userId: "local-board", }); await expect( interactionsSvc.acceptSuggestedTasks({ id: issueId, companyId, goalId, projectId: null, }, created.id, { selectedClientKeys: ["child"], }, { userId: "local-board", }), ).rejects.toThrow("requires its parent"); }); it("persists validated answers for ask_user_questions interactions", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Persist question answers", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Question parent", status: "todo", priority: "medium", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload: { version: 1, questions: [ { id: "scope", prompt: "Choose the scope", selectionMode: "single", required: true, options: [ { id: "phase-1", label: "Phase 1" }, { id: "phase-2", label: "Phase 2" }, ], }, { id: "extras", prompt: "Optional extras", selectionMode: "multi", options: [ { id: "tests", label: "Tests" }, { id: "docs", label: "Docs" }, ], }, ], }, }, { userId: "local-board", }); const answered = await interactionsSvc.answerQuestions({ id: issueId, companyId, }, created.id, { answers: [ { questionId: "scope", optionIds: ["phase-1"] }, { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }, { userId: "local-board", }); expect(answered.status).toBe("answered"); expect(answered.result).toEqual({ version: 1, answers: [ { questionId: "scope", optionIds: ["phase-1"] }, { questionId: "extras", optionIds: ["docs", "tests"] }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }); await expect(interactionsSvc.answerQuestions({ id: issueId, companyId, }, created.id, { answers: [ { questionId: "scope", optionIds: ["phase-2"] }, ], }, { userId: "local-board", })).rejects.toThrow("Interaction has already been resolved"); }); it("persists cancelled ask_user_questions interactions without answer data", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Cancel question answers", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Question parent", status: "in_review", priority: "medium", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload: { version: 1, questions: [{ id: "scope", prompt: "Choose the scope", selectionMode: "single", required: true, options: [ { id: "phase-1", label: "Phase 1" }, { id: "phase-2", label: "Phase 2" }, ], }], }, }, { userId: "local-board", }); const cancelled = await interactionsSvc.cancelQuestions({ id: issueId, companyId, }, created.id, { reason: "Not needed anymore", }, { userId: "local-board", }); expect(cancelled.status).toBe("cancelled"); expect(cancelled.result).toEqual({ version: 1, answers: [], cancelled: true, cancellationReason: "Not needed anymore", summaryMarkdown: null, }); await expect(interactionsSvc.answerQuestions({ id: issueId, companyId, }, created.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }], }, { userId: "local-board", })).rejects.toThrow("Interaction has already been resolved"); }); it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Interaction dedupe", level: "task", status: "active", }); await db.insert(agents).values({ id: agentId, companyId, name: "CodexCoder", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "manual", status: "running", startedAt: new Date("2026-04-20T12:00:00.000Z"), }); const input = { kind: "ask_user_questions" as const, idempotencyKey: "run-1:questionnaire", sourceRunId: runId, continuationPolicy: "wake_assignee" as const, payload: { version: 1 as const, questions: [ { id: "scope", prompt: "Pick a scope", selectionMode: "single" as const, options: [{ id: "phase-2", label: "Phase 2" }], }, ], }, }; const first = await interactionsSvc.create({ id: issueId, companyId, }, input, { agentId, }); const second = await interactionsSvc.create({ id: issueId, companyId, }, input, { agentId, }); expect(second.id).toBe(first.id); expect(second.sourceRunId).toBe(runId); const rows = await db.select().from(issueThreadInteractions); expect(rows).toHaveLength(1); expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire"); }); it("accepts request_confirmation interactions without creating child issues", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Confirm a request", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Apply this plan?", acceptLabel: "Apply", rejectLabel: "Keep editing", detailsMarkdown: "Creates follow-up work after acceptance.", }, }, { userId: "local-board", }); expect(created.kind).toBe("request_confirmation"); expect(created.status).toBe("pending"); const accepted = await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null, }, created.id, {}, { userId: "local-board", }); expect(accepted.createdIssues).toEqual([]); expect(accepted.interaction).toMatchObject({ kind: "request_confirmation", status: "accepted", result: { version: 1, outcome: "accepted", }, resolvedByUserId: "local-board", }); const requiresReason = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", payload: { version: 1, prompt: "Decline only with a reason?", rejectRequiresReason: true, }, }, { userId: "local-board", }); await expect(interactionsSvc.rejectInteraction({ id: issueId, companyId, }, requiresReason.id, {}, { userId: "local-board", })).rejects.toThrow("A decline reason is required for this confirmation"); }); it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Confirm a request", level: "task", status: "active", }); await db.insert(agents).values({ id: agentId, companyId, name: "Senior Product Engineer", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Review the plan", status: "in_review", priority: "medium", assigneeUserId: "local-board", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Approve this plan?", acceptLabel: "Approve plan", rejectLabel: "Ask for changes", }, }, { agentId, }); const accepted = await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null, }, created.id, {}, { userId: "local-board", }); expect(accepted.continuationIssue).toEqual({ id: issueId, assigneeAgentId: agentId, assigneeUserId: null, status: "todo", }); const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); expect(updatedIssue).toMatchObject({ id: issueId, status: "todo", assigneeAgentId: agentId, assigneeUserId: null, }); }); it("expires request confirmations opted into user-comment supersede after creation", async () => { const { companyId, issueId } = await seedConfirmationIssue(); const commentId = randomUUID(); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed with the current draft?", supersedeOnUserComment: true, }, }, { userId: "local-board", }); 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, status: "expired", result: { version: 1, outcome: "superseded_by_comment", commentId, }, resolvedByUserId: "local-board", }); }); it("keeps request confirmations pending unless user-comment supersede is explicitly enabled", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out"); await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed with the current draft?", }, }, { 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 request confirmations for agent, system, or older user comments", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed with the current draft?", supersedeOnUserComment: true, }, }, { 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 request confirmations superseded by later user comments idempotently", async () => { const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede"); const commentId = randomUUID(); const createdAt = new Date("2026-05-18T12:00:00.000Z"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed with the current draft?", supersedeOnUserComment: true, }, }, { 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, status: "expired", result: { version: 1, outcome: "superseded_by_comment", commentId, }, resolvedByAgentId: null, resolvedByUserId: "local-board", }); await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ id: issueId, companyId, })).resolves.toEqual([]); }); it("expires request confirmations when the watched issue document revision changes", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const documentId = randomUUID(); const revisionId = randomUUID(); const nextRevisionId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Document target confirmation", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }); await db.insert(documents).values({ id: documentId, companyId, title: "Plan", format: "markdown", latestBody: "v1", latestRevisionId: revisionId, latestRevisionNumber: 1, }); await db.insert(issueDocuments).values({ companyId, issueId, documentId, key: "plan", }); await db.insert(documentRevisions).values({ id: revisionId, companyId, documentId, revisionNumber: 1, title: "Plan", format: "markdown", body: "v1", }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Apply the plan document?", target: { type: "issue_document", issueId, documentId, key: "plan", revisionId, revisionNumber: 1, }, }, }, { userId: "local-board", }); await db.insert(documentRevisions).values({ id: nextRevisionId, companyId, documentId, revisionNumber: 2, title: "Plan", format: "markdown", body: "v2", }); await db.update(documents).set({ latestBody: "v2", latestRevisionId: nextRevisionId, latestRevisionNumber: 2, }); const accepted = await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null, }, created.id, {}, { userId: "local-board", }); expect(accepted.interaction).toMatchObject({ id: created.id, status: "expired", payload: { target: { type: "issue_document", key: "plan", revisionId: nextRevisionId, revisionNumber: 2, }, }, result: { version: 1, outcome: "stale_target", staleTarget: { type: "issue_document", key: "plan", revisionId, }, }, }); }); describe("workspace_finalize accept gate", () => { async function seedAcceptGateFixture() { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); const issueId = randomUUID(); const goalId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(projects).values({ id: projectId, companyId, name: "Project", status: "in_progress", }); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Workspace", sourceType: "local_path", visibility: "default", isPrimary: true, }); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, projectWorkspaceId, mode: "isolated_workspace", strategyType: "git_worktree", name: "exec", status: "active", providerType: "git_worktree", }); await db.insert(goals).values({ id: goalId, companyId, title: "Accept gate fixture", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, projectId, goalId, title: "Issue with execution workspace", status: "in_progress", priority: "medium", executionWorkspaceId, }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Mark this issue done?", }, }, { userId: "local-board", }); return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id }; } it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture(); // A run touched the workspace (prepare) but never recorded workspace_finalize. await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await expect( interactionsSvc.acceptInteraction( { id: issueId, companyId, goalId, projectId: null }, interactionId, {}, { userId: "local-board" }, ), ).rejects.toMatchObject({ status: 409, details: { executionWorkspaceId }, }); const row = await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interactionId)) .then((rows) => rows[0]); expect(row?.status).toBe("pending"); }); it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "workspace_finalize", status: "failed", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); await expect( interactionsSvc.acceptInteraction( { id: issueId, companyId, goalId, projectId: null }, interactionId, {}, { userId: "local-board" }, ), ).rejects.toMatchObject({ status: 409, details: { executionWorkspaceId }, }); const row = await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interactionId)) .then((rows) => rows[0]); expect(row?.status).toBe("pending"); }); it("allows accept once a successful workspace_finalize lands as the latest operation", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "workspace_finalize", status: "failed", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( { id: issueId, companyId, goalId, projectId: null }, interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, status: "accepted", }); }); it("allows accept of suggest_tasks even when no successful workspace_finalize has landed", async () => { // suggest_tasks acceptance only creates follow-up issues; it does not // approve code state or move the source workspace forward, so the // workspace_finalize gate (PAPA-440) must not apply here. Without this // carve-out the board cannot triage suggested tasks on an issue whose // latest workspace op is still worktree_prepare. const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-28T22:00:00.000Z"), }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "suggest_tasks", continuationPolicy: "wake_assignee", payload: { version: 1, tasks: [ { clientKey: "follow-up", title: "Created from suggest_tasks accept under prepare-only workspace", }, ], }, }, { userId: "local-board", }); const accepted = await interactionsSvc.acceptInteraction( { id: issueId, companyId, goalId, projectId: null }, created.id, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: created.id, kind: "suggest_tasks", status: "accepted", }); }); it("allows accept when the issue has no execution workspace attached", async () => { const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Mark this issue done?", }, }, { userId: "local-board", }); const accepted = await interactionsSvc.acceptInteraction( { id: issueId, companyId, goalId: null, projectId: null }, created.id, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: created.id, status: "accepted", }); }); }); });