@desplega.ai/agent-swarm 2.4.3 → 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 (270) hide show
  1. package/dist/{acp-adapter-1885dpr7.js → acp-adapter-2wdkktv6.js} +1 -2
  2. package/dist/{actions-pn76n32q.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-af84rx1d.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-6sv8mhzm.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-sc2tf825.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-37sp626b.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-rp0jtnwr.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-rdt248k7.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-6eyncg0y.js → cli-018r4jq6.js} +34 -6
  15. package/dist/{cli-sds5zzs8.js → cli-1f3qzw6y.js} +78 -12
  16. package/dist/{cli-f0p0f6ja.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-dm8p2dpf.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-tkf4x6ha.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-v87dr6e3.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-1n9gdg56.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-p0qerd2r.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-aa3tb29r.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-1xsraagq.js → cli-94gwyg7m.js} +8 -8
  25. package/dist/{cli-0dxxwfkt.js → cli-9hstj09h.js} +17 -14
  26. package/dist/{cli-1012xb7j.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-pygdsgh8.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
  29. package/dist/{cli-bxrqvpg5.js → cli-be2161h1.js} +7 -7
  30. package/dist/{cli-npfe9t1s.js → cli-bnes4z6b.js} +2 -2
  31. package/dist/{cli-aypbg9r2.js → cli-cxxe61f6.js} +1 -1
  32. package/dist/{cli-vq9gqarq.js → cli-dk44dxec.js} +1 -1
  33. package/dist/{cli-836ehxmt.js → cli-eww120g8.js} +3 -2
  34. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  35. package/dist/{cli-mzkwkbdy.js → cli-f0g3pgvf.js} +1 -1
  36. package/dist/{cli-1ma335fz.js → cli-ftc1g5tj.js} +2 -2
  37. package/dist/{cli-4yef8ghv.js → cli-fv5hepyy.js} +3 -3
  38. package/dist/{cli-58vwn100.js → cli-g35m2xwm.js} +4 -4
  39. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  40. package/dist/{cli-gx0j2khs.js → cli-j99azg3g.js} +3 -3
  41. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  42. package/dist/{cli-qp2m4btv.js → cli-kpw04va0.js} +2 -2
  43. package/dist/{cli-8y99kfey.js → cli-ms85309d.js} +33 -15
  44. package/dist/{cli-kgnkyabk.js → cli-n72rg53p.js} +1555 -645
  45. package/dist/{cli-e4d78xsv.js → cli-p14m2cnp.js} +1 -1
  46. package/dist/{cli-1eszr343.js → cli-qfxc84gh.js} +1 -1
  47. package/dist/{cli-wk28hzqn.js → cli-qwhy2qma.js} +1 -1
  48. package/dist/{cli-73ezfhha.js → cli-st8aw1m6.js} +228 -11
  49. package/dist/{cli-zvbb4n0e.js → cli-tw4p90jb.js} +92 -192
  50. package/dist/{cli-038fga5k.js → cli-v0yrfmdh.js} +148 -183
  51. package/dist/{cli-6aen3ezb.js → cli-v8fjakwf.js} +1 -1
  52. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  53. package/dist/{cli-vcgscaan.js → cli-wt0jf9hm.js} +217 -80
  54. package/dist/{cli-s9rr4gr2.js → cli-xpa64rek.js} +1065 -305
  55. package/dist/{cli-h0mgxann.js → cli-z7d29rw9.js} +2 -2
  56. package/dist/cli.js +45 -20
  57. package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-tn6k5mkg.js} +1 -1
  58. package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-7vjt7jbw.js} +1 -1
  59. package/dist/{commands-hd574th6.js → commands-t7rz9146.js} +2 -2
  60. package/dist/{controller-pz5mcfg4.js → controller-m2jasma5.js} +2 -2
  61. package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-9ftpex99.js} +19 -19
  62. package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-mjv79e4z.js} +1 -1
  63. package/dist/{db-6nf1207q.js → db-6sfrn9g3.js} +1 -1
  64. package/dist/{db-h06dxp05.js → db-rn673r06.js} +3 -3
  65. package/dist/{dispatcher-zthpy4m1.js → dispatcher-c155xz0p.js} +17 -17
  66. package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-3mmszc1x.js} +1 -1
  67. package/dist/{external-channel-x01t7ke6.js → external-channel-35k6tkv8.js} +2 -2
  68. package/dist/{grok-adapter-h21jv594.js → grok-adapter-tfrq7tg1.js} +2 -3
  69. package/dist/handlers-7gw64dxw.js +39 -0
  70. package/dist/{heartbeat-zzfksckz.js → heartbeat-dyg4npkp.js} +20 -20
  71. package/dist/{hook-z4zhzj1f.js → hook-drspbwpe.js} +1 -1
  72. package/dist/{hook-3xfqhh2s.js → hook-x26b31bg.js} +1 -1
  73. package/dist/{http-q813tmya.js → http-pk972xyw.js} +571 -294
  74. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  75. package/dist/{index-x8xbyn1t.js → index-arytgj5q.js} +1 -1
  76. package/dist/{index-wbbs14c5.js → index-bgdyvcnv.js} +17 -17
  77. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  78. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  79. package/dist/{index-m0a4ydqj.js → index-gabbcdn2.js} +17 -17
  80. package/dist/{index-220tk9bw.js → index-jwxkkex2.js} +139 -76
  81. package/dist/index-tfb26zhy.js +41 -0
  82. package/dist/{keepalive-y5t1my7m.js → keepalive-7yrng7px.js} +3 -3
  83. package/dist/{lead-2xwtq66b.js → lead-7e02z839.js} +29 -30
  84. package/dist/{maintenance-ybfsaedv.js → maintenance-e6cemdqt.js} +1 -1
  85. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  86. package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  87. package/dist/{onboard-xn6fkkys.js → onboard-0a9y5yrc.js} +2 -2
  88. package/dist/{otel-impl-e6mn0z13.js → otel-impl-trjpkfb7.js} +1 -1
  89. package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-d2b5my0y.js} +3 -3
  90. package/dist/{rbac-roles-mb470p4e.js → rbac-roles-hey3yv9d.js} +2 -2
  91. package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-nxh5ahwk.js} +2 -2
  92. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  93. package/dist/{render-v2-g0306nh1.js → render-v2-rr3p2atg.js} +3 -3
  94. package/dist/{resume-v315hnq6.js → resume-2efkf1s1.js} +5 -5
  95. package/dist/{session-access-2zg285a1.js → session-access-s2mttxpw.js} +6 -4
  96. package/dist/{setup-a4mtf02k.js → setup-vypma5yt.js} +1 -1
  97. package/dist/{work-objects-9fr0fgtn.js → work-objects-rvhckdkp.js} +20 -20
  98. package/dist/{worker-qae22jeb.js → worker-5snytpbd.js} +29 -30
  99. package/dist/worker-token-xf11yyds.js +136 -0
  100. package/openapi.json +303 -13
  101. package/package.json +8 -8
  102. package/src/apps/action-runner.ts +4 -0
  103. package/src/apps/catalog.generated.json +209 -4
  104. package/src/apps/page-validator.ts +21 -3
  105. package/src/be/chunking.ts +42 -9
  106. package/src/be/db/tasks/draft-text.ts +15 -0
  107. package/src/be/db/tasks/read.ts +14 -1
  108. package/src/be/db.ts +5 -1
  109. package/src/be/memory/index-content.ts +36 -17
  110. package/src/be/memory/providers/openai-embedding.ts +115 -61
  111. package/src/be/memory/providers/sqlite-store.ts +69 -23
  112. package/src/be/memory/reranker.ts +31 -1
  113. package/src/be/memory/search-candidates.ts +47 -0
  114. package/src/be/memory/types.ts +14 -0
  115. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  116. package/src/be/scripts/typecheck.ts +2 -0
  117. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +116 -55
  118. package/src/be/seed-scripts/index.ts +2 -2
  119. package/src/be/swarm-config-guard.ts +40 -1
  120. package/src/be/task-attachment-store.ts +30 -0
  121. package/src/be/users.ts +169 -12
  122. package/src/cli.tsx +24 -0
  123. package/src/commands/worker-token.ts +164 -0
  124. package/src/extensions/catalog.generated.json +4 -4
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/extensions/contract.ts +7 -0
  127. package/src/fs/provider.ts +13 -1
  128. package/src/heartbeat/heartbeat.ts +31 -11
  129. package/src/http/apps.ts +43 -5
  130. package/src/http/codex-oauth-keep-warm.ts +27 -9
  131. package/src/http/config.ts +5 -3
  132. package/src/http/core.ts +2 -0
  133. package/src/http/fs.ts +54 -34
  134. package/src/http/mcp-body.ts +37 -0
  135. package/src/http/mcp-user.ts +3 -5
  136. package/src/http/mcp.ts +6 -7
  137. package/src/http/memory.ts +26 -15
  138. package/src/http/page-proxy.ts +61 -5
  139. package/src/http/pages-public.ts +27 -44
  140. package/src/http/pages.ts +4 -4
  141. package/src/http/script-runs.ts +2 -0
  142. package/src/http/session-access.ts +51 -2
  143. package/src/http/sessions.ts +249 -10
  144. package/src/http/tasks.ts +4 -32
  145. package/src/http/users.ts +12 -1
  146. package/src/integrations/chat-sdk/adapters.ts +7 -3
  147. package/src/integrations/chat-sdk/attachments.ts +90 -12
  148. package/src/integrations/chat-sdk/facts.ts +120 -0
  149. package/src/integrations/chat-sdk/inbound.ts +156 -8
  150. package/src/integrations/chat-sdk/limits.ts +24 -0
  151. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  152. package/src/integrations/chat-sdk/outbound.ts +234 -4
  153. package/src/integrations/chat-sdk/relay.ts +4 -1
  154. package/src/integrations/chat-sdk/router.ts +28 -2
  155. package/src/integrations/chat-sdk/steering.ts +9 -0
  156. package/src/integrations/chat-sdk/templates.ts +14 -0
  157. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  158. package/src/mcp-limits.ts +20 -0
  159. package/src/pages/browser-sdk.ts +8 -3
  160. package/src/pages/feedback-overlay.ts +1 -1
  161. package/src/providers/acp-adapter.ts +33 -13
  162. package/src/providers/acp-swarm-events.ts +1 -1
  163. package/src/providers/amp-adapter.ts +1 -1
  164. package/src/providers/codex-adapter.ts +106 -119
  165. package/src/providers/codex-agents-md.ts +35 -229
  166. package/src/providers/codex-oauth/storage.ts +1 -1
  167. package/src/providers/cursor-adapter.ts +1 -1
  168. package/src/rbac/legacy-policy.ts +3 -1
  169. package/src/rbac/permissions.ts +4 -0
  170. package/src/realtime/transport.ts +1 -2
  171. package/src/script-workflows/workflow-ctx.ts +1 -0
  172. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  173. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  174. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  175. package/src/server-user.ts +2 -0
  176. package/src/server.ts +2 -0
  177. package/src/slack/assistant.ts +2 -2
  178. package/src/slack/files.ts +2 -0
  179. package/src/slack/handlers.ts +4 -4
  180. package/src/slack/inbound-files.ts +39 -6
  181. package/src/slack/thread-buffer.ts +2 -1
  182. package/src/tasks/task-terminal-effects.ts +21 -17
  183. package/src/tests/acp-adapter.test.ts +10 -23
  184. package/src/tests/acp-fake-agent.ts +36 -0
  185. package/src/tests/acp-swarm-events.test.ts +1 -1
  186. package/src/tests/agent-session-tokens.test.ts +452 -0
  187. package/src/tests/apps-action-parent-task.test.ts +295 -0
  188. package/src/tests/apps-card-grid.test.ts +197 -0
  189. package/src/tests/apps-catalog-additions.test.ts +120 -0
  190. package/src/tests/apps-tree.test.ts +129 -0
  191. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  192. package/src/tests/chat-sdk-config.test.ts +11 -0
  193. package/src/tests/chat-sdk-facts.test.ts +23 -0
  194. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  195. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  196. package/src/tests/codex-adapter.test.ts +167 -242
  197. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  198. package/src/tests/config-api-only-keys.test.ts +9 -0
  199. package/src/tests/config-session-auth.test.ts +14 -0
  200. package/src/tests/cors-allowlist.test.ts +1 -1
  201. package/src/tests/docs-index-footprint.test.js +2 -2
  202. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  203. package/src/tests/grok-adapter.test.ts +3 -3
  204. package/src/tests/heartbeat-checklist.test.ts +33 -0
  205. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  206. package/src/tests/heartbeat-jev-gate.test.ts +265 -1
  207. package/src/tests/kv-page-proxy.test.ts +91 -5
  208. package/src/tests/launch-password-rejection.test.ts +2 -2
  209. package/src/tests/mcp-input-caps.test.ts +294 -0
  210. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  211. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  212. package/src/tests/memory.test.ts +14 -0
  213. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  214. package/src/tests/page-guest-session.test.ts +19 -8
  215. package/src/tests/page-proxy-authed.test.ts +1 -1
  216. package/src/tests/page-proxy.test.ts +17 -10
  217. package/src/tests/page-session.test.ts +102 -1
  218. package/src/tests/pages-authed-mode.test.ts +82 -12
  219. package/src/tests/pages-password-mode.test.ts +5 -5
  220. package/src/tests/rbac-engine.test.ts +1 -0
  221. package/src/tests/realtime-transport.test.ts +13 -7
  222. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  223. package/src/tests/script-connections.test.ts +2 -0
  224. package/src/tests/script-runs-http.test.ts +84 -0
  225. package/src/tests/scripts-http.test.ts +10 -1
  226. package/src/tests/session-route-posture.test.ts +2 -0
  227. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  228. package/src/tests/task-audio-transcription.test.ts +299 -0
  229. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  230. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  231. package/src/tests/transcription.test.ts +299 -0
  232. package/src/tests/worker-token-cli.test.ts +76 -0
  233. package/src/tests/workflow-executors.test.ts +4 -2
  234. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  235. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  236. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  237. package/src/tools/chat-access.ts +10 -0
  238. package/src/tools/chat-post.ts +31 -11
  239. package/src/tools/chat-reply.ts +32 -10
  240. package/src/tools/memory-search.ts +3 -1
  241. package/src/tools/system-one-decide.ts +17 -4
  242. package/src/types.ts +8 -1
  243. package/src/utils/config-env-export.ts +3 -1
  244. package/src/utils/page-session.ts +133 -20
  245. package/src/utils/request-auth-context.ts +2 -1
  246. package/src/utils/transcription.ts +246 -0
  247. package/src/workflows/definition.ts +7 -1
  248. package/src/workflows/engine.ts +18 -12
  249. package/src/workflows/executors/index.ts +1 -0
  250. package/src/workflows/executors/registry.ts +2 -0
  251. package/src/workflows/executors/system-one-decision.ts +84 -262
  252. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  253. package/src/workflows/executors/system-one-extraction.ts +830 -0
  254. package/src/workflows/executors/system-one-providers.ts +316 -114
  255. package/src/workflows/executors/system-one-transport.ts +264 -0
  256. package/templates/extensions/slack-reply-gate/README.md +24 -22
  257. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  258. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  259. package/templates/official/lead/HEARTBEAT.md +1 -1
  260. package/templates/skills/apps/SKILL.md +31 -7
  261. package/templates/skills/apps/content.md +31 -7
  262. package/templates/skills/chat-sdk/content.md +6 -2
  263. package/templates/skills/code-quality/content.md +2 -1
  264. package/templates/skills/heartbeat-runbook/content.md +8 -8
  265. package/templates/skills/pages/content.md +6 -4
  266. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  267. package/templates/skills/swarm-scripts/content.md +2 -2
  268. package/dist/cli-v1sjhn52.js +0 -7
  269. package/dist/handlers-2qjw434w.js +0 -39
  270. package/dist/index-d0g18bax.js +0 -41
