@desplega.ai/agent-swarm 1.111.0 → 1.112.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 (69) hide show
  1. package/dist/{actions-a4crgvtd.js → actions-eckb4g8r.js} +6 -6
  2. package/dist/{app-va5mr9yw.js → app-b05sgm02.js} +3 -3
  3. package/dist/{assistant-0jm4vy8r.js → assistant-7255sbyw.js} +9 -9
  4. package/dist/{boot-reembed-50zzk97q.js → boot-reembed-y1bt3ttn.js} +4 -4
  5. package/dist/{boot-reembed-0t815mmm.js → boot-reembed-yv7awg4y.js} +3 -3
  6. package/dist/{boot-scrub-logs-r99nbmxh.js → boot-scrub-logs-ddyaeqar.js} +2 -2
  7. package/dist/{cli-aqr96mny.js → cli-0c3n0eba.js} +1 -1
  8. package/dist/{cli-dza5039b.js → cli-15598a4k.js} +299 -38
  9. package/dist/{cli-jrmjxsnk.js → cli-30d2ct3k.js} +3 -3
  10. package/dist/{cli-4f6vw5vy.js → cli-3r5y6ayj.js} +2 -2
  11. package/dist/{cli-3cm8ar1c.js → cli-52tbq17n.js} +1 -1
  12. package/dist/{cli-gchg43wt.js → cli-56s4ehzw.js} +1 -1
  13. package/dist/{cli-0d5xddf1.js → cli-709t753c.js} +1 -1
  14. package/dist/{cli-xqzvygxp.js → cli-76d1vtvq.js} +11 -2
  15. package/dist/{cli-0d404x64.js → cli-7xkwgkrs.js} +1 -1
  16. package/dist/{cli-kavm2cv5.js → cli-8f0dwca2.js} +1 -1
  17. package/dist/{cli-r3y5chyg.js → cli-94vzr3nq.js} +4 -4
  18. package/dist/{cli-4ke1amr9.js → cli-951w7c79.js} +1 -1
  19. package/dist/{cli-2b0ew5v8.js → cli-9p0qhead.js} +1 -1
  20. package/dist/{cli-z626gjqj.js → cli-a5e8n5hg.js} +1 -1
  21. package/dist/{cli-y7v6e7yr.js → cli-h9jx5mag.js} +4 -4
  22. package/dist/{cli-f1f82qgv.js → cli-hm27prrc.js} +5 -5
  23. package/dist/{cli-t8m00q3b.js → cli-pt7c7qxz.js} +3 -3
  24. package/dist/{cli-ee3fkebs.js → cli-r1btc895.js} +2 -2
  25. package/dist/{cli-6tfm5fr1.js → cli-rp8pca98.js} +1 -1
  26. package/dist/{cli-h3vhr1gj.js → cli-s9pj3c7z.js} +2 -2
  27. package/dist/{cli-8j1h9627.js → cli-tch0ypp6.js} +61 -14
  28. package/dist/{cli-1aqc46he.js → cli-tg0f2wmz.js} +5 -5
  29. package/dist/{cli-fdnpymsv.js → cli-tp7e60s3.js} +4 -2
  30. package/dist/cli.js +8 -8
  31. package/dist/{commands-t3rdvb55.js → commands-14g7taf4.js} +2 -2
  32. package/dist/{db-mjqc8hj6.js → db-3tge4jrn.js} +2 -2
  33. package/dist/{handlers-mcmg5qgc.js → handlers-sy64egg6.js} +9 -9
  34. package/dist/{hook-0p4q1tym.js → hook-rnkzw8vp.js} +1 -1
  35. package/dist/{http-gs6fk9sg.js → http-xx9xptcs.js} +33 -31
  36. package/dist/{index-w15fk28m.js → index-3wr6v2mm.js} +7 -7
  37. package/dist/{index-744yvc8x.js → index-brma88d4.js} +9 -9
  38. package/dist/{index-6zpajd9e.js → index-ha8e9xn2.js} +6 -6
  39. package/dist/{index-20mf9wvc.js → index-zfx5p959.js} +8 -8
  40. package/dist/{keepalive-3gvnbxaj.js → keepalive-rbz05zad.js} +4 -4
  41. package/dist/{lead-yjbmhj3g.js → lead-s790v7r0.js} +17 -17
  42. package/dist/{maintenance-jbvy2bfg.js → maintenance-420rmadb.js} +4 -4
  43. package/dist/{onboard-sexatejh.js → onboard-jgqamqnw.js} +2 -2
  44. package/dist/{otel-impl-sgqvcw0f.js → otel-impl-ch3n3783.js} +1 -1
  45. package/dist/{pricing-refresh-rat240yg.js → pricing-refresh-rmtrby6h.js} +4 -4
  46. package/dist/{seed-pricing-7yfaf123.js → seed-pricing-1szjzfj1.js} +3 -3
  47. package/dist/{setup-vm5vcc6c.js → setup-8jfzkyzv.js} +2 -2
  48. package/dist/{worker-02y2r0r2.js → worker-40vq0pm8.js} +17 -17
  49. package/openapi.json +1 -1
  50. package/package.json +1 -1
  51. package/src/be/scripts/typecheck.ts +2 -0
  52. package/src/commands/runner.ts +154 -8
  53. package/src/github/handlers.ts +128 -25
  54. package/src/scripts-runtime/sdk-allowlist.ts +2 -0
  55. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  56. package/src/server.ts +4 -0
  57. package/src/slack/message-text.ts +25 -0
  58. package/src/tests/github-event-filter.test.ts +3 -2
  59. package/src/tests/github-handlers-inline-comments.test.ts +114 -24
  60. package/src/tests/runner-repo-autostash.test.ts +168 -1
  61. package/src/tests/slack-delete.test.ts +157 -0
  62. package/src/tests/slack-message-text.test.ts +37 -1
  63. package/src/tests/slack-update.test.ts +167 -0
  64. package/src/tests/tool-annotations.test.ts +3 -0
  65. package/src/tools/slack-delete.ts +102 -0
  66. package/src/tools/slack-reply.ts +11 -3
  67. package/src/tools/slack-start-thread.ts +2 -0
  68. package/src/tools/slack-update.ts +127 -0
  69. package/src/tools/tool-config.ts +3 -1
