mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents split work into tasks. A task can have a parent task. > - Today agents can nest tasks to any depth: parent, child, grandchild, and deeper. > - Deep agent-made trees are hard to follow, and an agent that works on a child task makes grandchildren by default. > - This pull request limits agent-created trees to two levels: a top-level task and its children. > - A request for a grandchild does not fail. The server puts the new task beside the requester and tells the agent. > - Board users keep full control. They can still nest to any depth. Existing deep trees stay valid. > - The pull request also records which task created each new task, so the audit trail stays complete when the parent changes. ## Linked Issues or Issue Description No public issue exists. This is the feature description. Related: #16853 (budgets that include descendant work) also reasons about task trees; this change does not alter its traversal. **Subsystem affected** Issues (tasks): create, child create and move in the issue service and routes. Agent tools, chat suggestions and creation provenance use these paths. **Problem or motivation** Agents create subtasks as deep as they want. An agent that runs on a child task gets that child as the default parent, so its follow-ups become grandchildren. The trees become deep and hard to read. The parent link is also the only record of which task created a follow-up, so that record is lost when the parent changes. **Proposed solution** Limit agent-created trees to a parent and its children. When an agent asks for a grandchild, put the new task under the requester's parent and return `parentAdjusted`. Do not reject the request. Keep board users unrestricted. Record the originating task in a new server-set `origin_issue_id` column. Keep `origin_run_id` as the originating run. **Alternatives considered** Reject grandchild requests with an error. We did not do this, because agents then fail mid-task. Add a database constraint. We did not do this, because existing deep trees must stay valid and nesting can return later. ## What Changed - Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the `IssueParentAdjustment` type in `packages/shared`. - Add `resolveNewTaskParent` and `planIssueParentChange` in `server/src/services/issues.ts`. `create`, `createChild` and `update` use the agent limit by default (`parentDepthPolicy: "flatten"`). - The issue create, `/children` and PATCH routes pass `allow_nested` for board users. For agents, the routes check authorization against the parent that the server actually writes. - The requester keeps its behavior when the server moves a task beside it: `blockParentUntilDone` blocks the requester, and the new task inherits the requester's workspace and privacy. - The server never moves a task above a low-trust boundary root that contains the requester. - These subsystems keep any depth, because they find their work by parent: watchdog reviews, monitor recovery, email-channel placement, and routines that a person configured. - An agent move of a task that has children skips only the parent change and returns `reason: "has_children"`. - Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed) in migration `0434`. Only the server sets it. The `createdFrom` detail field, the `createdFromIssueId` filter and task-creation provenance read it first. - Chat prompts now let a chat agent create one task, a task with children, or separate tasks. The conversation is never a parent. - The runner `create_task` tool accepts an optional `parentTaskId`. A chat agent can create a top-level task and then put subtasks under it. The run must be able to read and change the parent. A conversation is never a parent. The depth limit still applies. Project tools map the field to `parentId` through the issue route. The protocol manifest, semantic contracts and eval fixture are regenerated. - Watchdog runs that have an active watchdog keep their requested parent on all create paths, because their access is limited to the watched subtree. - Fix a bug that the test drive found: the chat (MCP) `create_task` ignored the requested `status`, so a `backlog` task became `todo` and started to run. It now uses the same rule as the native tool. - Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`. ## Verification - `cd server && npx vitest run src/__tests__/agent-task-hierarchy-depth.test.ts src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests for service and route behavior, all pass. - Related suites (64 files: issues service, thread interactions, privacy, watchdogs, provenance, runner tool authority, decisions, routines, project tools): 1208 tests pass. Two tests changed because of the intended behavior and pass now. Two `email-channels.integration` tests fail locally because the test cannot find a bundled skill file from this worktree path. This change does not touch that code. - `packages/db`: migration safety, snapshot drift and journal tests pass. - Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli` and `ui` (local `ui` errors come only from generated Storybook catalog files that are absent). - Local test drive on an isolated instance with real Claude agents: 29 of 29 use cases pass, including an agent on a child task (follow-ups placed beside it, with the notice) and a chat that creates a top-level task with three subtasks. - I did not run the full `pnpm test:run` or `pnpm build` locally. CI runs them. ## Risks - Behavior change for agents: some follow-ups that were grandchildren are now siblings. The parent of the requester is woken when all its children finish, which now includes these tasks. - The runner `create_task` input contract adds one optional field, so the protocol manifest and the pack contract change. The tool output does not change: it shows the actual `parentId`, and the activity log records `parentAdjusted`. - Migration `0434` adds a nullable column, a foreign key and an index. It does not backfill rows. - `IssueCreationSource.run` is now nullable. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, local test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
4752 lines
169 KiB
TypeScript
4752 lines
169 KiB
TypeScript
import { subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyPendingMigrations,
|
|
activityLog,
|
|
agents,
|
|
authUsers,
|
|
companyMemberships,
|
|
instanceUserRoles,
|
|
issueQuestionResponseDeliveries,
|
|
companies,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
executionWorkspaces,
|
|
goals,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
instanceSettings,
|
|
issueRelations,
|
|
issueThreadInteractions,
|
|
issues,
|
|
projectWorkspaces,
|
|
projects,
|
|
workspaceOperations,
|
|
toolApplications,
|
|
toolConnections,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import {
|
|
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
|
|
type IssueThreadInteractionPayload,
|
|
type RequestCheckboxConfirmationPayload,
|
|
type RequestItemVerdictsPayload,
|
|
} from "@paperclipai/shared";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
import { agentService } from "../services/agents.js";
|
|
|
|
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_INTERACTIONS_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let issuesSvc!: ReturnType<typeof issueService>;
|
|
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (externalUrl) await applyPendingMigrations(externalUrl);
|
|
else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-");
|
|
db = createDb(externalUrl ?? tempDb!.connectionString);
|
|
issuesSvc = issueService(db);
|
|
interactionsSvc = issueThreadInteractionService(db);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
|
|
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(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(companies);
|
|
await db.delete(authUsers);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
function interactionCompanyRow(companyId: string): typeof companies.$inferInsert {
|
|
return {
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
};
|
|
}
|
|
|
|
function parentIssueRow(issueId: string, companyId: string, goalId: string): typeof issues.$inferInsert {
|
|
return {
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
};
|
|
}
|
|
|
|
function acceptIssueScope(issueId: string, companyId: string, goalId: string) {
|
|
return {
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
projectId: null,
|
|
};
|
|
}
|
|
|
|
function activeCodexAgentRow(agentId: string, companyId: string, name: string, role: string): typeof agents.$inferInsert {
|
|
return {
|
|
id: agentId,
|
|
companyId,
|
|
name,
|
|
role,
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
};
|
|
}
|
|
|
|
async function seedConfirmationIssue(title = "Comment supersede") {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title,
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
|
|
return { companyId, goalId, issueId };
|
|
}
|
|
|
|
it.each([
|
|
["tool_error", "GitHub", "config"], ["tool_execution_failed", "GitHub", "config"],
|
|
["tool_error", "GitHub (2)", "config"], ["tool_execution_failed", "Prototype code", "config"],
|
|
["tool_error", "Transport GitHub", "transport"],
|
|
["tool_error", "Imported GitHub", "application"], ["tool_execution_failed", "Legacy GitHub", "legacy_application"],
|
|
])("projects a saved GitHub %s permission error for %s without rewriting its diagnostic record", async (errorCode, appName, source) => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("GitHub access repair");
|
|
const [application] = await db.insert(toolApplications).values({ companyId, applicationKey: `github-${randomUUID()}`,
|
|
name: appName, type: "mcp_http", metadata: source === "application" ? { sourceTemplateKey: "github" }
|
|
: source === "legacy_application" ? { source: "github" } : {} }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: application!.id,
|
|
name: appName, uid: `github-${randomUUID()}`, transport: "mcp_remote",
|
|
config: source === "config" ? { sourceTemplateKey: "github" } : {},
|
|
transportConfig: source === "transport" ? { sourceTemplateKey: "github" } : {} }).returning();
|
|
const errorMessage = "failed to create repository: POST https://api.github.com/user/repos: 403 Resource not accessible by integration []";
|
|
const [row] = await db.insert(issueThreadInteractions).values({ companyId, issueId,
|
|
kind: "request_confirmation", status: "accepted", payload: { version: 1, prompt: "Create repository?",
|
|
toolAction: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "github.create_repository",
|
|
toolDisplayName: "Create repository", appDisplayName: appName, connectionId: connection!.id, applicationId: application!.id,
|
|
risk: "write", previewMarkdown: "Create a private prototype repository", argumentsSummaryJson: "{}",
|
|
argumentsHash: "hash", expiresAt: new Date().toISOString() } },
|
|
result: { version: 1, outcome: "accepted", toolAction: { version: 1, status: "failed",
|
|
errorCode, errorMessage, updatedAt: new Date().toISOString() } },
|
|
}).returning();
|
|
expect(await interactionsSvc.getById(row!.id)).toMatchObject({ result: { toolAction: {
|
|
errorCode: "github_access_denied", errorMessage,
|
|
} } });
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, row!.id)))[0]?.result)
|
|
.toMatchObject({ toolAction: { errorCode, errorMessage } });
|
|
});
|
|
|
|
it.each(["other_company", "other_provider", "other_application"])("does not infer GitHub identity from a card name with %s metadata", async variant => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Connection identity boundary");
|
|
const connectionCompanyId = variant === "other_company" ? randomUUID() : companyId;
|
|
if (connectionCompanyId !== companyId) await db.insert(companies).values(interactionCompanyRow(connectionCompanyId));
|
|
const [application] = await db.insert(toolApplications).values({ companyId: connectionCompanyId,
|
|
applicationKey: `provider-${randomUUID()}`, name: "GitHub", type: "mcp_http",
|
|
metadata: { sourceTemplateKey: "github" } }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId: connectionCompanyId, applicationId: application!.id,
|
|
name: "GitHub", uid: `provider-${randomUUID()}`, transport: "mcp_remote",
|
|
config: { sourceTemplateKey: variant === "other_provider" ? "notion" : "github" } }).returning();
|
|
const [row] = await db.insert(issueThreadInteractions).values({ companyId, issueId,
|
|
kind: "request_confirmation", status: "accepted", payload: { version: 1, prompt: "Run action?",
|
|
toolAction: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "provider.action",
|
|
toolDisplayName: "Run action", appDisplayName: "GitHub", connectionId: connection!.id,
|
|
applicationId: variant === "other_application" ? randomUUID() : application!.id,
|
|
risk: "write", previewMarkdown: "Run action", argumentsSummaryJson: "{}", argumentsHash: "hash",
|
|
expiresAt: new Date().toISOString() } },
|
|
result: { version: 1, outcome: "accepted", toolAction: { version: 1, status: "failed",
|
|
errorCode: "tool_error", errorMessage: "403 Resource not accessible by integration", updatedAt: new Date().toISOString() } },
|
|
}).returning();
|
|
expect(await interactionsSvc.getById(row!.id)).toMatchObject({ result: { toolAction: { errorCode: "tool_error" } } });
|
|
});
|
|
|
|
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(activeCodexAgentRow(agentId, companyId, "Questioner", "engineer"));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
createdAt: new Date("2026-07-25T12:00:00.000Z"),
|
|
startedAt: new Date("2026-07-25T12:00:01.000Z"),
|
|
contextSnapshot: { issueId, ...contextSnapshot },
|
|
});
|
|
return { companyId, issueId, agentId, runId };
|
|
}
|
|
|
|
function questionCreateInput(sourceRunId: string) {
|
|
return {
|
|
kind: "ask_user_questions" as const,
|
|
sourceRunId,
|
|
continuationPolicy: "wake_assignee" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
};
|
|
}
|
|
|
|
it("persists and answers a canonical-only mixed question form", async () => {
|
|
const { companyId, issueId } = await seedSourceQuestionFixture({});
|
|
const questionSet = {
|
|
schema: "paperclip.question_set.v1" as const,
|
|
questions: [
|
|
{ id: "repo", prompt: "Repository URL?", required: true, answerMode: "text" as const },
|
|
{ id: "scope", prompt: "Review scope?", required: true, answerMode: "single_select" as const, options: [{ id: "all", label: "All changes" }, { id: "selected", label: "Selected changes" }] },
|
|
{ id: "hosting", prompt: "Preview hosting?", required: true, answerMode: "multi_select" as const, options: [{ id: "existing", label: "Existing host" }, { id: "new", label: "New host" }] },
|
|
],
|
|
};
|
|
const input = { kind: "ask_user_questions" as const, idempotencyKey: "canonical:mixed", payload: { version: 1 as const, questionSet } };
|
|
const issue = { id: issueId, companyId };
|
|
const created = await interactionsSvc.create(issue, input, { userId: "local-board" });
|
|
if (created.kind !== "ask_user_questions") throw new Error("expected questions");
|
|
expect(created.payload.questionSet).toEqual(questionSet);
|
|
expect(created.payload.questions.map((question) => question.id)).toEqual(["repo", "scope", "hosting"]);
|
|
expect(await interactionsSvc.create(issue, input, { userId: "local-board" })).toEqual(created);
|
|
await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "repo", optionIds: [], otherText: "https://example.com/repo" }] }, { userId: "local-board" })).rejects.toThrow("requires an answer");
|
|
const notified = vi.fn();
|
|
const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.question, notified);
|
|
const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [
|
|
{ questionId: "repo", optionIds: [], otherText: "https://example.com/repo" },
|
|
{ questionId: "scope", optionIds: ["all"] },
|
|
{ questionId: "hosting", optionIds: ["existing", "new"] },
|
|
] }, { userId: "local-board" });
|
|
unsubscribe();
|
|
expect(notified).toHaveBeenCalledTimes(1);
|
|
expect(answered.status).toBe("answered");
|
|
});
|
|
|
|
it("rejects canonical text and custom answers that violate constraints before resolution", async () => {
|
|
const { companyId, issueId } = await seedSourceQuestionFixture({});
|
|
const issue = { id: issueId, companyId };
|
|
const cases = [
|
|
{ textValidation: { minLength: 3, maxLength: 4 }, invalid: ["ab", "abcde"], valid: "abcd" },
|
|
{ textValidation: { pattern: "^https://" }, invalid: ["http://example.test"], valid: "https://example.test" },
|
|
{ textValidation: { inputType: "integer" as const, minimum: 2, maximum: 4 }, invalid: ["no", "3.5", "1", "5"], valid: "3" },
|
|
{ textValidation: { inputType: "number" as const, minimum: 2, maximum: 4 }, invalid: ["Infinity", "1.9", "4.1"], valid: "2.5" },
|
|
];
|
|
for (const [index, entry] of cases.entries()) {
|
|
const created = await interactionsSvc.create(issue, {
|
|
kind: "ask_user_questions", payload: { version: 1, questionSet: {
|
|
schema: "paperclip.question_set.v1", questions: [{ id: "value", prompt: "Value?", required: true, answerMode: "text", textValidation: entry.textValidation }],
|
|
} },
|
|
}, { userId: "local-board" });
|
|
for (const otherText of entry.invalid) {
|
|
await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "value", optionIds: [], otherText }] }, { userId: "local-board" })).rejects.toThrow();
|
|
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, created.id));
|
|
expect(row?.status, `case ${index}: ${otherText}`).toBe("pending");
|
|
}
|
|
expect((await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "value", optionIds: [], otherText: entry.valid }] }, { userId: "local-board" })).status).toBe("answered");
|
|
}
|
|
const custom = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questionSet: {
|
|
schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options: [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }], customAnswer: { enabled: true }, textValidation: { minLength: 3 } }],
|
|
} } }, { userId: "local-board" });
|
|
await expect(interactionsSvc.answerQuestions(issue, custom.id, { answers: [{ questionId: "scope", optionIds: [], otherText: "ab" }] }, { userId: "local-board" })).rejects.toThrow("at least 3");
|
|
expect((await interactionsSvc.answerQuestions(issue, custom.id, { answers: [{ questionId: "scope", optionIds: [], otherText: "abc" }] }, { userId: "local-board" })).status).toBe("answered");
|
|
});
|
|
|
|
it("accepts existing custom-answer IDs in compatible dual forms", async () => {
|
|
const { companyId, issueId } = await seedSourceQuestionFixture({});
|
|
const issue = { id: issueId, companyId };
|
|
const options = [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }];
|
|
const created = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: {
|
|
version: 1,
|
|
questions: [{ id: "scope", prompt: "Scope?", required: true, selectionMode: "single", options: [...options, { id: "existing-custom-id", label: "Other", freeText: true }] }],
|
|
questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options, customAnswer: { enabled: true }, textValidation: { minLength: 3 } }] },
|
|
} }, { userId: "local-board" });
|
|
const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["existing-custom-id"], otherText: "Specific files" }] }, { userId: "local-board" });
|
|
expect(answered).toMatchObject({ status: "answered", result: { answers: [{ questionId: "scope", optionIds: ["existing-custom-id"], otherText: "Specific files" }] } });
|
|
});
|
|
|
|
it("keeps historical pending written-answer paths usable with their text constraints", async () => {
|
|
const { companyId, issueId } = await seedSourceQuestionFixture({});
|
|
const issue = { id: issueId, companyId };
|
|
const created = await interactionsSvc.create(issue, { kind: "ask_user_questions", payload: { version: 1, questionSet: {
|
|
schema: "paperclip.question_set.v1", questions: [{ id: "scope", prompt: "Scope?", required: true, answerMode: "single_select", options: [{ id: "all", label: "All" }, { id: "selected", label: "Selected" }], customAnswer: { enabled: true }, textValidation: { minLength: 3 } }],
|
|
} } }, { userId: "local-board" });
|
|
if (created.kind !== "ask_user_questions") throw new Error("expected questions");
|
|
const historicalPayload = structuredClone(created.payload);
|
|
delete historicalPayload.questionSet!.questions[0].customAnswer;
|
|
await db.update(issueThreadInteractions).set({ payload: historicalPayload }).where(eq(issueThreadInteractions.id, created.id));
|
|
await expect(interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["paperclip_custom_answer"], otherText: "ab" }] }, { userId: "local-board" })).rejects.toThrow("at least 3");
|
|
const answered = await interactionsSvc.answerQuestions(issue, created.id, { answers: [{ questionId: "scope", optionIds: ["paperclip_custom_answer"], otherText: "Specific files" }] }, { userId: "local-board" });
|
|
expect(answered.status).toBe("answered");
|
|
});
|
|
|
|
async function seedQuestionUser(companyId: string, userId: string, role = "member", status = "active") {
|
|
await db.insert(authUsers).values({ id: userId, name: "Question recipient", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: role, status });
|
|
}
|
|
|
|
async function seedChatQuestion() {
|
|
const fixture = await seedSourceQuestionFixture({});
|
|
const userId = "paperclip-id:question-owner";
|
|
await seedQuestionUser(fixture.companyId, userId);
|
|
await db.update(issues).set({
|
|
conversationAgentId: fixture.agentId, conversationUserId: userId, conversationState: "active",
|
|
assigneeAgentId: fixture.agentId,
|
|
}).where(eq(issues.id, fixture.issueId));
|
|
return { ...fixture, userId };
|
|
}
|
|
|
|
it("derives the exact Cloud chat owner, denies other responders, and saves one answer delivery", async () => {
|
|
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token");
|
|
const fixture = await seedChatQuestion();
|
|
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
|
const created = await interactionsSvc.create(scope, {
|
|
...questionCreateInput(fixture.runId), resolverPolicy: "human_only",
|
|
}, { agentId: fixture.agentId });
|
|
expect(created.addresseeUserId).toBe(fixture.userId);
|
|
const answer = { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] };
|
|
for (const userId of ["question-owner", "paperclip-id:other-user"]) {
|
|
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId }))
|
|
.rejects.toMatchObject({ status: 403, details: { code: "interaction_addressee_mismatch" } });
|
|
}
|
|
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { agentId: fixture.agentId, runId: fixture.runId }))
|
|
.rejects.toMatchObject({ status: 403 });
|
|
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0);
|
|
const answered = await interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId });
|
|
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: fixture.userId, continuationPolicy: "wake_assignee" });
|
|
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(1);
|
|
});
|
|
|
|
it("reuses concurrent omitted and explicit chat-owner requests and legacy omitted recipients", async () => {
|
|
const fixture = await seedChatQuestion();
|
|
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
|
const input = { ...questionCreateInput(fixture.runId), idempotencyKey: "chat-question" };
|
|
const actor = { agentId: fixture.agentId };
|
|
// Both callers must finish their optimistic lookup before either transaction
|
|
// inserts, forcing the unique-conflict recovery path instead of relying on timing.
|
|
const transaction = db.transaction.bind(db);
|
|
let arrivals = 0;
|
|
let release!: () => void;
|
|
const ready = new Promise<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.issueId)).toEqual([]);
|
|
});
|
|
|
|
it("allows a source-run question when the newer human comment is explicitly delivered", async () => {
|
|
const commentId = randomUUID();
|
|
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [{ id: commentId }] } });
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId: fixture.companyId,
|
|
issueId: fixture.issueId,
|
|
authorType: "user",
|
|
authorUserId: "board-user",
|
|
body: "Choose phase one.",
|
|
createdAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
);
|
|
expect(created).toMatchObject({ kind: "ask_user_questions", status: "pending" });
|
|
});
|
|
|
|
it("keeps legacy source snapshots compatible when delivered comments are unknown", async () => {
|
|
const fixture = await seedSourceQuestionFixture({});
|
|
await db.insert(issueComments).values({
|
|
companyId: fixture.companyId,
|
|
issueId: fixture.issueId,
|
|
authorType: "user",
|
|
authorUserId: "board-user",
|
|
body: "A legacy context cannot prove delivery.",
|
|
createdAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
);
|
|
expect(created.status).toBe("pending");
|
|
});
|
|
|
|
it("does not apply the delivery guard to approval interactions", async () => {
|
|
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } });
|
|
await db.insert(issueComments).values({
|
|
companyId: fixture.companyId,
|
|
issueId: fixture.issueId,
|
|
authorType: "user",
|
|
authorUserId: "board-user",
|
|
body: "Please review the plan.",
|
|
createdAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
{
|
|
kind: "request_confirmation",
|
|
sourceRunId: fixture.runId,
|
|
continuationPolicy: "wake_assignee",
|
|
payload: { version: 1, prompt: "Approve this plan?" },
|
|
},
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
);
|
|
expect(created).toMatchObject({ kind: "request_confirmation", status: "pending" });
|
|
});
|
|
|
|
it("does not treat the board concierge reply as human direction", async () => {
|
|
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } });
|
|
await db.insert(issueComments).values({
|
|
companyId: fixture.companyId,
|
|
issueId: fixture.issueId,
|
|
authorType: "user",
|
|
authorUserId: "board-concierge",
|
|
body: "The concierge relay replied.",
|
|
createdAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
);
|
|
expect(created.status).toBe("pending");
|
|
});
|
|
|
|
it("rejects an explicitly mismatched source-run issue or agent", async () => {
|
|
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } });
|
|
const otherAgentId = randomUUID();
|
|
await db.insert(agents).values(activeCodexAgentRow(otherAgentId, fixture.companyId, "Other questioner", "engineer"));
|
|
|
|
// Simulate historical corruption to retain the service-level defense test.
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local session_replication_role = replica`);
|
|
await tx.update(heartbeatRuns).set({ nativeIssueId: randomUUID() }).where(eq(heartbeatRuns.id, fixture.runId));
|
|
});
|
|
await expect(interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
)).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" });
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local session_replication_role = replica`);
|
|
await tx.update(heartbeatRuns).set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } }).where(eq(heartbeatRuns.id, fixture.runId));
|
|
});
|
|
await expect(interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: fixture.agentId, runId: fixture.runId },
|
|
)).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" });
|
|
|
|
await db.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId: fixture.issueId, paperclipWake: { comments: [] } } })
|
|
.where(eq(heartbeatRuns.id, fixture.runId));
|
|
await expect(interactionsSvc.create(
|
|
{ id: fixture.issueId, companyId: fixture.companyId },
|
|
questionCreateInput(fixture.runId),
|
|
{ agentId: otherAgentId, runId: fixture.runId },
|
|
)).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the creating agent" });
|
|
});
|
|
|
|
it("expires a question when a comment transaction started earlier inserts after it commits", async () => {
|
|
const fixture = await seedSourceQuestionFixture({});
|
|
let transactionStarted!: () => void;
|
|
let allowCommentInsert!: () => void;
|
|
const started = new Promise<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 observeInserts = (runner: typeof tx): typeof tx => new Proxy(runner as any, {
|
|
get(target, property, receiver) {
|
|
if (property === "transaction") return (callback: (nested: typeof tx) => Promise<unknown>) =>
|
|
target.transaction((nested: typeof tx) => callback(observeInserts(nested)));
|
|
if (property !== "insert") return Reflect.get(target, property, receiver);
|
|
return (table: unknown) => {
|
|
const builder = target.insert(table);
|
|
if (table !== issueComments) return builder;
|
|
return new Proxy(builder, {
|
|
get(insertBuilder, builderProperty, builderReceiver) {
|
|
if (builderProperty !== "values") {
|
|
return Reflect.get(insertBuilder, builderProperty, builderReceiver);
|
|
}
|
|
return (...values: unknown[]) => {
|
|
const valued = insertBuilder.values(...values);
|
|
return new Proxy(valued, {
|
|
get(returningBuilder, returningProperty, returningReceiver) {
|
|
if (returningProperty !== "returning") {
|
|
return Reflect.get(returningBuilder, returningProperty, returningReceiver);
|
|
}
|
|
return (...returningArgs: unknown[]) => {
|
|
insertReached();
|
|
return release.then(() => returningBuilder.returning(...returningArgs));
|
|
};
|
|
},
|
|
});
|
|
};
|
|
},
|
|
});
|
|
};
|
|
},
|
|
});
|
|
const commentPromise = issuesSvc.addComment(
|
|
fixture.issueId,
|
|
"Comment inserted under a caller-owned transaction.",
|
|
{ userId: "board-user" },
|
|
{ authorType: "user" },
|
|
observeInserts(tx),
|
|
);
|
|
try {
|
|
await Promise.race([reached, commentPromise.then(() => { throw new Error("Comment insert was not intercepted"); })]);
|
|
await expect(db.transaction(async (observer) => {
|
|
await observer.execute(sql`
|
|
select id from issues
|
|
where id = ${fixture.issueId}
|
|
for update nowait
|
|
`);
|
|
})).rejects.toMatchObject({ cause: { code: "55P03" } });
|
|
} finally {
|
|
releaseInsert();
|
|
await commentPromise;
|
|
}
|
|
});
|
|
});
|
|
|
|
it("reuses human-addressed connection intents across runs and ordinary comments", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Connection intent");
|
|
const agentId = randomUUID();
|
|
const firstRunId = randomUUID();
|
|
const secondRunId = randomUUID();
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Researcher", "researcher"));
|
|
await db.update(issues).set({ assigneeAgentId: agentId, status: "in_progress" }).where(eq(issues.id, issueId));
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: firstRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
responsibleUserId: "user-board",
|
|
contextSnapshot: { issueId },
|
|
},
|
|
{
|
|
id: secondRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
responsibleUserId: "user-board",
|
|
contextSnapshot: { issueId },
|
|
},
|
|
]);
|
|
const payload = {
|
|
version: 1 as const,
|
|
serviceSlug: "notion",
|
|
serviceName: "Notion",
|
|
serviceLogoUrl: null,
|
|
requestingAgentId: agentId,
|
|
requestingAgentName: "Researcher",
|
|
phase: "requested" as const,
|
|
};
|
|
const first = await interactionsSvc.createConnectionIntent(
|
|
{ id: issueId, companyId },
|
|
{
|
|
payload,
|
|
sourceRunId: firstRunId,
|
|
addresseeUserId: "user-board",
|
|
idempotencyKey: `connection-intent:${firstRunId}:notion`,
|
|
},
|
|
);
|
|
expect(first).toMatchObject({
|
|
kind: "connection_intent",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
addresseeUserId: "user-board",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
payload,
|
|
});
|
|
const repeated = await interactionsSvc.createConnectionIntent(
|
|
{ id: issueId, companyId },
|
|
{
|
|
payload,
|
|
sourceRunId: firstRunId,
|
|
addresseeUserId: "user-board",
|
|
idempotencyKey: `connection-intent:${firstRunId}:notion`,
|
|
},
|
|
);
|
|
expect(repeated.id).toBe(first.id);
|
|
|
|
const newer = await interactionsSvc.createConnectionIntent(
|
|
{ id: issueId, companyId },
|
|
{
|
|
payload,
|
|
sourceRunId: secondRunId,
|
|
addresseeUserId: "user-board",
|
|
idempotencyKey: `connection-intent:${secondRunId}:notion`,
|
|
},
|
|
);
|
|
expect(newer.id).toBe(first.id);
|
|
expect(await interactionsSvc.getById(first.id)).toMatchObject({ status: "pending" });
|
|
|
|
const [expiredByComment] = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
|
|
{ id: issueId, companyId },
|
|
{
|
|
id: randomUUID(),
|
|
createdAt: new Date(Date.now() + 1_000),
|
|
authorUserId: "user-board",
|
|
createdByRunId: null,
|
|
},
|
|
{ userId: "user-board" },
|
|
);
|
|
expect(expiredByComment).toBeUndefined();
|
|
});
|
|
|
|
it("persists addressees without allowing them to bypass human-only governance", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction");
|
|
const creatorAgentId = randomUUID();
|
|
const addresseeAgentId = randomUUID();
|
|
const unrelatedAgentId = randomUUID();
|
|
const addresseeRunId = randomUUID();
|
|
const unrelatedRunId = randomUUID();
|
|
const agentRows = [
|
|
{ id: creatorAgentId, name: "Creator" },
|
|
{ id: addresseeAgentId, name: "Addressee" },
|
|
{ id: unrelatedAgentId, name: "Unrelated" },
|
|
].map((agent) => ({
|
|
...agent,
|
|
companyId,
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
}));
|
|
await db.insert(agents).values(agentRows);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: addresseeRunId,
|
|
companyId,
|
|
agentId: addresseeAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date("2026-07-25T12:00:00.000Z"),
|
|
},
|
|
{
|
|
id: unrelatedRunId,
|
|
companyId,
|
|
agentId: unrelatedAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date("2026-07-25T12:01:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const input = {
|
|
kind: "ask_user_questions" as const,
|
|
resolverPolicy: "board_or_agents" as const,
|
|
addresseeAgentId,
|
|
continuationPolicy: "wake_assignee" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
};
|
|
const created = await interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
input,
|
|
{ agentId: creatorAgentId },
|
|
);
|
|
expect(created).toMatchObject({
|
|
addresseeAgentId,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
});
|
|
|
|
const answered = await interactionsSvc.answerQuestions(
|
|
{ id: issueId, companyId },
|
|
created.id,
|
|
{ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
|
{ agentId: addresseeAgentId, runId: addresseeRunId },
|
|
);
|
|
expect(answered).toMatchObject({
|
|
status: "answered",
|
|
addresseeAgentId,
|
|
resolvedByAgentId: addresseeAgentId,
|
|
resolvedByRunId: addresseeRunId,
|
|
});
|
|
|
|
const second = await interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{ ...input, idempotencyKey: "addressed:second" },
|
|
{ agentId: creatorAgentId },
|
|
);
|
|
await expect(interactionsSvc.answerQuestions(
|
|
{ id: issueId, companyId },
|
|
second.id,
|
|
{ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
|
{ agentId: unrelatedAgentId, runId: unrelatedRunId },
|
|
)).rejects.toMatchObject({
|
|
status: 403,
|
|
message: expect.stringContaining("addressed agent"),
|
|
});
|
|
|
|
const boardOnly = await interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
...input,
|
|
resolverPolicy: "board_only",
|
|
idempotencyKey: "addressed:board-only",
|
|
},
|
|
{ agentId: creatorAgentId },
|
|
);
|
|
await expect(interactionsSvc.answerQuestions(
|
|
{ id: issueId, companyId },
|
|
boardOnly.id,
|
|
{ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
|
{ agentId: addresseeAgentId, runId: addresseeRunId },
|
|
)).rejects.toMatchObject({
|
|
status: 403,
|
|
message: expect.stringContaining("human-only"),
|
|
});
|
|
|
|
await expect(interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{ ...input, addresseeAgentId: creatorAgentId },
|
|
{ agentId: creatorAgentId },
|
|
)).rejects.toMatchObject({
|
|
status: 422,
|
|
message: expect.stringContaining("themselves"),
|
|
});
|
|
});
|
|
|
|
it("cancels addressed interactions before deleting the addressee", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Deleted interaction addressee");
|
|
const creatorAgentId = randomUUID();
|
|
const addresseeAgentId = randomUUID();
|
|
const unrelatedAgentId = randomUUID();
|
|
const unrelatedRunId = randomUUID();
|
|
await db.insert(agents).values([
|
|
{ id: creatorAgentId, name: "Creator" },
|
|
{ id: addresseeAgentId, name: "Addressee" },
|
|
{ id: unrelatedAgentId, name: "Unrelated" },
|
|
].map((agent) => ({
|
|
...agent,
|
|
companyId,
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
})));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: unrelatedRunId,
|
|
companyId,
|
|
agentId: unrelatedAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date("2026-07-25T12:02:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
kind: "ask_user_questions",
|
|
resolverPolicy: "board_or_agents",
|
|
addresseeAgentId,
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
},
|
|
{ agentId: creatorAgentId },
|
|
);
|
|
|
|
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, addresseeAgentId));
|
|
await agentService(db).remove(addresseeAgentId);
|
|
|
|
const cancelled = await interactionsSvc.getById(created.id);
|
|
expect(cancelled).toMatchObject({
|
|
status: "cancelled",
|
|
addresseeAgentId: null,
|
|
resolvedByAgentId: null,
|
|
resolvedByRunId: null,
|
|
resolvedByUserId: null,
|
|
result: {
|
|
version: 1,
|
|
outcome: "addressee_deleted",
|
|
reason: "Cancelled because the addressed agent was deleted",
|
|
},
|
|
});
|
|
await expect(interactionsSvc.answerQuestions(
|
|
{ id: issueId, companyId },
|
|
created.id,
|
|
{ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
|
{ agentId: unrelatedAgentId, runId: unrelatedRunId },
|
|
)).rejects.toMatchObject({
|
|
status: 409,
|
|
message: "Interaction has already been resolved",
|
|
});
|
|
});
|
|
|
|
it.each(["paused", "pending_approval", "terminated"])(
|
|
"rejects %s interaction addressees",
|
|
async (status) => {
|
|
const { companyId, issueId } = await seedConfirmationIssue(`Reject ${status} addressee`);
|
|
const creatorAgentId = randomUUID();
|
|
const addresseeAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
{
|
|
id: creatorAgentId,
|
|
companyId,
|
|
name: "Creator",
|
|
role: "engineer",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: addresseeAgentId,
|
|
companyId,
|
|
name: "Unavailable addressee",
|
|
role: "engineer",
|
|
status,
|
|
},
|
|
]);
|
|
|
|
await expect(interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
kind: "ask_user_questions",
|
|
addresseeAgentId,
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
},
|
|
{ agentId: creatorAgentId },
|
|
)).rejects.toMatchObject({
|
|
status: 422,
|
|
message: expect.stringContaining("invokable agent"),
|
|
details: expect.objectContaining({ reason: status }),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("rejects interaction addressees with an invalid reporting chain", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Reject uninvokable addressee chain");
|
|
const creatorAgentId = randomUUID();
|
|
const managerAgentId = randomUUID();
|
|
const addresseeAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
{
|
|
id: creatorAgentId,
|
|
companyId,
|
|
name: "Creator",
|
|
role: "engineer",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: managerAgentId,
|
|
companyId,
|
|
name: "Terminated manager",
|
|
role: "manager",
|
|
status: "terminated",
|
|
},
|
|
{
|
|
id: addresseeAgentId,
|
|
companyId,
|
|
name: "Unavailable addressee",
|
|
role: "engineer",
|
|
status: "active",
|
|
reportsTo: managerAgentId,
|
|
},
|
|
]);
|
|
|
|
await expect(interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
kind: "ask_user_questions",
|
|
addresseeAgentId,
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
},
|
|
{ agentId: creatorAgentId },
|
|
)).rejects.toMatchObject({
|
|
status: 422,
|
|
message: expect.stringContaining("invokable agent"),
|
|
details: expect.objectContaining({
|
|
reason: "manager_terminated",
|
|
managerId: managerAgentId,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Persist thread interactions",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(agents).values(activeCodexAgentRow(assigneeAgentId, companyId, "CodexCoder", "engineer"));
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: 2,
|
|
responsibleUserId,
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "root",
|
|
title: "Create the root follow-up",
|
|
workMode: "planning",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
clientKey: "child",
|
|
parentClientKey: "root",
|
|
title: "Create the nested follow-up",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(created.status).toBe("pending");
|
|
|
|
const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(accepted.interaction.kind).toBe("suggest_tasks");
|
|
expect(accepted.interaction.status).toBe("accepted");
|
|
expect(accepted.interaction.result).toMatchObject({
|
|
version: 1,
|
|
createdTasks: [
|
|
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
|
|
expect.objectContaining({ clientKey: "child" }),
|
|
],
|
|
});
|
|
expect(accepted.createdIssues).toEqual([
|
|
expect.objectContaining({
|
|
assigneeAgentId,
|
|
status: "todo",
|
|
}),
|
|
expect.objectContaining({
|
|
assigneeAgentId: null,
|
|
status: "todo",
|
|
}),
|
|
]);
|
|
const createdIssueRows = await db
|
|
.select({
|
|
title: issues.title,
|
|
workMode: issues.workMode,
|
|
responsibleUserId: issues.responsibleUserId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId));
|
|
expect(createdIssueRows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }),
|
|
expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }),
|
|
]),
|
|
);
|
|
expect(createdIssueRows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ title: "Create the root follow-up", responsibleUserId }),
|
|
expect.objectContaining({ title: "Create the nested follow-up", responsibleUserId }),
|
|
]),
|
|
);
|
|
|
|
// Agent-authored suggestions nest one level: the nested suggestion would be
|
|
// a grandchild, so it lands beside its suggested parent and records it as origin.
|
|
const children = await issuesSvc.list(companyId, { parentId: issueId });
|
|
expect(children.map((child) => child.title).sort()).toEqual([
|
|
"Create the nested follow-up",
|
|
"Create the root follow-up",
|
|
]);
|
|
const rootFollowUp = children.find((child) => child.title === "Create the root follow-up")!;
|
|
const nestedFollowUp = children.find((child) => child.title === "Create the nested follow-up")!;
|
|
expect(nestedFollowUp.originIssueId).toBe(rootFollowUp.id);
|
|
expect(nestedFollowUp.requestDepth).toBe(4);
|
|
expect(await issuesSvc.list(companyId, { parentId: rootFollowUp.id })).toHaveLength(0);
|
|
|
|
const listed = await interactionsSvc.listForIssue(issueId);
|
|
expect(listed).toHaveLength(1);
|
|
expect(listed[0]?.status).toBe("accepted");
|
|
|
|
await expect(interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Interaction has already been resolved");
|
|
|
|
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
|
|
expect(childrenAfterDuplicateAccept).toHaveLength(2);
|
|
});
|
|
|
|
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Selectively persist thread interactions",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: 2,
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "root",
|
|
title: "Create the root follow-up",
|
|
},
|
|
{
|
|
clientKey: "child",
|
|
parentClientKey: "root",
|
|
title: "Create the nested follow-up",
|
|
},
|
|
{
|
|
clientKey: "sibling",
|
|
title: "Create the sibling follow-up",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedClientKeys: ["root"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(accepted.interaction.result).toMatchObject({
|
|
version: 1,
|
|
createdTasks: [
|
|
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
|
|
],
|
|
skippedClientKeys: ["child", "sibling"],
|
|
});
|
|
|
|
const children = await issuesSvc.list(companyId, { parentId: issueId });
|
|
expect(children).toHaveLength(1);
|
|
expect(children[0]?.title).toBe("Create the root follow-up");
|
|
});
|
|
|
|
it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Validate selective acceptance",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "root",
|
|
title: "Create the root follow-up",
|
|
},
|
|
{
|
|
clientKey: "child",
|
|
parentClientKey: "root",
|
|
title: "Create the nested follow-up",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await expect(
|
|
interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedClientKeys: ["child"],
|
|
}, {
|
|
userId: "local-board",
|
|
}),
|
|
).rejects.toThrow("requires its parent");
|
|
});
|
|
|
|
it("persists validated answers for ask_user_questions interactions", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Persist question answers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Question parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [
|
|
{ id: "phase-1", label: "Phase 1" },
|
|
{ id: "phase-2", label: "Phase 2" },
|
|
],
|
|
},
|
|
{
|
|
id: "extras",
|
|
prompt: "Optional extras",
|
|
selectionMode: "multi",
|
|
options: [
|
|
{ id: "tests", label: "Tests" },
|
|
{ id: "docs", label: "Docs" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const answered = await interactionsSvc.answerQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
answers: [
|
|
{ questionId: "scope", optionIds: [], otherText: "Custom Phase 1" },
|
|
{
|
|
questionId: "extras",
|
|
optionIds: ["docs", "tests", "docs"],
|
|
otherText: " Pair with release notes ",
|
|
},
|
|
],
|
|
summaryMarkdown: "Ship Phase 1 with tests and docs.",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(answered.status).toBe("answered");
|
|
expect(answered.result).toEqual({
|
|
version: 1,
|
|
answers: [
|
|
{ questionId: "scope", optionIds: [], otherText: "Custom Phase 1" },
|
|
{ questionId: "extras", optionIds: ["docs", "tests"], otherText: "Pair with release notes" },
|
|
],
|
|
summaryMarkdown: "Ship Phase 1 with tests and docs.",
|
|
});
|
|
|
|
await expect(interactionsSvc.answerQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
answers: [
|
|
{ questionId: "scope", optionIds: ["phase-2"] },
|
|
],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Interaction has already been resolved");
|
|
});
|
|
|
|
it("persists cancelled ask_user_questions interactions without answer data", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Cancel question answers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Question parent",
|
|
status: "in_review",
|
|
priority: "medium",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [
|
|
{ id: "phase-1", label: "Phase 1" },
|
|
{ id: "phase-2", label: "Phase 2" },
|
|
],
|
|
}],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const cancelled = await interactionsSvc.cancelQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
reason: "Not needed anymore",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(cancelled.status).toBe("cancelled");
|
|
expect(cancelled.result).toEqual({
|
|
version: 1,
|
|
answers: [],
|
|
cancelled: true,
|
|
cancellationReason: "Not needed anymore",
|
|
summaryMarkdown: null,
|
|
});
|
|
|
|
await expect(interactionsSvc.answerQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Interaction has already been resolved");
|
|
});
|
|
|
|
it("skips every durable interaction kind exactly once and retains partial item verdicts", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Universal composer Skip");
|
|
const inputs = [
|
|
{
|
|
kind: "suggest_tasks" as const,
|
|
payload: { version: 1 as const, tasks: [{ clientKey: "child", title: "Create child" }] },
|
|
},
|
|
{
|
|
kind: "ask_user_questions" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Scope?",
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "one", label: "One" }],
|
|
}],
|
|
},
|
|
},
|
|
{
|
|
kind: "request_confirmation" as const,
|
|
payload: { version: 1 as const, prompt: "Proceed?" },
|
|
},
|
|
{
|
|
kind: "request_checkbox_confirmation" as const,
|
|
payload: { version: 1 as const, prompt: "Select", options: [{ id: "one", label: "One" }] },
|
|
},
|
|
];
|
|
|
|
for (const input of inputs) {
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, input, { userId: "local-board" });
|
|
const skipped = await interactionsSvc.skipInteraction(
|
|
{ id: issueId, companyId, status: "in_progress" },
|
|
created.id,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
expect(skipped).toMatchObject({ status: "cancelled", result: { version: 1, outcome: "skipped" } });
|
|
if (skipped.kind === "ask_user_questions") {
|
|
expect(skipped.result).toMatchObject({ answers: [], cancelled: true });
|
|
}
|
|
await expect(interactionsSvc.skipInteraction(
|
|
{ id: issueId, companyId, status: "in_progress" },
|
|
created.id,
|
|
{},
|
|
{ userId: "local-board" },
|
|
)).rejects.toThrow("Interaction has already been resolved");
|
|
}
|
|
|
|
const verdicts = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_item_verdicts",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Review items",
|
|
items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }],
|
|
},
|
|
}, { userId: "local-board" });
|
|
await interactionsSvc.submitItemVerdicts(
|
|
{ id: issueId, companyId },
|
|
verdicts.id,
|
|
{ verdicts: [{ id: "one", verdict: "approve" }] },
|
|
{ userId: "local-board" },
|
|
);
|
|
const skippedVerdicts = await interactionsSvc.skipInteraction(
|
|
{ id: issueId, companyId, status: "in_progress" },
|
|
verdicts.id,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
expect(skippedVerdicts).toMatchObject({
|
|
status: "cancelled",
|
|
result: { outcome: "skipped", complete: false, items: [{ id: "one", verdict: "approve" }] },
|
|
});
|
|
});
|
|
|
|
it("expires ask_user_questions when a creator opts into comment supersede", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Question supersede");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(created).toMatchObject({
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
supersedeOnUserComment: true,
|
|
},
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
expect(expired[0]).toMatchObject({
|
|
id: created.id,
|
|
kind: "ask_user_questions",
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_comment",
|
|
commentId,
|
|
summaryMarkdown: null,
|
|
},
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
});
|
|
|
|
it("keeps ask_user_questions pending by default when the user sends a message", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out");
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const [created] = await db.select().from(issueThreadInteractions);
|
|
expect(created?.payload).toMatchObject({ supersedeOnUserComment: false });
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(Date.now() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(0);
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("does not supersede ask_user_questions for agent, system, or older user comments", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Question supersede exclusions");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
const createdAtMs = new Date(created.createdAt).getTime();
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs + 1_000),
|
|
authorUserId: null,
|
|
}, {
|
|
agentId: randomUUID(),
|
|
})).resolves.toHaveLength(0);
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs + 1_000),
|
|
authorUserId: null,
|
|
}, {})).resolves.toHaveLength(0);
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs - 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
})).resolves.toHaveLength(0);
|
|
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("repairs historical ask_user_questions superseded by later user comments idempotently", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Historical question supersede");
|
|
const commentId = randomUUID();
|
|
const createdAt = new Date("2026-05-18T12:00:00.000Z");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({ createdAt, updatedAt: createdAt })
|
|
.where(eq(issueThreadInteractions.id, created.id));
|
|
|
|
await db.insert(issueComments).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
authorType: "system",
|
|
body: "System-side progress note.",
|
|
createdAt: new Date("2026-05-18T12:00:30.000Z"),
|
|
updatedAt: new Date("2026-05-18T12:00:30.000Z"),
|
|
});
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-board",
|
|
authorType: "user",
|
|
body: "Please revise this first.",
|
|
createdAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
|
id: issueId,
|
|
companyId,
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
expect(expired[0]).toMatchObject({
|
|
id: created.id,
|
|
kind: "ask_user_questions",
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_comment",
|
|
commentId,
|
|
summaryMarkdown: null,
|
|
},
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
|
id: issueId,
|
|
companyId,
|
|
})).resolves.toEqual([]);
|
|
});
|
|
|
|
it("reuses the existing interaction when the same idempotency key is submitted twice", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Interaction dedupe",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer"));
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date("2026-04-20T12:00:00.000Z"),
|
|
});
|
|
|
|
const input = {
|
|
kind: "ask_user_questions" as const,
|
|
idempotencyKey: "run-1:questionnaire",
|
|
sourceRunId: runId,
|
|
continuationPolicy: "wake_assignee" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Pick a scope",
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "phase-2", label: "Phase 2" }],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const first = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, input, {
|
|
agentId,
|
|
});
|
|
|
|
const second = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, input, {
|
|
agentId,
|
|
});
|
|
|
|
expect(second.id).toBe(first.id);
|
|
expect(second.sourceRunId).toBe(runId);
|
|
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire");
|
|
});
|
|
|
|
it("supersedes older pending confirmations from the same agent without crossing agent or kind", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Newer confirmation supersedes older");
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"),
|
|
activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"),
|
|
]);
|
|
|
|
const older = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
idempotencyKey: "confirmation:first:older",
|
|
payload: { version: 1, prompt: "Approve the older draft?" },
|
|
}, { agentId: firstAgentId });
|
|
const otherKind = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_checkbox_confirmation",
|
|
idempotencyKey: "checkbox:first",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Select regions",
|
|
options: [{ id: "us", label: "US" }],
|
|
},
|
|
}, { agentId: firstAgentId });
|
|
const otherAgent = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
idempotencyKey: "confirmation:second",
|
|
payload: { version: 1, prompt: "Approve the second agent's draft?" },
|
|
}, { agentId: secondAgentId });
|
|
const replacement = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
idempotencyKey: "confirmation:first:newer",
|
|
payload: { version: 1, prompt: "Approve the newer draft?" },
|
|
}, { agentId: firstAgentId });
|
|
|
|
const interactions = await interactionsSvc.listForIssue(issueId);
|
|
expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({
|
|
status: "expired",
|
|
resolvedByAgentId: firstAgentId,
|
|
result: {
|
|
outcome: "superseded_by_newer_request",
|
|
supersededByInteractionId: replacement.id,
|
|
},
|
|
});
|
|
expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending");
|
|
expect(interactions.find((interaction) => interaction.id === otherAgent.id)?.status).toBe("pending");
|
|
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
|
|
});
|
|
|
|
it("supersedes an agent's own older pending ask_user_questions without crossing agent, kind, or issue", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Question supersedes older sibling");
|
|
const otherIssueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: otherIssueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Other issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
});
|
|
|
|
const probingAgentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
activeCodexAgentRow(probingAgentId, companyId, "Probing agent", "engineer"),
|
|
activeCodexAgentRow(otherAgentId, companyId, "Other agent", "engineer"),
|
|
]);
|
|
|
|
const question = (prompt: string) => ({
|
|
kind: "ask_user_questions" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [{
|
|
id: "q",
|
|
prompt,
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "opt", label: "Option" }],
|
|
}],
|
|
},
|
|
});
|
|
|
|
const older = await interactionsSvc.create(
|
|
{ id: issueId, companyId }, question("Older question"), { agentId: probingAgentId },
|
|
);
|
|
const otherKind = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Approve the draft?" },
|
|
}, { agentId: probingAgentId });
|
|
const otherAgentQuestion = await interactionsSvc.create(
|
|
{ id: issueId, companyId }, question("Other agent question"), { agentId: otherAgentId },
|
|
);
|
|
const otherIssueQuestion = await interactionsSvc.create(
|
|
{ id: otherIssueId, companyId }, question("Other issue question"), { agentId: probingAgentId },
|
|
);
|
|
const replacement = await interactionsSvc.create(
|
|
{ id: issueId, companyId }, question("Newer question"), { agentId: probingAgentId },
|
|
);
|
|
|
|
const interactions = await interactionsSvc.listForIssue(issueId);
|
|
expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({
|
|
status: "expired",
|
|
resolvedByAgentId: probingAgentId,
|
|
result: {
|
|
answers: [],
|
|
expirationReason: "superseded_by_newer_interaction",
|
|
supersededByInteractionId: replacement.id,
|
|
},
|
|
});
|
|
expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending");
|
|
// A different agent's pending question is untouched.
|
|
expect(interactions.find((interaction) => interaction.id === otherAgentQuestion.id)?.status).toBe("pending");
|
|
// A different kind from the same agent is untouched.
|
|
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
|
|
|
|
// The same agent's question on a different issue is untouched.
|
|
const otherIssueInteractions = await interactionsSvc.listForIssue(otherIssueId);
|
|
expect(otherIssueInteractions.find((interaction) => interaction.id === otherIssueQuestion.id)?.status)
|
|
.toBe("pending");
|
|
});
|
|
|
|
it("leaves exactly one pending ask_user_questions on the onboarding first task after probe cards and the real question arrive", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Chief of staff", "chief_of_staff"));
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Your first task",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Your first task",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
|
|
assigneeAgentId: agentId,
|
|
});
|
|
|
|
// Reproduces PAP-436: the assigned agent posts two throwaway schema probes
|
|
// (title/prompt/option "t"/"p"/"L") before the genuine question.
|
|
const probe = (prompt: string) => ({
|
|
kind: "ask_user_questions" as const,
|
|
payload: {
|
|
version: 1 as const,
|
|
questions: [{
|
|
id: "q",
|
|
prompt,
|
|
selectionMode: "single" as const,
|
|
options: [{ id: "L", label: "L" }],
|
|
}],
|
|
},
|
|
});
|
|
await interactionsSvc.create({ id: issueId, companyId }, probe("t"), { agentId });
|
|
await interactionsSvc.create({ id: issueId, companyId }, probe("p"), { agentId });
|
|
await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "focus",
|
|
prompt: "What would you like your team to focus on first?",
|
|
selectionMode: "single",
|
|
options: [
|
|
{ id: "mvp", label: "Ship the MVP" },
|
|
{ id: "bugs", label: "Fix bugs" },
|
|
],
|
|
}],
|
|
},
|
|
}, { agentId });
|
|
|
|
const interactions = await interactionsSvc.listForIssue(issueId);
|
|
const pendingQuestions = interactions.filter(
|
|
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "pending",
|
|
);
|
|
expect(pendingQuestions).toHaveLength(1);
|
|
expect(pendingQuestions[0]?.kind).toBe("ask_user_questions");
|
|
const [remaining] = pendingQuestions;
|
|
if (remaining?.kind === "ask_user_questions") {
|
|
expect(remaining.payload.questions[0]?.prompt).toContain("focus on first");
|
|
}
|
|
|
|
// Both probe cards auto-expired with the sibling-supersede reason.
|
|
const expiredQuestions = interactions.filter(
|
|
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "expired",
|
|
);
|
|
expect(expiredQuestions).toHaveLength(2);
|
|
for (const card of expiredQuestions) {
|
|
expect(card.result).toMatchObject({ expirationReason: "superseded_by_newer_interaction" });
|
|
}
|
|
});
|
|
|
|
it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep");
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"),
|
|
activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"),
|
|
]);
|
|
|
|
const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()];
|
|
const secondAgentIds = [randomUUID(), randomUUID()];
|
|
const checkboxId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values([
|
|
...firstAgentIds.map((id, index) => ({
|
|
id,
|
|
companyId,
|
|
issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
createdByAgentId: firstAgentId,
|
|
payload: { version: 1 as const, prompt: `First agent draft ${index + 1}` },
|
|
createdAt: new Date(`2026-07-01T12:0${index}:00.000Z`),
|
|
updatedAt: new Date(`2026-07-01T12:0${index}:00.000Z`),
|
|
})),
|
|
...secondAgentIds.map((id, index) => ({
|
|
id,
|
|
companyId,
|
|
issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
createdByAgentId: secondAgentId,
|
|
payload: { version: 1 as const, prompt: `Second agent draft ${index + 1}` },
|
|
createdAt: new Date(`2026-07-01T13:0${index}:00.000Z`),
|
|
updatedAt: new Date(`2026-07-01T13:0${index}:00.000Z`),
|
|
})),
|
|
{
|
|
id: checkboxId,
|
|
companyId,
|
|
issueId,
|
|
kind: "request_checkbox_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
createdByAgentId: firstAgentId,
|
|
payload: { version: 1, prompt: "Select one", options: [{ id: "one", label: "One" }] },
|
|
createdAt: new Date("2026-07-01T14:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T14:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations())
|
|
.resolves.toEqual({ expired: 3 });
|
|
await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations())
|
|
.resolves.toEqual({ expired: 0 });
|
|
|
|
const interactions = await interactionsSvc.listForIssue(issueId);
|
|
for (const id of firstAgentIds.slice(0, -1)) {
|
|
expect(interactions.find((interaction) => interaction.id === id)).toMatchObject({
|
|
status: "expired",
|
|
result: {
|
|
outcome: "superseded_by_newer_request",
|
|
supersededByInteractionId: firstAgentIds.at(-1),
|
|
},
|
|
});
|
|
}
|
|
expect(interactions.find((interaction) => interaction.id === firstAgentIds.at(-1))?.status).toBe("pending");
|
|
expect(interactions.find((interaction) => interaction.id === secondAgentIds[0])).toMatchObject({
|
|
status: "expired",
|
|
result: {
|
|
outcome: "superseded_by_newer_request",
|
|
supersededByInteractionId: secondAgentIds[1],
|
|
},
|
|
});
|
|
expect(interactions.find((interaction) => interaction.id === secondAgentIds[1])?.status).toBe("pending");
|
|
expect(interactions.find((interaction) => interaction.id === checkboxId)?.status).toBe("pending");
|
|
});
|
|
|
|
it("refuses to create an interaction on a closed issue", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Closed issue create guard");
|
|
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
|
|
await expect(interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: { version: 1, prompt: "Approve after close?" },
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toMatchObject({ status: 409 });
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issueId));
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts request_confirmation interactions without creating child issues", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Confirm a request",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Apply this plan?",
|
|
acceptLabel: "Apply",
|
|
rejectLabel: "Keep editing",
|
|
detailsMarkdown: "Creates follow-up work after acceptance.",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(created.kind).toBe("request_confirmation");
|
|
expect(created.status).toBe("pending");
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(accepted.createdIssues).toEqual([]);
|
|
expect(accepted.interaction).toMatchObject({
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
},
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
|
|
const requiresReason = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Decline only with a reason?",
|
|
rejectRequiresReason: true,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await expect(interactionsSvc.rejectInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
}, requiresReason.id, {}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("A decline reason is required for this confirmation");
|
|
});
|
|
|
|
it("reopens an in-review issue before waking the assignee after rejection", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue(
|
|
"Continue after review rejection",
|
|
);
|
|
const created = await interactionsSvc.create(
|
|
{ id: issueId, companyId },
|
|
{
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Continue the next turn?",
|
|
rejectLabel: "Continue work",
|
|
rejectRequiresReason: true,
|
|
target: {
|
|
type: "custom",
|
|
key: "warm_turn_1",
|
|
revisionId: "warm-turn-1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
userId: "local-board",
|
|
},
|
|
);
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "in_review" })
|
|
.where(eq(issues.id, issueId));
|
|
|
|
await interactionsSvc.rejectInteraction(
|
|
{
|
|
id: issueId,
|
|
companyId,
|
|
status: "in_review",
|
|
},
|
|
created.id,
|
|
{
|
|
reason: "Proceed with turn two.",
|
|
},
|
|
{
|
|
userId: "local-board",
|
|
},
|
|
);
|
|
|
|
await expect(issuesSvc.getById(issueId)).resolves.toMatchObject({
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("records an authorized agent as the review-confirmation resolver", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Agent review verdict");
|
|
const resolverAgentId = randomUUID();
|
|
const resolverRunId = randomUUID();
|
|
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId));
|
|
await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: resolverRunId,
|
|
companyId,
|
|
agentId: resolverAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
});
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
resolverPolicy: "anyone",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await recordReviewTransition({ companyId, issueId, interactionId: created.id });
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
agentId: resolverAgentId,
|
|
runId: resolverRunId,
|
|
resolverPolicyRestriction: "anyone",
|
|
});
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
status: "accepted",
|
|
resolvedByAgentId: resolverAgentId,
|
|
resolvedByRunId: resolverRunId,
|
|
resolvedByUserId: null,
|
|
});
|
|
});
|
|
|
|
it.each(["accept", "reject"] as const)(
|
|
"revalidates review policy under the issue lock before interaction %s",
|
|
async (action) => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue(`Locked ${action} policy`);
|
|
const resolverAgentId = randomUUID();
|
|
const resolverRunId = randomUUID();
|
|
await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: resolverRunId,
|
|
companyId,
|
|
agentId: resolverAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
});
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db.update(issues)
|
|
.set({ status: "in_review", reviewPolicy: "anyone" })
|
|
.where(eq(issues.id, issueId));
|
|
await recordReviewTransition({ companyId, issueId, interactionId: created.id });
|
|
|
|
let releasePolicyLock!: () => void;
|
|
let policyLockReady!: () => void;
|
|
const holdPolicyLock = new Promise<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(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: resolverRunId,
|
|
companyId,
|
|
agentId: resolverAgentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
});
|
|
|
|
const createdByResolver = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Approve your own request?" },
|
|
resolverPolicy: "anyone",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db.update(issueThreadInteractions)
|
|
.set({ createdByAgentId: resolverAgentId })
|
|
.where(eq(issueThreadInteractions.id, createdByResolver.id));
|
|
await recordReviewTransition({ companyId, issueId, interactionId: createdByResolver.id });
|
|
|
|
const createdBySameRun = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the same run?",
|
|
options: [{ id: "approve", label: "Approve" }],
|
|
},
|
|
resolverPolicy: "anyone",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db.update(issueThreadInteractions)
|
|
.set({ sourceRunId: resolverRunId })
|
|
.where(eq(issueThreadInteractions.id, createdBySameRun.id));
|
|
|
|
const issue = { id: issueId, companyId, goalId, projectId: null };
|
|
const actor = {
|
|
agentId: resolverAgentId,
|
|
runId: resolverRunId,
|
|
resolverPolicyRestriction: "not_creator" as const,
|
|
};
|
|
await expect(interactionsSvc.acceptInteraction(issue, createdByResolver.id, {}, actor))
|
|
.rejects.toThrow("requires a resolver other than its creator or creating run");
|
|
await db.update(activityLog).set({
|
|
details: {
|
|
status: "in_review",
|
|
reviewInteractionId: createdBySameRun.id,
|
|
_previous: { status: "in_progress" },
|
|
},
|
|
}).where(eq(activityLog.entityId, issueId));
|
|
await expect(interactionsSvc.acceptInteraction(issue, createdBySameRun.id, {
|
|
selectedOptionIds: ["approve"],
|
|
}, actor)).rejects.toThrow("requires a resolver other than its creator or creating run");
|
|
});
|
|
|
|
it("accepts request_checkbox_confirmation interactions with selected option ids", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation accept");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be deleted?",
|
|
options: [
|
|
{ id: "file-a", label: "a.txt" },
|
|
{ id: "file-b", label: "b.txt" },
|
|
{ id: "file-c", label: "c.txt" },
|
|
],
|
|
defaultSelectedOptionIds: ["file-a"],
|
|
minSelected: 0,
|
|
maxSelected: 2,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(created).toMatchObject({
|
|
kind: "request_checkbox_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
supersedeOnUserComment: false,
|
|
allowDeclineReason: true,
|
|
},
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedOptionIds: ["file-c", "file-a"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(accepted.createdIssues).toEqual([]);
|
|
expect(accepted.interaction).toMatchObject({
|
|
kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
selectedOptionIds: ["file-a", "file-c"],
|
|
},
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
});
|
|
|
|
it("enforces request_checkbox_confirmation selected option references and bounds", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation bounds");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Pick one or two options.",
|
|
options: [
|
|
{ id: "one", label: "One" },
|
|
{ id: "two", label: "Two" },
|
|
{ id: "three", label: "Three" },
|
|
],
|
|
defaultSelectedOptionIds: ["one"],
|
|
minSelected: 1,
|
|
maxSelected: 2,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedOptionIds: [],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
|
|
|
|
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedOptionIds: ["missing"],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
|
|
|
|
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
|
selectedOptionIds: ["one", "two", "three"],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Select no more than 2 checkbox confirmation option(s)");
|
|
});
|
|
|
|
it("expires request_checkbox_confirmation interactions when a user comments after creation", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Checkbox confirmation supersede");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be deleted?",
|
|
supersedeOnUserComment: true,
|
|
options: [{ id: "file-a", label: "a.txt" }],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
expect(expired[0]).toMatchObject({
|
|
id: created.id,
|
|
kind: "request_checkbox_confirmation",
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps checkbox confirmations pending by default after a user comment", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Checkbox card remains");
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Choose a file",
|
|
options: [{ id: "file-a", label: "a.txt" }],
|
|
},
|
|
}, { userId: "local-board" });
|
|
expect((created.payload as RequestCheckboxConfirmationPayload).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.each(["verdicts", "suggestions", "dismiss"] as const)(
|
|
"rolls back %s when external action authorization or receipt ownership changes",
|
|
async (kind) => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("External form transaction");
|
|
const issue = { id: issueId, companyId, projectId: null, goalId };
|
|
const interaction = await interactionsSvc.create(issue, kind === "verdicts"
|
|
? { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review", items: [{ id: "api", label: "API" }] } }
|
|
: { kind: "suggest_tasks", payload: { version: 1, tasks: [{ clientKey: "check", title: "Check the result" }] } },
|
|
{ userId: "local-board" });
|
|
const resolve = (options: import("../services/issue-thread-interactions.js").InteractionResolutionMutationOptions) => kind === "verdicts"
|
|
? interactionsSvc.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "api", verdict: "approve" }] }, { userId: "local-board" }, options)
|
|
: kind === "dismiss"
|
|
? interactionsSvc.rejectInteraction(issue, interaction.id, {}, { userId: "local-board" }, options)
|
|
: interactionsSvc.acceptInteraction(issue, interaction.id, { selectedClientKeys: ["check"] }, { userId: "local-board" }, options);
|
|
for (const hook of ["beforeResolveInTransaction", "afterResolveInTransaction"] as const) {
|
|
await expect(resolve({ [hook]: async () => { throw new Error("External action no longer authorized"); } })).rejects.toThrow("External action no longer authorized");
|
|
expect((await interactionsSvc.listForIssue(issueId)).find(item => item.id === interaction.id)).toMatchObject({ status: "pending", result: null });
|
|
expect(await db.select({ id: issues.id }).from(issues).where(eq(issues.parentId, issueId))).toEqual([]);
|
|
}
|
|
await resolve({});
|
|
const resolved = (await interactionsSvc.listForIssue(issueId)).find(item => item.id === interaction.id);
|
|
expect(resolved?.status).toBe(kind === "verdicts" ? "answered" : kind === "dismiss" ? "rejected" : "accepted");
|
|
const children = await db.select({ id: issues.id }).from(issues).where(eq(issues.parentId, issueId));
|
|
expect(children).toHaveLength(kind === "suggestions" ? 1 : 0);
|
|
},
|
|
);
|
|
|
|
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 as RequestItemVerdictsPayload).supersedeOnUserComment).toBe(false);
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
|
|
{ id: issueId, companyId },
|
|
{ id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "local-board" },
|
|
{ userId: "local-board" },
|
|
);
|
|
expect(expired).toHaveLength(0);
|
|
expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("returns accepted agent confirmations from review without resetting active work", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Confirm a request",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Senior Product Engineer", "engineer"));
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title: "Review the plan",
|
|
status: "in_review",
|
|
priority: "medium",
|
|
assigneeUserId: "local-board",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
acceptLabel: "Approve plan",
|
|
rejectLabel: "Ask for changes",
|
|
},
|
|
}, {
|
|
agentId,
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(accepted.continuationIssue).toEqual({
|
|
id: issueId,
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
status: "todo",
|
|
});
|
|
|
|
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
|
expect(updatedIssue).toMatchObject({
|
|
id: issueId,
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
});
|
|
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "in_review",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const agentOwnedConfirmation = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the next step?",
|
|
},
|
|
}, {
|
|
agentId,
|
|
});
|
|
|
|
const resumed = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), agentOwnedConfirmation.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(resumed.continuationIssue).toEqual({
|
|
id: issueId,
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
status: "todo",
|
|
});
|
|
|
|
const resumedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
|
expect(resumedIssue).toMatchObject({
|
|
id: issueId,
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
});
|
|
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "in_progress" })
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const activeConfirmation = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve while work is active?",
|
|
},
|
|
}, {
|
|
agentId,
|
|
});
|
|
|
|
const acceptedWhileActive = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), activeConfirmation.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(acceptedWhileActive.continuationIssue).toBeNull();
|
|
const activeIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
|
|
expect(activeIssue).toMatchObject({
|
|
id: issueId,
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
});
|
|
});
|
|
|
|
it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode");
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer"));
|
|
await db.update(issues).set({
|
|
status: "in_review",
|
|
workMode: "planning",
|
|
assigneeAgentId: agentId,
|
|
}).where(eq(issues.id, issueId));
|
|
const target = await attachPlanDocument(companyId, issueId);
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
payload: { version: 1, prompt: "Accept this plan?", target },
|
|
}, { agentId });
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" });
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: created.id,
|
|
status: "accepted",
|
|
result: { outcome: "accepted" },
|
|
});
|
|
expect(accepted.continuationIssue).toEqual({
|
|
id: issueId,
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
status: "todo",
|
|
workMode: "standard",
|
|
});
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0])).resolves.toMatchObject({
|
|
status: "todo",
|
|
workMode: "standard",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: null,
|
|
});
|
|
});
|
|
|
|
it("keeps Plan mode for non-plan and checkbox confirmations", async () => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations");
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer"));
|
|
await db.update(issues).set({
|
|
status: "in_review",
|
|
workMode: "planning",
|
|
assigneeAgentId: agentId,
|
|
}).where(eq(issues.id, issueId));
|
|
|
|
const nonPlan = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Accept this unrelated decision?" },
|
|
}, { agentId });
|
|
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), nonPlan.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
|
.resolves.toBe("planning");
|
|
|
|
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId));
|
|
const target = await attachPlanDocument(companyId, issueId);
|
|
const checkbox = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_checkbox_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Select approved plan sections",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
target,
|
|
},
|
|
}, { agentId });
|
|
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), checkbox.id, {
|
|
selectedOptionIds: ["phase-1"],
|
|
}, { userId: "local-board" });
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
|
.resolves.toBe("planning");
|
|
});
|
|
|
|
it.each(["ask", "standard"] as const)("keeps %s mode when accepting a plan confirmation", async (workMode) => {
|
|
const { companyId, goalId, issueId } = await seedConfirmationIssue(`Keep ${workMode} mode`);
|
|
await db.update(issues).set({ workMode }).where(eq(issues.id, issueId));
|
|
const target = await attachPlanDocument(companyId, issueId);
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Accept this plan?", target },
|
|
}, { userId: "local-board" });
|
|
|
|
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
|
.resolves.toBe(workMode);
|
|
});
|
|
|
|
it("keeps Plan mode when a plan confirmation is rejected", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Reject a plan");
|
|
await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId));
|
|
const target = await attachPlanDocument(companyId, issueId);
|
|
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Accept this plan?", target },
|
|
}, { userId: "local-board" });
|
|
|
|
const rejected = await interactionsSvc.rejectInteraction({ id: issueId, companyId }, created.id, {
|
|
reason: "Revise the plan",
|
|
}, { userId: "local-board" });
|
|
|
|
expect(rejected.status).toBe("rejected");
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
|
.resolves.toBe("planning");
|
|
});
|
|
|
|
it("expires request confirmations when a creator opts into comment supersede", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue();
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
supersedeOnUserComment: true,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(created).toMatchObject({
|
|
payload: {
|
|
supersedeOnUserComment: true,
|
|
},
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
expect(expired[0]).toMatchObject({
|
|
id: created.id,
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId,
|
|
},
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
});
|
|
|
|
it("keeps request confirmations pending by default when the user sends a message", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out");
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const [created] = await db.select().from(issueThreadInteractions);
|
|
expect(created?.payload).toMatchObject({ supersedeOnUserComment: false });
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(Date.now() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(0);
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("keeps legacy request confirmations pending when comment supersede was not stored", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Legacy confirmation without comment supersede flag");
|
|
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "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: "none",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
},
|
|
result: sql`'{"version":1,"outcome":"withdrawn_by_creator"}'::jsonb`,
|
|
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",
|
|
);
|
|
const closedIssue = { id: issueId, companyId, status: "done" };
|
|
await expect(interactionsSvc.withdrawInteraction(closedIssue, created.id, {}, {
|
|
userId: "local-board",
|
|
})).rejects.toThrow("Interaction is no longer actionable because the issue is closed");
|
|
});
|
|
|
|
it("does not supersede request confirmations for agent, system, or older user comments", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
const createdAtMs = new Date(created.createdAt).getTime();
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs + 1_000),
|
|
authorUserId: null,
|
|
}, {
|
|
agentId: randomUUID(),
|
|
})).resolves.toHaveLength(0);
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs + 1_000),
|
|
authorUserId: null,
|
|
}, {})).resolves.toHaveLength(0);
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(createdAtMs - 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
})).resolves.toHaveLength(0);
|
|
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("does not supersede request confirmations for run-originated comments even under user auth", async () => {
|
|
// Local-CLI agents post under user auth, so authorUserId is set nondeterministically.
|
|
// A comment carrying createdByRunId is machine-originated and must never expire a
|
|
// pending decision card.
|
|
const { companyId, issueId } = await seedConfirmationIssue("Run-originated comment supersede exclusion");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: randomUUID(),
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
createdByRunId: randomUUID(),
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(0);
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("repairs historical request confirmations superseded by later user comments idempotently", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede");
|
|
const commentId = randomUUID();
|
|
const createdAt = new Date("2026-05-18T12:00:00.000Z");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
supersedeOnUserComment: true,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({ createdAt, updatedAt: createdAt })
|
|
.where(eq(issueThreadInteractions.id, created.id));
|
|
|
|
await db.insert(issueComments).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
authorType: "system",
|
|
body: "System-side progress note.",
|
|
createdAt: new Date("2026-05-18T12:00:30.000Z"),
|
|
updatedAt: new Date("2026-05-18T12:00:30.000Z"),
|
|
});
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-board",
|
|
authorType: "user",
|
|
body: "Please revise this first.",
|
|
createdAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
|
id: issueId,
|
|
companyId,
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
expect(expired[0]).toMatchObject({
|
|
id: created.id,
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId,
|
|
},
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
|
id: issueId,
|
|
companyId,
|
|
})).resolves.toEqual([]);
|
|
});
|
|
|
|
it("does not repair historical confirmations from run-originated comments", async () => {
|
|
// The repair sweep must ignore machine-originated comments (createdByRunId set) even
|
|
// when authorUserId is present under user auth.
|
|
const { companyId, issueId } = await seedConfirmationIssue("Historical run-originated exclusion");
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const createdAt = new Date("2026-05-18T12:00:00.000Z");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Proceed with the current draft?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({ createdAt, updatedAt: createdAt })
|
|
.where(eq(issueThreadInteractions.id, created.id));
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "GiskardCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
await db.insert(issueComments).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "local-board",
|
|
authorType: "user",
|
|
createdByRunId: runId,
|
|
body: "SLA escalation relay posted from a heartbeat run.",
|
|
createdAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
|
|
});
|
|
|
|
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
|
id: issueId,
|
|
companyId,
|
|
})).resolves.toEqual([]);
|
|
const rows = await db.select().from(issueThreadInteractions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("pending");
|
|
});
|
|
|
|
it("expires request confirmations when the watched issue document revision changes", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
const nextRevisionId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Document target confirmation",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "v1",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v1",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Apply the plan document?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
revisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await db.insert(documentRevisions).values({
|
|
id: nextRevisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 2,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v2",
|
|
});
|
|
await db.update(documents).set({
|
|
latestBody: "v2",
|
|
latestRevisionId: nextRevisionId,
|
|
latestRevisionNumber: 2,
|
|
});
|
|
|
|
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: { code: "interaction_stale_target" },
|
|
});
|
|
|
|
const expired = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id);
|
|
expect(expired).toMatchObject({
|
|
id: created.id,
|
|
status: "expired",
|
|
payload: {
|
|
target: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId: nextRevisionId,
|
|
revisionNumber: 2,
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "stale_target",
|
|
staleTarget: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects creating a plan confirmation against a stale document revision and accepts the current one", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
const nextRevisionId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Stale plan confirmation",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId));
|
|
// Document is already at revision 2 — revision 1 is stale.
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "v2",
|
|
latestRevisionId: nextRevisionId,
|
|
latestRevisionNumber: 2,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(documentRevisions).values([
|
|
{
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v1",
|
|
},
|
|
{
|
|
id: nextRevisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 2,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v2",
|
|
},
|
|
]);
|
|
|
|
const staleTarget = {
|
|
type: "issue_document" as const,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
revisionId,
|
|
revisionNumber: 1,
|
|
};
|
|
|
|
// The revision check runs inside the create transaction (locking the
|
|
// document row), so a target pointing at an older revision is rejected
|
|
// atomically with the would-be insert rather than by a racy pre-check.
|
|
await expect(interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Apply the plan document?",
|
|
target: staleTarget,
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
message: expect.stringContaining("current issue document revision"),
|
|
});
|
|
|
|
const noRows = await db
|
|
.select({ id: issueThreadInteractions.id })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issueId));
|
|
expect(noRows).toHaveLength(0);
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Apply the plan document?",
|
|
target: {
|
|
...staleTarget,
|
|
revisionId: nextRevisionId,
|
|
revisionNumber: 2,
|
|
},
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
expect(created).toMatchObject({ status: "pending", kind: "request_confirmation" });
|
|
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
|
userId: "local-board",
|
|
})).resolves.toMatchObject({
|
|
interaction: { status: "accepted" },
|
|
continuationIssue: { id: issueId },
|
|
});
|
|
await expect(issueService(db).getById(issueId)).resolves.toMatchObject({
|
|
workMode: "standard",
|
|
});
|
|
});
|
|
|
|
it("preserves resolved request_item_verdicts items when the watched issue document revision changes", async () => {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
const nextRevisionId = randomUUID();
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Document target verdicts",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "v1",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v1",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_item_verdicts",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Review generated artifacts.",
|
|
items: [
|
|
{ id: "api", label: "API route" },
|
|
{ id: "docs", label: "Docs" },
|
|
],
|
|
target: {
|
|
type: "issue_document",
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
revisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.submitItemVerdicts({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
verdicts: [{ id: "api", verdict: "approve" }],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await db.insert(documentRevisions).values({
|
|
id: nextRevisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 2,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v2",
|
|
});
|
|
await db.update(documents).set({
|
|
latestBody: "v2",
|
|
latestRevisionId: nextRevisionId,
|
|
latestRevisionNumber: 2,
|
|
});
|
|
|
|
await expect(interactionsSvc.submitItemVerdicts({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
verdicts: [{ id: "docs", verdict: "approve" }],
|
|
}, {
|
|
userId: "local-board",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: { code: "interaction_stale_target" },
|
|
});
|
|
|
|
const stale = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id);
|
|
expect(stale).toMatchObject({
|
|
id: created.id,
|
|
status: "expired",
|
|
payload: {
|
|
target: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId: nextRevisionId,
|
|
revisionNumber: 2,
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "stale_target",
|
|
complete: false,
|
|
staleTarget: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId,
|
|
},
|
|
items: [
|
|
{
|
|
id: "api",
|
|
verdict: "approve",
|
|
resolvedByUserId: "local-board",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("workspace_finalize accept gate", () => {
|
|
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
|
|
|
|
async function seedAcceptGateFixture(options?: {
|
|
kind?: AcceptGateInteractionKind;
|
|
sourceRunId?: string | null;
|
|
sourceRunStatus?: string;
|
|
}) {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const sourceRunId =
|
|
options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID();
|
|
const foreignRunId = randomUUID();
|
|
const kind = options?.kind ?? "request_confirmation";
|
|
|
|
await db.insert(companies).values(interactionCompanyRow(companyId));
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Project",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Workspace",
|
|
sourceType: "local_path",
|
|
visibility: "default",
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer"));
|
|
const sourceRunStatus = options?.sourceRunStatus ?? "succeeded";
|
|
const sourceRunTerminal = sourceRunStatus !== "running";
|
|
await db.insert(heartbeatRuns).values([
|
|
...(sourceRunId
|
|
? [
|
|
{
|
|
id: sourceRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: sourceRunStatus,
|
|
startedAt: new Date("2026-05-23T21:55:00.000Z"),
|
|
finishedAt: sourceRunTerminal ? new Date("2026-05-23T22:05:00.000Z") : null,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
id: foreignRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "exec",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Accept gate fixture",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
goalId,
|
|
title: "Issue with execution workspace",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
});
|
|
|
|
const request = kind === "request_checkbox_confirmation"
|
|
? {
|
|
kind,
|
|
payload: {
|
|
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,
|
|
},
|
|
}
|
|
: {
|
|
kind,
|
|
payload: {
|
|
version: 1 as const,
|
|
prompt: "Mark this issue done?",
|
|
},
|
|
};
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
...request,
|
|
continuationPolicy: "wake_assignee",
|
|
sourceRunId,
|
|
}, {
|
|
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"] as const)(
|
|
"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: IssueThreadInteractionPayload = kind === "ask_user_questions"
|
|
? { version: 1, questions: [{ id: "question", prompt: "Which option?", selectionMode: "single", options: [{ id: "first", label: "First" }] }] }
|
|
: { version: 1, prompt: "Allow this action?", [kind]: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "example.write",
|
|
toolDisplayName: "Write", connectionId: randomUUID(), applicationId: randomUUID(),
|
|
appDisplayName: "Example", risk: "write", previewMarkdown: "Write one row", argumentsSummaryJson: "{}",
|
|
argumentsHash: "hash", expiresAt: "2099-01-01T00:00:00.000Z" } };
|
|
await db.update(issueThreadInteractions).set({
|
|
kind: kind === "ask_user_questions" ? kind : "request_confirmation", payload,
|
|
}).where(eq(issueThreadInteractions.id, interactionId));
|
|
expect((await interactionsSvc.listForIssue(issueId))[0].acceptanceBlocker).toBeUndefined();
|
|
expect((await interactionsSvc.getById(interactionId))?.acceptanceBlocker).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
|
await seedAcceptGateFixture();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: foreignRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
|
await seedAcceptGateFixture();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(
|
|
interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
message: expect.stringContaining(
|
|
"the run that created this interaction has not finished syncing its workspace",
|
|
),
|
|
details: { executionWorkspaceId, sourceRunId },
|
|
});
|
|
|
|
const row = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0]);
|
|
expect(row?.status).toBe("pending");
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows request_confirmation accept when the source run's workspace_finalize failed", async () => {
|
|
// A sync-back that ran and FAILED is terminal. The run will not retry it, so
|
|
// the confirmation must not stay wedged behind a misleading "still syncing"
|
|
// error — the user can merge/act manually.
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
|
await seedAcceptGateFixture({ sourceRunStatus: "failed" });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_config_freshness",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "failed",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows request_confirmation accept when a running workspace_finalize is stale (source run ended)", async () => {
|
|
// The source run died mid-finalize, leaving a `running` op that will never
|
|
// advance. A terminal/missing owner run means the record is stale, so the
|
|
// gate must not wait on it forever.
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
|
await seedAcceptGateFixture({ sourceRunStatus: "failed" });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "running",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("refuses request_confirmation accept while a workspace_finalize is running on a live source run", async () => {
|
|
// A genuinely in-flight sync-back on a still-active run must still block, so
|
|
// the confirmation cannot race commits that are actively being synced back.
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
|
await seedAcceptGateFixture({ sourceRunStatus: "running" });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "running",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
await expect(
|
|
interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
message: expect.stringContaining(
|
|
"the run that created this interaction has not finished syncing its workspace",
|
|
),
|
|
details: { executionWorkspaceId, sourceRunId },
|
|
});
|
|
});
|
|
|
|
it("allows request_confirmation accept when sourceRunId is null", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
|
await seedAcceptGateFixture({ sourceRunId: null });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: foreignRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
|
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: foreignRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{ selectedOptionIds: ["file-b"] },
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
result: {
|
|
selectedOptionIds: ["file-b"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
|
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(
|
|
interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{ selectedOptionIds: ["file-a"] },
|
|
{ userId: "local-board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
message: expect.stringContaining(
|
|
"the run that created this interaction has not finished syncing its workspace",
|
|
),
|
|
details: { executionWorkspaceId, sourceRunId },
|
|
});
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: sourceRunId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{ selectedOptionIds: ["file-a"] },
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => {
|
|
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
|
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null });
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: foreignRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
interactionId,
|
|
{ selectedOptionIds: ["file-a"] },
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: interactionId,
|
|
kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows accept of suggest_tasks even when no successful workspace_finalize has landed", async () => {
|
|
// suggest_tasks acceptance only creates follow-up issues; it does not
|
|
// approve code state or move the source workspace forward, so the
|
|
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
|
// carve-out the board cannot triage suggested tasks on an issue whose
|
|
// latest workspace op is still worktree_prepare.
|
|
const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
heartbeatRunId: foreignRunId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "follow-up",
|
|
title: "Created from suggest_tasks accept under prepare-only workspace",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
acceptIssueScope(issueId, companyId, goalId),
|
|
created.id,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: created.id,
|
|
kind: "suggest_tasks",
|
|
status: "accepted",
|
|
});
|
|
});
|
|
|
|
it("allows accept when the issue has no execution workspace attached", async () => {
|
|
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Mark this issue done?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const accepted = await interactionsSvc.acceptInteraction(
|
|
{ id: issueId, companyId, goalId: null, projectId: null },
|
|
created.id,
|
|
{},
|
|
{ userId: "local-board" },
|
|
);
|
|
|
|
expect(accepted.interaction).toMatchObject({
|
|
id: created.id,
|
|
status: "accepted",
|
|
});
|
|
});
|
|
});
|
|
});
|