@@ -1,23 +1,33 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { mkdtemp, rm, unlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
6
  import {
6
7
  closeDb,
8
+ completeTask,
7
9
  createAgent,
10
+ createTaskExtended,
11
+ createUser,
8
12
  getDbClient,
13
+ getKv,
9
14
  getTaskAttachments,
10
15
  getTaskById,
11
16
  initDb,
17
+ startTask,
12
18
  upsertKv,
13
19
  } from "../be/db";
20
+ import { linkIdentity } from "../be/users";
14
21
  import { resetFileStorageProviderForTests } from "../fs/registry";
15
22
  import { drainBackground } from "../integrations/chat-sdk/background";
23
+ import { formatChatRelayText } from "../integrations/chat-sdk/relay";
16
24
  import { CHAT_CHANNELS_NAMESPACE, routeChatMessage } from "../integrations/chat-sdk/router";
17
25
  import { getChatInstance } from "../integrations/chat-sdk/runtime";
18
26
  import { isChatThreadBuffered } from "../integrations/chat-sdk/thread-buffer";
19
27
  import { chatSdkContextKey } from "../tasks/context-key";
28
+ import { registerDeferTaskTool } from "../tools/defer-task";
20
29
  import type { Agent } from "../types";
30
+ import { NOT_TRANSCRIBED_NOTE, TRANSCRIPTION_ENV_KEYS } from "../utils/transcription";
21
31
  import {
22
32
  flushChatBuffers,
23
33
  postChatWebhook,
@@ -30,7 +40,14 @@ import {
30
40
  } from "./chat-sdk-harness";
31
41
 
32
42
  const TEST_DB_PATH = "./test-chat-sdk-inbound.sqlite";
33
- const ENV_KEYS = ["AGENT_FS_LOCAL_DIR", "AGENT_FS_API_URL", "STEERING_ENABLED"];
43
+ const ENV_KEYS = [
44
+ "AGENT_FS_LOCAL_DIR",
45
+ "AGENT_FS_API_URL",
46
+ "STEERING_ENABLED",
47
+ "TELEGRAM_MENTION_ON_REPLY",
48
+ "CHAT_SDK_OWNER_WINDOW_MIN",
49
+ ...TRANSCRIPTION_ENV_KEYS,
50
+ ];
34
51
  const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]));
