@desplega.ai/agent-swarm 1.88.0 → 1.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +41 -1
  3. package/package.json +2 -1
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +325 -2
  6. package/src/be/migrations/081_metrics.sql +39 -0
  7. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  8. package/src/be/modelsdev-cache.json +2750 -1431
  9. package/src/be/seed-skills/index.ts +7 -0
  10. package/src/cli.tsx +18 -0
  11. package/src/commands/runner.ts +153 -22
  12. package/src/commands/x.ts +118 -0
  13. package/src/github/handlers.ts +40 -1
  14. package/src/heartbeat/heartbeat.ts +26 -5
  15. package/src/http/active-sessions.ts +32 -1
  16. package/src/http/auth.ts +36 -0
  17. package/src/http/core.ts +20 -16
  18. package/src/http/db-query.ts +20 -0
  19. package/src/http/index.ts +2 -0
  20. package/src/http/metrics.ts +447 -0
  21. package/src/http/operator-actor.ts +9 -0
  22. package/src/http/poll.ts +11 -1
  23. package/src/http/tasks.ts +4 -1
  24. package/src/http/workflows.ts +5 -1
  25. package/src/metrics/version.ts +26 -0
  26. package/src/prompts/base-prompt.ts +8 -0
  27. package/src/prompts/session-templates.ts +23 -0
  28. package/src/providers/opencode-adapter.ts +22 -6
  29. package/src/server.ts +10 -1
  30. package/src/tests/base-prompt.test.ts +35 -0
  31. package/src/tests/budget-claim-gate.test.ts +26 -0
  32. package/src/tests/core-auth.test.ts +8 -1
  33. package/src/tests/events-http.test.ts +6 -2
  34. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  35. package/src/tests/heartbeat.test.ts +84 -3
  36. package/src/tests/http-api-integration.test.ts +3 -1
  37. package/src/tests/metrics-http.test.ts +247 -0
  38. package/src/tests/opencode-adapter.test.ts +90 -30
  39. package/src/tests/runner-repo-autostash.test.ts +117 -0
  40. package/src/tests/runner-requester-profile.test.ts +25 -0
  41. package/src/tests/runner-skills-refresh.test.ts +1 -1
  42. package/src/tests/swarm-x-tool.test.ts +90 -0
  43. package/src/tests/system-default-skills.test.ts +3 -0
  44. package/src/tests/ui-logs-parser.test.ts +271 -0
  45. package/src/tests/user-token-rest-auth.test.ts +129 -0
  46. package/src/tests/workflow-async-v2.test.ts +23 -0
  47. package/src/tests/x-composio.test.ts +122 -0
  48. package/src/tools/create-metric.ts +191 -0
  49. package/src/tools/swarm-x.ts +116 -0
  50. package/src/tools/tool-config.ts +6 -0
  51. package/src/types.ts +120 -0
  52. package/src/utils/request-auth-context.ts +28 -0
  53. package/src/utils/skills-refresh.ts +2 -2
  54. package/src/workflows/engine.ts +24 -2
  55. package/src/workflows/executors/agent-task.ts +2 -0
  56. package/src/x/composio.ts +295 -0
  57. package/templates/skills/attio-interaction/SKILL.md +279 -0
  58. package/templates/skills/attio-interaction/config.json +14 -0
  59. package/templates/skills/attio-interaction/content.md +272 -0
