@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,384 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defaultAssetKey } from "../assets/key";
6
+ import {
7
+ closeDb,
8
+ createSteeringMessage,
9
+ createUser,
10
+ getDbClient,
11
+ initDb,
12
+ updateTaskProgress,
13
+ upsertInboxState,
14
+ } from "../be/db";
15
+ import { findSessionSummaryDrift } from "../be/db/session-summaries";
16
+ import { sessionSummaryQuery } from "../be/db/session-tree";
17
+ import { deleteTask } from "../be/db/tasks/write";
18
+
19
+ const dbPath = join(tmpdir(), `session-summaries-${process.pid}-${Date.now()}.sqlite`);
20
+ const MIGRATION = join(import.meta.dir, "../be/migrations/253_session_summaries.sql");
21
+ const BASE = Date.parse("2026-10-01T00:00:00.000Z");
22
+ let users: string[] = [];
23
+
24
+ // mulberry32, so a failure replays with the same operations.
25
+ let seed = 0x2520_1028;
26
+ function rand(): number {
27
+ seed = (seed + 0x6d2b79f5) | 0;
28
+ let t = seed;
29
+ t = Math.imul(t ^ (t >>> 15), t | 1);
30
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
31
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
32
+ }
33
+ const pick = <T>(items: readonly T[]): T => items[Math.floor(rand() * items.length)]!;
34
+ // Random, not monotonic: writes move timestamps both ways.
35
+ const at = () => new Date(BASE + Math.floor(rand() * 20 * 86_400_000)).toISOString();
36
+
37
+ const client = () => getDbClient();
38
+ const taskIds = async () =>
39
+ (await client().query<{ id: string }>("SELECT id FROM agent_tasks ORDER BY id")).map((r) => r.id);
40
+ const rootIds = async () =>
41
+ (
42
+ await client().query<{ id: string }>(
43
+ "SELECT id FROM agent_tasks WHERE parentTaskId IS NULL ORDER BY id",
44
+ )
45
+ ).map((r) => r.id);
46
+
47
+ async function insertTask(opts: { parentTaskId?: string | null; id?: string } = {}) {
48
+ const id = opts.id ?? crypto.randomUUID();
49
+ const createdAt = at();
50
+ const requester = rand() < 0.7 ? pick(users) : null;
51
+ await client().run(
52
+ `INSERT INTO agent_tasks (id, "key", task, status, source, parentTaskId, requestedByUserId,
53
+ requestedByUserIdInherited, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
54
+ [
55
+ id,
56
+ defaultAssetKey("task", id),
57
+ `task ${id}`,
58
+ pick(["pending", "in_progress", "completed", "failed"]),
59
+ pick(["ui", "slack", "mcp", "system", "schedule", "workflow", "api"]),
60
+ opts.parentTaskId ?? null,
61
+ requester,
62
+ requester && rand() < 0.5 ? 1 : 0,
63
+ createdAt,
64
+ at(),
65
+ ],
66
+ );
67
+ return id;
68
+ }
69
+
70
+ const OPS: Record<string, () => Promise<unknown>> = {
71
+ async root() {
72
+ await insertTask();
73
+ },
74
+ async child() {
75
+ const ids = await taskIds();
76
+ if (ids.length) await insertTask({ parentTaskId: pick(ids) });
77
+ },
78
+ async danglingChild() {
79
+ await insertTask({ parentTaskId: crypto.randomUUID() });
80
+ },
81
+ async progress() {
82
+ const ids = await taskIds();
83
+ if (ids.length) await updateTaskProgress(pick(ids), "working");
84
+ },
85
+ async status() {
86
+ const ids = await taskIds();
87
+ if (!ids.length) return;
88
+ await client().run("UPDATE agent_tasks SET status = ?, lastUpdatedAt = ? WHERE id = ?", [
89
+ pick(["completed", "failed", "cancelled", "in_progress"]),
90
+ at(),
91
+ pick(ids),
92
+ ]);
93
+ },
94
+ async statusOnly() {
95
+ const ids = await taskIds();
96
+ if (!ids.length) return;
97
+ await client().run("UPDATE agent_tasks SET status = ? WHERE id = ?", [
98
+ pick(["completed", "failed"]),
99
+ pick(ids),
100
+ ]);
101
+ },
102
+ async moveRootKey() {
103
+ const roots = await rootIds();
104
+ if (!roots.length) return;
105
+ await client().run(`UPDATE agent_tasks SET "key" = ? WHERE id = ?`, [
106
+ defaultAssetKey("task", crypto.randomUUID()),
107
+ pick(roots),
108
+ ]);
109
+ },
110
+ async requester() {
111
+ const ids = await taskIds();
112
+ if (!ids.length) return;
113
+ await client().run(
114
+ "UPDATE agent_tasks SET requestedByUserId = ?, requestedByUserIdInherited = ?, source = ? WHERE id = ?",
115
+ [pick([null, ...users]), rand() < 0.5 ? 1 : 0, pick(["ui", "mcp", "slack"]), pick(ids)],
116
+ );
117
+ },
118
+ async steer() {
119
+ const ids = await taskIds();
120
+ if (!ids.length) return;
121
+ const user = rand() < 0.7;
122
+ const message = await createSteeringMessage({
123
+ taskId: pick(ids),
124
+ body: "steer",
125
+ mode: pick(["steer", "queue"] as const),
126
+ source: "ui",
127
+ createdByKind: user ? "user" : "agent",
128
+ createdByUserId: user ? pick(users) : undefined,
129
+ });
130
+ await client().run("UPDATE task_steering_messages SET created_at = ? WHERE id = ?", [
131
+ at(),
132
+ message.id,
133
+ ]);
134
+ },
135
+ async steerCancel() {
136
+ const row = await client().get<{ id: string }>(
137
+ "SELECT id FROM task_steering_messages WHERE status != 'cancelled' ORDER BY random() LIMIT 1",
138
+ );
139
+ if (row)
140
+ await client().run("UPDATE task_steering_messages SET status = 'cancelled' WHERE id = ?", [
141
+ row.id,
142
+ ]);
143
+ },
144
+ async steerDelivered() {
145
+ const row = await client().get<{ id: string }>(
146
+ "SELECT id FROM task_steering_messages WHERE status = 'pending' ORDER BY random() LIMIT 1",
147
+ );
148
+ if (row)
149
+ await client().run("UPDATE task_steering_messages SET status = 'delivered' WHERE id = ?", [
150
+ row.id,
151
+ ]);
152
+ },
153
+ async steerDelete() {
154
+ await client().run(
155
+ "DELETE FROM task_steering_messages WHERE id = (SELECT id FROM task_steering_messages ORDER BY random() LIMIT 1)",
156
+ );
157
+ },
158
+ async approval() {
159
+ const ids = await taskIds();
160
+ if (!ids.length) return;
161
+ await client().run(
162
+ `INSERT INTO approval_requests (id, title, questions, approvers, status, sourceTaskId, createdAt)
163
+ VALUES (?, 'ok?', '[]', '{}', 'pending', ?, ?)`,
164
+ [crypto.randomUUID(), pick(ids), at()],
165
+ );
166
+ },
167
+ async approvalResolve() {
168
+ await client().run(
169
+ `UPDATE approval_requests SET status = ? WHERE id = (
170
+ SELECT id FROM approval_requests WHERE status = 'pending' ORDER BY random() LIMIT 1)`,
171
+ [pick(["approved", "rejected", "timeout"])],
172
+ );
173
+ },
174
+ async approvalDelete() {
175
+ await client().run(
176
+ "DELETE FROM approval_requests WHERE id = (SELECT id FROM approval_requests ORDER BY random() LIMIT 1)",
177
+ );
178
+ },
179
+ async deleteTask() {
180
+ const ids = await taskIds();
181
+ if (ids.length > 10) await deleteTask(pick(ids));
182
+ },
183
+ async doneMark() {
184
+ const roots = await rootIds();
185
+ if (!roots.length) return;
186
+ await upsertInboxState({
187
+ userId: users[0]!,
188
+ itemType: "to_read",
189
+ itemId: pick(roots),
190
+ status: "done",
191
+ });
192
+ await client().run(
193
+ `UPDATE inbox_item_state SET lastUpdatedAt = ? WHERE userId = ? AND itemType = 'to_read' AND status = 'done'
194
+ AND itemId = (SELECT itemId FROM inbox_item_state WHERE userId = ? ORDER BY lastUpdatedAt DESC LIMIT 1)`,
195
+ [at(), users[0]!, users[0]!],
196
+ );
197
+ },
198
+ };
199
+
200
+ // Weighted toward the common writes: inserts and progress.
201
+ const SCHEDULE = [
202
+ ...Array(6).fill("root"),
203
+ ...Array(10).fill("child"),
204
+ ...Array(6).fill("progress"),
205
+ ...Array(3).fill("status"),
206
+ ...Object.keys(OPS),
207
+ ] as string[];
208
+
209
+ /** Each Done-marked root's read state from the chain walk equals the stored rule. */
210
+ async function expectReopensDoneMatches() {
211
+ const viewer = users[0]!;
212
+ const legacy = sessionSummaryQuery({ viewer: { userId: viewer, scope: "all" } });
213
+ const rows = await client().query<{ root_id: string; readState: string; stored: string }>(
214
+ `SELECT a.root_id, a.readState,
215
+ CASE WHEN s.lastHumanAt > d.lastUpdatedAt THEN 'unread' ELSE 'done' END AS stored
216
+ FROM (${legacy.sql}) a
217
+ JOIN inbox_item_state d ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = a.root_id AND d.status = 'done'
218
+ JOIN session_summaries s ON s.rootId = a.root_id`,
219
+ [...legacy.params, viewer],
220
+ );
221
+ for (const row of rows)
222
+ expect({ root: row.root_id, state: row.stored }).toEqual({
223
+ root: row.root_id,
224
+ state: row.readState,
225
+ });
226
+ return rows.length;
227
+ }
228
+
229
+ beforeAll(async () => {
230
+ initDb(dbPath);
231
+ users = [];
232
+ for (let i = 0; i < 3; i++) users.push((await createUser({ name: `summary user ${i}` })).id);
233
+ });
234
+
235
+ afterAll(() => {
236
+ closeDb();
237
+ for (const suffix of ["", "-wal", "-shm"]) {
238
+ try {
239
+ unlinkSync(dbPath + suffix);
240
+ } catch {}
241
+ }
242
+ });
243
+
244
+ describe("session_summaries", () => {
245
+ test("500 random writes keep the summaries equal to the chain walk", async () => {
246
+ const counts: Record<string, number> = {};
247
+ // The most chains, human events and pending approvals any checkpoint compared.
248
+ const seen = { chains: 0, humans: 0, pending: 0 };
249
+ let doneChecks = 0;
250
+ for (let i = 1; i <= 500; i++) {
251
+ const op = pick(SCHEDULE);
252
+ counts[op] = (counts[op] ?? 0) + 1;
253
+ await OPS[op]!();
254
+ if (i % 50 === 0) {
255
+ expect({ after: i, drift: await findSessionSummaryDrift() }).toEqual({
256
+ after: i,
257
+ drift: [],
258
+ });
259
+ doneChecks += await expectReopensDoneMatches();
260
+ const shape = (await client().get<typeof seen>(
261
+ `SELECT SUM(chainCount > 2) AS chains, SUM(lastHumanAt IS NOT NULL) AS humans,
262
+ SUM(pendingApprovalCount > 0) AS pending FROM session_summaries`,
263
+ ))!;
264
+ for (const key of Object.keys(seen) as (keyof typeof seen)[])
265
+ seen[key] = Math.max(seen[key], shape[key] ?? 0);
266
+ }
267
+ }
268
+ // Every operation ran, and the checks compared real chains and Done marks.
269
+ for (const op of Object.keys(OPS)) expect(counts[op] ?? 0).toBeGreaterThan(0);
270
+ expect(seen.chains).toBeGreaterThan(5);
271
+ expect(seen.humans).toBeGreaterThan(5);
272
+ expect(seen.pending).toBeGreaterThan(0);
273
+ expect(doneChecks).toBeGreaterThan(0);
274
+ }, 60_000);
275
+
276
+ test("the drift check reports a corrupted summary and member (positive control)", async () => {
277
+ const root = await insertTask();
278
+ const child = await insertTask({ parentTaskId: root });
279
+ expect(await findSessionSummaryDrift()).toEqual([]);
280
+
281
+ await client().run(
282
+ "UPDATE session_summaries SET chainCount = chainCount + 5, lastHumanAt = 'x' WHERE rootId = ?",
283
+ [root],
284
+ );
285
+ await client().run("DELETE FROM session_members WHERE taskId = ?", [child]);
286
+ const drift = await findSessionSummaryDrift();
287
+ expect(drift.map((d) => `${d.rootId}:${d.field}`).sort()).toEqual(
288
+ [`${root}:chainCount`, `${root}:lastHumanAt`, `${root}:member`].sort(),
289
+ );
290
+
291
+ await client().run("INSERT INTO session_members (taskId, rootId) VALUES (?, ?)", [child, root]);
292
+ await client().run("INSERT INTO session_summary_recompute (rootId) VALUES (?)", [root]);
293
+ expect(await findSessionSummaryDrift()).toEqual([]);
294
+ });
295
+
296
+ test("deleting a mid-chain task drops its descendants from the session", async () => {
297
+ const root = await insertTask();
298
+ const mid = await insertTask({ parentTaskId: root });
299
+ const leaf = await insertTask({ parentTaskId: mid });
300
+ await insertTask({ parentTaskId: leaf });
301
+ const sibling = await insertTask({ parentTaskId: root });
302
+
303
+ expect(await deleteTask(mid)).toBe(true);
304
+ const members = await client().query<{ taskId: string }>(
305
+ "SELECT taskId FROM session_members WHERE rootId = ? ORDER BY taskId",
306
+ [root],
307
+ );
308
+ expect(members.map((m) => m.taskId)).toEqual([root, sibling].sort());
309
+ const summary = await client().get<{ chainCount: number }>(
310
+ "SELECT chainCount FROM session_summaries WHERE rootId = ?",
311
+ [root],
312
+ );
313
+ expect(summary!.chainCount).toBe(2);
314
+ expect(await findSessionSummaryDrift()).toEqual([]);
315
+
316
+ expect(await deleteTask(root)).toBe(true);
317
+ const left = await client().get<{ members: number; summaries: number }>(
318
+ `SELECT (SELECT COUNT(*) FROM session_members WHERE rootId = ?) AS members,
319
+ (SELECT COUNT(*) FROM session_summaries WHERE rootId = ?) AS summaries`,
320
+ [root, root],
321
+ );
322
+ expect(left).toEqual({ members: 0, summaries: 0 });
323
+ });
324
+
325
+ test("deleting a task whose steering cascades leaves no half-recomputed summary", async () => {
326
+ // The steering FK cascade fires its trigger while the task row is already gone.
327
+ const steer = (taskId: string) =>
328
+ createSteeringMessage({
329
+ taskId,
330
+ body: "steer",
331
+ mode: "steer",
332
+ source: "ui",
333
+ createdByKind: "user",
334
+ createdByUserId: users[0]!,
335
+ });
336
+ const single = await insertTask();
337
+ await steer(single);
338
+ expect(await deleteTask(single)).toBe(true);
339
+
340
+ const root = await insertTask();
341
+ const child = await insertTask({ parentTaskId: root });
342
+ await steer(root);
343
+ await steer(child);
344
+ await client().run("DELETE FROM agent_tasks WHERE id IN (?, ?)", [child, root]);
345
+ expect(await findSessionSummaryDrift()).toEqual([]);
346
+ });
347
+
348
+ test("changing parentTaskId aborts", async () => {
349
+ const root = await insertTask();
350
+ const other = await insertTask();
351
+ const child = await insertTask({ parentTaskId: root });
352
+ await expect(
353
+ client().run("UPDATE agent_tasks SET parentTaskId = ? WHERE id = ?", [other, child]),
354
+ ).rejects.toThrow(/parentTaskId is immutable/);
355
+ // Rewriting the same value is not a change.
356
+ await client().run("UPDATE agent_tasks SET parentTaskId = ? WHERE id = ?", [root, child]);
357
+ });
358
+
359
+ test("the migration backfill rebuilds the same summaries", async () => {
360
+ const before = await client().query<Record<string, unknown>>(
361
+ "SELECT * FROM session_summaries ORDER BY rootId",
362
+ );
363
+ expect(before.length).toBeGreaterThan(20);
364
+ const sql = await Bun.file(MIGRATION).text();
365
+ const backfill = sql.slice(sql.indexOf("-- Backfill:"));
366
+ expect(backfill).toContain("INSERT INTO session_members");
367
+ await client().transaction(async (tx) => {
368
+ await tx.run("DELETE FROM session_summaries");
369
+ await tx.run("DELETE FROM session_members");
370
+ for (const statement of backfill
371
+ .split(/;\s*\n/)
372
+ .filter((s) => s.replace(/--.*$/gm, "").trim()))
373
+ await tx.run(statement);
374
+ });
375
+ expect(await findSessionSummaryDrift()).toEqual([]);
376
+ const after = await client().query<Record<string, unknown>>(
377
+ "SELECT * FROM session_summaries ORDER BY rootId",
378
+ );
379
+ // latestStatus may differ only where two chain tasks tie on the newest timestamp.
380
+ const strip = (rows: Record<string, unknown>[]) =>
381
+ rows.map(({ latestStatus: _, ...row }) => row);
382
+ expect(strip(after)).toEqual(strip(before));
383
+ });
384
+ });
@@ -917,6 +917,8 @@ describe("Mine view hides machine-started roots", () => {
917
917
  const slack = await task("shared/", { source: "slack" });
918
918
  const ui = await task("shared/", { source: "ui" });
919
919
  const whatsapp = await task("shared/", { source: "system", taskType: "kapso-inbound" });
920
+ // Comments the viewer sent from the Files page.
921
+ const comb = await task("shared/", { source: "comb", taskType: "comb-review" });
920
922
  // A workflow root someone else shared with the viewer stays too.
921
923
  const sharedRun = await task("shared/", { source: "workflow", requestedByUserId: other.id });
922
924
  await share(sharedRun);
@@ -935,13 +937,15 @@ describe("Mine view hides machine-started roots", () => {
935
937
  return ((await res.json()) as Awaited<ReturnType<typeof getSessionTree>>).noGroup.total;
936
938
  };
937
939
 
938
- const mine = [slack.id, ui.id, whatsapp.id, sharedRun.id].sort();
940
+ const mine = [slack.id, ui.id, whatsapp.id, comb.id, sharedRun.id].sort();
939
941
  const all = [...mine, demo.id, scheduled.id, heartbeat.id].sort();
940
942
  expect(await list("")).toEqual(mine);
941
943
  expect(await list("show=mine")).toEqual(mine);
942
944
  expect(await list("show=all")).toEqual(all);
943
945
  expect(await tree("")).toBe(mine.length);
944
946
  expect(await tree("show=all")).toBe(all.length);
947
+ // The sessions v2 default source filter (`comb,ui`) keeps the Files session.
948
+ expect(await list("showSource=comb,ui")).toEqual([comb.id, ui.id].sort());
945
949
 
946
950
  // No picked user: the bypass actor still sees every root, ownerless included.
947
951
  const bypass = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50`);
@@ -0,0 +1,90 @@
1
+ import { afterAll, afterEach, describe, expect, mock, setSystemTime, test } from "bun:test";
2
+
3
+ // A local Slack Web API. Each test uses its own bot token, so the per-token
4
+ // cache never leaks between tests.
5
+ type Behaviour = { hangMs?: number; botsInfoFails?: number };
6
+ const behaviour = new Map<string, Behaviour>();
7
+ const hits: string[] = [];
8
+ const server = Bun.serve({
9
+ port: 0,
10
+ async fetch(req) {
11
+ const method = new URL(req.url).pathname.split("/").pop() ?? "";
12
+ const token = req.headers.get("authorization")?.replace("Bearer ", "") ?? "";
13
+ hits.push(`${token} ${method}`);
14
+ const b = behaviour.get(token) ?? {};
15
+ if (method === "auth.test") {
16
+ if (b.hangMs) await Bun.sleep(b.hangMs);
17
+ return Response.json({ ok: true, bot_id: "B123" });
18
+ }
19
+ if (b.botsInfoFails) {
20
+ b.botsInfoFails--;
21
+ return Response.json({ ok: false, error: "missing_scope" });
22
+ }
23
+ return Response.json({ ok: true, bot: { icons: { image_72: `https://a/${token}.png` } } });
24
+ },
25
+ });
26
+ const slackApiUrl = `http://localhost:${server.port}/api/`;
27
+ let token: string | null = null;
28
+ mock.module("../slack/app", () => ({
29
+ getSlackApp: () => (token ? { client: { token, slackApiUrl } } : null),
30
+ }));
31
+ const { getSlackAppIconUrl } = await import("../slack/app-icon");
32
+
33
+ const calls = (t: string, method: string) => hits.filter((h) => h === `${t} ${method}`).length;
34
+
35
+ afterEach(() => setSystemTime());
36
+ afterAll(() => server.stop(true));
37
+
38
+ describe("getSlackAppIconUrl", () => {
39
+ test("returns the bot's image_72, cached for an hour, then asks again", async () => {
40
+ token = "xoxb-cache";
41
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-cache.png");
42
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-cache.png");
43
+ expect(calls(token, "bots.info")).toBe(1);
44
+ setSystemTime(new Date(Date.now() + 60 * 60 * 1000));
45
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-cache.png");
46
+ expect(calls(token, "bots.info")).toBe(2);
47
+ });
48
+
49
+ test("Slack off returns null even with an avatar cached", async () => {
50
+ token = "xoxb-off";
51
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-off.png");
52
+ token = null;
53
+ expect(await getSlackAppIconUrl()).toBeNull();
54
+ });
55
+
56
+ test("concurrent calls share one lookup", async () => {
57
+ token = "xoxb-concurrent";
58
+ const results = await Promise.all([1, 2, 3].map(() => getSlackAppIconUrl()));
59
+ expect(results).toEqual(Array(3).fill("https://a/xoxb-concurrent.png"));
60
+ expect(calls(token, "auth.test")).toBe(1);
61
+ });
62
+
63
+ test("a failed lookup is not cached", async () => {
64
+ token = "xoxb-flaky";
65
+ behaviour.set(token, { botsInfoFails: 1 });
66
+ expect(await getSlackAppIconUrl()).toBeNull();
67
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-flaky.png");
68
+ expect(calls(token, "bots.info")).toBe(2);
69
+ });
70
+
71
+ test(
72
+ "a hanging auth.test times out without a retry or a bots.info, then a new lookup works",
73
+ async () => {
74
+ token = "xoxb-hang";
75
+ behaviour.set(token, { hangMs: 4_000 });
76
+ const started = Date.now();
77
+ expect(await getSlackAppIconUrl()).toBeNull();
78
+ expect(Date.now() - started).toBeLessThan(3_500);
79
+ // Let the late auth.test reply arrive: nothing may follow it.
80
+ await Bun.sleep(4_000 - (Date.now() - started) + 300);
81
+ expect(calls(token, "auth.test")).toBe(1);
82
+ expect(calls(token, "bots.info")).toBe(0);
83
+
84
+ behaviour.set(token, {});
85
+ expect(await getSlackAppIconUrl()).toBe("https://a/xoxb-hang.png");
86
+ expect(calls(token, "auth.test")).toBe(2);
87
+ },
88
+ { timeout: 15_000 },
89
+ );
90
+ });
@@ -89,7 +89,7 @@ const writes: Array<[string, (c: InstanceType<typeof webApi.WebClient>) => Promi
89
89
  thread_ts: "1.0",
90
90
  text: "progress",
91
91
  username: "Agent",
92
- icon_emoji: ":robot_face:",
92
+ icon: { icon_emoji: ":robot_face:" },
93
93
  }),
