Files
PaperClipAI/server/src/__tests__/issue-thread-interaction-routes.test.ts
T
DottaandPaperclip 246e1b38bf [codex] Include checkbox selections in continuation wakes (#8893)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue-thread interactions are the subsystem that lets board users
answer structured prompts and resume agent work
> - Checkbox confirmations capture a selected subset of known options,
then wake the assignee through continuation context
> - The wake context previously carried generic interaction metadata,
but not the accepted checkbox option ids or option labels
> - That meant the resumed agent could be woken after a checkbox
confirmation without seeing the board's selected options in the turn
context
> - This pull request carries accepted checkbox selections through the
interaction continuation wake snapshot and renders them into the adapter
wake prompt
> - The benefit is that agents can act on checkbox-confirmation
selections without refetching or guessing the user's choices

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. Searched public issues and
PRs for checkbox confirmation / continuation selection duplicates and
found no matching issue or PR.

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip or can reproduce
on `master`.
- [x] I have confirmed the error originates in Paperclip itself, not in
my agent adapter, API provider, or local configuration.

### What happened?

When a board user accepted a `request_checkbox_confirmation`
interaction, the assignee continuation wake included generic interaction
metadata but did not include the accepted checkbox selections. The
resumed agent turn therefore had no in-prompt access to the selected
option ids or option labels/descriptions.

### Expected behavior

When a `request_checkbox_confirmation` interaction is accepted, the
resumed agent wake should include the checkbox prompt, selected option
ids, and selected option labels/descriptions so the agent can act on the
selected subset directly.

### Steps to reproduce

1. Create an issue-thread `request_checkbox_confirmation` interaction
with multiple options and `continuationPolicy: "wake_assignee"`.
2. Accept the interaction with one or more selected options.
3. Inspect the continuation wake payload/prompt received by the
assignee.
4. Observe that the selected checkbox options are missing from the wake
context before this fix.

### Paperclip version or commit

Reproduced against the pre-fix code path on `master`; this PR head is
`9d17e70bce373e4850117f30c015c973c4b61789`.

### Deployment mode

Local dev (pnpm dev) / built from source.

### Installation method

Built from source (pnpm dev / pnpm build).

### Agent adapter(s) involved

Not adapter-specific (core bug). The Codex/local adapter path exposed
the missing wake context, but the missing field was in core interaction
continuation payload construction.

### Database mode

Embedded PGlite or external Postgres; the bug is not database-mode
specific.

### Access context

Both. Board users resolve the checkbox interaction, and agent bearer-key
wakes consume the continuation context.

### Node.js version

`v22.22.2`

### Operating system

Linux workspace.

### Relevant logs or output

No runtime exception is required to reproduce this. The failure mode is
missing `checkboxSelection` data in the resolved interaction
continuation wake payload.

### Relevant config

Not config-related.

### Additional context

Root cause: accepted checkbox interaction results were not extracted
into the continuation wake context, and adapter wake payload
normalization/rendering had no typed `checkboxSelection` field.

### Privacy checklist

- [x] I have reviewed all pasted output for PII (usernames, file paths,
API keys, tokens, company names) and redacted where necessary.

## What Changed

- Added checkbox selection extraction for accepted
`request_checkbox_confirmation` interactions and stored it in
interaction continuation wake context.
- Included checkbox selection context in heartbeat wake payload
construction.
- Added adapter-utils normalization and wake prompt rendering for
checkbox prompt, selected ids, and selected option details.
- Added regression coverage for route continuation context, heartbeat
payload summaries, and adapter wake prompt rendering.

## Verification

- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-context-summary.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts`
- `git diff --check origin/master...HEAD`
- `rg -n "checkbox|confirmation|interaction|wake|continuation"
ROADMAP.md`
- `gh pr list --state all --search "checkbox continuation selection
repo:paperclipai/paperclip" --json number,title,state,url,headRefName
--limit 20`
- `gh issue list --state all --search "checkbox confirmation options
repo:paperclipai/paperclip" --json number,title,state,url --limit 20`

## Risks

Low risk. The new payload field is additive, only populated for accepted
checkbox confirmations, and existing continuation fields are preserved.
The main compatibility risk is downstream code assuming an exact wake
payload shape; adapter normalization treats the new field as optional.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent based on GPT-5, with shell/tool execution in
this workspace.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-02 18:39:16 -05:00

1000 lines
33 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockInteractionService = vi.hoisted(() => ({
listForIssue: vi.fn(),
create: vi.fn(),
acceptInteraction: vi.fn(),
acceptSuggestedTasks: vi.fn(),
rejectInteraction: vi.fn(),
rejectSuggestedTasks: vi.fn(),
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
answerQuestions: vi.fn(),
cancelQuestions: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: CREATED_AGENT_ID, contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
}),
accessService: () => ({
canUser: vi.fn(async () => true),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
})),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => ({ id: CREATED_AGENT_ID, companyId: "company-1", permissions: null })),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
clampIssueListLimit: (value: number) => value,
ISSUE_LIST_DEFAULT_LIMIT: 500,
ISSUE_LIST_MAX_LIMIT: 1000,
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()),
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockInteractionService,
taskWatchdogService: () => ({
getActiveForIssue: vi.fn(async () => null),
upsertForIssue: vi.fn(),
disableForIssue: vi.fn(async () => null),
}),
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
function createIssue(overrides: Record<string, unknown> = {}) {
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
workMode: "standard",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1714",
title: "Persist interactions",
executionPolicy: null,
executionState: null,
hiddenAt: null,
...overrides,
};
}
async function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
describe.sequential("issue thread interaction routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../services/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(createIssue());
mockInteractionService.listForIssue.mockResolvedValue([]);
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
mockInteractionService.create.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: null,
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:00:00.000Z",
});
mockInteractionService.acceptInteraction.mockResolvedValue({
interaction: {
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-1",
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
createdTasks: [{ clientKey: "task-1", issueId: "child-1" }],
skippedClientKeys: ["task-2"],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [
{
id: "child-1",
assigneeAgentId: CREATED_AGENT_ID,
status: "todo",
},
],
});
mockInteractionService.rejectInteraction.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "rejected",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-1",
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
rejectionReason: "Not actionable enough",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
mockInteractionService.answerQuestions.mockResolvedValue({
id: "interaction-2",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "ask_user_questions",
status: "answered",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-2",
sourceRunId: "run-2",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Scope?",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
result: {
version: 1,
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:06:00.000Z",
resolvedAt: "2026-04-20T12:06:00.000Z",
});
mockInteractionService.cancelQuestions.mockResolvedValue({
id: "interaction-2",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "ask_user_questions",
status: "cancelled",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-2",
sourceRunId: "run-2",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Scope?",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
result: {
version: 1,
answers: [],
cancelled: true,
cancellationReason: null,
summaryMarkdown: null,
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: CREATED_AGENT_ID, contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
}));
});
it("lists and creates board-authored interactions", async () => {
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([
{
id: "interaction-expired",
kind: "ask_user_questions",
status: "expired",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
summaryMarkdown: null,
},
},
]);
mockInteractionService.listForIssue.mockResolvedValue([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
const app = await createApp();
const listRes = await request(app).get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions");
expect(listRes.status).toBe(200);
expect(listRes.body).toEqual([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_expired",
details: expect.objectContaining({
interactionId: "interaction-expired",
interactionKind: "ask_user_questions",
source: "issue.interactions.catchup_superseded_by_comment",
result: expect.objectContaining({
expirationReason: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}),
}),
}),
);
const createRes = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.send({
kind: "suggest_tasks",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
expect(createRes.status).toBe(201);
expect(mockInteractionService.create).toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_created",
details: expect.objectContaining({
interactionId: "interaction-1",
interactionKind: "suggest_tasks",
}),
}),
);
});
it("accepts suggested tasks and wakes created assignees plus the current assignee", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept")
.send({ selectedClientKeys: ["task-1"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-1",
{ selectedClientKeys: ["task-1"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
1,
CREATED_AGENT_ID,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({
issueId: "child-1",
mutation: "interaction_accept",
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
2,
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-1",
interactionStatus: "accepted",
sourceCommentId: "comment-1",
sourceRunId: "run-1",
}),
}),
);
});
it("answers questions and emits a continuation wake", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
.send({
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
});
expect(res.status).toBe(200);
expect(mockInteractionService.answerQuestions).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-2",
interactionKind: "ask_user_questions",
interactionStatus: "answered",
sourceCommentId: "comment-2",
sourceRunId: "run-2",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_answered",
}),
);
});
it("cancels question interactions and emits a continuation wake", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/cancel")
.send({});
expect(res.status).toBe(200);
expect(res.body.status).toBe("cancelled");
expect(mockInteractionService.cancelQuestions).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-2",
{},
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-2",
interactionKind: "ask_user_questions",
interactionStatus: "cancelled",
sourceCommentId: "comment-2",
sourceRunId: "run-2",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_cancelled",
}),
);
});
it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-3",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("accepts request checkbox confirmations with selected option ids and wakes the assignee", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_checkbox_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-checkbox",
payload: {
version: 1,
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt", description: "Generated build output" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-b"],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox/accept")
.send({ selectedOptionIds: ["file-b"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-checkbox",
{ selectedOptionIds: ["file-b"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-checkbox",
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
}),
contextSnapshot: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_accepted",
details: expect.objectContaining({
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("preserves accepted empty checkbox selections in assignee wake context", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox-empty",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_checkbox_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-checkbox",
payload: {
version: 1,
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt", description: "Temporary export" },
{ id: "file-b", label: "b.txt", description: "Generated build output" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: [],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox-empty/accept")
.send({ selectedOptionIds: [] });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
payload: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
}),
contextSnapshot: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-plan",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: "confirmation:issue:plan:revision",
sourceCommentId: null,
sourceRunId: "run-plan",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
documentId: "document-plan",
key: "plan",
revisionId: "revision-plan",
revisionNumber: 1,
},
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-plan/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
contextSnapshot: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-plan",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
planReviewInteraction: expect.objectContaining({
id: "interaction-plan",
kind: "request_confirmation",
status: "accepted",
acceptedTargetRevision: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
documentId: "document-plan",
key: "plan",
revisionId: "revision-plan",
revisionNumber: 1,
}),
result: expect.objectContaining({
outcome: "accepted",
}),
}),
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a plan document confirmation on a standard-work issue", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "standard" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-standard-plan",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: "confirmation:issue:plan:revision-standard",
sourceCommentId: null,
sourceRunId: "run-standard-plan",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
documentId: "document-plan",
key: "plan",
revisionId: "revision-standard",
revisionNumber: 2,
},
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-standard-plan/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
contextSnapshot: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-standard-plan",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
}),
);
});
it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
}));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-4",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-4",
payload: {
version: 1,
prompt: "Approve this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
continuationIssue: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
assigneeAgentId: CREATED_AGENT_ID,
assigneeUserId: null,
status: "todo",
},
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-4/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
CREATED_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-4",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
source: "request_confirmation_accept",
assigneeAgentId: CREATED_AGENT_ID,
assigneeUserId: null,
_previous: expect.objectContaining({
assigneeUserId: "local-board",
}),
}),
}),
);
});
it("does not emit a continuation wake when request confirmations are rejected", async () => {
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "rejected",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "rejected",
reason: "Needs changes",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject")
.send({ reason: "Needs changes" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => {
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
id: "interaction-5",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "rejected",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-5",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
rejectionReason: "Not now",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-5/reject")
.send({ reason: "Not now" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("allows agent-authored interaction creation and stamps the active run id", async () => {
const app = await createApp({
type: "agent",
agentId: CREATED_AGENT_ID,
companyId: "company-1",
runId: "run-1",
});
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.send({
kind: "suggest_tasks",
idempotencyKey: "interaction:task-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
expect(res.status).toBe(201);
expect(mockInteractionService.create).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
expect.objectContaining({
kind: "suggest_tasks",
idempotencyKey: "interaction:task-1",
sourceRunId: "run-1",
}),
{
agentId: CREATED_AGENT_ID,
userId: null,
},
);
});
});