@@ -4,13 +4,17 @@
4
4
  * Covers:
5
5
  * - CHANGES_REQUESTED review with inline comments → task description includes them
6
6
  * - commented-no-body-with-inline-comments → task is created (not skipped)
7
- * - commented-no-body-no-inline-comments → task is skipped (existing behavior preserved)
8
- * - commented-no-body-no-installation → graceful fallback, task is skipped
7
+ * - commented-no-body-no-inline-comments with trusted fetch → task is skipped
8
+ * - degraded fetch/no installation → task is created with a loud manual-fetch block
9
9
  */
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
11
11
  import { unlink } from "node:fs/promises";
12
12
  import { closeDb, createAgent, getDb, initDb } from "../be/db";
13
- import { handleComment, handlePullRequestReview } from "../github/handlers";
13
+ import {
14
+ handleComment,
15
+ handlePullRequestReview,
16
+ setReviewCommentsRetryDelayForTests,
17
+ } from "../github/handlers";
14
18
  import { GITHUB_BOT_NAME } from "../github/mentions";
15
19
  import type { CommentEvent, PullRequestReviewEvent } from "../github/types";
16
20
  import { getTemplateDefinition } from "../prompts/registry";
@@ -55,6 +59,7 @@ beforeAll(async () => {
55
59
  });
56
60
 
