Files
PaperClipAI/server/src/__tests__/issue-thread-interactions-service.test.ts
T
DottaandPaperclip b67db12d90 feat: add durable storage for private tasks (#14717)
Add private task and project ownership, downward access grants, and immutable run/workspace provenance. Apply migration 0313 with bounded batch commits and concurrent indexes. Keep the enforcement and sharing changes in their dependent PRs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 06:53:20 -05:00

4986 lines
166 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
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 { 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 embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("issueThreadInteractionService", () => {
let db!: ReturnType<typeof createDb>;
let issuesSvc!: ReturnType<typeof issueService>;
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-");
db = createDb(tempDb.connectionString);
issuesSvc = issueService(db);
interactionsSvc = issueThreadInteractionService(db);
}, 20_000);
afterEach(async () => {
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 tempDb?.cleanup();
});
async function seedConfirmationIssue(title = "Comment supersede") {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title,
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
return { companyId, goalId, issueId };
}
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<string, unknown>) {
const { companyId, issueId } = await seedConfirmationIssue("Source question race");
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Questioner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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 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" });
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<void>(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<ReturnType<typeof interactionsSvc.create>>;
let explicit!: Awaited<ReturnType<typeof interactionsSvc.create>>;
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({
id: otherAgentId,
companyId: fixture.companyId,
name: "Other questioner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
// 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<void>((resolve) => { transactionStarted = resolve; });
const continueComment = new Promise<void>((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<ReturnType<typeof interactionsSvc.create>>;
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<void>((resolve) => { insertReached = resolve; });
const release = new Promise<void>((resolve) => { releaseInsert = resolve; });
await db.transaction(async (tx) => {
const lockedTx = new Proxy(tx as any, {
get(target, property, receiver) {
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" },
lockedTx,
);
await reached;
try {
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({
id: agentId,
companyId,
name: "Researcher",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist thread interactions",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction.kind).toBe("suggest_tasks");
expect(accepted.interaction.status).toBe("accepted");
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
expect.objectContaining({ clientKey: "child" }),
],
});
expect(accepted.createdIssues).toEqual([
expect.objectContaining({
assigneeAgentId,
status: "todo",
}),
expect.objectContaining({
assigneeAgentId: null,
status: "todo",
}),
]);
const createdIssueRows = await db
.select({
title: issues.title,
workMode: issues.workMode,
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
expect(childrenAfterDuplicateAccept).toHaveLength(1);
});
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Selectively persist thread interactions",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
{
clientKey: "sibling",
title: "Create the sibling follow-up",
},
],
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["root"],
}, {
userId: "local-board",
});
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
],
skippedClientKeys: ["child", "sibling"],
});
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
});
it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Validate selective acceptance",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
],
},
}, {
userId: "local-board",
});
await expect(
interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["child"],
}, {
userId: "local-board",
}),
).rejects.toThrow("requires its parent");
});
it("persists validated answers for ask_user_questions interactions", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist question answers",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Question parent",
status: "todo",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
},
{
id: "extras",
prompt: "Optional extras",
selectionMode: "multi",
options: [
{ id: "tests", label: "Tests" },
{ id: "docs", label: "Docs" },
],
},
],
},
}, {
userId: "local-board",
});
const answered = await interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [
{ questionId: "scope", optionIds: [], 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Cancel question answers",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Question parent",
status: "in_review",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
}],
},
}, {
userId: "local-board",
});
const cancelled = await interactionsSvc.cancelQuestions({
id: issueId,
companyId,
}, created.id, {
reason: "Not needed anymore",
}, {
userId: "local-board",
});
expect(cancelled.status).toBe("cancelled");
expect(cancelled.result).toEqual({
version: 1,
answers: [],
cancelled: true,
cancellationReason: "Not needed anymore",
summaryMarkdown: null,
});
await expect(interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
});
it("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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Interaction dedupe",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date("2026-04-20T12:00:00.000Z"),
});
const input = {
kind: "ask_user_questions" as const,
idempotencyKey: "run-1:questionnaire",
sourceRunId: runId,
continuationPolicy: "wake_assignee" as const,
payload: {
version: 1 as const,
questions: [
{
id: "scope",
prompt: "Pick a scope",
selectionMode: "single" as const,
options: [{ id: "phase-2", label: "Phase 2" }],
},
],
},
};
const first = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
const second = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
expect(second.id).toBe(first.id);
expect(second.sourceRunId).toBe(runId);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire");
});
it("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([
{
id: firstAgentId,
companyId,
name: "First agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: secondAgentId,
companyId,
name: "Second agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
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([
{
id: probingAgentId,
companyId,
name: "Probing agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "Other agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Chief of staff",
role: "chief_of_staff",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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([
{
id: firstAgentId,
companyId,
name: "First agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: secondAgentId,
companyId,
name: "Second agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Keep editing",
detailsMarkdown: "Creates follow-up work after acceptance.",
},
}, {
userId: "local-board",
});
expect(created.kind).toBe("request_confirmation");
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
},
resolvedByUserId: "local-board",
});
const requiresReason = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Decline only with a reason?",
rejectRequiresReason: true,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.rejectInteraction({
id: issueId,
companyId,
}, requiresReason.id, {}, {
userId: "local-board",
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("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({
id: resolverAgentId,
companyId,
name: "Review agent",
role: "reviewer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: resolverAgentId,
companyId,
name: "Review agent",
role: "reviewer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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<void>((resolve) => {
releasePolicyLock = resolve;
});
const policyLocked = new Promise<void>((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({
id: resolverAgentId,
companyId,
name: "Review agent",
role: "reviewer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: [],
}, {
userId: "local-board",
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["missing"],
}, {
userId: "local-board",
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Senior Product Engineer",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Review the plan",
status: "in_review",
priority: "medium",
assigneeUserId: "local-board",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Approve this plan?",
acceptLabel: "Approve plan",
rejectLabel: "Ask for changes",
},
}, {
agentId,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.continuationIssue).toEqual({
id: issueId,
assigneeAgentId: agentId,
assigneeUserId: null,
status: "todo",
});
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
expect(updatedIssue).toMatchObject({
id: issueId,
status: "todo",
assigneeAgentId: agentId,
assigneeUserId: null,
});
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: agentId,
companyId,
name: "Plan owner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: agentId,
companyId,
name: "Plan owner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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({ id: issueId, companyId, goalId, projectId: null }, 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({ id: issueId, companyId, goalId, projectId: null }, 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({ id: issueId, companyId, goalId, projectId: null }, 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target confirmation",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply the plan document?",
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
await db.insert(documentRevisions).values({
id: nextRevisionId,
companyId,
documentId,
revisionNumber: 2,
title: "Plan",
format: "markdown",
body: "v2",
});
await db.update(documents).set({
latestBody: "v2",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
});
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Stale plan confirmation",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
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({
id: issueId,
companyId,
goalId,
projectId: null,
}, 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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target verdicts",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_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({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
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(
{ id: issueId, companyId, goalId, projectId: null },
created.id,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: created.id,
kind: "suggest_tasks",
status: "accepted",
});
});
it("allows accept when the issue has no execution workspace attached", async () => {
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Mark this issue done?",
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId: null, projectId: null },
created.id,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: created.id,
status: "accepted",
});
});
});
});