@desplega.ai/agent-swarm 1.167.0 → 1.169.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 (184) hide show
  1. package/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
  2. package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
  3. package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
  4. package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
  5. package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
  6. package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
  7. package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
  8. package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
  9. package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
  10. package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
  11. package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
  12. package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
  13. package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
  14. package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
  15. package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
  16. package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
  17. package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
  18. package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
  19. package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
  20. package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
  21. package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
  22. package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
  23. package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
  24. package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
  25. package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
  26. package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
  27. package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
  28. package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
  29. package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
  30. package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
  31. package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
  32. package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
  33. package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
  34. package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
  35. package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
  36. package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
  37. package/dist/cli-rh44xx46.js +2659 -0
  38. package/dist/cli-s0k2j7q9.js +2084 -0
  39. package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
  40. package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
  41. package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
  42. package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
  43. package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
  44. package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
  45. package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
  46. package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
  47. package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
  48. package/dist/cli.js +20 -17
  49. package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
  50. package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
  51. package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
  52. package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
  53. package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
  54. package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
  55. package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
  56. package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
  57. package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
  58. package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
  59. package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
  60. package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
  61. package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
  62. package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
  63. package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
  64. package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
  65. package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
  66. package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
  67. package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
  68. package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
  69. package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
  70. package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
  71. package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
  72. package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
  73. package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
  74. package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
  75. package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
  76. package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
  77. package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
  78. package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
  79. package/dist/resume-cq2pkp8q.js +70 -0
  80. package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
  81. package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
  82. package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
  83. package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
  84. package/openapi.json +15622 -15042
  85. package/package.json +8 -5
  86. package/src/apps/store.ts +1 -0
  87. package/src/artifact-sdk/feedback-overlay.ts +528 -0
  88. package/src/be/db/workflow-runs.ts +95 -0
  89. package/src/be/db-queries/slack-inbound.ts +489 -0
  90. package/src/be/db.ts +9 -2
  91. package/src/be/extensions/assets.ts +1 -1
  92. package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
  93. package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
  94. package/src/be/script-connections.ts +41 -7
  95. package/src/be/scripts/run-saved.ts +40 -20
  96. package/src/be/swarm-config-guard.ts +4 -0
  97. package/src/extensions/catalog.generated.json +17 -0
  98. package/src/extensions/catalog.ts +1 -1
  99. package/src/extensions/dispatcher.ts +3 -3
  100. package/src/heartbeat/heartbeat.ts +152 -2
  101. package/src/heartbeat/templates.ts +16 -0
  102. package/src/http/all-routes.ts +1 -0
  103. package/src/http/index.ts +2 -0
  104. package/src/http/integrations.ts +3 -1
  105. package/src/http/mcp-bridge.ts +1 -0
  106. package/src/http/pages-public.ts +7 -2
  107. package/src/http/pages.ts +178 -0
  108. package/src/http/script-connections.ts +35 -0
  109. package/src/http/slack-inbound.ts +107 -0
  110. package/src/http/tasks.ts +45 -27
  111. package/src/http/users.ts +1 -1
  112. package/src/http/webhooks.ts +20 -20
  113. package/src/prompts/session-templates.ts +32 -0
  114. package/src/providers/acp-targets.ts +1 -1
  115. package/src/providers/amp-adapter.ts +2 -2
  116. package/src/providers/claude-adapter.ts +6 -0
  117. package/src/providers/claude-background-keepalive.ts +100 -0
  118. package/src/providers/claude-managed-adapter.ts +2 -11
  119. package/src/providers/claude-sdk-session.ts +7 -1
  120. package/src/providers/dsh-adapter.ts +1 -1
  121. package/src/providers/opencode-adapter.ts +1 -2
  122. package/src/providers/pi-mono-adapter.ts +3 -3
  123. package/src/realtime/browser.generated.txt +8 -8
  124. package/src/realtime/browser.ts +3 -3
  125. package/src/scheduler/scheduler.ts +82 -13
  126. package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
  127. package/src/scripts-runtime/executors/quickjs.ts +9 -2
  128. package/src/scripts-runtime/executors/registry.ts +1 -1
  129. package/src/server.ts +1 -0
  130. package/src/slack/actions.ts +6 -2
  131. package/src/slack/app.ts +5 -0
  132. package/src/slack/assistant.ts +15 -5
  133. package/src/slack/egress-scrub.ts +3 -2
  134. package/src/slack/handlers.ts +36 -18
  135. package/src/slack/inbound-dispatch.ts +549 -0
  136. package/src/slack/inbound-files.ts +2 -0
  137. package/src/telemetry-context.ts +1 -0
  138. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  139. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  140. package/src/tests/claude-background-keepalive.test.ts +139 -0
  141. package/src/tests/codex-oauth-device.test.ts +1 -1
  142. package/src/tests/connection-embedded-auth.test.ts +51 -0
  143. package/src/tests/docs-index-footprint.test.js +3 -3
  144. package/src/tests/dsh-adapter.test.ts +1 -1
  145. package/src/tests/extensions-catalog.test.ts +1 -0
  146. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  147. package/src/tests/heartbeat-checklist.test.ts +243 -0
  148. package/src/tests/pages-feedback.test.ts +218 -0
  149. package/src/tests/schedule-target-type.test.ts +136 -0
  150. package/src/tests/script-connections-http.test.ts +45 -0
  151. package/src/tests/script-connections-mcp.test.ts +41 -0
  152. package/src/tests/script-connections.test.ts +2 -0
  153. package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
  154. package/src/tests/script-executor-registry.test.ts +56 -0
  155. package/src/tests/send-task-self-send.test.ts +107 -0
  156. package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
  157. package/src/tests/slack-inbound-receipts.test.ts +684 -0
  158. package/src/tests/slack-session-status.test.ts +4 -2
  159. package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
  160. package/src/tests/user-token-routes.test.ts +1 -1
  161. package/src/tests/workflow-executors.test.ts +3 -2
  162. package/src/tests/workflow-sub-workflow.test.ts +363 -0
  163. package/src/tools/script-connections/tool.ts +35 -3
  164. package/src/tools/send-task.ts +9 -1
  165. package/src/utils/internal-ai/complete-structured.ts +6 -3
  166. package/src/utils/runtime-model-catalog.ts +1 -1
  167. package/src/workflows/engine.ts +17 -1
  168. package/src/workflows/executors/agent-task.ts +2 -2
  169. package/src/workflows/executors/human-in-the-loop.ts +3 -3
  170. package/src/workflows/executors/registry.ts +2 -0
  171. package/src/workflows/executors/script.ts +1 -1
  172. package/src/workflows/executors/sub-workflow.ts +135 -0
  173. package/src/workflows/executors/wait.ts +3 -3
  174. package/src/workflows/recovery.ts +11 -1
  175. package/src/workflows/resume.ts +77 -0
  176. package/src/workflows/wait-filter.ts +5 -7
  177. package/templates/extensions/README.md +1 -0
  178. package/templates/extensions/slack-reply-gate/README.md +98 -0
  179. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  180. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  181. package/templates/skills/heartbeat-runbook/content.md +3 -1
  182. package/templates/skills/pages/content.md +25 -0
  183. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  184. package/templates/skills/swarm-scripts/content.md +10 -0
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Durable Slack inbound receipts and explicit dispatch outcomes (Slack HTTP
3
+ * mode, Phase 2).
4
+ *
5
+ * Covers atomic admission under concurrency, encrypted payload retention,
6
+ * admission bounds, the crash/recovery contract (pending resumes, interrupted
7
+ * processing becomes uncertain and is never replayed), and that Socket Mode
8
+ * ingress keeps producing the same tasks and the same dedup outcome.
9
+ */
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
11
+ import { unlinkSync } from "node:fs";
12
+ import type { AnyMiddlewareArgs } from "@slack/bolt";
13
+ import {
14
+ __resetEncryptionKeyForTests,
15
+ decryptSecret,
16
+ getEncryptionKey,
17
+ resolveEncryptionKey,
18
+ } from "../be/crypto";
19
+ import { closeDb, createAgent, createTaskExtended, getDbClient, initDb } from "../be/db";
20
+ import {
21
+ admitSlackInboundReceipt,
22
+ claimSlackInboundReceipt,
23
+ getSlackInboundReceiptById,
24
+ getSlackInboundReceiptByKey,
25
+ purgeSlackInboundReceipts,
26
+ SLACK_INBOUND_LIMITS,
27
+ } from "../be/db-queries/slack-inbound";
28
+ import { registerActionHandlers } from "../slack/actions";
29
+ import { getSlackConfiguration } from "../slack/config";
30
+ import * as slackEnrichModule from "../slack/enrich";
31
+ import { _resetForTests as resetEventDedup, wasEventSeen } from "../slack/event-dedup";
32
+ import { registerMessageHandler, resetSlackHandlerCachesForTesting } from "../slack/handlers";
33
+ import {
34
+ admitSlackInbound,
35
+ computeSlackInboundKey,
36
+ createSlackInboundDrain,
37
+ getSlackInboundDiagnostics,
38
+ noteSlackInboundSideEffect,
39
+ processNextSlackInboundReceipt,
40
+ recoverSlackInboundAfterRestart,
41
+ resetSlackInboundCountersForTests,
42
+ slackInboundOutcomeMiddleware,
43
+ } from "../slack/inbound-dispatch";
44
+
45
+ const TEST_DB_PATH = "./test-slack-inbound-receipts.sqlite";
46
+ const testGlobals = globalThis as typeof globalThis & {
47
+ __testMigrationTemplate?: Uint8Array;
48
+ };
49
+ let savedMigrationTemplate: Uint8Array | undefined;
50
+ const previousEnv = {
51
+ ADDITIVE_SLACK: process.env.ADDITIVE_SLACK,
52
+ SLACK_RENDER_V2: process.env.SLACK_RENDER_V2,
53
+ };
54
+
55
+ function removeTestDb(): void {
56
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
57
+ try {
58
+ unlinkSync(path);
59
+ } catch {
60
+ // The SQLite sidecars are not always created.
61
+ }
62
+ }
63
+ }
64
+
65
+ type Handler = (args: Record<string, unknown>) => Promise<void>;
66
+ let channelMessage: Handler;
67
+ let followUpSubmit: Handler;
68
+ const resolveSlackUserIdSpy = spyOn(slackEnrichModule, "resolveSlackUserId");
69
+
70
+ beforeAll(() => {
71
+ closeDb();
72
+ savedMigrationTemplate = testGlobals.__testMigrationTemplate;
73
+ testGlobals.__testMigrationTemplate = undefined;
74
+ removeTestDb();
75
+ initDb(TEST_DB_PATH);
76
+ process.env.ADDITIVE_SLACK = "false";
77
+ process.env.SLACK_RENDER_V2 = "false";
78
+ resolveSlackUserIdSpy.mockImplementation(async () => undefined);
79
+ registerMessageHandler({
80
+ event: (type: string, handler: Handler) => {
81
+ if (type === "message") channelMessage = handler;
82
+ },
83
+ } as never);
84
+ registerActionHandlers({
85
+ action: () => {},
86
+ view: (id: string, handler: Handler) => {
87
+ if (id === "follow_up_submit") followUpSubmit = handler;
88
+ },
89
+ } as never);
90
+ });
91
+
92
+ afterAll(() => {
93
+ resolveSlackUserIdSpy.mockRestore();
94
+ for (const [key, value] of Object.entries(previousEnv)) {
95
+ if (value === undefined) delete process.env[key];
96
+ else process.env[key] = value;
97
+ }
98
+ closeDb();
99
+ removeTestDb();
100
+ testGlobals.__testMigrationTemplate = savedMigrationTemplate;
101
+ });
102
+
103
+ beforeEach(async () => {
104
+ await getDbClient().run("DELETE FROM slack_inbound_receipts");
105
+ await getDbClient().run("DELETE FROM agent_tasks");
106
+ await getDbClient().run("DELETE FROM agents");
107
+ resetEventDedup();
108
+ resetSlackHandlerCachesForTesting();
109
+ resetSlackInboundCountersForTests();
110
+ });
111
+
112
+ let seq = 0;
113
+ function nextTs(): string {
114
+ seq += 1;
115
+ return `1950000000.${String(seq).padStart(6, "0")}`;
116
+ }
117
+
118
+ function slackClient() {
119
+ return {
120
+ auth: { test: async () => ({ user_id: "U_SWARM_BOT", bot_id: "B_SWARM_BOT" }) },
121
+ conversations: { replies: async () => ({ ok: true, messages: [] }) },
122
+ reactions: { add: mock(async () => ({ ok: true })) },
123
+ chat: { postMessage: mock(async () => ({ ok: true, ts: "9999999999.000001" })) },
124
+ files: { info: mock(async () => ({ ok: false })) },
125
+ };
126
+ }
127
+
128
+ function messageEnvelope(eventId: string, ts: string, text = "<@U_SWARM_BOT> please triage") {
129
+ return {
130
+ type: "event_callback",
131
+ api_app_id: "A_TEST",
132
+ event_id: eventId,
133
+ event: { type: "message", channel: "C0RECEIPTS", ts, user: "U_HUMAN", text },
134
+ };
135
+ }
136
+
137
+ /** Mirrors Bolt: the global outcome middleware wraps the listener; Bolt's
138
+ * error handler swallows what the middleware rethrows. */
139
+ async function runThroughBolt(listener: () => Promise<void>): Promise<void> {
140
+ const middleware = slackInboundOutcomeMiddleware();
141
+ try {
142
+ await middleware({ next: listener } as unknown as AnyMiddlewareArgs & {
143
+ next: () => Promise<void>;
144
+ });
145
+ } catch {
146
+ // Bolt routes listener errors to App.handleError; processEvent resolves.
147
+ }
148
+ }
149
+
150
+ /** A drain processor that dispatches an event envelope to the real handler. */
151
+ function messageProcessor(client = slackClient()) {
152
+ return async ({ rawBody }: { rawBody: string }) => {
153
+ const body = JSON.parse(rawBody);
154
+ await runThroughBolt(() =>
155
+ channelMessage({ event: body.event, body, client, say: mock(async () => ({ ts: "1" })) }),
156
+ );
157
+ };
158
+ }
159
+
160
+ async function tasksForTs(ts: string) {
161
+ return getDbClient().query<{ id: string; parentTaskId: string | null }>(
162
+ "SELECT id, parentTaskId FROM agent_tasks WHERE slackTriggerMessageTs = ?",
163
+ [ts],
164
+ );
165
+ }
166
+
167
+ async function receiptCount(): Promise<number> {
168
+ const row = await getDbClient().get<{ n: number }>(
169
+ "SELECT COUNT(*) AS n FROM slack_inbound_receipts",
170
+ );
171
+ return row?.n ?? 0;
172
+ }
173
+
174
+ describe("admission", () => {
175
+ test("concurrent identical deliveries store one receipt", async () => {
176
+ const body = messageEnvelope("Ev_CONCURRENT", nextTs());
177
+ const rawBody = JSON.stringify(body);
178
+
179
+ const results = await Promise.all(
180
+ Array.from({ length: 12 }, () =>
181
+ admitSlackInbound({ transport: "http", kind: "event", rawBody, body }),
182
+ ),
183
+ );
184
+
185
+ expect(results.filter((r) => r.status === "admitted")).toHaveLength(1);
186
+ expect(results.filter((r) => r.status === "duplicate")).toHaveLength(11);
187
+ expect(await receiptCount()).toBe(1);
188
+ const receipt = await getSlackInboundReceiptByKey("event:A_TEST:Ev_CONCURRENT");
189
+ expect(receipt?.duplicateCount).toBe(11);
190
+ expect(receipt?.state).toBe("pending");
191
+ });
192
+
193
+ test("the payload is stored encrypted and round-trips", async () => {
194
+ const body = messageEnvelope("Ev_ENCRYPTED", nextTs(), "<@U_SWARM_BOT> secret words");
195
+ const rawBody = JSON.stringify(body);
196
+ const result = await admitSlackInbound({ transport: "http", kind: "event", rawBody, body });
197
+ if (result.status !== "admitted") throw new Error(`unexpected ${result.status}`);
198
+
199
+ const stored = result.receipt.payloadCiphertext!;
200
+ expect(stored).not.toContain("secret words");
201
+ expect(decryptSecret(stored, getEncryptionKey())).toBe(rawBody);
202
+ expect(result.receipt.payloadBytes).toBe(Buffer.byteLength(rawBody));
203
+ });
204
+
205
+ test("keys events by app and event id, and interactions by kind-scoped body hash", () => {
206
+ const missing = computeSlackInboundKey("event", "{}", { type: "event_callback" });
207
+ expect(missing).toEqual({ ok: false, reason: "missing_event_id" });
208
+
209
+ const control = computeSlackInboundKey("event", '{"type":"app_rate_limited"}', {
210
+ type: "app_rate_limited",
211
+ });
212
+ expect(control.ok && control.dedupKey.startsWith("event:app_rate_limited:")).toBe(true);
213
+
214
+ const a = '{"type":"view_submission","view":{"hash":"1"}}';
215
+ const b = '{"type":"view_submission","view":{"hash":"2"}}';
216
+ const keyA = computeSlackInboundKey("interaction", a, JSON.parse(a));
217
+ const keyA2 = computeSlackInboundKey("interaction", a, JSON.parse(a));
218
+ const keyB = computeSlackInboundKey("interaction", b, JSON.parse(b));
219
+ expect(keyA.ok && keyA2.ok && keyA.dedupKey === keyA2.dedupKey).toBe(true);
220
+ expect(keyA.ok && keyB.ok && keyA.dedupKey !== keyB.dedupKey).toBe(true);
221
+ });
222
+
223
+ test("a full backlog refuses new work but still acknowledges a stored duplicate", async () => {
224
+ const limits = { maxBacklogReceipts: 1, maxBacklogBytes: 1_000_000, maxPayloadBytes: 1_000 };
225
+ const input = (key: string) => ({
226
+ dedupKey: key,
227
+ transport: "http" as const,
228
+ kind: "event" as const,
229
+ payloadType: "event_callback",
230
+ payloadCiphertext: "x",
231
+ payloadBytes: 10,
232
+ });
233
+
234
+ expect((await admitSlackInboundReceipt(input("k1"), new Date(), limits)).status).toBe(
235
+ "admitted",
236
+ );
237
+ expect(await admitSlackInboundReceipt(input("k2"), new Date(), limits)).toEqual({
238
+ status: "rejected",
239
+ reason: "backlog_full",
240
+ });
241
+ expect((await admitSlackInboundReceipt(input("k1"), new Date(), limits)).status).toBe(
242
+ "duplicate",
243
+ );
244
+ expect(
245
+ await admitSlackInboundReceipt({ ...input("k3"), payloadBytes: 5_000 }, new Date(), {
246
+ ...limits,
247
+ maxBacklogReceipts: 10,
248
+ }),
249
+ ).toEqual({ status: "rejected", reason: "payload_too_large" });
250
+ expect(await receiptCount()).toBe(1);
251
+ });
252
+ });
253
+
254
+ describe("dispatch and recovery", () => {
255
+ test("concurrent identical modal submissions create one follow-up task", async () => {
256
+ const lead = await createAgent({ name: "ReceiptLead", isLead: true, status: "idle" });
257
+ const original = await createTaskExtended("original", {
258
+ agentId: lead.id,
259
+ source: "slack",
260
+ slackChannelId: "C0MODAL",
261
+ slackThreadTs: "1950000000.000000",
262
+ slackUserId: "U_HUMAN",
263
+ });
264
+ const body = {
265
+ type: "view_submission",
266
+ api_app_id: "A_TEST",
267
+ trigger_id: "trigger-1",
268
+ user: { id: "U_HUMAN" },
269
+ view: {
270
+ id: "V1",
271
+ hash: "h1",
272
+ callback_id: "follow_up_submit",
273
+ private_metadata: original.id,
274
+ state: { values: { follow_up_input: { follow_up_text: { value: "one more thing" } } } },
275
+ },
276
+ };
277
+ const rawBody = JSON.stringify(body);
278
+
279
+ const admissions = await Promise.all(
280
+ Array.from({ length: 8 }, () =>
281
+ admitSlackInbound({ transport: "http", kind: "interaction", rawBody, body }),
282
+ ),
283
+ );
284
+ expect(admissions.filter((r) => r.status === "admitted")).toHaveLength(1);
285
+
286
+ const processor = mock(async ({ rawBody: raw }: { rawBody: string }) => {
287
+ const parsed = JSON.parse(raw);
288
+ await runThroughBolt(() =>
289
+ followUpSubmit({
290
+ ack: async () => {},
291
+ view: parsed.view,
292
+ body: parsed,
293
+ client: slackClient(),
294
+ }),
295
+ );
296
+ });
297
+ const drain = createSlackInboundDrain(processor);
298
+ await Promise.all([drain.wake(), drain.wake(), drain.wake()]);
299
+
300
+ expect(processor).toHaveBeenCalledTimes(1);
301
+ const followUps = await getDbClient().query<{ id: string }>(
302
+ "SELECT id FROM agent_tasks WHERE parentTaskId = ?",
303
+ [original.id],
304
+ );
305
+ expect(followUps).toHaveLength(1);
306
+ const receipt = await getSlackInboundReceiptByKey(
307
+ (admissions[0] as { receipt: { dedupKey: string } }).receipt.dedupKey,
308
+ );
309
+ expect(receipt?.state).toBe("processed");
310
+ expect(receipt?.outcomeCode).toBe("task_created");
311
+ expect(receipt?.payloadCiphertext).toBeNull();
312
+ });
313
+
314
+ test("a crash before admission commits leaves nothing, so Slack's retry is admitted fresh", async () => {
315
+ const body = messageEnvelope("Ev_CRASH_BEFORE_ACK", nextTs());
316
+ const rawBody = JSON.stringify(body);
317
+
318
+ __resetEncryptionKeyForTests();
319
+ try {
320
+ await expect(
321
+ admitSlackInbound({ transport: "http", kind: "event", rawBody, body }),
322
+ ).rejects.toThrow();
323
+ } finally {
324
+ resolveEncryptionKey(TEST_DB_PATH);
325
+ }
326
+ expect(await receiptCount()).toBe(0);
327
+
328
+ const retry = await admitSlackInbound({
329
+ transport: "http",
330
+ kind: "event",
331
+ rawBody,
332
+ body,
333
+ retryNum: 1,
334
+ retryReason: "http_timeout",
335
+ });
336
+ expect(retry.status).toBe("admitted");
337
+ if (retry.status === "admitted") {
338
+ expect(retry.receipt.retryNum).toBe(1);
339
+ expect(retry.receipt.retryReason).toBe("http_timeout");
340
+ }
341
+ });
342
+
343
+ test("a crash after ack but before claim resumes the pending receipt", async () => {
344
+ await createAgent({ name: "ReceiptLead", isLead: true, status: "idle" });
345
+ const ts = nextTs();
346
+ const body = messageEnvelope("Ev_AFTER_ACK", ts);
347
+ await admitSlackInbound({
348
+ transport: "http",
349
+ kind: "event",
350
+ rawBody: JSON.stringify(body),
351
+ body,
352
+ });
353
+
354
+ expect(await recoverSlackInboundAfterRestart()).toBe(0);
355
+ const result = await processNextSlackInboundReceipt(messageProcessor());
356
+
357
+ expect(result?.state).toBe("processed");
358
+ expect(await tasksForTs(ts)).toHaveLength(1);
359
+ });
360
+
361
+ test("a crash after claim leaves an uncertain receipt that is never replayed", async () => {
362
+ const ts = nextTs();
363
+ const body = messageEnvelope("Ev_AFTER_CLAIM", ts);
364
+ const admitted = await admitSlackInbound({
365
+ transport: "http",
366
+ kind: "event",
367
+ rawBody: JSON.stringify(body),
368
+ body,
369
+ });
370
+ if (admitted.status !== "admitted") throw new Error("expected admission");
371
+ expect((await claimSlackInboundReceipt())?.id).toBe(admitted.receipt.id);
372
+
373
+ // Process dies here. Next boot:
374
+ expect(await recoverSlackInboundAfterRestart()).toBe(1);
375
+ const processor = mock(async () => {});
376
+ expect(await processNextSlackInboundReceipt(processor)).toBeNull();
377
+ expect(processor).not.toHaveBeenCalled();
378
+
379
+ const receipt = await getSlackInboundReceiptById(admitted.receipt.id);
380
+ expect(receipt?.state).toBe("uncertain");
381
+ expect(receipt?.errorCode).toBe("interrupted_during_processing");
382
+ expect(receipt?.payloadCiphertext).not.toBeNull();
383
+
384
+ const diagnostics = await getSlackInboundDiagnostics();
385
+ expect(diagnostics.receipts.counts.uncertain).toBe(1);
386
+ expect(diagnostics.receipts.uncertain[0]?.id).toBe(admitted.receipt.id);
387
+ expect(JSON.stringify(diagnostics)).not.toContain("please triage");
388
+ });
389
+
390
+ test("a failure after task creation is uncertain and creates no duplicate task", async () => {
391
+ await createAgent({ name: "ReceiptLead", isLead: true, status: "idle" });
392
+ const ts = nextTs();
393
+ const body = messageEnvelope("Ev_AFTER_TASK", ts);
394
+ await admitSlackInbound({
395
+ transport: "http",
396
+ kind: "event",
397
+ rawBody: JSON.stringify(body),
398
+ body,
399
+ });
400
+
401
+ // The task lands, then a later Slack call in the listener throws. Bolt's
402
+ // error handler swallows it; the outcome middleware still saw it.
403
+ const brokenClient = {
404
+ ...slackClient(),
405
+ reactions: { add: mock(async () => Promise.reject(new Error("slack down"))) },
406
+ };
407
+ const processor = async ({ rawBody }: { rawBody: string }) => {
408
+ const parsed = JSON.parse(rawBody);
409
+ await runThroughBolt(async () => {
410
+ await channelMessage({
411
+ event: parsed.event,
412
+ body: parsed,
413
+ client: brokenClient,
414
+ say: mock(async () => ({ ts: "1" })),
415
+ });
416
+ throw new Error("listener failed after its side effect");
417
+ });
418
+ };
419
+
420
+ const first = await processNextSlackInboundReceipt(processor);
421
+ expect(first?.state).toBe("uncertain");
422
+ expect(await processNextSlackInboundReceipt(processor)).toBeNull();
423
+ expect(await tasksForTs(ts)).toHaveLength(1);
424
+ });
425
+
426
+ test("a failure before any side effect is retried with backoff, then becomes terminal", async () => {
427
+ const body = messageEnvelope("Ev_RETRYABLE", nextTs());
428
+ const admitted = await admitSlackInbound({
429
+ transport: "http",
430
+ kind: "event",
431
+ rawBody: JSON.stringify(body),
432
+ body,
433
+ });
434
+ if (admitted.status !== "admitted") throw new Error("expected admission");
435
+
436
+ let clock = Date.now();
437
+ const now = () => new Date(clock);
438
+ const failing = async () => {
439
+ throw Object.assign(new Error("auth.test failed"), { code: "slack_webapi_platform_error" });
440
+ };
441
+
442
+ const first = await processNextSlackInboundReceipt(failing, { now });
443
+ expect(first).toMatchObject({ state: "pending", outcome: { state: "failed" } });
444
+ expect(await processNextSlackInboundReceipt(failing, { now })).toBeNull();
445
+
446
+ for (let attempt = 2; attempt <= SLACK_INBOUND_LIMITS.maxAttempts; attempt++) {
447
+ clock += SLACK_INBOUND_LIMITS.retryBackoffMs * attempt;
448
+ const result = await processNextSlackInboundReceipt(failing, { now });
449
+ expect(result?.state).toBe(
450
+ attempt === SLACK_INBOUND_LIMITS.maxAttempts ? "failed" : "pending",
451
+ );
452
+ }
453
+ const receipt = await getSlackInboundReceiptById(admitted.receipt.id);
454
+ expect(receipt?.state).toBe("failed");
455
+ expect(receipt?.attempts).toBe(SLACK_INBOUND_LIMITS.maxAttempts);
456
+ expect(receipt?.errorCode).toBe("slack_webapi_platform_error");
457
+ });
458
+
459
+ test("an admitted retry after a pre-side-effect failure is not dropped by the message cache", async () => {
460
+ await createAgent({ name: "ReceiptLead", isLead: true, status: "idle" });
461
+ const ts = nextTs();
462
+ const body = messageEnvelope("Ev_RETRY_MESSAGE_CACHE", ts);
463
+ await admitSlackInbound({
464
+ transport: "http",
465
+ kind: "event",
466
+ rawBody: JSON.stringify(body),
467
+ body,
468
+ });
469
+
470
+ let clock = Date.now();
471
+ const now = () => new Date(clock);
472
+ // Fails after the channel:ts message cache, before any side effect.
473
+ resolveSlackUserIdSpy.mockImplementationOnce(async () => {
474
+ throw new Error("identity lookup failed");
475
+ });
476
+
477
+ const first = await processNextSlackInboundReceipt(messageProcessor(), { now });
478
+ expect(first).toMatchObject({ state: "pending", outcome: { state: "failed" } });
479
+ expect(await tasksForTs(ts)).toHaveLength(0);
480
+
481
+ clock += SLACK_INBOUND_LIMITS.retryBackoffMs;
482
+ const retry = await processNextSlackInboundReceipt(messageProcessor(), { now });
483
+ expect(retry?.outcome.code).not.toBe("duplicate_message");
484
+ expect(retry?.state).toBe("processed");
485
+ expect(await tasksForTs(ts)).toHaveLength(1);
486
+ });
487
+
488
+ test("the drain wakes itself at a released receipt's retry time", async () => {
489
+ await createAgent({ name: "ReceiptLead", isLead: true, status: "idle" });
490
+ const ts = nextTs();
491
+ const body = messageEnvelope("Ev_DRAIN_REARM", ts);
492
+ await admitSlackInbound({
493
+ transport: "http",
494
+ kind: "event",
495
+ rawBody: JSON.stringify(body),
496
+ body,
497
+ });
498
+
499
+ let clock = Date.now();
500
+ const now = () => new Date(clock);
501
+ const timers: Array<{ fn: () => void; delayMs: number }> = [];
502
+ const setTimer = (fn: () => void, delayMs: number) => {
503
+ const timer = { fn, delayMs };
504
+ timers.push(timer);
505
+ return () => {
506
+ const index = timers.indexOf(timer);
507
+ if (index >= 0) timers.splice(index, 1);
508
+ };
509
+ };
510
+ const deliver = messageProcessor();
511
+ let calls = 0;
512
+ const processor = async (delivery: { rawBody: string }) => {
513
+ calls += 1;
514
+ if (calls === 1) throw Object.assign(new Error("transient"), { code: "transient" });
515
+ await deliver(delivery);
516
+ };
517
+
518
+ const drain = createSlackInboundDrain(processor, { now, setTimer });
519
+ try {
520
+ // A lone delivery: nothing else will call wake().
521
+ await drain.wake();
522
+ expect(calls).toBe(1);
523
+ expect(timers).toHaveLength(1);
524
+ expect(timers[0]?.delayMs).toBe(SLACK_INBOUND_LIMITS.retryBackoffMs);
525
+
526
+ clock += SLACK_INBOUND_LIMITS.retryBackoffMs;
527
+ timers.shift()?.fn();
528
+ await drain.wake();
529
+
530
+ expect(calls).toBe(2);
531
+ expect(await tasksForTs(ts)).toHaveLength(1);
532
+ expect(timers).toHaveLength(0);
533
+ } finally {
534
+ drain.stop();
535
+ }
536
+ });
537
+
538
+ test("a delivery Bolt never dispatched is a failure, not a success", async () => {
539
+ const body = messageEnvelope("Ev_NOT_DISPATCHED", nextTs());
540
+ await admitSlackInbound({
541
+ transport: "http",
542
+ kind: "event",
543
+ rawBody: JSON.stringify(body),
544
+ body,
545
+ });
546
+
547
+ const result = await processNextSlackInboundReceipt(async () => {}, {
548
+ requireBoltDispatch: true,
549
+ });
550
+ expect(result?.outcome).toEqual({ state: "failed", code: "not_dispatched" });
551
+ });
552
+
553
+ test("an expected filter is recorded as ignored", async () => {
554
+ const body = messageEnvelope("Ev_BOT_ECHO", nextTs());
555
+ body.event = { ...body.event, user: "U_SWARM_BOT" };
556
+ await admitSlackInbound({
557
+ transport: "http",
558
+ kind: "event",
559
+ rawBody: JSON.stringify(body),
560
+ body,
561
+ });
562
+
563
+ const result = await processNextSlackInboundReceipt(messageProcessor());
564
+ expect(result?.outcome).toEqual({ state: "ignored", code: "bot_message" });
565
+ });
566
+ });
567
+
568
+ describe("retention", () => {
569
+ test("never purges pending or uncertain work and keeps live dedup keys", async () => {
570
+ const received = new Date("2026-09-01T00:00:00.000Z");
571
+ const store = (key: string) =>
572
+ admitSlackInboundReceipt(
573
+ {
574
+ dedupKey: key,
575
+ transport: "http",
576
+ kind: "event",
577
+ payloadType: "event_callback",
578
+ payloadCiphertext: "ciphertext",
579
+ payloadBytes: 10,
580
+ },
581
+ received,
582
+ );
583
+ await store("pending");
584
+ await store("uncertain");
585
+ await store("processed");
586
+ await store("failed");
587
+ await getDbClient().run(
588
+ "UPDATE slack_inbound_receipts SET state = 'uncertain' WHERE dedup_key = 'uncertain'",
589
+ );
590
+ await getDbClient().run(
591
+ `UPDATE slack_inbound_receipts SET state = 'processed', completed_at = ?
592
+ WHERE dedup_key = 'processed'`,
593
+ ["2026-09-01T00:00:01.000Z"],
594
+ );
595
+ await getDbClient().run(
596
+ `UPDATE slack_inbound_receipts SET state = 'failed', completed_at = ?
597
+ WHERE dedup_key = 'failed'`,
598
+ ["2026-09-01T00:00:01.000Z"],
599
+ );
600
+
601
+ // Inside the window: the stray processed payload is erased, keys survive.
602
+ const early = await purgeSlackInboundReceipts(new Date("2026-09-02T00:00:00.000Z"));
603
+ expect(early).toEqual({ erasedPayloads: 1, deletedReceipts: 0 });
604
+ const processed = await getSlackInboundReceiptByKey("processed");
605
+ expect(processed?.payloadCiphertext).toBeNull();
606
+ expect((await store("processed")).status).toBe("duplicate");
607
+ expect((await getSlackInboundReceiptByKey("failed"))?.payloadCiphertext).toBe("ciphertext");
608
+
609
+ // Long after the window: completed receipts go; pending and uncertain stay.
610
+ const late = await purgeSlackInboundReceipts(new Date("2026-10-01T00:00:00.000Z"));
611
+ expect(late.deletedReceipts).toBe(2);
612
+ const remaining = await getDbClient().query<{ dedup_key: string; payload_ciphertext: string }>(
613
+ "SELECT dedup_key, payload_ciphertext FROM slack_inbound_receipts ORDER BY dedup_key",
614
+ );
615
+ expect(remaining).toEqual([
616
+ { dedup_key: "pending", payload_ciphertext: "ciphertext" },
617
+ { dedup_key: "uncertain", payload_ciphertext: "ciphertext" },
618
+ ]);
619
+ });
620
+ });
621
+
622
+ describe("Socket Mode ingress is unchanged", () => {
623
+ test("SLACK_MODE defaults to socket", () => {
624
+ expect(getSlackConfiguration({}).mode).toBe("socket");
625
+ });
626
+
627
+ test("a redelivered socket event creates one task, dedups in memory, and stores no receipt", async () => {
628
+ await createAgent({ name: "SocketLead", isLead: true, status: "idle" });
629
+ const ts = nextTs();
630
+ const body = messageEnvelope("Ev_SOCKET", ts);
631
+ const deliver = () =>
632
+ runThroughBolt(() =>
633
+ channelMessage({
634
+ event: body.event,
635
+ body,
636
+ client: slackClient(),
637
+ say: mock(async () => ({ ts: "1" })),
638
+ }),
639
+ );
640
+
641
+ await deliver();
642
+ await deliver();
643
+
644
+ expect(await tasksForTs(ts)).toHaveLength(1);
645
+ expect(await receiptCount()).toBe(0);
646
+ const { outcomes } = await getSlackInboundDiagnostics();
647
+ expect(outcomes.socket).toMatchObject({ processed: 1, ignored: 1, failed: 0, uncertain: 0 });
648
+ expect(outcomes.http).toMatchObject({ processed: 0, ignored: 0 });
649
+ });
650
+
651
+ test("socket listener errors still propagate to Bolt unchanged", async () => {
652
+ const middleware = slackInboundOutcomeMiddleware();
653
+ const boom = new Error("listener exploded");
654
+ await expect(
655
+ middleware({
656
+ next: async () => {
657
+ noteSlackInboundSideEffect("task_created");
658
+ throw boom;
659
+ },
660
+ } as unknown as AnyMiddlewareArgs & { next: () => Promise<void> }),
661
+ ).rejects.toBe(boom);
662
+ const { outcomes } = await getSlackInboundDiagnostics();
663
+ expect(outcomes.socket.uncertain).toBe(1);
664
+ });
665
+
666
+ test("an admitted delivery bypasses the legacy in-memory event cache", async () => {
667
+ await createAgent({ name: "HttpLead", isLead: true, status: "idle" });
668
+ const ts = nextTs();
669
+ const body = messageEnvelope("Ev_ADMITTED_BYPASS", ts);
670
+ await admitSlackInbound({
671
+ transport: "http",
672
+ kind: "event",
673
+ rawBody: JSON.stringify(body),
674
+ body,
675
+ });
676
+
677
+ const result = await processNextSlackInboundReceipt(messageProcessor());
678
+
679
+ expect(result?.state).toBe("processed");
680
+ expect(await tasksForTs(ts)).toHaveLength(1);
681
+ // The receipt deduplicated it; the socket cache never saw the id.
682
+ expect(wasEventSeen("Ev_ADMITTED_BYPASS")).toBe(false);
683
+ });
684
+ });
@@ -78,8 +78,10 @@ async function slackTask(
78
78
  return task.id;
79
79
  }
80
80
 
81
- const reconcile = (a: { channelId: string; threadTs: string }, extra?: object) =>
82
- reconcileSlackSessionStatus({ ...a, client, ...extra });
81
+ const reconcile = (
82
+ a: { channelId: string; threadTs: string },
83
+ extra?: Partial<Parameters<typeof reconcileSlackSessionStatus>[0]>,
84
+ ) => reconcileSlackSessionStatus({ ...a, client, ...extra });
83
85
 
84
86
  let warn: ReturnType<typeof spyOn>;
85
87