import { subscribeDeliveryWork } from "../services/delivery-work-notifications.js"; import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js"; import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { applyPendingMigrations, activityLog, agents, authUsers, companyMemberships, instanceUserRoles, issueQuestionResponseDeliveries, 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 { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; import { instanceSettingsService } from "../services/instance-settings.js"; import { issueService } from "../services/issues.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { agentService } from "../services/agents.js"; const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_INTERACTIONS_TEST_DATABASE_URL"); const embeddedPostgresSupport = externalUrl ? { supported: true } : 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 () => { if (externalUrl) await applyPendingMigrations(externalUrl); else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-"); db = createDb(externalUrl ?? tempDb!.connectionString); issuesSvc = issueService(db); interactionsSvc = issueThreadInteractionService(db); }, 20_000); afterEach(async () => { vi.unstubAllEnvs(); await db.delete(issueThreadInteractions); await db.delete(activityLog); 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(companyMemberships); await db.delete(instanceUserRoles); await db.delete(companies); await db.delete(authUsers); }); afterAll(async () => { await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); }); function interactionCompanyRow(companyId: string): typeof companies.$inferInsert { return { id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }; } function parentIssueRow(issueId: string, companyId: string, goalId: string): typeof issues.$inferInsert { return { id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", }; } function acceptIssueScope(issueId: string, companyId: string, goalId: string) { return { id: issueId, companyId, goalId, projectId: null, }; } function activeCodexAgentRow(agentId: string, companyId: string, name: string, role: string): typeof agents.$inferInsert { return { id: agentId, companyId, name, role, status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }; } async function seedConfirmationIssue(title = "Comment supersede") { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title, level: "task", status: "active", }); await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); return { companyId, goalId, issueId }; } async function attachPlanDocument(companyId: string, issueId: string) { const documentId = randomUUID(); const revisionId = randomUUID(); await db.insert(documents).values({ id: documentId, companyId, title: "Plan", format: "markdown", latestBody: "# Plan", 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: "# Plan", }); return { type: "issue_document" as const, issueId, documentId, key: "plan", revisionId, revisionNumber: 1, }; } async function recordReviewTransition(args: { companyId: string; issueId: string; interactionId: string; actorId?: string; }) { await db.insert(activityLog).values({ companyId: args.companyId, actorType: "user", actorId: args.actorId ?? "local-board", action: "issue.updated", entityType: "issue", entityId: args.issueId, details: { status: "in_review", reviewInteractionId: args.interactionId, _previous: { status: "in_progress" }, }, }); } async function seedSourceQuestionFixture(contextSnapshot: Record) { const { companyId, issueId } = await seedConfirmationIssue("Source question race"); const agentId = randomUUID(); const runId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Questioner", "engineer")); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "manual", status: "running", createdAt: new Date("2026-07-25T12:00:00.000Z"), startedAt: new Date("2026-07-25T12:00:01.000Z"), contextSnapshot: { issueId, ...contextSnapshot }, }); return { companyId, issueId, agentId, runId }; } function questionCreateInput(sourceRunId: string) { return { kind: "ask_user_questions" as const, sourceRunId, continuationPolicy: "wake_assignee" as const, payload: { version: 1 as const, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single" as const, options: [{ id: "phase-1", label: "Phase 1" }], }], }, }; } it("persists and answers a canonical-only mixed question form", async () => { const { companyId, issueId } = await seedSourceQuestionFixture({}); const questionSet = { schema: "paperclip.question_set.v1" as const, questions: [ { id: "repo", prompt: "Repository URL?", required: true, answerMode: "text" as const }, { id: "scope", prompt: "Review scope?", required: true, answerMode: "single_select" as const, options: [{ id: "all", label: "All changes" }, { id: "selected", label: "Selected changes" }] }, { id: "hosting", prompt: "Preview hosting?", required: true, answerMode: "multi_select" as const, options: [{ id: "existing", label: "Existing host" }, { id: "new", label: "New host" }] }, ], }; const input = { kind: "ask_user_questions" as const, idempotencyKey: "canonical:mixed", payload: { version: 1 as const, questionSet } }; const issue = { id: issueId, companyId }; const created = await interactionsSvc.create(issue, input, { userId: "local-board" }); if (created.kind !== "ask_user_questions") throw new Error("expected questions"); expect(created.payload.questionSet).toEqual(questionSet); expect(created.payload.questions.map((question) => question.id)).toEqual(["repo", "scope", "hosting"]); expect(await interactionsSvc.create(issue, input, { userId: "local-board" })).toEqual(created); await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "repo", optionIds: [], otherText: "https://example.com/repo" }] }, { userId: "local-board" })).rejects.toThrow("requires an answer"); const notified = vi.fn(); const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.question, notified); const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [ { questionId: "repo", optionIds: [], otherText: "https://example.com/repo" }, { questionId: "scope", optionIds: ["all"] }, { questionId: "hosting", optionIds: ["existing", "new"] }, ] }, { userId: "local-board" }); unsubscribe(); expect(notified).toHaveBeenCalledTimes(1); expect(answered.status).toBe("answered"); }); it("rejects canonical text and custom answers that violate constraints before resolution", async () => { const { companyId, issueId } = await seedSourceQuestionFixture({}); const issue = { id: issueId, companyId }; const cases = [ { textValidation: { minLength: 3, maxLength: 4 }, invalid: ["ab", "abcde"], valid: "abcd" }, { textValidation: { pattern: "^https://" }, invalid: ["http://example.test"], valid: "https://example.test" }, { textValidation: { inputType: "integer" as const, minimum: 2, maximum: 4 }, invalid: ["no", "3.5", "1", "5"], valid: "3" }, { textValidation: { inputType: "number" as const, minimum: 2, maximum: 4 }, invalid: ["Infinity", "1.9", "4.1"], valid: "2.5" }, ]; for (const [index, entry] of cases.entries()) { const created = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "value", prompt: "Value?", required: true, answerMode: "text", textValidation: entry.textValidation }], } }, }, { userId: "local-board" }); for (const otherText of entry.invalid) { await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "value", optionIds: [], otherText }] }, { userId: "local-board" })).rejects.toThrow(); const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, created.id)); expect(row?.status, `case ${index}: ${otherText}`).toBe("pending"); } expect((await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "value", optionIds: [], otherText: entry.valid }] }, { userId: "local-board" })).status).toBe("answered"); } const custom = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options: [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }], customAnswer: { enabled: true }, textValidation: { minLength: 3 } }], } } }, { userId: "local-board" }); await expect(interactionsSvc.answerQuestions(issue, custom.id, { answers: [{ questionId: "scope", optionIds: [], otherText: "ab" }] }, { userId: "local-board" })).rejects.toThrow("at least 3"); expect((await interactionsSvc.answerQuestions(issue, custom.id, { answers: [{ questionId: "scope", optionIds: [], otherText: "abc" }] }, { userId: "local-board" })).status).toBe("answered"); }); it("accepts existing custom-answer IDs in compatible dual forms", async () => { const { companyId, issueId } = await seedSourceQuestionFixture({}); const issue = { id: issueId, companyId }; const options = [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }]; const created = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questions: [{ id: "scope", prompt: "Scope?", required: true, selectionMode: "single", options: [...options, { id: "existing-custom-id", label: "Other", freeText: true }] }], questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options, customAnswer: { enabled: true }, textValidation: { minLength: 3 } }] }, } }, { userId: "local-board" }); const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["existing-custom-id"], otherText: "Specific files" }] }, { userId: "local-board" }); expect(answered).toMatchObject({ status: "answered", result: { answers: [{ questionId: "scope", optionIds: ["existing-custom-id"], otherText: "Specific files" }] } }); }); it("keeps historical pending written-answer paths usable with their text constraints", async () => { const { companyId, issueId } = await seedSourceQuestionFixture({}); const issue = { id: issueId, companyId }; const created = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options: [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }], customAnswer: { enabled: true }, textValidation: { minLength: 3 } }], } } }, { userId: "local-board" }); if (created.kind !== "ask_user_questions") throw new Error("expected questions"); const historicalPayload = structuredClone(created.payload); delete historicalPayload.questionSet!.questions[0].customAnswer; await db.update(issueThreadInteractions).set({ payload: historicalPayload }).where(eq(issueThreadInteractions.id, created.id)); await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["paperclip_custom_answer"], otherText: "ab" }] }, { userId: "local-board" })).rejects.toThrow("at least 3"); const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["paperclip_custom_answer"], otherText: "Specific files" }] }, { userId: "local-board" }); expect(answered.status).toBe("answered"); }); async function seedQuestionUser(companyId: string, userId: string, role = "member", status = "active") { await db.insert(authUsers).values({ id: userId, name: "Question recipient", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() }); await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: role, status }); } async function seedChatQuestion() { const fixture = await seedSourceQuestionFixture({}); const userId = "paperclip-id:question-owner"; await seedQuestionUser(fixture.companyId, userId); await db.update(issues).set({ conversationAgentId: fixture.agentId, conversationUserId: userId, conversationState: "active", assigneeAgentId: fixture.agentId, }).where(eq(issues.id, fixture.issueId)); return { ...fixture, userId }; } it("derives the exact Cloud chat owner, denies other responders, and saves one answer delivery", async () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token"); const fixture = await seedChatQuestion(); const scope = { id: fixture.issueId, companyId: fixture.companyId }; const created = await interactionsSvc.create(scope, { ...questionCreateInput(fixture.runId), resolverPolicy: "human_only", }, { agentId: fixture.agentId }); expect(created.addresseeUserId).toBe(fixture.userId); const answer = { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }; for (const userId of ["question-owner", "paperclip-id:other-user"]) { await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId })) .rejects.toMatchObject({ status: 403, details: { code: "interaction_addressee_mismatch" } }); } await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { agentId: fixture.agentId, runId: fixture.runId })) .rejects.toMatchObject({ status: 403 }); expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0); const answered = await interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId }); expect(answered).toMatchObject({ status: "answered", resolvedByUserId: fixture.userId, continuationPolicy: "wake_assignee" }); await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId })) .rejects.toMatchObject({ status: 409 }); expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(1); }); it("reuses concurrent omitted and explicit chat-owner requests and legacy omitted recipients", async () => { const fixture = await seedChatQuestion(); const scope = { id: fixture.issueId, companyId: fixture.companyId }; const input = { ...questionCreateInput(fixture.runId), idempotencyKey: "chat-question" }; const actor = { agentId: fixture.agentId }; // Both callers must finish their optimistic lookup before either transaction // inserts, forcing the unique-conflict recovery path instead of relying on timing. const transaction = db.transaction.bind(db); let arrivals = 0; let release!: () => void; const ready = new Promise(resolve => { release = resolve; }); const gate = vi.spyOn(db, "transaction").mockImplementation(async (callback, config) => { if (++arrivals === 2) release(); await ready; return transaction(callback, config); }); let omitted!: Awaited>; let explicit!: Awaited>; try { [omitted, explicit] = await Promise.all([ interactionsSvc.create(scope, input, actor), interactionsSvc.create(scope, { ...input, addresseeUserId: fixture.userId }, actor), ]); } finally { gate.mockRestore(); } expect(explicit.id).toBe(omitted.id); expect(explicit.addresseeUserId).toBe(fixture.userId); expect(await db.select().from(issueThreadInteractions)).toHaveLength(1); await db.update(issueThreadInteractions).set({ addresseeUserId: null }).where(eq(issueThreadInteractions.id, omitted.id)); expect((await interactionsSvc.create(scope, input, actor)).id).toBe(omitted.id); }); it.each(["question-owner", "paperclip-id:another-member"])("rejects contradictory chat recipient %s before saving", async (addresseeUserId) => { const fixture = await seedChatQuestion(); await seedQuestionUser(fixture.companyId, "paperclip-id:another-member"); await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, { ...questionCreateInput(fixture.runId), addresseeUserId, }, { agentId: fixture.agentId })).rejects.toMatchObject({ status: 422, details: { code: "interaction_chat_addressee_mismatch" }, }); expect(await db.select().from(issueThreadInteractions)).toHaveLength(0); expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0); }); it("does not infer chat recipients for confirmations or replace their explicit owner", async () => { const fixture = await seedChatQuestion(); const scope = { id: fixture.issueId, companyId: fixture.companyId }; const input = { kind: "request_confirmation" as const, payload: { version: 1 as const, prompt: "Proceed?" } }; const actor = { agentId: fixture.agentId }; expect(await interactionsSvc.create(scope, input, actor)).toMatchObject({ addresseeUserId: null }); const addresseeUserId = "paperclip-id:confirmation-reviewer"; await seedQuestionUser(fixture.companyId, addresseeUserId); expect(await interactionsSvc.create(scope, { ...input, addresseeUserId }, actor)).toMatchObject({ addresseeUserId }); }); it("keeps ordinary task addressing optional and enforces valid explicit recipients", async () => { const fixture = await seedSourceQuestionFixture({}); const scope = { id: fixture.issueId, companyId: fixture.companyId }; const input = { ...questionCreateInput(fixture.runId), resolverPolicy: "human_only" as const }; const actor = { agentId: fixture.agentId }; const open = await interactionsSvc.create(scope, input, actor); expect(open.addresseeUserId).toBeNull(); const userId = "paperclip-id:task-recipient"; await seedQuestionUser(fixture.companyId, userId); const addressed = await interactionsSvc.create(scope, { ...input, addresseeUserId: userId }, actor); expect(addressed.addresseeUserId).toBe(userId); const answer = { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }; await expect(interactionsSvc.answerQuestions(scope, addressed.id, answer, { userId: "other-user" })).rejects.toMatchObject({ status: 403 }); expect(await interactionsSvc.answerQuestions(scope, addressed.id, answer, { userId })).toMatchObject({ status: "answered", resolvedByUserId: userId }); }); it.each(["unknown", "prefix-dropped", "cross-company", "inactive", "viewer"])("rejects %s explicit task recipients before saving", async (kind) => { const fixture = await seedSourceQuestionFixture({}); const userId = "paperclip-id:task-recipient"; if (kind !== "unknown") { const companyId = kind === "cross-company" ? (await seedConfirmationIssue()).companyId : fixture.companyId; await seedQuestionUser(companyId, userId, kind === "viewer" ? "viewer" : "member", kind === "inactive" ? "inactive" : "active"); } await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, { ...questionCreateInput(fixture.runId), addresseeUserId: kind === "prefix-dropped" ? "task-recipient" : userId, }, { agentId: fixture.agentId })).rejects.toMatchObject({ status: 422, details: { code: "interaction_addressee_user_unavailable" }, }); expect(await db.select().from(issueThreadInteractions)).toHaveLength(0); expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0); }); it("preserves valid instance-admin recipients", async () => { const userId = "instance-admin"; const fixture = await seedSourceQuestionFixture({}); await db.insert(authUsers).values({ id: userId, name: "Admin", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() }); await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" }); expect(await interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, { ...questionCreateInput(fixture.runId), addresseeUserId: userId, }, { agentId: fixture.agentId })).toMatchObject({ addresseeUserId: userId }); }); it("lets the implicit local board answer chat questions without an auth row", async () => { vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "local_trusted"); const fixture = await seedSourceQuestionFixture({}); await db.update(issues).set({ conversationAgentId: fixture.agentId, conversationUserId: "local-board", conversationState: "active", assigneeAgentId: fixture.agentId, }).where(eq(issues.id, fixture.issueId)); const scope = { id: fixture.issueId, companyId: fixture.companyId }; const question = await interactionsSvc.create(scope, questionCreateInput(fixture.runId), { agentId: fixture.agentId }); expect(question.addresseeUserId).toBe("local-board"); expect(await db.select().from(authUsers)).toHaveLength(0); expect(await db.select().from(companyMemberships)).toHaveLength(0); expect(await interactionsSvc.answerQuestions(scope, question.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }], }, { userId: "local-board" })).toMatchObject({ status: "answered", resolvedByUserId: "local-board" }); }); it.each(["authenticated", "cloud"])("does not infer local-board authority in %s mode", async (mode) => { vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", mode === "cloud" ? "local_trusted" : "authenticated"); if (mode === "cloud") vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token"); const fixture = await seedSourceQuestionFixture({}); await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, { ...questionCreateInput(fixture.runId), addresseeUserId: "local-board", }, { agentId: fixture.agentId })).rejects.toMatchObject({ status: 422, details: { code: "interaction_addressee_user_unavailable" }, }); expect(await db.select().from(issueThreadInteractions)).toHaveLength(0); }); it("does not accept a stale Cloud instance-admin row as recipient authority", async () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token"); const fixture = await seedSourceQuestionFixture({}); const userId = "paperclip-id:stale-admin"; await db.insert(authUsers).values({ id: userId, name: "Stale admin", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() }); await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" }); await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, { ...questionCreateInput(fixture.runId), addresseeUserId: userId, }, { agentId: fixture.agentId })).rejects.toMatchObject({ status: 422 }); expect(await db.select().from(issueThreadInteractions)).toHaveLength(0); }); it("rejects a source-run question when a newer human comment was not delivered", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); const commentId = randomUUID(); await db.insert(issueComments).values({ id: commentId, companyId: fixture.companyId, issueId: fixture.issueId, authorType: "user", authorUserId: "board-user", body: "The requested scope is already specified.", createdAt: new Date("2026-07-25T12:01:00.000Z"), updatedAt: new Date("2026-07-25T12:01:00.000Z"), }); await expect(interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, )).rejects.toMatchObject({ status: 409, details: expect.objectContaining({ reason: "newer_comment_not_delivered", commentIds: [commentId], }), }); expect(await interactionsSvc.listForIssue(fixture.companyId, fixture.issueId)).toEqual([]); }); it("allows a source-run question when the newer human comment is explicitly delivered", async () => { const commentId = randomUUID(); const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [{ id: commentId }] } }); await db.insert(issueComments).values({ id: commentId, companyId: fixture.companyId, issueId: fixture.issueId, authorType: "user", authorUserId: "board-user", body: "Choose phase one.", createdAt: new Date("2026-07-25T12:01:00.000Z"), updatedAt: new Date("2026-07-25T12:01:00.000Z"), }); const created = await interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, ); expect(created).toMatchObject({ kind: "ask_user_questions", status: "pending" }); }); it("keeps legacy source snapshots compatible when delivered comments are unknown", async () => { const fixture = await seedSourceQuestionFixture({}); await db.insert(issueComments).values({ companyId: fixture.companyId, issueId: fixture.issueId, authorType: "user", authorUserId: "board-user", body: "A legacy context cannot prove delivery.", createdAt: new Date("2026-07-25T12:01:00.000Z"), updatedAt: new Date("2026-07-25T12:01:00.000Z"), }); const created = await interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, ); expect(created.status).toBe("pending"); }); it("does not apply the delivery guard to approval interactions", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); await db.insert(issueComments).values({ companyId: fixture.companyId, issueId: fixture.issueId, authorType: "user", authorUserId: "board-user", body: "Please review the plan.", createdAt: new Date("2026-07-25T12:01:00.000Z"), updatedAt: new Date("2026-07-25T12:01:00.000Z"), }); const created = await interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, { kind: "request_confirmation", sourceRunId: fixture.runId, continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Approve this plan?" }, }, { agentId: fixture.agentId, runId: fixture.runId }, ); expect(created).toMatchObject({ kind: "request_confirmation", status: "pending" }); }); it("does not treat the board concierge reply as human direction", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); await db.insert(issueComments).values({ companyId: fixture.companyId, issueId: fixture.issueId, authorType: "user", authorUserId: "board-concierge", body: "The concierge relay replied.", createdAt: new Date("2026-07-25T12:01:00.000Z"), updatedAt: new Date("2026-07-25T12:01:00.000Z"), }); const created = await interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, ); expect(created.status).toBe("pending"); }); it("rejects an explicitly mismatched source-run issue or agent", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); const otherAgentId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(otherAgentId, fixture.companyId, "Other questioner", "engineer")); // Simulate historical corruption to retain the service-level defense test. await db.transaction(async (tx) => { await tx.execute(sql`set local session_replication_role = replica`); await tx.update(heartbeatRuns).set({ nativeIssueId: randomUUID() }).where(eq(heartbeatRuns.id, fixture.runId)); }); await expect(interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, )).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" }); await db.transaction(async (tx) => { await tx.execute(sql`set local session_replication_role = replica`); await tx.update(heartbeatRuns).set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } }).where(eq(heartbeatRuns.id, fixture.runId)); }); await expect(interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: fixture.agentId, runId: fixture.runId }, )).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" }); await db.update(heartbeatRuns) .set({ contextSnapshot: { issueId: fixture.issueId, paperclipWake: { comments: [] } } }) .where(eq(heartbeatRuns.id, fixture.runId)); await expect(interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, questionCreateInput(fixture.runId), { agentId: otherAgentId, runId: fixture.runId }, )).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the creating agent" }); }); it("expires a question when a comment transaction started earlier inserts after it commits", async () => { const fixture = await seedSourceQuestionFixture({}); let transactionStarted!: () => void; let allowCommentInsert!: () => void; const started = new Promise((resolve) => { transactionStarted = resolve; }); const continueComment = new Promise((resolve) => { allowCommentInsert = resolve; }); const commentPromise = db.transaction(async (tx) => { // Establish the PostgreSQL transaction before the question is created; // the old DEFAULT now() would therefore make this comment appear older. await tx.execute(sql`select now()`); transactionStarted(); await continueComment; return issuesSvc.addComment( fixture.issueId, "The board supplied the missing scope.", { userId: "board-user" }, { authorType: "user" }, tx, ); }); await started; let created: Awaited>; try { created = await interactionsSvc.create( { id: fixture.issueId, companyId: fixture.companyId }, { kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload: { version: 1, supersedeOnUserComment: true, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "phase-1", label: "Phase 1" }], }], }, }, { agentId: fixture.agentId }, ); } finally { allowCommentInsert(); } await commentPromise; const [comment] = await db .select({ createdAt: issueComments.createdAt, updatedAt: issueComments.updatedAt }) .from(issueComments) .where(eq(issueComments.issueId, fixture.issueId)); expect(comment?.updatedAt.toISOString()).toBe(comment?.createdAt.toISOString()); await expect(interactionsSvc.getById(created.id)).resolves.toMatchObject({ status: "expired", result: { expirationReason: "superseded_by_comment" }, }); }); it("locks the issue before inserting a supplied-transaction comment", async () => { const fixture = await seedSourceQuestionFixture({}); let insertReached!: () => void; let releaseInsert!: () => void; const reached = new Promise((resolve) => { insertReached = resolve; }); const release = new Promise((resolve) => { releaseInsert = resolve; }); await db.transaction(async (tx) => { const observeInserts = (runner: typeof tx): typeof tx => new Proxy(runner as any, { get(target, property, receiver) { if (property === "transaction") return (callback: (nested: typeof tx) => Promise) => target.transaction((nested: typeof tx) => callback(observeInserts(nested))); if (property !== "insert") return Reflect.get(target, property, receiver); return (table: unknown) => { const builder = target.insert(table); if (table !== issueComments) return builder; return new Proxy(builder, { get(insertBuilder, builderProperty, builderReceiver) { if (builderProperty !== "values") { return Reflect.get(insertBuilder, builderProperty, builderReceiver); } return (...values: unknown[]) => { const valued = insertBuilder.values(...values); return new Proxy(valued, { get(returningBuilder, returningProperty, returningReceiver) { if (returningProperty !== "returning") { return Reflect.get(returningBuilder, returningProperty, returningReceiver); } return (...returningArgs: unknown[]) => { insertReached(); return release.then(() => returningBuilder.returning(...returningArgs)); }; }, }); }; }, }); }; }, }); const commentPromise = issuesSvc.addComment( fixture.issueId, "Comment inserted under a caller-owned transaction.", { userId: "board-user" }, { authorType: "user" }, observeInserts(tx), ); try { await Promise.race([reached, commentPromise.then(() => { throw new Error("Comment insert was not intercepted"); })]); await expect(db.transaction(async (observer) => { await observer.execute(sql` select id from issues where id = ${fixture.issueId} for update nowait `); })).rejects.toMatchObject({ cause: { code: "55P03" } }); } finally { releaseInsert(); await commentPromise; } }); }); it("reuses human-addressed connection intents across runs and ordinary comments", async () => { const { companyId, issueId } = await seedConfirmationIssue("Connection intent"); const agentId = randomUUID(); const firstRunId = randomUUID(); const secondRunId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Researcher", "researcher")); await db.update(issues).set({ assigneeAgentId: agentId, status: "in_progress" }).where(eq(issues.id, issueId)); await db.insert(heartbeatRuns).values([ { id: firstRunId, companyId, agentId, status: "running", responsibleUserId: "user-board", contextSnapshot: { issueId }, }, { id: secondRunId, companyId, agentId, status: "running", responsibleUserId: "user-board", contextSnapshot: { issueId }, }, ]); const payload = { version: 1 as const, serviceSlug: "notion", serviceName: "Notion", serviceLogoUrl: null, requestingAgentId: agentId, requestingAgentName: "Researcher", phase: "requested" as const, }; const first = await interactionsSvc.createConnectionIntent( { id: issueId, companyId }, { payload, sourceRunId: firstRunId, addresseeUserId: "user-board", idempotencyKey: `connection-intent:${firstRunId}:notion`, }, ); expect(first).toMatchObject({ kind: "connection_intent", status: "pending", continuationPolicy: "wake_assignee", addresseeUserId: "user-board", requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", payload, }); const repeated = await interactionsSvc.createConnectionIntent( { id: issueId, companyId }, { payload, sourceRunId: firstRunId, addresseeUserId: "user-board", idempotencyKey: `connection-intent:${firstRunId}:notion`, }, ); expect(repeated.id).toBe(first.id); const newer = await interactionsSvc.createConnectionIntent( { id: issueId, companyId }, { payload, sourceRunId: secondRunId, addresseeUserId: "user-board", idempotencyKey: `connection-intent:${secondRunId}:notion`, }, ); expect(newer.id).toBe(first.id); expect(await interactionsSvc.getById(first.id)).toMatchObject({ status: "pending" }); const [expiredByComment] = await interactionsSvc.expireRequestConfirmationsSupersededByComment( { id: issueId, companyId }, { id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "user-board", createdByRunId: null, }, { userId: "user-board" }, ); expect(expiredByComment).toBeUndefined(); }); it("persists addressees without allowing them to bypass human-only governance", async () => { const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction"); const creatorAgentId = randomUUID(); const addresseeAgentId = randomUUID(); const unrelatedAgentId = randomUUID(); const addresseeRunId = randomUUID(); const unrelatedRunId = randomUUID(); const agentRows = [ { id: creatorAgentId, name: "Creator" }, { id: addresseeAgentId, name: "Addressee" }, { id: unrelatedAgentId, name: "Unrelated" }, ].map((agent) => ({ ...agent, companyId, role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, })); await db.insert(agents).values(agentRows); await db.insert(heartbeatRuns).values([ { id: addresseeRunId, companyId, agentId: addresseeAgentId, invocationSource: "manual", status: "running", startedAt: new Date("2026-07-25T12:00:00.000Z"), }, { id: unrelatedRunId, companyId, agentId: unrelatedAgentId, invocationSource: "manual", status: "running", startedAt: new Date("2026-07-25T12:01:00.000Z"), }, ]); const input = { kind: "ask_user_questions" as const, resolverPolicy: "board_or_agents" as const, addresseeAgentId, continuationPolicy: "wake_assignee" as const, payload: { version: 1 as const, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single" as const, options: [{ id: "phase-1", label: "Phase 1" }], }], }, }; const created = await interactionsSvc.create( { id: issueId, companyId }, input, { agentId: creatorAgentId }, ); expect(created).toMatchObject({ addresseeAgentId, requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", resolverPolicyProvenance: "explicit", effectiveResolverPolicySource: "requested", }); const answered = await interactionsSvc.answerQuestions( { id: issueId, companyId }, created.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, { agentId: addresseeAgentId, runId: addresseeRunId }, ); expect(answered).toMatchObject({ status: "answered", addresseeAgentId, resolvedByAgentId: addresseeAgentId, resolvedByRunId: addresseeRunId, }); const second = await interactionsSvc.create( { id: issueId, companyId }, { ...input, idempotencyKey: "addressed:second" }, { agentId: creatorAgentId }, ); await expect(interactionsSvc.answerQuestions( { id: issueId, companyId }, second.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, { agentId: unrelatedAgentId, runId: unrelatedRunId }, )).rejects.toMatchObject({ status: 403, message: expect.stringContaining("addressed agent"), }); const boardOnly = await interactionsSvc.create( { id: issueId, companyId }, { ...input, resolverPolicy: "board_only", idempotencyKey: "addressed:board-only", }, { agentId: creatorAgentId }, ); await expect(interactionsSvc.answerQuestions( { id: issueId, companyId }, boardOnly.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, { agentId: addresseeAgentId, runId: addresseeRunId }, )).rejects.toMatchObject({ status: 403, message: expect.stringContaining("human-only"), }); await expect(interactionsSvc.create( { id: issueId, companyId }, { ...input, addresseeAgentId: creatorAgentId }, { agentId: creatorAgentId }, )).rejects.toMatchObject({ status: 422, message: expect.stringContaining("themselves"), }); }); it("cancels addressed interactions before deleting the addressee", async () => { const { companyId, issueId } = await seedConfirmationIssue("Deleted interaction addressee"); const creatorAgentId = randomUUID(); const addresseeAgentId = randomUUID(); const unrelatedAgentId = randomUUID(); const unrelatedRunId = randomUUID(); await db.insert(agents).values([ { id: creatorAgentId, name: "Creator" }, { id: addresseeAgentId, name: "Addressee" }, { id: unrelatedAgentId, name: "Unrelated" }, ].map((agent) => ({ ...agent, companyId, role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }))); await db.insert(heartbeatRuns).values({ id: unrelatedRunId, companyId, agentId: unrelatedAgentId, invocationSource: "manual", status: "running", startedAt: new Date("2026-07-25T12:02:00.000Z"), }); const created = await interactionsSvc.create( { id: issueId, companyId }, { kind: "ask_user_questions", resolverPolicy: "board_or_agents", addresseeAgentId, payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "phase-1", label: "Phase 1" }], }], }, }, { agentId: creatorAgentId }, ); await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, addresseeAgentId)); await agentService(db).remove(addresseeAgentId); const cancelled = await interactionsSvc.getById(created.id); expect(cancelled).toMatchObject({ status: "cancelled", addresseeAgentId: null, resolvedByAgentId: null, resolvedByRunId: null, resolvedByUserId: null, result: { version: 1, outcome: "addressee_deleted", reason: "Cancelled because the addressed agent was deleted", }, }); await expect(interactionsSvc.answerQuestions( { id: issueId, companyId }, created.id, { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, { agentId: unrelatedAgentId, runId: unrelatedRunId }, )).rejects.toMatchObject({ status: 409, message: "Interaction has already been resolved", }); }); it.each(["paused", "pending_approval", "terminated"])( "rejects %s interaction addressees", async (status) => { const { companyId, issueId } = await seedConfirmationIssue(`Reject ${status} addressee`); const creatorAgentId = randomUUID(); const addresseeAgentId = randomUUID(); await db.insert(agents).values([ { id: creatorAgentId, companyId, name: "Creator", role: "engineer", status: "active", }, { id: addresseeAgentId, companyId, name: "Unavailable addressee", role: "engineer", status, }, ]); await expect(interactionsSvc.create( { id: issueId, companyId }, { kind: "ask_user_questions", addresseeAgentId, payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "phase-1", label: "Phase 1" }], }], }, }, { agentId: creatorAgentId }, )).rejects.toMatchObject({ status: 422, message: expect.stringContaining("invokable agent"), details: expect.objectContaining({ reason: status }), }); }, ); it("rejects interaction addressees with an invalid reporting chain", async () => { const { companyId, issueId } = await seedConfirmationIssue("Reject uninvokable addressee chain"); const creatorAgentId = randomUUID(); const managerAgentId = randomUUID(); const addresseeAgentId = randomUUID(); await db.insert(agents).values([ { id: creatorAgentId, companyId, name: "Creator", role: "engineer", status: "active", }, { id: managerAgentId, companyId, name: "Terminated manager", role: "manager", status: "terminated", }, { id: addresseeAgentId, companyId, name: "Unavailable addressee", role: "engineer", status: "active", reportsTo: managerAgentId, }, ]); await expect(interactionsSvc.create( { id: issueId, companyId }, { kind: "ask_user_questions", addresseeAgentId, payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "phase-1", label: "Phase 1" }], }], }, }, { agentId: creatorAgentId }, )).rejects.toMatchObject({ status: 422, message: expect.stringContaining("invokable agent"), details: expect.objectContaining({ reason: "manager_terminated", managerId: managerAgentId, }), }); }); 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(); const responsibleUserId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); 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(activeCodexAgentRow(assigneeAgentId, companyId, "CodexCoder", "engineer")); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Parent issue", status: "in_progress", priority: "medium", requestDepth: 2, responsibleUserId, }); 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(acceptIssueScope(issueId, companyId, goalId), 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, responsibleUserId: issues.responsibleUserId, }) .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" }), ]), ); expect(createdIssueRows).toEqual( expect.arrayContaining([ expect.objectContaining({ title: "Create the root follow-up", responsibleUserId }), expect.objectContaining({ title: "Create the nested follow-up", responsibleUserId }), ]), ); 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(acceptIssueScope(issueId, companyId, goalId), 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(interactionCompanyRow(companyId)); 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(acceptIssueScope(issueId, companyId, goalId), 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(interactionCompanyRow(companyId)); 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(parentIssueRow(issueId, companyId, goalId)); 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(acceptIssueScope(issueId, companyId, goalId), 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(interactionCompanyRow(companyId)); 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: [], otherText: "Custom Phase 1" }, { questionId: "extras", optionIds: ["docs", "tests", "docs"], otherText: " Pair with release notes ", }, ], 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: [], otherText: "Custom Phase 1" }, { questionId: "extras", optionIds: ["docs", "tests"], otherText: "Pair with release notes" }, ], 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(interactionCompanyRow(companyId)); 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("skips every durable interaction kind exactly once and retains partial item verdicts", async () => { const { companyId, issueId } = await seedConfirmationIssue("Universal composer Skip"); const inputs = [ { kind: "suggest_tasks" as const, payload: { version: 1 as const, tasks: [{ clientKey: "child", title: "Create child" }] }, }, { kind: "ask_user_questions" as const, payload: { version: 1 as const, questions: [{ id: "scope", prompt: "Scope?", selectionMode: "single" as const, options: [{ id: "one", label: "One" }], }], }, }, { kind: "request_confirmation" as const, payload: { version: 1 as const, prompt: "Proceed?" }, }, { kind: "request_checkbox_confirmation" as const, payload: { version: 1 as const, prompt: "Select", options: [{ id: "one", label: "One" }] }, }, ]; for (const input of inputs) { const created = await interactionsSvc.create({ id: issueId, companyId }, input, { userId: "local-board" }); const skipped = await interactionsSvc.skipInteraction( { id: issueId, companyId, status: "in_progress" }, created.id, {}, { userId: "local-board" }, ); expect(skipped).toMatchObject({ status: "cancelled", result: { version: 1, outcome: "skipped" } }); if (skipped.kind === "ask_user_questions") { expect(skipped.result).toMatchObject({ answers: [], cancelled: true }); } await expect(interactionsSvc.skipInteraction( { id: issueId, companyId, status: "in_progress" }, created.id, {}, { userId: "local-board" }, )).rejects.toThrow("Interaction has already been resolved"); } const verdicts = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review items", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }], }, }, { userId: "local-board" }); await interactionsSvc.submitItemVerdicts( { id: issueId, companyId }, verdicts.id, { verdicts: [{ id: "one", verdict: "approve" }] }, { userId: "local-board" }, ); const skippedVerdicts = await interactionsSvc.skipInteraction( { id: issueId, companyId, status: "in_progress" }, verdicts.id, {}, { userId: "local-board" }, ); expect(skippedVerdicts).toMatchObject({ status: "cancelled", result: { outcome: "skipped", complete: false, items: [{ id: "one", verdict: "approve" }] }, }); }); it("expires ask_user_questions when a creator opts into comment supersede", 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, supersedeOnUserComment: true, 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 by default when the user sends a message", async () => { const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out"); 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 [created] = await db.select().from(issueThreadInteractions); expect(created?.payload).toMatchObject({ supersedeOnUserComment: false }); 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, supersedeOnUserComment: true, 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(); const issueId = randomUUID(); const agentId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); 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(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); 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("supersedes older pending confirmations from the same agent without crossing agent or kind", async () => { const { companyId, issueId } = await seedConfirmationIssue("Newer confirmation supersedes older"); const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const older = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", idempotencyKey: "confirmation:first:older", payload: { version: 1, prompt: "Approve the older draft?" }, }, { agentId: firstAgentId }); const otherKind = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_checkbox_confirmation", idempotencyKey: "checkbox:first", payload: { version: 1, prompt: "Select regions", options: [{ id: "us", label: "US" }], }, }, { agentId: firstAgentId }); const otherAgent = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", idempotencyKey: "confirmation:second", payload: { version: 1, prompt: "Approve the second agent's draft?" }, }, { agentId: secondAgentId }); const replacement = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", idempotencyKey: "confirmation:first:newer", payload: { version: 1, prompt: "Approve the newer draft?" }, }, { agentId: firstAgentId }); const interactions = await interactionsSvc.listForIssue(issueId); expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({ status: "expired", resolvedByAgentId: firstAgentId, result: { outcome: "superseded_by_newer_request", supersededByInteractionId: replacement.id, }, }); expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending"); expect(interactions.find((interaction) => interaction.id === otherAgent.id)?.status).toBe("pending"); expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending"); }); it("supersedes an agent's own older pending ask_user_questions without crossing agent, kind, or issue", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Question supersedes older sibling"); const otherIssueId = randomUUID(); await db.insert(issues).values({ id: otherIssueId, companyId, goalId, title: "Other issue", status: "in_progress", priority: "medium", }); const probingAgentId = randomUUID(); const otherAgentId = randomUUID(); await db.insert(agents).values([ activeCodexAgentRow(probingAgentId, companyId, "Probing agent", "engineer"), activeCodexAgentRow(otherAgentId, companyId, "Other agent", "engineer"), ]); const question = (prompt: string) => ({ kind: "ask_user_questions" as const, payload: { version: 1 as const, questions: [{ id: "q", prompt, selectionMode: "single" as const, options: [{ id: "opt", label: "Option" }], }], }, }); const older = await interactionsSvc.create( { id: issueId, companyId }, question("Older question"), { agentId: probingAgentId }, ); const otherKind = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Approve the draft?" }, }, { agentId: probingAgentId }); const otherAgentQuestion = await interactionsSvc.create( { id: issueId, companyId }, question("Other agent question"), { agentId: otherAgentId }, ); const otherIssueQuestion = await interactionsSvc.create( { id: otherIssueId, companyId }, question("Other issue question"), { agentId: probingAgentId }, ); const replacement = await interactionsSvc.create( { id: issueId, companyId }, question("Newer question"), { agentId: probingAgentId }, ); const interactions = await interactionsSvc.listForIssue(issueId); expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({ status: "expired", resolvedByAgentId: probingAgentId, result: { answers: [], expirationReason: "superseded_by_newer_interaction", supersededByInteractionId: replacement.id, }, }); expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending"); // A different agent's pending question is untouched. expect(interactions.find((interaction) => interaction.id === otherAgentQuestion.id)?.status).toBe("pending"); // A different kind from the same agent is untouched. expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending"); // The same agent's question on a different issue is untouched. const otherIssueInteractions = await interactionsSvc.listForIssue(otherIssueId); expect(otherIssueInteractions.find((interaction) => interaction.id === otherIssueQuestion.id)?.status) .toBe("pending"); }); it("leaves exactly one pending ask_user_questions on the onboarding first task after probe cards and the real question arrive", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Chief of staff", "chief_of_staff")); await db.insert(goals).values({ id: goalId, companyId, title: "Your first task", level: "task", status: "active", }); await db.insert(issues).values({ id: issueId, companyId, goalId, title: "Your first task", status: "in_progress", priority: "medium", originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, assigneeAgentId: agentId, }); // Reproduces PAP-436: the assigned agent posts two throwaway schema probes // (title/prompt/option "t"/"p"/"L") before the genuine question. const probe = (prompt: string) => ({ kind: "ask_user_questions" as const, payload: { version: 1 as const, questions: [{ id: "q", prompt, selectionMode: "single" as const, options: [{ id: "L", label: "L" }], }], }, }); await interactionsSvc.create({ id: issueId, companyId }, probe("t"), { agentId }); await interactionsSvc.create({ id: issueId, companyId }, probe("p"), { agentId }); await interactionsSvc.create({ id: issueId, companyId }, { kind: "ask_user_questions", payload: { version: 1, questions: [{ id: "focus", prompt: "What would you like your team to focus on first?", selectionMode: "single", options: [ { id: "mvp", label: "Ship the MVP" }, { id: "bugs", label: "Fix bugs" }, ], }], }, }, { agentId }); const interactions = await interactionsSvc.listForIssue(issueId); const pendingQuestions = interactions.filter( (interaction) => interaction.kind === "ask_user_questions" && interaction.status === "pending", ); expect(pendingQuestions).toHaveLength(1); expect(pendingQuestions[0]?.kind).toBe("ask_user_questions"); const [remaining] = pendingQuestions; if (remaining?.kind === "ask_user_questions") { expect(remaining.payload.questions[0]?.prompt).toContain("focus on first"); } // Both probe cards auto-expired with the sibling-supersede reason. const expiredQuestions = interactions.filter( (interaction) => interaction.kind === "ask_user_questions" && interaction.status === "expired", ); expect(expiredQuestions).toHaveLength(2); for (const card of expiredQuestions) { expect(card.result).toMatchObject({ expirationReason: "superseded_by_newer_interaction" }); } }); it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => { const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep"); const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()]; const secondAgentIds = [randomUUID(), randomUUID()]; const checkboxId = randomUUID(); await db.insert(issueThreadInteractions).values([ ...firstAgentIds.map((id, index) => ({ id, companyId, issueId, kind: "request_confirmation", status: "pending", continuationPolicy: "wake_assignee", createdByAgentId: firstAgentId, payload: { version: 1 as const, prompt: `First agent draft ${index + 1}` }, createdAt: new Date(`2026-07-01T12:0${index}:00.000Z`), updatedAt: new Date(`2026-07-01T12:0${index}:00.000Z`), })), ...secondAgentIds.map((id, index) => ({ id, companyId, issueId, kind: "request_confirmation", status: "pending", continuationPolicy: "wake_assignee", createdByAgentId: secondAgentId, payload: { version: 1 as const, prompt: `Second agent draft ${index + 1}` }, createdAt: new Date(`2026-07-01T13:0${index}:00.000Z`), updatedAt: new Date(`2026-07-01T13:0${index}:00.000Z`), })), { id: checkboxId, companyId, issueId, kind: "request_checkbox_confirmation", status: "pending", continuationPolicy: "wake_assignee", createdByAgentId: firstAgentId, payload: { version: 1, prompt: "Select one", options: [{ id: "one", label: "One" }] }, createdAt: new Date("2026-07-01T14:00:00.000Z"), updatedAt: new Date("2026-07-01T14:00:00.000Z"), }, ]); await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations()) .resolves.toEqual({ expired: 3 }); await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations()) .resolves.toEqual({ expired: 0 }); const interactions = await interactionsSvc.listForIssue(issueId); for (const id of firstAgentIds.slice(0, -1)) { expect(interactions.find((interaction) => interaction.id === id)).toMatchObject({ status: "expired", result: { outcome: "superseded_by_newer_request", supersededByInteractionId: firstAgentIds.at(-1), }, }); } expect(interactions.find((interaction) => interaction.id === firstAgentIds.at(-1))?.status).toBe("pending"); expect(interactions.find((interaction) => interaction.id === secondAgentIds[0])).toMatchObject({ status: "expired", result: { outcome: "superseded_by_newer_request", supersededByInteractionId: secondAgentIds[1], }, }); expect(interactions.find((interaction) => interaction.id === secondAgentIds[1])?.status).toBe("pending"); expect(interactions.find((interaction) => interaction.id === checkboxId)?.status).toBe("pending"); }); it("refuses to create an interaction on a closed issue", async () => { const { companyId, issueId } = await seedConfirmationIssue("Closed issue create guard"); await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId)); await expect(interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Approve after close?" }, }, { userId: "local-board", })).rejects.toMatchObject({ status: 409 }); const rows = await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.issueId, issueId)); expect(rows).toHaveLength(0); }); it("accepts request_confirmation interactions without creating child issues", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); 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(parentIssueRow(issueId, companyId, goalId)); 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(acceptIssueScope(issueId, companyId, goalId), 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("reopens an in-review issue before waking the assignee after rejection", async () => { const { companyId, issueId } = await seedConfirmationIssue( "Continue after review rejection", ); const created = await interactionsSvc.create( { id: issueId, companyId }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Continue the next turn?", rejectLabel: "Continue work", rejectRequiresReason: true, target: { type: "custom", key: "warm_turn_1", revisionId: "warm-turn-1", }, }, }, { userId: "local-board", }, ); await db .update(issues) .set({ status: "in_review" }) .where(eq(issues.id, issueId)); await interactionsSvc.rejectInteraction( { id: issueId, companyId, status: "in_review", }, created.id, { reason: "Proceed with turn two.", }, { userId: "local-board", }, ); await expect(issuesSvc.getById(issueId)).resolves.toMatchObject({ status: "todo", }); }); it("records an authorized agent as the review-confirmation resolver", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Agent review verdict"); const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, agentId: resolverAgentId, invocationSource: "manual", status: "running", startedAt: new Date(), }); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Approve this review?" }, resolverPolicy: "anyone", }, { userId: "local-board", }); await recordReviewTransition({ companyId, issueId, interactionId: created.id }); const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { agentId: resolverAgentId, runId: resolverRunId, resolverPolicyRestriction: "anyone", }); expect(accepted.interaction).toMatchObject({ status: "accepted", resolvedByAgentId: resolverAgentId, resolvedByRunId: resolverRunId, resolvedByUserId: null, }); }); it.each(["accept", "reject"] as const)( "revalidates review policy under the issue lock before interaction %s", async (action) => { const { companyId, goalId, issueId } = await seedConfirmationIssue(`Locked ${action} policy`); const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, agentId: resolverAgentId, invocationSource: "manual", status: "running", startedAt: new Date(), }); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Approve this review?" }, }, { userId: "local-board", }); await db.update(issues) .set({ status: "in_review", reviewPolicy: "anyone" }) .where(eq(issues.id, issueId)); await recordReviewTransition({ companyId, issueId, interactionId: created.id }); let releasePolicyLock!: () => void; let policyLockReady!: () => void; const holdPolicyLock = new Promise((resolve) => { releasePolicyLock = resolve; }); const policyLocked = new Promise((resolve) => { policyLockReady = resolve; }); const tightenPolicy = db.transaction(async (tx) => { await tx.select({ id: issues.id }) .from(issues) .where(eq(issues.id, issueId)) .for("update"); await tx.update(issues) .set({ reviewPolicy: "human_only" }) .where(eq(issues.id, issueId)); policyLockReady(); await holdPolicyLock; }); await policyLocked; const actor = { agentId: resolverAgentId, runId: resolverRunId, reviewVerdictAuthorized: true, }; const verdict = action === "accept" ? interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null, status: "in_review", }, created.id, {}, actor) : interactionsSvc.rejectInteraction({ id: issueId, companyId, status: "in_review", }, created.id, { reason: "Needs changes" }, actor); let verdictSettled = false; void verdict.then( () => { verdictSettled = true; }, () => { verdictSettled = true; }, ); const denied = expect(verdict).rejects.toMatchObject({ status: 403, details: expect.objectContaining({ code: "review_policy_denied", policy: "human_only", }), }); await new Promise((resolve) => setTimeout(resolve, 25)); expect(verdictSettled).toBe(false); releasePolicyLock(); await tightenPolicy; await denied; const persisted = await db.select({ status: issueThreadInteractions.status }) .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, created.id)) .then((rows) => rows[0]); expect(persisted?.status).toBe("pending"); }, ); it("preserves creator and same-run guards for authorized agent review verdicts", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Guard agent review verdicts"); const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, agentId: resolverAgentId, invocationSource: "manual", status: "running", startedAt: new Date(), }); const createdByResolver = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Approve your own request?" }, resolverPolicy: "anyone", }, { userId: "local-board", }); await db.update(issueThreadInteractions) .set({ createdByAgentId: resolverAgentId }) .where(eq(issueThreadInteractions.id, createdByResolver.id)); await recordReviewTransition({ companyId, issueId, interactionId: createdByResolver.id }); const createdBySameRun = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Approve the same run?", options: [{ id: "approve", label: "Approve" }], }, resolverPolicy: "anyone", }, { userId: "local-board", }); await db.update(issueThreadInteractions) .set({ sourceRunId: resolverRunId }) .where(eq(issueThreadInteractions.id, createdBySameRun.id)); const issue = { id: issueId, companyId, goalId, projectId: null }; const actor = { agentId: resolverAgentId, runId: resolverRunId, resolverPolicyRestriction: "not_creator", }; await expect(interactionsSvc.acceptInteraction(issue, createdByResolver.id, {}, actor)) .rejects.toThrow("requires a resolver other than its creator or creating run"); await db.update(activityLog).set({ details: { status: "in_review", reviewInteractionId: createdBySameRun.id, _previous: { status: "in_progress" }, }, }).where(eq(activityLog.entityId, issueId)); await expect(interactionsSvc.acceptInteraction(issue, createdBySameRun.id, { selectedOptionIds: ["approve"], }, actor)).rejects.toThrow("requires a resolver other than its creator or creating run"); }); it("accepts request_checkbox_confirmation interactions with selected option ids", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation accept"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Which files should be deleted?", options: [ { id: "file-a", label: "a.txt" }, { id: "file-b", label: "b.txt" }, { id: "file-c", label: "c.txt" }, ], defaultSelectedOptionIds: ["file-a"], minSelected: 0, maxSelected: 2, }, }, { userId: "local-board", }); expect(created).toMatchObject({ kind: "request_checkbox_confirmation", status: "pending", continuationPolicy: "wake_assignee", payload: { supersedeOnUserComment: false, allowDeclineReason: true, }, }); const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["file-c", "file-a"], }, { userId: "local-board", }); expect(accepted.createdIssues).toEqual([]); expect(accepted.interaction).toMatchObject({ kind: "request_checkbox_confirmation", status: "accepted", result: { version: 1, outcome: "accepted", selectedOptionIds: ["file-a", "file-c"], }, resolvedByUserId: "local-board", }); }); it("enforces request_checkbox_confirmation selected option references and bounds", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation bounds"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Pick one or two options.", options: [ { id: "one", label: "One" }, { id: "two", label: "Two" }, { id: "three", label: "Three" }, ], defaultSelectedOptionIds: ["one"], minSelected: 1, maxSelected: 2, }, }, { userId: "local-board", }); await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: [], }, { userId: "local-board", })).rejects.toThrow("Select at least 1 checkbox confirmation option(s)"); await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["missing"], }, { userId: "local-board", })).rejects.toThrow("Unknown checkbox confirmation optionId: missing"); await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["one", "two", "three"], }, { userId: "local-board", })).rejects.toThrow("Select no more than 2 checkbox confirmation option(s)"); }); it("expires request_checkbox_confirmation interactions when a user comments after creation", async () => { const { companyId, issueId } = await seedConfirmationIssue("Checkbox confirmation supersede"); const commentId = randomUUID(); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Which files should be deleted?", supersedeOnUserComment: true, options: [{ id: "file-a", label: "a.txt" }], }, }, { 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, kind: "request_checkbox_confirmation", status: "expired", result: { version: 1, outcome: "superseded_by_comment", commentId, }, }); }); it("keeps checkbox confirmations pending by default after a user comment", async () => { const { companyId, issueId } = await seedConfirmationIssue("Checkbox card remains"); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Choose a file", options: [{ id: "file-a", label: "a.txt" }], }, }, { userId: "local-board" }); expect(created.payload.supersedeOnUserComment).toBe(false); 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); expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending"); }); it("submits request_item_verdicts partially and completes when all items are resolved", async () => { const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review generated artifacts.", items: [ { id: "api", label: "API route" }, { id: "docs", label: "Docs" }, { id: "tests", label: "Tests" }, ], }, }, { userId: "local-board", }); expect(created).toMatchObject({ kind: "request_item_verdicts", status: "pending", continuationPolicy: "wake_assignee", payload: { verdicts: ["approve", "reject"], requireReasonOn: ["reject"], allowBulkApprove: true, supersedeOnUserComment: false, }, }); const first = await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }], }, { userId: "local-board", }); expect(first.newlyResolvedItemIds).toEqual(["docs"]); expect(first.interaction).toMatchObject({ kind: "request_item_verdicts", status: "pending", result: { version: 1, outcome: "resolved", complete: false, items: [ { id: "docs", verdict: "reject", reason: "Missing examples", resolvedByUserId: "local-board", }, ], }, resolvedAt: null, }); const duplicate = await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "docs", verdict: "reject" }], }, { userId: "local-board", }); expect(duplicate.newlyResolvedItemIds).toEqual([]); expect(duplicate.interaction).toMatchObject({ status: "pending", result: { complete: false, items: [ { id: "docs", verdict: "reject", reason: "Missing examples", }, ], }, }); const completed = await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [ { id: "api", verdict: "approve" }, { id: "tests", verdict: "reject", reason: "No route coverage" }, ], }, { userId: "local-board", }); expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]); expect(completed.interaction).toMatchObject({ kind: "request_item_verdicts", status: "answered", result: { version: 1, outcome: "resolved", complete: true, items: [ { id: "api", verdict: "approve" }, { id: "docs", verdict: "reject", reason: "Missing examples" }, { id: "tests", verdict: "reject", reason: "No route coverage" }, ], }, resolvedByUserId: "local-board", }); const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "api", verdict: "approve" }], }, { userId: "local-board", }); expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]); expect(duplicateAfterComplete.interaction.status).toBe("answered"); }); it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => { const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation"); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review generated artifacts.", items: [ { id: "api", label: "API route" }, { id: "docs", label: "Docs" }, ], }, }, { userId: "local-board", }); await expect(interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "missing", verdict: "approve" }], }, { userId: "local-board", })).rejects.toThrow("Unknown item verdict id: missing"); await expect(interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "api", verdict: "defer" }], }, { userId: "local-board", })).rejects.toThrow("Verdict defer is not enabled"); await expect(interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "docs", verdict: "reject" }], }, { userId: "local-board", })).rejects.toThrow("A reason is required when verdict is reject"); }); it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => { const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede"); const commentId = randomUUID(); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review generated artifacts.", supersedeOnUserComment: true, items: [ { id: "api", label: "API route" }, { id: "docs", label: "Docs" }, ], }, }, { userId: "local-board", }); await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "api", verdict: "approve" }], }, { 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, kind: "request_item_verdicts", status: "expired", result: { version: 1, outcome: "superseded_by_comment", complete: false, commentId, items: [ { id: "api", verdict: "approve", resolvedByUserId: "local-board", }, ], }, }); }); it("keeps item verdict requests pending by default after a user comment", async () => { const { companyId, issueId } = await seedConfirmationIssue("Verdict card remains"); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review the file", items: [{ id: "file-a", label: "a.txt" }], }, }, { userId: "local-board" }); expect(created.payload.supersedeOnUserComment).toBe(false); 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); expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending"); }); it("returns accepted agent confirmations from review without resetting active work", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); 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(activeCodexAgentRow(agentId, companyId, "Senior Product Engineer", "engineer")); 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(acceptIssueScope(issueId, companyId, goalId), 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, }); await db .update(issues) .set({ status: "in_review", assigneeAgentId: agentId, assigneeUserId: null, }) .where(eq(issues.id, issueId)); const agentOwnedConfirmation = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Approve the next step?", }, }, { agentId, }); const resumed = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), agentOwnedConfirmation.id, {}, { userId: "local-board", }); expect(resumed.continuationIssue).toEqual({ id: issueId, assigneeAgentId: agentId, assigneeUserId: null, status: "todo", }); const resumedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); expect(resumedIssue).toMatchObject({ id: issueId, status: "todo", assigneeAgentId: agentId, assigneeUserId: null, }); await db .update(issues) .set({ status: "in_progress" }) .where(eq(issues.id, issueId)); const activeConfirmation = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Approve while work is active?", }, }, { agentId, }); const acceptedWhileActive = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), activeConfirmation.id, {}, { userId: "local-board", }); expect(acceptedWhileActive.continuationIssue).toBeNull(); const activeIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); expect(activeIssue).toMatchObject({ id: issueId, status: "in_progress", assigneeAgentId: agentId, assigneeUserId: null, }); }); it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode"); const agentId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", assigneeAgentId: agentId, }).where(eq(issues.id, issueId)); const target = await attachPlanDocument(companyId, issueId); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Accept this plan?", target }, }, { agentId }); const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" }); expect(accepted.interaction).toMatchObject({ id: created.id, status: "accepted", result: { outcome: "accepted" }, }); expect(accepted.continuationIssue).toEqual({ id: issueId, assigneeAgentId: agentId, assigneeUserId: null, status: "todo", workMode: "standard", }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0])).resolves.toMatchObject({ status: "todo", workMode: "standard", assigneeAgentId: agentId, assigneeUserId: null, }); }); it("keeps Plan mode for non-plan and checkbox confirmations", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations"); const agentId = randomUUID(); await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", assigneeAgentId: agentId, }).where(eq(issues.id, issueId)); const nonPlan = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Accept this unrelated decision?" }, }, { agentId }); await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), nonPlan.id, {}, { userId: "local-board", }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) .resolves.toBe("planning"); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); const target = await attachPlanDocument(companyId, issueId); const checkbox = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Select approved plan sections", options: [{ id: "phase-1", label: "Phase 1" }], target, }, }, { agentId }); await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), checkbox.id, { selectedOptionIds: ["phase-1"], }, { userId: "local-board" }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) .resolves.toBe("planning"); }); it.each(["ask", "standard"] as const)("keeps %s mode when accepting a plan confirmation", async (workMode) => { const { companyId, goalId, issueId } = await seedConfirmationIssue(`Keep ${workMode} mode`); await db.update(issues).set({ workMode }).where(eq(issues.id, issueId)); const target = await attachPlanDocument(companyId, issueId); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Accept this plan?", target }, }, { userId: "local-board" }); await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) .resolves.toBe(workMode); }); it("keeps Plan mode when a plan confirmation is rejected", async () => { const { companyId, issueId } = await seedConfirmationIssue("Reject a plan"); await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId)); const target = await attachPlanDocument(companyId, issueId); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Accept this plan?", target }, }, { userId: "local-board" }); const rejected = await interactionsSvc.rejectInteraction({ id: issueId, companyId }, created.id, { reason: "Revise the plan", }, { userId: "local-board" }); expect(rejected.status).toBe("rejected"); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) .resolves.toBe("planning"); }); it("expires request confirmations when a creator opts into comment supersede", 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", }); expect(created).toMatchObject({ 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, status: "expired", result: { version: 1, outcome: "superseded_by_comment", commentId, }, resolvedByUserId: "local-board", }); }); it("keeps request confirmations pending by default when the user sends a message", 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 [created] = await db.select().from(issueThreadInteractions); expect(created?.payload).toMatchObject({ supersedeOnUserComment: false }); 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("keeps legacy request confirmations pending when comment supersede was not stored", async () => { const { companyId, issueId } = await seedConfirmationIssue("Legacy confirmation without comment supersede flag"); await db.insert(issueThreadInteractions).values({ id: randomUUID(), companyId, issueId, kind: "request_confirmation", status: "pending", continuationPolicy: { kind: "none" }, payload: { version: 1, prompt: "Proceed with the current draft?", }, createdByUserId: "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("lists interactions whose stored result predates the current schema without throwing (LOOA-629)", async () => { const { companyId, issueId } = await seedConfirmationIssue("Legacy result outcome"); // Simulate a row persisted by an older build: a resolved confirmation whose // result.outcome is a value no longer in the current enum. A hard parse // would 500 the whole listForIssue call and brick every consumer (web // thread + Slack gateway notifier/digest/aging). await db.insert(issueThreadInteractions).values({ id: randomUUID(), companyId, issueId, kind: "request_confirmation", status: "cancelled", continuationPolicy: { kind: "none" }, payload: { version: 1, prompt: "Proceed with the current draft?", }, result: { version: 1, outcome: "withdrawn_by_creator", }, createdByUserId: "local-board", }); const listed = await interactionsSvc.listForIssue(issueId); expect(listed).toHaveLength(1); expect(listed[0]?.kind).toBe("request_confirmation"); // The unparseable result degrades to null; the interaction still lists. expect(listed[0]?.result).toBeNull(); expect(listed[0]?.status).toBe("cancelled"); }); it("derives legacy pending interactions as expired on closed issues without mutating the GET", async () => { const { companyId, issueId } = await seedConfirmationIssue("Legacy pending interaction on closed issue"); const created = await interactionsSvc.create({ id: issueId, companyId }, { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed?" }, }, { userId: "local-board" }); await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId)); const listed = await interactionsSvc.listForIssue(issueId); expect(listed[0]).toMatchObject({ id: created.id, status: "expired", result: { version: 1, outcome: "issue_closed" }, }); const stored = await db .select({ status: issueThreadInteractions.status }) .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, created.id)) .then((rows) => rows[0]); expect(stored?.status).toBe("pending"); await expect(interactionsSvc.acceptInteraction({ id: issueId, companyId, projectId: null, goalId: null, status: "done", }, created.id, {}, { userId: "local-board" })).rejects.toThrow( "Interaction is no longer actionable because the issue is closed", ); await expect(interactionsSvc.withdrawInteraction({ id: issueId, companyId, status: "done" }, created.id, {}, { userId: "local-board", })).rejects.toThrow("Interaction is no longer actionable because the issue is closed"); }); 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?", }, }, { 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("does not supersede request confirmations for run-originated comments even under user auth", async () => { // Local-CLI agents post under user auth, so authorUserId is set nondeterministically. // A comment carrying createdByRunId is machine-originated and must never expire a // pending decision card. const { companyId, issueId } = await seedConfirmationIssue("Run-originated comment supersede exclusion"); const created = 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(new Date(created.createdAt).getTime() + 1_000), authorUserId: "local-board", createdByRunId: randomUUID(), }, { 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("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("does not repair historical confirmations from run-originated comments", async () => { // The repair sweep must ignore machine-originated comments (createdByRunId set) even // when authorUserId is present under user auth. const { companyId, issueId } = await seedConfirmationIssue("Historical run-originated exclusion"); const agentId = randomUUID(); const runId = 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?", }, }, { userId: "local-board", }); await db .update(issueThreadInteractions) .set({ createdAt, updatedAt: createdAt }) .where(eq(issueThreadInteractions.id, created.id)); await db.insert(agents).values({ id: agentId, companyId, name: "GiskardCoder", role: "engineer", status: "active", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", }); await db.insert(issueComments).values({ id: randomUUID(), companyId, issueId, authorUserId: "local-board", authorType: "user", createdByRunId: runId, body: "SLA escalation relay posted from a heartbeat run.", createdAt: new Date("2026-05-18T12:01:00.000Z"), updatedAt: new Date("2026-05-18T12:01:00.000Z"), }); await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ id: issueId, companyId, })).resolves.toEqual([]); const rows = await db.select().from(issueThreadInteractions); expect(rows).toHaveLength(1); expect(rows[0]?.status).toBe("pending"); }); 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(interactionCompanyRow(companyId)); 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(parentIssueRow(issueId, companyId, goalId)); 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, }); await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).rejects.toMatchObject({ status: 409, details: { code: "interaction_stale_target" }, }); const expired = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id); expect(expired).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, }, }, }); }); it("rejects creating a plan confirmation against a stale document revision and accepts the current one", async () => { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); const documentId = randomUUID(); const revisionId = randomUUID(); const nextRevisionId = randomUUID(); await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Stale plan confirmation", level: "task", status: "active", }); await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId)); // Document is already at revision 2 — revision 1 is stale. await db.insert(documents).values({ id: documentId, companyId, title: "Plan", format: "markdown", latestBody: "v2", latestRevisionId: nextRevisionId, latestRevisionNumber: 2, }); 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", }, { id: nextRevisionId, companyId, documentId, revisionNumber: 2, title: "Plan", format: "markdown", body: "v2", }, ]); const staleTarget = { type: "issue_document" as const, issueId, documentId, key: "plan", revisionId, revisionNumber: 1, }; // The revision check runs inside the create transaction (locking the // document row), so a target pointing at an older revision is rejected // atomically with the would-be insert rather than by a racy pre-check. await expect(interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Apply the plan document?", target: staleTarget, }, }, { userId: "local-board", })).rejects.toMatchObject({ status: 422, message: expect.stringContaining("current issue document revision"), }); const noRows = await db .select({ id: issueThreadInteractions.id }) .from(issueThreadInteractions) .where(eq(issueThreadInteractions.issueId, issueId)); expect(noRows).toHaveLength(0); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Apply the plan document?", target: { ...staleTarget, revisionId: nextRevisionId, revisionNumber: 2, }, }, }, { userId: "local-board", }); expect(created).toMatchObject({ status: "pending", kind: "request_confirmation" }); await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).resolves.toMatchObject({ interaction: { status: "accepted" }, continuationIssue: { id: issueId }, }); await expect(issueService(db).getById(issueId)).resolves.toMatchObject({ workMode: "standard", }); }); it("preserves resolved request_item_verdicts items 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(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, companyId, title: "Document target verdicts", level: "task", status: "active", }); await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); 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_item_verdicts", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Review generated artifacts.", items: [ { id: "api", label: "API route" }, { id: "docs", label: "Docs" }, ], target: { type: "issue_document", issueId, documentId, key: "plan", revisionId, revisionNumber: 1, }, }, }, { userId: "local-board", }); await interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "api", verdict: "approve" }], }, { 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, }); await expect(interactionsSvc.submitItemVerdicts({ id: issueId, companyId, }, created.id, { verdicts: [{ id: "docs", verdict: "approve" }], }, { userId: "local-board", })).rejects.toMatchObject({ status: 409, details: { code: "interaction_stale_target" }, }); const stale = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id); expect(stale).toMatchObject({ id: created.id, status: "expired", payload: { target: { type: "issue_document", key: "plan", revisionId: nextRevisionId, revisionNumber: 2, }, }, result: { version: 1, outcome: "stale_target", complete: false, staleTarget: { type: "issue_document", key: "plan", revisionId, }, items: [ { id: "api", verdict: "approve", resolvedByUserId: "local-board", }, ], }, }); }); describe("workspace_finalize accept gate", () => { type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation"; async function seedAcceptGateFixture(options?: { kind?: AcceptGateInteractionKind; sourceRunId?: string | null; sourceRunStatus?: string; }) { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); const issueId = randomUUID(); const goalId = randomUUID(); const agentId = randomUUID(); const sourceRunId = options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID(); const foreignRunId = randomUUID(); const kind = options?.kind ?? "request_confirmation"; await db.insert(companies).values(interactionCompanyRow(companyId)); 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(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); const sourceRunStatus = options?.sourceRunStatus ?? "succeeded"; const sourceRunTerminal = sourceRunStatus !== "running"; await db.insert(heartbeatRuns).values([ ...(sourceRunId ? [ { id: sourceRunId, companyId, agentId, invocationSource: "manual", status: sourceRunStatus, startedAt: new Date("2026-05-23T21:55:00.000Z"), finishedAt: sourceRunTerminal ? new Date("2026-05-23T22:05:00.000Z") : null, }, ] : []), { id: foreignRunId, companyId, agentId, invocationSource: "manual", status: "running", startedAt: new Date("2026-05-23T22:10:00.000Z"), }, ]); 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 payload = kind === "request_checkbox_confirmation" ? { version: 1 as const, prompt: "Which files should be accepted?", options: [ { id: "file-a", label: "a.txt" }, { id: "file-b", label: "b.txt" }, ], minSelected: 0, maxSelected: 2, } : { version: 1 as const, prompt: "Mark this issue done?", }; const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind, continuationPolicy: "wake_assignee", sourceRunId, payload, }, { userId: "local-board", }); return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id, sourceRunId, foreignRunId, }; } it.each(["request_confirmation", "request_checkbox_confirmation"] as const)( "projects %s readiness until its source workspace settles", async (kind) => { const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId, foreignRunId } = await seedAcceptGateFixture({ kind, sourceRunStatus: "running" }); // A source run without workspace operations needs no sync barrier. expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", }); const assertPreparing = async () => { const expected = { id: interactionId, status: "pending", acceptanceBlocker: "workspace_sync_pending" }; expect((await interactionsSvc.listForIssue(issueId))[0]).toMatchObject(expected); expect(await interactionsSvc.getById(interactionId)).toMatchObject(expected); expect(await interactionsSvc.getForIssue({ id: issueId, companyId }, interactionId)).toMatchObject(expected); }; await assertPreparing(); const [finalize] = await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "running", }).returning(); await assertPreparing(); await db.update(workspaceOperations).set({ status: "succeeded" }).where(eq(workspaceOperations.id, finalize.id)); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", }); expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined(); expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined(); expect((await interactionsSvc.getForIssue({ id: issueId, companyId }, interactionId)).acceptanceBlocker).toBeUndefined(); }, ); it.each(["failed", "skipped", "stale"])("does not keep preparing after a %s finalize", async (outcome) => { const { companyId, executionWorkspaceId, issueId, sourceRunId } = await seedAcceptGateFixture({ sourceRunStatus: "failed" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: outcome === "stale" ? "running" : outcome, }); expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined(); }); it.each(["accepted", "rejected", "cancelled", "expired"])("does not project a blocker on %s history", async (status) => { const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", }); await db.update(issueThreadInteractions).set({ status }).where(eq(issueThreadInteractions.id, interactionId)); expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined(); expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined(); }); it("does not project a blocker for confirmations expired by task closure", async () => { const { companyId, executionWorkspaceId, issueId, sourceRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", }); await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId)); const [interaction] = await interactionsSvc.listForIssue(issueId); expect(interaction.status).toBe("expired"); expect(interaction.acceptanceBlocker).toBeUndefined(); }); it.each(["toolAction", "ask_user_questions"])( "keeps live %s decisions available while the workspace is active", async (kind) => { const { companyId, executionWorkspaceId, issueId, interactionId, sourceRunId } = await seedAcceptGateFixture({ sourceRunStatus: "running" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", }); const payload = kind === "ask_user_questions" ? { version: 1, questions: [{ id: "question", prompt: "Which option?", selectionMode: "single", options: [{ id: "first", label: "First" }] }] } : { version: 1, prompt: "Allow this action?", [kind]: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "example.write", toolDisplayName: "Write", connectionId: randomUUID(), applicationId: randomUUID(), appDisplayName: "Example", risk: "write", previewMarkdown: "Write one row", argumentsSummaryJson: "{}", argumentsHash: "hash", expiresAt: "2099-01-01T00:00:00.000Z" } }; await db.update(issueThreadInteractions).set({ kind: kind === "ask_user_questions" ? kind : "request_confirmation", payload, }).where(eq(issueThreadInteractions.id, interactionId)); expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined(); expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined(); }, ); it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_confirmation", status: "accepted", }); }); it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await expect( interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ), ).rejects.toMatchObject({ status: 409, message: expect.stringContaining( "the run that created this interaction has not finished syncing its workspace", ), details: { executionWorkspaceId, sourceRunId }, }); const row = await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interactionId)) .then((rows) => rows[0]); expect(row?.status).toBe("pending"); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_confirmation", status: "accepted", }); }); it("allows request_confirmation accept when the source run's workspace_finalize failed", async () => { // A sync-back that ran and FAILED is terminal. The run will not retry it, so // the confirmation must not stay wedged behind a misleading "still syncing" // error — the user can merge/act manually. const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = await seedAcceptGateFixture({ sourceRunStatus: "failed" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_config_freshness", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "failed", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_confirmation", status: "accepted", }); }); it("allows request_confirmation accept when a running workspace_finalize is stale (source run ended)", async () => { // The source run died mid-finalize, leaving a `running` op that will never // advance. A terminal/missing owner run means the record is stale, so the // gate must not wait on it forever. const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = await seedAcceptGateFixture({ sourceRunStatus: "failed" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "running", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_confirmation", status: "accepted", }); }); it("refuses request_confirmation accept while a workspace_finalize is running on a live source run", async () => { // A genuinely in-flight sync-back on a still-active run must still block, so // the confirmation cannot race commits that are actively being synced back. const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = await seedAcceptGateFixture({ sourceRunStatus: "running" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "running", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); await expect( interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ), ).rejects.toMatchObject({ status: 409, message: expect.stringContaining( "the run that created this interaction has not finished syncing its workspace", ), details: { executionWorkspaceId, sourceRunId }, }); }); it("allows request_confirmation accept when sourceRunId is null", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } = await seedAcceptGateFixture({ sourceRunId: null }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_confirmation", status: "accepted", }); }); it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } = await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-b"] }, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_checkbox_confirmation", status: "accepted", result: { selectedOptionIds: ["file-b"], }, }); }); it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), }); await expect( interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, ), ).rejects.toMatchObject({ status: 409, message: expect.stringContaining( "the run that created this interaction has not finished syncing its workspace", ), details: { executionWorkspaceId, sourceRunId }, }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: sourceRunId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_checkbox_confirmation", status: "accepted", }); }); it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => { const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } = await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null }); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); const accepted = await interactionsSvc.acceptInteraction( acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, ); expect(accepted.interaction).toMatchObject({ id: interactionId, kind: "request_checkbox_confirmation", 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, foreignRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, heartbeatRunId: foreignRunId, 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( acceptIssueScope(issueId, companyId, goalId), 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", }); }); }); });