@desplega.ai/agent-swarm 2.4.4 → 2.4.5

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 (221) hide show
  1. package/dist/{acp-adapter-thq04adh.js → acp-adapter-2wdkktv6.js} +1 -1
  2. package/dist/{actions-j6ywh6x5.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-k8b5xmj8.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-rb7vc02s.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-ftta1zjf.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-srp6v6af.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-xns7h5h9.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-3qqrh2mz.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-9ks8hce7.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-k5ksyres.js → claude-adapter-m7q0a085.js} +2 -2
  11. package/dist/{claude-managed-adapter-h2y889ge.js → claude-managed-adapter-5ptb6tzs.js} +2 -2
  12. package/dist/{claude-managed-setup-n5a5eqks.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-2j3j4kwn.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-cynvc9fj.js → cli-018r4jq6.js} +5 -5
  15. package/dist/{cli-9fhkkpj5.js → cli-1f3qzw6y.js} +1 -1
  16. package/dist/{cli-dqb4n629.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-gqvh8d8a.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-qkhc5aqy.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-1d70crsy.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-t7cyef5g.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-hj3debgw.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-55qmbp6z.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-pccfmtb5.js → cli-94gwyg7m.js} +7 -7
  25. package/dist/{cli-51y09c6p.js → cli-9hstj09h.js} +5 -5
  26. package/dist/{cli-zrwpctp4.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-esxerzg7.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-b8k7epj1.js → cli-be2161h1.js} +7 -7
  29. package/dist/{cli-xc27fwzg.js → cli-bnes4z6b.js} +2 -2
  30. package/dist/{cli-zrk6jxv8.js → cli-cxxe61f6.js} +1 -1
  31. package/dist/{cli-amyhm3gj.js → cli-dk44dxec.js} +1 -1
  32. package/dist/{cli-nc35pxb7.js → cli-eww120g8.js} +1 -0
  33. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  34. package/dist/{cli-82d3nkc3.js → cli-f0g3pgvf.js} +1 -1
  35. package/dist/{cli-1ephgwnq.js → cli-ftc1g5tj.js} +2 -2
  36. package/dist/{cli-2v045xyf.js → cli-fv5hepyy.js} +3 -3
  37. package/dist/{cli-bnz0r3kc.js → cli-g35m2xwm.js} +4 -4
  38. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  39. package/dist/{cli-w6nadkh4.js → cli-j99azg3g.js} +3 -3
  40. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  41. package/dist/{cli-4dg55wsk.js → cli-kpw04va0.js} +1 -1
  42. package/dist/{cli-3cgngyd8.js → cli-ms85309d.js} +33 -15
  43. package/dist/{cli-v3yr2gbg.js → cli-n72rg53p.js} +1471 -635
  44. package/dist/{cli-37yb7307.js → cli-p14m2cnp.js} +1 -1
  45. package/dist/{cli-2zqpkvtt.js → cli-qfxc84gh.js} +1 -1
  46. package/dist/{cli-44159k28.js → cli-qwhy2qma.js} +1 -1
  47. package/dist/{cli-2y91wnbh.js → cli-st8aw1m6.js} +221 -9
  48. package/dist/{cli-vq97g1jq.js → cli-tw4p90jb.js} +92 -192
  49. package/dist/{cli-te6b5wvg.js → cli-v0yrfmdh.js} +1 -1
  50. package/dist/{cli-xj0re5tm.js → cli-v8fjakwf.js} +1 -1
  51. package/dist/{cli-p20r8w42.js → cli-wt0jf9hm.js} +215 -80
  52. package/dist/{cli-q6r7t299.js → cli-xpa64rek.js} +33 -11
  53. package/dist/{cli-vdm20wej.js → cli-z7d29rw9.js} +2 -2
  54. package/dist/cli.js +19 -19
  55. package/dist/{codex-adapter-968xwfy7.js → codex-adapter-tn6k5mkg.js} +1 -1
  56. package/dist/{codex-session-runner-ht4z99fa.js → codex-session-runner-7vjt7jbw.js} +1 -1
  57. package/dist/{commands-pcymaeg9.js → commands-t7rz9146.js} +2 -2
  58. package/dist/{controller-fpjyk46r.js → controller-m2jasma5.js} +2 -2
  59. package/dist/{controllers-connect-mwzx1fh6.js → controllers-connect-9ftpex99.js} +19 -19
  60. package/dist/{cursor-adapter-cp4s2e2f.js → cursor-adapter-mjv79e4z.js} +1 -1
  61. package/dist/{db-xsjg9jqq.js → db-6sfrn9g3.js} +1 -1
  62. package/dist/{db-sgd12bt9.js → db-rn673r06.js} +3 -3
  63. package/dist/{dispatcher-x0jfw2c9.js → dispatcher-c155xz0p.js} +17 -17
  64. package/dist/{dsh-adapter-jrq7ma7h.js → dsh-adapter-3mmszc1x.js} +1 -1
  65. package/dist/{external-channel-8njj4h3b.js → external-channel-35k6tkv8.js} +2 -2
  66. package/dist/{grok-adapter-kkr97v2f.js → grok-adapter-tfrq7tg1.js} +2 -2
  67. package/dist/handlers-7gw64dxw.js +39 -0
  68. package/dist/{heartbeat-wd03zhn0.js → heartbeat-dyg4npkp.js} +20 -20
  69. package/dist/{hook-f0vghj42.js → hook-drspbwpe.js} +1 -1
  70. package/dist/{hook-xzj2ts2p.js → hook-x26b31bg.js} +1 -1
  71. package/dist/{http-4hwxv62p.js → http-pk972xyw.js} +355 -216
  72. package/dist/{index-ww2vznkp.js → index-arytgj5q.js} +1 -1
  73. package/dist/{index-hg0x2cdv.js → index-bgdyvcnv.js} +17 -17
  74. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  75. package/dist/{index-gdh8jrdm.js → index-gabbcdn2.js} +17 -17
  76. package/dist/{index-97q4xn03.js → index-jwxkkex2.js} +129 -75
  77. package/dist/index-tfb26zhy.js +41 -0
  78. package/dist/{keepalive-hrh7xnr9.js → keepalive-7yrng7px.js} +3 -3
  79. package/dist/{lead-ee8vazz2.js → lead-7e02z839.js} +29 -29
  80. package/dist/{maintenance-e8wnwnqx.js → maintenance-e6cemdqt.js} +1 -1
  81. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  82. package/dist/{oauth-refresh-sweep-zm0yj4sj.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  83. package/dist/{onboard-dtwcq603.js → onboard-0a9y5yrc.js} +2 -2
  84. package/dist/{otel-impl-w3fbhpn0.js → otel-impl-trjpkfb7.js} +1 -1
  85. package/dist/{pricing-refresh-j38dehfs.js → pricing-refresh-d2b5my0y.js} +3 -3
  86. package/dist/{rbac-roles-ejxx9b35.js → rbac-roles-hey3yv9d.js} +2 -2
  87. package/dist/{rbac-roles-ae8yttq4.js → rbac-roles-nxh5ahwk.js} +1 -1
  88. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  89. package/dist/{render-v2-n16n0phe.js → render-v2-rr3p2atg.js} +3 -3
  90. package/dist/{resume-z0fgjc38.js → resume-2efkf1s1.js} +5 -5
  91. package/dist/{session-access-cv3mq6mc.js → session-access-s2mttxpw.js} +4 -4
  92. package/dist/{setup-mafhj3yt.js → setup-vypma5yt.js} +1 -1
  93. package/dist/{work-objects-8b86mgjp.js → work-objects-rvhckdkp.js} +20 -20
  94. package/dist/{worker-16s75hm5.js → worker-5snytpbd.js} +29 -29
  95. package/openapi.json +9 -2
  96. package/package.json +7 -7
  97. package/src/apps/catalog.generated.json +206 -4
  98. package/src/apps/page-validator.ts +13 -1
  99. package/src/be/chunking.ts +42 -9
  100. package/src/be/db/tasks/draft-text.ts +15 -0
  101. package/src/be/db/tasks/read.ts +14 -1
  102. package/src/be/db.ts +5 -1
  103. package/src/be/memory/index-content.ts +36 -17
  104. package/src/be/memory/providers/openai-embedding.ts +115 -61
  105. package/src/be/memory/providers/sqlite-store.ts +69 -23
  106. package/src/be/memory/reranker.ts +31 -1
  107. package/src/be/memory/search-candidates.ts +47 -0
  108. package/src/be/memory/types.ts +14 -0
  109. package/src/be/scripts/typecheck.ts +2 -0
  110. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +106 -54
  111. package/src/be/seed-scripts/index.ts +2 -2
  112. package/src/be/swarm-config-guard.ts +24 -1
  113. package/src/be/task-attachment-store.ts +30 -0
  114. package/src/extensions/catalog.generated.json +4 -4
  115. package/src/extensions/contract-types.generated.ts +1 -1
  116. package/src/extensions/contract.ts +7 -0
  117. package/src/fs/provider.ts +13 -1
  118. package/src/heartbeat/heartbeat.ts +31 -11
  119. package/src/http/fs.ts +54 -34
  120. package/src/http/mcp-body.ts +37 -0
  121. package/src/http/mcp-user.ts +3 -5
  122. package/src/http/mcp.ts +6 -7
  123. package/src/http/memory.ts +26 -15
  124. package/src/http/page-proxy.ts +61 -5
  125. package/src/http/pages-public.ts +27 -44
  126. package/src/http/pages.ts +4 -4
  127. package/src/http/script-runs.ts +2 -0
  128. package/src/http/users.ts +12 -1
  129. package/src/integrations/chat-sdk/adapters.ts +7 -3
  130. package/src/integrations/chat-sdk/attachments.ts +90 -12
  131. package/src/integrations/chat-sdk/facts.ts +120 -0
  132. package/src/integrations/chat-sdk/inbound.ts +156 -8
  133. package/src/integrations/chat-sdk/limits.ts +24 -0
  134. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  135. package/src/integrations/chat-sdk/outbound.ts +234 -4
  136. package/src/integrations/chat-sdk/relay.ts +4 -1
  137. package/src/integrations/chat-sdk/router.ts +28 -2
  138. package/src/integrations/chat-sdk/steering.ts +9 -0
  139. package/src/integrations/chat-sdk/templates.ts +14 -0
  140. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  141. package/src/mcp-limits.ts +20 -0
  142. package/src/pages/browser-sdk.ts +8 -3
  143. package/src/pages/feedback-overlay.ts +1 -1
  144. package/src/providers/amp-adapter.ts +1 -1
  145. package/src/providers/codex-adapter.ts +106 -119
  146. package/src/providers/codex-agents-md.ts +35 -229
  147. package/src/providers/cursor-adapter.ts +1 -1
  148. package/src/realtime/transport.ts +1 -2
  149. package/src/script-workflows/workflow-ctx.ts +1 -0
  150. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  151. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  152. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  153. package/src/server-user.ts +2 -0
  154. package/src/server.ts +2 -0
  155. package/src/slack/assistant.ts +2 -2
  156. package/src/slack/files.ts +2 -0
  157. package/src/slack/handlers.ts +4 -4
  158. package/src/slack/inbound-files.ts +39 -6
  159. package/src/slack/thread-buffer.ts +2 -1
  160. package/src/tasks/task-terminal-effects.ts +21 -17
  161. package/src/tests/apps-card-grid.test.ts +197 -0
  162. package/src/tests/apps-catalog-additions.test.ts +120 -0
  163. package/src/tests/apps-tree.test.ts +129 -0
  164. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  165. package/src/tests/chat-sdk-config.test.ts +11 -0
  166. package/src/tests/chat-sdk-facts.test.ts +23 -0
  167. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  168. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  169. package/src/tests/codex-adapter.test.ts +167 -242
  170. package/src/tests/config-api-only-keys.test.ts +9 -0
  171. package/src/tests/cors-allowlist.test.ts +1 -1
  172. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  173. package/src/tests/heartbeat-checklist.test.ts +33 -0
  174. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  175. package/src/tests/heartbeat-jev-gate.test.ts +242 -1
  176. package/src/tests/kv-page-proxy.test.ts +91 -5
  177. package/src/tests/launch-password-rejection.test.ts +2 -2
  178. package/src/tests/mcp-input-caps.test.ts +294 -0
  179. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  180. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  181. package/src/tests/memory.test.ts +14 -0
  182. package/src/tests/page-guest-session.test.ts +19 -8
  183. package/src/tests/page-proxy-authed.test.ts +1 -1
  184. package/src/tests/page-proxy.test.ts +17 -10
  185. package/src/tests/page-session.test.ts +102 -1
  186. package/src/tests/pages-authed-mode.test.ts +82 -12
  187. package/src/tests/pages-password-mode.test.ts +5 -5
  188. package/src/tests/realtime-transport.test.ts +13 -7
  189. package/src/tests/script-runs-http.test.ts +84 -0
  190. package/src/tests/scripts-http.test.ts +10 -1
  191. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  192. package/src/tests/task-audio-transcription.test.ts +299 -0
  193. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  194. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  195. package/src/tests/transcription.test.ts +299 -0
  196. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  197. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  198. package/src/tools/chat-access.ts +10 -0
  199. package/src/tools/chat-post.ts +31 -11
  200. package/src/tools/chat-reply.ts +32 -10
  201. package/src/tools/memory-search.ts +3 -1
  202. package/src/tools/system-one-decide.ts +17 -4
  203. package/src/types.ts +6 -0
  204. package/src/utils/config-env-export.ts +3 -1
  205. package/src/utils/page-session.ts +133 -20
  206. package/src/utils/transcription.ts +246 -0
  207. package/src/workflows/executors/system-one-decision.ts +28 -7
  208. package/src/workflows/executors/system-one-providers.ts +39 -4
  209. package/templates/extensions/slack-reply-gate/README.md +24 -22
  210. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  211. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  212. package/templates/skills/apps/SKILL.md +28 -6
  213. package/templates/skills/apps/content.md +28 -6
  214. package/templates/skills/chat-sdk/content.md +6 -2
  215. package/templates/skills/code-quality/content.md +2 -1
  216. package/templates/skills/heartbeat-runbook/content.md +5 -5
  217. package/templates/skills/pages/content.md +6 -4
  218. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  219. package/templates/skills/swarm-scripts/content.md +2 -2
  220. package/dist/handlers-0pvee9nw.js +0 -39
  221. package/dist/index-thm0y6a8.js +0 -41
@@ -0,0 +1,652 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtemp, rm, unlink } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import type { TelegramMockFilePart } from "../../scripts/dev/telegram-bot-api-mock";
16
+ import {
17
+ closeDb,
18
+ completeTask,
19
+ createAgent,
20
+ createTaskExtended,
21
+ initDb,
22
+ insertTaskAttachment,
23
+ upsertSwarmConfig,
24
+ } from "../be/db";
25
+ import { recordTaskAttachmentUpload } from "../be/task-attachment-store";
26
+ import { getFileStorageProvider, resetFileStorageProviderForTests } from "../fs/registry";
27
+ import { formatUnsentFilesNote, telegramAttachmentType } from "../integrations/chat-sdk/outbound";
28
+ import { processPendingChatRelays, stopChatRelayWatcher } from "../integrations/chat-sdk/relay";
29
+ import { chatSdkContextKey } from "../tasks/context-key";
30
+ import { registerChatPostTool } from "../tools/chat-post";
31
+ import { registerChatReplyTool } from "../tools/chat-reply";
32
+ import type { Agent, AgentTask, TaskAttachment } from "../types";
33
+ import { startTelegramHarness, type TelegramHarness } from "./chat-sdk-harness";
34
+
35
+ const TEST_DB_PATH = "./test-chat-sdk-outbound-files.sqlite";
36
+ const MB = 1024 * 1024;
37
+ const PDF_BYTES = new TextEncoder().encode("%PDF-1.4\n% test document\n%%EOF\n");
38
+ const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
39
+ const savedEnv = new Map(
40
+ [
41
+ "AGENT_FS_LOCAL_DIR",
42
+ "AGENT_FS_API_URL",
43
+ "AGENT_FS_DEFAULT_ORG_ID",
44
+ "AGENT_FS_DEFAULT_DRIVE_ID",
45
+ "API_AGENT_FS_API_KEY",
46
+ ].map((key) => [key, process.env[key]]),
47
+ );
48
+
49
+ let harness: TelegramHarness;
50
+ let lead: Agent;
51
+ let worker: Agent;
52
+ let leaky: Agent;
53
+ let server: McpServer;
54
+ let fsDir: string;
55
+ let nextChatId = 660_000;
56
+
57
+ // A stand-in agent-fs API. The API's own key reads every file; the worker's
58
+ // key reads only its own thoughts, as a per-agent agent-fs credential does.
59
+ const AGENT_FS_ORG = "shared-org";
60
+ const AGENT_FS_DRIVE = "shared-drive";
61
+ const API_FS_KEY = "api-owned-fs-key";
62
+ const WORKER_FS_KEY = "worker-fs-key";
63
+ const LEAD_FS_KEY = "lead-fs-key";
64
+ // Shorter than the global scrubber's floor, so only an exact-value scrub hides it.
65
+ const SHORT_FS_KEY = "local-pass";
66
+ const PRIVATE_BYTES = new TextEncoder().encode("private: not for the worker\n");
67
+ const agentFsFiles = new Map<string, { bytes: Uint8Array; type: string }>([
68
+ ["thoughts/worker/reports/q3.pdf", { bytes: PDF_BYTES, type: "application/pdf" }],
69
+ ["restricted/private.txt", { bytes: PRIVATE_BYTES, type: "text/plain" }],
70
+ ]);
71
+ /** Every request the stand-in agent-fs received: the raw path and the bearer. */
72
+ const agentFsRequests: Array<{ path: string; auth: string | null }> = [];
73
+ let agentFs: ReturnType<typeof Bun.serve>;
74
+
75
+ function startAgentFs(): ReturnType<typeof Bun.serve> {
76
+ return Bun.serve({
77
+ port: 0,
78
+ fetch(req) {
79
+ const path = new URL(req.url).pathname;
80
+ const auth = req.headers.get("authorization");
81
+ agentFsRequests.push({ path, auth });
82
+ const prefix = `/orgs/${AGENT_FS_ORG}/drives/${AGENT_FS_DRIVE}/files/`;
83
+ if (!path.startsWith(prefix) || !path.endsWith("/raw")) {
84
+ return new Response("not found", { status: 404 });
85
+ }
86
+ const key = decodeURIComponent(path.slice(prefix.length, -"/raw".length));
87
+ if (key === "thoughts/leaky/broken.pdf") {
88
+ return new Response(`storage failed using ${auth?.replace(/^Bearer /, "")}`, {
89
+ status: 500,
90
+ });
91
+ }
92
+ const file = agentFsFiles.get(key);
93
+ if (!file) return new Response("not found", { status: 404 });
94
+ const allowed =
95
+ auth === `Bearer ${API_FS_KEY}` ||
96
+ auth === `Bearer ${LEAD_FS_KEY}` ||
97
+ (auth === `Bearer ${WORKER_FS_KEY}` && key.startsWith("thoughts/worker/"));
98
+ if (!allowed) return new Response("forbidden", { status: 403 });
99
+ return new Response(file.bytes, {
100
+ headers: { "content-type": file.type, "content-length": String(file.bytes.byteLength) },
101
+ });
102
+ },
103
+ });
104
+ }
105
+
106
+ type ToolResult = {
107
+ structuredContent: { success: boolean; message: string; [key: string]: unknown };
108
+ };
109
+
110
+ async function callTool(
111
+ name: string,
112
+ headers: Record<string, string>,
113
+ args: Record<string, unknown>,
114
+ ): Promise<ToolResult> {
115
+ // biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
116
+ const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
117
+ ._registeredTools;
118
+ const handler = tools[name]?.handler;
119
+ if (!handler) throw new Error(`Tool not registered: ${name}`);
120
+ return (await handler(args, {
121
+ sessionId: "test-session",
122
+ requestInfo: { headers },
123
+ })) as ToolResult;
124
+ }
125
+
126
+ /** Every Bot API call addressed to `chatId`, in order. */
127
+ function callsTo(chatId: number): Array<{ method: string; body: Record<string, unknown> }> {
128
+ return harness.mock.calls
129
+ .map((call) => ({ method: call.method, body: call.body as Record<string, unknown> }))
130
+ .filter((call) => String(call.body.chat_id) === String(chatId));
131
+ }
132
+
133
+ function textOf(body: Record<string, unknown>): string {
134
+ const rich = body.rich_message as { markdown?: string } | undefined;
135
+ return String(rich?.markdown ?? body.text ?? "");
136
+ }
137
+
138
+ async function chatTask(chatId: number, agentId: string): Promise<AgentTask> {
139
+ return createTaskExtended(`chat task for ${chatId}`, {
140
+ agentId,
141
+ source: "chat",
142
+ contextKey: chatSdkContextKey(`telegram:${chatId}`),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Make the API's file provider an agent-fs provider on the stand-in, holding
148
+ * the key that reads every file, as a deployment with agent-fs has. Path
149
+ * attachments must still be read with the caller's key.
150
+ */
151
+ function useApiAgentFsProvider(): void {
152
+ process.env.AGENT_FS_API_URL = agentFs.url.origin;
153
+ process.env.AGENT_FS_DEFAULT_ORG_ID = AGENT_FS_ORG;
154
+ process.env.AGENT_FS_DEFAULT_DRIVE_ID = AGENT_FS_DRIVE;
155
+ resetFileStorageProviderForTests();
156
+ expect(getFileStorageProvider().id).toBe("agent-fs");
157
+ }
158
+
159
+ /** Back to the local provider the task attachment tests store their bytes in. */
160
+ function useLocalFileProvider(): void {
161
+ delete process.env.AGENT_FS_API_URL;
162
+ delete process.env.AGENT_FS_DEFAULT_ORG_ID;
163
+ delete process.env.AGENT_FS_DEFAULT_DRIVE_ID;
164
+ resetFileStorageProviderForTests();
165
+ }
166
+
167
+ /** Store bytes through the active provider and record them as an attachment of `taskId`. */
168
+ async function attach(
169
+ taskId: string,
170
+ name: string,
171
+ bytes: Uint8Array,
172
+ contentType: string,
173
+ options: { agentId?: string | null; intent?: string } = {},
174
+ ): Promise<TaskAttachment> {
175
+ const provider = getFileStorageProvider();
176
+ const scope = { taskId, name };
177
+ const uploaded = await provider.upload(scope, bytes, {
178
+ contentType,
179
+ sizeBytes: bytes.byteLength,
180
+ });
181
+ return recordTaskAttachmentUpload({
182
+ provider,
183
+ scope,
184
+ uploaded,
185
+ sizeBytes: bytes.byteLength,
186
+ sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),
187
+ contentType,
188
+ agentId: options.agentId === undefined ? worker.id : options.agentId,
189
+ intent: options.intent,
190
+ });
191
+ }
192
+
193
+ async function removeDbFiles(): Promise<void> {
194
+ for (const suffix of ["", "-wal", "-shm"]) {
195
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
196
+ }
197
+ }
198
+
199
+ beforeAll(async () => {
200
+ await removeDbFiles();
201
+ fsDir = await mkdtemp(join(tmpdir(), "chat-sdk-outbound-fs-"));
202
+ process.env.AGENT_FS_LOCAL_DIR = fsDir;
203
+ useLocalFileProvider();
204
+ initDb(TEST_DB_PATH);
205
+ lead = await createAgent({
206
+ id: crypto.randomUUID(),
207
+ name: "ChatFilesLead",
208
+ isLead: true,
209
+ status: "idle",
210
+ });
211
+ worker = await createAgent({
212
+ id: crypto.randomUUID(),
213
+ name: "ChatFilesWorker",
214
+ isLead: false,
215
+ status: "idle",
216
+ });
217
+ leaky = await createAgent({
218
+ id: crypto.randomUUID(),
219
+ name: "ChatFilesShortKey",
220
+ isLead: false,
221
+ status: "idle",
222
+ });
223
+ agentFs = startAgentFs();
224
+ // The API holds a key that reads everything; path attachments must not use it.
225
+ process.env.API_AGENT_FS_API_KEY = API_FS_KEY;
226
+ for (const [key, value] of [
227
+ ["AGENT_FS_API_URL", agentFs.url.origin],
228
+ ["AGENT_FS_DEFAULT_ORG_ID", AGENT_FS_ORG],
229
+ ["AGENT_FS_DEFAULT_DRIVE_ID", AGENT_FS_DRIVE],
230
+ ] as const) {
231
+ await upsertSwarmConfig({ scope: "global", key, value });
232
+ }
233
+ for (const [agent, value] of [
234
+ [worker, WORKER_FS_KEY],
235
+ [lead, LEAD_FS_KEY],
236
+ ] as const) {
237
+ await upsertSwarmConfig({ scope: "agent", scopeId: agent.id, key: "AGENT_FS_API_KEY", value });
238
+ }
239
+ await upsertSwarmConfig({
240
+ scope: "agent",
241
+ scopeId: leaky.id,
242
+ key: "AGENT_FS_API_KEY",
243
+ value: SHORT_FS_KEY,
244
+ isSecret: true,
245
+ });
246
+ server = new McpServer({ name: "chat-files-test", version: "1.0.0" });
247
+ registerChatReplyTool(server);
248
+ registerChatPostTool(server);
249
+ harness = await startTelegramHarness();
250
+ // Relay ticks are driven by the tests.
251
+ await stopChatRelayWatcher();
252
+ });
253
+
254
+ afterAll(async () => {
255
+ await harness.stop();
256
+ agentFs.stop(true);
257
+ closeDb();
258
+ await removeDbFiles();
259
+ await rm(fsDir, { recursive: true, force: true });
260
+ for (const [key, value] of savedEnv) {
261
+ if (value === undefined) delete process.env[key];
262
+ else process.env[key] = value;
263
+ }
264
+ resetFileStorageProviderForTests();
265
+ });
266
+
267
+ beforeEach(() => {
268
+ nextChatId += 1;
269
+ agentFsRequests.length = 0;
270
+ });
271
+
272
+ describe("telegramAttachmentType", () => {
273
+ test("only types Telegram's media methods take become photo, video or audio", () => {
274
+ expect(telegramAttachmentType("image/png", 1_000)).toBe("image");
275
+ expect(telegramAttachmentType("image/jpeg", 11 * MB)).toBe("file");
276
+ expect(telegramAttachmentType("image/svg+xml", 1_000)).toBe("file");
277
+ expect(telegramAttachmentType("video/mp4", 1_000)).toBe("video");
278
+ expect(telegramAttachmentType("audio/mpeg", 1_000)).toBe("audio");
279
+ expect(telegramAttachmentType("application/pdf", 1_000)).toBe("file");
280
+ });
281
+ });
282
+
283
+ describe("formatUnsentFilesNote", () => {
284
+ test("file names render as literal code spans, with no formatting or mentions", () => {
285
+ const note = formatUnsentFilesNote([
286
+ { name: "**[click](https://evil.example)** <!channel> @here.pdf", reason: "too big" },
287
+ { name: "a`b``c.txt", reason: "refused" },
288
+ { name: "line\n\n# Heading\n- item.md", reason: "bad @everyone\nreason" },
289
+ ]);
290
+ const lines = note.split("\n");
291
+ expect(lines).toHaveLength(4);
292
+ expect(lines[0]).toBe("Could not send 3 files:");
293
+ expect(lines[1]).toBe(
294
+ "- `**[click](https://evil.example)** <\u200b!channel> @\u200bhere.pdf`: too big",
295
+ );
296
+ expect(lines[2]).toBe("- ```a`b``c.txt```: refused");
297
+ expect(lines[3]).toBe("- `line # Heading - item.md`: bad @\u200beveryone reason");
298
+ });
299
+
300
+ test("an edge backtick is padded away from the fence", () => {
301
+ expect(formatUnsentFilesNote([{ name: "`x`", reason: "r" }])).toBe(
302
+ "Could not send 1 file:\n- `` `x` ``: r",
303
+ );
304
+ });
305
+ });
306
+
307
+ describe("chat-reply agent-fs paths", () => {
308
+ beforeEach(useApiAgentFsProvider);
309
+ afterEach(useLocalFileProvider);
310
+
311
+ test("an agent-fs PDF arrives as a document after the text", async () => {
312
+ const chatId = nextChatId;
313
+ const task = await chatTask(chatId, worker.id);
314
+
315
+ const result = await callTool(
316
+ "chat-reply",
317
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
318
+ { text: "Here is the report.", attachments: ["/thoughts/worker/reports/q3.pdf"] },
319
+ );
320
+
321
+ expect(result.structuredContent.success).toBe(true);
322
+ expect(result.structuredContent.unsentFiles).toEqual([]);
323
+ const calls = callsTo(chatId);
324
+ expect(calls.map((call) => call.method)).toEqual([
325
+ expect.stringMatching(/^send(Rich)?Message$/),
326
+ "sendDocument",
327
+ ]);
328
+ expect(textOf(calls[0]!.body)).toBe("Here is the report.");
329
+ expect(calls[1]!.body.document).toEqual({
330
+ filename: "q3.pdf",
331
+ type: "application/pdf",
332
+ size: PDF_BYTES.byteLength,
333
+ } satisfies TelegramMockFilePart);
334
+ // Read from the caller's drive with the caller's own key.
335
+ expect(agentFsRequests).toEqual([
336
+ {
337
+ path: `/orgs/${AGENT_FS_ORG}/drives/${AGENT_FS_DRIVE}/files/thoughts/worker/reports/q3.pdf/raw`,
338
+ auth: `Bearer ${WORKER_FS_KEY}`,
339
+ },
340
+ ]);
341
+ });
342
+
343
+ test("a path the caller's own agent-fs key cannot read is not sent, though the API's key can read it", async () => {
344
+ const chatId = nextChatId;
345
+ const task = await chatTask(chatId, worker.id);
346
+
347
+ const result = await callTool(
348
+ "chat-reply",
349
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
350
+ { text: "Here it is.", attachments: ["restricted/private.txt"] },
351
+ );
352
+
353
+ expect(result.structuredContent.success).toBe(true);
354
+ const unsent = result.structuredContent.unsentFiles as Array<{ name: string; reason: string }>;
355
+ expect(unsent).toEqual([
356
+ {
357
+ name: "private.txt",
358
+ reason: "could not be read (your agent-fs credential cannot read this path)",
359
+ },
360
+ ]);
361
+ const calls = callsTo(chatId);
362
+ expect(calls.map((call) => call.method)).toEqual([
363
+ expect.stringMatching(/^send(Rich)?Message$/),
364
+ expect.stringMatching(/^send(Rich)?Message$/),
365
+ ]);
366
+ expect(textOf(calls[1]!.body)).toContain("`private.txt`: could not be read");
367
+ expect(JSON.stringify(calls)).not.toContain("not for the worker");
368
+ expect(agentFsRequests.map((request) => request.auth)).toEqual([`Bearer ${WORKER_FS_KEY}`]);
369
+ });
370
+
371
+ test("an encoded traversal path is refused before any agent-fs request or send", async () => {
372
+ const chatId = nextChatId;
373
+ const task = await chatTask(chatId, worker.id);
374
+ const headers = { "x-agent-id": worker.id, "x-source-task-id": task.id };
375
+
376
+ for (const path of [
377
+ "%2e%2e/%2e%2e/private-drive/files/private.txt",
378
+ "thoughts/%2E%2E/%2E%2E/%2E%2E/private-drive/files/private.txt",
379
+ "..%2f..%2fprivate-drive/files/private.txt",
380
+ ]) {
381
+ const result = await callTool("chat-reply", headers, { text: "x", attachments: [path] });
382
+ expect(result.structuredContent.success).toBe(false);
383
+ expect(result.structuredContent.message).toContain(`Path ${path}:`);
384
+ }
385
+
386
+ expect(agentFsRequests).toEqual([]);
387
+ expect(callsTo(chatId)).toEqual([]);
388
+ });
389
+
390
+ test("a `?` or `#` in a path stays inside the file name", async () => {
391
+ const chatId = nextChatId;
392
+ const task = await chatTask(chatId, worker.id);
393
+
394
+ await callTool(
395
+ "chat-reply",
396
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
397
+ { text: "x", attachments: ["thoughts/worker/a?b#c.pdf"] },
398
+ );
399
+
400
+ expect(agentFsRequests.map((request) => request.path)).toEqual([
401
+ `/orgs/${AGENT_FS_ORG}/drives/${AGENT_FS_DRIVE}/files/thoughts/worker/a%3Fb%23c.pdf/raw`,
402
+ ]);
403
+ });
404
+
405
+ test("a caller without its own agent-fs key cannot send a path", async () => {
406
+ const chatId = nextChatId;
407
+ const keyless = await createAgent({
408
+ id: crypto.randomUUID(),
409
+ name: "ChatFilesKeyless",
410
+ isLead: false,
411
+ status: "idle",
412
+ });
413
+ const task = await chatTask(chatId, keyless.id);
414
+
415
+ const result = await callTool(
416
+ "chat-reply",
417
+ { "x-agent-id": keyless.id, "x-source-task-id": task.id },
418
+ { text: "x", attachments: ["restricted/private.txt"] },
419
+ );
420
+
421
+ expect(result.structuredContent.success).toBe(false);
422
+ expect(result.structuredContent.message).toContain("no agent-fs credential");
423
+ expect(agentFsRequests).toEqual([]);
424
+ expect(callsTo(chatId)).toEqual([]);
425
+ });
426
+
427
+ /** The tool result and the thread's failure note for one path the read of which fails. */
428
+ async function failedRead(path: string): Promise<{ result: string; note: string }> {
429
+ const chatId = nextChatId;
430
+ const task = await chatTask(chatId, leaky.id);
431
+ const result = await callTool(
432
+ "chat-reply",
433
+ { "x-agent-id": leaky.id, "x-source-task-id": task.id },
434
+ { text: "x", attachments: [path] },
435
+ );
436
+ expect(result.structuredContent.success).toBe(true);
437
+ expect((result.structuredContent.unsentFiles as unknown[]).length).toBe(1);
438
+ const calls = callsTo(chatId);
439
+ expect(calls.map((call) => call.method)).toEqual([
440
+ expect.stringMatching(/^send(Rich)?Message$/),
441
+ expect.stringMatching(/^send(Rich)?Message$/),
442
+ ]);
443
+ return { result: JSON.stringify(result), note: textOf(calls[1]!.body) };
444
+ }
445
+
446
+ test("a storage error body naming the caller's short key does not leak it", async () => {
447
+ const { result, note } = await failedRead("thoughts/leaky/broken.pdf");
448
+
449
+ // The stand-in put the key in its 500 body; the key reached it as the bearer.
450
+ expect(agentFsRequests.map((request) => request.auth)).toEqual([`Bearer ${SHORT_FS_KEY}`]);
451
+ expect(note).toContain("`broken.pdf`: could not be read (storage failed using [REDACTED:");
452
+ expect(result).not.toContain(SHORT_FS_KEY);
453
+ expect(note).not.toContain(SHORT_FS_KEY);
454
+ });
455
+
456
+ test("a transport error naming the caller's short key does not leak it", async () => {
457
+ const realFetch = globalThis.fetch;
458
+ const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (
459
+ input: Parameters<typeof fetch>[0],
460
+ init?: Parameters<typeof fetch>[1],
461
+ ) => {
462
+ const url = input instanceof Request ? input.url : String(input);
463
+ if (url.includes("/thoughts/leaky/offline.pdf/")) {
464
+ const auth = new Headers(init?.headers).get("authorization") ?? "";
465
+ throw new TypeError(`connect failed using ${auth.replace(/^Bearer /, "")}`);
466
+ }
467
+ return realFetch(input, init);
468
+ }) as typeof fetch);
469
+ try {
470
+ const { result, note } = await failedRead("thoughts/leaky/offline.pdf");
471
+
472
+ expect(note).toContain("`offline.pdf`: could not be read (connect failed using [REDACTED:");
473
+ expect(result).not.toContain(SHORT_FS_KEY);
474
+ expect(note).not.toContain(SHORT_FS_KEY);
475
+ } finally {
476
+ fetchSpy.mockRestore();
477
+ }
478
+ });
479
+ });
480
+
481
+ describe("chat-reply attachments", () => {
482
+ test("a task attachment id sends the file; an image goes as a photo", async () => {
483
+ const chatId = nextChatId;
484
+ const task = await chatTask(chatId, worker.id);
485
+ const png = await attach(task.id, "chart.png", PNG_BYTES, "image/png");
486
+
487
+ const result = await callTool(
488
+ "chat-reply",
489
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
490
+ { text: "The chart.", attachments: [png.id] },
491
+ );
492
+
493
+ expect(result.structuredContent.success).toBe(true);
494
+ const photo = callsTo(chatId).find((call) => call.method === "sendPhoto");
495
+ expect((photo?.body.photo as TelegramMockFilePart).filename).toBe("chart.png");
496
+ });
497
+
498
+ test("several documents go as one media group; a photo goes on its own", async () => {
499
+ const chatId = nextChatId;
500
+ const task = await chatTask(chatId, worker.id);
501
+ const a = await attach(task.id, "a.pdf", PDF_BYTES, "application/pdf");
502
+ // Different bytes: a task keeps one attachment row per sha256.
503
+ const b = await attach(
504
+ task.id,
505
+ "b.pdf",
506
+ new TextEncoder().encode("%PDF-1.4 b"),
507
+ "application/pdf",
508
+ );
509
+ const png = await attach(task.id, "c.png", PNG_BYTES, "image/png");
510
+
511
+ const result = await callTool(
512
+ "chat-reply",
513
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
514
+ { text: "Three files.", attachments: [a.id, png.id, b.id] },
515
+ );
516
+
517
+ expect(result.structuredContent.success).toBe(true);
518
+ const uploads = callsTo(chatId).filter((call) => !/^send(Rich)?Message$/.test(call.method));
519
+ expect(uploads.map((call) => call.method).sort()).toEqual(["sendMediaGroup", "sendPhoto"]);
520
+ const group = uploads.find((call) => call.method === "sendMediaGroup")!;
521
+ const media = JSON.parse(String(group.body.media)) as Array<{ type: string }>;
522
+ expect(media.map((item) => item.type)).toEqual(["document", "document"]);
523
+ expect((group.body.media0 as TelegramMockFilePart).filename).toBe("a.pdf");
524
+ expect((group.body.media1 as TelegramMockFilePart).filename).toBe("b.pdf");
525
+ });
526
+
527
+ test("a file over Telegram's 50 MB limit is not sent and the thread gets a note", async () => {
528
+ const chatId = nextChatId;
529
+ const task = await chatTask(chatId, worker.id);
530
+ const big = await insertTaskAttachment({
531
+ taskId: task.id,
532
+ agentId: worker.id,
533
+ name: "dump.zip",
534
+ kind: "shared-fs",
535
+ path: "never/read.zip",
536
+ providerId: "local-fs",
537
+ providerKey: "never/read.zip",
538
+ mimeType: "application/zip",
539
+ sizeBytes: 60 * MB,
540
+ });
541
+
542
+ const result = await callTool(
543
+ "chat-reply",
544
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
545
+ { text: "The dump.", attachments: [big.id] },
546
+ );
547
+
548
+ expect(result.structuredContent.success).toBe(true);
549
+ expect(result.structuredContent.unsentFiles).toEqual([
550
+ { name: "dump.zip", reason: "60.0 MB, over the 50.0 MB upload limit of Telegram" },
551
+ ]);
552
+ expect(result.structuredContent.message).toContain("Could not send 1 file");
553
+ const calls = callsTo(chatId);
554
+ expect(calls.some((call) => call.method === "sendDocument")).toBe(false);
555
+ expect(textOf(calls.at(-1)!.body)).toContain(
556
+ "`dump.zip`: 60.0 MB, over the 50.0 MB upload limit",
557
+ );
558
+ });
559
+
560
+ test("a platform refusal becomes a note, not a silent failure", async () => {
561
+ const chatId = nextChatId;
562
+ const task = await chatTask(chatId, worker.id);
563
+ const pdf = await attach(task.id, "refused.pdf", PDF_BYTES, "application/pdf");
564
+ harness.mock.failUploads(1);
565
+
566
+ const result = await callTool(
567
+ "chat-reply",
568
+ { "x-agent-id": worker.id, "x-source-task-id": task.id },
569
+ { text: "A file.", attachments: [pdf.id] },
570
+ );
571
+
572
+ expect(result.structuredContent.success).toBe(true);
573
+ const unsent = result.structuredContent.unsentFiles as Array<{ name: string; reason: string }>;
574
+ expect(unsent.map((file) => file.name)).toEqual(["refused.pdf"]);
575
+ expect(unsent[0]!.reason).toContain("refused by Telegram");
576
+ expect(textOf(callsTo(chatId).at(-1)!.body)).toContain("`refused.pdf`: refused by Telegram");
577
+ });
578
+
579
+ test("an unknown attachment id or a traversal path is refused before anything is sent", async () => {
580
+ const chatId = nextChatId;
581
+ const task = await chatTask(chatId, worker.id);
582
+ const headers = { "x-agent-id": worker.id, "x-source-task-id": task.id };
583
+
584
+ const unknown = await callTool("chat-reply", headers, {
585
+ text: "x",
586
+ attachments: [crypto.randomUUID()],
587
+ });
588
+ expect(unknown.structuredContent.success).toBe(false);
589
+ expect(unknown.structuredContent.message).toContain("is not an attachment of task");
590
+
591
+ const traversal = await callTool("chat-reply", headers, {
592
+ text: "x",
593
+ attachments: ["../etc/passwd"],
594
+ });
595
+ expect(traversal.structuredContent.success).toBe(false);
596
+ expect(traversal.structuredContent.message).toContain("parent path segments");
597
+
598
+ expect(callsTo(chatId)).toEqual([]);
599
+ });
600
+ });
601
+
602
+ describe("chat-post attachments", () => {
603
+ test("the lead posts a file to another thread", async () => {
604
+ const chatId = nextChatId;
605
+ const leadTask = await createTaskExtended("lead work", { agentId: lead.id, source: "mcp" });
606
+ const pdf = await attach(leadTask.id, "brief.pdf", PDF_BYTES, "application/pdf", {
607
+ agentId: lead.id,
608
+ });
609
+
610
+ const result = await callTool(
611
+ "chat-post",
612
+ { "x-agent-id": lead.id, "x-source-task-id": leadTask.id },
613
+ { adapter: "telegram", threadId: `telegram:${chatId}`, text: "FYI", attachments: [pdf.id] },
614
+ );
615
+
616
+ expect(result.structuredContent.success).toBe(true);
617
+ expect(callsTo(chatId).map((call) => call.method)).toContain("sendDocument");
618
+ });
619
+ });
620
+
621
+ describe("relay of output attachments", () => {
622
+ test("a completed task relays its output attachments after the answer, not its input files", async () => {
623
+ const chatId = nextChatId;
624
+ const task = await chatTask(chatId, lead.id);
625
+ await attach(task.id, "result.pdf", PDF_BYTES, "application/pdf", { agentId: lead.id });
626
+ await attach(task.id, "from-user.png", PNG_BYTES, "image/png", {
627
+ agentId: null,
628
+ intent: "user-upload",
629
+ });
630
+ await attach(
631
+ task.id,
632
+ "fetched-from-slack.png",
633
+ new Uint8Array([...PNG_BYTES, 5]),
634
+ "image/png",
635
+ {
636
+ agentId: lead.id,
637
+ intent: "slack-file",
638
+ },
639
+ );
640
+ await completeTask(task.id, "Done, report attached.");
641
+
642
+ await processPendingChatRelays();
643
+
644
+ const calls = callsTo(chatId);
645
+ expect(calls.map((call) => call.method)).toEqual([
646
+ expect.stringMatching(/^send(Rich)?Message$/),
647
+ "sendDocument",
648
+ ]);
649
+ expect(textOf(calls[0]!.body)).toBe("Done, report attached.");
650
+ expect((calls[1]!.body.document as TelegramMockFilePart).filename).toBe("result.pdf");
651
+ });
652
+ });