35
52
 
36
53
  let harness: TelegramHarness;
@@ -68,6 +85,11 @@ beforeAll(async () => {
68
85
  process.env.AGENT_FS_LOCAL_DIR = fsDir;
69
86
  delete process.env.AGENT_FS_API_URL;
70
87
  process.env.STEERING_ENABLED = "true";
88
+ // The adapter must treat a reply to the bot as a mention without the env fallback.
89
+ delete process.env.TELEGRAM_MENTION_ON_REPLY;
90
+ delete process.env.CHAT_SDK_OWNER_WINDOW_MIN;
91
+ // A provider key in the shell (OPENAI_API_KEY, ...) would switch transcription on.
92
+ for (const key of TRANSCRIPTION_ENV_KEYS) delete process.env[key];
71
93
  resetFileStorageProviderForTests();
72
94
  initDb(TEST_DB_PATH);
73
95
  lead = await createAgent({
@@ -109,6 +131,16 @@ beforeEach(() => {
109
131
  process.env.CHAT_SDK_BUFFER_MS = "60000";
110
132
  });
111
133
 
134
+ type DeferTaskTool = { handler: (args: unknown, extra: unknown) => Promise<unknown> };
135
+
136
+ function buildDeferTaskTool(): DeferTaskTool {
137
+ const server = new McpServer({ name: "chat-sdk-inbound-test", version: "1.0.0" });
138
+ registerDeferTaskTool(server);
139
+ const tools = (server as unknown as { _registeredTools: Record<string, DeferTaskTool> })
140
+ ._registeredTools;
141
+ return tools["defer-task"]!;
142
+ }
143
+
112
144
  const dm = (id: number): TelegramChat => ({ id, type: "private" });
113
145
  const group = (id: number): TelegramChat => ({ id, type: "group" });
114
146
 
@@ -134,7 +166,7 @@ describe("Chat SDK inbound pipeline", () => {
134
166
  await postTelegramUpdate(telegramUpdate(dm(2002), "!now ship the hotfix"));
135
167
  const tasks = await chatTasks(2002);
136
168
  expect(tasks).toHaveLength(1);
137
- expect(tasks[0]!.task).toBe("ship the hotfix");
169
+ expect(tasks[0]!.task).toEndWith("\n\nship the hotfix");
138
170
  });
139
171
 
140
172
  test("swarm#<uuid> routes to that agent", async () => {
@@ -143,7 +175,7 @@ describe("Chat SDK inbound pipeline", () => {
143
175
  const tasks = await chatTasks(2003);
144
176
  expect(tasks).toHaveLength(1);
145
177
  expect(tasks[0]!.agentId).toBe(worker.id);
146
- expect(tasks[0]!.task).toBe("build the parser");
178
+ expect(tasks[0]!.task).toEndWith("\n\nbuild the parser");
147
179
  });
148
180
 
149
181
  test("a KV channel mapping routes to the pinned agent", async () => {
@@ -361,3 +393,525 @@ describe("Chat SDK inbound pipeline", () => {
361
393
  expect(await chatTasks(-2009)).toHaveLength(0);
362
394
  });
363
395
  });
396
+
397
+ /**
398
+ * Who asked and where. The harness posts every update straight to the
399
+ * webhook, which is what a bot with privacy mode off (or a group admin bot)
400
+ * receives. With privacy mode on Telegram delivers only a subset (commands,
401
+ * mentions, replies to the bot), so these filters hold there too.
402
+ */
403
+ describe("Chat SDK requester and group addressing", () => {
404
+ const ACTOR = { kind: "system", id: "chat-sdk-inbound-test" } as const;
405
+ const supergroup = (id: number, title: string, extra: Record<string, unknown> = {}) => ({
406
+ chat: { id, type: "supergroup", title, ...extra },
407
+ });
408
+ const person = (id: number, first: string, username: string) => ({
409
+ from: { id, is_bot: false, first_name: first, username },
410
+ });
411
+ const ALICE = person(777_201, "Alice", "alice");
412
+ const BOB = person(777_202, "Bob", "bob");
413
+ const CAROL = person(777_203, "Carol", "carol");
414
+
415
+ async function steeringBodies(taskId: string): Promise<string[]> {
416
+ const rows = await getDbClient().query<{ body: string }>(
417
+ "SELECT body FROM task_steering_messages WHERE task_id = ? ORDER BY rowid",
418
+ [taskId],
419
+ );
420
+ return rows.map((row) => row.body);
421
+ }
422
+
423
+ function groupUpdate(chatId: number, text: string, sender: Record<string, unknown>) {
424
+ return telegramUpdate(group(chatId), text, { ...supergroup(chatId, "Life"), ...sender });
425
+ }
426
+
427
+ test("a registered user's chat task has requestedByUserId, and defer-task and the relay print their timezone", async () => {
428
+ const user = await createUser({ name: "Taras", timezone: "Europe/Madrid" });
429
+ await linkIdentity(user.id, "telegram", "777101", ACTOR);
430
+ await postTelegramUpdate(
431
+ telegramUpdate(dm(3001), "check the deploy", person(777_101, "Taras", "tarasyarema")),
432
+ );
433
+ await flushChatBuffers();
434
+ const [row] = await chatTasks(3001);
435
+ const task = await getTaskById(row!.id);
436
+ expect(task?.requestedByUserId).toBe(user.id);
437
+
438
+ await startTask(task!.id);
439
+ const tool = buildDeferTaskTool();
440
+ const result = (await tool.handler(
441
+ { taskId: task!.id, delayMs: 60_000, summary: "deploy is rolling", note: "wait" },
442
+ { sessionId: crypto.randomUUID(), requestInfo: { headers: { "x-agent-id": lead.id } } },
443
+ )) as { structuredContent: { success: boolean; nextRunAt?: string } };
444
+ expect(result.structuredContent.success).toBe(true);
445
+ const madrid = new Intl.DateTimeFormat("en-GB", {
446
+ timeZone: "Europe/Madrid",
447
+ hour: "2-digit",
448
+ minute: "2-digit",
449
+ hourCycle: "h23",
450
+ }).format(new Date(result.structuredContent.nextRunAt!));
451
+ const deferred = await getTaskById(task!.id);
452
+ expect(deferred?.output).toMatch(new RegExp(`at ${madrid}$`));
453
+ expect(deferred?.output).not.toContain("UTC");
454
+ expect(formatChatRelayText(deferred!)).toBe(deferred!.output!);
455
+ });
456
+
457
+ test("an unknown sender gets no requester and lands in the Unmapped tracker", async () => {
458
+ await postTelegramUpdate(
459
+ telegramUpdate(dm(3002), "who am I", person(777_102, "Stranger", "stranger")),
460
+ );
461
+ await flushChatBuffers();
462
+ const [row] = await chatTasks(3002);
463
+ expect((await getTaskById(row!.id))?.requestedByUserId).toBeUndefined();
464
+ const meta = await getKv("integration:unmapped:telegram", "777102:meta");
465
+ expect(meta?.value).toMatchObject({ sampleEventType: "chat.message" });
466
+ });
467
+
468
+ test("the task description starts with the chat header", async () => {
469
+ await postTelegramUpdate(
470
+ telegramUpdate(group(-3003), "@swarm_test_bot plan the trip", {
471
+ ...supergroup(-3003, "Life"),
472
+ ...person(777_104, "Taras", "tarasyarema"),
473
+ }),
474
+ );
475
+ await flushChatBuffers();
476
+ const [plain] = await chatTasks(-3003);
477
+ expect(plain!.task).toStartWith(
478
+ '[chat] telegram · supergroup "Life" · from: Taras (@tarasyarema) · addressed: mention\n\n',
479
+ );
480
+
481
+ // A forum topic: the topic root is the bot's own message, which is the
482
+ // topic and not a reply to the bot.
483
+ await postTelegramUpdate(
484
+ telegramUpdate(group(-3004), "@swarm_test_bot book the flights", {
485
+ ...supergroup(-3004, "Life", { is_forum: true }),
486
+ ...person(777_104, "Taras", "tarasyarema"),
487
+ is_topic_message: true,
488
+ message_thread_id: 77,
489
+ reply_to_message: {
490
+ message_id: 77,
491
+ date: Math.floor(Date.now() / 1000),
492
+ chat: { id: -3004, type: "supergroup", title: "Life", is_forum: true },
493
+ from: { id: 4242, is_bot: true, first_name: "Swarm", username: "swarm_test_bot" },
494
+ forum_topic_created: { name: "Travel", icon_color: 0 },
495
+ },
496
+ }),
497
+ );
498
+ await flushChatBuffers();
499
+ const topicTasks = await getDbClient().query<{ task: string }>(
500
+ "SELECT task FROM agent_tasks WHERE source = 'chat' AND contextKey LIKE ?",
501
+ [`${chatSdkContextKey("telegram:-3004")}%`],
502
+ );
503
+ expect(topicTasks).toHaveLength(1);
504
+ expect(topicTasks[0]!.task).toStartWith(
505
+ '[chat] telegram · supergroup "Life" · forum topic "Travel" · from: Taras (@tarasyarema) · addressed: mention\n\n',
506
+ );
507
+ });
508
+
509
+ test("side chatter between two other people neither steers the running task nor creates one; the requester and a mention do", async () => {
510
+ const chatId = -3005;
511
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot draft the budget", ALICE));
512
+ await flushChatBuffers();
513
+ const [first] = await chatTasks(chatId);
514
+ await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
515
+ first!.id,
516
+ ]);
517
+
518
+ // Bob and Carol talk to each other.
519
+ await postTelegramUpdate(groupUpdate(chatId, "lunch at 2?", BOB));
520
+ await postTelegramUpdate(groupUpdate(chatId, "sure, the usual place", CAROL));
521
+ await flushChatBuffers();
522
+ expect(await chatTasks(chatId)).toHaveLength(1);
523
+ expect(await steeringBodies(first!.id)).toEqual([]);
524
+
525
+ // The requester's plain message, then Bob with a mention.
526
+ await postTelegramUpdate(groupUpdate(chatId, "include the rent", ALICE));
527
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot and the car", BOB));
528
+ await flushChatBuffers();
529
+ expect(await chatTasks(chatId)).toHaveLength(1);
530
+ expect(await steeringBodies(first!.id)).toEqual(["include the rent", "and the car"]);
531
+ });
532
+
533
+ test("an unaddressed group message after the owner window creates no task", async () => {
534
+ const chatId = -3006;
535
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot book a table", ALICE));
536
+ await flushChatBuffers();
537
+ const [first] = await chatTasks(chatId);
538
+ const endedAt = (minutesAgo: number) =>
539
+ new Date(Date.now() - minutesAgo * 60_000).toISOString();
540
+ await getDbClient().run(
541
+ "UPDATE agent_tasks SET status = 'completed', finishedAt = ? WHERE id = ?",
542
+ [endedAt(60), first!.id],
543
+ );
544
+
545
+ // An hour after the task ended: even the requester's plain message is ignored.
546
+ await postTelegramUpdate(groupUpdate(chatId, "make it for four", ALICE));
547
+ await flushChatBuffers();
548
+ expect(await chatTasks(chatId)).toHaveLength(1);
549
+
550
+ // Control: inside the default 15-minute window the same message is a follow-up.
551
+ await getDbClient().run("UPDATE agent_tasks SET finishedAt = ? WHERE id = ?", [
552
+ endedAt(5),
553
+ first!.id,
554
+ ]);
555
+ await postTelegramUpdate(groupUpdate(chatId, "make it for four", ALICE));
556
+ await flushChatBuffers();
557
+ const tasks = await chatTasks(chatId);
558
+ expect(tasks).toHaveLength(2);
559
+ expect(tasks[1]!.routingReason).toBe("continuity");
560
+ expect(tasks[1]!.task).toContain("addressed: requester-follow-up");
561
+ });
562
+
563
+ test("a reply to the bot counts as a mention without TELEGRAM_MENTION_ON_REPLY", async () => {
564
+ expect(process.env.TELEGRAM_MENTION_ON_REPLY).toBeUndefined();
565
+ const chatId = -3007;
566
+ await postTelegramUpdate(
567
+ groupUpdate(chatId, "yes, go ahead", {
568
+ ...BOB,
569
+ reply_to_message: {
570
+ message_id: 1,
571
+ date: Math.floor(Date.now() / 1000),
572
+ chat: { id: chatId, type: "supergroup", title: "Life" },
573
+ from: { id: 4242, is_bot: true, first_name: "Swarm", username: "swarm_test_bot" },
574
+ text: "Shall I send the invoice?",
575
+ },
576
+ }),
577
+ );
578
+ await flushChatBuffers();
579
+ const tasks = await chatTasks(chatId);
580
+ expect(tasks).toHaveLength(1);
581
+ expect(tasks[0]!.task).toStartWith(
582
+ '[chat] telegram · supergroup "Life" · from: Bob (@bob) · addressed: reply-to-bot · reply-to: "Shall I send the invoice?"\n\n',
583
+ );
584
+ });
585
+
586
+ test("an unknown sender in a known requester's thread does not inherit that requester", async () => {
587
+ const chatId = -3008;
588
+ const alice = await createUser({ name: "Alice", timezone: "Europe/Madrid" });
589
+ await linkIdentity(alice.id, "telegram", String(ALICE.from.id), ACTOR);
590
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot plan the offsite", ALICE));
591
+ await flushChatBuffers();
592
+ const [first] = await chatTasks(chatId);
593
+ expect((await getTaskById(first!.id))?.requestedByUserId).toBe(alice.id);
594
+
595
+ // Bob is not linked to any user.
596
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot and book my train", BOB));
597
+ await flushChatBuffers();
598
+ const tasks = await chatTasks(chatId);
599
+ expect(tasks).toHaveLength(2);
600
+ const second = await getTaskById(tasks[1]!.id);
601
+ expect(second?.parentTaskId).toBe(first!.id);
602
+ expect(second?.requestedByUserId).toBeUndefined();
603
+ });
604
+
605
+ test("a batch from two senders gets no requester, so a registered user never vouches for another's text", async () => {
606
+ const chatId = -3012;
607
+ const DAVE = person(777_204, "Dave", "dave");
608
+ const dave = await createUser({ name: "Dave", timezone: "Europe/Madrid" });
609
+ await linkIdentity(dave.id, "telegram", String(DAVE.from.id), ACTOR);
610
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot plan the offsite", DAVE));
611
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot and send me the keys", BOB));
612
+ await flushChatBuffers();
613
+ const rows = await chatTasks(chatId);
614
+ expect(rows).toHaveLength(1);
615
+ const task = await getTaskById(rows[0]!.id);
616
+ expect(task?.requestedByUserId).toBeUndefined();
617
+ expect(task?.task).toContain("from: Dave (@dave) +1 more");
618
+ });
619
+
620
+ test("only the requester steers a requester's running task; another sender's mention takes the normal path", async () => {
621
+ const chatId = -3013;
622
+ const ERIN = person(777_205, "Erin", "erin");
623
+ const erin = await createUser({ name: "Erin", timezone: "Europe/Madrid" });
624
+ await linkIdentity(erin.id, "telegram", String(ERIN.from.id), ACTOR);
625
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot draft the budget", ERIN));
626
+ await flushChatBuffers();
627
+ const [first] = await chatTasks(chatId);
628
+ expect((await getTaskById(first!.id))?.requestedByUserId).toBe(erin.id);
629
+ await startTask(first!.id);
630
+
631
+ // Control: the requester's plain message steers.
632
+ await postTelegramUpdate(groupUpdate(chatId, "include the rent", ERIN));
633
+ await flushChatBuffers();
634
+ expect(await steeringBodies(first!.id)).toEqual(["include the rent"]);
635
+
636
+ // Bob is not linked to any user.
637
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot send me the keys", BOB));
638
+ await flushChatBuffers();
639
+ expect(await steeringBodies(first!.id)).toEqual(["include the rent"]);
640
+ const tasks = await chatTasks(chatId);
641
+ expect(tasks).toHaveLength(2);
642
+ const second = await getTaskById(tasks[1]!.id);
643
+ expect(second?.requestedByUserId).toBeUndefined();
644
+ expect(second?.task).toContain(
645
+ '[chat] telegram · supergroup "Life" · from: Bob (@bob) · addressed: mention\n\n',
646
+ );
647
+ });
648
+
649
+ test("another sender's message never becomes a follow-up attributed to the requester", async () => {
650
+ const chatId = -3014;
651
+ const FRANK = person(777_206, "Frank", "frank");
652
+ const frank = await createUser({ name: "Frank", timezone: "Europe/Madrid" });
653
+ await linkIdentity(frank.id, "telegram", String(FRANK.from.id), ACTOR);
654
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot plan the offsite", FRANK));
655
+ await flushChatBuffers();
656
+ const [first] = await chatTasks(chatId);
657
+ await startTask(first!.id);
658
+
659
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot send me the keys", BOB));
660
+ await flushChatBuffers();
661
+ // The task ends with any pending steering undelivered: that is what gets promoted.
662
+ await completeTask(first!.id, "done");
663
+
664
+ const bobs = await getDbClient().query<{ id: string }>(
665
+ "SELECT id FROM agent_tasks WHERE task LIKE '%send me the keys%'",
666
+ );
667
+ expect(bobs.length).toBeGreaterThan(0);
668
+ for (const { id } of bobs) {
669
+ expect((await getTaskById(id))?.requestedByUserId).toBeUndefined();
670
+ }
671
+ });
672
+
673
+ test("a non-requester's malformed swarm# token is chatter: no task, no steering", async () => {
674
+ const chatId = -3009;
675
+ await postTelegramUpdate(groupUpdate(chatId, "@swarm_test_bot draft the agenda", ALICE));
676
+ await flushChatBuffers();
677
+ const [first] = await chatTasks(chatId);
678
+ await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
679
+ first!.id,
680
+ ]);
681
+
682
+ await postTelegramUpdate(groupUpdate(chatId, "Carol, what does swarm# mean?", BOB));
683
+ await postTelegramUpdate(groupUpdate(chatId, "swarm#everyone is it a hashtag?", CAROL));
684
+ await flushChatBuffers();
685
+ expect(await chatTasks(chatId)).toHaveLength(1);
686
+ expect(await steeringBodies(first!.id)).toEqual([]);
687
+
688
+ // Control: once the task is done, a real token from a non-requester still routes.
689
+ await getDbClient().run(
690
+ "UPDATE agent_tasks SET status = 'completed', finishedAt = ? WHERE id = ?",
691
+ [new Date().toISOString(), first!.id],
692
+ );
693
+ await postTelegramUpdate(groupUpdate(chatId, `swarm#${worker.id} check the numbers`, BOB));
694
+ await flushChatBuffers();
695
+ const tasks = await chatTasks(chatId);
696
+ expect(tasks).toHaveLength(2);
697
+ expect(tasks[1]!.agentId).toBe(worker.id);
698
+ });
699
+
700
+ test("only a Telegram group expires the thread owner; DMs and other adapters keep continuity", async () => {
701
+ const finishedAt = new Date(Date.now() - 60 * 60_000).toISOString();
702
+ async function seedFinishedOwner(threadId: string): Promise<void> {
703
+ const task = await createTaskExtended("earlier ask", {
704
+ agentId: worker.id,
705
+ source: "chat",
706
+ contextKey: chatSdkContextKey(threadId),
707
+ });
708
+ await getDbClient().run(
709
+ "UPDATE agent_tasks SET status = 'completed', finishedAt = ? WHERE id = ?",
710
+ [finishedAt, task.id],
711
+ );
712
+ }
713
+ const continuity = {
714
+ kind: "agents",
715
+ matches: [
716
+ {
717
+ agent: expect.objectContaining({ id: worker.id }),
718
+ reason: "continuity",
719
+ matchedText: "thread follow-up",
720
+ },
721
+ ],
722
+ };
723
+ const contexts = [
724
+ { adapter: "telegram", channelId: "telegram:3101", threadId: "telegram:3101", dm: true },
725
+ { adapter: "slack", channelId: "slack:C3101", threadId: "slack:C3101:1700000000.000100" },
726
+ { adapter: "discord", channelId: "discord:g1:c3101", threadId: "discord:g1:c3101:t1" },
727
+ { adapter: "teams", channelId: "teams:c3101", threadId: "teams:c3101:m1" },
728
+ ];
729
+ for (const { dm: isDirectMessage = false, ...ctx } of contexts) {
730
+ await seedFinishedOwner(ctx.threadId);
731
+ expect(
732
+ await routeChatMessage("one more thing", { ...ctx, botMentioned: false, isDirectMessage }),
733
+ ).toEqual(continuity);
734
+ }
735
+
736
+ // Control: a Telegram group thread with the same history has no owner.
737
+ await seedFinishedOwner("telegram:-3101");
738
+ expect(
739
+ await routeChatMessage("one more thing", {
740
+ adapter: "telegram",
741
+ channelId: "telegram:-3101",
742
+ threadId: "telegram:-3101",
743
+ botMentioned: false,
744
+ isDirectMessage: false,
745
+ }),
746
+ ).toBeNull();
747
+ });
748
+ });
749
+
750
+ describe("Chat SDK inbound voice notes and size limit", () => {
751
+ const voiceBytes = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0, 2, 0, 0, 1, 2, 3]);
752
+ const voiceUpdate = (chatId: number, fileId: string) =>
753
+ telegramUpdate(dm(chatId), "", {
754
+ voice: {
755
+ file_id: fileId,
756
+ file_unique_id: `${fileId}-unique`,
757
+ duration: 2,
758
+ mime_type: "audio/ogg",
759
+ file_size: voiceBytes.byteLength,
760
+ },
761
+ });
762
+
763
+ test("a voice note is transcribed into the task text and stays attached", async () => {
764
+ const requests: Array<{ auth: string | null; model: unknown; filename: string }> = [];
765
+ const stt = Bun.serve({
766
+ port: 0,
767
+ hostname: "127.0.0.1",
768
+ async fetch(req) {
769
+ const form = await req.formData();
770
+ const file = form.get("file") as File;
771
+ requests.push({
772
+ auth: req.headers.get("authorization"),
773
+ model: form.get("model"),
774
+ filename: file.name,
775
+ });
776
+ return Response.json({ text: "Deploy the staging branch tonight." });
777
+ },
778
+ });
779
+ process.env.TRANSCRIPTION_API_KEY = "stt-test-key";
780
+ process.env.TRANSCRIPTION_BASE_URL = `http://127.0.0.1:${stt.port}/v1`;
781
+ try {
782
+ harness.mock.addFile("voice/note-1.oga", voiceBytes);
783
+ await postTelegramUpdate(voiceUpdate(2101, "voice/note-1.oga"));
784
+ await flushChatBuffers();
785
+ } finally {
786
+ stt.stop(true);
787
+ delete process.env.TRANSCRIPTION_API_KEY;
788
+ delete process.env.TRANSCRIPTION_BASE_URL;
789
+ }
790
+
791
+ expect(requests).toEqual([
792
+ { auth: "Bearer stt-test-key", model: "whisper-1", filename: "audio-1.ogg" },
793
+ ]);
794
+ const tasks = await chatTasks(2101);
795
+ expect(tasks).toHaveLength(1);
796
+ expect(tasks[0]!.task).toContain("[Transcript of audio-1]\nDeploy the staging branch tonight.");
797
+ const attachments = await getTaskAttachments(tasks[0]!.id);
798
+ expect(attachments).toHaveLength(1);
799
+ expect(attachments[0]).toMatchObject({
800
+ mimeType: "audio/ogg",
801
+ sizeBytes: voiceBytes.byteLength,
802
+ });
803
+ });
804
+
805
+ test("a short transcription key echoed in a provider or network error stays out of logs and task text", async () => {
806
+ const shortKey = "local-pass";
807
+ const stt = Bun.serve({
808
+ port: 0,
809
+ hostname: "127.0.0.1",
810
+ fetch: () =>
811
+ Response.json(
812
+ {
813
+ error: {
814
+ message: `Incorrect API key provided: ${shortKey}. Check your credentials.`,
815
+ },
816
+ },
817
+ { status: 401 },
818
+ ),
819
+ });
820
+ const realFetch = globalThis.fetch;
821
+ const warnings: string[] = [];
822
+ const warn = spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
823
+ warnings.push(args.map(String).join(" "));
824
+ });
825
+ process.env.TRANSCRIPTION_API_KEY = shortKey;
826
+ process.env.TRANSCRIPTION_BASE_URL = `http://127.0.0.1:${stt.port}/v1`;
827
+ try {
828
+ harness.mock.addFile("voice/note-401.oga", voiceBytes);
829
+ await postTelegramUpdate(voiceUpdate(2111, "voice/note-401.oga"));
830
+ await flushChatBuffers();
831
+
832
+ // The network-error branch: the transport error echoes the key.
833
+ globalThis.fetch = Object.assign(
834
+ (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
835
+ String(input instanceof Request ? input.url : input).endsWith("/audio/transcriptions")
836
+ ? Promise.reject(new TypeError(`connect failed, bearer ${shortKey} refused`))
837
+ : realFetch(input, init),
838
+ realFetch,
839
+ );
840
+ harness.mock.addFile("voice/note-net.oga", voiceBytes);
841
+ await postTelegramUpdate(voiceUpdate(2112, "voice/note-net.oga"));
842
+ await flushChatBuffers();
843
+ } finally {
844
+ globalThis.fetch = realFetch;
845
+ warn.mockRestore();
846
+ stt.stop(true);
847
+ delete process.env.TRANSCRIPTION_API_KEY;
848
+ delete process.env.TRANSCRIPTION_BASE_URL;
849
+ }
850
+
851
+ for (const [chatId, failure] of [
852
+ [2111, "Incorrect API key provided: [REDACTED:TRANSCRIPTION_API_KEY]"],
853
+ [2112, "connect failed, bearer [REDACTED:TRANSCRIPTION_API_KEY] refused"],
854
+ ] as const) {
855
+ const tasks = await chatTasks(chatId);
856
+ expect(tasks).toHaveLength(1);
857
+ expect(tasks[0]!.task).toContain(`(transcription failed: ${failure}`);
858
+ expect(tasks[0]!.task).not.toContain(shortKey);
859
+ expect(await getTaskAttachments(tasks[0]!.id)).toHaveLength(1);
860
+ }
861
+ const transcriptionWarnings = warnings.filter((line) => line.includes("could not transcribe"));
862
+ expect(transcriptionWarnings).toHaveLength(2);
863
+ expect(warnings.join("\n")).not.toContain(shortKey);
864
+ });
865
+
866
+ test("without a transcription provider the voice note is attached with a one-line note", async () => {
867
+ harness.mock.addFile("voice/note-2.oga", voiceBytes);
868
+ await postTelegramUpdate(voiceUpdate(2102, "voice/note-2.oga"));
869
+ await flushChatBuffers();
870
+
871
+ const tasks = await chatTasks(2102);
872
+ expect(tasks).toHaveLength(1);
873
+ expect(tasks[0]!.task).toContain(NOT_TRANSCRIBED_NOTE);
874
+ expect(await getTaskAttachments(tasks[0]!.id)).toHaveLength(1);
875
+ });
876
+
877
+ test("a 30 MB file is not downloaded; the task says why and the user gets a too-large reply", async () => {
878
+ const chatId = 2103;
879
+ await postTelegramUpdate(
880
+ telegramUpdate(dm(chatId), "", {
881
+ caption: "here is the recording",
882
+ document: {
883
+ file_id: "documents/big.zip",
884
+ file_unique_id: "big-unique",
885
+ file_name: "big.zip",
886
+ mime_type: "application/zip",
887
+ file_size: 30 * 1024 * 1024,
888
+ },
889
+ }),
890
+ );
891
+ await flushChatBuffers();
892
+
893
+ const tasks = await chatTasks(chatId);
894
+ expect(tasks).toHaveLength(1);
895
+ expect(tasks[0]!.task).toContain("big.zip (application/zip, 30.0 MB)");
896
+ expect(tasks[0]!.task).toContain(
897
+ "(not attached: too large (30.0 MB, the limit for files sent to the Telegram bot is 20.0 MB))",
898
+ );
899
+ expect(
900
+ harness.mock.calls.some(
901
+ (call) =>
902
+ call.method === "getFile" &&
903
+ (call.body as { file_id?: string }).file_id === "documents/big.zip",
904
+ ),
905
+ ).toBe(false);
906
+ const replies = harness.mock.calls
907
+ .filter((call) => /^send(Rich)?Message$/.test(call.method))
908
+ .map((call) => call.body as Record<string, unknown>)
909
+ .filter((body) => String(body.chat_id) === String(chatId))
910
+ .map((body) =>
911
+ String((body.rich_message as { markdown?: string } | undefined)?.markdown ?? body.text),
912
+ );
913
+ expect(replies).toHaveLength(1);
914
+ expect(replies[0]).toContain("Too large to read");
915
+ expect(replies[0]).toContain("`big.zip`: too large (30.0 MB");
916
+ });
917
+ });