3361 lines
120 KiB
TypeScript
3361 lines
120 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
|
const 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(),
|
|
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,
|
|
};
|
|
}
|
|
|
|
function loadAppModules() {
|
|
return Promise.all([
|
|
import("../routes/issues.js"),
|
|
import("../middleware/index.js"),
|
|
]);
|
|
}
|
|
|
|
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.sequential("issue thread interaction routes", () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("../routes/issues.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
vi.doUnmock("../services/index.js");
|
|
registerModuleMocks();
|
|
// 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;
|
|
// Keep cold route imports in setup rather than the HTTP assertion timeout.
|
|
await loadAppModules();
|
|
}, 60_000);
|
|
|
|
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("does not run historical-comment catch-up or queue recovery from the interaction read path", async () => {
|
|
mockIssueService.getById.mockResolvedValue(createIssue({
|
|
status: "in_review",
|
|
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
|
}));
|
|
mockInteractionService.listForIssue.mockResolvedValue([]);
|
|
|
|
await request(await createApp())
|
|
.get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
|
.expect(200);
|
|
|
|
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).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("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("allows the creator agent and creating run under anyone", async () => {
|
|
mockInteractionService.getForIssue.mockResolvedValueOnce({
|
|
id: "interaction-open",
|
|
kind: "ask_user_questions",
|
|
createdByAgentId: CREATED_AGENT_ID,
|
|
sourceRunId: RUN_9,
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
resolverPolicyProvenance: "inherited",
|
|
effectiveResolverPolicySource: "requested",
|
|
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-open/respond")
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"interaction-open",
|
|
expect.anything(),
|
|
expect.objectContaining({ agentId: CREATED_AGENT_ID, runId: RUN_9 }),
|
|
);
|
|
});
|
|
|
|
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]).toHaveBeenCalled();
|
|
});
|
|
|
|
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)(
|
|
"fails a capped run closed on cross-issue $route with no interaction effect",
|
|
async ({ route, kind, body, payload, service }) => {
|
|
stubCrossIssueResolution({ kind, payload });
|
|
mockCrossIssueInfluence.sourceIssueId = OTHER_ISSUE_ID;
|
|
mockCrossIssueInfluence.priorCount = 20;
|
|
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(429);
|
|
expect(res.body.details).toMatchObject({
|
|
code: "cross_issue_influence_cap_exceeded",
|
|
cap: 20,
|
|
count: 21,
|
|
mode: "enforce",
|
|
});
|
|
// The refusal names the boundary and the way forward without naming the
|
|
// run's own inaccessible source issue or the resolver policy.
|
|
expect(res.body.error).toContain("Try this:");
|
|
expect(res.body.error).not.toContain(OTHER_ISSUE_ID);
|
|
expect(res.body.details.code).not.toContain("resolver");
|
|
// No terminal mutation, activity receipt, continuation, or wake.
|
|
expect(mockInteractionService[service]).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
expect(mockCrossIssueInfluence.inserted).toEqual([
|
|
expect.objectContaining({
|
|
action: "issue.cross_issue_influence_cap_rejected",
|
|
details: expect.objectContaining({ kind: "interaction_resolution", allowed: false }),
|
|
}),
|
|
]);
|
|
},
|
|
);
|
|
|
|
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,
|
|
}),
|
|
}),
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("does not charge a same-issue resolution even past the cap", async () => {
|
|
stubCrossIssueResolution({ kind: "ask_user_questions", payload: { version: 1, questions: [] } });
|
|
mockCrossIssueInfluence.sourceIssueId = ISSUE_ID;
|
|
mockCrossIssueInfluence.priorCount = 20;
|
|
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-ask_user_questions/respond`)
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledTimes(1);
|
|
expect(mockCrossIssueInfluence.inserted).toEqual([]);
|
|
});
|
|
|
|
it("leaves board resolutions outside the per-run counter", async () => {
|
|
stubCrossIssueResolution({ kind: "ask_user_questions", payload: { version: 1, questions: [] } });
|
|
mockCrossIssueInfluence.sourceIssueId = OTHER_ISSUE_ID;
|
|
mockCrossIssueInfluence.priorCount = 20;
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-cross-ask_user_questions/respond`)
|
|
.send({ answers: [] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockInteractionService.answerQuestions).toHaveBeenCalledTimes(1);
|
|
expect(mockDbTransaction).not.toHaveBeenCalled();
|
|
expect(mockCrossIssueInfluence.inserted).toEqual([]);
|
|
});
|
|
});
|