@desplega.ai/agent-swarm 1.123.0 → 1.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +2 -1
  2. package/dist/{actions-e4c9f0xy.js → actions-z3d0fk7z.js} +6 -6
  3. package/dist/{app-vmvmj2v3.js → app-q2rc9tdr.js} +4 -4
  4. package/dist/{assistant-yn7a792a.js → assistant-3stjq7fz.js} +9 -9
  5. package/dist/{boot-reembed-xg1r86h2.js → boot-reembed-5867whnb.js} +3 -3
  6. package/dist/{boot-reembed-chxty50a.js → boot-reembed-v6r1df32.js} +4 -4
  7. package/dist/{boot-scrub-logs-exwddvp5.js → boot-scrub-logs-8b8jegk3.js} +2 -2
  8. package/dist/{cli-1592nfd6.js → cli-0925phzv.js} +1 -1
  9. package/dist/{cli-hbbxmhec.js → cli-2yr6edr7.js} +4 -4
  10. package/dist/{cli-8cq4z4x7.js → cli-3z4q09tn.js} +182 -19
  11. package/dist/{cli-vz9drmce.js → cli-5jn6wnd9.js} +17 -8
  12. package/dist/{cli-5arfywtm.js → cli-5ncdb7ff.js} +19 -9
  13. package/dist/{cli-3tv9z4wx.js → cli-92bqgbgy.js} +1 -1
  14. package/dist/{cli-fjf04hpa.js → cli-dq2ssyf0.js} +48 -56
  15. package/dist/{cli-mngsj9fx.js → cli-gkvf8d83.js} +1 -1
  16. package/dist/{cli-mepk1j0t.js → cli-gzjsamwc.js} +2 -2
  17. package/dist/{cli-c9m59f83.js → cli-h89g0qrd.js} +1 -1
  18. package/dist/{cli-n8mf7x4b.js → cli-jp4mdj9d.js} +2 -2
  19. package/dist/{cli-822d4bjk.js → cli-kk58tg33.js} +7 -7
  20. package/dist/{cli-9hj1adgp.js → cli-mcez7z4p.js} +1 -1
  21. package/dist/{cli-byv3720g.js → cli-mmk5vagg.js} +2 -2
  22. package/dist/{cli-zdp11t8m.js → cli-p0ec4vv6.js} +1 -1
  23. package/dist/{cli-h2say6xk.js → cli-p0zgv8pj.js} +3 -1
  24. package/dist/{cli-8ef5ky75.js → cli-perb20ma.js} +1 -1
  25. package/dist/{cli-zkj6f2sk.js → cli-qf7acvyr.js} +90 -4
  26. package/dist/{cli-kmm1rwmp.js → cli-qwsagsmg.js} +1 -1
  27. package/dist/{cli-we91ywvy.js → cli-sqv6c9b8.js} +6 -6
  28. package/dist/{cli-np0yhmmk.js → cli-vm20sh8d.js} +3 -3
  29. package/dist/{cli-p11rf59d.js → cli-wn3m6q54.js} +3 -3
  30. package/dist/{cli-mkpf4ndf.js → cli-x268say8.js} +2976 -5621
  31. package/dist/{cli-ngbsk4yx.js → cli-y43n987v.js} +1 -1
  32. package/dist/{cli-4509mwm5.js → cli-ykthjw29.js} +8 -8
  33. package/dist/{cli-9eka6jwv.js → cli-zxb72ctk.js} +2 -2
  34. package/dist/cli.js +12 -10
  35. package/dist/{commands-sjk3zfyr.js → commands-sda76yn8.js} +2 -2
  36. package/dist/{db-2je5agjb.js → db-vn9e4rqj.js} +6 -2
  37. package/dist/{e2b-2s4psnbf.js → e2b-q345y8s4.js} +0 -4
  38. package/dist/{handlers-yzv369hf.js → handlers-xfja18ac.js} +9 -9
  39. package/dist/{hook-rfsj2r8x.js → hook-2medrp47.js} +1 -40
  40. package/dist/{http-v9rr144m.js → http-937pyjfc.js} +99 -44
  41. package/dist/{index-nr2199m6.js → index-2bqd2q9v.js} +11 -11
  42. package/dist/{index-3xtk777g.js → index-gw4en7jg.js} +9 -9
  43. package/dist/{index-c3tan44f.js → index-knqczmnm.js} +10 -10
  44. package/dist/{index-smjdc3h9.js → index-vah60vxp.js} +8 -8
  45. package/dist/{keepalive-5ds53bjd.js → keepalive-20rd6hxh.js} +5 -5
  46. package/dist/{lead-5xhez25b.js → lead-7w2k3a49.js} +21 -21
  47. package/dist/{maintenance-b43d24q6.js → maintenance-p3x918yw.js} +4 -4
  48. package/dist/{oauth-refresh-sweep-qe286vn2.js → oauth-refresh-sweep-shm0nzm4.js} +4 -4
  49. package/dist/{onboard-c4r3ds3z.js → onboard-py9dnf54.js} +6 -2
  50. package/dist/{otel-impl-nv3rjw61.js → otel-impl-7yjkfg68.js} +1 -1
  51. package/dist/{pi-mono-adapter-2syx1x4d.js → pi-mono-adapter-z6h98fva.js} +4 -38
  52. package/dist/{pricing-refresh-5ct2wp4k.js → pricing-refresh-3snb6370.js} +4 -4
  53. package/dist/{rbac-roles-m502w0qv.js → rbac-roles-5e72bbpk.js} +3 -3
  54. package/dist/{rbac-roles-b82cxpxn.js → rbac-roles-gqxdp8st.js} +4 -4
  55. package/dist/{seed-pricing-jhjphbf0.js → seed-pricing-bg42d7h2.js} +3 -3
  56. package/dist/{setup-zbaspydq.js → setup-nzk3kbbq.js} +2 -2
  57. package/dist/{worker-snvhpax5.js → worker-0p1jdzkm.js} +21 -21
  58. package/openapi.json +146 -1
  59. package/package.json +3 -1
  60. package/src/be/db.ts +81 -4
  61. package/src/be/models-catalog.ts +156 -0
  62. package/src/be/modelsdev-cache.ts +14 -1
  63. package/src/be/pricing-refresh.ts +2 -0
  64. package/src/be/scripts/typecheck.ts +17 -4
  65. package/src/be/seed-scripts/catalog/task-context-gathering.ts +1 -1
  66. package/src/commands/onboard/env-generator.ts +12 -0
  67. package/src/e2b/env.ts +0 -4
  68. package/src/hooks/hook.ts +0 -67
  69. package/src/http/all-routes.ts +1 -0
  70. package/src/http/index.ts +2 -0
  71. package/src/http/models-catalog.ts +65 -0
  72. package/src/http/workflows.ts +16 -4
  73. package/src/prompts/base-prompt.ts +8 -7
  74. package/src/prompts/session-templates.ts +106 -12
  75. package/src/providers/pi-mono-adapter.ts +12 -2
  76. package/src/providers/pi-mono-extension.ts +0 -64
  77. package/src/providers/pricing-sources.md +16 -1
  78. package/src/scripts-runtime/eval-harness.ts +18 -1
  79. package/src/scripts-runtime/swarm-sdk.ts +10 -0
  80. package/src/scripts-runtime/types/stdlib.d.ts +13 -0
  81. package/src/scripts-runtime/types/swarm-sdk.d.ts +13 -0
  82. package/src/server-user.ts +4 -7
  83. package/src/slack/blocks.ts +21 -8
  84. package/src/slack/responses.ts +27 -51
  85. package/src/slack/watcher.ts +22 -5
  86. package/src/telemetry.ts +169 -3
  87. package/src/tests/base-prompt.test.ts +79 -0
  88. package/src/tests/mcp-tools-user.test.ts +62 -16
  89. package/src/tests/mcp-tools.test.ts +6 -3
  90. package/src/tests/memory-edit.test.ts +21 -4
  91. package/src/tests/model-groups-live-catalog.test.ts +65 -0
  92. package/src/tests/models-catalog.test.ts +119 -0
  93. package/src/tests/oauth-access-token-tool.test.ts +71 -1
  94. package/src/tests/pricing-refresh.test.ts +25 -0
  95. package/src/tests/prompt-template-session.test.ts +142 -5
  96. package/src/tests/rbac-charact-skills.test.ts +81 -14
  97. package/src/tests/script-apis-mcp.test.ts +56 -8
  98. package/src/tests/scripts-mcp-e2e.test.ts +157 -8
  99. package/src/tests/scripts-typecheck.test.ts +17 -0
  100. package/src/tests/sdk-allowlist.test.ts +34 -1
  101. package/src/tests/seed-scripts.test.ts +43 -0
  102. package/src/tests/slack-blocks.test.ts +2 -1
  103. package/src/tests/slack-inline-output.test.ts +24 -30
  104. package/src/tests/slack-watcher.test.ts +42 -0
  105. package/src/tests/steer-task-tool.test.ts +37 -8
  106. package/src/tests/steering-transport.test.ts +18 -3
  107. package/src/tests/swarm-tool-result-gate.test.ts +399 -0
  108. package/src/tests/task-tools-ctx.test.ts +49 -17
  109. package/src/tests/task-tools-ownership.test.ts +41 -13
  110. package/src/tests/telemetry-init.test.ts +376 -2
  111. package/src/tests/tool-output-agent-id.test.ts +14 -2
  112. package/src/tests/workflow-http-v2.test.ts +116 -0
  113. package/src/tools/accept-steer.ts +45 -53
  114. package/src/tools/cancel-task.ts +31 -41
  115. package/src/tools/context-diff.ts +19 -63
  116. package/src/tools/context-history.ts +22 -51
  117. package/src/tools/create-channel.ts +24 -39
  118. package/src/tools/create-metric.ts +19 -82
  119. package/src/tools/create-page.ts +20 -81
  120. package/src/tools/credential-bindings/tool.ts +125 -246
  121. package/src/tools/db-query.ts +13 -24
  122. package/src/tools/delete-channel.ts +25 -71
  123. package/src/tools/delete-page.ts +27 -75
  124. package/src/tools/get-metrics.ts +29 -22
  125. package/src/tools/get-swarm.ts +44 -15
  126. package/src/tools/get-task-details.ts +150 -32
  127. package/src/tools/get-tasks.ts +63 -29
  128. package/src/tools/inject-learning.ts +8 -38
  129. package/src/tools/join-swarm.ts +45 -47
  130. package/src/tools/kv/kv-delete.ts +13 -37
  131. package/src/tools/kv/kv-get.ts +38 -29
  132. package/src/tools/kv/kv-incr.ts +28 -56
  133. package/src/tools/kv/kv-list.ts +40 -31
  134. package/src/tools/kv/kv-set.ts +34 -76
  135. package/src/tools/list-channels.ts +24 -17
  136. package/src/tools/list-services.ts +37 -45
  137. package/src/tools/manage-user.ts +53 -66
  138. package/src/tools/mcp-servers/mcp-server-create.ts +19 -55
  139. package/src/tools/mcp-servers/mcp-server-delete.ts +9 -39
  140. package/src/tools/mcp-servers/mcp-server-get.ts +9 -35
  141. package/src/tools/mcp-servers/mcp-server-install.ts +13 -60
  142. package/src/tools/mcp-servers/mcp-server-list.ts +28 -26
  143. package/src/tools/mcp-servers/mcp-server-uninstall.ts +10 -36
  144. package/src/tools/mcp-servers/mcp-server-update.ts +13 -57
  145. package/src/tools/memory-delete.ts +16 -46
  146. package/src/tools/memory-edit.ts +48 -49
  147. package/src/tools/memory-get.ts +40 -36
  148. package/src/tools/memory-rate.ts +14 -44
  149. package/src/tools/memory-search.ts +46 -52
  150. package/src/tools/my-agent-info.ts +39 -33
  151. package/src/tools/oauth-access-token.ts +9 -24
  152. package/src/tools/poll-task.ts +50 -77
  153. package/src/tools/post-message.ts +24 -38
  154. package/src/tools/prompt-templates/delete.ts +16 -48
  155. package/src/tools/prompt-templates/get.ts +45 -42
  156. package/src/tools/prompt-templates/list.ts +29 -44
  157. package/src/tools/prompt-templates/preview.ts +14 -40
  158. package/src/tools/prompt-templates/set.ts +30 -52
  159. package/src/tools/read-messages.ts +45 -54
  160. package/src/tools/register-agentmail-inbox.ts +28 -84
  161. package/src/tools/register-kapso-number.ts +20 -50
  162. package/src/tools/register-service.ts +36 -44
  163. package/src/tools/repos/get-repos.ts +29 -21
  164. package/src/tools/repos/update-repo.ts +30 -27
  165. package/src/tools/request-human-input.ts +10 -33
  166. package/src/tools/resolve-user.ts +59 -16
  167. package/src/tools/schedules/create-schedule.ts +58 -173
  168. package/src/tools/schedules/delete-schedule.ts +16 -56
  169. package/src/tools/schedules/list-schedules.ts +41 -67
  170. package/src/tools/schedules/patch-schedule.ts +57 -153
  171. package/src/tools/schedules/run-schedule-now.ts +18 -59
  172. package/src/tools/schedules/update-schedule.ts +57 -153
  173. package/src/tools/script-apis.ts +46 -28
  174. package/src/tools/script-common.ts +129 -34
  175. package/src/tools/script-connections/tool.ts +59 -154
  176. package/src/tools/script-query-types.ts +43 -3
  177. package/src/tools/script-run.ts +41 -2
  178. package/src/tools/script-runs.ts +69 -1
  179. package/src/tools/script-search.ts +24 -1
  180. package/src/tools/script-upsert.ts +18 -3
  181. package/src/tools/send-task.ts +32 -81
  182. package/src/tools/skills/skill-create.ts +14 -39
  183. package/src/tools/skills/skill-delete.ts +14 -42
  184. package/src/tools/skills/skill-get-file.ts +10 -43
  185. package/src/tools/skills/skill-get.ts +9 -32
  186. package/src/tools/skills/skill-install-remote.ts +16 -52
  187. package/src/tools/skills/skill-install.ts +14 -52
  188. package/src/tools/skills/skill-list.ts +24 -26
  189. package/src/tools/skills/skill-publish.ts +18 -54
  190. package/src/tools/skills/skill-search.ts +21 -18
  191. package/src/tools/skills/skill-sync-remote.ts +12 -24
  192. package/src/tools/skills/skill-uninstall.ts +10 -26
  193. package/src/tools/skills/skill-update.ts +24 -81
  194. package/src/tools/slack-delete.ts +8 -32
  195. package/src/tools/slack-download-file.ts +17 -57
  196. package/src/tools/slack-list-channels.ts +16 -43
  197. package/src/tools/slack-post.ts +11 -40
  198. package/src/tools/slack-read.ts +33 -103
  199. package/src/tools/slack-reply.ts +16 -53
  200. package/src/tools/slack-start-thread.ts +14 -55
  201. package/src/tools/slack-update.ts +8 -35
  202. package/src/tools/slack-upload-file.ts +22 -122
  203. package/src/tools/steer-task.ts +22 -35
  204. package/src/tools/store-progress.ts +11 -26
  205. package/src/tools/swarm-config/delete-config.ts +19 -57
  206. package/src/tools/swarm-config/get-config.ts +36 -44
  207. package/src/tools/swarm-config/list-config.ts +36 -44
  208. package/src/tools/swarm-config/set-config.ts +35 -73
  209. package/src/tools/swarm-x.ts +20 -26
  210. package/src/tools/task-action.ts +26 -46
  211. package/src/tools/task-tool-ctx.ts +5 -13
  212. package/src/tools/tracker/tracker-link-task.ts +8 -22
  213. package/src/tools/tracker/tracker-map-agent.ts +8 -22
  214. package/src/tools/tracker/tracker-status.ts +27 -19
  215. package/src/tools/tracker/tracker-sync-status.ts +8 -18
  216. package/src/tools/tracker/tracker-unlink.ts +4 -16
  217. package/src/tools/unregister-service.ts +19 -59
  218. package/src/tools/update-profile.ts +60 -100
  219. package/src/tools/update-service-status.ts +42 -67
  220. package/src/tools/utils.ts +189 -8
  221. package/src/tools/whatsapp-message.ts +19 -32
  222. package/src/tools/workflows/cancel-workflow-run.ts +4 -16
  223. package/src/tools/workflows/create-workflow.ts +9 -38
  224. package/src/tools/workflows/delete-workflow.ts +5 -17
  225. package/src/tools/workflows/get-workflow-run.ts +41 -31
  226. package/src/tools/workflows/get-workflow.ts +10 -21
  227. package/src/tools/workflows/list-workflow-runs.ts +107 -34
  228. package/src/tools/workflows/list-workflows.ts +5 -17
  229. package/src/tools/workflows/patch-workflow-node.ts +11 -39
  230. package/src/tools/workflows/patch-workflow.ts +11 -36
  231. package/src/tools/workflows/retry-workflow-run.ts +4 -16
  232. package/src/tools/workflows/trigger-workflow.ts +22 -58
  233. package/src/tools/workflows/update-workflow.ts +10 -42
  234. package/templates/skills/swarm-scripts/SKILL.md +1 -1
