Files
PaperClipAI/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
T
19c710fe93 fix(issues): record an interrupt comment as the user's Stop (#19149)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A board user can interrupt a running agent with a comment
(`interrupt: true`) to redirect the work
> - An interrupt is a Stop plus a message, but it did not record the
user's stop identity the way the Stop control does
> - The release path therefore treated an interrupted native run as a
failed run: it blocked the task and took the finalization lease from the
runner that was still settling
> - Workspace preservation then stopped the sandbox under that live
runner, so the runner reconnected for about 6 minutes and the stop could
not be verified afterward
> - The redirect message stayed saved, but it waited on workspace repair
that could not finish
> - This pull request records the board user's stop identity on
interrupts, the same as the Stop control and terminalization stops
> - The benefit is that an interrupt now settles like a Stop and the
redirect can continue

## Linked Issues or Issue Description

Refs #19122 (the Stop-then-redirect fix for the dispatch gate; this PR
covers the interrupt-comment path).

**What happened?**
A board user posted a comment with `interrupt: true` on a task with a
running native run (Daytona sandbox). The run was cancelled with
`operator_interrupted` and the native cancel was acknowledged. Within a
second, the task went to `blocked` and a
`native_continuation_requires_reconciliation` hold was created. The
runner transport then logged `state=reconnecting` for about 6 minutes,
and the run ended with `runner_process_termination_unconfirmed: ...
Daytona sandbox lease ... is no longer active`. Workspace recovery
started about 10–17 minutes later and failed with
`agent_stop_unconfirmed` / `provider_unavailable`. The redirect comment
stayed saved as a deferred wake waiting on `workspace_repair_required`.
No follow-up run started.

**Expected behavior**
An interrupt behaves like Stop followed by a message. The stopped runner
settles normally. The task is not blocked as a failure. The saved
message continues once the previous execution has stopped.

**Steps to reproduce**
1. Start a native run on a remote sandbox.
2. Post a board comment with `interrupt: true`.
3. Observe the task become `blocked` with a
`native-continuation:<runId>` hold, and the run's finalization row
rewritten to `terminal_failure` with no lease owner while the executor
is still settling.
4. Observe workspace preservation stop the sandbox, the runner transport
reconnect until its grace ends, and recovery fail to confirm the stop.

**Root cause**
`recordNativeTerminalRecoveryIfNeeded`
(`server/src/modules/wake-queue/adapters/postgres.ts`) skips intentional
stops through `isAcknowledgedNativeStop`. That check requires
`resultJson.cancelledByActorType === "user"` and `cancelledByUserId`.
The Stop control and terminalization stops record these fields.
`operatorInterruptCancelOptions` (`server/src/routes/issues.ts`)
recorded only `interruptedByActorType` / `interruptedByActorId`. So an
acknowledged interrupt took the failure branch. That branch blocks the
task and sets `nativeRunFinalizations` to `terminal_failure` with
`leaseOwner: null`. Cancelled-workspace preservation then saw a terminal
row with no owner and stopped the sandbox while the runner was still
settling.

**Paperclip version or commit**
Seen on staging at 69dea878c0. The touched
code is unchanged on current `master`.

**Agent adapter(s) involved**
Not adapter-specific (core bug). Seen with the Paperclip runner adapter
on a Daytona sandbox.

## What Changed

- `operatorInterruptCancelOptions` now records `cancelledByActorType:
"user"` and `cancelledByUserId` for a board user, in addition to the
existing interrupt fields. All three interrupt call sites (comment POST,
issue PATCH, queued-comment interrupt) use it.
- Route regression in `issue-update-comment-wakeup-routes.test.ts`: an
`interrupt: true` comment on a running native run sends these fields,
and the route's actual fields satisfy `isAcknowledgedNativeStop` with an
acknowledged native stop receipt.
- Release-path regression in `wake-queue/adapters/postgres.test.ts`: an
acknowledged interrupt Stop, while the executor still owns finalization,
releases the task without blocking it, creates no recovery hold, keeps
the finalization lease owner, and keeps the saved redirect deferred.

## Verification

