@desplega.ai/agent-swarm 1.100.2 → 1.100.4
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/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/db.ts +131 -4
- package/src/be/memory/raters/retrieval.ts +6 -3
- package/src/be/migrations/097_memory_retrieval_grouping.sql +10 -0
- package/src/github/handlers.ts +84 -7
- package/src/github/templates.ts +6 -2
- package/src/heartbeat/heartbeat.ts +191 -5
- package/src/providers/claude-adapter.ts +41 -4
- package/src/slack/assistant.ts +28 -0
- package/src/slack/channel-join.ts +38 -3
- package/src/slack/handlers.ts +4 -1
- package/src/tasks/worker-follow-up.ts +181 -20
- package/src/tests/claude-adapter-binary.test.ts +74 -0
- package/src/tests/github-handlers-inline-comments.test.ts +308 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +570 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +137 -0
- package/src/tests/heartbeat.test.ts +4 -2
- package/src/tests/memory-rater-implicit-citation.test.ts +31 -0
- package/src/tests/prompt-template-remaining.test.ts +2 -1
- package/src/tests/slack-assistant-comention-production.test.ts +319 -0
- package/src/tests/slack-assistant-comention.test.ts +139 -0
- package/src/tests/slack-channel-join.test.ts +150 -16
- package/src/tests/workflow-swarm-script.test.ts +225 -0
- package/src/tests/workflow-template.test.ts +17 -0
- package/src/tools/send-task.ts +51 -1
- package/src/tools/templates.ts +61 -0
- package/src/workflows/engine.ts +22 -1
- package/src/workflows/retry-poller.ts +2 -3
- package/src/workflows/template.ts +48 -0
|
@@ -372,4 +372,141 @@ describe("Heartbeat — supersede + resume (DES-523)", () => {
|
|
|
372
372
|
expect(updatedParent?.status).toBe("failed");
|
|
373
373
|
expect(updatedParent?.failureReason).toBe("superseded_workflow_task");
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
// --------------------------------------------------------------------------
|
|
377
|
+
// Phase 1 (DES-523) — same-agent pin
|
|
378
|
+
//
|
|
379
|
+
// crash_recovery resumes pin to the original (stable-ID) agent instead of the
|
|
380
|
+
// role-blind unassigned pool, even when the agent's `lastActivityAt` is stale
|
|
381
|
+
// (the >30s "fresh" gate is dropped for crash_recovery). The retained
|
|
382
|
+
// `offline` gate still routes genuinely-gone (gracefully-closed) agents to the
|
|
383
|
+
// pool.
|
|
384
|
+
// --------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
test("Phase 1: recoverable-but-stale agent → resume is PINNED (agentId=original, pending), not pooled", async () => {
|
|
387
|
+
const agent = createAgent({ name: "stale-recoverable", isLead: false, status: "busy" });
|
|
388
|
+
const parent = createTaskExtended("Work to resume on the same agent", { agentId: agent.id });
|
|
389
|
+
startTask(parent.id);
|
|
390
|
+
|
|
391
|
+
// Force the default single-slot capacity so the capacity-ordering invariant
|
|
392
|
+
// below is unambiguous.
|
|
393
|
+
getDb().run("UPDATE agents SET maxTasks = 1 WHERE id = ?", [agent.id]);
|
|
394
|
+
|
|
395
|
+
// Stale on BOTH axes: the task hasn't updated in 10 min (past the no-session
|
|
396
|
+
// threshold) AND the agent's lastActivityAt is 10 min old (far past
|
|
397
|
+
// WORKER_LIVENESS_WINDOW_SECONDS = 30s). Under the old `fresh` gate this
|
|
398
|
+
// resume would have been released to the unassigned pool; the pin must now
|
|
399
|
+
// hold regardless of staleness because the agent ID is stable across restart.
|
|
400
|
+
const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
401
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
|
|
402
|
+
getDb().run("UPDATE agents SET lastActivityAt = ? WHERE id = ?", [oldTime, agent.id]);
|
|
403
|
+
|
|
404
|
+
const findings = await codeLevelTriage();
|
|
405
|
+
|
|
406
|
+
expect(findings.autoResumedTasks.length).toBe(1);
|
|
407
|
+
expect(findings.pinnedResumes.length).toBe(1);
|
|
408
|
+
expect(findings.pinnedResumes[0]!.agentId).toBe(agent.id);
|
|
409
|
+
|
|
410
|
+
const children = getChildTasks(parent.id);
|
|
411
|
+
expect(children.length).toBe(1);
|
|
412
|
+
const resume = children[0]!;
|
|
413
|
+
expect(resume.taskType).toBe("resume");
|
|
414
|
+
// The pin: assigned to the ORIGINAL agent and therefore `pending` (NOT
|
|
415
|
+
// `unassigned`). createTaskExtended derives `pending` from a set agentId.
|
|
416
|
+
expect(resume.agentId).toBe(agent.id);
|
|
417
|
+
expect(resume.status).toBe("pending");
|
|
418
|
+
expect(findings.pinnedResumes[0]!.taskId).toBe(resume.id);
|
|
419
|
+
|
|
420
|
+
// Capacity-ordering invariant: maxTasks=1 and the agent held the parent
|
|
421
|
+
// `in_progress`. The pin succeeds ONLY because remediateCrashedWorkerTask
|
|
422
|
+
// supersedes the parent (freeing the single in_progress slot) BEFORE
|
|
423
|
+
// createResumeFollowUp runs its `activeCount < maxTasks` check. A reversed
|
|
424
|
+
// order would see activeCount=1 >= 1, skip the pin, and fall back to the
|
|
425
|
+
// pool — the exact bug this fix closes.
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("Phase 1: Case B (stale session heartbeat) also pins the resume to the original agent", async () => {
|
|
429
|
+
const agent = createAgent({ name: "crashed-stale", isLead: false, status: "busy" });
|
|
430
|
+
const parent = createTaskExtended("Crashed worker work (Case B)", { agentId: agent.id });
|
|
431
|
+
startTask(parent.id);
|
|
432
|
+
|
|
433
|
+
insertActiveSession({ agentId: agent.id, taskId: parent.id, triggerType: "task_assigned" });
|
|
434
|
+
|
|
435
|
+
const oldTime = new Date(Date.now() - 20 * 60 * 1000).toISOString();
|
|
436
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
|
|
437
|
+
getDb().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
|
|
438
|
+
oldTime,
|
|
439
|
+
parent.id,
|
|
440
|
+
]);
|
|
441
|
+
getDb().run("UPDATE agents SET lastActivityAt = ? WHERE id = ?", [oldTime, agent.id]);
|
|
442
|
+
|
|
443
|
+
const findings = await codeLevelTriage();
|
|
444
|
+
|
|
445
|
+
expect(findings.pinnedResumes.length).toBe(1);
|
|
446
|
+
expect(findings.pinnedResumes[0]!.agentId).toBe(agent.id);
|
|
447
|
+
|
|
448
|
+
const resume = getChildTasks(parent.id)[0]!;
|
|
449
|
+
expect(resume.taskType).toBe("resume");
|
|
450
|
+
expect(resume.agentId).toBe(agent.id);
|
|
451
|
+
expect(resume.status).toBe("pending");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("Phase 1: offline (gracefully-closed) agent → resume is NOT pinned, falls back to the pool", async () => {
|
|
455
|
+
const agent = createAgent({ name: "gone-worker", isLead: false, status: "busy" });
|
|
456
|
+
const parent = createTaskExtended("Work whose agent is gone", { agentId: agent.id });
|
|
457
|
+
startTask(parent.id);
|
|
458
|
+
|
|
459
|
+
const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
460
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
|
|
461
|
+
// Genuinely gone: a graceful close set the agent offline. The retained
|
|
462
|
+
// `offline` gate must keep this routing to the pool. (The Phase 3 reaper
|
|
463
|
+
// does NOT act here — it acts only on pinned, still-pending resumes.)
|
|
464
|
+
getDb().run("UPDATE agents SET status = 'offline' WHERE id = ?", [agent.id]);
|
|
465
|
+
|
|
466
|
+
const findings = await codeLevelTriage();
|
|
467
|
+
|
|
468
|
+
// The crash path created the resume but did NOT pin it — the retained
|
|
469
|
+
// `offline` gate routed it to the unassigned pool instead.
|
|
470
|
+
expect(findings.autoResumedTasks.length).toBe(1);
|
|
471
|
+
expect(findings.pinnedResumes.length).toBe(0);
|
|
472
|
+
|
|
473
|
+
const resume = getChildTasks(parent.id)[0]!;
|
|
474
|
+
expect(resume.taskType).toBe("resume");
|
|
475
|
+
// NOTE: we deliberately do NOT assert the resume's final agentId/status. The
|
|
476
|
+
// resume is created `unassigned`, but `autoAssignPoolTasks` runs later in the
|
|
477
|
+
// same sweep and may legitimately assign the pool task to an idle worker
|
|
478
|
+
// (existing, intended pool behavior, untouched by Phase 1). The Phase-1
|
|
479
|
+
// contract here is only that the crash path itself did not pin it.
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("Phase 1: a pinned pending resume is invisible to the stall detector — re-sweep creates no 2nd resume", async () => {
|
|
483
|
+
const agent = createAgent({ name: "stale-recoverable-2", isLead: false, status: "busy" });
|
|
484
|
+
const parent = createTaskExtended("Work pinned then left unclaimed", { agentId: agent.id });
|
|
485
|
+
startTask(parent.id);
|
|
486
|
+
|
|
487
|
+
const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
488
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
|
|
489
|
+
getDb().run("UPDATE agents SET lastActivityAt = ? WHERE id = ?", [oldTime, agent.id]);
|
|
490
|
+
|
|
491
|
+
// First sweep pins the resume to the agent.
|
|
492
|
+
const first = await codeLevelTriage();
|
|
493
|
+
expect(first.pinnedResumes.length).toBe(1);
|
|
494
|
+
const resumeId = first.autoResumedTasks[0]!.resumeTaskId;
|
|
495
|
+
expect(getTaskById(resumeId)?.status).toBe("pending");
|
|
496
|
+
expect(getTaskById(resumeId)?.agentId).toBe(agent.id);
|
|
497
|
+
|
|
498
|
+
// Age the pinned resume well past the stall threshold. It is `pending`, not
|
|
499
|
+
// `in_progress`, so getStalledInProgressTasks cannot see it — no loop, no
|
|
500
|
+
// second resume, and the agent's still-stale activity does not matter.
|
|
501
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, resumeId]);
|
|
502
|
+
|
|
503
|
+
const second = await codeLevelTriage();
|
|
504
|
+
expect(second.autoResumedTasks.length).toBe(0);
|
|
505
|
+
expect(second.pinnedResumes.length).toBe(0);
|
|
506
|
+
|
|
507
|
+
const children = getChildTasks(parent.id);
|
|
508
|
+
expect(children.length).toBe(1);
|
|
509
|
+
expect(children[0]!.id).toBe(resumeId);
|
|
510
|
+
expect(getTaskById(resumeId)?.status).toBe("pending");
|
|
511
|
+
});
|
|
375
512
|
});
|
|
@@ -460,8 +460,10 @@ describe("Heartbeat Triage", () => {
|
|
|
460
460
|
|
|
461
461
|
await codeLevelTriage();
|
|
462
462
|
|
|
463
|
-
// Agent
|
|
464
|
-
//
|
|
463
|
+
// Agent goes idle: the parent task is terminal (superseded) and the
|
|
464
|
+
// crash_recovery resume is now PINNED back to this agent as `pending`
|
|
465
|
+
// (DES-523 same-agent pin). `pending` does not count toward in_progress
|
|
466
|
+
// capacity, so getActiveTaskCount drops to 0 and the agent flips to idle.
|
|
465
467
|
const agents = getDb().query("SELECT status FROM agents WHERE id = ?").get(agent.id) as {
|
|
466
468
|
status: string;
|
|
467
469
|
};
|
|
@@ -188,6 +188,37 @@ describe("retrieval → ImplicitCitationRater → posterior shift", () => {
|
|
|
188
188
|
expect(rows.map((r) => r.memoryId).sort()).toEqual([m1.id, m2.id].sort());
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
test("recordRetrievals groups rows from one call and stamps result rank", () => {
|
|
192
|
+
const first = makeMemory("retrieval-group-first");
|
|
193
|
+
const second = makeMemory("retrieval-group-second");
|
|
194
|
+
const third = makeMemory("retrieval-group-third");
|
|
195
|
+
|
|
196
|
+
recordRetrievals(taskId, agentId, [
|
|
197
|
+
{ memoryId: first.id, similarity: 0.9 },
|
|
198
|
+
{ memoryId: second.id, similarity: 0.8 },
|
|
199
|
+
]);
|
|
200
|
+
recordRetrievals(taskId, agentId, [{ memoryId: third.id, similarity: 0.7 }]);
|
|
201
|
+
|
|
202
|
+
const rows = getDb()
|
|
203
|
+
.prepare<{ memoryId: string; retrievalId: string | null; rank: number | null }, [string]>(
|
|
204
|
+
"SELECT memoryId, retrievalId, rank FROM memory_retrieval WHERE taskId = ?",
|
|
205
|
+
)
|
|
206
|
+
.all(taskId);
|
|
207
|
+
|
|
208
|
+
expect(rows).toHaveLength(3);
|
|
209
|
+
const byMemoryId = new Map(rows.map((row) => [row.memoryId, row]));
|
|
210
|
+
const firstRow = byMemoryId.get(first.id)!;
|
|
211
|
+
const secondRow = byMemoryId.get(second.id)!;
|
|
212
|
+
const thirdRow = byMemoryId.get(third.id)!;
|
|
213
|
+
expect(firstRow.retrievalId).toBeTruthy();
|
|
214
|
+
expect(secondRow.retrievalId).toBe(firstRow.retrievalId);
|
|
215
|
+
expect(firstRow.rank).toBe(0);
|
|
216
|
+
expect(secondRow.rank).toBe(1);
|
|
217
|
+
expect(thirdRow.retrievalId).toBeTruthy();
|
|
218
|
+
expect(thirdRow.retrievalId).not.toBe(firstRow.retrievalId);
|
|
219
|
+
expect(thirdRow.rank).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
191
222
|
test("recordRetrievals is a no-op when taskId is undefined", () => {
|
|
192
223
|
const m = makeMemory("no-task");
|
|
193
224
|
recordRetrievals(undefined, agentId, [{ memoryId: m.id, similarity: 0.9 }]);
|
|
@@ -102,11 +102,12 @@ describe("template registration — all sources", () => {
|
|
|
102
102
|
expect(eventTypes).toContain("heartbeat.checklist");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
test("Task lifecycle templates are registered (
|
|
105
|
+
test("Task lifecycle templates are registered (3 task_lifecycle)", () => {
|
|
106
106
|
const all = getAllTemplateDefinitions();
|
|
107
107
|
const eventTypes = all.map((d) => d.eventType);
|
|
108
108
|
expect(eventTypes).toContain("task.worker.completed");
|
|
109
109
|
expect(eventTypes).toContain("task.worker.failed");
|
|
110
|
+
expect(eventTypes).toContain("task.reroute.decision");
|
|
110
111
|
});
|
|
111
112
|
|
|
112
113
|
test("Runner trigger templates are registered (7 task_lifecycle)", () => {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production-path regression tests for the assistant-surface co-mention guard.
|
|
3
|
+
*
|
|
4
|
+
* These tests invoke the REAL production handlers (createAssistant().userMessage and
|
|
5
|
+
* the registerMessageHandler callback) to verify that task creation is suppressed
|
|
6
|
+
* when a Slack message @-mentions a different agent (e.g. Devin) but NOT our bot.
|
|
7
|
+
*
|
|
8
|
+
* Mutation resistance: removing the guard from src/slack/assistant.ts or
|
|
9
|
+
* src/slack/handlers.ts causes the co-mention message to reach
|
|
10
|
+
* createTaskWithSiblingAwareness, which fails the `not.toHaveBeenCalled()` assertions.
|
|
11
|
+
*
|
|
12
|
+
* Complements slack-assistant-comention.test.ts (pure helper-function unit tests).
|
|
13
|
+
* Regression for task 4ae1f3b5 — "<@U0831BS93V1> Are you here?" spawned an unwanted task.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
16
|
+
import * as dbModule from "../be/db";
|
|
17
|
+
import * as slackEnrichModule from "../slack/enrich";
|
|
18
|
+
import * as slackEventDedupModule from "../slack/event-dedup";
|
|
19
|
+
import * as siblingAwarenessModule from "../tasks/sibling-awareness";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Production-handler spies.
|
|
23
|
+
//
|
|
24
|
+
// Avoid mock.module here: Bun's module overrides are process-global and can be
|
|
25
|
+
// observed by other test files during module loading. Restorable spies keep the
|
|
26
|
+
// regression test on the real production handlers without leaking fake modules.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
let createAssistantFn: typeof import("../slack/assistant").createAssistant;
|
|
30
|
+
let registerMessageHandlerFn: typeof import("../slack/handlers").registerMessageHandler;
|
|
31
|
+
|
|
32
|
+
let createTaskWithSiblingAwarenessSpy: any;
|
|
33
|
+
let getAgentWorkingOnThreadSpy: any;
|
|
34
|
+
let getLeadAgentSpy: any;
|
|
35
|
+
let getMostRecentTaskInThreadSpy: any;
|
|
36
|
+
let getAgentByIdSpy: any;
|
|
37
|
+
let getTasksByAgentIdSpy: any;
|
|
38
|
+
let resolveSlackUserIdSpy: any;
|
|
39
|
+
let enrichSlackUserEmailSpy: any;
|
|
40
|
+
let wasEventSeenSpy: any;
|
|
41
|
+
|
|
42
|
+
const originalEnv = {
|
|
43
|
+
ADDITIVE_SLACK: process.env.ADDITIVE_SLACK,
|
|
44
|
+
SLACK_ALLOWED_EMAIL_DOMAINS: process.env.SLACK_ALLOWED_EMAIL_DOMAINS,
|
|
45
|
+
SLACK_ALLOWED_USER_IDS: process.env.SLACK_ALLOWED_USER_IDS,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function restoreEnvValue(key: keyof typeof originalEnv): void {
|
|
49
|
+
const value = originalEnv[key];
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
delete process.env[key];
|
|
52
|
+
} else {
|
|
53
|
+
process.env[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function installSpyImplementations(): void {
|
|
58
|
+
createTaskWithSiblingAwarenessSpy.mockImplementation(() => ({ id: "mock-task-id-prod-path" }));
|
|
59
|
+
getAgentWorkingOnThreadSpy.mockImplementation(() => null);
|
|
60
|
+
getLeadAgentSpy.mockImplementation(() => ({
|
|
61
|
+
id: "lead-prod-test-1",
|
|
62
|
+
name: "TestLead",
|
|
63
|
+
isLead: true,
|
|
64
|
+
}));
|
|
65
|
+
getMostRecentTaskInThreadSpy.mockImplementation(() => null);
|
|
66
|
+
getAgentByIdSpy.mockImplementation(() => null);
|
|
67
|
+
getTasksByAgentIdSpy.mockImplementation(() => []);
|
|
68
|
+
resolveSlackUserIdSpy.mockImplementation(async () => undefined);
|
|
69
|
+
enrichSlackUserEmailSpy.mockImplementation(async () => null);
|
|
70
|
+
wasEventSeenSpy.mockImplementation(() => false);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
process.env.ADDITIVE_SLACK = "false";
|
|
75
|
+
delete process.env.SLACK_ALLOWED_EMAIL_DOMAINS;
|
|
76
|
+
delete process.env.SLACK_ALLOWED_USER_IDS;
|
|
77
|
+
|
|
78
|
+
createTaskWithSiblingAwarenessSpy = spyOn(
|
|
79
|
+
siblingAwarenessModule,
|
|
80
|
+
"createTaskWithSiblingAwareness",
|
|
81
|
+
);
|
|
82
|
+
getAgentWorkingOnThreadSpy = spyOn(dbModule, "getAgentWorkingOnThread");
|
|
83
|
+
getLeadAgentSpy = spyOn(dbModule, "getLeadAgent");
|
|
84
|
+
getMostRecentTaskInThreadSpy = spyOn(dbModule, "getMostRecentTaskInThread");
|
|
85
|
+
getAgentByIdSpy = spyOn(dbModule, "getAgentById");
|
|
86
|
+
getTasksByAgentIdSpy = spyOn(dbModule, "getTasksByAgentId");
|
|
87
|
+
resolveSlackUserIdSpy = spyOn(slackEnrichModule, "resolveSlackUserId");
|
|
88
|
+
enrichSlackUserEmailSpy = spyOn(slackEnrichModule, "enrichSlackUserEmail");
|
|
89
|
+
wasEventSeenSpy = spyOn(slackEventDedupModule, "wasEventSeen");
|
|
90
|
+
|
|
91
|
+
installSpyImplementations();
|
|
92
|
+
|
|
93
|
+
({ createAssistant: createAssistantFn } = await import("../slack/assistant"));
|
|
94
|
+
({ registerMessageHandler: registerMessageHandlerFn } = await import("../slack/handlers"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
createTaskWithSiblingAwarenessSpy.mockClear();
|
|
99
|
+
getAgentWorkingOnThreadSpy.mockClear();
|
|
100
|
+
getLeadAgentSpy.mockClear();
|
|
101
|
+
getMostRecentTaskInThreadSpy.mockClear();
|
|
102
|
+
getAgentByIdSpy.mockClear();
|
|
103
|
+
getTasksByAgentIdSpy.mockClear();
|
|
104
|
+
resolveSlackUserIdSpy.mockClear();
|
|
105
|
+
enrichSlackUserEmailSpy.mockClear();
|
|
106
|
+
wasEventSeenSpy.mockClear();
|
|
107
|
+
installSpyImplementations();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(() => {
|
|
111
|
+
restoreEnvValue("ADDITIVE_SLACK");
|
|
112
|
+
restoreEnvValue("SLACK_ALLOWED_EMAIL_DOMAINS");
|
|
113
|
+
restoreEnvValue("SLACK_ALLOWED_USER_IDS");
|
|
114
|
+
mock.restore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Shared constants
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const BOT_USER_ID = "U_BOT_PROD_TEST";
|
|
122
|
+
const DEVIN_USER_ID = "U0831BS93V1"; // the other agent from the original regression
|
|
123
|
+
let slackDeliverySequence = 0;
|
|
124
|
+
|
|
125
|
+
function nextSlackDelivery(eventIdPrefix: string): { eventId: string; ts: string } {
|
|
126
|
+
slackDeliverySequence += 1;
|
|
127
|
+
return {
|
|
128
|
+
eventId: `${eventIdPrefix}_${slackDeliverySequence}`,
|
|
129
|
+
ts: `2000000001.${String(slackDeliverySequence).padStart(6, "0")}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Mock Slack WebClient — auth.test() returns our bot's user ID so the
|
|
134
|
+
// module-level cachedBotUserId gets populated on the first handler invocation.
|
|
135
|
+
const mockClient = {
|
|
136
|
+
auth: {
|
|
137
|
+
test: async () => ({ user_id: BOT_USER_ID, bot_id: "B_BOT_PROD_TEST" }),
|
|
138
|
+
},
|
|
139
|
+
conversations: {
|
|
140
|
+
// Needed only if getThreadContext is reached (thread_ts set); returning
|
|
141
|
+
// empty messages is safe for the paths exercised here.
|
|
142
|
+
replies: async () => ({ messages: [], ok: true }),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Production-path: assistant.ts — createAssistant().userMessage
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("assistant.ts — userMessage production-path co-mention guard", () => {
|
|
151
|
+
// Access the registered middleware function directly.
|
|
152
|
+
// Bolt stores handlers as an array; [0] is the callback passed to the config.
|
|
153
|
+
let userMessageHandler: (args: Record<string, unknown>) => Promise<void>;
|
|
154
|
+
|
|
155
|
+
beforeAll(() => {
|
|
156
|
+
userMessageHandler = (createAssistantFn() as any).userMessage[0] as typeof userMessageHandler;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("does NOT spawn a task when message @-mentions another agent but not our bot", async () => {
|
|
160
|
+
await userMessageHandler({
|
|
161
|
+
message: {
|
|
162
|
+
channel: "D_ASSISTANT_PROD_TEST",
|
|
163
|
+
ts: "1000000001.000001",
|
|
164
|
+
text: `<@${DEVIN_USER_ID}> Are you here?`,
|
|
165
|
+
user: "U_HUMAN_ASST_001",
|
|
166
|
+
},
|
|
167
|
+
body: { event_id: "evt_prod_asst_comention_001" },
|
|
168
|
+
say: mock(async () => {}),
|
|
169
|
+
setStatus: mock(async () => {}),
|
|
170
|
+
setTitle: mock(async () => {}),
|
|
171
|
+
getThreadContext: mock(async () => ({})),
|
|
172
|
+
client: mockClient,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(createTaskWithSiblingAwarenessSpy).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("DOES spawn a task for a plain DM with no @-mentions (baseline)", async () => {
|
|
179
|
+
await userMessageHandler({
|
|
180
|
+
message: {
|
|
181
|
+
channel: "D_ASSISTANT_PROD_TEST",
|
|
182
|
+
ts: "1000000001.000002",
|
|
183
|
+
text: "What is the current status of all agents?",
|
|
184
|
+
user: "U_HUMAN_ASST_001",
|
|
185
|
+
},
|
|
186
|
+
body: { event_id: "evt_prod_asst_plain_001" },
|
|
187
|
+
say: mock(async () => {}),
|
|
188
|
+
setStatus: mock(async () => {}),
|
|
189
|
+
setTitle: mock(async () => {}),
|
|
190
|
+
getThreadContext: mock(async () => ({})),
|
|
191
|
+
client: mockClient,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(createTaskWithSiblingAwarenessSpy).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("does NOT spawn a task when message @-mentions a human user but not our bot", async () => {
|
|
198
|
+
await userMessageHandler({
|
|
199
|
+
message: {
|
|
200
|
+
channel: "D_ASSISTANT_PROD_TEST",
|
|
201
|
+
ts: "1000000001.000003",
|
|
202
|
+
text: "<@U037TJB7VHQ> what do you think?",
|
|
203
|
+
user: "U_HUMAN_ASST_001",
|
|
204
|
+
},
|
|
205
|
+
body: { event_id: "evt_prod_asst_comention_002" },
|
|
206
|
+
say: mock(async () => {}),
|
|
207
|
+
setStatus: mock(async () => {}),
|
|
208
|
+
setTitle: mock(async () => {}),
|
|
209
|
+
getThreadContext: mock(async () => ({})),
|
|
210
|
+
client: mockClient,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(createTaskWithSiblingAwarenessSpy).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Production-path: handlers.ts — registerMessageHandler, assistant_thread fallback
|
|
219
|
+
//
|
|
220
|
+
// File-share messages in DM assistant threads bypass the Assistant handler and
|
|
221
|
+
// land in the generic message handler. The isImplicitMention logic in
|
|
222
|
+
// registerMessageHandler must suppress task creation when assistant_thread is set
|
|
223
|
+
// AND the message @-mentions a different user (not our bot).
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
describe("registerMessageHandler — assistant_thread co-mention guard (production-path)", () => {
|
|
227
|
+
type MessageEventArg = {
|
|
228
|
+
channel: string;
|
|
229
|
+
ts: string;
|
|
230
|
+
text?: string;
|
|
231
|
+
user?: string;
|
|
232
|
+
subtype?: string;
|
|
233
|
+
bot_id?: string;
|
|
234
|
+
assistant_thread?: Record<string, unknown>;
|
|
235
|
+
thread_ts?: string;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
type HandlerArgs = {
|
|
239
|
+
event: MessageEventArg;
|
|
240
|
+
body: Record<string, unknown>;
|
|
241
|
+
client: typeof mockClient;
|
|
242
|
+
say: (args: unknown) => Promise<void>;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
let capturedHandler: ((args: HandlerArgs) => Promise<void>) | null = null;
|
|
246
|
+
|
|
247
|
+
beforeAll(() => {
|
|
248
|
+
const mockApp = {
|
|
249
|
+
event: (eventType: string, handler: (args: HandlerArgs) => Promise<void>) => {
|
|
250
|
+
// registerMessageHandler calls app.event("message", ...) and then
|
|
251
|
+
// app.event("app_mention", ...). Capture only the message handler.
|
|
252
|
+
if (eventType === "message") {
|
|
253
|
+
capturedHandler = handler;
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
registerMessageHandlerFn(mockApp as any);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("does NOT spawn a task when assistant_thread message @-mentions another agent", async () => {
|
|
261
|
+
expect(capturedHandler).not.toBeNull();
|
|
262
|
+
const delivery = nextSlackDelivery("evt_prod_hdlr_comention");
|
|
263
|
+
|
|
264
|
+
await capturedHandler!({
|
|
265
|
+
event: {
|
|
266
|
+
channel: "D_HANDLER_PROD_TEST",
|
|
267
|
+
ts: delivery.ts,
|
|
268
|
+
text: `<@${DEVIN_USER_ID}> Are you here?`,
|
|
269
|
+
user: "U_HUMAN_HDLR_001",
|
|
270
|
+
assistant_thread: { channel_id: "D_HANDLER_PROD_TEST" },
|
|
271
|
+
},
|
|
272
|
+
body: { event_id: delivery.eventId },
|
|
273
|
+
client: mockClient,
|
|
274
|
+
say: mock(async () => {}),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(createTaskWithSiblingAwarenessSpy).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("DOES spawn a task for assistant_thread plain message with no @-mentions (baseline)", async () => {
|
|
281
|
+
expect(capturedHandler).not.toBeNull();
|
|
282
|
+
const delivery = nextSlackDelivery("evt_prod_hdlr_plain");
|
|
283
|
+
|
|
284
|
+
await capturedHandler!({
|
|
285
|
+
event: {
|
|
286
|
+
channel: "D_HANDLER_PROD_TEST",
|
|
287
|
+
ts: delivery.ts,
|
|
288
|
+
text: "What is the current status of all agents?",
|
|
289
|
+
user: "U_HUMAN_HDLR_001",
|
|
290
|
+
assistant_thread: { channel_id: "D_HANDLER_PROD_TEST" },
|
|
291
|
+
},
|
|
292
|
+
body: { event_id: delivery.eventId },
|
|
293
|
+
client: mockClient,
|
|
294
|
+
say: mock(async () => {}),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(createTaskWithSiblingAwarenessSpy).toHaveBeenCalledTimes(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("does NOT spawn a task when assistant_thread message @-mentions a human (not our bot)", async () => {
|
|
301
|
+
expect(capturedHandler).not.toBeNull();
|
|
302
|
+
const delivery = nextSlackDelivery("evt_prod_hdlr_comention");
|
|
303
|
+
|
|
304
|
+
await capturedHandler!({
|
|
305
|
+
event: {
|
|
306
|
+
channel: "D_HANDLER_PROD_TEST",
|
|
307
|
+
ts: delivery.ts,
|
|
308
|
+
text: "<@U037TJB7VHQ> what do you think?",
|
|
309
|
+
user: "U_HUMAN_HDLR_001",
|
|
310
|
+
assistant_thread: { channel_id: "D_HANDLER_PROD_TEST" },
|
|
311
|
+
},
|
|
312
|
+
body: { event_id: delivery.eventId },
|
|
313
|
+
client: mockClient,
|
|
314
|
+
say: mock(async () => {}),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(createTaskWithSiblingAwarenessSpy).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the assistant-surface co-mention guard.
|
|
3
|
+
*
|
|
4
|
+
* The guard in assistant.ts and handlers.ts prevents the swarm from spawning a
|
|
5
|
+
* task when a Slack message arrives on the AI-App / assistant surface and
|
|
6
|
+
* @-mentions a DIFFERENT agent (e.g. Devin) but NOT our bot.
|
|
7
|
+
*
|
|
8
|
+
* Regression for task 4ae1f3b5 — root message "<@U0831BS93V1> Are you here?"
|
|
9
|
+
* (Devin) triggered an unwanted swarm task.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { hasOtherUserMention } from "../slack/router";
|
|
13
|
+
|
|
14
|
+
const BOT_USER_ID = "U0ASK3PCZ4P"; // our bot
|
|
15
|
+
const DEVIN_USER_ID = "U0831BS93V1"; // another agent
|
|
16
|
+
const HUMAN_USER_ID = "U037TJB7VHQ"; // a human
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// hasOtherUserMention — the function powering both guards
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
describe("hasOtherUserMention — assistant surface scenarios", () => {
|
|
22
|
+
test("returns true when message mentions only another agent (e.g. Devin)", () => {
|
|
23
|
+
expect(hasOtherUserMention(`<@${DEVIN_USER_ID}> Are you here?`, BOT_USER_ID)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns true when message mentions a human (not our bot)", () => {
|
|
27
|
+
expect(hasOtherUserMention(`<@${HUMAN_USER_ID}> what do you think?`, BOT_USER_ID)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns false when message mentions only our bot", () => {
|
|
31
|
+
expect(hasOtherUserMention(`<@${BOT_USER_ID}> help me`, BOT_USER_ID)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns false when message has no @-mentions at all (plain DM)", () => {
|
|
35
|
+
expect(hasOtherUserMention("Hello, what is the agent status?", BOT_USER_ID)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns true when message mentions both our bot AND another user", () => {
|
|
39
|
+
// @-mentions both — hasOtherUserMention is true because Devin is mentioned.
|
|
40
|
+
// The guard also checks !botMentioned, so this path goes through normally.
|
|
41
|
+
expect(
|
|
42
|
+
hasOtherUserMention(
|
|
43
|
+
`<@${BOT_USER_ID}> <@${DEVIN_USER_ID}> what do you both think?`,
|
|
44
|
+
BOT_USER_ID,
|
|
45
|
+
),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns false for swarm#all text command (no @-mention)", () => {
|
|
50
|
+
expect(hasOtherUserMention("swarm#all deploy staging", BOT_USER_ID)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns false for swarm#<uuid> text command (no @-mention)", () => {
|
|
54
|
+
expect(
|
|
55
|
+
hasOtherUserMention("swarm#5fd166b4-7d41-40ce-852f-9a3c2ea191a3 run task", BOT_USER_ID),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Guard condition — mirrors the logic in assistant.ts and handlers.ts
|
|
62
|
+
// The guard fires (suppresses task creation) when:
|
|
63
|
+
// !botMentioned && hasOtherUserMention(text, botUserId)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
describe("assistant surface guard condition", () => {
|
|
66
|
+
function shouldSkip(text: string, botUserId: string): boolean {
|
|
67
|
+
const botMentioned = text.includes(`<@${botUserId}>`);
|
|
68
|
+
return !botMentioned && hasOtherUserMention(text, botUserId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test("skips — message mentions only Devin (the Devin co-mention case)", () => {
|
|
72
|
+
expect(shouldSkip(`<@${DEVIN_USER_ID}> Are you here?`, BOT_USER_ID)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("skips — message mentions a human user, not our bot", () => {
|
|
76
|
+
expect(shouldSkip(`<@${HUMAN_USER_ID}> wdyt?`, BOT_USER_ID)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("does NOT skip — message mentions our bot (direct mention)", () => {
|
|
80
|
+
expect(shouldSkip(`<@${BOT_USER_ID}> help me`, BOT_USER_ID)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("does NOT skip — plain DM with no @-mentions (normal assistant use)", () => {
|
|
84
|
+
expect(shouldSkip("Show me the latest agent tasks", BOT_USER_ID)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("does NOT skip — message mentions our bot AND Devin (co-mention, bot included)", () => {
|
|
88
|
+
// botMentioned=true → guard does NOT fire → task proceeds normally
|
|
89
|
+
expect(
|
|
90
|
+
shouldSkip(
|
|
91
|
+
`<@${BOT_USER_ID}> and <@${DEVIN_USER_ID}> can you both look at this?`,
|
|
92
|
+
BOT_USER_ID,
|
|
93
|
+
),
|
|
94
|
+
).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("does NOT skip — swarm#all command (no @-mention)", () => {
|
|
98
|
+
expect(shouldSkip("swarm#all run the deployment", BOT_USER_ID)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("does NOT skip — swarm#<uuid> command (no @-mention)", () => {
|
|
102
|
+
expect(shouldSkip("swarm#5fd166b4-7d41-40ce-852f-9a3c2ea191a3 do the thing", BOT_USER_ID)).toBe(
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// isImplicitMention guard — mirrors the logic added to handlers.ts line 494
|
|
110
|
+
// isImplicitMention = isAssistantThread && !botMentioned && !hasOtherUserMention(text, botUserId)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
describe("isImplicitMention with co-mention guard (handlers.ts)", () => {
|
|
113
|
+
function computeIsImplicitMention(
|
|
114
|
+
isAssistantThread: boolean,
|
|
115
|
+
text: string,
|
|
116
|
+
botUserId: string,
|
|
117
|
+
): boolean {
|
|
118
|
+
const botMentioned = text.includes(`<@${botUserId}>`);
|
|
119
|
+
return isAssistantThread && !botMentioned && !hasOtherUserMention(text, botUserId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test("false — not an assistant thread", () => {
|
|
123
|
+
expect(computeIsImplicitMention(false, "Hello there", BOT_USER_ID)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("false — assistant thread but message mentions Devin only", () => {
|
|
127
|
+
expect(computeIsImplicitMention(true, `<@${DEVIN_USER_ID}> are you here?`, BOT_USER_ID)).toBe(
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("false — assistant thread, message mentions our bot (explicit mention)", () => {
|
|
133
|
+
expect(computeIsImplicitMention(true, `<@${BOT_USER_ID}> help`, BOT_USER_ID)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("true — assistant thread, plain message with no @-mentions (normal DM use)", () => {
|
|
137
|
+
expect(computeIsImplicitMention(true, "What are the active tasks?", BOT_USER_ID)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|