@@ -301,6 +301,24 @@ export async function processTreeMessages(): Promise<void> {
301
301
  // Check if tree is fully terminal — do one final render then clean up
302
302
  const fullyTerminal = isTreeFullyTerminal(nodes);
303
303
 
304
+ // Deliver any output that the compact tree would truncate before the tree
305
+ // is finalized and removed from tracking. Previously cleanup marked these
306
+ // tasks notified first, so the completion loop below never posted them.
307
+ let outputDeliverySucceeded = true;
308
+ for (const node of nodes.flatMap((root) => [root, ...root.children])) {
309
+ const task = getTaskById(node.taskId);
310
+ if (!task || !shouldPostInlineCompletionOutput(task)) continue;
311
+
312
+ const sent = await sendInlineTaskOutput(task);
313
+ if (sent) {
314
+ node.slackReplySent = true;
315
+ notifiedCompletions.set(task.id, now);
316
+ console.log(`[Slack] Sent full output for tree-tracked completion ${task.id.slice(0, 8)}`);
317
+ } else {
318
+ outputDeliverySucceeded = false;
319
+ }
320
+ }
321
+
304
322
  // Build blocks
305
323
  let blocks: unknown[];
306
324
  try {
@@ -315,7 +333,7 @@ export async function processTreeMessages(): Promise<void> {
315
333
  const lastSerialized = lastRenderedTree.get(messageTs);
316
334
  if (serialized === lastSerialized) {
317
335
  // No changes — skip update
318
- if (fullyTerminal) {
336
+ if (fullyTerminal && outputDeliverySucceeded) {
319
337
  // Even though nothing changed visually, clean up if terminal
320
338
  cleanupCompletedTree(messageTs, tree, nodes);
321
339
  }
@@ -380,7 +398,7 @@ export async function processTreeMessages(): Promise<void> {
380
398
  }
381
399
 
382
400
  // Clean up fully terminal trees after final render
383
- if (fullyTerminal && success) {
401
+ if (fullyTerminal && success && outputDeliverySucceeded) {
384
402
  cleanupCompletedTree(messageTs, tree, nodes);
385
403
  }
386
404
  }
@@ -711,11 +729,10 @@ export function startTaskWatcher(intervalMs = 3000): void {
711
729
  if (lastSent && now - lastSent < MIN_SEND_INTERVAL) continue;
712
730
 
713
731
  // Tasks tracked in a tree are still rendered by processTreeMessages().
714
- // For prose-only completions, add one inline output reply so the user
732
+ // For truncated completions, add one inline output reply so the user
715
733
  // sees the actual answer instead of only the compact tree preview.
716
734
  if (taskToTree.has(task.id)) {
717
- const attachments = task.status === "completed" ? getTaskAttachments(task.id) : [];
718
- if (!shouldPostInlineCompletionOutput(task, attachments)) {
735
+ if (!shouldPostInlineCompletionOutput(task)) {
719
736
  notifiedCompletions.set(task.id, now);
720
737
  continue;
721
738
  }
package/src/telemetry.ts CHANGED
@@ -15,9 +15,15 @@ const PRODUCT = "agent-swarm";
15
15
  const TIMEOUT_MS = 5_000;
16
16
 
17
17
  let installationId: string | null = null;
18
+ let installedAt: string | null = null;
18
19
  let source = "unknown";
19
20
  let cachedIsCloud = false;
20
21
  let cachedIsE2b = false;
22
+ let cachedHasEmbedding = false;
23
+ let cachedHasSlackChannel = false;
24
+ let cachedHasEmailChannel = false;
25
+ let cachedInstallMethod = "manual";
26
+ let cachedInstallPreset: string | undefined;
21
27
 
22
28
  function isEnabled(): boolean {
23
29
  return isEnvFlagEnabled("ANONYMIZED_TELEMETRY", true);
@@ -80,6 +86,92 @@ export function _resolveCloudMode(mcpBaseUrl: string | undefined | null): {
80
86
  return { isCloud: isCloudHostname(hostname) };
81
87
  }
82
88
 
89
+ const KNOWN_INSTALL_METHODS = new Set(["onboard_interactive", "onboard_noninteractive"]);
90
+
91
+ /**
92
+ * Resolve the "which entry point produced this install" cohort.
93
+ *
94
+ * `raw` comes from `INSTALL_METHOD`, written into `.env` by the onboard
95
+ * wizard's `generateEnv()` (`onboard_interactive` / `onboard_noninteractive`
96
+ * per `--yes`/`-y`). Installs that never ran the wizard — hand-written
97
+ * docker-compose, a bare `bun run start:http` clone — never set this var, so
98
+ * an unrecognized/missing value falls back to `"e2b"` (detected independently
99
+ * via `E2B_SANDBOX_ID`, since the `e2b start-stack` CLI path doesn't go
100
+ * through `generateEnv()`) or `"manual"`.
101
+ *
102
+ * Exported for tests; not part of the public API.
103
+ */
104
+ export function _resolveInstallMethod(raw: string | undefined | null, isE2b: boolean): string {
105
+ const normalized = raw?.trim();
106
+ if (normalized && KNOWN_INSTALL_METHODS.has(normalized)) return normalized;
107
+ if (isE2b) return "e2b";
108
+ return "manual";
109
+ }
110
+
111
+ /**
112
+ * Preset IDs defined in `src/commands/onboard/presets.ts` (`PRESETS`).
113
+ * Duplicated as a literal set here — rather than imported — to preserve this
114
+ * file's zero-dependency contract (see file header); bump this list in the
115
+ * same PR that adds/removes/renames a preset.
116
+ */
117
+ const KNOWN_INSTALL_PRESETS = new Set(["dev", "content", "research", "solo", "custom"]);
118
+
119
+ /**
120
+ * Validate `INSTALL_PRESET` against the wizard's known preset IDs.
121
+ *
122
+ * Unlike `_resolveInstallMethod`, an unrecognized value is omitted entirely
123
+ * rather than mapped to a fallback sentinel: `INSTALL_PRESET` is free-form
124
+ * operator-set environment text, so forwarding an unrecognized value (an
125
+ * email address, a customer name, anything placed in that env var by
126
+ * mistake) would violate telemetry's enum-only / no-PII contract. Returning
127
+ * `undefined` here means the `install_preset` metadata key is omitted from
128
+ * the event entirely (see the spread-guard in `track()`).
129
+ *
130
+ * Exported for tests; not part of the public API.
131
+ */
132
+ export function _resolveInstallPreset(raw: string | undefined | null): string | undefined {
133
+ const normalized = raw?.trim();
134
+ return normalized && KNOWN_INSTALL_PRESETS.has(normalized) ? normalized : undefined;
135
+ }
136
+
137
+ /**
138
+ * Whether semantic memory is likely enabled — i.e. an embedding-capable key
139
+ * is present. The onboard wizard's `generateEnv()` currently only writes
140
+ * Anthropic credentials (`src/commands/onboard/env-generator.ts`), so this
141
+ * lets us measure how many installs are silently running with memory search
142
+ * degraded/off. Boolean only — never the key value.
143
+ *
144
+ * Exported for tests; not part of the public API.
145
+ */
146
+ export function _hasEmbeddingKey(env: NodeJS.ProcessEnv = process.env): boolean {
147
+ return !!(env.EMBEDDING_API_KEY || env.OPENAI_API_KEY);
148
+ }
149
+
150
+ /**
151
+ * Mirrors `initSlackApp()`'s enablement check (`src/slack/app.ts`) without
152
+ * importing the Slack module — this file is intentionally dependency-free
153
+ * (see file header) so both the api-server and workers can import it cheaply.
154
+ * If Slack's disable/credential logic changes, update this in the same PR.
155
+ *
156
+ * Exported for tests; not part of the public API.
157
+ */
158
+ export function _hasSlackChannel(env: NodeJS.ProcessEnv = process.env): boolean {
159
+ return (
160
+ !!(env.SLACK_BOT_TOKEN && env.SLACK_APP_TOKEN) && !isEnvFlagEnabled("SLACK_DISABLE", false, env)
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Mirrors `isAgentMailEnabled()` (`src/agentmail/app.ts`) without importing
166
+ * the AgentMail module — see `_hasSlackChannel` for why. AgentMail is the
167
+ * swarm's only outbound email channel today.
168
+ *
169
+ * Exported for tests; not part of the public API.
170
+ */
171
+ export function _hasEmailChannel(env: NodeJS.ProcessEnv = process.env): boolean {
172
+ return !!env.AGENTMAIL_WEBHOOK_SECRET && !isEnvFlagEnabled("AGENTMAIL_DISABLE", false, env);
173
+ }
174
+
83
175
  interface InitTelemetryOptions {
84
176
  /**
85
177
  * Whether to mint and persist a new install ID when the config read returns
@@ -113,15 +205,38 @@ export async function initTelemetry(
113
205
  const resolved = _resolveCloudMode(process.env.MCP_BASE_URL);
114
206
  cachedIsCloud = resolved.isCloud;
115
207
  cachedIsE2b = _isE2bSandbox();
116
- console.log(`telemetry: cloud=${cachedIsCloud} e2b=${cachedIsE2b}`);
208
+ cachedHasEmbedding = _hasEmbeddingKey();
209
+ cachedHasSlackChannel = _hasSlackChannel();
210
+ cachedHasEmailChannel = _hasEmailChannel();
211
+ cachedInstallMethod = _resolveInstallMethod(process.env.INSTALL_METHOD, cachedIsE2b);
212
+ cachedInstallPreset = _resolveInstallPreset(process.env.INSTALL_PRESET);
213
+ console.log(
214
+ `telemetry: cloud=${cachedIsCloud} e2b=${cachedIsE2b} install_method=${cachedInstallMethod}`,
215
+ );
117
216
 
118
217
  try {
119
218
  const existing = await getConfig("telemetry_installation_id");
120
219
  if (existing) {
121
220
  installationId = existing;
221
+ // A pre-existing installation ID with no stored anchor means this
222
+ // install predates `telemetry_installed_at` tracking. Do NOT mint
223
+ // now() as a stand-in "install date" here — that back-fills a date
224
+ // that's wrong by however long the install has actually existed
225
+ // (seen: 110 days on our own production install after a routine
226
+ // upgrade). Leave installedAt null so the field is omitted from the
227
+ // payload: absence unambiguously means "pre-existing install, anchor
228
+ // unknown", and consumers can fall back to min(occurred_at) per
229
+ // installation_id in ClickHouse for the real anchor. Only a
230
+ // genuinely new installationId (the branch below) mints one.
231
+ const existingInstalledAt = await getConfig("telemetry_installed_at");
232
+ if (existingInstalledAt) {
233
+ installedAt = existingInstalledAt;
234
+ }
122
235
  } else if (generateIfMissing) {
123
- installationId = `install_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
124
- await setConfig("telemetry_installation_id", installationId);
236
+ const candidateId = `install_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
237
+ await setConfig("telemetry_installation_id", candidateId);
238
+ installationId = candidateId;
239
+ await tryPersistInstalledAt(setConfig);
125
240
  }
126
241
  // else: leave installationId = null; track() will no-op
127
242
  } catch {
@@ -129,11 +244,39 @@ export async function initTelemetry(
129
244
  if (generateIfMissing) {
130
245
  // Generate ephemeral ID so telemetry still works this session.
131
246
  installationId = `ephemeral_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
247
+ // Not persisted — config access is failing, so there's nowhere durable
248
+ // to write it. Leave installedAt null rather than a fake fresh mint.
132
249
  }
133
250
  // else: leave installationId = null; track() will no-op
134
251
  }
135
252
  }
136
253
 
254
+ /**
255
+ * Mint + persist the `telemetry_installed_at` anchor, but only commit it to
256
+ * module state (and therefore only emit it on this session's events) once
257
+ * the write actually lands.
258
+ *
259
+ * The anchor's entire value is being a stable, one-time identity for "when
260
+ * did this install first appear" — a failed write here must not fabricate
261
+ * that stability. If `setConfig` throws, this process emits no anchor at
262
+ * all (rather than an unpersisted one that the next boot won't see and will
263
+ * mint a *different* replacement for), and — critically — the failure is
264
+ * swallowed locally so it can't unwind into the caller's `catch`, which
265
+ * would otherwise discard an already-resolved `installationId` that has
266
+ * nothing to do with this write.
267
+ */
268
+ async function tryPersistInstalledAt(
269
+ setConfig: (key: string, value: string) => Promise<void> | void,
270
+ ): Promise<void> {
271
+ const candidate = new Date().toISOString();
272
+ try {
273
+ await setConfig("telemetry_installed_at", candidate);
274
+ installedAt = candidate;
275
+ } catch {
276
+ // Leave installedAt null for this session.
277
+ }
278
+ }
279
+
137
280
  interface TrackOptions {
138
281
  event: string;
139
282
  properties?: Record<string, unknown>;
@@ -208,6 +351,15 @@ export function track(options: TrackOptions): void {
208
351
  is_cloud: cachedIsCloud || isCloudDeployment(),
209
352
  is_e2b: cachedIsE2b,
210
353
  swarmVersion: pkg.version,
354
+ // Instrumentation-gap closure (2026-07-29): activation-funnel signals.
355
+ // All boolean/enum, resolved once at init from process.env — never a
356
+ // secret, URL, channel ID, address, or other identifier. Spread LAST
357
+ // (same rule as is_cloud/is_e2b above) so callers can't spoof them.
358
+ has_embedding_key: cachedHasEmbedding,
359
+ has_slack_channel: cachedHasSlackChannel,
360
+ has_email_channel: cachedHasEmailChannel,
361
+ has_notification_channel: cachedHasSlackChannel || cachedHasEmailChannel,
362
+ install_method: cachedInstallMethod,
211
363
  },
212
364
  metadata: {
213
365
  transport: "https",
@@ -215,6 +367,9 @@ export function track(options: TrackOptions): void {
215
367
  environment: getTelemetryEnvironment(),
216
368
  is_cloud: isCloudDeployment(),
217
369
  ...getOrgIdentity(),
370
+ // Optional — only present when known, same pattern as organization_*.
371
+ ...(cachedInstallPreset ? { install_preset: cachedInstallPreset } : {}),
372
+ ...(installedAt ? { install_created_at: installedAt } : {}),
218
373
  ...options.metadata,
219
374
  },
220
375
  };
@@ -235,9 +390,15 @@ export function track(options: TrackOptions): void {
235
390
  */
236
391
  export function _resetTelemetryStateForTests(): void {
237
392
  installationId = null;
393
+ installedAt = null;
238
394
  source = "unknown";
239
395
  cachedIsCloud = false;
240
396
  cachedIsE2b = false;
397
+ cachedHasEmbedding = false;
398
+ cachedHasSlackChannel = false;
399
+ cachedHasEmailChannel = false;
400
+ cachedInstallMethod = "manual";
401
+ cachedInstallPreset = undefined;
241
402
  }
242
403
 
243
404
  /** Test-only: read the resolved install ID. */
@@ -245,6 +406,11 @@ export function _getInstallationIdForTests(): string | null {
245
406
  return installationId;
246
407
  }
247
408
 
409
+ /** Test-only: read the resolved install-created-at anchor. */
410
+ export function _getInstalledAtForTests(): string | null {
411
+ return installedAt;
412
+ }
413
+
248
414
  export const telemetry = {
249
415
  taskEvent(
250
416
  event: string,
@@ -42,6 +42,51 @@ function disableSlackPromptTools() {
42
42
  delete process.env.SLACK_APP_TOKEN;
43
43
  }
44
44
 
45
+ /** Heading of `system.agent.script_authoring_contract` — used for dedupe checks */
46
+ const AUTHORING_CONTRACT_MARKER = "### Script Authoring Contract";
47
+
48
+ /** How many times the authoring contract appears in a rendered prompt */
49
+ function countAuthoringContract(text: string): number {
50
+ return text.split(AUTHORING_CONTRACT_MARKER).length - 1;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Script authoring contract — must reach every script-carrying composite, and
55
+ // must never be duplicated (scripts-only mode renders after a composite that
56
+ // already carries the rubric).
57
+ // ---------------------------------------------------------------------------
58
+ describe("getBasePrompt — script authoring contract", () => {
59
+ const combos: { role: string; provider?: "claude" | "pi"; scriptsOnly?: boolean }[] = [
60
+ { role: "worker" },
61
+ { role: "lead" },
62
+ { role: "worker", provider: "pi" },
63
+ { role: "lead", provider: "pi" },
64
+ { role: "worker", scriptsOnly: true },
65
+ { role: "lead", scriptsOnly: true },
66
+ { role: "worker", provider: "pi", scriptsOnly: true },
67
+ ];
68
+
69
+ for (const combo of combos) {
70
+ const label = `${combo.role}/${combo.provider ?? "default"}${
71
+ combo.scriptsOnly ? "/scripts-only" : ""
72
+ }`;
73
+ test(`renders exactly once for ${label}`, async () => {
74
+ const result = await getBasePrompt({ ...minimalArgs, ...combo });
75
+ expect(countAuthoringContract(result)).toBe(1);
76
+ expect(result).toContain("`args` FIRST, `ctx` SECOND");
77
+ expect(result).toContain("[REDACTED:<CONFIG_KEY>]");
78
+ });
79
+ }
80
+
81
+ test("remote provider (no MCP) gets no script authoring contract", async () => {
82
+ const result = await getBasePrompt({
83
+ ...minimalArgs,
84
+ traits: { hasMcp: false, hasLocalEnvironment: false },
85
+ });
86
+ expect(countAuthoringContract(result)).toBe(0);
87
+ });
88
+ });
89
+
45
90
  // ---------------------------------------------------------------------------
46
91
  // Basic fields
47
92
  // ---------------------------------------------------------------------------
@@ -663,6 +708,40 @@ describe("getBasePrompt — context-mode provider gating", () => {
663
708
  expect(result).not.toContain("batch_execute");
664
709
  expect(result).toContain("Agent Scripts");
665
710
  expect(result).toContain("Pre-built Seed Scripts");
711
+ // Scheduling rules live outside the ctx_* advertisement — pi keeps them.
712
+ expect(result).toContain("Scheduling — Pick the Right targetType");
713
+ });
714
+
715
+ test("pi LEAD also skips the context-mode block (lead.pi composite)", async () => {
716
+ const result = await getBasePrompt({
717
+ ...minimalArgs,
718
+ role: "lead",
719
+ traits: localTraits,
720
+ provider: "pi",
721
+ });
722
+ // Lead content is present
723
+ expect(result).toContain("CRITICAL: You are a coordinator");
724
+ // ...but no phantom ctx_* tooling
725
+ expect(result).not.toContain("Context Window Management");
726
+ expect(result).not.toContain("batch_execute");
727
+ expect(result).not.toContain("execute_file");
728
+ // Script guidance (incl. the authoring contract) survives
729
+ expect(result).toContain("Agent Scripts");
730
+ expect(result).toContain(AUTHORING_CONTRACT_MARKER);
731
+ expect(result).toContain("Pre-built Seed Scripts");
732
+ // Scheduling rules live outside the ctx_* advertisement — pi keeps them.
733
+ expect(result).toContain("Scheduling — Pick the Right targetType");
734
+ });
735
+
736
+ test("claude LEAD keeps the context-mode block", async () => {
737
+ const result = await getBasePrompt({
738
+ ...minimalArgs,
739
+ role: "lead",
740
+ traits: localTraits,
741
+ provider: "claude",
742
+ });
743
+ expect(result).toContain("Context Window Management");
744
+ expect(result).toContain("batch_execute");
666
745
  });
667
746
 
668
747
  for (const provider of ["claude", "codex", "opencode"] as const) {
@@ -45,6 +45,18 @@ function textOf(result: CallToolResult): string {
45
45
  return "";
46
46
  }
47
47
 
48
+ /**
49
+ * Structured payload the registrar composes: `{ ...data, success, message,
50
+ * details?, nudge? }` (see finalizeSwarmToolResult in src/tools/utils.ts).
51
+ * Tool-specific data keys (status/unknown/ambiguous/candidates/externalIds/id/...)
52
+ * are spread at the TOP LEVEL alongside the envelope keys — this is where the
53
+ * machine-readable payload now lives ("never prose" = never ONLY in the text
54
+ * channel; structuredContent is always populated).
55
+ */
56
+ function structuredOf(result: CallToolResult): Record<string, unknown> {
57
+ return (result.structuredContent ?? {}) as Record<string, unknown>;
58
+ }
59
+
48
60
  function eventsFor(userId: string): Array<{ eventType: string; afterJson: string | null }> {
49
61
  return getDb()
50
62
  .prepare<{ eventType: string; afterJson: string | null }, string>(
@@ -90,6 +102,12 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
90
102
  const text = textOf(result);
91
103
  expect(text).toContain(user.id);
92
104
  expect(text).toContain("Slack User One");
105
+ expect(result.isError).toBe(false);
106
+ expect(structuredOf(result)).toMatchObject({
107
+ success: true,
108
+ id: user.id,
109
+ name: "Slack User One",
110
+ });
93
111
  });
94
112
 
95
113
  test("matches by email → findUserByEmail hit (primary + alias)", async () => {
@@ -101,22 +119,34 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
101
119
 
102
120
  const byPrimary = await callTool(server, "resolve-user", { email: "primary@example.com" });
103
121
  expect(textOf(byPrimary)).toContain(user.id);
122
+ expect(byPrimary.isError).toBe(false);
123
+ expect(structuredOf(byPrimary)).toMatchObject({ success: true, id: user.id });
104
124
 
105
125
  const byAlias = await callTool(server, "resolve-user", { email: "alias@example.com" });
106
126
  expect(textOf(byAlias)).toContain(user.id);
127
+ expect(byAlias.isError).toBe(false);
128
+ expect(structuredOf(byAlias)).toMatchObject({ success: true, id: user.id });
107
129
  });
108
130
 
109
- test("returns a structured {status: 'unknown', ...} payload when nothing matches — never prose", async () => {
131
+ test("returns a structured {status: 'unknown', ...} payload when nothing matches — machine-readable via structuredContent, not prose-only", async () => {
110
132
  const result = await callTool(server, "resolve-user", {
111
133
  kind: "slack",
112
134
  externalId: "U_DOES_NOT_EXIST",
113
135
  });
114
- const parsed = JSON.parse(textOf(result));
115
- expect(parsed).toEqual({
136
+ // A miss is a successful, honest lookup outcome — not a tool failure.
137
+ expect(result.isError).toBe(false);
138
+ // NEW CONTRACT (SwarmToolResult): the {status, kind, externalId} payload is
139
+ // spread at the top level of structuredContent alongside the envelope
140
+ // (success/message), not embedded as a JSON blob inside content[0].text.
141
+ expect(structuredOf(result)).toMatchObject({
142
+ success: true,
116
143
  status: "unknown",
117
144
  kind: "slack",
118
145
  externalId: "U_DOES_NOT_EXIST",
119
146
  });
147
+ // Prose summary still shows up in the text channel for harnesses that
148
+ // only read content.text.
149
+ expect(textOf(result)).toContain('No user found for slack="U_DOES_NOT_EXIST"');
120
150
  });
121
151
 
122
152
  // Schema-level validation tests. The MCP SDK runs Zod at the transport
@@ -183,18 +213,21 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
183
213
  kind: "github",
184
214
  externalId: "extid-gh-handle",
185
215
  });
186
- const parsed = JSON.parse(textOf(result));
216
+ const parsed = structuredOf(result);
187
217
  expect(parsed.id).toBe(user.id);
188
218
  expect(Array.isArray(parsed.externalIds)).toBe(true);
189
- const kinds = parsed.externalIds.map((e: { kind: string }) => e.kind).sort();
219
+ const kinds = (parsed.externalIds as Array<{ kind: string }>).map((e) => e.kind).sort();
190
220
  expect(kinds).toEqual(["github", "slack"]);
221
+ // Both channels must be independently self-sufficient — the same payload
222
+ // is also present (via `details`) in the text channel.
223
+ expect(textOf(result)).toContain(user.id);
191
224
  });
192
225
 
193
226
  test("externalIds is empty array when user has no identities", async () => {
194
227
  const user = createUser({ name: "No Identities User", email: "noid@example.com" });
195
228
 
196
229
  const result = await callTool(server, "resolve-user", { email: "noid@example.com" });
197
- const parsed = JSON.parse(textOf(result));
230
+ const parsed = structuredOf(result);
198
231
  expect(parsed.id).toBe(user.id);
199
232
  expect(parsed.externalIds).toEqual([]);
200
233
  });
@@ -204,17 +237,21 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
204
237
  linkIdentity(user.id, "linear", "L_UIDLOOKUP", SYSTEM_ACTOR);
205
238
 
206
239
  const result = await callTool(server, "resolve-user", { userId: user.id });
207
- const parsed = JSON.parse(textOf(result));
240
+ const parsed = structuredOf(result);
208
241
  expect(parsed.id).toBe(user.id);
209
242
  expect(parsed.name).toBe("User ID Lookup");
210
243
  expect(parsed.externalIds).toHaveLength(1);
211
- expect(parsed.externalIds[0]).toMatchObject({ kind: "linear", externalId: "L_UIDLOOKUP" });
244
+ expect((parsed.externalIds as unknown[])[0]).toMatchObject({
245
+ kind: "linear",
246
+ externalId: "L_UIDLOOKUP",
247
+ });
212
248
  });
213
249
 
214
250
  test("userId lookup returns a structured {status: 'unknown', ...} payload for an unknown ID", async () => {
215
251
  const result = await callTool(server, "resolve-user", { userId: "nonexistent-user-id-xyz" });
216
- const parsed = JSON.parse(textOf(result));
217
- expect(parsed).toEqual({
252
+ expect(result.isError).toBe(false);
253
+ expect(structuredOf(result)).toMatchObject({
254
+ success: true,
218
255
  status: "unknown",
219
256
  kind: "userId",
220
257
  externalId: "nonexistent-user-id-xyz",
@@ -225,7 +262,7 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
225
262
  const user = createUser({ name: "Zbigniew Solo", email: "zbigniew@example.com" });
226
263
 
227
264
  const result = await callTool(server, "resolve-user", { name: "Zbigniew Solo" });
228
- const parsed = JSON.parse(textOf(result));
265
+ const parsed = structuredOf(result);
229
266
  expect(parsed.id).toBe(user.id);
230
267
  expect(parsed.name).toBe("Zbigniew Solo");
231
268
  });
@@ -234,7 +271,7 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
234
271
  const user = createUser({ name: "Priyanka Unique Prefix", email: "priyanka@example.com" });
235
272
 
236
273
  const result = await callTool(server, "resolve-user", { name: "Priyanka" });
237
- const parsed = JSON.parse(textOf(result));
274
+ const parsed = structuredOf(result);
238
275
  expect(parsed.id).toBe(user.id);
239
276
  });
240
277
 
@@ -243,17 +280,22 @@ describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
243
280
  const b = createUser({ name: "Alberto Dubois", email: "alberto.dubois@example.com" });
244
281
 
245
282
  const result = await callTool(server, "resolve-user", { name: "Alberto" });
246
- const parsed = JSON.parse(textOf(result));
283
+ const parsed = structuredOf(result);
247
284
  expect(parsed.status).toBe("ambiguous");
248
285
  expect(parsed.message).toMatch(/AMBIGUOUS/);
249
- const candidateIds = parsed.candidates.map((c: { userId: string }) => c.userId).sort();
286
+ const candidateIds = (parsed.candidates as Array<{ userId: string }>)
287
+ .map((c) => c.userId)
288
+ .sort();
250
289
  expect(candidateIds).toEqual([a.id, b.id].sort());
290
+ // The "never guess" message must also reach harnesses that only read text.
291
+ expect(textOf(result)).toMatch(/AMBIGUOUS/);
251
292
  });
252
293
 
253
294
  test("name lookup: zero matches return a structured {status: 'unknown', kind: 'name', ...} payload", async () => {
254
295
  const result = await callTool(server, "resolve-user", { name: "Nobody Registered Xyz" });
255
- const parsed = JSON.parse(textOf(result));
256
- expect(parsed).toEqual({
296
+ expect(result.isError).toBe(false);
297
+ expect(structuredOf(result)).toMatchObject({
298
+ success: true,
257
299
  status: "unknown",
258
300
  kind: "name",
259
301
  externalId: "Nobody Registered Xyz",
@@ -364,7 +406,11 @@ describe("manage-user MCP tool (identities array)", () => {
364
406
 
365
407
  test("non-lead caller is rejected", async () => {
366
408
  const result = await callTool(server, "manage-user", { action: "list" }, WORKER_ID);
409
+ // NEW CONTRACT: failures set isError=true (derived from ok=false) and
410
+ // structuredContent.success=false — not just prose in content.text.
411
+ expect(result.isError).toBe(true);
367
412
  expect(textOf(result)).toContain("Only the lead agent");
413
+ expect(structuredOf(result)).toMatchObject({ success: false });
368
414
  });
369
415
 
370
416
  test("create no longer accepts old top-level slackUserId / linearUserId / githubUsername / gitlabUsername", async () => {
@@ -48,13 +48,16 @@ describe("script MCP tools", () => {
48
48
  "script-search":
49
49
  "Semantic search over swarm-shared TypeScript scripts (catalog persisted in the agent-swarm DB; callable from agents and workflows). For ephemeral throwaway TS on your local machine, use code-mode instead.",
50
50
  "script-run":
51
- "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.",
51
+ "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Inline source executes without the `script-upsert` compile-time typecheck. Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.",
52
52
  "script-upsert":
53
- "Persist a TypeScript script to the swarm catalog under your agent scope (or global if you're a lead). Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.",
53
+ 'Typecheck and persist a TypeScript script to the swarm catalog under your agent scope (or global if you\'re a lead). Import `ScriptContext` from "swarm-sdk" for a real context type. Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.',
54
54
  "script-delete":
55
55
  "Remove a swarm-shared script from the catalog. Versions table preserves history.",
56
56
  "script-query-types":
57
- "Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
57
+ // Updated for the frozen SwarmToolResult contract refactor: `name` is
58
+ // now optional — omit it to get the swarm-wide type surface instead
59
+ // of a single script's signature.
60
+ "Fetch the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. Pass `name` to also get that script's signature; omit it for the swarm-wide type surface. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
58
61
  "launch-script-run":
59
62
  "Launch a durable one-off script workflow run. The run executes in the background and can be inspected with get-script-run for terminal status and journal entries.",
60
63
  "get-script-run":
@@ -254,13 +254,30 @@ describe("memory editing", () => {
254
254
  intent: "test stale-link pruning",
255
255
  },
256
256
  meta(),
257
- )) as { structuredContent: { success: boolean; message: string; changed?: boolean } };
257
+ )) as {
258
+ isError?: boolean;
259
+ content: Array<{ type: string; text: string }>;
260
+ structuredContent: {
261
+ success: boolean;
262
+ message: string;
263
+ changed?: boolean;
264
+ version?: number;
265
+ memory?: { id?: string };
266
+ };
267
+ };
258
268
 
259
- // Assert on the message first — on failure it carries the tool's error
260
- // (a bare success:false told us nothing when this flaked in CI).
261
- expect(result.structuredContent.message).toStartWith("Memory edited to version");
269
+ // New envelope contract (src/tools/utils.ts finalizeSwarmToolResult):
270
+ // isError = !ok, structuredContent = { ...data, success, message }, and
271
+ // content[0].text carries the same message. Assert on the message first
272
+ // — on failure it carries the tool's error (a bare success:false told us
273
+ // nothing when this flaked in CI).
274
+ expect(result.isError).toBe(false);
275
+ expect(result.structuredContent.message).toStartWith(`Memory "${a.id}" edited to version`);
276
+ expect(result.content[0]?.text).toStartWith(`Memory "${a.id}" edited to version`);
262
277
  expect(result.structuredContent.success).toBe(true);
263
278
  expect(result.structuredContent.changed).toBe(true);
279
+ expect(result.structuredContent.version).toBe(2);
280
+ expect(result.structuredContent.memory?.id).toBe(a.id);
264
281
 
265
282
  const rows = linkRowsFor(a.id);
266
283
  expect(rows).toHaveLength(2);