- Red before the fix:
- The route test failed because `cancelRun` received no
`cancelledByActorType` / `cancelledByUserId`.
- The release-path test failed with the old interrupt fields: the task
was blocked, a hold was created, and the finalization owner was cleared.
- Green after the fix:
- `cd server && npx vitest run
src/__tests__/issue-update-comment-wakeup-routes.test.ts
src/modules/wake-queue`
- Related suites: `acknowledged-native-stop`,
`issue-queued-comments-routes`, `issue-comment-reopen-routes`,
`issue-execution-policy-routes`, `explicit-native-continuation`,
`heartbeat-process-recovery`.
- The staging incident was read through the read-only run, event, log,
recovery-action and wake diagnostics APIs.
- Not done: a live staging reproduction at the fixed revision.
Deployment is out of scope.

## Risks

- An interrupt now counts as the user's Stop everywhere the Stop control
does. This includes case-handoff supersession in `cancelRun`: an
interrupt supersedes a pending case handoff, like Stop.
- Only board users get the stop identity. Agent actors cannot use
`interrupt: true` (the route returns 403).
- Runs interrupted before this change keep their old fields. They are
not migrated.
- Not changed here, and suitable for follow-ups:
- `recordNativeTerminalRecoveryIfNeeded` can still clear a live
finalization `leaseOwner` for other failed native runs.
- The runner PRP ingress reconnect loop
(`server/src/realtime/runner-prp-outbound.ts`) does not stop early when
the sandbox lease is closed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, subagent code tracing, browser for
read-only staging diagnostics).

## 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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:58:51 -06:00

992 lines
43 KiB
TypeScript