94
94
  ],
95
95
  [
@@ -23,6 +23,7 @@ import {
23
23
  getSlackInboundReceiptById,
24
24
  getSlackInboundReceiptByKey,
25
25
  purgeSlackInboundReceipts,
26
+ recordSlackInboundInline,
26
27
  SLACK_INBOUND_LIMITS,
27
28
  } from "../be/db-queries/slack-inbound";
28
29
  import { registerActionHandlers } from "../slack/actions";
@@ -619,6 +620,39 @@ describe("retention", () => {
619
620
  });
620
621
  });
621
622
 
623
+ describe("inline tombstones", () => {
624
+ const input = {
625
+ dedupKey: "event:A_TEST:Ev_INLINE",
626
+ transport: "socket",
627
+ kind: "event",
628
+ payloadType: "event_callback",
629
+ eventId: "Ev_INLINE",
630
+ } as const;
631
+
632
+ test("a recorded key is terminal, holds no payload, is never claimable and expires", async () => {
633
+ await getDbClient().run("DELETE FROM slack_inbound_receipts");
634
+ const at = new Date("2026-09-01T00:00:00.000Z");
635
+ expect(await recordSlackInboundInline(input, at)).toBe("recorded");
636
+ expect(await recordSlackInboundInline(input, at)).toBe("duplicate");
637
+
638
+ const receipt = await getSlackInboundReceiptByKey(input.dedupKey);
639
+ expect(receipt).toMatchObject({
640
+ state: "processed",
641
+ outcomeCode: "ran_inline",
642
+ payloadCiphertext: null,
643
+ payloadBytes: 0,
644
+ duplicateCount: 1,
645
+ });
646
+ expect(await claimSlackInboundReceipt(new Date("2026-09-01T00:01:00.000Z"))).toBeNull();
647
+
648
+ // Retention drops it with the other completed keys.
649
+ await purgeSlackInboundReceipts(new Date("2026-09-02T00:00:00.000Z"));
650
+ expect(await getSlackInboundReceiptByKey(input.dedupKey)).not.toBeNull();
651
+ await purgeSlackInboundReceipts(new Date(at.getTime() + 49 * 60 * 60 * 1000));
652
+ expect(await getSlackInboundReceiptByKey(input.dedupKey)).toBeNull();
653
+ });
654
+ });
655
+
622
656
  describe("Socket Mode ingress is unchanged", () => {
623
657
  test("SLACK_MODE defaults to socket", () => {
624
658
  expect(getSlackConfiguration({}).mode).toBe("socket");
@@ -2,10 +2,11 @@ import { describe, expect, mock, test } from "bun:test";
2
2
  import { MAX_SECTION_LENGTH } from "../slack/blocks";
3
3
  import {
4
4
  formatInlineCompletionOutputChunks,
5
+ getAgentSlackIcon,
5
6
  sendWithPersona,
6
7
  shouldPostInlineCompletionOutput,
7
8
  } from "../slack/responses";
8
- import type { AgentTask } from "../types";
9
+ import type { Agent, AgentTask } from "../types";
9
10
 
10
11
  const TASK_ID = "abcdef12-3456-7890-abcd-ef1234567890";
11
12
 
@@ -30,13 +31,39 @@ describe("Slack inline completion output", () => {
30
31
  thread_ts: "1",
31
32
  text: "done",
32
33
  username: "Literal Lead",
33
- icon_emoji: ":crown:",
34
+ icon: { icon_emoji: ":crown:" },
34
35
  });
35
36
  expect(postMessage).toHaveBeenCalledWith(
36
37
  expect.objectContaining({ username: "Literal Lead", icon_emoji: ":crown:" }),
37
38
  );
38
39
  });
39
40
 
41
+ test("posts each agent's Slack icon choice and nothing else", async () => {
42
+ const cases: [Partial<Agent>, Record<string, string>][] = [
43
+ [{ slackIcon: { type: "emoji", emoji: ":owl:" } }, { icon_emoji: ":owl:" }],
44
+ [{ slackIcon: { type: "url", url: "https://a/o.png" } }, { icon_url: "https://a/o.png" }],
45
+ [{ slackIcon: { type: "app" } }, {}],
46
+ [{ isLead: true, slackIcon: null }, { icon_emoji: ":crown:" }],
47
+ [{ slackIcon: null }, { icon_emoji: ":star:" }],
48
+ ];
49
+ for (const [overrides, icon] of cases) {
50
+ const postMessage = mock(async (_payload: Record<string, unknown>) => ({ ts: "1" }));
51
+ const agent = { id: "a1", name: "Owl Worker", isLead: false, ...overrides } as Agent;
52
+ await sendWithPersona({ chat: { postMessage } } as never, {
53
+ channel: "C123",
54
+ thread_ts: "1",
55
+ text: "done",
56
+ username: "Owl Worker",
57
+ icon: getAgentSlackIcon(agent),
58
+ });
59
+ const payload = postMessage.mock.calls[0]?.[0] ?? {};
60
+ const sent = Object.fromEntries(
61
+ Object.entries(payload).filter(([key]) => key.startsWith("icon_")),
62
+ );
63
+ expect(sent).toEqual(icon);
64
+ }
65
+ });
66
+
40
67
  test("posts only truncated completed Slack output that has not already been replied", () => {
41
68
  const longOutput = `Here is the answer. ${"Detailed finding ".repeat(20)}`;
42
69
  expect(shouldPostInlineCompletionOutput(task({ output: longOutput }))).toBe(true);
@@ -96,8 +96,9 @@ describe("slack render-v2 watcher queries", () => {
96
96
  test("deferral resolution drives from the chain, not from every task", async () => {
97
97
  const sql = await capturedSql(() => getResolvableDeferralOutcomes());
98
98
  const detail = plan(sql, [50]);
99
- expect(detail).toContain(
100
- "SEARCH wake USING INDEX idx_agent_tasks_parentTaskId (parentTaskId=?)",
99
+ // Either parentTaskId-led index is a seek on the chain's children.
100
+ expect(detail).toMatch(
101
+ /SEARCH wake USING INDEX idx_agent_tasks_parentTaskId(_source)? \(parentTaskId=\?\)/,
101
102
  );
102
103
  expect(detail).not.toContain("idx_agent_tasks_taskType");
103
104
  expect(detail).not.toContain("idx_agent_tasks_status_last_updated");
@@ -40,6 +40,7 @@ import {
40
40
  noteSlackOutcomeDeliveryFailure,
41
41
  startTask,
42
42
  supersedeTask,
43
+ updateAgentProfile,
43
44
  upsertSwarmConfig,
44
45
  } from "../be/db";
45
46
  import { upsertTaskCitations } from "../be/task-citations";
@@ -1798,6 +1799,40 @@ describe("Slack renderer v2", () => {
1798
1799
  );
1799
1800
  });
1800
1801
 
1802
+ test("a URL Slack icon reaches the stream and its postMessage fallback", async () => {
1803
+ const lead = await createAgent({ name: "Owl Lead", isLead: true, status: "idle" });
1804
+ const iconUrl = "https://example.com/owl.png";
1805
+ await updateAgentProfile(lead.id, { slackIcon: { type: "url", url: iconUrl } });
1806
+ const { channelId, threadTs } = uniqueSlackAddress("C_ICON_URL");
1807
+ const ask = await createTaskExtended("Owl outcome", {
1808
+ agentId: lead.id,
1809
+ source: "slack",
1810
+ slackChannelId: channelId,
1811
+ slackThreadTs: threadTs,
1812
+ contextKey: slackContextKey({ channelId, threadTs }),
1813
+ });
1814
+ await startTask(ask.id);
1815
+ await completeTask(ask.id, "Hoot.");
1816
+ await backdateLastUpdated([ask.id], 20);
1817
+ await ensureSlackThreadTree([ask.id]);
1818
+ calls.length = 0;
1819
+ startStreamFailuresRemaining = 1;
1820
+ try {
1821
+ await processSlackRenderV2();
1822
+ } finally {
1823
+ startStreamFailuresRemaining = 0;
1824
+ }
1825
+
1826
+ const stream = calls.find((call) => call.method === "chat.startStream");
1827
+ const fallback = calls.find(
1828
+ (call) => call.method === "chat.postMessage" && String(call.payload.text).startsWith("✅"),
1829
+ );
1830
+ for (const payload of [stream?.payload, fallback?.payload]) {
1831
+ expect(payload?.icon_url).toBe(iconUrl);
1832
+ expect(payload).not.toHaveProperty("icon_emoji");
1833
+ }
1834
+ });
1835
+
1801
1836
  test("preserves complete native Markdown beyond the Block Kit text ceiling", async () => {
1802
1837
  const lead = await createAgent({ name: "Markdown Lead", isLead: true, status: "idle" });
1803
1838
  const { channelId, threadTs } = uniqueSlackAddress("C_OUTCOME_MARKDOWN");