mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server enforces an issue execution policy. It gates status changes while a review or approval stage is active. > - A board user could not cancel a task while an agent reviewer held the active stage. The API returned "Only the active reviewer or approver can advance the current execution stage". > - Board users own the board. They must always be able to edit and cancel any task. > - This pull request adds a board override to the execution stage transition. A board cancel clears the pending stage state and proceeds instead of raising an error. > - The benefit is that board users can always stop work, even while a review is pending or the stored stage state has drifted. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Description follows `bug_report.yml`: **What happened?** A board user set a task to `cancelled` while the task had an active reviewer stage held by an agent. The PATCH failed with "Task Update Failed... only the assigned approver or reviewer...". The same failure occurred when the stored stage state had drifted: the server silently forced the task back to `in_review` instead of honoring the cancel. **Expected behavior** A board user can always edit and cancel any task. A board cancel must clear the pending review stage and apply the requested status. **Steps to reproduce** 1. Create a task assigned to agent A with agent B configured as reviewer in the execution policy. 2. Let agent A hand the task off so the review stage becomes active. 3. As the board user, set the task status to Cancelled. 4. The update fails with the reviewer-only error. Related work: #5487 touches the execution-policy approver UI. It does not address the board cancel path. ## What Changed - `server/src/routes/issues.ts`: the issue PATCH route now passes `allowBoardOverride` when the actor is a board user. - `server/src/services/issue-execution-policy.ts`: when `allowBoardOverride` is set and the requested status is not `in_review` or `in_progress`, the transition clears `executionState` and proceeds. This applies both while a stage decision is pending and when the stage state has drifted, so a board cancel is no longer rejected or silently flipped back to `in_review`. - Reviewer gating is unchanged for everyone else: a board user who is the active participant still uses the normal approve / request-changes flow, and non-participant agents still receive the 422 guard. - Assignee-only board updates on an `in_review` task keep the stage state coherent: reassigning to an eligible stage participant re-pends the stage with them as the current participant, while reassigning to a non-participant (or unassigning) dissolves the review back to `in_progress` instead of persisting an `in_review` issue with no execution state or an ineligible participant. - New unit tests and route tests cover board cancellation of an active review stage and of a drifted pending review, plus reviewer swap, non-participant reassignment, and unassignment during an active review. ## Verification - In `server/`: `pnpm exec vitest run src/__tests__/issue-execution-policy.test.ts src/__tests__/issue-execution-policy-routes.test.ts` — 2 files, 73/73 tests pass on top of current `master`. - In `server/`: `pnpm run typecheck` passes. ## Risks - Low risk. The override branch runs only for board actors and only for target statuses other than `in_review` and `in_progress`. Cancelling clears `executionState`, so a later reopen starts from a fresh stage state. Agent-facing flows and reviewer gating are unchanged. ## Model Used - Claude Fable 5 (Anthropic), model ID `claude-fable-5`, running in Claude Code (Claude Agent SDK) with extended thinking and agentic tool use. ## 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 - [x] I will address all Greptile and reviewer comments before requesting merge
2088 lines
69 KiB
TypeScript
2088 lines
69 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
|
|
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
|
|
|
const coderAgentId = "11111111-1111-4111-8111-111111111111";
|
|
const qaAgentId = "22222222-2222-4222-8222-222222222222";
|
|
const ctoAgentId = "33333333-3333-4333-8333-333333333333";
|
|
const ctoUserId = "cto-user";
|
|
const boardUserId = "board-user";
|
|
|
|
function makePolicy(
|
|
stages: Array<{ type: "review" | "approval"; participants: Array<{ type: "agent" | "user"; agentId?: string; userId?: string }> }>,
|
|
) {
|
|
return normalizeIssueExecutionPolicy({ stages })!;
|
|
}
|
|
|
|
function twoStagePolicy() {
|
|
return makePolicy([
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
{ type: "approval", participants: [{ type: "user", userId: ctoUserId }] },
|
|
]);
|
|
}
|
|
|
|
function reviewOnlyPolicy() {
|
|
return makePolicy([
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
]);
|
|
}
|
|
|
|
function approvalOnlyPolicy() {
|
|
return makePolicy([
|
|
{ type: "approval", participants: [{ type: "user", userId: ctoUserId }] },
|
|
]);
|
|
}
|
|
|
|
describe("normalizeIssueExecutionPolicy", () => {
|
|
it("returns null for null/undefined input", () => {
|
|
expect(normalizeIssueExecutionPolicy(null)).toBeNull();
|
|
expect(normalizeIssueExecutionPolicy(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns null when stages are empty", () => {
|
|
expect(normalizeIssueExecutionPolicy({ stages: [] })).toBeNull();
|
|
});
|
|
|
|
it("throws when all participants are invalid (missing agentId)", () => {
|
|
expect(() =>
|
|
normalizeIssueExecutionPolicy({
|
|
stages: [{ type: "review", participants: [{ type: "agent" }] }],
|
|
}),
|
|
).toThrow("Invalid execution policy");
|
|
});
|
|
|
|
it("deduplicates participants within a stage", () => {
|
|
const result = normalizeIssueExecutionPolicy({
|
|
stages: [
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: qaAgentId },
|
|
{ type: "agent", agentId: qaAgentId },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(result!.stages[0].participants).toHaveLength(1);
|
|
});
|
|
|
|
it("assigns UUIDs to stages and participants", () => {
|
|
const result = normalizeIssueExecutionPolicy({
|
|
stages: [
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
],
|
|
});
|
|
expect(result!.stages[0].id).toBeDefined();
|
|
expect(result!.stages[0].participants[0].id).toBeDefined();
|
|
});
|
|
|
|
it("always sets commentRequired to true", () => {
|
|
const result = normalizeIssueExecutionPolicy({
|
|
commentRequired: false,
|
|
stages: [
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
],
|
|
});
|
|
expect(result!.commentRequired).toBe(true);
|
|
});
|
|
|
|
it("defaults mode to normal", () => {
|
|
const result = normalizeIssueExecutionPolicy({
|
|
stages: [
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
],
|
|
});
|
|
expect(result!.mode).toBe("normal");
|
|
});
|
|
|
|
it("rejects approvalsNeeded values above 1", () => {
|
|
expect(() =>
|
|
normalizeIssueExecutionPolicy({
|
|
stages: [
|
|
{
|
|
type: "review",
|
|
approvalsNeeded: 2,
|
|
participants: [{ type: "agent", agentId: qaAgentId }],
|
|
},
|
|
],
|
|
}),
|
|
).toThrow("Invalid execution policy");
|
|
});
|
|
|
|
it("throws for invalid input", () => {
|
|
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
|
|
});
|
|
|
|
it("keeps monitor-only policies", () => {
|
|
const result = normalizeIssueExecutionPolicy({
|
|
monitor: {
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
externalRef: "https://example.test/deploy?token=secret",
|
|
},
|
|
stages: [],
|
|
});
|
|
expect(result).toMatchObject({
|
|
stages: [],
|
|
monitor: {
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
scheduledBy: "assignee",
|
|
externalRef: "[redacted]",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseIssueExecutionState", () => {
|
|
it("returns null for null/undefined", () => {
|
|
expect(parseIssueExecutionState(null)).toBeNull();
|
|
expect(parseIssueExecutionState(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns null for invalid shape", () => {
|
|
expect(parseIssueExecutionState({ status: "bogus" })).toBeNull();
|
|
});
|
|
|
|
it("parses a valid state", () => {
|
|
const state = parseIssueExecutionState({
|
|
status: "pending",
|
|
currentStageId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
});
|
|
expect(state).not.toBeNull();
|
|
expect(state!.status).toBe("pending");
|
|
});
|
|
});
|
|
|
|
describe("issue execution policy transitions", () => {
|
|
describe("happy path: executor → review → approval → done", () => {
|
|
const policy = twoStagePolicy();
|
|
|
|
it("routes executor completion into review", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Implemented the feature",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "review",
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
});
|
|
expect(result.decision).toBeUndefined();
|
|
});
|
|
|
|
it("carries loose review instructions on the pending handoff", () => {
|
|
const reviewInstructions = [
|
|
"Please focus on whether the migration path is reversible.",
|
|
"",
|
|
"- Check failure handling",
|
|
"- Call out any unclear operator instructions",
|
|
].join("\n");
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Implemented the migration",
|
|
reviewRequest: { instructions: reviewInstructions },
|
|
});
|
|
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
reviewRequest: { instructions: reviewInstructions },
|
|
});
|
|
});
|
|
|
|
it("clears loose review instructions with explicit null during a stage transition", () => {
|
|
const reviewStageId = policy.stages[0].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
reviewRequest: { instructions: "Old review request" },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_review",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Ready for review",
|
|
reviewRequest: null,
|
|
});
|
|
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
reviewRequest: null,
|
|
});
|
|
});
|
|
|
|
it("reviewer approves → advances to approval stage", () => {
|
|
const reviewStageId = policy.stages[0].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "QA signoff complete",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBeNull();
|
|
expect(result.patch.assigneeUserId).toBe(ctoUserId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "approval",
|
|
completedStageIds: [reviewStageId],
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
});
|
|
expect(result.decision).toMatchObject({
|
|
stageId: reviewStageId,
|
|
stageType: "review",
|
|
outcome: "approved",
|
|
});
|
|
});
|
|
|
|
it("lets a reviewer provide loose instructions for the next approval stage", () => {
|
|
const reviewStageId = policy.stages[0].id;
|
|
const approvalInstructions = "Please decide whether this is ready to ship, with any launch caveats.";
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
reviewRequest: { instructions: "Review the implementation details." },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "QA signoff complete",
|
|
reviewRequest: { instructions: approvalInstructions },
|
|
});
|
|
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
reviewRequest: { instructions: approvalInstructions },
|
|
});
|
|
});
|
|
|
|
it("approver approves → marks completed (allows done)", () => {
|
|
const reviewStageId = policy.stages[0].id;
|
|
const approvalStageId = policy.stages[1].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: approvalStageId,
|
|
currentStageIndex: 1,
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [reviewStageId],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: ctoUserId },
|
|
commentBody: "Approved, ship it",
|
|
});
|
|
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "completed",
|
|
completedStageIds: expect.arrayContaining([reviewStageId, approvalStageId]),
|
|
lastDecisionOutcome: "approved",
|
|
});
|
|
expect(result.decision).toMatchObject({
|
|
stageId: approvalStageId,
|
|
stageType: "approval",
|
|
outcome: "approved",
|
|
});
|
|
// status should NOT be overridden — caller can set done
|
|
expect(result.patch.status).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("changes requested flow", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
it("reviewer requests changes → returns to executor", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Needs another pass on edge cases",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
currentStageType: "review",
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
lastDecisionOutcome: "changes_requested",
|
|
});
|
|
expect(result.decision).toMatchObject({
|
|
stageId: reviewStageId,
|
|
stageType: "review",
|
|
outcome: "changes_requested",
|
|
});
|
|
});
|
|
|
|
it("executor re-submits after changes → returns to same review stage", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "changes_requested",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "changes_requested",
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Fixed edge cases",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("review-only policy (no approval stage)", () => {
|
|
const policy = reviewOnlyPolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
it("reviewer approval completes the policy", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "LGTM",
|
|
});
|
|
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "completed",
|
|
completedStageIds: [reviewStageId],
|
|
lastDecisionOutcome: "approved",
|
|
});
|
|
expect(result.decision).toMatchObject({
|
|
stageType: "review",
|
|
outcome: "approved",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("approval-only policy (no review stage)", () => {
|
|
const policy = approvalOnlyPolicy();
|
|
|
|
it("executor completion routes directly to approval", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Done",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeUserId).toBe(ctoUserId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageType: "approval",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("access control", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
it("non-participant cannot advance the active stage", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Trying to bypass review",
|
|
}),
|
|
).toThrow("Only the active reviewer or approver can advance");
|
|
});
|
|
|
|
it("board override can cancel an active review without recording an approval decision", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "cancelled",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
allowBoardOverride: true,
|
|
commentBody: "Cancelling this task",
|
|
});
|
|
|
|
expect(result.patch).toEqual({ executionState: null });
|
|
expect(result.decision).toBeUndefined();
|
|
expect(result.workflowControlledAssignment).toBeUndefined();
|
|
});
|
|
|
|
it("board override can cancel a drifted pending review without rebuilding the pending stage", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "blocked",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "cancelled",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
allowBoardOverride: true,
|
|
commentBody: "Cancelling this drifted task",
|
|
});
|
|
|
|
expect(result.patch).toEqual({ executionState: null });
|
|
expect(result.decision).toBeUndefined();
|
|
expect(result.workflowControlledAssignment).toBeUndefined();
|
|
});
|
|
|
|
it("board override reassignment to an eligible participant re-pends the stage", () => {
|
|
const multiReviewerPolicy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: qaAgentId },
|
|
{ type: "agent", agentId: ctoAgentId },
|
|
],
|
|
},
|
|
]);
|
|
const multiReviewerStageId = multiReviewerPolicy.stages[0].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: multiReviewerPolicy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: multiReviewerStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy: multiReviewerPolicy,
|
|
requestedAssigneePatch: { assigneeAgentId: ctoAgentId },
|
|
actor: { userId: boardUserId },
|
|
allowBoardOverride: true,
|
|
commentBody: "Swapping the reviewer",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
|
|
expect(result.patch.assigneeUserId).toBeNull();
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageId: multiReviewerStageId,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
});
|
|
expect(result.decision).toBeUndefined();
|
|
});
|
|
|
|
it("board override reassignment to a non-participant dissolves the review", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
|
actor: { userId: boardUserId },
|
|
allowBoardOverride: true,
|
|
commentBody: "Handing the task back",
|
|
});
|
|
|
|
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
|
|
expect(result.decision).toBeUndefined();
|
|
expect(result.workflowControlledAssignment).toBeUndefined();
|
|
});
|
|
|
|
it("board override unassignment dissolves the review instead of stranding in_review", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedAssigneePatch: { assigneeAgentId: null, assigneeUserId: null },
|
|
actor: { userId: boardUserId },
|
|
allowBoardOverride: true,
|
|
commentBody: "Unassigning the reviewer",
|
|
});
|
|
|
|
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
|
|
expect(result.decision).toBeUndefined();
|
|
expect(result.workflowControlledAssignment).toBeUndefined();
|
|
});
|
|
|
|
it("non-participant can still post non-advancing updates", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Just a note",
|
|
});
|
|
|
|
// No error — just no patch modifications
|
|
expect(result.patch).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("comment requirements", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
it("approval without comment throws", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "",
|
|
}),
|
|
).toThrow(/Approving a review or approval stage requires a comment.*same PATCH request.*prior comments are not considered/);
|
|
});
|
|
|
|
it("changes requested without comment throws", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: null,
|
|
}),
|
|
).toThrow(/Requesting changes requires a comment.*same PATCH request.*prior comments are not considered/);
|
|
});
|
|
|
|
it("whitespace-only comment is treated as empty", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: " ",
|
|
}),
|
|
).toThrow("requires a comment");
|
|
});
|
|
});
|
|
|
|
describe("policy removal mid-flow", () => {
|
|
it("clears execution state when policy removed and returns to executor", () => {
|
|
// Use a real UUID for currentStageId so parseIssueExecutionState succeeds
|
|
const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: stageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy: null,
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
});
|
|
|
|
expect(result.patch.executionState).toBeNull();
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
});
|
|
|
|
it("clears execution state without assignee change when not in_review", () => {
|
|
const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: {
|
|
status: "changes_requested",
|
|
currentStageId: stageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "changes_requested",
|
|
},
|
|
},
|
|
policy: null,
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch.executionState).toBeNull();
|
|
// Not in_review, so no status/assignee change
|
|
expect(result.patch.status).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("reopening from done/cancelled clears state", () => {
|
|
it("reopening a done issue clears execution state", () => {
|
|
const policy = twoStagePolicy();
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "done",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "completed",
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [policy.stages[0].id, policy.stages[1].id],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "approved",
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "todo",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
});
|
|
|
|
expect(result.patch.executionState).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("no-op transitions", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
it("non-done status change without review context is a no-op", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "blocked",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch).toEqual({});
|
|
});
|
|
|
|
it("coerces a malformed executor in_review patch into the first policy stage", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "in_review",
|
|
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reasserts the active stage when issue status drifted out of in_review", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("no policy and no state is a no-op", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
},
|
|
policy: null,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch).toEqual({});
|
|
});
|
|
|
|
it("does not auto-start workflow when policy is added to an already in_review issue", () => {
|
|
const reviewOnly = reviewOnlyPolicy();
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: boardUserId,
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
},
|
|
policy: reviewOnly,
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
});
|
|
|
|
expect(result.patch).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("multi-participant stages", () => {
|
|
it("selects the preferred participant when explicitly requested", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: qaAgentId },
|
|
{ type: "agent", agentId: ctoAgentId },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: { assigneeAgentId: ctoAgentId },
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Ready for review",
|
|
});
|
|
|
|
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
|
|
});
|
|
|
|
it("falls back to first participant when no preference given", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: qaAgentId },
|
|
{ type: "agent", agentId: ctoAgentId },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Ready for review",
|
|
});
|
|
|
|
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
|
});
|
|
|
|
it("excludes the return assignee from participant selection", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: coderAgentId },
|
|
{ type: "agent", agentId: qaAgentId },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Done",
|
|
});
|
|
|
|
// coderAgentId is the returnAssignee, so QA should be selected
|
|
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
|
|
});
|
|
|
|
it("skips a self-review-only stage and completes the workflow", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [{ type: "agent", agentId: coderAgentId }],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Done",
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
executionState: {
|
|
status: "completed",
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [policy.stages[0].id],
|
|
},
|
|
});
|
|
expect(result.patch.status).toBeUndefined();
|
|
expect(result.patch.assigneeAgentId).toBeUndefined();
|
|
});
|
|
|
|
it("skips a self-review-only review stage and advances to approval", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [{ type: "agent", agentId: coderAgentId }],
|
|
},
|
|
{
|
|
type: "approval",
|
|
participants: [{ type: "user", userId: ctoUserId }],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Done",
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [policy.stages[0].id],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("final stage completion terminates the policy (#7893)", () => {
|
|
function threeStagePolicy() {
|
|
return makePolicy([
|
|
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
|
|
{ type: "review", participants: [{ type: "agent", agentId: ctoAgentId }] },
|
|
{ type: "approval", participants: [{ type: "user", userId: ctoUserId }] },
|
|
]);
|
|
}
|
|
|
|
it("final-stage approval completes even when earlier completedStageIds are stale", () => {
|
|
const policy = threeStagePolicy();
|
|
const approvalStageId = policy.stages[2].id;
|
|
// completedStageIds reference stage ids from a previous version of the
|
|
// embedded policy (stage ids regenerate when the policy is re-sent or
|
|
// edited mid-flow); only the active final stage id still matches.
|
|
const staleStageIds = [
|
|
"99999999-9999-4999-8999-999999999991",
|
|
"99999999-9999-4999-8999-999999999992",
|
|
];
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: approvalStageId,
|
|
currentStageIndex: 2,
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: staleStageIds,
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "approved",
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: ctoUserId },
|
|
commentBody: "Approved, ship it",
|
|
});
|
|
|
|
// Must terminate the policy, not wrap around to the first stage.
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "completed",
|
|
completedStageIds: expect.arrayContaining([...staleStageIds, approvalStageId]),
|
|
lastDecisionOutcome: "approved",
|
|
});
|
|
expect(result.patch.status).toBeUndefined();
|
|
expect(result.patch.assigneeAgentId).toBeUndefined();
|
|
expect(result.decision).toMatchObject({
|
|
stageId: approvalStageId,
|
|
stageType: "approval",
|
|
outcome: "approved",
|
|
});
|
|
});
|
|
|
|
it("non-final stage approval still advances forward to the next stage", () => {
|
|
const policy = threeStagePolicy();
|
|
const firstStageId = policy.stages[0].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: firstStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "QA pass",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageId: policy.stages[1].id,
|
|
currentStageIndex: 1,
|
|
completedStageIds: [firstStageId],
|
|
});
|
|
});
|
|
|
|
it("final-stage changes requested still returns to the executor", () => {
|
|
const policy = threeStagePolicy();
|
|
const approvalStageId = policy.stages[2].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: approvalStageId,
|
|
currentStageIndex: 2,
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [policy.stages[0].id, policy.stages[1].id],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "approved",
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: ctoUserId },
|
|
commentBody: "Needs rework before release",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
currentStageId: approvalStageId,
|
|
lastDecisionOutcome: "changes_requested",
|
|
});
|
|
});
|
|
|
|
it("a completed execution state does not restart the workflow on done", () => {
|
|
const policy = threeStagePolicy();
|
|
// Completed state whose stage ids no longer match the current policy
|
|
// (e.g. policy re-sent with regenerated ids after the chain finished).
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "completed",
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [
|
|
"99999999-9999-4999-8999-999999999991",
|
|
"99999999-9999-4999-8999-999999999992",
|
|
"99999999-9999-4999-8999-999999999993",
|
|
],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "approved",
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: ctoUserId },
|
|
commentBody: "Closing out",
|
|
});
|
|
|
|
// No rewind to the first stage — the caller's done is allowed through.
|
|
expect(result.patch).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("changes requested with no return assignee", () => {
|
|
it("throws when requesting changes with no return assignee", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: null,
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Changes needed",
|
|
}),
|
|
).toThrow("no return assignee");
|
|
});
|
|
});
|
|
|
|
describe("approval stage changes requested → bounces back to executor", () => {
|
|
it("approver requests changes during approval stage", () => {
|
|
const policy = twoStagePolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
const approvalStageId = policy.stages[1].id;
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: ctoUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: approvalStageId,
|
|
currentStageIndex: 1,
|
|
currentStageType: "approval",
|
|
currentParticipant: { type: "user", userId: ctoUserId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [reviewStageId],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: ctoUserId },
|
|
commentBody: "Not happy with the approach, needs rework",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
currentStageType: "approval",
|
|
lastDecisionOutcome: "changes_requested",
|
|
});
|
|
expect(result.decision).toMatchObject({
|
|
stageId: approvalStageId,
|
|
stageType: "approval",
|
|
outcome: "changes_requested",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("user participants", () => {
|
|
it("handles user-type reviewer participant correctly", () => {
|
|
const policy = makePolicy([
|
|
{ type: "review", participants: [{ type: "user", userId: boardUserId }] },
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Done",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBeNull();
|
|
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
|
});
|
|
});
|
|
|
|
describe("policy edits while a stage is active", () => {
|
|
it("clears the active execution state when its stage is removed from the policy", () => {
|
|
const reviewAndApproval = twoStagePolicy();
|
|
const approvalOnly = approvalOnlyPolicy();
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: reviewAndApproval,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewAndApproval.stages[0].id,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy: approvalOnly,
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionState: null,
|
|
});
|
|
});
|
|
|
|
it("reassigns the active stage when the current participant is removed", () => {
|
|
const policy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [
|
|
{ type: "agent", agentId: qaAgentId },
|
|
{ type: "agent", agentId: ctoAgentId },
|
|
],
|
|
},
|
|
]);
|
|
const updatedPolicy = makePolicy([
|
|
{
|
|
type: "review",
|
|
participants: [{ type: "agent", agentId: ctoAgentId }],
|
|
},
|
|
]);
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: policy.stages[0].id,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy: {
|
|
...updatedPolicy,
|
|
stages: [{ ...updatedPolicy.stages[0], id: policy.stages[0].id }],
|
|
},
|
|
requestedStatus: undefined,
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
});
|
|
|
|
expect(result.patch).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: ctoAgentId,
|
|
assigneeUserId: null,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: policy.stages[0].id,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("monitor policy", () => {
|
|
it("schedules a one-shot monitor on an active agent-owned issue", () => {
|
|
const policy = normalizeIssueExecutionPolicy({
|
|
stages: [],
|
|
monitor: {
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
scheduledBy: "board",
|
|
},
|
|
})!;
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
monitorAttemptCount: 0,
|
|
monitorNextCheckAt: null,
|
|
monitorLastTriggeredAt: null,
|
|
monitorNotes: null,
|
|
monitorScheduledBy: null,
|
|
},
|
|
policy,
|
|
previousPolicy: null,
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
monitorExplicitlyUpdated: true,
|
|
});
|
|
|
|
expect(result.patch.monitorNextCheckAt).toEqual(new Date("2026-04-11T12:30:00.000Z"));
|
|
expect(result.patch.monitorScheduledBy).toBe("board");
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "idle",
|
|
monitor: {
|
|
status: "scheduled",
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
scheduledBy: "board",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("auto-clears a scheduled monitor when the issue moves to done", () => {
|
|
const policy = normalizeIssueExecutionPolicy({
|
|
stages: [],
|
|
monitor: {
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
scheduledBy: "assignee",
|
|
},
|
|
})!;
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "idle",
|
|
currentStageId: null,
|
|
currentStageIndex: null,
|
|
currentStageType: null,
|
|
currentParticipant: null,
|
|
returnAssignee: null,
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
monitor: {
|
|
status: "scheduled",
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
lastTriggeredAt: null,
|
|
attemptCount: 0,
|
|
notes: "Check deployment",
|
|
scheduledBy: "assignee",
|
|
clearedAt: null,
|
|
clearReason: null,
|
|
},
|
|
},
|
|
monitorAttemptCount: 0,
|
|
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
|
|
monitorLastTriggeredAt: null,
|
|
monitorNotes: "Check deployment",
|
|
monitorScheduledBy: "assignee",
|
|
},
|
|
policy,
|
|
previousPolicy: policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch.executionPolicy).toBeNull();
|
|
expect(result.patch.monitorNextCheckAt).toBeNull();
|
|
expect(result.patch.executionState).toMatchObject({
|
|
monitor: {
|
|
status: "cleared",
|
|
clearReason: "done",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects explicitly scheduling a monitor on an invalid issue state", () => {
|
|
const policy = normalizeIssueExecutionPolicy({
|
|
stages: [],
|
|
monitor: {
|
|
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
|
notes: "Check deployment",
|
|
},
|
|
})!;
|
|
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "blocked",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
},
|
|
policy,
|
|
previousPolicy: null,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
monitorExplicitlyUpdated: true,
|
|
}),
|
|
).toThrow("Monitor can only be scheduled");
|
|
});
|
|
|
|
it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => {
|
|
const policy = normalizeIssueExecutionPolicy({
|
|
stages: [],
|
|
monitor: {
|
|
nextCheckAt: "2099-04-11T12:30:00.000Z",
|
|
maxAttempts: 1,
|
|
scheduledBy: "assignee",
|
|
},
|
|
})!;
|
|
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
monitorAttemptCount: 1,
|
|
monitorNextCheckAt: null,
|
|
monitorLastTriggeredAt: null,
|
|
monitorNotes: null,
|
|
monitorScheduledBy: "assignee",
|
|
},
|
|
policy,
|
|
previousPolicy: null,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
monitorExplicitlyUpdated: true,
|
|
}),
|
|
).toThrow("Monitor bounds are already exhausted");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("review round circuit breaker", () => {
|
|
const policy = reviewOnlyPolicy();
|
|
const reviewStageId = policy.stages[0].id;
|
|
|
|
function reviewPendingIssue(overrides: Record<string, unknown> = {}, stateOverrides: Record<string, unknown> = {}) {
|
|
return {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
responsibleUserId: boardUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
...stateOverrides,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("counts agent-initiated changes-requested rounds on the hand-back", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(),
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Round one feedback",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
changesRequestedCount: 1,
|
|
});
|
|
});
|
|
|
|
it("carries the round count through the executor's resubmission", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_progress",
|
|
assigneeAgentId: coderAgentId,
|
|
assigneeUserId: null,
|
|
responsibleUserId: boardUserId,
|
|
executionPolicy: policy,
|
|
executionState: {
|
|
status: "changes_requested",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: "changes_requested",
|
|
changesRequestedCount: 2,
|
|
},
|
|
},
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
commentBody: "Addressed round two",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
changesRequestedCount: 2,
|
|
});
|
|
});
|
|
|
|
it("escalates the pending stage to the responsible human at the round cap", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue({}, { changesRequestedCount: 2 }),
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Round three feedback — still not converging",
|
|
});
|
|
|
|
// The decision is still recorded, but the stage stays pending with the
|
|
// responsible human as participant instead of bouncing to the executor.
|
|
expect(result.decision).toMatchObject({ outcome: "changes_requested" });
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBeNull();
|
|
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
});
|
|
});
|
|
|
|
it("keeps the escalated hold sticky across unrelated transitions", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
{ assigneeAgentId: null, assigneeUserId: boardUserId },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch.executionState).toBeUndefined();
|
|
expect(result.patch.assigneeAgentId).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a non-escalated actor advancing the stage during the hold", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
{ assigneeAgentId: null, assigneeUserId: boardUserId },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Agent trying to close it anyway",
|
|
}),
|
|
).toThrow("Only the escalated reviewer can advance the current execution stage");
|
|
});
|
|
|
|
it("rejects a non-escalated actor reassigning the issue during the hold", () => {
|
|
expect(() =>
|
|
applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
{ assigneeAgentId: null, assigneeUserId: boardUserId },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
|
actor: { agentId: coderAgentId },
|
|
}),
|
|
).toThrow("Only the escalated reviewer can advance the current execution stage");
|
|
});
|
|
|
|
it("re-asserts the hold when the assignee has drifted away from the escalated human", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
// Assignee drifted back to the agent reviewer while the state still
|
|
// records the escalated human as participant.
|
|
{ assigneeAgentId: qaAgentId, assigneeUserId: null },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: coderAgentId },
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_review");
|
|
expect(result.patch.assigneeAgentId).toBeNull();
|
|
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
});
|
|
});
|
|
|
|
it("resets the counter when the escalated human requests changes", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
{ assigneeAgentId: null, assigneeUserId: boardUserId },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
commentBody: "Human direction: do X instead",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
changesRequestedCount: 0,
|
|
});
|
|
});
|
|
|
|
it("lets the escalated human approve the stage", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue(
|
|
{ assigneeAgentId: null, assigneeUserId: boardUserId },
|
|
{
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 3,
|
|
},
|
|
),
|
|
policy,
|
|
requestedStatus: "done",
|
|
requestedAssigneePatch: {},
|
|
actor: { userId: boardUserId },
|
|
commentBody: "Good enough — shipping",
|
|
});
|
|
|
|
expect(result.decision).toMatchObject({ outcome: "approved" });
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "completed",
|
|
changesRequestedCount: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps handing back to the executor when no responsible human exists", () => {
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: reviewPendingIssue({ responsibleUserId: null }, { changesRequestedCount: 9 }),
|
|
policy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "Round ten feedback",
|
|
});
|
|
|
|
expect(result.patch.status).toBe("in_progress");
|
|
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "changes_requested",
|
|
changesRequestedCount: 10,
|
|
});
|
|
});
|
|
|
|
it("honors a policy maxReviewRounds override", () => {
|
|
const strictPolicy = normalizeIssueExecutionPolicy({
|
|
stages: [{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }],
|
|
maxReviewRounds: 1,
|
|
})!;
|
|
const stageId = strictPolicy.stages[0].id;
|
|
|
|
const result = applyIssueExecutionPolicyTransition({
|
|
issue: {
|
|
status: "in_review",
|
|
assigneeAgentId: qaAgentId,
|
|
assigneeUserId: null,
|
|
responsibleUserId: boardUserId,
|
|
executionPolicy: strictPolicy,
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: stageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
},
|
|
policy: strictPolicy,
|
|
requestedStatus: "in_progress",
|
|
requestedAssigneePatch: {},
|
|
actor: { agentId: qaAgentId },
|
|
commentBody: "First and only agent round",
|
|
});
|
|
|
|
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
|
expect(result.patch.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentParticipant: { type: "user", userId: boardUserId },
|
|
changesRequestedCount: 1,
|
|
});
|
|
});
|
|
});
|