@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,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defaultSlackEmoji,
4
+ parseSlackIconInput,
5
+ slackEmojiGlyph,
6
+ } from "../../apps/ui/src/lib/slack-icon";
7
+ import { getAgentEmoji } from "../slack/responses";
8
+ import { type Agent, AgentSlackIconSchema } from "../types";
9
+
10
+ function agent(overrides: Partial<Agent> = {}): Agent {
11
+ return { id: "a1", name: "Owl Worker", isLead: false, status: "idle", ...overrides } as Agent;
12
+ }
13
+
14
+ // Each raw input, the icon the server receives for it, and whether both the
15
+ // zod schema and the dashboard parser accept it.
16
+ const PARITY_CASES: Array<[string, boolean]> = [
17
+ [":owl:", true],
18
+ [":+1:", true],
19
+ [":bee-lgtm:", true],
20
+ [":thumbs_up:", true],
21
+ ["https://example.com/owl.png", true],
22
+ ["https://example.com/owl.png?size=64", true],
23
+ ["http://example.com/owl.png", false],
24
+ ["https://", false],
25
+ ["https://exa mple.com/owl.png", false],
26
+ ["https://[::1", false],
27
+ ["https://user:pass@example.com/owl.png", false],
28
+ ["https://user@example.com/owl.png", false],
29
+ [`https://example.com/${"a".repeat(2048)}`, false],
30
+ ["javascript:alert(1)", false],
31
+ [":owl face:", false],
32
+ [":wave::skin-tone-3:", false],
33
+ [`:${"a".repeat(101)}:`, false],
34
+ ];
35
+
36
+ function asIcon(raw: string) {
37
+ return /^https?:/i.test(raw) ? { type: "url", url: raw } : { type: "emoji", emoji: raw };
38
+ }
39
+
40
+ describe("Slack icon validation parity", () => {
41
+ for (const [raw, accepted] of PARITY_CASES) {
42
+ test(`${raw.slice(0, 48)} is ${accepted ? "accepted" : "rejected"} by both`, () => {
43
+ expect(AgentSlackIconSchema.safeParse(asIcon(raw)).success).toBe(accepted);
44
+ const parsed = parseSlackIconInput(raw);
45
+ expect(parsed.ok).toBe(accepted);
46
+ if (parsed.ok) expect(parsed.icon).toEqual(asIcon(raw) as never);
47
+ });
48
+ }
49
+
50
+ test("the schema rejects shortcodes the dashboard would normalise", () => {
51
+ expect(AgentSlackIconSchema.safeParse({ type: "emoji", emoji: "owl" }).success).toBe(false);
52
+ expect(AgentSlackIconSchema.safeParse({ type: "emoji", emoji: ":Owl:" }).success).toBe(false);
53
+ });
54
+
55
+ test("the dashboard normalises bare and capitalised shortcodes, and blank resets", () => {
56
+ expect(parseSlackIconInput(" owl ")).toEqual({
57
+ ok: true,
58
+ icon: { type: "emoji", emoji: ":owl:" },
59
+ });
60
+ expect(parseSlackIconInput(":Owl:")).toEqual({
61
+ ok: true,
62
+ icon: { type: "emoji", emoji: ":owl:" },
63
+ });
64
+ expect(parseSlackIconInput(" ")).toEqual({ ok: true, icon: null });
65
+ expect(parseSlackIconInput("http://example.com/owl.png")).toEqual({
66
+ ok: false,
67
+ error: "Use an https URL.",
68
+ });
69
+ });
70
+ });
71
+
72
+ describe("default emoji parity", () => {
73
+ test("the dashboard picks the same default as getAgentEmoji", () => {
74
+ const agents = [
75
+ agent({ isLead: true, name: "Neo" }),
76
+ ...["Owl Worker", "Coder", "Reviewer", "Mobile Coder", "e2e-worker-a", "Ω", ""].map((name) =>
77
+ agent({ name }),
78
+ ),
79
+ ];
80
+ for (const a of agents) {
81
+ expect(defaultSlackEmoji(a)).toBe(getAgentEmoji(a));
82
+ expect(slackEmojiGlyph(getAgentEmoji(a))).not.toBeNull();
83
+ }
84
+ });
85
+ });
@@ -24,6 +24,8 @@ describe("migration 140 approval cancellation lifecycle", () => {
24
24
  runMigrations(db);
25
25
  db.run("DELETE FROM approval_requests");
26
26
  db.run("DROP TRIGGER cancel_approval_request_after_workflow_step_cancel");
27
+ // Reads approval_requests from another table, so the rebuild's rename would fail.
28
+ db.run("DROP TRIGGER session_summary_recompute_apply");
27
29
  db.run("DROP TABLE approval_requests");
28
30
  db.run(`CREATE TABLE approval_requests (
29
31
  id TEXT PRIMARY KEY,
@@ -20,7 +20,23 @@ async function removeDb(path: string): Promise<void> {
20
20
  }
21
21
  }
22
22
 
23
+ /** Migration 253's triggers read agent_tasks."key", so a pre-115 fixture predates them too. */
24
+ function dropMigration253ForHistoricalFixture(): void {
25
+ const db = getDb();
26
+ const triggers = db
27
+ .prepare<{ name: string }, []>(
28
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'session\\_%' ESCAPE '\\'",
29
+ )
30
+ .all();
31
+ for (const trigger of triggers) db.run(`DROP TRIGGER "${trigger.name}"`);
32
+ db.run("DROP VIEW session_summary_recompute");
33
+ db.run("DROP TABLE session_summaries");
34
+ db.run("DROP TABLE session_members");
35
+ db.run("DELETE FROM _migrations WHERE version = 253");
36
+ }
37
+
23
38
  function dropMigration115ForHistoricalFixture(): void {
39
+ dropMigration253ForHistoricalFixture();
24
40
  const db = getDb();
25
41
  const triggers = db
26
42
  .prepare<{ name: string }, []>(
@@ -482,6 +498,16 @@ describe("asset namespace key migrations", () => {
482
498
  expect(getDb().query('SELECT "key" FROM agent_tasks WHERE id = ?').get(taskId)).toEqual({
483
499
  key: "shared/",
484
500
  });
501
+ // The rebuild keeps the session summary triggers, including the ones on other tables.
502
+ expect(
503
+ getDb()
504
+ .query(
505
+ `SELECT
506
+ (SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = 'session_task_insert') AS onTasks,
507
+ (SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = 'session_summary_recompute_apply') AS recompute`,
508
+ )
509
+ .get(),
510
+ ).toEqual({ onTasks: 1, recompute: 1 });
485
511
  expect(
486
512
  getDb()
487
513
  .prepare<
@@ -0,0 +1,159 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { getCombConfig } from "../comb/config";
3
+ import { getCombState, isBrowserReachableUrl, isCombEnabled } from "../utils/constants";
4
+
5
+ const ENV_KEYS = [
6
+ "COMB_ENABLED",
7
+ "AGENT_FS_API_URL",
8
+ "AGENT_FS_PUBLIC_URL",
9
+ "AGENT_FS_PROXY",
10
+ "MCP_BASE_URL",
11
+ "PUBLIC_MCP_BASE_URL",
12
+ ];
13
+ const saved = new Map<string, string | undefined>();
14
+
15
+ beforeEach(() => {
16
+ for (const key of ENV_KEYS) {
17
+ saved.set(key, process.env[key]);
18
+ delete process.env[key];
19
+ }
20
+ });
21
+
22
+ afterEach(() => {
23
+ for (const key of ENV_KEYS) {
24
+ const value = saved.get(key);
25
+ if (value === undefined) delete process.env[key];
26
+ else process.env[key] = value;
27
+ }
28
+ });
29
+
30
+ describe("isBrowserReachableUrl", () => {
31
+ test("public hosts and addresses are reachable", () => {
32
+ for (const url of [
33
+ "https://fs.example.com",
34
+ "https://fs.example.com:7433/",
35
+ "http://8.8.8.8:7433",
36
+ "http://172.32.0.1",
37
+ "http://172.15.0.1",
38
+ "http://[2001:db8::1]:7433",
39
+ "http://[2606:4700::1111]:7433",
40
+ "http://[::ffff:8.8.8.8]:7433",
41
+ "http://[::ffff:7f00:1]:7433",
42
+ ]) {
43
+ expect(isBrowserReachableUrl(url)).toBe(true);
44
+ }
45
+ });
46
+
47
+ test("loopback is reachable: a localhost URL is the local dev setup", () => {
48
+ for (const url of ["http://localhost:7433", "http://127.0.0.1:7433", "http://[::1]:7433"]) {
49
+ expect(isBrowserReachableUrl(url)).toBe(true);
50
+ }
51
+ });
52
+
53
+ test("single-label docker hosts are not", () => {
54
+ for (const url of ["http://agent-fs:7433", "http://agent-fs", "http://swarm-agent-fs:7433"]) {
55
+ expect(isBrowserReachableUrl(url)).toBe(false);
56
+ }
57
+ });
58
+
59
+ test("internal suffixes are not", () => {
60
+ for (const url of [
61
+ "http://agent-fs.railway.internal:7433",
62
+ "http://agent-fs.internal",
63
+ "http://agent-fs.default.svc:7433",
64
+ "http://agent-fs.default.svc.cluster.local:7433",
65
+ "http://agent-fs.internal.:7433",
66
+ ]) {
67
+ expect(isBrowserReachableUrl(url)).toBe(false);
68
+ }
69
+ });
70
+
71
+ test("private, link-local and unique-local addresses are not", () => {
72
+ for (const url of [
73
+ "http://10.0.0.5:7433",
74
+ "http://172.16.0.1",
75
+ "http://172.31.255.255",
76
+ "http://192.168.1.10:7433",
77
+ "http://169.254.169.254",
78
+ "http://[fd00::1]:7433",
79
+ "http://[fe80::1]:7433",
80
+ "http://[::ffff:10.0.0.5]:7433",
81
+ "http://[::ffff:192.168.1.10]:7433",
82
+ "http://[::ffff:172.16.0.1]:7433",
83
+ "http://[::ffff:169.254.169.254]",
84
+ "http://[::]:7433",
85
+ "http://[ff02::1]:7433",
86
+ "http://[ff00::1]:7433",
87
+ ]) {
88
+ expect(isBrowserReachableUrl(url)).toBe(false);
89
+ }
90
+ });
91
+
92
+ test("an unparseable value is not reachable", () => {
93
+ expect(isBrowserReachableUrl("agent-fs:7433")).toBe(false);
94
+ expect(isBrowserReachableUrl("")).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("getCombState", () => {
99
+ test("nothing set: off, agent-fs missing", () => {
100
+ expect(getCombState()).toEqual({
101
+ enabled: false,
102
+ explicit: false,
103
+ reason: "AGENT_FS_API_URL is not set.",
104
+ });
105
+ });
106
+
107
+ test("explicit true still needs AGENT_FS_API_URL, but not a browser-reachable one", () => {
108
+ process.env.COMB_ENABLED = "true";
109
+ expect(isCombEnabled()).toBe(false);
110
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
111
+ expect(getCombState()).toEqual({ enabled: true, explicit: true, reason: null });
112
+ });
113
+
114
+ test("explicit false wins over every reachable URL", () => {
115
+ process.env.COMB_ENABLED = "false";
116
+ process.env.AGENT_FS_API_URL = "https://fs.example.com";
117
+ process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
118
+ process.env.AGENT_FS_PROXY = "true";
119
+ expect(getCombState()).toMatchObject({ enabled: false, explicit: true });
120
+ });
121
+
122
+ test("unset: the reachable-URL conditions decide", () => {
123
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
124
+ expect(isCombEnabled()).toBe(false);
125
+ process.env.AGENT_FS_PROXY = "true";
126
+ expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
127
+ delete process.env.AGENT_FS_PROXY;
128
+ process.env.AGENT_FS_PUBLIC_URL = " ";
129
+ expect(isCombEnabled()).toBe(false);
130
+ process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
131
+ expect(isCombEnabled()).toBe(true);
132
+ });
133
+
134
+ test("unset with the proxy on: the public API URL decides, not AGENT_FS_API_URL", () => {
135
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
136
+ process.env.AGENT_FS_PROXY = "true";
137
+ process.env.MCP_BASE_URL = "http://api:3013";
138
+ const state = getCombState();
139
+ expect(state).toMatchObject({ enabled: false, explicit: false });
140
+ expect(state.reason).toContain("PUBLIC_MCP_BASE_URL");
141
+ expect(getCombConfig()).toMatchObject({ enabled: false, apiUrl: "http://api:3013/agentfs" });
142
+ process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.com";
143
+ expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
144
+ expect(getCombConfig().apiUrl).toBe("https://swarm.example.com/agentfs");
145
+ delete process.env.PUBLIC_MCP_BASE_URL;
146
+ process.env.MCP_BASE_URL = "http://[::ffff:10.0.0.5]:3013";
147
+ expect(isCombEnabled()).toBe(false);
148
+ process.env.COMB_ENABLED = "true";
149
+ expect(isCombEnabled()).toBe(true);
150
+ });
151
+
152
+ test("an unrecognized COMB_ENABLED counts as unset", () => {
153
+ process.env.COMB_ENABLED = "treu";
154
+ process.env.AGENT_FS_API_URL = "http://localhost:7433";
155
+ expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
156
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
157
+ expect(getCombState()).toMatchObject({ enabled: false, explicit: false });
158
+ });
159
+ });
@@ -29,7 +29,8 @@ import {
29
29
  upsertPromptTemplate,
30
30
  } from "../be/db";
31
31
  import { installExtension } from "../be/extensions/db";
32
- import { mintToken } from "../be/users";
32
+ import { findUserByExternalId, linkIdentity, mintToken } from "../be/users";
33
+ import { AGENT_FS_IDENTITY_KIND } from "../comb/requester";
33
34
  import {
34
35
  REVIEW_BATCH_MAX,
35
36
  REVIEW_BATCH_READ_CONCURRENCY,
@@ -39,7 +40,11 @@ import {
39
40
  sendReviewBatch,
40
41
  } from "../comb/review-batch";
41
42
  import { enableExtension, stopExtensionRuntime } from "../extensions/lifecycle";
42
- import type { AgentFsComment, AgentFsFileVersion } from "../fs/agent-fs-provider";
43
+ import type {
44
+ AgentFsComment,
45
+ AgentFsDriveMember,
46
+ AgentFsFileVersion,
47
+ } from "../fs/agent-fs-provider";
43
48
  import { resetFileStorageProvider } from "../fs/registry";
44
49
  import { handleComb } from "../http/comb";
45
50
  import { handleCore } from "../http/core";
@@ -67,20 +72,27 @@ const ENV_KEYS = [
67
72
  "AGENT_FS_REQUEST_TIMEOUT_MS",
68
73
  "APP_URL",
69
74
  "RBAC_ENABLED",
75
+ "TRUST_BODY_REQUESTED_BY_USER_ID",
70
76
  ];
71
77
  const savedEnv = new Map<string, string | undefined>();
72
78
 
73
79
  /**
74
80
  * An in-memory agent-fs behind a real HTTP server: `/auth/me`, `comment-get`,
75
- * `log`, and `comment-add` replies. The swarm reaches it through the registry
81
+ * `log`, `drive-members`, and `comment-add` replies. The swarm reaches it through the registry
76
82
  * provider (env), like production.
77
83
  */
78
84
  class FakeAgentFs {
79
85
  comments = new Map<string, AgentFsComment>();
80
86
  /** `log` answers, by stored version path (exact match, like agent-fs). */
81
87
  versions = new Map<string, AgentFsFileVersion[]>();
88
+ /** `drive-members` answer. */
89
+ members: AgentFsDriveMember[] = [];
82
90
  failReplies = false;
83
91
  failGets = false;
92
+ failMembers = false;
93
+ failAuthMe = false;
94
+ /** `drive-members` calls. */
95
+ memberReads = 0;
84
96
  /** `comment-get` calls: total, in flight now, the most in flight at once, and a delay per call. */
85
97
  gets = 0;
86
98
  getsInFlight = 0;
@@ -111,7 +123,10 @@ class FakeAgentFs {
111
123
  return Response.json({ error: "UNAUTHORIZED" }, { status: 401 });
112
124
  }
113
125
  const url = new URL(request.url);
114
- if (url.pathname === "/auth/me") return Response.json({ userId: SERVICE_USER });
126
+ if (url.pathname === "/auth/me") {
127
+ if (this.failAuthMe) return Response.json({ error: "DOWN" }, { status: 500 });
128
+ return Response.json({ userId: SERVICE_USER });
129
+ }
115
130
  if (url.pathname !== `/orgs/${ORG}/ops` || request.method !== "POST") {
116
131
  return new Response("not found", { status: 404 });
117
132
  }
@@ -128,6 +143,11 @@ class FakeAgentFs {
128
143
  if (!comment) return Response.json({ error: "NOT_FOUND" }, { status: 404 });
129
144
  return Response.json({ comment, replies: this.repliesTo(comment.id) });
130
145
  }
146
+ if (body.op === "drive-members") {
147
+ this.memberReads++;
148
+ if (this.failMembers) return Response.json({ error: "INTERNAL" }, { status: 500 });
149
+ return Response.json({ members: this.members });
150
+ }
131
151
  if (body.op === "log") {
132
152
  return Response.json({ versions: this.versions.get(body.path as string) ?? [] });
133
153
  }
@@ -238,8 +258,12 @@ afterAll(async () => {
238
258
  beforeEach(async () => {
239
259
  fake.comments.clear();
240
260
  fake.versions.clear();
261
+ fake.members = [];
262
+ fake.memberReads = 0;
241
263
  fake.failReplies = false;
242
264
  fake.failGets = false;
265
+ fake.failMembers = false;
266
+ fake.failAuthMe = false;
243
267
  fake.gets = 0;
244
268
  fake.maxGetsInFlight = 0;
245
269
  fake.getDelayMs = 0;
@@ -650,6 +674,170 @@ describe("sendReviewBatch", () => {
650
674
  });
651
675
  });
652
676
 
677
+ describe("requester from the comment authors", () => {
678
+ /** A swarm user plus the agent-fs drive member with the same email. */
679
+ async function member(name: string): Promise<{ userId: string; author: string; email: string }> {
680
+ const email = `${name.toLowerCase()}-${crypto.randomUUID()}@example.test`;
681
+ const user = await createUser({ name, email });
682
+ const author = `af-${crypto.randomUUID()}`;
683
+ fake.members.push({ userId: author, email, displayName: name });
684
+ return { userId: user.id, author, email };
685
+ }
686
+
687
+ /** A swarm user linked to the swarm service account's agent-fs id, once per file. */
688
+ async function linkServiceAccount(): Promise<void> {
689
+ if (await findUserByExternalId(AGENT_FS_IDENTITY_KIND, SERVICE_USER)) return;
690
+ const service = await createUser({
691
+ name: "Service",
692
+ email: `svc-${crypto.randomUUID()}@x.test`,
693
+ });
694
+ await linkIdentity(service.id, AGENT_FS_IDENTITY_KIND, SERVICE_USER, {
695
+ kind: "system",
696
+ id: "test",
697
+ });
698
+ }
699
+
700
+ test("one author: the swarm user with the agent-fs member's email", async () => {
701
+ const ana = await member("Ana");
702
+ const a = fake.add({ body: "@swarm a", author: ana.author });
703
+ const b = fake.add({ body: "@swarm b", author: ana.author });
704
+
705
+ const result = await sendReviewBatch(batch([a.id, b.id]));
706
+
707
+ const task = await getTaskById(result.taskId as string);
708
+ expect(task?.requestedByUserId).toBe(ana.userId);
709
+ expect(task?.task).toContain(`Requested by: Ana <${ana.email}>`);
710
+ expect(fake.memberReads).toBe(1);
711
+ });
712
+
713
+ test("a linked agent-fs identity maps without reading the drive members", async () => {
714
+ const user = await createUser({
715
+ name: "Linked",
716
+ email: `linked-${crypto.randomUUID()}@x.test`,
717
+ });
718
+ const author = `af-${crypto.randomUUID()}`;
719
+ await linkIdentity(user.id, AGENT_FS_IDENTITY_KIND, author, { kind: "system", id: "test" });
720
+ const a = fake.add({ body: "@swarm a", author });
721
+
722
+ const result = await sendReviewBatch(batch([a.id]));
723
+
724
+ expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBe(user.id);
725
+ expect(fake.memberReads).toBe(0);
726
+ });
727
+
728
+ test("several authors: the author of the newest mapped comment", async () => {
729
+ const ana = await member("Ana");
730
+ const bo = await member("Bo");
731
+ const older = fake.add({ body: "@swarm a", author: bo.author });
732
+ const newest = fake.add({ body: "@swarm b", author: ana.author });
733
+ const middle = fake.add({ body: "@swarm c", author: bo.author });
734
+ middle.createdAt = new Date(Date.parse(older.createdAt) + 1).toISOString();
735
+ newest.createdAt = new Date(Date.parse(older.createdAt) + 60_000).toISOString();
736
+ // The newest comment of all has an author with no swarm user: skipped.
737
+ const stranger = fake.add({
738
+ body: "@swarm d",
739
+ author: "af-stranger",
740
+ createdAt: new Date(Date.parse(older.createdAt) + 120_000).toISOString(),
741
+ });
742
+
743
+ // Bo is first, last mapped, and the majority: only "newest" picks Ana.
744
+ const result = await sendReviewBatch(batch([middle.id, newest.id, older.id, stranger.id]));
745
+
746
+ const task = await getTaskById(result.taskId as string);
747
+ expect(task?.requestedByUserId).toBe(ana.userId);
748
+ expect(task?.task).toContain("Requested by: Ana <");
749
+ });
750
+
751
+ test("no author maps: no requester, as before", async () => {
752
+ const a = fake.add({ body: "@swarm a", author: "af-stranger" });
753
+ fake.members.push({ userId: "af-stranger", email: "nobody-here@example.test" });
754
+
755
+ const result = await sendReviewBatch(batch([a.id]));
756
+
757
+ const task = await getTaskById(result.taskId as string);
758
+ expect(task?.requestedByUserId).toBeUndefined();
759
+ expect(task?.task).toContain("Requested by: an operator (swarm API key)");
760
+ });
761
+
762
+ test("a failed drive-members read leaves the requester empty and still sends", async () => {
763
+ const ana = await member("Ana");
764
+ fake.failMembers = true;
765
+ const a = fake.add({ body: "@swarm a", author: ana.author });
766
+
767
+ const result = await sendReviewBatch(batch([a.id]));
768
+
769
+ expect(result.sent).toEqual([a.id]);
770
+ expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
771
+ });
772
+
773
+ test("the swarm service account is never the requester", async () => {
774
+ await linkServiceAccount();
775
+ const a = fake.add({ body: "@swarm a", author: SERVICE_USER });
776
+
777
+ const result = await sendReviewBatch(batch([a.id]));
778
+
779
+ expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
780
+ });
781
+
782
+ test("an unknown service identity leaves the requester empty and still sends", async () => {
783
+ await linkServiceAccount();
784
+ fake.failAuthMe = true;
785
+ const a = fake.add({ body: "@swarm a", author: SERVICE_USER });
786
+
787
+ const result = await sendReviewBatch(batch([a.id]));
788
+
789
+ expect(result.sent).toEqual([a.id]);
790
+ expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
791
+ expect(fake.memberReads).toBe(0);
792
+ });
793
+
794
+ test("TRUST_BODY_REQUESTED_BY_USER_ID=false: no author requester, and the send still works", async () => {
795
+ process.env.TRUST_BODY_REQUESTED_BY_USER_ID = "false";
796
+ const ana = await member("Ana");
797
+ const a = fake.add({ body: "@swarm a", author: ana.author });
798
+
799
+ const result = await sendReviewBatch(batch([a.id]));
800
+
801
+ expect(result.sent).toEqual([a.id]);
802
+ expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
803
+ expect(fake.memberReads).toBe(0);
804
+ });
805
+
806
+ test("an authenticated sender wins, and the body cannot name a requester", async () => {
807
+ const ana = await member("Ana");
808
+ const sender = await createUser({
809
+ name: "Sender",
810
+ email: `sender-${crypto.randomUUID()}@x.test`,
811
+ });
812
+ const { plaintext } = await mintToken(sender.id, "comb-test", { kind: "system", id: "test" });
813
+ const a = fake.add({ body: "@swarm a", author: ana.author });
814
+ const b = fake.add({ body: "@swarm b", author: ana.author });
815
+
816
+ const asUser = await post(
817
+ { orgId: ORG, driveId: DRIVE, commentIds: [a.id], scopePath: "/" },
818
+ { Authorization: `Bearer ${plaintext}` },
819
+ );
820
+ expect(asUser.status).toBe(201);
821
+ const userTask = await getTaskById(((await asUser.json()) as ReviewBatchResult).taskId ?? "");
822
+ expect(userTask?.requestedByUserId).toBe(sender.id);
823
+ expect(fake.memberReads).toBe(0);
824
+
825
+ // Operator key: a requestedByUserId in the body is dropped, the author maps.
826
+ const asOperator = await post({
827
+ orgId: ORG,
828
+ driveId: DRIVE,
829
+ commentIds: [b.id],
830
+ scopePath: "/",
831
+ requestedByUserId: sender.id,
832
+ });
833
+ expect(asOperator.status).toBe(201);
834
+ const operatorTask = await getTaskById(
835
+ ((await asOperator.json()) as ReviewBatchResult).taskId ?? "",
836
+ );
837
+ expect(operatorTask?.requestedByUserId).toBe(ana.userId);
838
+ });
839
+ });
840
+
653
841
  describe("POST /api/comb/review-batches", () => {
654
842
  test("the dashboard batch cap and note limit match the server's", () => {
655
843
  expect(COMB_BATCH_MAX).toBe(REVIEW_BATCH_MAX);
@@ -62,11 +62,67 @@ afterEach(() => {
62
62
  });
63
63
 
64
64
  describe("/status agent_fs.comb", () => {
65
- test("is off by default", async () => {
65
+ test("unset and AGENT_FS_API_URL is a docker-internal host: off, and says what is missing", async () => {
66
66
  process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
67
67
  const result = await comb();
68
68
  expect(result.enabled).toBe(false);
69
69
  expect(result.api_url).toBe("http://agent-fs:7433");
70
+ expect(result.reason).toContain("AGENT_FS_PUBLIC_URL or AGENT_FS_PROXY=true");
71
+ });
72
+
73
+ test("unset and AGENT_FS_PROXY on: on, through the API", async () => {
74
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
75
+ process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.com";
76
+ process.env.AGENT_FS_PROXY = "true";
77
+ const result = await comb();
78
+ expect(result.enabled).toBe(true);
79
+ expect(result.reason).toBeNull();
80
+ expect(result.api_url).toBe("https://swarm.example.com/agentfs");
81
+ });
82
+
83
+ test("unset, AGENT_FS_PROXY on and an internal API address: off, and names PUBLIC_MCP_BASE_URL", async () => {
84
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
85
+ process.env.MCP_BASE_URL = "http://api:3013";
86
+ process.env.AGENT_FS_PROXY = "true";
87
+ const result = await comb();
88
+ expect(result.enabled).toBe(false);
89
+ expect(result.reason).toContain("PUBLIC_MCP_BASE_URL");
90
+ });
91
+
92
+ test("unset and an IPv4-mapped private IPv6 AGENT_FS_API_URL: off", async () => {
93
+ process.env.AGENT_FS_API_URL = "http://[::ffff:10.0.0.5]:7433";
94
+ const result = await comb();
95
+ expect(result.enabled).toBe(false);
96
+ expect(result.reason).toContain("not reachable from a browser");
97
+ });
98
+
99
+ test("unset and AGENT_FS_PUBLIC_URL set: on", async () => {
100
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
101
+ process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
102
+ const result = await comb();
103
+ expect(result.enabled).toBe(true);
104
+ expect(result.api_url).toBe("https://fs.example.com");
105
+ });
106
+
107
+ test("unset and a browser-reachable AGENT_FS_API_URL: on", async () => {
108
+ process.env.AGENT_FS_API_URL = "https://fs.example.com";
109
+ expect((await comb()).enabled).toBe(true);
110
+ });
111
+
112
+ test("COMB_ENABLED=false wins over a reachable URL", async () => {
113
+ process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
114
+ process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
115
+ process.env.COMB_ENABLED = "false";
116
+ const result = await comb();
117
+ expect(result.enabled).toBe(false);
118
+ expect(result.reason).toBe("COMB_ENABLED is false.");
119
+ });
120
+
121
+ test("unset and no AGENT_FS_API_URL: off, and says so", async () => {
122
+ process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
123
+ const result = await comb();
124
+ expect(result.enabled).toBe(false);
125
+ expect(result.reason).toBe("AGENT_FS_API_URL is not set.");
70
126
  });
71
127
 
72
128
  test("stays off with the flag on but no AGENT_FS_API_URL", async () => {
@@ -81,7 +137,12 @@ describe("/status agent_fs.comb", () => {
81
137
  process.env.COMB_ENABLED = value;
82
138
  expect((await comb()).enabled).toBe(true);
83
139
  }
84
- for (const value of ["false", "0", "yes", ""]) {
140
+ for (const value of ["false", "0", " FALSE "]) {
141
+ process.env.COMB_ENABLED = value;
142
+ expect((await comb()).enabled).toBe(false);
143
+ }
144
+ // Unrecognized or blank means unset: the docker-internal URL keeps it off.
145
+ for (const value of ["yes", ""]) {
85
146
  process.env.COMB_ENABLED = value;
86
147
  expect((await comb()).enabled).toBe(false);
87
148
  }
@@ -150,6 +211,7 @@ describe("/status agent_fs.comb", () => {
150
211
  result = await comb();
151
212
  expect(result).toEqual({
152
213
  enabled: true,
214
+ reason: null,
153
215
  api_url: "http://agent-fs:7433",
154
216
  live_url: "https://live.example.com",
155
217
  org_id: "org-1",
@@ -177,6 +239,7 @@ describe("/status agent_fs.comb", () => {
177
239
  process.env.AGENT_FS_DEFAULT_DRIVE_ID = "drive-1";
178
240
 
179
241
  // Comb off: no lookup.
242
+ process.env.COMB_ENABLED = "false";
180
243
  expect((await comb()).service_user_id).toBeNull();
181
244
  expect(meCalls).toBe(0);
182
245
 
@@ -77,6 +77,7 @@ test("unmoved profile and idle-worker callers share agent mapping defaults", asy
77
77
  credStatus: null,
78
78
  harnessProvider: null,
79
79
  avatar: null,
80
+ slackIcon: null,
80
81
  });
81
82
  const updated = await db.updateAgentProfile(agent.id, {
82
83
  role: "coder",
@@ -89,3 +90,15 @@ test("unmoved profile and idle-worker callers share agent mapping defaults", asy
89
90
  .run("UPDATE agents SET avatar = ? WHERE id = ?", ["invalid json", agent.id]);
90
91
  expect((await db.getAgentById(agent.id))?.avatar).toBeNull();
91
92
  });
93
+
94
+ test("slackIcon is set, kept when omitted, reset by null, and parsed safely", async () => {
95
+ const agent = await db.createAgent(worker);
96
+ const owl = { type: "emoji" as const, emoji: ":owl:" };
97
+ expect((await db.updateAgentProfile(agent.id, { slackIcon: owl }))?.slackIcon).toEqual(owl);
98
+ expect((await db.updateAgentProfile(agent.id, { role: "coder" }))?.slackIcon).toEqual(owl);
99
+ expect((await db.updateAgentProfile(agent.id, { slackIcon: null }))?.slackIcon).toBeNull();
100
+ await db
101
+ .getDbClient()
102
+ .run("UPDATE agents SET slack_icon = ? WHERE id = ?", ["invalid json", agent.id]);
103
+ expect((await db.getAgentById(agent.id))?.slackIcon).toBeNull();
104
+ });