@desplega.ai/agent-swarm 2.7.2 → 2.7.3

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 (220) hide show
  1. package/README.md +6 -0
  2. package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
  3. package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
  4. package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
  5. package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
  6. package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
  7. package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
  8. package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
  9. package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
  10. package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
  11. package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
  12. package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
  13. package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
  14. package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
  15. package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
  16. package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
  17. package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
  18. package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
  19. package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
  20. package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
  21. package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
  22. package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
  23. package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
  24. package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
  25. package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
  26. package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
  27. package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
  28. package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
  29. package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
  30. package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
  31. package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
  32. package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
  33. package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
  34. package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
  35. package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
  36. package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
  37. package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
  38. package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
  39. package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
  40. package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
  41. package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
  42. package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
  43. package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
  44. package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
  45. package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
  46. package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
  47. package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
  48. package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
  49. package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
  50. package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
  51. package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
  52. package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
  53. package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
  54. package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
  55. package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
  56. package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
  57. package/dist/cli.js +19 -19
  58. package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
  59. package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
  60. package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
  61. package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
  62. package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
  63. package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
  64. package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
  65. package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
  66. package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
  67. package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
  68. package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
  69. package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
  70. package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
  71. package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
  72. package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
  73. package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
  74. package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
  75. package/dist/handlers-ss5948rq.js +42 -0
  76. package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
  77. package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
  78. package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
  79. package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
  80. package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
  81. package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
  82. package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
  83. package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
  84. package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
  85. package/dist/index-z3b1vfjx.js +44 -0
  86. package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
  87. package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
  88. package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
  89. package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
  90. package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
  91. package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
  92. package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
  93. package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
  94. package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
  95. package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
  96. package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
  97. package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
  98. package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
  99. package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
  100. package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
  101. package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
  102. package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
  103. package/openapi.json +134 -3
  104. package/package.json +5 -5
  105. package/src/be/db/agents.ts +16 -1
  106. package/src/be/db/runtime.ts +25 -15
  107. package/src/be/db/session-summaries.ts +120 -0
  108. package/src/be/db/tasks/read.ts +7 -4
  109. package/src/be/db/tasks/search-index.ts +159 -0
  110. package/src/be/db/tasks/write.ts +3 -0
  111. package/src/be/db-queries/slack-inbound.ts +77 -0
  112. package/src/be/db.ts +27 -8
  113. package/src/be/mcp-overflow-sweep.ts +39 -0
  114. package/src/be/migrations/253_session_summaries.sql +300 -0
  115. package/src/be/migrations/254_query_audit_indexes.sql +25 -0
  116. package/src/be/migrations/256_agent_slack_icon.sql +2 -0
  117. package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
  118. package/src/comb/config.ts +6 -2
  119. package/src/comb/requester.ts +69 -0
  120. package/src/comb/review-batch.ts +24 -2
  121. package/src/commands/onboard/agent-fs-version.ts +1 -1
  122. package/src/commands/onboard/compose-generator.ts +7 -2
  123. package/src/commands/onboard/env-generator.ts +8 -3
  124. package/src/commands/runner.ts +41 -24
  125. package/src/fs/agent-fs-provider.ts +9 -0
  126. package/src/heartbeat/heartbeat.ts +13 -6
  127. package/src/http/agents.ts +10 -2
  128. package/src/http/index.ts +15 -0
  129. package/src/http/integrations.ts +20 -0
  130. package/src/http/list-total-cache.ts +62 -0
  131. package/src/http/sessions.ts +9 -2
  132. package/src/http/slack-inbound.ts +1 -0
  133. package/src/http/status.ts +4 -1
  134. package/src/http/tasks.ts +12 -2
  135. package/src/providers/amp-adapter.ts +1 -1
  136. package/src/providers/cursor-adapter.ts +1 -1
  137. package/src/providers/dsh-adapter.ts +1 -1
  138. package/src/queue-stall-alarm.ts +4 -1
  139. package/src/slack/actions.ts +2 -2
  140. package/src/slack/app-icon.ts +50 -0
  141. package/src/slack/app.ts +13 -1
  142. package/src/slack/assistant.ts +22 -1
  143. package/src/slack/event-dedup.test.ts +11 -0
  144. package/src/slack/event-dedup.ts +8 -4
  145. package/src/slack/inbound-dispatch.ts +58 -0
  146. package/src/slack/render-v2.ts +7 -8
  147. package/src/slack/responses.ts +21 -8
  148. package/src/slack/socket-ack.ts +237 -0
  149. package/src/slack/thread-buffer.ts +3 -5
  150. package/src/slack/watcher.ts +2 -2
  151. package/src/slack/work-objects.ts +5 -1
  152. package/src/tests/agent-fs-release-notes.test.ts +40 -0
  153. package/src/tests/agent-slack-icon.test.ts +85 -0
  154. package/src/tests/approval-cancellation-migration.test.ts +2 -0
  155. package/src/tests/asset-key-migration.test.ts +26 -0
  156. package/src/tests/comb-default.test.ts +159 -0
  157. package/src/tests/comb-review-batch.test.ts +192 -4
  158. package/src/tests/comb-status.test.ts +65 -2
  159. package/src/tests/db-agents.test.ts +13 -0
  160. package/src/tests/db-tasks-read.test.ts +10 -15
  161. package/src/tests/deploy-one-box.test.ts +161 -0
  162. package/src/tests/deploy-stubs.ts +68 -0
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/dsh-adapter.test.ts +1 -1
  165. package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
  166. package/src/tests/fixtures/deploy-stubs/bun +3 -0
  167. package/src/tests/fixtures/deploy-stubs/bunx +27 -0
  168. package/src/tests/fixtures/deploy-stubs/curl +26 -0
  169. package/src/tests/fixtures/deploy-stubs/docker +122 -0
  170. package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
  171. package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
  172. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
  173. package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
  174. package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
  175. package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
  176. package/src/tests/heartbeat-expiry.test.ts +151 -25
  177. package/src/tests/http-api-integration.test.ts +31 -0
  178. package/src/tests/human-free-reclassify.test.ts +6 -0
  179. package/src/tests/install-sh-bind.test.ts +242 -0
  180. package/src/tests/install-sh-tunnel.test.ts +89 -0
  181. package/src/tests/kv-storage.test.ts +28 -0
  182. package/src/tests/list-endpoint-slimming.test.ts +3 -0
  183. package/src/tests/list-total-cache.test.ts +63 -0
  184. package/src/tests/mcp-overflow-sweep.test.ts +84 -0
  185. package/src/tests/onboard-compose.test.ts +17 -3
  186. package/src/tests/onboard-env.test.ts +13 -0
  187. package/src/tests/onboarding.test.ts +1 -1
  188. package/src/tests/preload.ts +1 -0
  189. package/src/tests/query-audit-plans.test.ts +314 -0
  190. package/src/tests/script-connections.test.ts +6 -0
  191. package/src/tests/session-summaries.test.ts +384 -0
  192. package/src/tests/sessions-tree.test.ts +5 -1
  193. package/src/tests/slack-app-icon.test.ts +90 -0
  194. package/src/tests/slack-external-channel-egress.test.ts +1 -1
  195. package/src/tests/slack-inbound-receipts.test.ts +34 -0
  196. package/src/tests/slack-inline-output.test.ts +29 -2
  197. package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
  198. package/src/tests/slack-render-v2.test.ts +35 -0
  199. package/src/tests/slack-socket-ack.test.ts +553 -0
  200. package/src/tests/task-lineage.test.ts +3 -0
  201. package/src/tests/task-search-filter.test.ts +4 -4
  202. package/src/tests/task-search-fts.test.ts +320 -0
  203. package/src/tests/update-profile-auth.test.ts +28 -0
  204. package/src/tools/get-swarm.ts +1 -0
  205. package/src/tools/get-tasks.ts +6 -1
  206. package/src/tools/join-swarm.ts +1 -0
  207. package/src/tools/my-agent-info.ts +1 -0
  208. package/src/tools/slack-post.ts +2 -2
  209. package/src/tools/slack-reply.ts +2 -2
  210. package/src/tools/slack-start-thread.ts +2 -2
  211. package/src/tools/slack-update.ts +1 -1
  212. package/src/tools/update-profile.ts +20 -4
  213. package/src/tools/utils.ts +3 -4
  214. package/src/types.ts +47 -0
  215. package/src/utils/constants.ts +127 -5
  216. package/templates/official/lead/HEARTBEAT.md +4 -2
  217. package/templates/official/lead/config.json +2 -2
  218. package/templates/skills/swarm-docs/content.md +2 -0
  219. package/dist/handlers-7x8sywgr.js +0 -42
  220. package/dist/index-mc30r1hs.js +0 -44
