mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations show requests in the feed and above the message composer. > - Earlier fixes kept dismissed questions in history, but excluded completion reviews and other request kinds. > - Closing those forms left a pending badge, and the server could still demand a response. > - This pull request saves human dismissal and removes the request from composer reminders and ordinary completion gates. > - People can continue the conversation and reopen the request from history. ## Linked Issues or Issue Description Related work: #15229 and #15392. **What happened?** Dismissing a completion review closed its form but left it counted as pending. It could return after reload, block a retry, or cause the agent to demand another response. Raw runtime requests could also lose their only usable control when dismissed. **Expected behavior** Dismissal leaves an accessible request in the feed and clears the composer form and pending count. It persists after reload. It does not submit an answer, approve an action, or wake the agent. **Steps to reproduce** 1. Open a task with a pending completion review or another composer request. 2. Dismiss the request, then reload the task. 3. Check that the composer stays clear and the feed entry can reopen the request. ## What Changed - Add nullable dismissal fields and a human-only dismissal endpoint with company, private-task access, resolver, and locked review-policy checks. - Persist saved-request dismissal on the server. Keep browser persistence for raw runtime requests and existing question dismissals. - Keep confirmations, choices, item verdicts, suggested tasks, and runtime requests accessible in the feed. - Exclude dismissed ordinary requests from completion and retry gates. Tell agents not to repeat deferred requests. - Preserve tool, secret, connection, plan, company-policy, and configured review authorization gates. Expire deferred non-question requests when their task completes. - Preserve the selected form when another dismissal finishes saving. Keep reopened reviews visible through delayed comment refreshes. - Add a completion-review Storybook test drive and update design, API, database, and capability documentation. ## Verification - `pnpm -r typecheck`: passed. The final UI changes also passed the UI typecheck. - `pnpm build`: passed. The final UI changes also passed the UI production build. - Focused UI and transcript tests: 550 passed across nine files after merging the current base branch. - Focused database, completion, and prompt tests: 105 passed across three files, including two review-policy regressions that failed before the fix. The tests reject a review requester excluded by current policy, preserve the pending gate on denial, and permit an independent reviewer to defer the request. - Route regression suite: 81 passed, including denial before reading or mutating a private-task request. - `pnpm check:token-gates`: passed. - Map and Storybook structural checks: passed; 30 map/catalog script tests passed. - Browser: used the production task-thread components in the completion-review fixture. Dismissed, reloaded, reopened from the feed, and dismissed again. No pending badge or composer takeover remained. Story callbacks simulate API saves; separate server tests cover persistence and permissions. - `pnpm test:run`: stopped after the general-server group reported one failure in `workspace-runtime.test.ts`: "returns a bounded structured conflict and exposes only same-company workspace references". The same test passed in isolation (1 passed, 178 skipped). The cause of the full-suite failure is not established. The full PR CI suite subsequently passed on e243cbb001; its two authorization review findings are fixed in9e1b4478b3. Fresh [full CI](https://github.com/paperclipai/paperclip/actions/runs/39871944846) passes on9e1b4478b3, including the previously failing server and browser shards. All 54 executed checks passed; two optional Storybook deployment/visual jobs were skipped. The legacy security status also passed. Fresh Greptile review is 5/5, the security scan passes, and no review threads remain open. This full CI run settles the merge gate; the earlier local workspace-suite failure remains recorded above because its cause was not established. - Additional verification: the original failing browser journey passed locally after the delayed-refresh fix. The expanded page-reload case also passes on9e1b4478b3(52.7s): dismiss, reload, reopen, approve, and receive the actual tool failure in the continuing task. It exposed a fixture reading an accepted request before tool execution finished; the fixture now waits for a stored execution result. The first reload attempt stopped on a still-loading page at the default five-second assertion; the reload now uses the suite’s navigation allowance. A controlled server-restart run timed out waiting for server health before the UI assertions. Local route-suite runs timed out during concurrent module loading; sequential imports passed all 81 tests, including the privacy regression. - Initial verification failures: a missing SDK build and a story fixture type error were fixed. PostgreSQL bootstrap then failed because the host had exhausted its semaphore allowance. After explicit operator approval, 64 verified abandoned PostgreSQL semaphore sets were reclaimed; no live process was stopped. The database regressions then passed. The raw-runtime regression exposed a missing feed control; the implementation now preserves that entry and the regression passes. ## Risks - Apply the additive database migration before the new server code. It adds two nullable columns and does not change existing decisions. - A dismissal is shared task attention state. Only a human with task read access and current resolver/review authority can persist it. Review policy is checked under the task lock. The operation is idempotent and records the dismissing user. - Raw runtime dismissal remains specific to a browser. Saved interaction dismissal persists across browsers. - A deferred review is not an approval. Tests retain protected-action and configured review gates. - The browser walkthrough uses a fixture. This branch has not been deployed to a live instance. ## Model Used OpenAI Codex, GPT-6, with code execution and browser tools. The exact backend model ID and context-window size are not exposed in this session. ## 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
3329 lines
121 KiB
TypeScript
3329 lines
121 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
|
const UNRELATED_AGENT_ID = "33333333-3333-4333-8333-333333333333";
|
|
const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222";
|
|
const ISSUE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const OTHER_ISSUE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
|
|
// Authenticated run ids are real UUIDs in production, and the per-run
|
|
// cross-issue influence counter fails closed on a malformed one before it can
|
|
// reach the database, so the fixtures must look like the real thing.
|
|
const RUN_1 = "d1111111-1111-4111-8111-111111111111";
|
|
const RUN_2 = "d2222222-2222-4222-8222-222222222222";
|
|
const RUN_3 = "d3333333-3333-4333-8333-333333333333";
|
|
const RUN_9 = "d9999999-9999-4999-8999-999999999999";
|
|
const RUN_CROSS_COMPANY = "dccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
const RUN_INVALID = "d0000000-0000-4000-8000-000000000000";
|
|
const RUN_LOW_TRUST = "d0000000-0000-4000-8000-000000000001";
|
|
const RUN_TASK_BRIDGE = "d0000000-0000-4000-8000-000000000002";
|
|
const RUN_WATCHDOG = "d0000000-0000-4000-8000-000000000003";
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
listReviewAttention: vi.fn(),
|
|
addComment: vi.fn(),
|
|
}));
|
|
|
|
const mockInteractionService = vi.hoisted(() => ({
|
|
listForIssue: vi.fn(),
|
|
getForIssue: vi.fn(),
|
|
create: vi.fn(),
|
|
acceptInteraction: vi.fn(),
|
|
acceptSuggestedTasks: vi.fn(),
|
|
rejectInteraction: vi.fn(),
|
|
rejectSuggestedTasks: vi.fn(),
|
|
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
|
|
expirePendingInteractionsForTerminalIssue: vi.fn(),
|
|
answerQuestions: vi.fn(),
|
|
submitItemVerdicts: vi.fn(),
|
|
cancelQuestions: vi.fn(),
|
|
skipInteraction: vi.fn(),
|
|
dismissInteraction: vi.fn(),
|
|
withdrawInteraction: vi.fn(),
|
|
recordSecretProposalExecutionResult: vi.fn(),
|
|
}));
|
|
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
wakeup: vi.fn(async () => undefined),
|
|
cancelRun: vi.fn(async () => null),
|
|
}));
|
|
const mockRequestNativeQuestionRunCancellation = vi.hoisted(() =>
|
|
vi.fn(async () => null as string | null)
|
|
);
|
|
|
|
vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
|
|
deliverNativeQuestionResponse: vi.fn(async () => "not_native"),
|
|
requestNativeQuestionRunCancellation: mockRequestNativeQuestionRunCancellation,
|
|
validateNativeQuestionResponseInput: vi.fn(),
|
|
}));
|
|
const mockQuestionResponseDeliveries = vi.hoisted(() => ({
|
|
deliver: vi.fn(async () => null),
|
|
}));
|
|
const mockResolveTaskWatchdogMutationScope = vi.hoisted(() => vi.fn(async () => ({ kind: "none" })));
|
|
const mockResolveCoreTrustPreset = vi.hoisted(() => vi.fn(() => ({ kind: "standard" })));
|
|
const mockRunAttribution = vi.hoisted(() => ({
|
|
value: {
|
|
companyId: "company-1",
|
|
agentId: "22222222-2222-4222-8222-222222222222",
|
|
responsibleUserId: null,
|
|
} as Record<string, unknown> | null,
|
|
}));
|
|
const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string }) => ({
|
|
allowed: true,
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation: "Allowed by test grant.",
|
|
})));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const mockReviewTransition = vi.hoisted(() => ({
|
|
value: null as null | { actorType: string; actorId: string; details: Record<string, unknown> },
|
|
}));
|
|
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 })));
|
|
|
|
// The per-run cross-issue influence counter runs in its own locking
|
|
// transaction, so the route harness has to model it: the locked run row (whose
|
|
// persisted context snapshot names the source issue) and the observation count.
|
|
const mockCrossIssueInfluence = vi.hoisted(() => ({
|
|
sourceIssueId: null as string | null,
|
|
priorCount: 0,
|
|
inserted: [] as Array<Record<string, unknown>>,
|
|
}));
|
|
const mockDbTransaction = vi.hoisted(() => vi.fn(async (callback: (tx: unknown) => unknown) => callback({
|
|
select: (selection: Record<string, unknown>) => ({
|
|
from: () => ({
|
|
where: () => {
|
|
if (Object.keys(selection).includes("count")) {
|
|
return {
|
|
then: (resolve: (rows: unknown[]) => unknown) =>
|
|
resolve([{ count: mockCrossIssueInfluence.priorCount }]),
|
|
};
|
|
}
|
|
const run = mockRunAttribution.value;
|
|
return {
|
|
for: () => ({
|
|
then: (resolve: (rows: unknown[]) => unknown) => resolve(run
|
|
? [{
|
|
id: run.runId ?? null,
|
|
companyId: run.companyId ?? null,
|
|
agentId: run.agentId ?? null,
|
|
responsibleUserId: run.responsibleUserId ?? null,
|
|
contextSnapshot: { issueId: mockCrossIssueInfluence.sourceIssueId },
|
|
}]
|
|
: []),
|
|
}),
|
|
};
|
|
},
|
|
}),
|
|
}),
|
|
insert: () => ({
|
|
values: async (value: Record<string, unknown>) => {
|
|
mockCrossIssueInfluence.inserted.push(value);
|
|
if (value.action === "issue.cross_issue_influence_observed") mockCrossIssueInfluence.priorCount += 1;
|
|
},
|
|
}),
|
|
})));
|
|
const mockDb = vi.hoisted(() => ({
|
|
select: mockDbSelect,
|
|
transaction: mockDbTransaction,
|
|
}));
|
|
|
|
vi.mock("@paperclipai/shared/telemetry", () => ({
|
|
trackAgentTaskCompleted: vi.fn(),
|
|
trackErrorHandlerCrash: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../telemetry.js", () => ({
|
|
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
|
}));
|
|
|
|
vi.mock("../services/task-watchdog-scope.js", () => ({
|
|
TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog",
|
|
resolveTaskWatchdogMutationScope: mockResolveTaskWatchdogMutationScope,
|
|
taskWatchdogScopeAllowsIssueMutation: vi.fn(async (_db, scope) => scope),
|
|
}));
|
|
|
|
vi.mock("../services/trust-preset-resolver.js", () => ({
|
|
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH: 100,
|
|
resolveCoreTrustPreset: mockResolveCoreTrustPreset,
|
|
}));
|
|
|
|
function registerModuleMocks() {
|
|
vi.doMock("../services/question-response-delivery.js", () => ({
|
|
questionResponseDeliveryService: () => mockQuestionResponseDeliveries,
|
|
}));
|
|
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 () => ({ 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,
|
|
companySkillService: () => ({
|
|
completeTestRunForIssue: vi.fn(async () => null),
|
|
}),
|
|
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),
|
|
revalidateMutationScope: vi.fn(async (scope: unknown) => ({ allowed: true, scope })),
|
|
}),
|
|
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 loadAppModules() {
|
|
const routes = await import("../routes/issues.js");
|
|
const middleware = await import("../middleware/index.js");
|
|
return [routes, middleware] as const;
|
|
}
|
|
|
|
async function createApp(actor: Record<string, unknown> = {
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}, routeOptions: Record<string, unknown> = {}) {
|
|
if (actor.type === "agent") {
|
|
mockRunAttribution.value = {
|
|
runId: actor.runId,
|
|
companyId: actor.companyId ?? "company-1",
|
|
agentId: actor.agentId,
|
|
responsibleUserId: actor.onBehalfOfUserId ?? null,
|
|
};
|
|
}
|
|
const [{ issueRoutes }, { errorHandler }] = await loadAppModules();
|
|
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, routeOptions));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
async function resolveMockInteraction(
|
|
args: unknown[],
|
|
interaction: Record<string, unknown>,
|
|
) {
|
|
const mutationOptions = args[4] as {
|
|
afterResolveInTransaction?: (
|
|
tx: Record<string, unknown>,
|
|
resolved: Record<string, unknown>,
|
|
) => Promise<void>;
|
|
} | undefined;
|
|
await mutationOptions?.afterResolveInTransaction?.({}, interaction);
|
|
return interaction;
|
|
}
|
|
|
|
describe("issue thread interaction routes", () => {
|
|
beforeAll(async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("../routes/issues.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
vi.doUnmock("../services/index.js");
|
|
registerModuleMocks();
|
|
await loadAppModules();
|
|
}, 60_000);
|
|
|
|
beforeEach(() => {
|
|
// Every mock here is a vi.hoisted() singleton. All 70+ tests share it.
|
|
// A queued mockResolvedValueOnce() value can outlive its own test and
|
|
// leak into a later, unrelated test. vi.resetAllMocks() drains that
|
|
// queue for every mock in one call. It also keeps each mock's
|
|
// constructor-provided vi.fn(impl) default. So the code below only
|
|
// sets values that must differ from that default.
|
|
// vi.clearAllMocks() clears call history only. It does not drain the
|
|
// queue. That gap once let a leftover queued value deny an unrelated
|
|
// later test.
|
|
vi.resetAllMocks();
|
|
// mockRunAttribution.value is a plain object, not a vi.fn().
|
|
// resetAllMocks() does not reset it. createApp() overwrites it for an
|
|
// agent actor. A board actor leaves whatever value a prior test set here.
|
|
mockRunAttribution.value = {
|
|
companyId: "company-1",
|
|
agentId: CREATED_AGENT_ID,
|
|
responsibleUserId: null,
|
|
};
|
|
mockQuestionResponseDeliveries.deliver.mockResolvedValue(null);
|
|
mockRequestNativeQuestionRunCancellation.mockResolvedValue(null);
|
|
mockResolveTaskWatchdogMutationScope.mockResolvedValue({ kind: "none" });
|
|
mockResolveCoreTrustPreset.mockReturnValue({ kind: "standard" });
|
|
mockAccessDecide.mockImplementation(async (input: { action?: string }) => ({
|
|
allowed: true,
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation: "Allowed by test grant.",
|
|
}));
|
|
mockIssueService.getById.mockResolvedValue(createIssue());
|
|
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
|
|
mockInteractionService.listForIssue.mockResolvedValue([]);
|
|
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
|
|
mockInteractionService.expirePendingInteractionsForTerminalIssue.mockResolvedValue([]);
|
|
mockInteractionService.getForIssue.mockResolvedValue({
|
|
id: "interaction-withdraw",
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
continuationPolicy: "wake_assignee",
|
|
status: "pending",
|
|
payload: { version: 1, questions: [] },
|
|
});
|
|
mockInteractionService.withdrawInteraction.mockImplementation((...args) => resolveMockInteraction(args, {
|
|
id: "interaction-withdraw",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
status: "cancelled",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
result: { version: 1, outcome: "withdrawn", reason: "Replanning" },
|
|
}));
|
|
mockInteractionService.recordSecretProposalExecutionResult.mockImplementation(
|
|
async (_issue, _interactionId, _proposalId, execution) => ({
|
|
...(await mockInteractionService.acceptInteraction.mock.results.at(-1)?.value)?.interaction,
|
|
id: _interactionId,
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Create the binding?",
|
|
secretProposal: {
|
|
version: 1,
|
|
proposalId: _proposalId,
|
|
configPath: "access.NEW_ALIAS",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted", secretProposal: { version: 1, ...execution } },
|
|
}),
|
|
);
|
|
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.submitItemVerdicts.mockResolvedValue({
|
|
interaction: {
|
|
id: "interaction-verdicts",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_item_verdicts",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: null,
|
|
sourceCommentId: "comment-verdicts",
|
|
sourceRunId: "run-verdicts",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Review generated artifacts.",
|
|
items: [
|
|
{ id: "api", label: "API route" },
|
|
{ id: "docs", label: "Docs" },
|
|
],
|
|
verdicts: ["approve", "reject"],
|
|
requireReasonOn: ["reject"],
|
|
allowBulkApprove: true,
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "resolved",
|
|
complete: false,
|
|
items: [
|
|
{
|
|
id: "docs",
|
|
verdict: "reject",
|
|
reason: "Missing examples",
|
|
resolvedByUserId: "local-board",
|
|
resolvedAt: "2026-04-20T12:06:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
createdAt: "2026-04-20T12:00:00.000Z",
|
|
updatedAt: "2026-04-20T12:06:00.000Z",
|
|
resolvedAt: null,
|
|
},
|
|
newlyResolvedItemIds: ["docs"],
|
|
});
|
|
mockInteractionService.cancelQuestions.mockImplementation((...args) => resolveMockInteraction(args, {
|
|
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(mockRunAttribution.value ? [mockRunAttribution.value] : []).then(
|
|
onFulfilled,
|
|
onRejected,
|
|
),
|
|
orderBy: () => ({
|
|
limit: () => Promise.resolve(mockReviewTransition.value ? [mockReviewTransition.value] : []),
|
|
}),
|
|
}));
|
|
mockReviewTransition.value = null;
|
|
// Default: the authenticated run is already working the issue under test, so
|
|
// resolution is a same-issue write and the cross-issue counter must ignore it.
|
|
mockCrossIssueInfluence.sourceIssueId = ISSUE_ID;
|
|
mockCrossIssueInfluence.priorCount = 0;
|
|
mockCrossIssueInfluence.inserted.length = 0;
|
|
});
|
|
|
|
it("creates board-authored interactions", async () => {
|
|
const app = await createApp();
|
|
|
|
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",
|
|
}),
|
|
}),
|
|
);
|
|
}, 10_000);
|
|
|
|
it("records a board dismissal without resolving or waking, and denies agent dismissal", async () => {
|
|
mockInteractionService.dismissInteraction.mockResolvedValue({ id: "request-1", kind: "request_confirmation", status: "pending", dismissedAt: "2026-10-22T12:00:00Z" });
|
|
const path = "/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/request-1/dismiss";
|
|
const result = await request(await createApp()).post(path).send({});
|
|
expect(result.status).toBe(200);
|
|
expect(result.body).toMatchObject({ status: "pending", dismissedAt: "2026-10-22T12:00:00Z" });
|
|
expect(mockInteractionService.dismissInteraction).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1" }), "request-1", { userId: "local-board" });
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.thread_interaction_dismissed" }));
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
mockInteractionService.dismissInteraction.mockClear();
|
|
expect((await request(await createApp({ type: "agent", agentId: CREATED_AGENT_ID, companyId: "company-1", runId: RUN_1 })).post(path).send({})).status).toBe(403);
|
|
expect(mockInteractionService.dismissInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides private task requests from same-company members without task access before dismissal", async () => {
|
|
mockAccessDecide.mockResolvedValue({ allowed: false, action: "issue:read", reason: "deny_issue_private",
|
|
explanation: "Task access was revoked.",
|
|
});
|
|
const app = await createApp({ type: "board", userId: "same-company-member", companyIds: ["company-1"],
|
|
source: "session", isInstanceAdmin: false,
|
|
});
|
|
const result = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/request-1/dismiss").send({});
|
|
expect(result.status).toBe(404);
|
|
expect(result.body).toEqual({ error: "Issue not found" });
|
|
expect(mockInteractionService.dismissInteraction).not.toHaveBeenCalled();
|
|
expect(mockInteractionService.getForIssue).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes a canonical-only mixed question form at the HTTP boundary", async () => {
|
|
const questionSet = { schema: "paperclip.question_set.v1", questions: [
|
|
{ id: "repo", prompt: "Repository URL?", required: true, answerMode: "text" },
|
|
{ id: "scope", prompt: "Review scope?", required: true, answerMode: "single_select", options: [{ id: "all", label: "All changes" }, { id: "selected", label: "Selected changes" }] },
|
|
] };
|
|
const res = await request(await createApp())
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({ kind: "ask_user_questions", payload: { version: 1, questionSet } });
|
|
expect(res.status).toBe(201);
|
|
expect(mockInteractionService.create).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
payload: expect.objectContaining({ questionSet, questions: [
|
|
expect.objectContaining({ id: "repo", options: [{ id: "paperclip_text_answer", label: "Type an answer", freeText: true }] }),
|
|
expect.objectContaining({ id: "scope", options: questionSet.questions[1].options }),
|
|
] }),
|
|
}), expect.anything());
|
|
});
|
|
|
|
it.each(["in_review", "done"])("keeps interaction reads free of expiry, activity writes, and recovery while %s", async (status) => {
|
|
mockIssueService.getById.mockResolvedValue(createIssue({
|
|
status,
|
|
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
|
}));
|
|
mockInteractionService.listForIssue.mockResolvedValue([]);
|
|
|
|
await request(await createApp())
|
|
.get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.expect(200, []);
|
|
|
|
expect(mockInteractionService.listForIssue).toHaveBeenCalledExactlyOnceWith(ISSUE_ID);
|
|
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).not.toHaveBeenCalled();
|
|
expect(mockInteractionService.expirePendingInteractionsForTerminalIssue).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("wakes the addressed agent when an interaction is created", async () => {
|
|
mockInteractionService.create.mockResolvedValueOnce({
|
|
id: "interaction-addressed",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: null,
|
|
payload: { version: 1, questions: [] },
|
|
result: null,
|
|
createdAt: "2026-07-25T12:00:00.000Z",
|
|
updatedAt: "2026-07-25T12:00:00.000Z",
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "ask_user_questions",
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
reason: "interaction_pending",
|
|
idempotencyKey: "interaction-pending:interaction-addressed",
|
|
payload: expect.objectContaining({
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
interactionId: "interaction-addressed",
|
|
}),
|
|
contextSnapshot: expect.objectContaining({ wakeReason: "interaction_pending" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not route agent attention for a human-only interaction", async () => {
|
|
mockInteractionService.create.mockResolvedValueOnce({
|
|
id: "interaction-human-attention",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: null,
|
|
createdByAgentId: null,
|
|
payload: { version: 1, questions: [] },
|
|
result: null,
|
|
createdAt: "2026-07-25T12:00:00.000Z",
|
|
updatedAt: "2026-07-25T12:00:00.000Z",
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "ask_user_questions",
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
resolverPolicy: "human_only",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Which scope?",
|
|
selectionMode: "single",
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
}],
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 for agent-addressed tool-action confirmations", async () => {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "request_confirmation",
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Run the tool?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "11111111-1111-4111-8111-111111111111",
|
|
invocationId: "22222222-2222-4222-8222-222222222222",
|
|
toolName: "send_email",
|
|
toolDisplayName: "Send email",
|
|
connectionId: "33333333-3333-4333-8333-333333333333",
|
|
applicationId: "44444444-4444-4444-8444-444444444444",
|
|
appDisplayName: "Gmail",
|
|
risk: "write",
|
|
previewMarkdown: "Send an email to the reviewed recipient.",
|
|
argumentsSummaryJson: '{"to":"recipient@example.com"}',
|
|
argumentsHash: "reviewed-arguments-hash",
|
|
expiresAt: "2026-07-25T16:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("cannot be addressed");
|
|
expect(mockInteractionService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 through the durable delivery service", 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(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith(
|
|
"interaction-2",
|
|
);
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.thread_interaction_answered",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each(["paperclip-id:chat-owner", "chat-owner", "paperclip-id:another-user"])("keeps chat response delivery limited to the exact owner: %s", async (userId) => {
|
|
const issue = createIssue({ conversationAgentId: ASSIGNEE_AGENT_ID, conversationUserId: "paperclip-id:chat-owner" });
|
|
mockIssueService.getById.mockResolvedValue(issue);
|
|
mockInteractionService.getForIssue.mockResolvedValue({
|
|
id: "interaction-2", kind: "ask_user_questions", status: "pending",
|
|
requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only",
|
|
addresseeUserId: "paperclip-id:chat-owner", continuationPolicy: "wake_assignee",
|
|
payload: { version: 1, questions: [] },
|
|
});
|
|
const app = await createApp({ type: "board", userId, source: "cloud_tenant", companyIds: ["company-1"] });
|
|
const response = await request(app).post(`/api/issues/${ISSUE_ID}/interactions/interaction-2/respond`)
|
|
.send({ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] });
|
|
if (userId === "paperclip-id:chat-owner") {
|
|
expect(response.status).toBe(200);
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledWith(expect.anything(), "interaction-2", expect.anything(), expect.objectContaining({ userId }));
|
|
expect(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith("interaction-2");
|
|
} else {
|
|
expect(response.status).toBe(403);
|
|
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
|
expect(mockQuestionResponseDeliveries.deliver).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("routes wake-on-accept question answers through the same causal delivery service", async () => {
|
|
mockInteractionService.answerQuestions.mockResolvedValueOnce({
|
|
id: "interaction-2",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "ask_user_questions",
|
|
status: "answered",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
sourceCommentId: "comment-2",
|
|
sourceRunId: RUN_2,
|
|
payload: { version: 1, questions: [] },
|
|
result: { version: 1, answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
|
});
|
|
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(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith("interaction-2");
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("submits item verdicts and emits one continuation wake with resolved item ids", async () => {
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts")
|
|
.send({
|
|
verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
|
"interaction-verdicts",
|
|
{ verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] },
|
|
expect.objectContaining({ userId: "local-board" }),
|
|
);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
reason: "issue_commented",
|
|
idempotencyKey: expect.stringMatching(
|
|
/^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/,
|
|
),
|
|
payload: expect.objectContaining({
|
|
interactionId: "interaction-verdicts",
|
|
interactionKind: "request_item_verdicts",
|
|
interactionStatus: "pending",
|
|
sourceCommentId: "comment-verdicts",
|
|
sourceRunId: "run-verdicts",
|
|
newlyResolvedItemIds: ["docs"],
|
|
itemVerdicts: {
|
|
newlyResolvedItemIds: ["docs"],
|
|
coalesceWindowMs: 2000,
|
|
},
|
|
}),
|
|
contextSnapshot: expect.objectContaining({
|
|
interactionId: "interaction-verdicts",
|
|
interactionKind: "request_item_verdicts",
|
|
interactionStatus: "pending",
|
|
newlyResolvedItemIds: ["docs"],
|
|
itemVerdicts: {
|
|
newlyResolvedItemIds: ["docs"],
|
|
coalesceWindowMs: 2000,
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.thread_interaction_item_verdicts_submitted",
|
|
details: expect.objectContaining({
|
|
interactionKind: "request_item_verdicts",
|
|
newlyResolvedItemCount: 1,
|
|
newlyResolvedItemIds: ["docs"],
|
|
complete: false,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("allows a board user to withdraw and wakes the assignee", async () => {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw")
|
|
.send({ reason: "Replanning" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.withdrawInteraction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
|
"interaction-withdraw",
|
|
{ reason: "Replanning" },
|
|
expect.objectContaining({ userId: "local-board" }),
|
|
expect.objectContaining({ afterResolveInTransaction: expect.any(Function) }),
|
|
);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.objectContaining({
|
|
payload: expect.objectContaining({ interactionStatus: "cancelled" }),
|
|
}));
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "issue.thread_interaction_withdrawn",
|
|
}));
|
|
});
|
|
|
|
it("cancels the bound native run when its question is withdrawn", async () => {
|
|
mockInteractionService.withdrawInteraction.mockImplementationOnce((...args) => resolveMockInteraction(args, {
|
|
id: "interaction-withdraw",
|
|
companyId: "company-1",
|
|
issueId: ISSUE_ID,
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
status: "cancelled",
|
|
continuationPolicy: "none",
|
|
payload: { version: 1, questions: [] },
|
|
result: { version: 1, answers: [], cancelled: true },
|
|
}));
|
|
mockRequestNativeQuestionRunCancellation.mockResolvedValueOnce(RUN_1);
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-withdraw/withdraw`)
|
|
.send({ reason: "No longer needed" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRequestNativeQuestionRunCancellation).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ id: "interaction-withdraw", sourceRunId: RUN_1 }),
|
|
{ kind: "interaction_withdrawn", interactionId: "interaction-withdraw" },
|
|
);
|
|
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
|
|
RUN_1,
|
|
"Question withdrawn while waiting for operator input",
|
|
expect.objectContaining({
|
|
resultJson: expect.objectContaining({
|
|
withdrawnInteractionId: "interaction-withdraw",
|
|
withdrawnByActorType: "user",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps a durable withdrawal intent when immediate native cancellation fails", async () => {
|
|
mockInteractionService.withdrawInteraction.mockImplementationOnce((...args) => resolveMockInteraction(args, {
|
|
id: "interaction-withdraw",
|
|
companyId: "company-1",
|
|
issueId: ISSUE_ID,
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
status: "cancelled",
|
|
continuationPolicy: "none",
|
|
payload: { version: 1, questions: [] },
|
|
result: { version: 1, answers: [], cancelled: true },
|
|
}));
|
|
mockRequestNativeQuestionRunCancellation.mockResolvedValueOnce(RUN_1);
|
|
mockHeartbeatService.cancelRun.mockRejectedValueOnce(new Error("process unavailable"));
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-withdraw/withdraw`)
|
|
.send({ reason: "No longer needed" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRequestNativeQuestionRunCancellation).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ id: "interaction-withdraw" }),
|
|
{ kind: "interaction_withdrawn", interactionId: "interaction-withdraw" },
|
|
);
|
|
});
|
|
|
|
it("allows the creator agent to withdraw and wakes a different assignee", async () => {
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "in_review", reviewPolicy: null }));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-withdraw",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
});
|
|
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/interaction-withdraw/withdraw")
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.anything());
|
|
});
|
|
|
|
it("allows the assignee agent to withdraw without waking itself", async () => {
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: RUN_2 });
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw")
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects withdrawal by an unrelated agent", async () => {
|
|
const app = await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: RUN_3 });
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw")
|
|
.send({});
|
|
expect(res.status).toBe(403);
|
|
expect(mockInteractionService.withdrawInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets a watchdog-scoped assignee withdraw through ordinary containment", async () => {
|
|
mockResolveTaskWatchdogMutationScope.mockResolvedValue({
|
|
kind: "watchdog",
|
|
watchdogId: "watchdog-1",
|
|
companyId: "company-1",
|
|
watchedIssueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
watchdogIssueId: null,
|
|
stopFingerprint: "stop-1",
|
|
});
|
|
const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: RUN_WATCHDOG });
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw")
|
|
.send({});
|
|
expect(mockInteractionService.withdrawInteraction).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-withdraw",
|
|
{},
|
|
expect.objectContaining({ agentId: ASSIGNEE_AGENT_ID, runId: RUN_WATCHDOG }),
|
|
expect.objectContaining({ afterResolveInTransaction: expect.any(Function) }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects withdrawal by low-trust actors", async () => {
|
|
mockResolveCoreTrustPreset.mockReturnValueOnce({ kind: "low_trust_review" });
|
|
const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: RUN_LOW_TRUST });
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw")
|
|
.send({});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "interaction_scope_denied" });
|
|
expect(mockInteractionService.withdrawInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.objectContaining({ afterResolveInTransaction: expect.any(Function) }),
|
|
);
|
|
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("durably marks a board-cancelled native question before cancelling its run", async () => {
|
|
mockRequestNativeQuestionRunCancellation.mockResolvedValueOnce(RUN_2);
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-2/cancel`)
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRequestNativeQuestionRunCancellation).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ id: "interaction-2", sourceRunId: RUN_2 }),
|
|
{ kind: "interaction_cancelled", interactionId: "interaction-2" },
|
|
);
|
|
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
|
|
RUN_2,
|
|
"Cancelled while waiting for operator input",
|
|
expect.objectContaining({
|
|
resultJson: expect.objectContaining({ cancelledInteractionId: "interaction-2" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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",
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockHeartbeatService.wakeup.mock.calls[0]?.[1]?.payload).not.toHaveProperty("toolAction");
|
|
expect(mockHeartbeatService.wakeup.mock.calls[0]?.[1]?.contextSnapshot).not.toHaveProperty("toolAction");
|
|
});
|
|
|
|
it("executes an accepted tool-action confirmation through the gateway callback", async () => {
|
|
const approveToolActionRequest = vi.fn().mockResolvedValue({
|
|
status: "executed",
|
|
resultSummary: "Added row 42",
|
|
});
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-tool-action",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the action?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "action-request-1",
|
|
toolName: "google_sheets_add_row",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp(undefined, { approveToolActionRequest });
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool-action/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(approveToolActionRequest).toHaveBeenCalledWith({
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
interactionId: "interaction-tool-action",
|
|
actionRequestId: "action-request-1",
|
|
actor: { agentId: null, userId: "local-board" },
|
|
});
|
|
const expectedToolAction = {
|
|
toolName: "google_sheets_add_row",
|
|
actionRequestId: "action-request-1",
|
|
decision: "accepted",
|
|
executionStatus: "executed",
|
|
resultSummary: "Added row 42",
|
|
instructions: "the approved google_sheets_add_row action already ran — do not call the tool again; continue with this result.",
|
|
};
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({ toolAction: expectedToolAction }),
|
|
contextSnapshot: expect.objectContaining({ toolAction: expectedToolAction }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("wakes with failure instructions after an accepted tool action fails", async () => {
|
|
const approveToolActionRequest = vi.fn().mockResolvedValue({
|
|
status: "failed",
|
|
error: "Connector timed out",
|
|
});
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-tool-action-failed",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the action?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "action-request-2",
|
|
toolName: "google_sheets_add_row",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp(undefined, { approveToolActionRequest });
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool-action-failed/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
toolAction: {
|
|
toolName: "google_sheets_add_row",
|
|
actionRequestId: "action-request-2",
|
|
decision: "accepted",
|
|
executionStatus: "failed",
|
|
error: "Connector timed out",
|
|
instructions: "the approved action ran and failed with Connector timed out; adjust your approach — a fresh call will open a new approval.",
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("wakes with fresh-approval instructions after an accepted tool action expires", async () => {
|
|
const approveToolActionRequest = vi.fn().mockResolvedValue({
|
|
status: "expired",
|
|
error: "Managed arguments changed after review",
|
|
});
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-tool-action-expired",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the action?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "action-request-expired",
|
|
toolName: "shopify_update_product",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp(undefined, { approveToolActionRequest });
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool-action-expired/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
toolAction: {
|
|
toolName: "shopify_update_product",
|
|
actionRequestId: "action-request-expired",
|
|
decision: "accepted",
|
|
executionStatus: "expired",
|
|
error: "Managed arguments changed after review",
|
|
instructions: "the approved shopify_update_product action expired before execution: Managed arguments changed after review; if the task still requires it, call the tool again to request a fresh approval.",
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects client-supplied tool-action metadata on interaction creation", async () => {
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the forged action?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "11111111-1111-4111-8111-111111111111",
|
|
invocationId: "22222222-2222-4222-8222-222222222222",
|
|
toolName: "forged_tool",
|
|
toolDisplayName: "Forged tool",
|
|
connectionId: null,
|
|
applicationId: null,
|
|
appDisplayName: null,
|
|
risk: "write",
|
|
previewMarkdown: "Forged preview",
|
|
argumentsSummaryJson: "{}",
|
|
argumentsHash: "forged-hash",
|
|
expiresAt: "2026-07-12T12:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("payload.toolAction is server-owned metadata");
|
|
expect(mockInteractionService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects client-supplied secret-proposal metadata on interaction creation", async () => {
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the forged secret binding?",
|
|
secretProposal: {
|
|
version: 1,
|
|
proposalId: "11111111-1111-4111-8111-111111111111",
|
|
sourceSecretLabel: "forged/source",
|
|
configPath: "access.FORGED_ALIAS",
|
|
targetAgentId: ASSIGNEE_AGENT_ID,
|
|
targetAgentName: "Target agent",
|
|
justification: "Trust me",
|
|
expiresAt: "2026-08-30T12:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("payload.secretProposal is server-owned metadata");
|
|
expect(mockInteractionService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("executes an accepted secret-proposal confirmation and wakes with verification instructions", async () => {
|
|
const proposalId = "44444444-4444-4444-8444-444444444444";
|
|
const approveSecretProposal = vi.fn().mockResolvedValue({ status: "approved" });
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-secret-proposal",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Create the binding?",
|
|
secretProposal: {
|
|
version: 1,
|
|
proposalId,
|
|
configPath: "access.NEW_ALIAS",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp(undefined, { approveSecretProposal });
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-secret-proposal/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(approveSecretProposal).toHaveBeenCalledWith({
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
interactionId: "interaction-secret-proposal",
|
|
proposalId,
|
|
actor: { agentId: null, userId: "local-board" },
|
|
});
|
|
expect(mockInteractionService.recordSecretProposalExecutionResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
|
"interaction-secret-proposal",
|
|
proposalId,
|
|
{ status: "executed" },
|
|
);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
secretProposal: expect.objectContaining({
|
|
proposalId,
|
|
configPath: "access.NEW_ALIAS",
|
|
executionStatus: "executed",
|
|
instructions: expect.stringContaining("GET /api/agents/me/secrets"),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("records a failed secret-proposal execution and posts a thread comment", async () => {
|
|
const proposalId = "55555555-5555-4555-8555-555555555555";
|
|
const approveSecretProposal = vi.fn().mockRejectedValue(new Error("binding failed"));
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-secret-proposal-failed",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Create the binding?",
|
|
secretProposal: {
|
|
version: 1,
|
|
proposalId,
|
|
configPath: "access.NEW_ALIAS",
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp(undefined, { approveSecretProposal });
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-secret-proposal-failed/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.recordSecretProposalExecutionResult).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-secret-proposal-failed",
|
|
proposalId,
|
|
{ status: "failed", errorCode: "secret_proposal_execution_failed" },
|
|
);
|
|
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
expect.stringContaining("Binding created: **no**"),
|
|
{ userId: "local-board" },
|
|
);
|
|
expect(res.body.result.secretProposal).toMatchObject({
|
|
status: "failed",
|
|
errorCode: "secret_proposal_execution_failed",
|
|
});
|
|
});
|
|
|
|
it("forwards plan-document confirmations to the interaction service for revision validation", async () => {
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.send({
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
documentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
key: "plan",
|
|
revisionId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
// The route delegates plan-target validation to the service, which rejects a
|
|
// stale/missing revision atomically inside its insert transaction
|
|
// (assertRequestConfirmationTargetIsCurrent). The route must pass the target
|
|
// through unchanged rather than pre-checking it non-atomically.
|
|
expect(res.status).toBe(201);
|
|
expect(mockInteractionService.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
|
expect.objectContaining({
|
|
kind: "request_confirmation",
|
|
payload: expect.objectContaining({
|
|
target: expect.objectContaining({
|
|
key: "plan",
|
|
revisionId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
}),
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
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.each([
|
|
{ label: "explicit", targetIssueId: ISSUE_ID },
|
|
{ label: "omitted", targetIssueId: undefined },
|
|
{ label: "null", targetIssueId: null },
|
|
])("forces a fresh workspace-aware session when accepting a planning confirmation with $label issueId", async ({ targetIssueId }) => {
|
|
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",
|
|
...(targetIssueId !== undefined ? { issueId: targetIssueId } : {}),
|
|
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: [],
|
|
continuationIssue: {
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
|
assigneeUserId: null,
|
|
status: "todo",
|
|
workMode: "standard",
|
|
},
|
|
});
|
|
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",
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.updated",
|
|
details: expect.objectContaining({
|
|
source: "request_confirmation_accept",
|
|
workMode: "standard",
|
|
_previous: expect.objectContaining({ workMode: "planning" }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not project an explicitly different issue's approved plan into the current issue wake", async () => {
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-other-plan",
|
|
companyId: "company-1",
|
|
issueId: ISSUE_ID,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
sourceRunId: RUN_1,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the other issue's plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: OTHER_ISSUE_ID,
|
|
key: "plan",
|
|
revisionId: "other-revision",
|
|
revisionNumber: 2,
|
|
},
|
|
},
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const response = await request(await createApp())
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-other-plan/accept`)
|
|
.send({});
|
|
expect(response.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
|
const wake = mockHeartbeatService.wakeup.mock.calls[0]?.[1] as unknown as {
|
|
contextSnapshot: Record<string, unknown>;
|
|
payload: Record<string, unknown>;
|
|
};
|
|
expect(wake.contextSnapshot).not.toHaveProperty("planReviewInteraction");
|
|
expect(wake.payload).not.toHaveProperty("planReviewInteraction");
|
|
expect(wake.contextSnapshot).not.toHaveProperty("forceFreshSession");
|
|
});
|
|
|
|
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("wakes the assignee to revise a rejected plan even when its policy is accept-only", async () => {
|
|
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
|
id: "interaction-rejected-plan",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "rejected",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
idempotencyKey: "confirmation:issue:plan:revision-2",
|
|
sourceCommentId: null,
|
|
sourceRunId: RUN_3,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-2",
|
|
revisionNumber: 2,
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: "Keep the API smaller and add a Unicode test.",
|
|
},
|
|
createdAt: "2026-04-20T12:00:00.000Z",
|
|
updatedAt: "2026-04-20T12:05:00.000Z",
|
|
resolvedAt: "2026-04-20T12:05:00.000Z",
|
|
});
|
|
|
|
const res = await request(await createApp())
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-rejected-plan/reject")
|
|
.send({ reason: "Keep the API smaller and add a Unicode test." });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
planReviewInteraction: expect.objectContaining({
|
|
id: "interaction-rejected-plan",
|
|
status: "rejected",
|
|
target: expect.objectContaining({
|
|
key: "plan",
|
|
revisionId: "revision-2",
|
|
}),
|
|
result: expect.objectContaining({
|
|
outcome: "rejected",
|
|
reason: "Keep the API smaller and add a Unicode test.",
|
|
}),
|
|
}),
|
|
}),
|
|
contextSnapshot: expect.objectContaining({
|
|
planReviewInteraction: expect.objectContaining({ status: "rejected" }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("delivers generic confirmation rejection feedback as the next turn message", async () => {
|
|
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
|
id: "interaction-warm-turn",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "rejected",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: "warm-turn-1",
|
|
sourceCommentId: null,
|
|
sourceRunId: RUN_3,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Continue to turn two?",
|
|
target: {
|
|
type: "custom",
|
|
key: "warm_turn_1",
|
|
revisionId: "turn-1",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: "Read T1, append T2, and verify both lines.",
|
|
},
|
|
createdAt: "2026-04-20T12:00:00.000Z",
|
|
updatedAt: "2026-04-20T12:05:00.000Z",
|
|
resolvedAt: "2026-04-20T12:05:00.000Z",
|
|
});
|
|
|
|
const res = await request(await createApp())
|
|
.post(
|
|
"/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-warm-turn/reject",
|
|
)
|
|
.send({ reason: "Read T1, append T2, and verify both lines." });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
paperclipAgentMessage: {
|
|
text: "Read T1, append T2, and verify both lines.",
|
|
source: "interaction_rejection",
|
|
sessionId: "interaction-warm-turn",
|
|
},
|
|
}),
|
|
contextSnapshot: expect.objectContaining({
|
|
paperclipAgentMessage: expect.objectContaining({
|
|
text: "Read T1, append T2, and verify both lines.",
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns a rejected native completion review with a narrow reviewer-reason continuation", async () => {
|
|
const issue = createIssue({ status: "in_review" });
|
|
mockIssueService.getById.mockResolvedValue(issue);
|
|
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
|
id: "interaction-native-completion-review",
|
|
companyId: "company-1",
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "rejected",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: RUN_3,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve completion?",
|
|
target: { type: "custom", key: "native_completion_review", revisionId: "decision-29" },
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: "Run the external verification and report only that result.",
|
|
},
|
|
createdAt: "2026-04-20T12:00:00.000Z",
|
|
updatedAt: "2026-04-20T12:05:00.000Z",
|
|
resolvedAt: "2026-04-20T12:05:00.000Z",
|
|
});
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/issues/${issue.id}/interactions/interaction-native-completion-review/reject`)
|
|
.send({ reason: "Run the external verification and report only that result." });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
nativeCompletionReview: expect.objectContaining({
|
|
decisionId: "decision-29",
|
|
outcome: "rejected",
|
|
reviewerReason: "Run the external verification and report only that result.",
|
|
instruction: expect.stringContaining("do not redo completed implementation"),
|
|
}),
|
|
}),
|
|
contextSnapshot: expect.objectContaining({
|
|
nativeCompletionReview: expect.objectContaining({ reviewerReason: expect.any(String) }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("overrides accept-only continuation when rejection consumes the last review path", async () => {
|
|
const issue = createIssue({ status: "in_review" });
|
|
mockIssueService.getById.mockResolvedValue(issue);
|
|
mockIssueService.listReviewAttention.mockResolvedValue(new Map([[
|
|
issue.id,
|
|
{ state: "stalled", paths: [], reason: "review path consumed" },
|
|
]]));
|
|
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
|
id: "interaction-last-review-path",
|
|
companyId: "company-1",
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "rejected",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: "run-last-review-path",
|
|
payload: { version: 1, prompt: "Approve this?" },
|
|
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 res = await request(await createApp())
|
|
.post(`/api/issues/${issue.id}/interactions/interaction-last-review-path/reject`)
|
|
.send({ reason: "Needs changes" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
reviewPathLost: true,
|
|
reviewPathConsumedRef: "interaction-last-review-path",
|
|
}),
|
|
contextSnapshot: expect.objectContaining({
|
|
reviewPathLost: true,
|
|
reviewPathInstruction: expect.stringContaining("Restore a reviewer"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("wakes with decline instructions when a tool-action confirmation is rejected", async () => {
|
|
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
|
id: "interaction-tool-action-rejected",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "rejected",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: "run-tool-action-rejected",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the action?",
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: "action-request-3",
|
|
toolName: "google_sheets_add_row",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: "Use the sandbox sheet instead",
|
|
},
|
|
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-tool-action-rejected/reject")
|
|
.send({ reason: "Use the sandbox sheet instead" });
|
|
|
|
expect(res.status).toBe(200);
|
|
const expectedToolAction = {
|
|
toolName: "google_sheets_add_row",
|
|
actionRequestId: "action-request-3",
|
|
decision: "rejected",
|
|
executionStatus: "rejected",
|
|
declineReason: "Use the sandbox sheet instead",
|
|
instructions: "the action was declined: Use the sandbox sheet instead; do not retry the same call — adjust your approach or mark the task blocked/in_review with the decline reason.",
|
|
};
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
ASSIGNEE_AGENT_ID,
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({ toolAction: expectedToolAction }),
|
|
contextSnapshot: expect.objectContaining({ toolAction: expectedToolAction }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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,
|
|
},
|
|
);
|
|
});
|
|
|
|
it("allows a different in-scope agent run to respond when policy permits", async () => {
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
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).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-2",
|
|
expect.anything(),
|
|
expect.objectContaining({ agentId: ASSIGNEE_AGENT_ID, runId: RUN_2, userId: null }),
|
|
);
|
|
expect(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith("interaction-2");
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
actorType: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
runId: RUN_2,
|
|
details: expect.objectContaining({ resolutionActorKind: "agent" }),
|
|
}));
|
|
});
|
|
|
|
it("allows an agent to accept another agent's pending review confirmation by default", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: CREATED_AGENT_ID,
|
|
details: { reviewInteractionId: "interaction-agent-review" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: null,
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-agent-review",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
});
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-agent-review",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "none",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
result: { version: 1, outcome: "accepted" },
|
|
},
|
|
createdIssues: [],
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-agent-review/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-agent-review",
|
|
{},
|
|
{
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
runId: RUN_2,
|
|
userId: null,
|
|
resolverPolicyRestriction: "anyone",
|
|
suggestedTaskEffectsAuthorized: true,
|
|
},
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
actorType: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
details: expect.objectContaining({ resolutionActorKind: "agent" }),
|
|
}));
|
|
});
|
|
|
|
it("allows an agent to reject a pending review confirmation by default", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: CREATED_AGENT_ID,
|
|
details: { reviewInteractionId: "interaction-agent-reject" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: null,
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-agent-reject",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-agent-reject/reject")
|
|
.send({ reason: "Needs changes" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.rejectInteraction).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-agent-reject",
|
|
{ reason: "Needs changes" },
|
|
expect.objectContaining({
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
resolverPolicyRestriction: "anyone",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps an unrelated pending confirmation human-only on an in-review issue", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: CREATED_AGENT_ID,
|
|
details: { reviewInteractionId: "interaction-agent-review" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: null,
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-unrelated-confirmation",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: UNRELATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_only",
|
|
effectiveResolverPolicy: "board_only",
|
|
payload: { version: 1, prompt: "Approve an unrelated operation?" },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-unrelated-confirmation/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
error: "This issue-thread interaction is human-only",
|
|
code: "interaction_human_only",
|
|
});
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps a same-requester sibling confirmation human-only", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: CREATED_AGENT_ID,
|
|
details: { reviewInteractionId: "interaction-agent-review" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: null,
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-requester-sibling",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_only",
|
|
effectiveResolverPolicy: "board_only",
|
|
payload: { version: 1, prompt: "Approve a different operation?" },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-requester-sibling/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
error: "This issue-thread interaction is human-only",
|
|
code: "interaction_human_only",
|
|
});
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "it addresses a different agent",
|
|
requesterAgentId: CREATED_AGENT_ID,
|
|
interaction: {
|
|
addresseeAgentId: "agent-other-reviewer",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
},
|
|
code: "interaction_addressee_mismatch",
|
|
},
|
|
{
|
|
name: "the agent created it",
|
|
requesterAgentId: ASSIGNEE_AGENT_ID,
|
|
interaction: { createdByAgentId: ASSIGNEE_AGENT_ID, sourceRunId: RUN_1 },
|
|
code: "interaction_creator_excluded",
|
|
},
|
|
{
|
|
name: "the same run created it",
|
|
requesterAgentId: CREATED_AGENT_ID,
|
|
interaction: { createdByAgentId: CREATED_AGENT_ID, sourceRunId: RUN_2 },
|
|
code: "interaction_creator_excluded",
|
|
},
|
|
])("rejects an agent review verdict when $name", async ({ interaction, code, requesterAgentId }) => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: requesterAgentId,
|
|
details: { reviewInteractionId: "interaction-agent-review-scope" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: "not_creator",
|
|
createdByAgentId: requesterAgentId,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-agent-review-scope",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
...interaction,
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-agent-review-scope/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code });
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an agent review-confirmation verdict under human_only with actionable copy", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "agent",
|
|
actorId: CREATED_AGENT_ID,
|
|
details: { reviewInteractionId: "interaction-human-only" },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: "human_only",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-human-only",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-human-only/accept")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
error: "This issue-thread interaction is human-only",
|
|
code: "interaction_human_only",
|
|
details: { code: "interaction_human_only", effectiveResolverPolicy: "human_only" },
|
|
});
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ action: "accept", body: {} },
|
|
{ action: "reject", body: { reason: "Needs changes" } },
|
|
])("rejects an agent $action verdict under human_only when a user review transition omitted the interaction binding", async ({ action, body }) => {
|
|
mockReviewTransition.value = {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
details: { status: "in_review", _previous: { status: "in_progress" } },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: "human_only",
|
|
createdByAgentId: null,
|
|
createdByUserId: "local-board",
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-user-review-unbound",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: null,
|
|
createdByUserId: "local-board",
|
|
sourceRunId: null,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
payload: { version: 1, prompt: "Approve this review?" },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-user-review-unbound/${action}`)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
details: {
|
|
code: "review_policy_denied",
|
|
policy: "human_only",
|
|
},
|
|
});
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
expect(mockInteractionService.rejectInteraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an unrelated agent-resolvable confirmation under a restrictive issue review policy", async () => {
|
|
mockReviewTransition.value = {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
details: { status: "in_review", _previous: { status: "in_progress" } },
|
|
};
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "in_review",
|
|
reviewPolicy: "human_only",
|
|
createdByAgentId: null,
|
|
createdByUserId: "local-board",
|
|
}));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-unrelated",
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
createdByAgentId: UNRELATED_AGENT_ID,
|
|
createdByUserId: null,
|
|
sourceRunId: "run-1",
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
payload: { version: 1, prompt: "Confirm an independent action?" },
|
|
});
|
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
|
interaction: {
|
|
id: "interaction-unrelated",
|
|
companyId: "company-1",
|
|
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "none",
|
|
idempotencyKey: null,
|
|
sourceCommentId: null,
|
|
sourceRunId: "run-1",
|
|
payload: { version: 1, prompt: "Confirm an independent action?" },
|
|
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({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-unrelated/accept")
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockInteractionService.acceptInteraction).toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows only the addressed agent or board to resolve an addressed interaction", async () => {
|
|
const addressed = {
|
|
id: "interaction-addressed",
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
addresseeAgentId: ASSIGNEE_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
payload: { version: 1, questions: [] },
|
|
};
|
|
mockInteractionService.answerQuestions.mockImplementationOnce(async (_issue, interactionId) => ({
|
|
...addressed,
|
|
id: interactionId,
|
|
status: "answered",
|
|
result: { version: 1, answers: [] },
|
|
}));
|
|
mockInteractionService.getForIssue
|
|
.mockResolvedValueOnce(addressed)
|
|
.mockResolvedValueOnce(addressed)
|
|
.mockResolvedValueOnce(addressed);
|
|
mockIssueService.getById
|
|
.mockResolvedValueOnce(createIssue({ status: "todo" }))
|
|
.mockResolvedValueOnce(createIssue({ status: "todo" }))
|
|
.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
|
|
const addresseeApp = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
const addressee = await request(addresseeApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond")
|
|
.send({ answers: [] });
|
|
expect(addressee.status).toBe(200);
|
|
expect(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith("interaction-addressed");
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
|
|
const unrelatedApp = await createApp({
|
|
type: "agent",
|
|
agentId: UNRELATED_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_3,
|
|
});
|
|
const unrelated = await request(unrelatedApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond")
|
|
.send({ answers: [] });
|
|
expect(unrelated.status).toBe(403);
|
|
expect(unrelated.body.error).toContain("addressed agent");
|
|
|
|
const boardApp = await createApp();
|
|
const board = await request(boardApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond")
|
|
.send({ answers: [] });
|
|
expect(board.status).toBe(200);
|
|
});
|
|
|
|
it("preserves creator exclusion for legacy board_or_agents rows", async () => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-2",
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
resolverPolicyProvenance: "legacy_inherited_restriction",
|
|
payload: { version: 1, questions: [] },
|
|
});
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "todo",
|
|
assigneeAgentId: CREATED_AGENT_ID,
|
|
}));
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: CREATED_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_9,
|
|
});
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "interaction_creator_excluded" });
|
|
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
kind: "suggest_tasks",
|
|
action: "accept",
|
|
body: { selectedClientKeys: ["task-1"] },
|
|
payload: { version: 1, tasks: [{ clientKey: "task-1", title: "Task" }] },
|
|
service: "acceptInteraction",
|
|
},
|
|
{
|
|
kind: "ask_user_questions",
|
|
action: "respond",
|
|
body: { answers: [] },
|
|
payload: { version: 1, questions: [] },
|
|
service: "answerQuestions",
|
|
},
|
|
{
|
|
kind: "request_confirmation",
|
|
action: "accept",
|
|
body: {},
|
|
payload: { version: 1, prompt: "Continue?" },
|
|
service: "acceptInteraction",
|
|
},
|
|
{
|
|
kind: "request_checkbox_confirmation",
|
|
action: "accept",
|
|
body: { selectedOptionIds: [] },
|
|
payload: { version: 1, prompt: "Select", options: [] },
|
|
service: "acceptInteraction",
|
|
},
|
|
{
|
|
kind: "request_item_verdicts",
|
|
action: "verdicts",
|
|
body: { verdicts: [{ id: "item-1", verdict: "approve" }] },
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Review",
|
|
items: [{ id: "item-1", label: "Item" }],
|
|
verdicts: ["approve", "reject"],
|
|
},
|
|
service: "submitItemVerdicts",
|
|
},
|
|
])("lets the creator and same run resolve $kind through anyone", async ({
|
|
kind,
|
|
action,
|
|
body,
|
|
payload,
|
|
service,
|
|
}) => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: `interaction-${kind}`,
|
|
kind,
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_9,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload,
|
|
});
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
|
status: "todo",
|
|
assigneeAgentId: CREATED_AGENT_ID,
|
|
}));
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: CREATED_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_9,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-${kind}/${action}`)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService[service as keyof typeof mockInteractionService]).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
`interaction-${kind}`,
|
|
body,
|
|
expect.objectContaining({ agentId: CREATED_AGENT_ID, runId: RUN_9 }),
|
|
);
|
|
});
|
|
|
|
it("enforces same-run exclusion under not_creator and requires a resolver run id", async () => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-2",
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_2,
|
|
requestedResolverPolicy: "not_creator",
|
|
effectiveResolverPolicy: "not_creator",
|
|
payload: { version: 1, questions: [] },
|
|
});
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
const sameRunApp = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
const sameRun = await request(sameRunApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
expect(sameRun.status).toBe(403);
|
|
expect(sameRun.body).toMatchObject({ code: "interaction_creator_excluded" });
|
|
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
const missingRunApp = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
});
|
|
const missingRun = await request(missingRunApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
expect(missingRun.status).toBe(422);
|
|
expect(missingRun.body).toMatchObject({ code: "interaction_run_attribution_required" });
|
|
});
|
|
|
|
it("rejects an invalid run attribution before resolving or waking", async () => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-invalid-run",
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: { version: 1, questions: [] },
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_INVALID,
|
|
});
|
|
mockRunAttribution.value = null;
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-invalid-run/respond")
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({ code: "interaction_run_attribution_required" });
|
|
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides cross-company interactions before resolver evaluation", async () => {
|
|
mockAccessDecide.mockResolvedValue({
|
|
allowed: false,
|
|
action: "issue:read",
|
|
reason: "deny_company_boundary",
|
|
explanation: "Denied by company boundary.",
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-2",
|
|
runId: RUN_CROSS_COMPANY,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(mockInteractionService.getForIssue).not.toHaveBeenCalled();
|
|
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("contains task-bridge resolvers at the ordinary issue mutation boundary", async () => {
|
|
mockAccessDecide.mockResolvedValue({
|
|
allowed: false,
|
|
action: "issue:mutate",
|
|
reason: "deny_task_bridge_scope",
|
|
explanation: "Denied by task bridge scope.",
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_TASK_BRIDGE,
|
|
source: "agent_key",
|
|
keyId: "bridge-key",
|
|
keyScope: { kind: "task_bridge" },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "interaction_scope_denied" });
|
|
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reauthorizes suggested-task effects before resolving the interaction", async () => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-suggest-assigned",
|
|
kind: "suggest_tasks",
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [{
|
|
clientKey: "assigned",
|
|
title: "Assigned child",
|
|
assigneeAgentId: UNRELATED_AGENT_ID,
|
|
}],
|
|
},
|
|
});
|
|
mockAccessDecide.mockImplementation(async (input: { action?: string }) => input.action === "tasks:assign"
|
|
? {
|
|
allowed: false,
|
|
action: input.action,
|
|
reason: "deny_assignment",
|
|
explanation: "Task assignment is not authorized.",
|
|
}
|
|
: {
|
|
allowed: true,
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation: "Allowed by test grant.",
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-suggest-assigned/accept")
|
|
.send({ selectedClientKeys: ["assigned"] });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "interaction_governed_action_denied" });
|
|
expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks human-only and tool-action interactions for agents", async () => {
|
|
mockInteractionService.getForIssue
|
|
.mockResolvedValueOnce({
|
|
id: "interaction-1",
|
|
kind: "request_confirmation",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_only",
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: "interaction-tool",
|
|
kind: "request_confirmation",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "board_or_agents",
|
|
effectiveResolverPolicy: "board_or_agents",
|
|
payload: { version: 1, prompt: "Run?", toolAction: { actionRequestId: "action-1" } },
|
|
});
|
|
mockIssueService.getById
|
|
.mockResolvedValueOnce(createIssue({ status: "todo" }))
|
|
.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const capped = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept")
|
|
.send({});
|
|
expect(capped.status).toBe(403);
|
|
expect(capped.body.error).toContain("human-only");
|
|
|
|
const toolAction = await request(app)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool/accept")
|
|
.send({});
|
|
expect(toolAction.status).toBe(403);
|
|
expect(toolAction.body).toMatchObject({ code: "interaction_governed_action_denied" });
|
|
});
|
|
|
|
it("lets watchdog-scoped agents use the ordinary resolver and contains low-trust agents", async () => {
|
|
mockIssueService.getById.mockResolvedValue(createIssue({ status: "todo" }));
|
|
const actor = {
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
};
|
|
|
|
mockResolveTaskWatchdogMutationScope.mockResolvedValueOnce({
|
|
kind: "watchdog",
|
|
watchdogId: "watchdog-1",
|
|
companyId: "company-1",
|
|
watchedIssueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
watchdogIssueId: null,
|
|
stopFingerprint: "stop-1",
|
|
});
|
|
const watchdogApp = await createApp(actor);
|
|
const watchdog = await request(watchdogApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
expect(watchdog.status).toBe(200);
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledTimes(1);
|
|
|
|
mockResolveCoreTrustPreset.mockReturnValueOnce({ kind: "low_trust_review" });
|
|
const lowTrustApp = await createApp(actor);
|
|
const lowTrust = await request(lowTrustApp)
|
|
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
|
.send({ answers: [] });
|
|
expect(lowTrust.status).toBe(403);
|
|
expect(lowTrust.body).toMatchObject({ code: "interaction_scope_denied" });
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const CROSS_ISSUE_RESOLUTION_ROUTES = [
|
|
{
|
|
route: "accept",
|
|
kind: "request_confirmation",
|
|
body: {},
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
service: "acceptInteraction",
|
|
},
|
|
{
|
|
route: "reject",
|
|
kind: "request_confirmation",
|
|
body: { reason: "Not now" },
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
service: "rejectInteraction",
|
|
},
|
|
{
|
|
route: "respond",
|
|
kind: "ask_user_questions",
|
|
body: { answers: [] },
|
|
payload: { version: 1, questions: [] },
|
|
service: "answerQuestions",
|
|
},
|
|
{
|
|
route: "verdicts",
|
|
kind: "request_item_verdicts",
|
|
body: { verdicts: [{ id: "item-1", verdict: "approve" }] },
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Review",
|
|
items: [{ id: "item-1", label: "Item" }],
|
|
verdicts: ["approve", "reject"],
|
|
},
|
|
service: "submitItemVerdicts",
|
|
},
|
|
] as const;
|
|
|
|
function stubCrossIssueResolution(
|
|
{ kind, payload }: { kind: string; payload: Record<string, unknown> },
|
|
) {
|
|
// The run authenticated below is working OTHER_ISSUE_ID, so resolving this
|
|
// card is a cross-issue mutation even though the audience is open.
|
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" }));
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: `interaction-cross-${kind}`,
|
|
kind,
|
|
status: "pending",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_1,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
payload,
|
|
});
|
|
}
|
|
|
|
it.each(CROSS_ISSUE_RESOLUTION_ROUTES)(
|
|
"charges one shared cross-issue observation for an allowed $route",
|
|
async ({ route, kind, body, payload, service }) => {
|
|
stubCrossIssueResolution({ kind, payload });
|
|
mockCrossIssueInfluence.sourceIssueId = OTHER_ISSUE_ID;
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: ASSIGNEE_AGENT_ID,
|
|
companyId: "company-1",
|
|
runId: RUN_2,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-cross-${kind}/${route}`)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService[service]).toHaveBeenCalledTimes(1);
|
|
expect(mockCrossIssueInfluence.inserted).toEqual([
|
|
expect.objectContaining({
|
|
action: "issue.cross_issue_influence_observed",
|
|
entityId: ISSUE_ID,
|
|
details: expect.objectContaining({
|
|
kind: "interaction_resolution",
|
|
sourceIssueId: OTHER_ISSUE_ID,
|
|
targetIssueId: ISSUE_ID,
|
|
count: 1,
|
|
}),
|
|
}),
|
|
]);
|
|
},
|
|
);
|
|
});
|