import express from "express";
import request from "supertest";
import { isAcknowledgedNativeStop } from "../services/acknowledged-native-stop.js";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(async () => undefined),
publishActivity: vi.fn(),
}));
vi.mock("../services/fast-responses.js", () => ({
enqueueFastResponse: vi.fn(async () => undefined),
}));
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
const PREVIOUS_AGENT_ID = "22222222-2222-4222-8222-222222222222";
const MENTIONED_AGENT_ID = "33333333-3333-4333-8333-333333333333";
const SOURCE_RUN_ID = "44444444-4444-4444-8444-444444444444";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
getByIdForUpdate: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
getRelationSummaries: vi.fn(),
getDependencyReadiness: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
listReviewAttention: vi.fn(),
}));
const mockPauseGate = vi.hoisted(() => vi.fn(async (): Promise<Record<string, unknown> | null> => null));
const mockExecutionBlocker = vi.hoisted(() => vi.fn(async (): Promise<Record<string, unknown> | null> => null));
vi.mock("../services/execution-blocker.js", () => ({ getExecutionBlocker: mockExecutionBlocker }));
const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string; resource?: { issueId?: string } }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async (_runId: string): Promise<Record<string, unknown> | null> => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async (
_runId: string,
_reason: string,
_options: { resultJson: Record<string, unknown> },
): Promise<Record<string, unknown> | null> => null),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
deliverNativeQuestionResponse: vi.fn(async () => "not_native"),
nativeQuestionRunToCancel: vi.fn(async () => null),
validateNativeQuestionResponseInput: vi.fn(),
}));
vi.mock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: mockAccessDecide,
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueTreeControlService: () => ({ getActivePauseHoldGate: mockPauseGate }),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
questionResponseDeliveryService: () => ({
deliver: vi.fn(async () => undefined),
}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => ({
syncCommentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}),
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: mockAccessDecide,
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueTreeControlService: () => ({ getActivePauseHoldGate: mockPauseGate }),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
questionResponseDeliveryService: () => ({
deliver: vi.fn(async () => undefined),
}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
async function loadAppModules() {
return Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
}
async function createApp(transaction: (callback: (tx: Record<string, never>) => Promise<unknown>) => Promise<unknown> =
async (callback) => callback({ select: () => ({ from: () => ({ where: () => Object.assign(Promise.resolve([]), { for: async () => [{ id: "comment-3" }] }) }) }) } as any)) {
const [{ errorHandler }, { issueRoutes }] = await loadAppModules();
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
runId: req.header("x-paperclip-run-id") ?? null,
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({
transaction,
select: () => ({ from: () => ({ where: async () => [] }) }),
} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: null,
assigneeUserId: "local-board",
createdByUserId: "local-board",
identifier: "PAP-999",
title: "Wake test",
executionPolicy: null,
executionState: null,
hiddenAt: null,
...overrides,
};
}
describe("issue update comment wakeups", () => {
beforeAll(async () => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
await loadAppModules();
});
beforeEach(() => {
vi.resetAllMocks();
mockAccessDecide.mockImplementation(async (input) => ({ allowed: true, action: input.action, reason: "allow_explicit_grant", explanation: "Allowed by test grant." }));
mockPauseGate.mockResolvedValue(null);
mockExecutionBlocker.mockResolvedValue(null);
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById());
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.getDependencyReadiness.mockResolvedValue({ unresolvedBlockerCount: 1 });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
});
it.each(["post", "patch"] as const)("rejects %s board messages under an inherited pause before any mutation", async (method) => {
const existing = makeIssue();
mockIssueService.getById.mockResolvedValue(existing);
mockPauseGate.mockResolvedValue({ holdId: "hold-1", rootIssueId: "parent-1" });
const app = await createApp();
const res = method === "post"
? await request(app).post(`/api/issues/${existing.id}/comments`).send({ body: "go", reopen: true, interrupt: true })
: await request(app).patch(`/api/issues/${existing.id}`).send({ comment: "go", assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" });
expect(res.status).toBe(409);
expect(res.body.error).toBe("Task is paused. Resume it before sending a message.");
expect(res.body.details.rootIssueId).toBe("parent-1");
expect(mockPauseGate).toHaveBeenCalledWith(existing.companyId, existing.id);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["done", "cancelled"])(
"keeps %s assignment-only updates from waking completed work",
async (status) => {
// A parent may restore the assignee after releasing a completed child.
// That is recordkeeping, not a request to execute the child again.
const existing = makeIssue({ status, assigneeAgentId: null, assigneeUserId: null });
const updated = makeIssue({ status, assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({ assigneeAgentId: ASSIGNEE_AGENT_ID });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ status, assigneeAgentId: ASSIGNEE_AGENT_ID });
await new Promise((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
it.each(["done", "cancelled"])("stops a goal-less native run before a board marks its task %s", async (status) => {
const existing = makeIssue({ status: "in_progress", assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null, executionRunId: SOURCE_RUN_ID });
mockIssueService.getById.mockResolvedValue(existing);
mockHeartbeatService.getRun.mockResolvedValue({ id: SOURCE_RUN_ID, companyId: existing.companyId,
agentId: ASSIGNEE_AGENT_ID, runtimeMode: "native", status: "running",
nativeIssueId: existing.id, contextSnapshot: { issueId: existing.id } });
const effects: string[] = [];
mockHeartbeatService.cancelRun.mockImplementation(async () => {
effects.push("stop");
return { id: SOURCE_RUN_ID, companyId: existing.companyId, status: "cancelled" };
});
mockIssueService.update.mockImplementation(async () => {
effects.push("update");
return { ...existing, status, executionRunId: null };
});
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({ status });
expect(res.status).toBe(200);
expect(effects).toEqual(["stop", "update"]);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledOnce();
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(SOURCE_RUN_ID,
"Cancelled before issue terminalization", expect.objectContaining({
errorCode: "issue_terminalized",
resultJson: expect.objectContaining({ terminalizationStopConfirmed: true }),
eventPayload: { issueId: existing.id, status },
}));
const stopMetadata = mockHeartbeatService.cancelRun.mock.calls[0]![2].resultJson;
// Exercise the recovery consumer with the route's actual producer fields.
// A Stop receipt alone must not authorize this intentional-stop exception.
expect(isAcknowledgedNativeStop({ id: SOURCE_RUN_ID, companyId: existing.companyId,
status: "cancelled", nativeIssueId: existing.id, resultJson: { ...stopMetadata,
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: SOURCE_RUN_ID,
companyId: existing.companyId, issueId: existing.id, scope: "run",
reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
intentAuditId: "stop-intent", acknowledgementAuditId: "stop-acknowledgement" },
} })).toBe(true);
expect(res.body).toMatchObject({ status, executionRunId: null });
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("keeps the task open when a goal-less native run cannot confirm its stop", async () => {
const existing = makeIssue({ status: "in_progress", assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null, executionRunId: SOURCE_RUN_ID });
mockIssueService.getById.mockResolvedValue(existing);
mockHeartbeatService.getRun.mockResolvedValue({ id: SOURCE_RUN_ID, companyId: existing.companyId,
agentId: ASSIGNEE_AGENT_ID, runtimeMode: "native", status: "running",
nativeIssueId: existing.id, contextSnapshot: { issueId: existing.id } });
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockIssueService.update.mockResolvedValue({ ...existing, status: "done", executionRunId: null });
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({ status: "done" });
expect(res.status).toBe(409);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not inspect agent runs when closing a task without an execution owner", async () => {
const existing = makeIssue({ status: "in_progress", assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null, executionRunId: null });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue({ ...existing, status: "done" });
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({ status: "done" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockHeartbeatService.getActiveRunForAgent).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("preserves legacy completion without a native goal", async () => {
const existing = makeIssue({ status: "in_progress", assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null, executionRunId: SOURCE_RUN_ID });
mockIssueService.getById.mockResolvedValue(existing);
mockHeartbeatService.getRun.mockResolvedValue({ id: SOURCE_RUN_ID, companyId: existing.companyId,
agentId: ASSIGNEE_AGENT_ID, runtimeMode: "legacy", status: "running", contextSnapshot: { issueId: existing.id } });
mockIssueService.update.mockResolvedValue({ ...existing, status: "done", executionRunId: null });
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({ status: "done" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it.each([false, true])("still wakes explicitly reopened work (reassigned: %s)", async (reassigned) => {
const existing = makeIssue({ status: "done", assigneeAgentId: PREVIOUS_AGENT_ID, assigneeUserId: null });
const agentId = reassigned ? ASSIGNEE_AGENT_ID : PREVIOUS_AGENT_ID;
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: agentId, assigneeUserId: null }));
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({ status: "todo", ...(reassigned ? { assigneeAgentId: agentId } : {}) });
expect(res.status).toBe(200);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({
reason: reassigned ? "issue_assigned" : "issue_status_changed",
payload: expect.objectContaining({ issueId: existing.id }),
}));
});
it.each([null, "active"] as const)("includes the new comment and shared session key in assignment wakes (external: %s)", async (externalConversationState) => {
const existing = makeIssue({ externalConversationState });
const assigneeAdapterOverrides = { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } };
const updated = makeIssue({
externalConversationState,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
assigneeAdapterOverrides,
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: existing.id,
companyId: existing.companyId,
body: "write the whole thing",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
comment: "write the whole thing",
commentClientRequestId: "55555555-5555-4555-8555-555555555555",
assigneeAdapterOverrides,
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
assigneeAdapterOverrides,
}), expect.anything());
expect(mockIssueService.addComment).toHaveBeenCalledWith(existing.id, "write the whole thing", expect.anything(),
expect.objectContaining({ clientRequestId: "55555555-5555-4555-8555-555555555555" }), expect.anything());
expect(mockIssueService.update.mock.calls[0]?.[2]).toBe(mockIssueService.addComment.mock.calls[0]?.[4]);
// The route dispatches the wake after it sends the response, so wait for
// the fire-and-forget dispatch to settle. This keeps the wake inside this
// test and stops it from leaking into the next test as an extra call.
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({
issueId: existing.id,
commentId: "comment-1",
mutation: "update",
}),
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-1",
wakeCommentId: "comment-1",
...(externalConversationState ? { taskKey: existing.identifier } : {}),
source: "issue.update",
}),
}),
);
});
it("rolls back adapter settings if the accompanying comment fails", async () => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
let persistedModel = "gpt-6-sol";
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockImplementation(async (_id, fields) => {
persistedModel = fields.assigneeAdapterOverrides.adapterConfig.model;
return { ...existing, ...fields };
});
mockIssueService.addComment.mockRejectedValue(new Error("comment write failed"));
const transaction = vi.fn(async (callback: (tx: Record<string, never>) => Promise<unknown>) => {
const previousModel = persistedModel;
try {
return await callback({});
} catch (error) {
persistedModel = previousModel;
throw error;
}
});
const res = await request(await createApp(transaction))
.patch(`/api/issues/${existing.id}`)
.send({ comment: "use Astra", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra" } } });
expect(res.status).toBe(500);
expect(transaction).toHaveBeenCalledOnce();
expect(persistedModel).toBe("gpt-6-sol");
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("interrupts the active run and wakes the newly assigned agent with handoff context", async () => {
const existing = makeIssue({
assigneeAgentId: PREVIOUS_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-1",
status: "in_progress",
});
const updated = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-1",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-interrupt-agent",
issueId: existing.id,
companyId: existing.companyId,
body: "stop and hand this to CodexCoder",
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "running",
contextSnapshot: { issueId: existing.id },
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-1",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "cancelled",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
comment: "stop and hand this to CodexCoder",
interrupt: true,
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-1",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: existing.id,
}),
eventMessage: "run interrupted by board comment",
eventPayload: expect.objectContaining({
issueId: existing.id,
source: "issue_comment_interrupt",
}),
}),
);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({
issueId: existing.id,
commentId: "comment-interrupt-agent",
interruptedRunId: "run-1",
mutation: "update",
}),
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-interrupt-agent",
wakeCommentId: "comment-interrupt-agent",
interruptedRunId: "run-1",
source: "issue.update",
}),
}),
);
});
it("records an interrupt comment as the board user's acknowledged native Stop", async () => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, executionRunId: SOURCE_RUN_ID, status: "in_progress" });
const run = { id: SOURCE_RUN_ID, companyId: existing.companyId, agentId: ASSIGNEE_AGENT_ID, nativeIssueId: existing.id };
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({ id: "comment-interrupt-redirect", issueId: existing.id, companyId: existing.companyId, body: "Redirect" });
mockHeartbeatService.getRun.mockResolvedValue({ ...run, runtimeMode: "native", status: "running", contextSnapshot: { issueId: existing.id } });
mockHeartbeatService.cancelRun.mockResolvedValue({ ...run, status: "cancelled" });
const res = await request(await createApp()).post(`/api/issues/${existing.id}/comments`).send({ body: "Redirect", interrupt: true });
expect(res.status).toBe(201);
const stopMetadata = mockHeartbeatService.cancelRun.mock.calls[0]![2].resultJson;
expect(stopMetadata).toMatchObject({ operatorInterrupted: true, cancelledByActorType: "user", cancelledByUserId: "local-board" });
expect(isAcknowledgedNativeStop({ ...run, status: "cancelled", resultJson: { ...stopMetadata,
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: SOURCE_RUN_ID, companyId: existing.companyId,
issueId: existing.id, scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
intentAuditId: "stop-intent", acknowledgementAuditId: "stop-acknowledgement" } } })).toBe(true);
});
it("interrupts the active run without waking an agent when the handoff assigns a user", async () => {
const existing = makeIssue({
assigneeAgentId: PREVIOUS_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-2",
status: "in_progress",
});
const updated = makeIssue({
assigneeAgentId: null,
assigneeUserId: "local-board",
executionRunId: "run-2",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-interrupt-user",
issueId: existing.id,
companyId: existing.companyId,
body: "stop here, I will take it",
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "running",
contextSnapshot: { issueId: existing.id },
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-2",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "cancelled",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: null,
assigneeUserId: "local-board",
comment: "stop here, I will take it",
interrupt: true,
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-2",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: existing.id,
}),
eventMessage: "run interrupted by board comment",
}),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("defers the assignment wake when a structured goal owns the next run", async () => {
const existing = makeIssue({ assigneeAgentId: null, assigneeUserId: null, status: "todo" });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" }));
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({
assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, deferWakeForGoal: true,
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not allow goal wake deferral to suppress an unrelated status change", async () => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" });
mockIssueService.getById.mockResolvedValue(existing);
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({
assigneeAgentId: ASSIGNEE_AGENT_ID, status: "in_progress", deferWakeForGoal: true,
});
expect(res.status).toBe(400);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("wakes the assignee on comment-only issue updates", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_progress",
});
const updated = { ...existing };
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-2",
issueId: existing.id,
companyId: existing.companyId,
body: "please revise this",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
comment: "please revise this",
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: existing.id,
commentId: "comment-2",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-2",
wakeCommentId: "comment-2",
wakeReason: "issue_commented",
source: "issue.comment",
}),
}),
);
});
it.each([null, "active"] as const)("wakes the assignee on top-level board comments with external conversation %s", async (externalConversationState) => {
const existing = makeIssue({
externalConversationState,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-3",
issueId: existing.id,
companyId: existing.companyId,
body: "please handle this top-level thread comment",
});
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.send({
body: "please handle this top-level thread comment",
clientRequestId: "66666666-6666-4666-8666-666666666666",
});
expect(res.status).toBe(201);
if (externalConversationState) expect(mockRunnerGoalService.projection).not.toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalledWith(existing.id, "please handle this top-level thread comment", expect.anything(),
expect.objectContaining({ clientRequestId: "66666666-6666-4666-8666-666666666666", mirrorToSlack: true }), expect.anything());
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: existing.id,
commentId: "comment-3",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-3",
wakeCommentId: "comment-3",
...(externalConversationState ? { taskKey: existing.identifier } : {}),
wakeReason: "issue_commented",
source: "issue.comment",
}),
}),
);
});
it("retains the Slack session key when reopening strips the read-only conversation projection", async () => {
const existing = makeIssue({ externalConversationState: "active", status: "done", assigneeAgentId: ASSIGNEE_AGENT_ID });
const updated = makeIssue({ status: "todo", assigneeAgentId: ASSIGNEE_AGENT_ID });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({ id: "comment-reopen-slack", issueId: existing.id, companyId: existing.companyId, body: "Continue from Paperclip" });
const res = await request(await createApp()).post(`/api/issues/${existing.id}/comments`).send({ body: "Continue from Paperclip", reopen: true });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.objectContaining({
contextSnapshot: expect.objectContaining({ source: "issue.comment.reopen", taskKey: existing.identifier, wakeCommentId: "comment-reopen-slack" }),
}));
});
it.each(["post", "patch"] as const)("saves a %s comment without promoting a workspace-held task to Todo", async method => {
const existing = makeIssue({ status: "blocked", assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({ id: "saved-comment", issueId: existing.id, companyId: existing.companyId, body: "Continue after recovery." });
mockExecutionBlocker.mockResolvedValue({ workspaceRepairRequired: true });
const app = await createApp();
const res = method === "post"
? await request(app).post(`/api/issues/${existing.id}/comments`).send({ body: "Continue after recovery." })
: await request(app).patch(`/api/issues/${existing.id}`).send({ comment: "Continue after recovery." });
expect(res.status).toBe(method === "post" ? 201 : 200);
expect(mockIssueService.addComment).toHaveBeenCalledOnce();
expect(mockIssueService.update.mock.calls.every(([, patch]) => patch.status !== "todo")).toBe(true);
expect(mockExecutionBlocker).toHaveBeenCalled();
});
it.each((["post", "patch"] as const).flatMap((method) =>
["assignee_comment", "board_comment", "human_owned", "unassigned", "blocked_parent", "done_parent", "done_comment", ...(method === "patch" ? ["closing_comment"] : [])].map((scenario) => ({ method, scenario })),
))("keeps $method mentions as context for $scenario", async ({ method, scenario }) => {
const selfComment = ["assignee_comment", "blocked_parent", "done_parent", "done_comment", "closing_comment"].includes(scenario);
const existing = makeIssue({
assigneeAgentId: ["human_owned", "unassigned"].includes(scenario) ? null : ASSIGNEE_AGENT_ID,
assigneeUserId: scenario === "human_owned" ? "local-board" : null,
status: scenario === "blocked_parent" ? "blocked" : ["done_parent", "done_comment"].includes(scenario) ? "done" : "in_progress",
executionRunId: selfComment ? SOURCE_RUN_ID : null,
});
const child = makeIssue({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", identifier: "PAP-1000", parentId: existing.id,
assigneeAgentId: MENTIONED_AGENT_ID, assigneeUserId: null,
status: scenario === "done_parent" ? "done" : "in_progress",
executionRunId: "55555555-5555-4555-8555-555555555555",
});
const body = ["blocked_parent", "done_parent"].includes(scenario)
? `[@QA](agent://${MENTIONED_AGENT_ID}) is relevant; see [${child.identifier}](/PAP/issues/${child.identifier}).`
: `Finished the task. [@QA](agent://${MENTIONED_AGENT_ID}) has relevant context.`;
const originalComment = {
id: "context-note", issueId: existing.id, companyId: existing.companyId, body,
createdByRunId: selfComment ? SOURCE_RUN_ID : null,
};
mockIssueService.getById.mockImplementation(async (id) => id === child.id ? child : existing);
mockIssueService.getByIdentifier.mockResolvedValue(child);
mockIssueService.update.mockResolvedValue(scenario === "closing_comment" ? { ...existing, status: "done" } : existing);
mockIssueService.addComment.mockResolvedValue(originalComment);
mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [child], blocks: [] });
mockHeartbeatService.getRun.mockImplementation(async (id) => ({
id, companyId: existing.companyId, status: "running",
agentId: id === SOURCE_RUN_ID ? ASSIGNEE_AGENT_ID : MENTIONED_AGENT_ID,
contextSnapshot: { issueId: id === SOURCE_RUN_ID ? existing.id : child.id },
}));
const app = await createApp();
const req = method === "post"
? request(app).post(`/api/issues/${existing.id}/comments`)
: request(app).patch(`/api/issues/${existing.id}`);
if (selfComment) req.set("X-Paperclip-Run-Id", SOURCE_RUN_ID);
const res = await req.send(method === "post" ? { body } : { comment: body, ...(scenario === "closing_comment" ? { status: "done" } : {}) });
expect(res.status).toBe(method === "post" ? 201 : 200);
// Wake scheduling runs after sending the response. Drain its resolved
// mock promises before asserting that no extra work was dispatched.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockIssueService.addComment).toHaveBeenCalledTimes(1);
expect(mockIssueService.addComment.mock.calls[0].slice(0, 2)).toEqual([existing.id, body]);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(scenario === "board_comment" ? 1 : 0);
if (scenario === "board_comment") {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.objectContaining({
reason: "issue_commented", contextSnapshot: expect.objectContaining({ issueId: existing.id }),
}));
}
expect(mockIssueService.update.mock.calls.every(([id]) => id === existing.id)).toBe(true);
});
it("preserves an explicit resume on a run-authenticated top-level comment", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-self-resume",
issueId: existing.id,
companyId: existing.companyId,
body: "Resume intentionally.",
createdByRunId: SOURCE_RUN_ID,
});
mockHeartbeatService.getRun.mockResolvedValue({
id: SOURCE_RUN_ID,
companyId: existing.companyId,
agentId: ASSIGNEE_AGENT_ID,
status: "succeeded",
});
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.set("X-Paperclip-Run-Id", SOURCE_RUN_ID)
.send({ body: "Resume intentionally.", resume: true });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
contextSnapshot: expect.objectContaining({
resumeIntent: true,
}),
}),
);
});
it("tags the wake when a board comment supersedes the last review interaction", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_review",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-path",
issueId: existing.id,
companyId: existing.companyId,
body: "one more review note",
});
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([{
id: "interaction-review-path",
kind: "request_confirmation",
status: "expired",
}]);
mockIssueService.listReviewAttention.mockResolvedValue(new Map([[
existing.id,
{ state: "stalled", paths: [], reason: "review path consumed" },
]]));
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.send({ body: "one more review note" });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
payload: expect.objectContaining({
reviewPathLost: true,
reviewPathConsumedRef: "interaction-review-path",
reviewPathInstruction: expect.stringContaining("Restore a reviewer"),
}),
contextSnapshot: expect.objectContaining({
reviewPathLost: true,
reviewPathConsumedRef: "interaction-review-path",
}),
}),
);
});
});