@@ -72,6 +72,7 @@ export type BasePromptArgs = {
72
72
  claudeMd?: string | null;
73
73
  clonePath: string;
74
74
  warning?: string | null;
75
+ autoStashes?: { ref: string; message: string }[];
75
76
  guidelines?: {
76
77
  prChecks: string[];
77
78
  mergeChecks: string[];
@@ -197,6 +198,13 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
197
198
  } else if (!args.repoContext.warning) {
198
199
  prompt += `Repository is cloned at \`${args.repoContext.clonePath}\` but has no CLAUDE.md file.\n`;
199
200
  }
201
+
202
+ if (args.repoContext.autoStashes && args.repoContext.autoStashes.length > 0) {
203
+ const stashes = args.repoContext.autoStashes
204
+ .map((stash) => `- ${stash.ref}: ${stash.message}`)
205
+ .join("\n");
206
+ prompt += `\nPending auto-stashed work exists in this repo:\n${stashes}\nRestore if relevant with \`git stash apply <ref>\` or \`git stash pop <ref>\`.\n`;
207
+ }
200
208
  }
201
209
 
202
210
  // Inject repo guidelines
@@ -565,6 +565,29 @@ When working in a repository, your system prompt may include a **Repository Guid
565
565
  category: "system",
566
566
  });
567
567
 
568
+ // ============================================================================
569
+ // Per-task prompt templates (category: "task_lifecycle")
570
+ // ============================================================================
571
+
572
+ registerTemplate({
573
+ eventType: "task.requester.profile",
574
+ header: "",
575
+ defaultBody: `
576
+ ## Requester Profile
577
+ This task was requested by {{requester_name}}{{requester_role_suffix}}.{{requester_notes_section}}
578
+ Honor this requester profile in tone, depth, and format where it doesn't conflict with correctness or your operating rules.
579
+ `,
580
+ variables: [
581
+ { name: "requester_name", description: "The requesting user's display name" },
582
+ { name: "requester_role_suffix", description: "Formatted role suffix, including parentheses" },
583
+ {
584
+ name: "requester_notes_section",
585
+ description: "Formatted notes section sourced from users.notes, or empty string",
586
+ },
587
+ ],
588
+ category: "task_lifecycle",
589
+ });
590
+
568
591
  // ============================================================================
569
592
  // Composite session templates (category: "session")
570
593
  // ============================================================================