57
61
  afterAll(async () => {
62
+ setReviewCommentsRetryDelayForTests();
58
63
  closeDb();
59
64
  await unlink(TEST_DB_PATH).catch(() => {});
60
65
  await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
@@ -63,6 +68,7 @@ afterAll(async () => {
63
68
 
64
69
  beforeEach(async () => {
65
70
  await ensureTemplatesRegistered();
71
+ setReviewCommentsRetryDelayForTests(() => {});
66
72
  getDb().prepare("DELETE FROM agent_tasks").run();
67
73
  });
68
74
 
@@ -72,6 +78,16 @@ const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/r
72
78
 
73
79
  let reviewIdCounter = 9000;
74
80
 
81
+ type TestInlineComment = {
82
+ id: number;
83
+ path: string;
84
+ line: number | null;
85
+ body: string;
86
+ html_url: string;
87
+ diff_hunk: string;
88
+ pull_request_review_id?: number | null;
89
+ };
90
+
75
91
  function makeReviewEvent(opts: {
76
92
  state: "changes_requested" | "commented";
77
93
  body: string | null;
@@ -104,7 +120,7 @@ function makeReviewEvent(opts: {
104
120
  };
105
121
  }
106
122
 
107
- const SAMPLE_INLINE_COMMENTS = [
123
+ const SAMPLE_INLINE_COMMENTS: TestInlineComment[] = [
108
124
  {
109
125
  id: 1001,
110
126
  path: "src/domain_tables.go",
@@ -123,16 +139,16 @@ const SAMPLE_INLINE_COMMENTS = [
123
139
  },
124
140
  ];
125
141
 
126
- function mockFetchWithComments(
127
- comments: typeof SAMPLE_INLINE_COMMENTS | [],
128
- ): ReturnType<typeof spyOn> {
129
- return spyOn(globalThis, "fetch").mockImplementationOnce(
130
- async () =>
131
- new Response(JSON.stringify(comments), {
132
- status: 200,
133
- headers: { "Content-Type": "application/json" },
134
- }),
135
- );
142
+ function commentsResponse(comments: TestInlineComment[]): Response {
143
+ return new Response(JSON.stringify(comments), {
144
+ status: 200,
145
+ headers: { "Content-Type": "application/json" },
146
+ });
147
+ }
148
+
149
+ function mockFetchWithComments(comments: TestInlineComment[]): ReturnType<typeof spyOn> {
150
+ const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(async () => commentsResponse([]));
151
+ return fetchSpy.mockImplementationOnce(async () => commentsResponse(comments));
136
152
  }
137
153
 
138
154
  function getLastTaskText(): string | undefined {
@@ -142,10 +158,19 @@ function getLastTaskText(): string | undefined {
142
158
  return row?.task;
143
159
  }
144
160
 
161
+ function expectDegradedBlock(text: string | undefined): void {
162
+ expect(text).toContain("## ⚠️ Inline comments could NOT be auto-fetched");
163
+ expect(text).toContain(
164
+ "gh api \"repos/test/repo/pulls/99/comments?per_page=100\" --jq '.[] | {id,path,line,body}'",
165
+ );
166
+ expect(text).toContain("Do NOT dispatch off the review body alone");
167
+ expect(text).toContain("Address EVERY inline comment");
168
+ }
169
+
145
170
  // ── Tests ──
146
171
 
147
172
  describe("inline review comment surfacing", () => {
148
- test("CHANGES_REQUESTED review with inline comments: task includes path:line and bodies", async () => {
173
+ test("happy path: CHANGES_REQUESTED review with inline comments includes path:line and bodies", async () => {
149
174
  const fetchSpy = mockFetchWithComments(SAMPLE_INLINE_COMMENTS);
150
175
 
151
176
  const event = makeReviewEvent({
@@ -166,6 +191,29 @@ describe("inline review comment surfacing", () => {
166
191
  expect(text).toContain("Why is this hardcoded?");
167
192
  expect(text).toContain("Inline review comments");
168
193
  expect(text).toContain("reply to and resolve each inline review thread");
194
+ expect(text).not.toContain("Inline comments could NOT be auto-fetched");
195
+ });
196
+
197
+ test("consistency lag: empty review-scoped fetch retries and embeds later comments", async () => {
198
+ const fetchSpy = spyOn(globalThis, "fetch")
199
+ .mockImplementation(async () => commentsResponse([]))
200
+ .mockImplementationOnce(async () => commentsResponse([]))
201
+ .mockImplementationOnce(async () => commentsResponse([SAMPLE_INLINE_COMMENTS[0]]));
202
+
203
+ const event = makeReviewEvent({
204
+ state: "changes_requested",
205
+ body: "Please handle the line note",
206
+ installationId: 123,
207
+ });
208
+ const result = await handlePullRequestReview(event);
209
+
210
+ expect(result.created).toBe(true);
211
+ fetchSpy.mockRestore();
212
+
213
+ const text = getLastTaskText();
214
+ expect(text).toContain("src/domain_tables.go:77");
215
+ expect(text).toContain("This logic looks wrong");
216
+ expect(text).not.toContain("Inline comments could NOT be auto-fetched");
169
217
  });
170
218
 
171
219
  test("commented review with no body but with inline comments: task is created, not skipped", async () => {
@@ -180,26 +228,36 @@ describe("inline review comment surfacing", () => {
180
228
  });
181
229
 
182
230
  test("commented review with no body and no inline comments: task is skipped", async () => {
183
- const fetchSpy = mockFetchWithComments([]);
231
+ const fetchSpy = spyOn(globalThis, "fetch")
232
+ .mockImplementation(async () => commentsResponse([]))
233
+ .mockImplementationOnce(async () => commentsResponse([]))
234
+ .mockImplementationOnce(async () => commentsResponse([]))
235
+ .mockImplementationOnce(async () => commentsResponse([]))
236
+ .mockImplementationOnce(async () => commentsResponse([]))
237
+ .mockImplementationOnce(async () => commentsResponse([]));
184
238
 
185
239
  const event = makeReviewEvent({ state: "commented", body: null, installationId: 123 });
186
240
  const result = await handlePullRequestReview(event);
187
241
 
242
+ expect(fetchSpy).toHaveBeenCalledTimes(5);
188
243
  fetchSpy.mockRestore();
189
244
 
190
245
  expect(result.created).toBe(false);
191
246
  });
192
247
 
193
- test("commented review with no body and no installation: graceful fallback, task is skipped", async () => {
248
+ test("commented review with no body and no installation: degraded task is created", async () => {
194
249
  const event = makeReviewEvent({ state: "commented", body: null });
195
250
  const result = await handlePullRequestReview(event);
196
- expect(result.created).toBe(false);
251
+
252
+ expect(result.created).toBe(true);
253
+ expectDegradedBlock(getLastTaskText());
197
254
  });
198
255
 
199
- test("review comments fetch failure: task still created (graceful degradation)", async () => {
200
- const fetchSpy = spyOn(globalThis, "fetch").mockImplementationOnce(
201
- async () => new Response("Internal Server Error", { status: 500 }),
202
- );
256
+ test("non-2xx review-scoped fetch: degraded block is present", async () => {
257
+ const fetchSpy = spyOn(globalThis, "fetch")
258
+ .mockImplementation(async () => commentsResponse([]))
259
+ .mockImplementationOnce(async () => new Response("Internal Server Error", { status: 500 }))
260
+ .mockImplementationOnce(async () => commentsResponse([]));
203
261
 
204
262
  const event = makeReviewEvent({
205
263
  state: "changes_requested",
@@ -214,8 +272,39 @@ describe("inline review comment surfacing", () => {
214
272
  expect(result.created).toBe(true);
215
273
  const text = getLastTaskText();
216
274
  expect(text).toContain("Needs work");
217
- // No inline comments section when fetch failed
218
- expect(text).not.toContain("Inline review comments");
275
+ expectDegradedBlock(text);
276
+ expect(text).not.toContain("Inline review comments (");
277
+ });
278
+
279
+ test("PR-level fallback embeds a review comment missed by review-scoped fetch", async () => {
280
+ const event = makeReviewEvent({
281
+ state: "changes_requested",
282
+ body: "Review-scoped path missed this",
283
+ installationId: 123,
284
+ });
285
+ const fallbackComment = {
286
+ ...SAMPLE_INLINE_COMMENTS[1],
287
+ pull_request_review_id: event.review.id,
288
+ };
289
+ const fetchSpy = spyOn(globalThis, "fetch")
290
+ .mockImplementation(async () => commentsResponse([]))
291
+ .mockImplementationOnce(async () => commentsResponse([]))
292
+ .mockImplementationOnce(async () => commentsResponse([]))
293
+ .mockImplementationOnce(async () => commentsResponse([]))
294
+ .mockImplementationOnce(async () => commentsResponse([]))
295
+ .mockImplementationOnce(async () => commentsResponse([fallbackComment]));
296
+
297
+ const result = await handlePullRequestReview(event);
298
+
299
+ fetchSpy.mockRestore();
300
+
301
+ expect(result.created).toBe(true);
302
+ const text = getLastTaskText();
303
+ expect(text).toContain("config/table-renderers.json:7");
304
+ expect(text).toContain("Why is this hardcoded?");
305
+ expect(text).toContain("Inline review comments (1)");
306
+ expect(text).toContain("Address EVERY inline comment");
307
+ expect(text).not.toContain("Inline comments could NOT be auto-fetched");
219
308
  });
220
309
 
221
310
  test("pagination: two-page review comment response surfaces all comments from both pages", async () => {
@@ -225,6 +314,7 @@ describe("inline review comment surfacing", () => {
225
314
  "https://api.github.com/repos/test/repo/pulls/99/reviews/9001/comments?per_page=100&page=2";
226
315
 
227
316
  const fetchSpy = spyOn(globalThis, "fetch")
317
+ .mockImplementation(async () => commentsResponse([]))
228
318
  .mockImplementationOnce(
229
319
  async () =>
230
320
  new Response(JSON.stringify(page1), {
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { ensureRepoForTask } from "../commands/runner";
7
+ import { ensureRepoForTask, isFirstKickoffTask } from "../commands/runner";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
10
10
 
@@ -145,4 +145,171 @@ describe("ensureRepoForTask auto-stash refresh", () => {
145
145
  expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote commit\n");
146
146
  expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
147
147
  });
148
+
149
+ test("first kickoff hard-resets a leftover feature branch back to origin/default", async () => {
150
+ const remotePath = join(tempRoot, "remote.git");
151
+ const upstreamPath = join(tempRoot, "upstream");
152
+ const clonePath = join(tempRoot, "clone");
153
+
154
+ await gitRaw(["init", "--bare", remotePath]);
155
+ await mkdir(upstreamPath);
156
+ await git(upstreamPath, ["init", "-b", "main"]);
157
+ await configureIdentity(upstreamPath);
158
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
159
+ await commitAll(upstreamPath, "initial commit");
160
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
161
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
162
+
163
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
164
+ await configureIdentity(clonePath);
165
+
166
+ // Simulate a prior task leaving a diverged feature branch checked out —
167
+ // this is the scenario that used to abort the merge and strand the repo.
168
+ await git(clonePath, ["checkout", "-b", "leftover-feature"]);
169
+ await writeFile(join(clonePath, "feature.txt"), "leftover work\n");
170
+ await commitAll(clonePath, "leftover feature commit");
171
+
172
+ await writeFile(join(upstreamPath, "remote.txt"), "remote commit\n");
173
+ await commitAll(upstreamPath, "remote commit");
174
+ await git(upstreamPath, ["push", "origin", "main"]);
175
+
176
+ const result = await withCleanGitEnv(() =>
177
+ ensureRepoForTask(
178
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
179
+ "test",
180
+ true,
181
+ ),
182
+ );
183
+
184
+ expect(result.warning).toBeNull();
185
+ expect((await git(clonePath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()).toBe("main");
186
+ expect((await git(clonePath, ["rev-parse", "HEAD"])).trim()).toBe(
187
+ (await git(upstreamPath, ["rev-parse", "HEAD"])).trim(),
188
+ );
189
+ expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote commit\n");
190
+ expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
191
+ });
192
+
193
+ test("resume/continuation (isFirstKickoff=false) leaves a leftover feature branch checked out", async () => {
194
+ const remotePath = join(tempRoot, "remote.git");
195
+ const upstreamPath = join(tempRoot, "upstream");
196
+ const clonePath = join(tempRoot, "clone");
197
+
198
+ await gitRaw(["init", "--bare", remotePath]);
199
+ await mkdir(upstreamPath);
200
+ await git(upstreamPath, ["init", "-b", "main"]);
201
+ await configureIdentity(upstreamPath);
202
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
203
+ await commitAll(upstreamPath, "initial commit");
204
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
205
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
206
+
207
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
208
+ await configureIdentity(clonePath);
209
+
210
+ await git(clonePath, ["checkout", "-b", "in-progress-feature"]);
211
+ await writeFile(join(clonePath, "feature.txt"), "in-progress work\n");
212
+ await commitAll(clonePath, "in-progress feature commit");
213
+
214
+ const result = await withCleanGitEnv(() =>
215
+ ensureRepoForTask(
216
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
217
+ "test",
218
+ false,
219
+ ),
220
+ );
221
+
222
+ expect(result.warning).toBeNull();
223
+ expect((await git(clonePath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()).toBe(
224
+ "in-progress-feature",
225
+ );
226
+ expect(await readFile(join(clonePath, "feature.txt"), "utf8")).toBe("in-progress work\n");
227
+ });
228
+ });
229
+
230
+ describe("isFirstKickoffTask", () => {
231
+ test("a feature task with a parentTaskId but an inactive/unknown parent is still a first kickoff", async () => {
232
+ expect(
233
+ await isFirstKickoffTask({ parentTaskId: "parent-1", taskType: "feature" }, async () => null),
234
+ ).toBe(true);
235
+ });
236
+
237
+ test("a pr-fix task with a parentTaskId but an inactive/unknown parent is still a first kickoff", async () => {
238
+ expect(
239
+ await isFirstKickoffTask({ parentTaskId: "parent-1", taskType: "pr-fix" }, async () => null),
240
+ ).toBe(true);
241
+ });
242
+
243
+ test("a parentTaskId with no status-checker at all is still a first kickoff (taskType alone decides)", async () => {
244
+ expect(await isFirstKickoffTask({ parentTaskId: "parent-1", taskType: "feature" })).toBe(true);
245
+ });
246
+
247
+ test("an explicit resume task is never a first kickoff", async () => {
248
+ expect(await isFirstKickoffTask({ taskType: "resume" })).toBe(false);
249
+ });
250
+
251
+ test("a brand-new top-level task with no fields at all is a first kickoff", async () => {
252
+ expect(await isFirstKickoffTask({})).toBe(true);
253
+ });
254
+
255
+ test("undefined/null task is a first kickoff (safe default)", async () => {
256
+ expect(await isFirstKickoffTask(undefined)).toBe(true);
257
+ expect(await isFirstKickoffTask(null)).toBe(true);
258
+ });
259
+
260
+ test("known continuation task types are never a first kickoff, regardless of parent status", async () => {
261
+ const continuationTypes = [
262
+ "follow-up",
263
+ "reroute-decision",
264
+ "agentmail-reply",
265
+ "github-comment",
266
+ "github-review",
267
+ "gitlab-comment",
268
+ "gitlab-ci",
269
+ ];
270
+ for (const taskType of continuationTypes) {
271
+ expect(
272
+ await isFirstKickoffTask({ parentTaskId: "parent-1", taskType }, async () => null),
273
+ ).toBe(false);
274
+ }
275
+ });
276
+
277
+ test("a genuinely new root task type (e.g. agentmail-message, no reserved taskType) stays a first kickoff", async () => {
278
+ expect(await isFirstKickoffTask({ taskType: "agentmail-message" }, async () => null)).toBe(
279
+ true,
280
+ );
281
+ });
282
+
283
+ // Regression: a parent-linked NON-resume follow-up (Slack follow-ups and
284
+ // sibling-awareness both wire parentTaskId with no reserved taskType at
285
+ // all) must NOT take the hard-reset path when the parent it's continuing
286
+ // is still active — e.g. an in-progress sibling on the same worker with
287
+ // maxTasks > 1, or a paused task that may resume onto the same clone.
288
+ test("a Slack-style follow-up (no distinguishing taskType) with an in-progress parent is NOT a first kickoff", async () => {
289
+ const fetchParentTaskStatus = async (parentTaskId: string) => {
290
+ expect(parentTaskId).toBe("active-parent-1");
291
+ return "in_progress";
292
+ };
293
+ expect(
294
+ await isFirstKickoffTask({ parentTaskId: "active-parent-1" }, fetchParentTaskStatus),
295
+ ).toBe(false);
296
+ });
297
+
298
+ test("a sibling-awareness-linked task with a paused parent is NOT a first kickoff", async () => {
299
+ expect(
300
+ await isFirstKickoffTask(
301
+ { parentTaskId: "paused-parent-1", taskType: "github-pr" },
302
+ async () => "paused",
303
+ ),
304
+ ).toBe(false);
305
+ });
306
+
307
+ test("a parent-linked task whose parent already completed IS a first kickoff", async () => {
308
+ expect(
309
+ await isFirstKickoffTask(
310
+ { parentTaskId: "done-parent-1", taskType: "feature" },
311
+ async () => "completed",
312
+ ),
313
+ ).toBe(true);
314
+ });
148
315
  });
@@ -0,0 +1,157 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { closeDb, createAgent, initDb } from "../be/db";
5
+ import { registerSlackDeleteTool } from "../tools/slack-delete";
6
+
7
+ const TEST_DB_PATH = "./test-slack-delete.sqlite";
8
+
9
+ const mockChatDelete = mock(() => Promise.resolve({ ok: true }));
10
+ const mockGetSlackApp = mock(() => ({ client: { chat: { delete: mockChatDelete } } }));
11
+
12
+ mock.module("../slack/app", () => ({
13
+ getSlackApp: mockGetSlackApp,
14
+ }));
15
+
16
+ type RegisteredTool = {
17
+ handler: (
18
+ args: unknown,
19
+ extra: unknown,
20
+ ) => Promise<{
21
+ structuredContent: { success: boolean; message: string };
22
+ }>;
23
+ };
24
+
25
+ function buildTool() {
26
+ const server = new McpServer({ name: "slack-delete-test", version: "1.0.0" });
27
+ registerSlackDeleteTool(server);
28
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
29
+ ._registeredTools;
30
+ const tool = registered["slack-delete"];
31
+ if (!tool) throw new Error("slack-delete tool not registered");
32
+ return tool;
33
+ }
34
+
35
+ function meta(agentId?: string) {
36
+ return { sessionId: "s1", requestInfo: { headers: agentId ? { "x-agent-id": agentId } : {} } };
37
+ }
38
+
39
+ describe("slack-delete", () => {
40
+ let leadAgentId: string;
41
+ let nonLeadAgentId: string;
42
+
43
+ beforeAll(async () => {
44
+ for (const suffix of ["", "-wal", "-shm"]) {
45
+ try {
46
+ await unlink(`${TEST_DB_PATH}${suffix}`);
47
+ } catch {}
48
+ }
49
+ initDb(TEST_DB_PATH);
50
+ leadAgentId = createAgent({ name: "Lead", isLead: true, status: "idle" }).id;
51
+ nonLeadAgentId = createAgent({ name: "Worker", isLead: false, status: "idle" }).id;
52
+ });
53
+
54
+ beforeEach(() => {
55
+ mockChatDelete.mockClear();
56
+ mockGetSlackApp.mockClear();
57
+ });
58
+
59
+ afterAll(async () => {
60
+ closeDb();
61
+ for (const suffix of ["", "-wal", "-shm"]) {
62
+ try {
63
+ await unlink(`${TEST_DB_PATH}${suffix}`);
64
+ } catch {}
65
+ }
66
+ });
67
+
68
+ test("rejects when agentId header is missing, without touching Slack", async () => {
69
+ const tool = buildTool();
70
+ const result = await tool.handler({ channelId: "C1", messageTs: "1783411554.596189" }, meta());
71
+
72
+ expect(result.structuredContent.success).toBe(false);
73
+ expect(result.structuredContent.message).toBe("Agent ID not found.");
74
+ expect(mockGetSlackApp).not.toHaveBeenCalled();
75
+ expect(mockChatDelete).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test("rejects when the agent does not exist, without touching Slack", async () => {
79
+ const tool = buildTool();
80
+ const result = await tool.handler(
81
+ { channelId: "C1", messageTs: "1783411554.596189" },
82
+ meta("00000000-0000-0000-0000-000000000000"),
83
+ );
84
+
85
+ expect(result.structuredContent.success).toBe(false);
86
+ expect(result.structuredContent.message).toBe("Agent not found.");
87
+ expect(mockGetSlackApp).not.toHaveBeenCalled();
88
+ expect(mockChatDelete).not.toHaveBeenCalled();
89
+ });
90
+
91
+ test("rejects non-lead agents without touching Slack", async () => {
92
+ const tool = buildTool();
93
+ const result = await tool.handler(
94
+ { channelId: "C1", messageTs: "1783411554.596189" },
95
+ meta(nonLeadAgentId),
96
+ );
97
+
98
+ expect(result.structuredContent.success).toBe(false);
99
+ expect(result.structuredContent.message).toBe(
100
+ "Deleting Slack messages requires lead privileges.",
101
+ );
102
+ expect(mockGetSlackApp).not.toHaveBeenCalled();
103
+ expect(mockChatDelete).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test("lead caller deletes using the normalized timestamp", async () => {
107
+ const tool = buildTool();
108
+ // 'p' deep-link form must be normalized to the dotted API form before chat.delete.
109
+ const result = await tool.handler(
110
+ { channelId: "C1", messageTs: "p1783411554596189" },
111
+ meta(leadAgentId),
112
+ );
113
+
114
+ expect(result.structuredContent.success).toBe(true);
115
+ expect(mockChatDelete).toHaveBeenCalledTimes(1);
116
+ expect(mockChatDelete.mock.calls[0][0]).toEqual({
117
+ channel: "C1",
118
+ ts: "1783411554.596189",
119
+ });
120
+ });
121
+
122
+ test.each([
123
+ ["message_not_found", "No message found at that timestamp in this channel."],
124
+ [
125
+ "cant_delete_message",
126
+ "Cannot delete this message — the bot can only delete messages it authored.",
127
+ ],
128
+ ["channel_not_found", "Channel not found or the bot has no access."],
129
+ ["not_in_channel", "The bot is not in that channel."],
130
+ ])("maps Slack error %s to a structured failure message", async (errorCode, expectedMessage) => {
131
+ mockChatDelete.mockImplementationOnce(() =>
132
+ Promise.reject(Object.assign(new Error("slack error"), { data: { error: errorCode } })),
133
+ );
134
+
135
+ const tool = buildTool();
136
+ const result = await tool.handler(
137
+ { channelId: "C1", messageTs: "1783411554.596189" },
138
+ meta(leadAgentId),
139
+ );
140
+
141
+ expect(result.structuredContent.success).toBe(false);
142
+ expect(result.structuredContent.message).toBe(expectedMessage);
143
+ });
144
+
145
+ test("falls back to the raw error message for unmapped Slack error codes", async () => {
146
+ mockChatDelete.mockImplementationOnce(() => Promise.reject(new Error("boom")));
147
+
148
+ const tool = buildTool();
149
+ const result = await tool.handler(
150
+ { channelId: "C1", messageTs: "1783411554.596189" },
151
+ meta(leadAgentId),
152
+ );
153
+
154
+ expect(result.structuredContent.success).toBe(false);
155
+ expect(result.structuredContent.message).toBe("Failed to delete message: boom");
156
+ });
157
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { extractSlackMessageText } from "../slack/message-text";
2
+ import { extractSlackMessageText, parseSlackTs } from "../slack/message-text";
3
3
 
4
4
  describe("extractSlackMessageText", () => {
5
5
  test("returns top-level text when present", () => {
@@ -454,3 +454,39 @@ describe("extractSlackMessageText", () => {
454
454
  });
455
455
  });
456
456
  });
457
+
458
+ describe("parseSlackTs", () => {
459
+ test("passes the dotted API form through unchanged", () => {
460
+ expect(parseSlackTs("1783411554.596189")).toBe("1783411554.596189");
461
+ });
462
+
463
+ test("converts the 'p' deep-link form back to dotted", () => {
464
+ expect(parseSlackTs("p1783411554596189")).toBe("1783411554.596189");
465
+ });
466
+
467
+ test("converts a bare digit run back to dotted", () => {
468
+ expect(parseSlackTs("1783411554596189")).toBe("1783411554.596189");
469
+ });
470
+
471
+ test("extracts the ts from a full permalink URL", () => {
472
+ expect(parseSlackTs("https://example.slack.com/archives/C0123456789/p1783411554596189")).toBe(
473
+ "1783411554.596189",
474
+ );
475
+ });
476
+
477
+ test("extracts the ts from a permalink URL with a thread_ts query param", () => {
478
+ expect(
479
+ parseSlackTs(
480
+ "https://example.slack.com/archives/C0123456789/p1783411554596189?thread_ts=1783411000.000100&cid=C0123456789",
481
+ ),
482
+ ).toBe("1783411554.596189");
483
+ });
484
+
485
+ test("trims surrounding whitespace before parsing", () => {
486
+ expect(parseSlackTs(" 1783411554.596189 ")).toBe("1783411554.596189");
487
+ });
488
+
489
+ test("falls through unchanged for unrecognized input", () => {
490
+ expect(parseSlackTs("not-a-ts")).toBe("not-a-ts");
491
+ });
492
+ });