@@ -0,0 +1,553 @@
1
+ /**
2
+ * A Socket Mode ack that fails because the client is not ready must not lose
3
+ * the event: Bolt acks before it runs listeners and drops the event when the
4
+ * ack throws. The event is stored as a pending receipt before anything runs and
5
+ * a durable drain hands it back to Bolt. Drives a real Bolt App through a real
6
+ * SocketModeReceiver.
7
+ */
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { unlinkSync } from "node:fs";
10
+ import { App, LogLevel, SocketModeReceiver } from "@slack/bolt";
11
+ import { closeDb, getDbClient, initDb } from "../be/db";
12
+ import { createAssistant } from "../slack/assistant";
13
+ import { _resetForTests as resetEventDedup, wasEventSeen } from "../slack/event-dedup";
14
+ import {
15
+ getSlackInboundDiagnostics,
16
+ isAdmittedSlackDelivery,
17
+ recoverSlackInboundAfterRestart,
18
+ resetSlackInboundCountersForTests,
19
+ slackInboundOutcomeMiddleware,
20
+ } from "../slack/inbound-dispatch";
21
+ import { installSocketAckRecovery } from "../slack/socket-ack";
22
+
23
+ type SocketClient = SocketModeReceiver["client"];
24
+ type Drain = ReturnType<typeof installSocketAckRecovery>;
25
+
26
+ const drains: Drain[] = [];
27
+
28
+ const TEST_DB_PATH = "./test-slack-socket-ack.sqlite";
29
+ const testGlobals = globalThis as typeof globalThis & {
30
+ __testMigrationTemplate?: Uint8Array;
31
+ };
32
+ let savedMigrationTemplate: Uint8Array | undefined;
33
+ const previousRenderV2 = process.env.SLACK_RENDER_V2;
34
+
35
+ function removeTestDb(): void {
36
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
37
+ try {
38
+ unlinkSync(path);
39
+ } catch {
40
+ // The SQLite sidecars are not always created.
41
+ }
42
+ }
43
+ }
44
+
45
+ beforeAll(() => {
46
+ closeDb();
47
+ savedMigrationTemplate = testGlobals.__testMigrationTemplate;
48
+ testGlobals.__testMigrationTemplate = undefined;
49
+ removeTestDb();
50
+ initDb(TEST_DB_PATH);
51
+ process.env.SLACK_RENDER_V2 = "false";
52
+ });
53
+
54
+ afterAll(() => {
55
+ if (previousRenderV2 === undefined) delete process.env.SLACK_RENDER_V2;
56
+ else process.env.SLACK_RENDER_V2 = previousRenderV2;
57
+ closeDb();
58
+ removeTestDb();
59
+ testGlobals.__testMigrationTemplate = savedMigrationTemplate;
60
+ });
61
+
62
+ /** The real client `send`, used by the ack the client builds for each envelope. */
63
+ function clientSend(
64
+ client: SocketClient,
65
+ ): (id: string, body?: Record<string, unknown>) => Promise<void> {
66
+ const send = (
67
+ client as unknown as { send: (id: string, body?: Record<string, unknown>) => Promise<void> }
68
+ ).send;
69
+ return send.bind(client);
70
+ }
71
+
72
+ function setSocket(client: SocketClient, socket: { isActive: () => boolean } | undefined): void {
73
+ (client as unknown as { websocket?: { isActive: () => boolean } }).websocket = socket;
74
+ }
75
+
76
+ function buildApp(options: { failListener?: boolean } = {}): {
77
+ client: SocketClient;
78
+ drain: Drain;
79
+ runs: string[];
80
+ errors: unknown[];
81
+ } {
82
+ const receiver = new SocketModeReceiver({ appToken: "xapp-test", logLevel: LogLevel.ERROR });
83
+ const app = new App({
84
+ receiver,
85
+ socketMode: true,
86
+ logLevel: LogLevel.ERROR,
87
+ authorize: async () => ({ botToken: "xoxb-test", botId: "B_TEST", botUserId: "U_BOT" }),
88
+ });
89
+ const drain = installSocketAckRecovery(receiver.client, app);
90
+ drains.push(drain);
91
+ const runs: string[] = [];
92
+ const errors: unknown[] = [];
93
+ app.error(async (error) => {
94
+ errors.push(error);
95
+ });
96
+ app.use(slackInboundOutcomeMiddleware());
97
+ // Mirrors the dedup at the top of the message handler (src/slack/handlers.ts).
98
+ app.event("message", async ({ body, event }) => {
99
+ if (!isAdmittedSlackDelivery() && wasEventSeen(body.event_id)) return;
100
+ if (options.failListener) throw new Error("listener failed before any side effect");
101
+ runs.push(body.event_id ?? (event as { text?: string }).text ?? "");
102
+ });
103
+ // Mirrors actions.ts: acks first, then does the work.
104
+ app.view("follow_up_submit", async ({ ack, body }) => {
105
+ await ack();
106
+ runs.push(`view:${body.view.id}`);
107
+ });
108
+ return { client: receiver.client, drain, runs, errors };
109
+ }
110
+
111
+ /** Deliver an envelope the way SocketModeClient does: emit `slack_event` with its ack. */
112
+ async function deliverEvent(
113
+ client: SocketClient,
114
+ eventId: string,
115
+ event: Record<string, unknown>,
116
+ retryNum?: number,
117
+ ): Promise<void> {
118
+ const send = clientSend(client);
119
+ const envelopeId = `env-${eventId}-${retryNum ?? 0}`;
120
+ client.emit("slack_event", {
121
+ ack: (response?: Record<string, unknown>) => send(envelopeId, response),
122
+ envelope_id: envelopeId,
123
+ type: "events_api",
124
+ body: {
125
+ type: "event_callback",
126
+ team_id: "T_TEST",
127
+ api_app_id: "A_TEST",
128
+ event_id: eventId,
129
+ event,
130
+ },
131
+ retry_num: retryNum,
132
+ retry_reason: retryNum ? "timeout" : undefined,
133
+ });
134
+ // The receiver's listener is async; let it settle.
135
+ await new Promise((resolve) => setTimeout(resolve, 40));
136
+ }
137
+
138
+ function deliver(client: SocketClient, eventId: string, retryNum?: number): Promise<void> {
139
+ return deliverEvent(
140
+ client,
141
+ eventId,
142
+ { type: "message", channel: "C_TEST", ts: "1.000001", user: "U_HUMAN", text: "hi" },
143
+ retryNum,
144
+ );
145
+ }
146
+
147
+ /** Deliver an interactive modal submission the way SocketModeClient does. */
148
+ async function deliverViewSubmission(client: SocketClient, viewId: string): Promise<void> {
149
+ const send = clientSend(client);
150
+ const envelopeId = `env-${viewId}`;
151
+ client.emit("slack_event", {
152
+ ack: (response?: Record<string, unknown>) => send(envelopeId, response),
153
+ envelope_id: envelopeId,
154
+ type: "interactive",
155
+ body: {
156
+ type: "view_submission",
157
+ team: { id: "T_TEST" },
158
+ user: { id: "U_HUMAN" },
159
+ api_app_id: "A_TEST",
160
+ view: {
161
+ id: viewId,
162
+ callback_id: "follow_up_submit",
163
+ private_metadata: "t",
164
+ state: { values: {} },
165
+ },
166
+ },
167
+ accepts_response_payload: true,
168
+ });
169
+ await new Promise((resolve) => setTimeout(resolve, 20));
170
+ }
171
+
172
+ const noSend = (client: SocketClient) => {
173
+ (client as unknown as { send: () => Promise<void> }).send = async () => {};
174
+ };
175
+
176
+ const receiptFor = (eventId: string) =>
177
+ getDbClient().get<{ state: string; outcome_code: string | null; attempts: number }>(
178
+ "SELECT state, outcome_code, attempts FROM slack_inbound_receipts WHERE event_id = ?",
179
+ [eventId],
180
+ );
181
+
182
+ describe("Socket Mode ack failures", () => {
183
+ beforeEach(async () => {
184
+ resetEventDedup();
185
+ resetSlackInboundCountersForTests();
186
+ await getDbClient().run("DELETE FROM slack_inbound_receipts");
187
+ });
188
+
189
+ afterEach(() => {
190
+ for (const drain of drains.splice(0)) drain.stop();
191
+ });
192
+
193
+ test("a not-ready ack stores the event, the drain runs it once, and the retry is dropped", async () => {
194
+ const { client, drain, runs } = buildApp();
195
+ setSocket(client, { isActive: () => false });
196
+
197
+ await deliver(client, "Ev_NOT_READY");
198
+ await drain.wake();
199
+ expect(runs).toEqual(["Ev_NOT_READY"]);
200
+ expect(await receiptFor("Ev_NOT_READY")).toMatchObject({ state: "processed", attempts: 1 });
201
+
202
+ // Slack redelivers the unacked event with the same event_id once the socket is back.
203
+ setSocket(client, undefined);
204
+ noSend(client);
205
+ await deliver(client, "Ev_NOT_READY", 1);
206
+ await drain.wake();
207
+ expect(runs).toEqual(["Ev_NOT_READY"]);
208
+
209
+ const { outcomes } = await getSlackInboundDiagnostics();
210
+ expect(outcomes.socket.ackNotReady).toBe(1);
211
+ expect(outcomes.socket.processed).toBe(1); // the drain's run; the retry never reaches Bolt
212
+ expect(outcomes.http.ackNotReady).toBe(0);
213
+ });
214
+
215
+ test("an event stored before the process died runs after the restart", async () => {
216
+ const first = buildApp();
217
+ // The process dies after the receipt is stored and before anything runs.
218
+ first.drain.stop();
219
+ setSocket(first.client, { isActive: () => false });
220
+ await deliver(first.client, "Ev_CRASH");
221
+ expect(first.runs).toEqual([]);
222
+ expect(await receiptFor("Ev_CRASH")).toMatchObject({ state: "pending" });
223
+
224
+ // Slack redelivers; the stored receipt owns the event, so Bolt never sees it.
225
+ setSocket(first.client, undefined);
226
+ noSend(first.client);
227
+ await deliver(first.client, "Ev_CRASH", 1);
228
+ expect(first.runs).toEqual([]);
229
+
230
+ // Restart: the in-memory dedup cache and the whole Bolt app are rebuilt.
231
+ resetEventDedup();
232
+ const second = buildApp();
233
+ await recoverSlackInboundAfterRestart();
234
+ await second.drain.wake();
235
+ expect(second.runs).toEqual(["Ev_CRASH"]);
236
+ expect(await receiptFor("Ev_CRASH")).toMatchObject({ state: "processed" });
237
+ expect(second.errors).toEqual([]);
238
+
239
+ const row = await getDbClient().get<{ duplicate_count: number }>(
240
+ "SELECT duplicate_count FROM slack_inbound_receipts WHERE event_id = ?",
241
+ ["Ev_CRASH"],
242
+ );
243
+ expect(row?.duplicate_count).toBe(1);
244
+ });
245
+
246
+ test("a listener that fails before any side effect leaves a retryable receipt, not a processed one", async () => {
247
+ const { client, drain, runs } = buildApp({ failListener: true });
248
+ setSocket(client, { isActive: () => false });
249
+
250
+ await deliver(client, "Ev_FAILS");
251
+ await drain.wake();
252
+ expect(runs).toEqual([]);
253
+ const row = await getDbClient().get<{ state: string; available_at: string }>(
254
+ "SELECT state, available_at FROM slack_inbound_receipts WHERE event_id = ?",
255
+ ["Ev_FAILS"],
256
+ );
257
+ expect(row?.state).toBe("pending");
258
+ expect(new Date(row?.available_at ?? 0).getTime()).toBeGreaterThan(Date.now());
259
+
260
+ // The redelivery is still dropped: the receipt owns the retry.
261
+ setSocket(client, undefined);
262
+ noSend(client);
263
+ await deliver(client, "Ev_FAILS", 1);
264
+ expect(runs).toEqual([]);
265
+ });
266
+
267
+ test("an event that cannot be stored runs inline instead of being lost", async () => {
268
+ const { client, runs } = buildApp();
269
+ setSocket(client, { isActive: () => false });
270
+
271
+ // No event_id: no dedup key, so no receipt.
272
+ client.emit("slack_event", {
273
+ ack: (response?: Record<string, unknown>) => clientSend(client)("env-no-id", response),
274
+ envelope_id: "env-no-id",
275
+ type: "events_api",
276
+ body: {
277
+ type: "event_callback",
278
+ api_app_id: "A_TEST",
279
+ event: {
280
+ type: "message",
281
+ channel: "C_TEST",
282
+ ts: "1.000009",
283
+ user: "U_HUMAN",
284
+ text: "inline",
285
+ },
286
+ },
287
+ });
288
+ await new Promise((resolve) => setTimeout(resolve, 40));
289
+ expect(runs).toEqual(["inline"]);
290
+ const rows = await getDbClient().get<{ n: number }>(
291
+ "SELECT COUNT(*) AS n FROM slack_inbound_receipts",
292
+ );
293
+ expect(rows?.n).toBe(0);
294
+ });
295
+
296
+ test("a retry whose original was never recorded still runs", async () => {
297
+ const { client, runs } = buildApp();
298
+ noSend(client);
299
+ await deliver(client, "Ev_NEVER_SEEN", 1);
300
+ expect(runs).toEqual(["Ev_NEVER_SEEN"]);
301
+ });
302
+
303
+ test("any other ack error still drops the event", async () => {
304
+ const { client, runs } = buildApp();
305
+ // No socket at all: the client rejects with "not connected", not "not ready".
306
+ setSocket(client, undefined);
307
+
308
+ await deliver(client, "Ev_NOT_CONNECTED");
309
+ expect(runs).toEqual([]);
310
+ const { outcomes } = await getSlackInboundDiagnostics();
311
+ expect(outcomes.socket.ackNotReady).toBe(0);
312
+ });
313
+
314
+ test("a failed modal submission ack is not swallowed, so the handler does no work", async () => {
315
+ const { client, runs, errors } = buildApp();
316
+ setSocket(client, { isActive: () => false });
317
+
318
+ await deliverViewSubmission(client, "V_FAILED");
319
+ expect(runs).toEqual([]);
320
+ expect(errors.length).toBe(1);
321
+ const { outcomes } = await getSlackInboundDiagnostics();
322
+ expect(outcomes.socket.ackNotReady).toBe(0);
323
+ });
324
+
325
+ test("a healthy modal submission still runs", async () => {
326
+ const { client, runs } = buildApp();
327
+ noSend(client);
328
+
329
+ await deliverViewSubmission(client, "V_OK");
330
+ expect(runs).toEqual(["view:V_OK"]);
331
+ });
332
+ });
333
+
334
+ /** A Slack Web API that accepts every call and records the methods it served. */
335
+ function startFakeSlackApi(): { url: string; calls: string[]; stop: () => void } {
336
+ const calls: string[] = [];
337
+ const server = Bun.serve({
338
+ port: 0,
339
+ fetch(req) {
340
+ calls.push(new URL(req.url).pathname.replace(/^\/api\//, ""));
341
+ return Response.json({ ok: true, ts: "2.000001", channel: "D_TEST" });
342
+ },
343
+ });
344
+ return { url: `http://localhost:${server.port}/api/`, calls, stop: () => server.stop(true) };
345
+ }
346
+
347
+ describe("assistant events through Bolt", () => {
348
+ let api: ReturnType<typeof startFakeSlackApi>;
349
+ let saved: string[];
350
+
351
+ beforeEach(async () => {
352
+ resetEventDedup();
353
+ await getDbClient().run("DELETE FROM slack_inbound_receipts");
354
+ api = startFakeSlackApi();
355
+ saved = [];
356
+ });
357
+
358
+ afterEach(() => {
359
+ for (const drain of drains.splice(0)) drain.stop();
360
+ });
361
+
362
+ function buildAssistantApp(): { client: SocketClient; drain: Drain } {
363
+ const receiver = new SocketModeReceiver({ appToken: "xapp-test", logLevel: LogLevel.ERROR });
364
+ const app = new App({
365
+ receiver,
366
+ socketMode: true,
367
+ logLevel: LogLevel.ERROR,
368
+ clientOptions: { slackApiUrl: api.url },
369
+ authorize: async () => ({ botToken: "xoxb-test", botId: "B_TEST", botUserId: "U_BOT" }),
370
+ });
371
+ const drain = installSocketAckRecovery(receiver.client, app);
372
+ drains.push(drain);
373
+ app.use(slackInboundOutcomeMiddleware());
374
+ // Bolt's default store reads and writes thread metadata through the Slack API.
375
+ app.assistant(
376
+ createAssistant({
377
+ threadContextStore: {
378
+ get: async () => ({ channel_id: saved.at(-1) }),
379
+ save: async (args) => {
380
+ const payload = args.payload as {
381
+ assistant_thread: { context: { channel_id: string } };
382
+ };
383
+ saved.push(payload.assistant_thread.context.channel_id);
384
+ },
385
+ },
386
+ }),
387
+ );
388
+ return { client: receiver.client, drain };
389
+ }
390
+
391
+ const threadStarted = {
392
+ type: "assistant_thread_started",
393
+ assistant_thread: {
394
+ user_id: "U_HUMAN",
395
+ channel_id: "D_TEST",
396
+ thread_ts: "1.000001",
397
+ context: { channel_id: "C_A", team_id: "T_TEST" },
398
+ },
399
+ event_ts: "1.000002",
400
+ };
401
+
402
+ const contextChanged = (channelId: string) => ({
403
+ type: "assistant_thread_context_changed",
404
+ assistant_thread: {
405
+ user_id: "U_HUMAN",
406
+ channel_id: "D_TEST",
407
+ thread_ts: "1.000001",
408
+ context: { channel_id: channelId, team_id: "T_TEST" },
409
+ },
410
+ event_ts: "1.000003",
411
+ });
412
+
413
+ test("a redelivered thread_started after a failed ack does not greet twice", async () => {
414
+ const { client, drain } = buildAssistantApp();
415
+ try {
416
+ setSocket(client, { isActive: () => false });
417
+ await deliverEvent(client, "Ev_STARTED", threadStarted);
418
+ await drain.wake();
419
+ expect(api.calls.filter((c) => c === "chat.postMessage")).toHaveLength(1);
420
+
421
+ setSocket(client, undefined);
422
+ noSend(client);
423
+ await deliverEvent(client, "Ev_STARTED", threadStarted, 1);
424
+ expect(api.calls.filter((c) => c === "chat.postMessage")).toHaveLength(1);
425
+ } finally {
426
+ api.stop();
427
+ }
428
+ });
429
+
430
+ test("a stale context_changed redelivery does not overwrite newer context", async () => {
431
+ const { client, drain } = buildAssistantApp();
432
+ try {
433
+ // A is stored without an ack and drained, B arrives and acks, then Slack redelivers A.
434
+ setSocket(client, { isActive: () => false });
435
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
436
+ await drain.wake();
437
+ setSocket(client, undefined);
438
+ noSend(client);
439
+ await deliverEvent(client, "Ev_CTX_B", contextChanged("C_B"));
440
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"), 1);
441
+ expect(saved).toEqual(["C_A", "C_B"]);
442
+ } finally {
443
+ api.stop();
444
+ }
445
+ });
446
+
447
+ test("a context_changed whose ack failed runs in arrival order, so the newer one wins", async () => {
448
+ const { client, drain } = buildAssistantApp();
449
+ // Hold the drain: A's wake-up is lost to a busy or backing-off drain, so any
450
+ // work stored for it is only claimed after B ran live.
451
+ const wake = drain.wake;
452
+ drain.wake = async () => {};
453
+ try {
454
+ setSocket(client, { isActive: () => false });
455
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
456
+ setSocket(client, undefined);
457
+ noSend(client);
458
+ await deliverEvent(client, "Ev_CTX_B", contextChanged("C_B"));
459
+ drain.wake = wake;
460
+ await drain.wake();
461
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"), 1);
462
+ expect(saved).toEqual(["C_A", "C_B"]);
463
+ // Nothing for the drain to claim, so it cannot replay A over B.
464
+ expect(await receiptFor("Ev_CTX_A")).toMatchObject({
465
+ state: "processed",
466
+ outcome_code: "ran_inline",
467
+ });
468
+ } finally {
469
+ drain.wake = wake;
470
+ api.stop();
471
+ }
472
+ });
473
+
474
+ test("a context_changed runs in arrival order while its tombstone write is blocked by a DB transaction", async () => {
475
+ const { client } = buildAssistantApp();
476
+ // A real transaction holds the write lock: A's tombstone write queues behind it.
477
+ let release: () => void = () => {};
478
+ const held = new Promise<void>((resolve) => {
479
+ release = resolve;
480
+ });
481
+ let locked: () => void = () => {};
482
+ const lockTaken = new Promise<void>((resolve) => {
483
+ locked = resolve;
484
+ });
485
+ const lock = getDbClient().transaction(async (tx) => {
486
+ await tx.run("DELETE FROM slack_inbound_receipts WHERE 0");
487
+ locked();
488
+ await held;
489
+ });
490
+ try {
491
+ await lockTaken;
492
+ setSocket(client, { isActive: () => false });
493
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
494
+ // A does not wait for its own tombstone.
495
+ expect(saved).toEqual(["C_A"]);
496
+
497
+ setSocket(client, undefined);
498
+ noSend(client);
499
+ await deliverEvent(client, "Ev_CTX_B", contextChanged("C_B"));
500
+ release();
501
+ await lock;
502
+ await new Promise((resolve) => setTimeout(resolve, 40));
503
+ expect(saved).toEqual(["C_A", "C_B"]);
504
+
505
+ // The tombstone still lands once the lock is gone, and drops a post-restart redelivery.
506
+ expect(await receiptFor("Ev_CTX_A")).toMatchObject({
507
+ state: "processed",
508
+ outcome_code: "ran_inline",
509
+ });
510
+ resetEventDedup();
511
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"), 1);
512
+ expect(saved).toEqual(["C_A", "C_B"]);
513
+ } finally {
514
+ release();
515
+ await lock;
516
+ api.stop();
517
+ }
518
+ });
519
+
520
+ test("a context_changed redelivered after a restart does not overwrite the newer context", async () => {
521
+ const { client } = buildAssistantApp();
522
+ try {
523
+ // A fails its ack and runs inline, then B runs live.
524
+ setSocket(client, { isActive: () => false });
525
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
526
+ setSocket(client, undefined);
527
+ noSend(client);
528
+ await deliverEvent(client, "Ev_CTX_B", contextChanged("C_B"));
529
+ expect(saved).toEqual(["C_A", "C_B"]);
530
+
531
+ // The API restarts: the in-memory event_id cache is gone, the receipts are not.
532
+ resetEventDedup();
533
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"), 1);
534
+ expect(saved).toEqual(["C_A", "C_B"]);
535
+ } finally {
536
+ api.stop();
537
+ }
538
+ });
539
+
540
+ test("a stale context_changed redelivery is dropped by the listener when it was not stored", async () => {
541
+ const { client } = buildAssistantApp();
542
+ try {
543
+ noSend(client);
544
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
545
+ await deliverEvent(client, "Ev_CTX_B", contextChanged("C_B"));
546
+ // No retry_num, so the stored-key check is skipped: the listener's own dedup must hold.
547
+ await deliverEvent(client, "Ev_CTX_A", contextChanged("C_A"));
548
+ expect(saved).toEqual(["C_A", "C_B"]);
549
+ } finally {
550
+ api.stop();
551
+ }
552
+ });
553
+ });
@@ -171,6 +171,9 @@ describe("getTaskLineage", () => {
171
171
  test("a parentTaskId cycle ends at the ancestor cap instead of looping", async () => {
172
172
  const a = await createTaskExtended("cycle a", { agentId });
173
173
  const b = await createTaskExtended("cycle b", { agentId, parentTaskId: a.id });
174
+ // Product code never rewrites parentTaskId, and migration 253 guards it;
175
+ // this cycle fixture needs the guard off.
176
+ await getDbClient().run("DROP TRIGGER IF EXISTS session_task_parent_immutable");
174
177
  await getDbClient().run("UPDATE agent_tasks SET parentTaskId = ? WHERE id = ?", [b.id, a.id]);
175
178
 
176
179
  const lineage = await getTaskLineage(a.id, { maxAncestors: 5, maxDepth: 4 });
@@ -23,7 +23,7 @@ describe("getAllTasks search filter", () => {
23
23
  }
24
24
  });
25
25
 
26
- test("matches by id prefix and substring, plus description", async () => {
26
+ test("matches by id prefix, not id substring, plus description", async () => {
27
27
  const agent = await createAgent({
28
28
  id: "search-filter-agent",
29
29
  name: "Search Filter Agent",
@@ -45,11 +45,11 @@ describe("getAllTasks search filter", () => {
45
45
  expect(byPrefix.map((t) => t.id)).toContain(taskA.id);
46
46
  expect(byPrefix.map((t) => t.id)).not.toContain(taskB.id);
47
47
 
48
- // Arbitrary substring of UUID also matches
48
+ // Id search is prefix-only: a substring from the middle of a UUID does
49
+ // not match (search runs on agent_tasks_fts plus an id range).
49
50
  const idMid = taskB.id.slice(9, 17);
50
51
  const byMid = await getAllTasks({ search: idMid });
51
- expect(byMid.map((t) => t.id)).toContain(taskB.id);
52
- expect(byMid.map((t) => t.id)).not.toContain(taskA.id);
52
+ expect(byMid.map((t) => t.id)).not.toContain(taskB.id);
53
53
 
54
54
  // Count query honors the same filter
55
55
  expect(await getTasksCount({ search: idPrefix })).toBe(1);