@@ -219,6 +219,7 @@ export class OpencodeSession implements ProviderSession {
219
219
  private completionPromise: Promise<ProviderResult>;
220
220
  private server: { url: string; close(): void };
221
221
  private aborted = false;
222
+ private completed = false;
222
223
 
223
224
  // Running cost accumulators
224
225
  private totalCostUsd = 0;
@@ -273,6 +274,10 @@ export class OpencodeSession implements ProviderSession {
273
274
  return this._sessionId;
274
275
  }
275
276
 
277
+ get isFinished(): boolean {
278
+ return this.completed;
279
+ }
280
+
276
281
  /** Emit the synthetic session_init event. Called by the adapter immediately
277
282
  * after construction; buffers if no listener is attached yet. */
278
283
  emitSessionInit(provider: "opencode"): void {
@@ -358,7 +363,7 @@ export class OpencodeSession implements ProviderSession {
358
363
 
359
364
  /** Process a single opencode SSE event */
360
365
  handleOpencodeEvent(ev: OpencodeEvent): void {
361
- if (this.aborted) return;
366
+ if (this.aborted || this.completed) return;
362
367
 
363
368
  // Always emit the raw event as a scrubbed raw_log
364
369
  const rawContent = scrubSecrets(JSON.stringify(ev));
@@ -471,7 +476,7 @@ export class OpencodeSession implements ProviderSession {
471
476
  for (const l of this.listeners) l(resultEvent);
472
477
  const raw = scrubSecrets(JSON.stringify(resultEvent));
473
478
  this.emitDirect({ type: "raw_log", content: raw });
474
- this.completionResolve({
479
+ void this.finish({
475
480
  exitCode: 0,
476
481
  sessionId: this._sessionId,
477
482
  cost,
@@ -515,7 +520,7 @@ export class OpencodeSession implements ProviderSession {
515
520
  const raw = scrubSecrets(JSON.stringify(errorEvent));
516
521
  this.emitDirect({ type: "raw_log", content: raw });
517
522
  const cost = this.buildCostData(true);
518
- this.completionResolve({
523
+ void this.finish({
519
524
  exitCode: 1,
520
525
  sessionId: this._sessionId,
521
526
  cost,
@@ -546,12 +551,22 @@ export class OpencodeSession implements ProviderSession {
546
551
  return this.completionPromise;
547
552
  }
548
553
 
554
+ private async finish(result: ProviderResult): Promise<void> {
555
+ if (this.completed) return;
556
+ this.completed = true;
557
+ try {
558
+ this.server.close();
559
+ } catch {
560
+ // best-effort
561
+ }
562
+ await this.cleanupFiles();
563
+ this.completionResolve(result);
564
+ }
565
+
549
566
  async abort(): Promise<void> {
550
567
  if (this.aborted) return;
551
568
  this.aborted = true;
552
- this.server.close();
553
- await this.cleanupFiles();
554
- this.completionResolve({
569
+ await this.finish({
555
570
  exitCode: 1,
556
571
  sessionId: this._sessionId,
557
572
  isError: true,
@@ -760,6 +775,7 @@ export class OpencodeAdapter implements ProviderAdapter {
760
775
  .then(async ({ stream }) => {
761
776
  for await (const event of stream) {
762
777
  session.handleOpencodeEvent(event as OpencodeEvent);
778
+ if (session.isFinished) break;
763
779
  }
764
780
  // Stream ended without session.idle — treat as completion
765
781
  })
package/src/server.ts CHANGED
@@ -6,6 +6,7 @@ import { registerCancelTaskTool } from "./tools/cancel-task";
6
6
  import { registerContextDiffTool } from "./tools/context-diff";
7
7
  import { registerContextHistoryTool } from "./tools/context-history";
8
8
  import { registerCreateChannelTool } from "./tools/create-channel";
9
+ import { registerCreateMetricTool } from "./tools/create-metric";
9
10
  import { registerCreatePageTool } from "./tools/create-page";
10
11
  import { registerDbQueryTool } from "./tools/db-query";
11
12
  import { registerDeleteChannelTool } from "./tools/delete-channel";
@@ -109,6 +110,7 @@ import {
109
110
  registerListConfigTool,
110
111
  registerSetConfigTool,
111
112
  } from "./tools/swarm-config";
113
+ import { registerSwarmXTool } from "./tools/swarm-x";
112
114
  // Task pool capability
113
115
  import { registerTaskActionTool } from "./tools/task-action";
114
116
  // Tracker capability
@@ -146,7 +148,7 @@ import {
146
148
  // Capability-based feature flags
147
149
  // Default: all capabilities enabled
148
150
  const DEFAULT_CAPABILITIES =
149
- "core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
151
+ "core,task-pool,profiles,services,scheduling,memory,workflows,pages,metrics,kv";
150
152
  const CAPABILITIES = new Set(
151
153
  (process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
152
154
  );
@@ -226,6 +228,9 @@ export function createServer() {
226
228
  registerScriptDeleteTool(server);
227
229
  registerScriptQueryTypesTool(server);
228
230
 
231
+ // External command routes - mirrors the `agent-swarm x ...` CLI surface.
232
+ registerSwarmXTool(server);
233
+
229
234
  // Slack integration tools (always registered, will no-op if Slack not configured)
230
235
  registerSlackReplyTool(server);
231
236
  registerSlackReadTool(server);
@@ -338,6 +343,10 @@ export function createServer() {
338
343
  registerCreatePageTool(server);
339
344
  }
340
345
 
346
+ if (hasCapability("metrics")) {
347
+ registerCreateMetricTool(server);
348
+ }
349
+
341
350
  // KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
342
351
  // Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
343
352
  if (hasCapability("kv")) {
@@ -272,6 +272,41 @@ describe("getBasePrompt — repoContext", () => {
272
272
  expect(result).toContain("Repository Guidelines (MANDATORY)");
273
273
  expect(result).toContain("Auto-merge: Allowed");
274
274
  });
275
+
276
+ test("surfaces swarm-autostash entries when present", async () => {
277
+ const result = await getBasePrompt({
278
+ ...minimalArgs,
279
+ repoContext: {
280
+ claudeMd: "Rules",
281
+ clonePath: "/workspace/my-repo",
282
+ autoStashes: [
283
+ {
284
+ ref: "stash@{0}",
285
+ message: "On main: swarm-autostash main 2026-06-01T13:00:00.000Z",
286
+ },
287
+ ],
288
+ },
289
+ });
290
+
291
+ expect(result).toContain("Pending auto-stashed work exists in this repo");
292
+ expect(result).toContain("stash@{0}: On main: swarm-autostash main");
293
+ expect(result).toContain("git stash apply <ref>");
294
+ expect(result).toContain("git stash pop <ref>");
295
+ });
296
+
297
+ test("does not mention auto-stashed work when no swarm-autostash entries exist", async () => {
298
+ const result = await getBasePrompt({
299
+ ...minimalArgs,
300
+ repoContext: {
301
+ claudeMd: "Rules",
302
+ clonePath: "/workspace/my-repo",
303
+ autoStashes: [],
304
+ },
305
+ });
306
+
307
+ expect(result).not.toContain("Pending auto-stashed work exists in this repo");
308
+ expect(result).not.toContain("git stash apply <ref>");
309
+ });
275
310
  });
276
311
 
277
312
  // ---------------------------------------------------------------------------
@@ -11,6 +11,7 @@ import {
11
11
  createAgent,
12
12
  createSessionCost,
13
13
  createTaskExtended,
14
+ createUser,
14
15
  getAgentById,
15
16
  getDb,
16
17
  incrementEmptyPollCount,
@@ -129,6 +130,31 @@ describe("Phase 3 — /api/poll budget admission gate", () => {
129
130
  expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
130
131
  });
131
132
 
133
+ test("pending task trigger includes requester role and notes", async () => {
134
+ const worker = createAgent({ name: "w-requester", isLead: false, status: "idle", maxTasks: 1 });
135
+ const requester = createUser({
136
+ name: "Requester One",
137
+ email: "requester@example.com",
138
+ role: "engineering manager",
139
+ notes: "Include implementation detail and test coverage.",
140
+ });
141
+ createTaskExtended("profile-aware task", {
142
+ agentId: worker.id,
143
+ requestedByUserId: requester.id,
144
+ });
145
+
146
+ const { status, body } = await callPoll(worker.id);
147
+ expect(status).toBe(200);
148
+ if ("error" in body) throw new Error("unexpected error response");
149
+ expect(body.trigger?.type).toBe("task_assigned");
150
+ expect(body.trigger?.requestedBy).toEqual({
151
+ name: "Requester One",
152
+ email: "requester@example.com",
153
+ role: "engineering manager",
154
+ notes: "Include implementation detail and test coverage.",
155
+ });
156
+ });
157
+
132
158
  test("no budgets configured + no work → trigger=null", async () => {
133
159
  const worker = createAgent({ name: "w-empty", isLead: false, status: "idle", maxTasks: 1 });
134
160
  const { status, body } = await callPoll(worker.id);
@@ -135,8 +135,15 @@ describe("handleCore auth middleware (no API_KEY configured)", () => {
135
135
  server.close();
136
136
  });
137
137
 
138
- test("authed routes pass without Bearer when API_KEY is empty", async () => {
138
+ test("authed routes fail closed without Bearer when API_KEY is empty", async () => {
139
139
  const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
140
+ expect(res.status).toBe(401);
141
+ const body = await res.json();
142
+ expect(body.error).toBe("Unauthorized");
143
+ });
144
+
145
+ test("public routes still pass when API_KEY is empty", async () => {
146
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/callback`);
140
147
  expect(res.status).not.toBe(401);
141
148
  });
142
149
  });
@@ -5,6 +5,7 @@ import type { Subprocess } from "bun";
5
5
  const TEST_PORT = 13033;
6
6
  const TEST_DB_PATH = `/tmp/test-events-http-${Date.now()}.sqlite`;
7
7
  const BASE = `http://localhost:${TEST_PORT}`;
8
+ const TEST_API_KEY = "test-events-http-key";
8
9
 
9
10
  let serverProc: Subprocess;
10
11
 
@@ -15,7 +16,10 @@ async function api(
15
16
  ): Promise<{ status: number; body: Record<string, unknown> }> {
16
17
  const res = await fetch(`${BASE}${path}`, {
17
18
  method,
18
- headers: { "Content-Type": "application/json" },
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ Authorization: `Bearer ${TEST_API_KEY}`,
22
+ },
19
23
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
20
24
  });
21
25
  const text = await res.text();
@@ -54,7 +58,7 @@ beforeAll(async () => {
54
58
  ...process.env,
55
59
  PORT: String(TEST_PORT),
56
60
  DATABASE_PATH: TEST_DB_PATH,
57
- API_KEY: "",
61
+ API_KEY: TEST_API_KEY,
58
62
  CAPABILITIES: "core",
59
63
  SLACK_BOT_TOKEN: "",
60
64
  GITHUB_WEBHOOK_SECRET: "",
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Tests for the runtime-config flags that control whether GitHub unassign and
3
+ * review-request-removed events terminate the linked swarm task.
4
+ *
5
+ * Flags (scope "global"):
6
+ * github.cancelOnUnassign — PR unassigned + issue unassigned
7
+ * github.cancelOnReviewRequestRemoved — PR review_request_removed
8
+ *
9
+ * Absent / any value ≠ "false" → cancel (current behavior, default).
10
+ * Value "false" → leave task untouched, return { created: false }.
11
+ */
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { unlink } from "node:fs/promises";
14
+ import {
15
+ closeDb,
16
+ createAgent,
17
+ createTaskExtended,
18
+ deleteSwarmConfig,
19
+ getDb,
20
+ getSwarmConfigs,
21
+ getTaskById,
22
+ initDb,
23
+ upsertSwarmConfig,
24
+ } from "../be/db";
25
+ import { handleIssue, handlePullRequest } from "../github/handlers";
26
+ import { GITHUB_BOT_NAME } from "../github/mentions";
27
+ import type { IssueEvent, PullRequestEvent } from "../github/types";
28
+
29
+ const TEST_DB_PATH = "./test-github-handlers-cancel-config.sqlite";
30
+
31
+ const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
32
+ const BASE_PR = {
33
+ number: 1,
34
+ title: "Test PR",
35
+ body: null as string | null,
36
+ html_url: "https://github.com/test/repo/pull/1",
37
+ user: { login: "sender" },
38
+ head: { ref: "feature", sha: "abc1234567890" },
39
+ base: { ref: "main" },
40
+ merged: false,
41
+ merged_by: undefined,
42
+ };
43
+ const BASE_ISSUE = {
44
+ number: 10,
45
+ title: "Test Issue",
46
+ body: null as string | null,
47
+ html_url: "https://github.com/test/repo/issues/10",
48
+ user: { login: "sender" },
49
+ };
50
+
51
+ // ── Setup ──
52
+
53
+ beforeAll(async () => {
54
+ await unlink(TEST_DB_PATH).catch(() => {});
55
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
56
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
57
+ initDb(TEST_DB_PATH);
58
+ createAgent({
59
+ id: "lead-cancel-config-test",
60
+ name: "CancelConfigTestLead",
61
+ status: "idle",
62
+ isLead: true,
63
+ });
64
+ });
65
+
66
+ afterAll(async () => {
67
+ closeDb();
68
+ await unlink(TEST_DB_PATH).catch(() => {});
69
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
70
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
71
+ });
72
+
73
+ // Clear tasks and config rows between tests.
74
+ beforeEach(() => {
75
+ const db = getDb();
76
+ db.prepare("DELETE FROM agent_tasks").run();
77
+ // Remove both flag rows so each test starts from a clean slate.
78
+ for (const key of ["github.cancelOnUnassign", "github.cancelOnReviewRequestRemoved"]) {
79
+ const rows = getSwarmConfigs({ scope: "global", key });
80
+ for (const row of rows) deleteSwarmConfig(row.id);
81
+ }
82
+ });
83
+
84
+ // ── Helpers ──
85
+
86
+ function seedTask(vcsNumber: number, kind: "pr" | "issue"): string {
87
+ const task = createTaskExtended("test task", {
88
+ agentId: "lead-cancel-config-test",
89
+ source: "github",
90
+ vcsProvider: "github",
91
+ vcsRepo: BASE_REPO.full_name,
92
+ vcsNumber,
93
+ vcsEventType: kind === "pr" ? "pull_request" : "issues",
94
+ });
95
+ return task.id;
96
+ }
97
+
98
+ function setConfigFlag(key: string, value: string) {
99
+ upsertSwarmConfig({ scope: "global", key, value });
100
+ }
101
+
102
+ function getTaskStatus(taskId: string): string | undefined {
103
+ return getTaskById(taskId)?.status;
104
+ }
105
+
106
+ // ── github.cancelOnUnassign — PR unassigned ──
107
+
108
+ describe("PR unassigned — github.cancelOnUnassign", () => {
109
+ test("default (no config row): unassign cancels the task", async () => {
110
+ const taskId = seedTask(BASE_PR.number, "pr");
111
+ expect(getTaskStatus(taskId)).toBe("pending");
112
+
113
+ const event: PullRequestEvent = {
114
+ action: "unassigned",
115
+ pull_request: { ...BASE_PR },
116
+ repository: BASE_REPO,
117
+ sender: { login: "someone" },
118
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
119
+ };
120
+ const result = await handlePullRequest(event);
121
+
122
+ expect(result.created).toBe(false);
123
+ expect(getTaskStatus(taskId)).toBe("failed");
124
+ });
125
+
126
+ test("config = 'false': unassign leaves task untouched", async () => {
127
+ setConfigFlag("github.cancelOnUnassign", "false");
128
+ const taskId = seedTask(BASE_PR.number, "pr");
129
+ expect(getTaskStatus(taskId)).toBe("pending");
130
+
131
+ const event: PullRequestEvent = {
132
+ action: "unassigned",
133
+ pull_request: { ...BASE_PR },
134
+ repository: BASE_REPO,
135
+ sender: { login: "someone" },
136
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
137
+ };
138
+ const result = await handlePullRequest(event);
139
+
140
+ expect(result.created).toBe(false);
141
+ // Task must NOT have been failed — still pending.
142
+ expect(getTaskStatus(taskId)).toBe("pending");
143
+ });
144
+ });
145
+
146
+ // ── github.cancelOnUnassign — issue unassigned ──
147
+
148
+ describe("issue unassigned — github.cancelOnUnassign", () => {
149
+ test("default (no config row): unassign cancels the task", async () => {
150
+ const taskId = seedTask(BASE_ISSUE.number, "issue");
151
+ expect(getTaskStatus(taskId)).toBe("pending");
152
+
153
+ const event: IssueEvent = {
154
+ action: "unassigned",
155
+ issue: { ...BASE_ISSUE },
156
+ repository: BASE_REPO,
157
+ sender: { login: "someone" },
158
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
159
+ };
160
+ const result = await handleIssue(event);
161
+
162
+ expect(result.created).toBe(false);
163
+ expect(getTaskStatus(taskId)).toBe("failed");
164
+ });
165
+
166
+ test("config = 'false': unassign leaves task untouched", async () => {
167
+ setConfigFlag("github.cancelOnUnassign", "false");
168
+ const taskId = seedTask(BASE_ISSUE.number, "issue");
169
+ expect(getTaskStatus(taskId)).toBe("pending");
170
+
171
+ const event: IssueEvent = {
172
+ action: "unassigned",
173
+ issue: { ...BASE_ISSUE },
174
+ repository: BASE_REPO,
175
+ sender: { login: "someone" },
176
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
177
+ };
178
+ const result = await handleIssue(event);
179
+
180
+ expect(result.created).toBe(false);
181
+ expect(getTaskStatus(taskId)).toBe("pending");
182
+ });
183
+ });
184
+
185
+ // ── github.cancelOnReviewRequestRemoved ──
186
+
187
+ describe("PR review_request_removed — github.cancelOnReviewRequestRemoved", () => {
188
+ test("default (no config row): review removal cancels the task", async () => {
189
+ const taskId = seedTask(BASE_PR.number, "pr");
190
+ expect(getTaskStatus(taskId)).toBe("pending");
191
+
192
+ const event: PullRequestEvent = {
193
+ action: "review_request_removed",
194
+ pull_request: { ...BASE_PR },
195
+ repository: BASE_REPO,
196
+ sender: { login: "someone" },
197
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
198
+ };
199
+ const result = await handlePullRequest(event);
200
+
201
+ expect(result.created).toBe(false);
202
+ expect(getTaskStatus(taskId)).toBe("failed");
203
+ });
204
+
205
+ test("config = 'false': review removal leaves task untouched", async () => {
206
+ setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
207
+ const taskId = seedTask(BASE_PR.number, "pr");
208
+ expect(getTaskStatus(taskId)).toBe("pending");
209
+
210
+ const event: PullRequestEvent = {
211
+ action: "review_request_removed",
212
+ pull_request: { ...BASE_PR },
213
+ repository: BASE_REPO,
214
+ sender: { login: "someone" },
215
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
216
+ };
217
+ const result = await handlePullRequest(event);
218
+
219
+ expect(result.created).toBe(false);
220
+ expect(getTaskStatus(taskId)).toBe("pending");
221
+ });
222
+ });
223
+
224
+ // ── Independence: flags do not bleed into each other ──
225
+
226
+ describe("flag independence", () => {
227
+ test("cancelOnUnassign=false does NOT affect review_request_removed (still cancels)", async () => {
228
+ // Only disable the unassign flag; leave review-request flag absent (default = cancel).
229
+ setConfigFlag("github.cancelOnUnassign", "false");
230
+ const taskId = seedTask(BASE_PR.number, "pr");
231
+
232
+ const event: PullRequestEvent = {
233
+ action: "review_request_removed",
234
+ pull_request: { ...BASE_PR },
235
+ repository: BASE_REPO,
236
+ sender: { login: "someone" },
237
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
238
+ };
239
+ await handlePullRequest(event);
240
+
241
+ // review_request_removed STILL cancels because its own flag is absent (default true).
242
+ expect(getTaskStatus(taskId)).toBe("failed");
243
+ });
244
+
245
+ test("cancelOnReviewRequestRemoved=false does NOT affect unassign (still cancels)", async () => {
246
+ // Only disable the review-request flag; leave unassign flag absent (default = cancel).
247
+ setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
248
+ const taskId = seedTask(BASE_PR.number, "pr");
249
+
250
+ const event: PullRequestEvent = {
251
+ action: "unassigned",
252
+ pull_request: { ...BASE_PR },
253
+ repository: BASE_REPO,
254
+ sender: { login: "someone" },
255
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
256
+ };
257
+ await handlePullRequest(event);
258
+
259
+ // unassigned STILL cancels because its own flag is absent (default true).
260
+ expect(getTaskStatus(taskId)).toBe("failed");
261
+ });
262
+ });