@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.
- package/dist/{actions-a4crgvtd.js → actions-eckb4g8r.js} +6 -6
- package/dist/{app-va5mr9yw.js → app-b05sgm02.js} +3 -3
- package/dist/{assistant-0jm4vy8r.js → assistant-7255sbyw.js} +9 -9
- package/dist/{boot-reembed-50zzk97q.js → boot-reembed-y1bt3ttn.js} +4 -4
- package/dist/{boot-reembed-0t815mmm.js → boot-reembed-yv7awg4y.js} +3 -3
- package/dist/{boot-scrub-logs-r99nbmxh.js → boot-scrub-logs-ddyaeqar.js} +2 -2
- package/dist/{cli-aqr96mny.js → cli-0c3n0eba.js} +1 -1
- package/dist/{cli-dza5039b.js → cli-15598a4k.js} +299 -38
- package/dist/{cli-jrmjxsnk.js → cli-30d2ct3k.js} +3 -3
- package/dist/{cli-4f6vw5vy.js → cli-3r5y6ayj.js} +2 -2
- package/dist/{cli-3cm8ar1c.js → cli-52tbq17n.js} +1 -1
- package/dist/{cli-gchg43wt.js → cli-56s4ehzw.js} +1 -1
- package/dist/{cli-0d5xddf1.js → cli-709t753c.js} +1 -1
- package/dist/{cli-xqzvygxp.js → cli-76d1vtvq.js} +11 -2
- package/dist/{cli-0d404x64.js → cli-7xkwgkrs.js} +1 -1
- package/dist/{cli-kavm2cv5.js → cli-8f0dwca2.js} +1 -1
- package/dist/{cli-r3y5chyg.js → cli-94vzr3nq.js} +4 -4
- package/dist/{cli-4ke1amr9.js → cli-951w7c79.js} +1 -1
- package/dist/{cli-2b0ew5v8.js → cli-9p0qhead.js} +1 -1
- package/dist/{cli-z626gjqj.js → cli-a5e8n5hg.js} +1 -1
- package/dist/{cli-y7v6e7yr.js → cli-h9jx5mag.js} +4 -4
- package/dist/{cli-f1f82qgv.js → cli-hm27prrc.js} +5 -5
- package/dist/{cli-t8m00q3b.js → cli-pt7c7qxz.js} +3 -3
- package/dist/{cli-ee3fkebs.js → cli-r1btc895.js} +2 -2
- package/dist/{cli-6tfm5fr1.js → cli-rp8pca98.js} +1 -1
- package/dist/{cli-h3vhr1gj.js → cli-s9pj3c7z.js} +2 -2
- package/dist/{cli-8j1h9627.js → cli-tch0ypp6.js} +61 -14
- package/dist/{cli-1aqc46he.js → cli-tg0f2wmz.js} +5 -5
- package/dist/{cli-fdnpymsv.js → cli-tp7e60s3.js} +4 -2
- package/dist/cli.js +8 -8
- package/dist/{commands-t3rdvb55.js → commands-14g7taf4.js} +2 -2
- package/dist/{db-mjqc8hj6.js → db-3tge4jrn.js} +2 -2
- package/dist/{handlers-mcmg5qgc.js → handlers-sy64egg6.js} +9 -9
- package/dist/{hook-0p4q1tym.js → hook-rnkzw8vp.js} +1 -1
- package/dist/{http-gs6fk9sg.js → http-xx9xptcs.js} +33 -31
- package/dist/{index-w15fk28m.js → index-3wr6v2mm.js} +7 -7
- package/dist/{index-744yvc8x.js → index-brma88d4.js} +9 -9
- package/dist/{index-6zpajd9e.js → index-ha8e9xn2.js} +6 -6
- package/dist/{index-20mf9wvc.js → index-zfx5p959.js} +8 -8
- package/dist/{keepalive-3gvnbxaj.js → keepalive-rbz05zad.js} +4 -4
- package/dist/{lead-yjbmhj3g.js → lead-s790v7r0.js} +17 -17
- package/dist/{maintenance-jbvy2bfg.js → maintenance-420rmadb.js} +4 -4
- package/dist/{onboard-sexatejh.js → onboard-jgqamqnw.js} +2 -2
- package/dist/{otel-impl-sgqvcw0f.js → otel-impl-ch3n3783.js} +1 -1
- package/dist/{pricing-refresh-rat240yg.js → pricing-refresh-rmtrby6h.js} +4 -4
- package/dist/{seed-pricing-7yfaf123.js → seed-pricing-1szjzfj1.js} +3 -3
- package/dist/{setup-vm5vcc6c.js → setup-8jfzkyzv.js} +2 -2
- package/dist/{worker-02y2r0r2.js → worker-40vq0pm8.js} +17 -17
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/scripts/typecheck.ts +2 -0
- package/src/commands/runner.ts +154 -8
- package/src/github/handlers.ts +128 -25
- package/src/scripts-runtime/sdk-allowlist.ts +2 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
- package/src/server.ts +4 -0
- package/src/slack/message-text.ts +25 -0
- package/src/tests/github-event-filter.test.ts +3 -2
- package/src/tests/github-handlers-inline-comments.test.ts +114 -24
- package/src/tests/runner-repo-autostash.test.ts +168 -1
- package/src/tests/slack-delete.test.ts +157 -0
- package/src/tests/slack-message-text.test.ts +37 -1
- package/src/tests/slack-update.test.ts +167 -0
- package/src/tests/tool-annotations.test.ts +3 -0
- package/src/tools/slack-delete.ts +102 -0
- package/src/tools/slack-reply.ts +11 -3
- package/src/tools/slack-start-thread.ts +2 -0
- package/src/tools/slack-update.ts +127 -0
- 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
|
|
8
|
-
* -
|
|
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 {
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
251
|
+
|
|
252
|
+
expect(result.created).toBe(true);
|
|
253
|
+
expectDegradedBlock(getLastTaskText());
|
|
197
254
|
});
|
|
198
255
|
|
|
199
|
-
test("review
|
|
200
|
-
const fetchSpy = spyOn(globalThis, "fetch")
|
|
201
|
-
async () =>
|
|
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
|
-
|
|
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
|
+
});
|