@vellumai/assistant 0.12.0 → 0.12.1-staging.1

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 (406) hide show
  1. package/AGENTS.md +5 -1
  2. package/ARCHITECTURE.md +11 -12
  3. package/docs/architecture/turn-actor.md +15 -8
  4. package/openapi.yaml +292 -2
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-window-theme.ts +186 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +18 -10
  8. package/src/__tests__/app-compiler.test.ts +6 -0
  9. package/src/__tests__/app-control-tool-schemas.test.ts +5 -2
  10. package/src/__tests__/app-dir-path-guard.test.ts +1 -3
  11. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +5 -2
  12. package/src/__tests__/computer-use-tools.test.ts +17 -5
  13. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  14. package/src/__tests__/config-schema.test.ts +28 -29
  15. package/src/__tests__/config-watcher.test.ts +23 -8
  16. package/src/__tests__/connection-model-compat.test.ts +2 -2
  17. package/src/__tests__/conversation-key-store-disk-view.test.ts +1 -1
  18. package/src/__tests__/conversation-pairing.test.ts +26 -0
  19. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -10
  20. package/src/__tests__/conversation-queue.test.ts +48 -207
  21. package/src/__tests__/conversation-retry-discard.test.ts +54 -1
  22. package/src/__tests__/conversation-skill-tools.test.ts +27 -0
  23. package/src/__tests__/conversation-surfaces-app-control.test.ts +8 -3
  24. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  25. package/src/__tests__/conversation-title-service.test.ts +14 -10
  26. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +3 -5
  27. package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
  28. package/src/__tests__/credential-record-retire.test.ts +171 -1
  29. package/src/__tests__/credential-routes.test.ts +212 -3
  30. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  31. package/src/__tests__/cu-unified-flow.test.ts +8 -2
  32. package/src/__tests__/drain-kick-guard.test.ts +0 -2
  33. package/src/__tests__/drain-requeue-on-contention.test.ts +0 -6
  34. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  35. package/src/__tests__/git-porcelain.test.ts +47 -0
  36. package/src/__tests__/helpers/set-workspace-mcp.ts +11 -0
  37. package/src/__tests__/host-bash-proxy.test.ts +26 -45
  38. package/src/__tests__/host-browser-proxy.test.ts +6 -17
  39. package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
  40. package/src/__tests__/host-file-proxy.test.ts +11 -23
  41. package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
  42. package/src/__tests__/llm-catalog-parity.test.ts +25 -0
  43. package/src/__tests__/mcp-auth-routes.test.ts +6 -9
  44. package/src/__tests__/mcp-config-secret-boundary.test.ts +78 -136
  45. package/src/__tests__/mcp-health-check.test.ts +8 -11
  46. package/src/__tests__/mcp-list-plugin-servers.test.ts +8 -10
  47. package/src/__tests__/message-deleted-hook-dispatch.test.ts +119 -0
  48. package/src/__tests__/messages-after-tiebreaker.test.ts +91 -0
  49. package/src/__tests__/messages-read-boundary-guard.test.ts +1 -1
  50. package/src/__tests__/messaging-send-tool.test.ts +355 -548
  51. package/src/__tests__/model-intents.test.ts +3 -0
  52. package/src/__tests__/outlook-messaging-provider.test.ts +6 -6
  53. package/src/__tests__/platform-client-verify-credential.test.ts +2 -0
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +13 -0
  55. package/src/__tests__/resolve-app.test.ts +135 -0
  56. package/src/__tests__/script-proxy-certs.test.ts +35 -0
  57. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  58. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  59. package/src/__tests__/source-fingerprint.test.ts +184 -0
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +19 -3
  61. package/src/__tests__/subagent-tools.test.ts +3 -2
  62. package/src/__tests__/telephony-tts-guard.test.ts +38 -3
  63. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +4 -0
  64. package/src/__tests__/watch-retro-tool-availability.test.ts +8 -17
  65. package/src/__tests__/workspace-git-service.test.ts +26 -0
  66. package/src/__tests__/workspace-migration-154-repair-retired-codex-gpt-5-4-model-ids.test.ts +336 -0
  67. package/src/acp/__tests__/agent-process.test.ts +258 -4
  68. package/src/acp/__tests__/auth-required.test.ts +96 -0
  69. package/src/acp/__tests__/client-handler.test.ts +90 -2
  70. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  71. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  72. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  73. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  74. package/src/acp/__tests__/session-manager.test.ts +895 -4
  75. package/src/acp/agent-process.test.ts +4 -1
  76. package/src/acp/agent-process.ts +94 -9
  77. package/src/acp/auth-required.ts +50 -1
  78. package/src/acp/auto-install.test.ts +9 -7
  79. package/src/acp/client-handler.ts +16 -3
  80. package/src/acp/model-config.test.ts +166 -0
  81. package/src/acp/model-config.ts +138 -0
  82. package/src/acp/resolve-agent.test.ts +220 -6
  83. package/src/acp/resolve-agent.ts +88 -23
  84. package/src/acp/session-manager.test.ts +196 -3
  85. package/src/acp/session-manager.ts +441 -18
  86. package/src/acp/types.ts +50 -5
  87. package/src/api/events/acp-session-model-update.test.ts +88 -0
  88. package/src/api/events/acp-session-model-update.ts +46 -0
  89. package/src/api/events/app-files-changed.ts +4 -3
  90. package/src/api/index.ts +6 -0
  91. package/src/apps/app-store.ts +1 -1
  92. package/src/apps/resolve-app.ts +81 -0
  93. package/src/apps/source-fingerprint.ts +266 -0
  94. package/src/bundler/app-compiler.ts +15 -0
  95. package/src/calls/__tests__/barge-in-guard.test.ts +81 -0
  96. package/src/{__tests__ → calls/__tests__}/call-controller.test.ts +503 -82
  97. package/src/{__tests__ → calls/__tests__}/media-stream-audio-transcode.test.ts +1 -1
  98. package/src/{__tests__ → calls/__tests__}/media-stream-output.test.ts +37 -8
  99. package/src/{__tests__ → calls/__tests__}/media-stream-parser.test.ts +1 -1
  100. package/src/{__tests__ → calls/__tests__}/media-stream-server-integration.test.ts +150 -57
  101. package/src/{__tests__ → calls/__tests__}/media-stream-stt-session.test.ts +5 -5
  102. package/src/{__tests__ → calls/__tests__}/media-turn-detector.test.ts +1 -1
  103. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  104. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  105. package/src/calls/__tests__/voice-control-protocol.test.ts +58 -25
  106. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  107. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  108. package/src/{__tests__/voice-session-bridge.test.ts → calls/__tests__/voice-session-bridge-turn-flow.test.ts} +20 -12
  109. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -1
  110. package/src/calls/__tests__/voice-triage-escalate.test.ts +105 -0
  111. package/src/calls/barge-in-guard.ts +118 -0
  112. package/src/calls/call-constants.ts +24 -4
  113. package/src/calls/call-controller.ts +499 -199
  114. package/src/calls/call-transport.ts +16 -0
  115. package/src/calls/media-stream-output.ts +32 -0
  116. package/src/calls/media-stream-server.ts +82 -30
  117. package/src/calls/media-stream-stt-session.ts +15 -0
  118. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  119. package/src/calls/resolve-call-tts-provider.ts +33 -2
  120. package/src/calls/tts-call-strategy.ts +9 -5
  121. package/src/calls/types.ts +2 -0
  122. package/src/calls/voice-control-protocol.ts +53 -21
  123. package/src/calls/voice-leg-coordinator.ts +189 -0
  124. package/src/calls/voice-progress-cadence.ts +423 -0
  125. package/src/calls/voice-session-bridge.ts +59 -46
  126. package/src/calls/voice-triage-escalate.ts +175 -56
  127. package/src/cli/commands/__tests__/apps.test.ts +245 -0
  128. package/src/cli/commands/__tests__/roadmap.help.test.ts +19 -0
  129. package/src/cli/commands/apps.help.ts +67 -13
  130. package/src/cli/commands/apps.ts +183 -0
  131. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  132. package/src/cli/commands/channels/index.help.ts +70 -2
  133. package/src/cli/commands/channels/index.ts +16 -3
  134. package/src/cli/commands/channels/send.ts +128 -0
  135. package/src/cli/commands/credentials.help.ts +6 -2
  136. package/src/cli/commands/credentials.ts +2 -2
  137. package/src/cli/commands/mcp.help.ts +8 -8
  138. package/src/cli/commands/mcp.ts +1 -1
  139. package/src/cli/commands/roadmap.help.ts +5 -1
  140. package/src/cli/commands/roadmap.ts +1 -1
  141. package/src/cli/lib/bundled-marketplace.json +103 -90
  142. package/src/config/__tests__/default-profile-catalog.test.ts +22 -0
  143. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  144. package/src/config/__tests__/platform-identity.test.ts +117 -0
  145. package/src/config/__tests__/platform-rehydration.test.ts +93 -0
  146. package/src/config/acp-agent-write.ts +43 -0
  147. package/src/config/acp-defaults.test.ts +13 -3
  148. package/src/config/acp-defaults.ts +23 -6
  149. package/src/config/acp-schema.test.ts +81 -0
  150. package/src/config/acp-schema.ts +30 -2
  151. package/src/config/bundled-skills/acp/SKILL.md +22 -14
  152. package/src/config/bundled-skills/acp/TOOLS.json +5 -1
  153. package/src/config/bundled-skills/app-builder/SKILL.md +12 -9
  154. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +1 -1
  155. package/src/config/bundled-skills/messaging/TOOLS.json +3 -3
  156. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +67 -139
  157. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +1 -1
  158. package/src/config/bundled-skills/screen-annotation/SKILL.md +8 -1
  159. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +4 -1
  160. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  161. package/src/config/default-profile-catalog.ts +1 -1
  162. package/src/config/env.ts +2 -2
  163. package/src/config/feature-flag-registry.json +8 -12
  164. package/src/config/memory-v3-gate.test.ts +31 -0
  165. package/src/config/memory-v3-gate.ts +20 -16
  166. package/src/config/platform-identity.ts +144 -0
  167. package/src/config/platform-rehydration.ts +75 -21
  168. package/src/config/schema.ts +2 -2
  169. package/src/config/schemas/__tests__/live-voice.test.ts +8 -158
  170. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  171. package/src/config/schemas/calls.ts +1 -28
  172. package/src/config/schemas/live-voice.ts +1 -173
  173. package/src/config/schemas/mcp.ts +1 -1
  174. package/src/config/schemas/memory-retrospective.ts +10 -0
  175. package/src/config/schemas/skills.ts +0 -14
  176. package/src/config/schemas/voice.ts +150 -0
  177. package/src/credential-execution/process-manager.test.ts +18 -19
  178. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -121
  179. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +4 -1
  180. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  181. package/src/daemon/app-change-notify.ts +45 -0
  182. package/src/daemon/config-watcher.ts +13 -194
  183. package/src/daemon/conversation-process.ts +35 -64
  184. package/src/daemon/conversation-skill-tools.ts +13 -31
  185. package/src/daemon/conversation-store.ts +1 -1
  186. package/src/daemon/conversation-surfaces.ts +10 -8
  187. package/src/daemon/conversation-tool-setup.ts +37 -43
  188. package/src/daemon/conversation.ts +0 -3
  189. package/src/daemon/host-bash-proxy.ts +8 -12
  190. package/src/daemon/host-browser-proxy.ts +15 -1
  191. package/src/daemon/host-file-proxy.ts +8 -10
  192. package/src/daemon/host-transfer-proxy.ts +14 -18
  193. package/src/daemon/lifecycle.ts +0 -5
  194. package/src/daemon/mcp-reload-service.ts +5 -5
  195. package/src/daemon/message-types/sync.ts +1 -0
  196. package/src/daemon/providers-setup.ts +1 -1
  197. package/src/daemon/shutdown-handlers.ts +1 -9
  198. package/src/daemon/skill-memory-refresh.ts +64 -0
  199. package/src/daemon/tool-side-effects.ts +7 -18
  200. package/src/desktop/__tests__/fake-desktop.ts +119 -0
  201. package/src/desktop/desktop-chrome-policy.test.ts +71 -0
  202. package/src/desktop/desktop-chrome-policy.ts +42 -0
  203. package/src/desktop/desktop-dependencies.test.ts +102 -0
  204. package/src/desktop/desktop-dependencies.ts +306 -0
  205. package/src/desktop/desktop-feature.test.ts +18 -0
  206. package/src/desktop/desktop-feature.ts +19 -0
  207. package/src/desktop/desktop-panel-config.test.ts +135 -0
  208. package/src/desktop/desktop-panel-config.ts +149 -0
  209. package/src/desktop/desktop-session-manager.test.ts +627 -0
  210. package/src/desktop/desktop-session-manager.ts +814 -0
  211. package/src/desktop/desktop-stream-bridge.test.ts +395 -0
  212. package/src/desktop/desktop-stream-bridge.ts +214 -0
  213. package/src/desktop/desktop-wallpaper.test.ts +191 -0
  214. package/src/desktop/desktop-wallpaper.ts +113 -0
  215. package/src/desktop/desktop-window-theme.ts +170 -0
  216. package/src/hooks/types.ts +33 -0
  217. package/src/i18n/AGENTS.md +22 -0
  218. package/src/i18n/__tests__/i18n.test.ts +108 -0
  219. package/src/i18n/index.ts +16 -0
  220. package/src/i18n/locales.ts +93 -0
  221. package/src/i18n/messages.ts +61 -0
  222. package/src/i18n/title.ts +25 -0
  223. package/src/inbound/platform-callback-registration.ts +4 -5
  224. package/src/live-voice/__tests__/live-voice-metrics.test.ts +0 -3
  225. package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
  226. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  227. package/src/live-voice/__tests__/runtime-websocket-shell.test.ts +13 -69
  228. package/src/live-voice/live-voice-session.ts +213 -693
  229. package/src/live-voice/protocol.ts +0 -2
  230. package/src/mcp/__tests__/effective-config.test.ts +2 -2
  231. package/src/mcp/__tests__/workspace-mcp-config.test.ts +135 -0
  232. package/src/mcp/effective-config.ts +3 -3
  233. package/src/mcp/mcp-header-store.ts +29 -45
  234. package/src/mcp/spec-schema.ts +111 -0
  235. package/src/mcp/startup.ts +8 -4
  236. package/src/mcp/workspace-mcp-config.ts +188 -0
  237. package/src/messaging/provider.ts +10 -3
  238. package/src/messaging/providers/__tests__/proactive-address.test.ts +95 -0
  239. package/src/messaging/providers/callback-routing.ts +18 -0
  240. package/src/messaging/providers/channel-transport.ts +56 -0
  241. package/src/messaging/providers/discord/transport.ts +17 -0
  242. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +6 -17
  243. package/src/messaging/providers/slack/adapter.ts +7 -44
  244. package/src/messaging/providers/slack/transport.ts +16 -0
  245. package/src/messaging/providers/telegram-bot/adapter.ts +1 -48
  246. package/src/messaging/providers/telegram-bot/client.ts +0 -26
  247. package/src/messaging/providers/telegram-bot/transport.ts +20 -0
  248. package/src/messaging/providers/whatsapp/adapter.ts +1 -48
  249. package/src/messaging/providers/whatsapp/transport.ts +18 -0
  250. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +8 -0
  251. package/src/notifications/__tests__/assistant-reply-producer.test.ts +55 -0
  252. package/src/notifications/__tests__/home-feed-side-effect.test.ts +153 -3
  253. package/src/notifications/assistant-reply-producer.ts +8 -3
  254. package/src/notifications/conversation-pairing.ts +36 -16
  255. package/src/notifications/delivered-post-record.ts +11 -1
  256. package/src/notifications/home-feed-side-effect.ts +55 -7
  257. package/src/outbound-proxy/certs.ts +3 -5
  258. package/src/permissions/checker.test.ts +11 -6
  259. package/src/persistence/compaction-log-store-clickhouse.ts +3 -3
  260. package/src/persistence/conversation-crud.ts +71 -63
  261. package/src/persistence/conversation-lineage.ts +12 -4
  262. package/src/persistence/conversation-title-placeholders.ts +23 -0
  263. package/src/persistence/conversation-title-service.ts +9 -26
  264. package/src/persistence/llm-request-log-sink-clickhouse.ts +3 -3
  265. package/src/persistence/llm-request-log-source-clickhouse.ts +3 -3
  266. package/src/persistence/message-cursor.ts +78 -0
  267. package/src/persistence/message-reads.ts +30 -0
  268. package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
  269. package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
  270. package/src/persistence/schema/memory-core.ts +5 -0
  271. package/src/platform/client.test.ts +2 -0
  272. package/src/platform/client.ts +2 -10
  273. package/src/plugin-api/constants.ts +2 -0
  274. package/src/plugin-api/index.ts +1 -0
  275. package/src/plugin-api/types.ts +2 -0
  276. package/src/plugins/defaults/index.ts +2 -0
  277. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +46 -0
  278. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-backfill.test.ts +118 -0
  279. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-preserve.test.ts +83 -0
  280. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +9 -5
  281. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +20 -10
  282. package/src/plugins/defaults/memory/__tests__/memory-retrospective-state.test.ts +219 -0
  283. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +31 -0
  284. package/src/plugins/defaults/memory/__tests__/memory-retrospective-trigger-check.test.ts +63 -0
  285. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  286. package/src/plugins/defaults/memory/graph/capability-seed.ts +4 -6
  287. package/src/plugins/defaults/memory/hooks/init.ts +10 -0
  288. package/src/plugins/defaults/memory/hooks/message-deleted.ts +21 -0
  289. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  290. package/src/plugins/defaults/memory/jobs-worker.ts +13 -0
  291. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +34 -60
  292. package/src/plugins/defaults/memory/memory-retrospective-cursor-backfill.ts +126 -0
  293. package/src/plugins/defaults/memory/memory-retrospective-cursor-preserve.ts +79 -0
  294. package/src/plugins/defaults/memory/memory-retrospective-cursor.ts +28 -0
  295. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +3 -5
  296. package/src/plugins/defaults/memory/memory-retrospective-job.ts +12 -3
  297. package/src/plugins/defaults/memory/memory-retrospective-state.ts +312 -71
  298. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +2 -1
  299. package/src/plugins/defaults/memory/memory-retrospective-trigger-check.ts +5 -3
  300. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  301. package/src/plugins/defaults/memory/startup.ts +6 -1
  302. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +7 -9
  303. package/src/plugins/defaults/memory/substrate/skill-content.ts +2 -2
  304. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +89 -0
  305. package/src/plugins/defaults/memory/worker.ts +6 -0
  306. package/src/plugins/mcp-servers.ts +15 -75
  307. package/src/providers/__tests__/connection-model-compat.test.ts +3 -3
  308. package/src/providers/gemini/client.ts +9 -23
  309. package/src/providers/gemini-thought-signature.test.ts +63 -0
  310. package/src/providers/gemini-thought-signature.ts +44 -0
  311. package/src/providers/model-catalog.ts +34 -0
  312. package/src/providers/model-intents.ts +10 -5
  313. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +146 -18
  314. package/src/providers/openai/__tests__/chat-completions-provider-thought-signature.test.ts +613 -0
  315. package/src/providers/openai/__tests__/google-thought-signature.test.ts +235 -0
  316. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +83 -1
  317. package/src/providers/openai/chat-completions-provider.ts +192 -78
  318. package/src/providers/openai/codex-models.ts +0 -4
  319. package/src/providers/openai/google-thought-signature.ts +320 -0
  320. package/src/runtime/AGENTS.md +2 -0
  321. package/src/runtime/__tests__/channel-send.test.ts +443 -0
  322. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +89 -0
  323. package/src/runtime/__tests__/live-voice-upgrade.test.ts +58 -0
  324. package/src/runtime/__tests__/runtime-ws-test-utils.ts +72 -0
  325. package/src/runtime/__tests__/watch-stream-upgrade.test.ts +59 -0
  326. package/src/runtime/auth/same-actor.ts +38 -3
  327. package/src/runtime/channel-send.ts +316 -0
  328. package/src/runtime/http-server.ts +160 -151
  329. package/src/runtime/routes/__tests__/acp-routes.test.ts +159 -4
  330. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +205 -0
  331. package/src/runtime/routes/__tests__/channel-send-routes.test.ts +127 -0
  332. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  333. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
  334. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +16 -12
  335. package/src/runtime/routes/acp-routes.test.ts +15 -0
  336. package/src/runtime/routes/acp-routes.ts +72 -9
  337. package/src/runtime/routes/app-management-routes.ts +73 -0
  338. package/src/runtime/routes/app-routes.ts +1 -1
  339. package/src/runtime/routes/channel-send-routes.ts +85 -0
  340. package/src/runtime/routes/conversation-list-routes.ts +14 -3
  341. package/src/runtime/routes/conversation-query-routes.ts +40 -0
  342. package/src/runtime/routes/credential-routes.ts +19 -10
  343. package/src/runtime/routes/desktop-setup-routes.test.ts +74 -0
  344. package/src/runtime/routes/desktop-setup-routes.ts +35 -0
  345. package/src/runtime/routes/diagnostics-routes.ts +55 -3
  346. package/src/runtime/routes/index.ts +4 -0
  347. package/src/runtime/routes/log-export-routes.ts +2 -0
  348. package/src/runtime/routes/mcp-auth-routes.ts +39 -47
  349. package/src/runtime/routes/migration-routes.ts +8 -21
  350. package/src/runtime/routes/platform-routes.ts +18 -15
  351. package/src/runtime/services/__tests__/conversation-serializer.test.ts +28 -0
  352. package/src/runtime/services/conversation-serializer.ts +16 -3
  353. package/src/runtime/sync/resource-sync-events.ts +2 -1
  354. package/src/schedule/worker.ts +1 -1
  355. package/src/security/__tests__/ces-http-record-backend.test.ts +137 -0
  356. package/src/security/ces-http-record-backend.ts +120 -0
  357. package/src/security/ces-rpc-record-backend.ts +7 -0
  358. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  359. package/src/skills/workspace-skill-md-signature.ts +50 -0
  360. package/src/subagent/consult-context.ts +12 -25
  361. package/src/tools/acp/list-agents.test.ts +3 -1
  362. package/src/tools/acp/spawn.test.ts +146 -11
  363. package/src/tools/acp/spawn.ts +21 -3
  364. package/src/tools/acp/status.test.ts +94 -0
  365. package/src/tools/acp/status.ts +40 -3
  366. package/src/tools/app-control/skill-proxy-bridge.ts +2 -1
  367. package/src/tools/capability-offer.test.ts +22 -2
  368. package/src/tools/capability-offer.ts +16 -1
  369. package/src/tools/computer-use/definitions.ts +4 -2
  370. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -1
  371. package/src/tools/credentials/metadata-store.ts +100 -0
  372. package/src/tools/host-filesystem/edit.test.ts +2 -1
  373. package/src/tools/host-filesystem/edit.ts +2 -2
  374. package/src/tools/host-filesystem/read.test.ts +2 -1
  375. package/src/tools/host-filesystem/read.ts +2 -2
  376. package/src/tools/host-filesystem/transfer.test.ts +2 -1
  377. package/src/tools/host-filesystem/transfer.ts +2 -2
  378. package/src/tools/host-filesystem/write.test.ts +2 -1
  379. package/src/tools/host-filesystem/write.ts +2 -2
  380. package/src/tools/host-terminal/host-shell.test.ts +16 -0
  381. package/src/tools/host-terminal/host-shell.ts +4 -20
  382. package/src/tools/policy-context.ts +6 -7
  383. package/src/tools/subagent/spawn.ts +5 -8
  384. package/src/tools/terminal/__tests__/safe-env.test.ts +6 -6
  385. package/src/tools/terminal/safe-env.ts +23 -3
  386. package/src/tools/watch/watch-retro-report.ts +0 -8
  387. package/src/util/__tests__/language-subtag.test.ts +23 -1
  388. package/src/util/ca-bundle.ts +13 -0
  389. package/src/util/language-subtag.ts +16 -0
  390. package/src/util/platform.ts +8 -0
  391. package/src/workspace/git-porcelain.ts +66 -0
  392. package/src/workspace/git-service.ts +5 -54
  393. package/src/workspace/migrations/154-repair-retired-codex-gpt-5-4-model-ids.ts +258 -0
  394. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  395. package/src/workspace/migrations/156-extract-workspace-mcp-json.ts +214 -0
  396. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  397. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +199 -0
  398. package/src/workspace/migrations/registry.ts +6 -0
  399. package/src/__tests__/app-source-watcher.test.ts +0 -218
  400. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
  401. package/src/__tests__/speaker-identification.test.ts +0 -56
  402. package/src/calls/speaker-identification.ts +0 -233
  403. package/src/daemon/app-source-watcher.ts +0 -218
  404. package/src/messaging/providers/whatsapp/client.ts +0 -29
  405. package/src/util/fs-watcher-error.ts +0 -36
  406. /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
@@ -36,25 +36,18 @@ function makeProvider(id: string, displayName: string): MessagingProvider {
36
36
  };
37
37
  }
38
38
 
39
- // "outlook" is a messaging provider but not a channel (it is not in
40
- // CHANNEL_IDS): a send through it has no chat conversation to record in.
41
- // "phone" and "telegram" are channels.
39
+ // "phone" is a provider with a send of its own and no channel transport, so
40
+ // its send stays tool-mediated; "outlook" drafts. A channel with a transport
41
+ // (slack, telegram) never reaches a provider's send: the tool hands it to
42
+ // the transport-backed send, which is what these tests prove.
42
43
  const phoneProvider = makeProvider("phone", "Phone");
43
- const telegramProvider = makeProvider("telegram", "Telegram");
44
44
  const outlookProvider = makeProvider("outlook", "Outlook");
45
- // Slack's provider threads exactly where it is told and reports that thread
46
- // on the result; Telegram's ignores a requested thread and reports none.
47
- const slackProvider: MessagingProvider = {
48
- ...makeProvider("slack", "Slack"),
49
- sendMessage: async (_connection, conversationId, _text, options) => ({
50
- id: "1700000000.000100",
51
- timestamp: 123,
52
- conversationId,
53
- ...(options?.threadId ? { threadId: options.threadId } : {}),
54
- }),
45
+ const telegramProvider: MessagingProvider = {
46
+ ...makeProvider("telegram", "Telegram"),
47
+ sendMessage: undefined,
55
48
  };
56
- let provider: MessagingProvider = phoneProvider;
57
49
 
50
+ let provider: MessagingProvider = phoneProvider;
58
51
  let connection: OAuthConnection | undefined = undefined;
59
52
 
60
53
  /** Fires during provider resolution, the first await after the entry guard. */
@@ -74,26 +67,24 @@ mock.module("../config/bundled-skills/messaging/tools/shared.js", () => ({
74
67
  isMailboxAddress: (value: string) => value.includes("@"),
75
68
  }));
76
69
 
77
- // ── Sent-post record dependency mocks ──
78
-
79
- const getConversationMock = mock(
80
- (_id: string) => null as { id: string; createdAt: number } | null,
81
- );
82
-
83
- const syncMessageToDiskMock = mock(
84
- (_conversationId: string, _messageId: string, _createdAtMs: number) => {},
85
- );
86
-
87
- const resolveProactiveHomeConversationMock = mock(
88
- async (_params: Record<string, unknown>) => ({
89
- conversationId: "home-1",
90
- createdNewConversation: false,
91
- }),
92
- );
70
+ // ── The transport-backed send ──
71
+
72
+ const ADDRESSABLE = new Set(["slack", "telegram", "discord", "whatsapp"]);
73
+ const sendChannelTextMock = mock(async (params: Record<string, unknown>) => ({
74
+ channel: params.channel,
75
+ chatId: (params.target as { chatId: string }).chatId,
76
+ ...((params.target as { threadId?: string }).threadId
77
+ ? { threadId: (params.target as { threadId?: string }).threadId }
78
+ : {}),
79
+ messageIds: ["1700000000.000100"],
80
+ lastMessageId: "1700000000.000100",
81
+ recordedIn: "home-1",
82
+ }));
93
83
 
94
- const recordDeliveredChannelPostMock = mock(
95
- async (_post: Record<string, unknown>) => ({ messageId: "row-1" }),
96
- );
84
+ mock.module("../runtime/channel-send.js", () => ({
85
+ isProactivelyAddressable: (channel: string) => ADDRESSABLE.has(channel),
86
+ sendChannelText: sendChannelTextMock,
87
+ }));
97
88
 
98
89
  const mockOutlookCreateDraft = mock(
99
90
  async (_conn: OAuthConnection, _draft: Record<string, unknown>) => ({
@@ -127,155 +118,155 @@ mock.module("../messaging/providers/outlook/client.js", () => ({
127
118
  })),
128
119
  }));
129
120
 
130
- mock.module("../persistence/conversation-crud.js", () => ({
131
- setConversationProcessingStartedAt: () => {},
132
- isConversationProcessing: () => false,
133
- getConversation: getConversationMock,
134
- reserveMessage: mock(async () => ({ id: "msg-reserve" })),
135
- }));
136
-
137
- mock.module("../persistence/conversation-disk-view.js", () => ({
138
- syncMessageToDisk: syncMessageToDiskMock,
139
- }));
140
-
141
- mock.module("../notifications/conversation-pairing.js", () => ({
142
- resolveProactiveHomeConversation: resolveProactiveHomeConversationMock,
143
- }));
144
-
145
- mock.module("../persistence/external-conversation-store.js", () => ({
146
- normalizeExternalThreadId: (value: string | null | undefined) => {
147
- const trimmed = value?.trim();
148
- return trimmed ? trimmed : null;
149
- },
150
- }));
151
-
152
- mock.module("../notifications/delivered-post-record.js", () => ({
153
- recordDeliveredChannelPost: recordDeliveredChannelPostMock,
154
- }));
155
-
156
121
  import { run } from "../config/bundled-skills/messaging/tools/messaging-send.js";
157
122
 
123
+ const turn = {
124
+ workingDir: "/tmp",
125
+ conversationId: "conv-A",
126
+ assistantId: "ast-1",
127
+ trustClass: "guardian" as const,
128
+ executionChannel: "slack",
129
+ requesterChatId: "D0123456789",
130
+ sourceThreadId: "1700000000.000001",
131
+ };
132
+
158
133
  describe("messaging-send tool", () => {
159
134
  beforeEach(() => {
160
135
  provider = phoneProvider;
161
136
  connection = undefined;
162
137
  sendMessageMock.mockClear();
138
+ sendChannelTextMock.mockClear();
163
139
  mockOutlookCreateDraft.mockClear();
164
140
  mockOutlookCreateReplyDraft.mockClear();
165
141
  onResolveProvider = null;
166
- getConversationMock.mockClear();
167
- syncMessageToDiskMock.mockClear();
168
- resolveProactiveHomeConversationMock.mockClear();
169
- resolveProactiveHomeConversationMock.mockImplementation(async () => ({
170
- conversationId: "home-1",
171
- createdNewConversation: false,
172
- }));
173
- recordDeliveredChannelPostMock.mockClear();
174
- recordDeliveredChannelPostMock.mockImplementation(async () => ({
175
- messageId: "row-1",
176
- }));
177
142
  });
178
143
 
179
- test("passes assistantId from tool context to provider send options", async () => {
180
- const result = await run(
181
- {
182
- platform: "phone",
183
- conversation_id: "+15550004444",
184
- text: "test message",
185
- },
186
- {
187
- workingDir: "/tmp",
188
- conversationId: "conv-1",
189
- assistantId: "ast-alpha",
190
- trustClass: "guardian" as const,
191
- },
192
- );
193
-
194
- expect(result.isError).toBe(false);
195
- expect(sendMessageMock).toHaveBeenCalledWith(
196
- undefined,
197
- "+15550004444",
198
- "test message",
199
- {
200
- subject: undefined,
201
- inReplyTo: undefined,
202
- threadId: undefined,
203
- attachments: undefined,
204
- assistantId: "ast-alpha",
205
- },
206
- );
207
- });
144
+ describe("a channel with a transport", () => {
145
+ test("is sent through the transport-backed send with the turn's snapshot, never a provider", async () => {
146
+ const result = await run(
147
+ {
148
+ platform: "slack",
149
+ conversation_id: "C123",
150
+ thread_id: "1700000000.000009",
151
+ text: "hello",
152
+ },
153
+ turn,
154
+ );
208
155
 
209
- test("passes threadId to provider when replying on non-Gmail platform", async () => {
210
- const result = await run(
211
- {
212
- platform: "phone",
213
- conversation_id: "conv-1",
214
- text: "reply text",
215
- thread_id: "thread-abc",
216
- },
217
- {
218
- workingDir: "/tmp",
219
- conversationId: "conv-1",
220
- assistantId: "ast-alpha",
221
- trustClass: "guardian" as const,
222
- },
223
- );
224
-
225
- expect(result.isError).toBe(false);
226
- expect(sendMessageMock).toHaveBeenCalledWith(
227
- undefined,
228
- "conv-1",
229
- "reply text",
230
- {
231
- subject: undefined,
232
- inReplyTo: undefined,
233
- threadId: "thread-abc",
234
- attachments: undefined,
235
- assistantId: "ast-alpha",
236
- },
237
- );
238
- });
156
+ expect(result.isError).toBe(false);
157
+ expect(sendMessageMock).not.toHaveBeenCalled();
158
+ expect(sendChannelTextMock).toHaveBeenCalledTimes(1);
159
+ expect(sendChannelTextMock.mock.calls[0]![0]).toEqual({
160
+ channel: "slack",
161
+ target: {
162
+ kind: "chat",
163
+ chatId: "C123",
164
+ threadId: "1700000000.000009",
165
+ },
166
+ text: "hello",
167
+ renderRichly: true,
168
+ assistantId: "ast-1",
169
+ sender: {
170
+ conversationId: "conv-A",
171
+ executionChannel: "slack",
172
+ requesterChatId: "D0123456789",
173
+ sourceThreadId: "1700000000.000001",
174
+ },
175
+ });
176
+ expect(result.content).toBe(
177
+ 'Message sent (ID: 1700000000.000100, "thread_id": "1700000000.000009").',
178
+ );
179
+ });
239
180
 
240
- test("rejects attachments on platforms that can't carry them", async () => {
241
- const result = await run(
242
- {
243
- platform: "phone",
244
- conversation_id: "+12025550142",
245
- text: "with a file",
246
- attachment_paths: ["/tmp/does-not-matter.pdf"],
247
- },
248
- {
249
- workingDir: "/tmp",
250
- conversationId: "conv-1",
251
- assistantId: "ast-alpha",
252
- trustClass: "guardian" as const,
253
- },
254
- );
181
+ test("names the channel outright without resolving a provider, so a channel with no provider is reachable", async () => {
182
+ provider = outlookProvider;
183
+ const result = await run(
184
+ { platform: "discord", conversation_id: "C1", text: "hi" },
185
+ turn,
186
+ );
187
+ expect(result.isError).toBe(false);
188
+ expect(sendChannelTextMock.mock.calls[0]![0]).toMatchObject({
189
+ channel: "discord",
190
+ target: { kind: "chat", chatId: "C1" },
191
+ });
192
+ });
193
+
194
+ test("an auto-detected provider that is a channel goes through the transport too", async () => {
195
+ provider = telegramProvider;
196
+ const result = await run(
197
+ { conversation_id: "123456789", text: "hi" },
198
+ turn,
199
+ );
200
+ expect(result.isError).toBe(false);
201
+ expect(sendChannelTextMock.mock.calls[0]![0]).toMatchObject({
202
+ channel: "telegram",
203
+ target: { kind: "chat", chatId: "123456789" },
204
+ });
205
+ });
206
+
207
+ test("rejects attachments before any send", async () => {
208
+ const result = await run(
209
+ {
210
+ platform: "slack",
211
+ conversation_id: "C123",
212
+ text: "with a file",
213
+ attachment_paths: ["/tmp/does-not-matter.pdf"],
214
+ },
215
+ turn,
216
+ );
217
+ expect(result.isError).toBe(true);
218
+ expect(result.content).toContain("Gmail and Outlook");
219
+ expect(sendChannelTextMock).not.toHaveBeenCalled();
220
+ });
255
221
 
256
- expect(result.isError).toBe(true);
257
- expect(result.content).toContain("Gmail and Outlook");
258
- expect(sendMessageMock).not.toHaveBeenCalled();
222
+ test("refuses an account rather than sending from the channel's default identity", async () => {
223
+ const result = await run(
224
+ {
225
+ platform: "slack",
226
+ account: "workspace-b@example.com",
227
+ conversation_id: "C123",
228
+ text: "hi",
229
+ },
230
+ turn,
231
+ );
232
+ expect(result.isError).toBe(true);
233
+ expect(result.content).toContain("account does not apply to slack");
234
+ expect(sendChannelTextMock).not.toHaveBeenCalled();
235
+ });
236
+
237
+ test("a turn cancelled during provider resolution sends nothing", async () => {
238
+ provider = telegramProvider;
239
+ const controller = new AbortController();
240
+ onResolveProvider = () => controller.abort();
241
+ await expect(
242
+ run(
243
+ { conversation_id: "123456789", text: "never sent" },
244
+ { ...turn, signal: controller.signal },
245
+ ),
246
+ ).rejects.toThrow();
247
+ expect(sendChannelTextMock).not.toHaveBeenCalled();
248
+ });
249
+
250
+ test("reports the send's refusal as the tool's error", async () => {
251
+ sendChannelTextMock.mockImplementationOnce(async () => {
252
+ throw new Error('Channel "slack" cannot be addressed by person.');
253
+ });
254
+ const result = await run(
255
+ { platform: "slack", conversation_id: "C123", text: "hi" },
256
+ turn,
257
+ );
258
+ expect(result.isError).toBe(true);
259
+ expect(result.content).toContain("cannot be addressed");
260
+ });
259
261
  });
260
262
 
261
- test("reads and forwards attachments onto an Outlook draft", async () => {
262
- const dir = mkdtempSync(join(tmpdir(), "msg-send-att-"));
263
- const filePath = join(dir, "report.pdf");
264
- writeFileSync(filePath, "pdf-bytes");
265
- provider = outlookProvider;
266
- connection = {
267
- id: "outlook-conn-1",
268
- provider: "outlook",
269
- } as OAuthConnection;
270
-
271
- try {
263
+ describe("a provider with a send of its own", () => {
264
+ test("passes assistantId from tool context to provider send options", async () => {
272
265
  const result = await run(
273
266
  {
274
- platform: "outlook",
275
- conversation_id: "user@example.com",
276
- text: "see attached",
277
- subject: "Docs",
278
- attachment_paths: [filePath],
267
+ platform: "phone",
268
+ conversation_id: "+12125550100",
269
+ text: "test message",
279
270
  },
280
271
  {
281
272
  workingDir: "/tmp",
@@ -286,411 +277,227 @@ describe("messaging-send tool", () => {
286
277
  );
287
278
 
288
279
  expect(result.isError).toBe(false);
289
- expect(result.content).toContain("Outlook draft created");
290
- expect(result.content).toContain("Draft ID: outlook-draft-1");
291
- expect(sendMessageMock).not.toHaveBeenCalled();
292
- expect(mockOutlookCreateDraft).toHaveBeenCalledTimes(1);
293
- expect(mockOutlookCreateDraft).toHaveBeenCalledWith(
294
- connection,
295
- expect.objectContaining({
296
- subject: "Docs",
297
- body: { contentType: "text", content: "see attached" },
298
- toRecipients: [{ emailAddress: { address: "user@example.com" } }],
299
- attachments: [expect.objectContaining({ name: "report.pdf" })],
300
- }),
280
+ expect(sendChannelTextMock).not.toHaveBeenCalled();
281
+ expect(sendMessageMock).toHaveBeenCalledWith(
282
+ undefined,
283
+ "+12125550100",
284
+ "test message",
285
+ {
286
+ subject: undefined,
287
+ inReplyTo: undefined,
288
+ threadId: undefined,
289
+ attachments: undefined,
290
+ assistantId: "ast-alpha",
291
+ },
301
292
  );
302
- } finally {
303
- rmSync(dir, { recursive: true, force: true });
304
- }
305
- });
306
-
307
- test("records a threaded channel send in the thread's home, with the delivered thread on the row", async () => {
308
- provider = slackProvider;
309
- getConversationMock.mockImplementation(() => ({
310
- id: "home-topic",
311
- createdAt: 1700000000000,
312
- }));
313
- resolveProactiveHomeConversationMock.mockImplementation(async () => ({
314
- conversationId: "home-topic",
315
- createdNewConversation: false,
316
- }));
317
-
318
- const result = await run(
319
- {
320
- platform: "slack",
321
- conversation_id: "C123",
322
- thread_id: "1700000000.000009",
323
- text: "hello from A",
324
- },
325
- {
326
- workingDir: "/tmp",
327
- conversationId: "conv-A",
328
- assistantId: "ast-1",
329
- trustClass: "guardian" as const,
330
- },
331
- );
332
-
333
- expect(result.isError).toBe(false);
334
- expect(
335
- resolveProactiveHomeConversationMock.mock.calls[0]![0],
336
- ).toMatchObject({
337
- sourceChannel: "slack",
338
- externalChatId: "C123",
339
- threadId: "1700000000.000009",
340
- });
341
- expect(recordDeliveredChannelPostMock.mock.calls[0]![0]).toMatchObject({
342
- conversationId: "home-topic",
343
- externalChatId: "C123",
344
- threadId: "1700000000.000009",
345
- providerMessageId: "1700000000.000100",
346
293
  });
347
- });
348
-
349
- test("records a channel send in the chat's home conversation once the provider has it", async () => {
350
- provider = telegramProvider;
351
- getConversationMock.mockImplementation(() => ({
352
- id: "home-1",
353
- createdAt: 1700000000000,
354
- }));
355
-
356
- const result = await run(
357
- {
358
- platform: "telegram",
359
- conversation_id: "123456789",
360
- text: "hello from A",
361
- },
362
- {
363
- workingDir: "/tmp",
364
- conversationId: "conv-A",
365
- assistantId: "ast-1",
366
- trustClass: "guardian" as const,
367
- },
368
- );
369
-
370
- expect(result.isError).toBe(false);
371
- expect(resolveProactiveHomeConversationMock).toHaveBeenCalledTimes(1);
372
- expect(
373
- resolveProactiveHomeConversationMock.mock.calls[0]![0],
374
- ).toMatchObject({
375
- sourceChannel: "telegram",
376
- externalChatId: "123456789",
377
- });
378
- expect(recordDeliveredChannelPostMock).toHaveBeenCalledWith({
379
- conversationId: "home-1",
380
- channel: "telegram",
381
- externalChatId: "123456789",
382
- text: "hello from A",
383
- providerMessageId: "msg-1",
384
- crossPostedFrom: "conv-A",
385
- });
386
- expect(syncMessageToDiskMock).toHaveBeenCalledWith(
387
- "home-1",
388
- "row-1",
389
- 1700000000000,
390
- );
391
- });
392
-
393
- test("records nothing when the home conversation is the sender", async () => {
394
- provider = telegramProvider;
395
- resolveProactiveHomeConversationMock.mockImplementation(async () => ({
396
- conversationId: "conv-A",
397
- createdNewConversation: false,
398
- }));
399
-
400
- await run(
401
- {
402
- platform: "telegram",
403
- conversation_id: "123456789",
404
- text: "hello",
405
- },
406
- {
407
- workingDir: "/tmp",
408
- conversationId: "conv-A",
409
- assistantId: "ast-1",
410
- trustClass: "guardian" as const,
411
- },
412
- );
413
294
 
414
- expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
415
- });
416
-
417
- test("records nothing when the post landed in the turn's own thread, even though the home is elsewhere", async () => {
418
- // A Slack agent-style DM keys one conversation per thread. Its home
419
- // resolves to the chat's notification conversation, so the home test
420
- // alone would record this send as a cross-post away from the thread it
421
- // was made in. The turn's own snapshot (channel, chat, thread) decides
422
- // instead, against the thread the provider says it delivered into.
423
- provider = slackProvider;
424
-
425
- await run(
426
- {
427
- platform: "slack",
428
- conversation_id: "D0123456789",
429
- thread_id: "1700000000.000001",
430
- text: "hello",
431
- },
432
- {
433
- workingDir: "/tmp",
434
- conversationId: "conv-A",
435
- assistantId: "ast-1",
436
- trustClass: "guardian" as const,
437
- executionChannel: "slack",
438
- requesterChatId: "D0123456789",
439
- sourceThreadId: "1700000000.000001",
440
- },
441
- );
442
-
443
- expect(resolveProactiveHomeConversationMock).not.toHaveBeenCalled();
444
- expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
445
- });
446
-
447
- test("records a send into the turn's chat but a different thread as a cross-post", async () => {
448
- provider = slackProvider;
295
+ test("passes threadId to provider when replying", async () => {
296
+ const result = await run(
297
+ {
298
+ platform: "phone",
299
+ conversation_id: "conv-1",
300
+ text: "reply text",
301
+ thread_id: "thread-abc",
302
+ },
303
+ {
304
+ workingDir: "/tmp",
305
+ conversationId: "conv-1",
306
+ assistantId: "ast-alpha",
307
+ trustClass: "guardian" as const,
308
+ },
309
+ );
449
310
 
450
- await run(
451
- {
452
- platform: "slack",
453
- conversation_id: "D0123456789",
454
- thread_id: "1700000000.000002",
455
- text: "hello",
456
- },
457
- {
458
- workingDir: "/tmp",
459
- conversationId: "conv-A",
460
- assistantId: "ast-1",
461
- trustClass: "guardian" as const,
462
- executionChannel: "slack",
463
- requesterChatId: "D0123456789",
464
- sourceThreadId: "1700000000.000001",
465
- },
466
- );
467
-
468
- expect(resolveProactiveHomeConversationMock).toHaveBeenCalledTimes(1);
469
- expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
470
- });
311
+ expect(result.isError).toBe(false);
312
+ expect(sendMessageMock).toHaveBeenCalledWith(
313
+ undefined,
314
+ "conv-1",
315
+ "reply text",
316
+ {
317
+ subject: undefined,
318
+ inReplyTo: undefined,
319
+ threadId: "thread-abc",
320
+ attachments: undefined,
321
+ assistantId: "ast-alpha",
322
+ },
323
+ );
324
+ });
471
325
 
472
- test("a thread-less delivery never matches a turn that arrived in a thread", async () => {
473
- provider = slackProvider;
326
+ test("rejects attachments on platforms that can't carry them", async () => {
327
+ const result = await run(
328
+ {
329
+ platform: "phone",
330
+ conversation_id: "+12025550142",
331
+ text: "with a file",
332
+ attachment_paths: ["/tmp/does-not-matter.pdf"],
333
+ },
334
+ {
335
+ workingDir: "/tmp",
336
+ conversationId: "conv-1",
337
+ assistantId: "ast-alpha",
338
+ trustClass: "guardian" as const,
339
+ },
340
+ );
474
341
 
475
- await run(
476
- {
477
- platform: "slack",
478
- conversation_id: "D0123456789",
479
- text: "hello",
480
- },
481
- {
482
- workingDir: "/tmp",
483
- conversationId: "conv-A",
484
- assistantId: "ast-1",
485
- trustClass: "guardian" as const,
486
- executionChannel: "slack",
487
- requesterChatId: "D0123456789",
488
- sourceThreadId: "1700000000.000001",
489
- },
490
- );
342
+ expect(result.isError).toBe(true);
343
+ expect(result.content).toContain("Gmail and Outlook");
344
+ expect(sendMessageMock).not.toHaveBeenCalled();
345
+ });
491
346
 
492
- expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
347
+ test("a provider with neither a send nor a transport is refused", async () => {
348
+ provider = { ...makeProvider("fax", "Fax"), sendMessage: undefined };
349
+ const result = await run(
350
+ { platform: "fax", conversation_id: "1", text: "hi" },
351
+ turn,
352
+ );
353
+ expect(result.isError).toBe(true);
354
+ expect(result.content).toContain("Fax cannot send from here");
355
+ expect(sendChannelTextMock).not.toHaveBeenCalled();
356
+ });
493
357
  });
494
358
 
495
- test("decides by where the provider delivered, not by the thread requested", async () => {
496
- // Telegram's provider ignores a requested thread and reports none, so a
497
- // turn that arrived in a topic and asked for that topic still gets a
498
- // post in the thread-less chat; that post is recorded, not suppressed.
499
- provider = telegramProvider;
500
-
501
- await run(
502
- {
503
- platform: "telegram",
504
- conversation_id: "123456789",
505
- thread_id: "777",
506
- text: "hello",
507
- },
508
- {
509
- workingDir: "/tmp",
510
- conversationId: "conv-A",
511
- assistantId: "ast-1",
512
- trustClass: "guardian" as const,
513
- executionChannel: "telegram",
514
- requesterChatId: "123456789",
515
- sourceThreadId: "777",
516
- },
517
- );
359
+ describe("Outlook drafts", () => {
360
+ test("reads and forwards attachments onto an Outlook draft", async () => {
361
+ const dir = mkdtempSync(join(tmpdir(), "msg-send-att-"));
362
+ const filePath = join(dir, "report.pdf");
363
+ writeFileSync(filePath, "pdf-bytes");
364
+ provider = outlookProvider;
365
+ connection = {
366
+ id: "outlook-conn-1",
367
+ provider: "outlook",
368
+ } as OAuthConnection;
518
369
 
519
- expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
520
- });
370
+ try {
371
+ const result = await run(
372
+ {
373
+ platform: "outlook",
374
+ conversation_id: "user@example.com",
375
+ text: "see attached",
376
+ subject: "Docs",
377
+ attachment_paths: [filePath],
378
+ },
379
+ {
380
+ workingDir: "/tmp",
381
+ conversationId: "conv-1",
382
+ assistantId: "ast-alpha",
383
+ trustClass: "guardian" as const,
384
+ },
385
+ );
386
+
387
+ expect(result.isError).toBe(false);
388
+ expect(result.content).toContain("Outlook draft created");
389
+ expect(result.content).toContain("Draft ID: outlook-draft-1");
390
+ expect(sendMessageMock).not.toHaveBeenCalled();
391
+ expect(mockOutlookCreateDraft).toHaveBeenCalledTimes(1);
392
+ expect(mockOutlookCreateDraft).toHaveBeenCalledWith(
393
+ connection,
394
+ expect.objectContaining({
395
+ subject: "Docs",
396
+ body: { contentType: "text", content: "see attached" },
397
+ toRecipients: [{ emailAddress: { address: "user@example.com" } }],
398
+ attachments: [expect.objectContaining({ name: "report.pdf" })],
399
+ }),
400
+ );
401
+ } finally {
402
+ rmSync(dir, { recursive: true, force: true });
403
+ }
404
+ });
521
405
 
522
- test("a turn that arrived through no channel falls back to the home comparison", async () => {
523
- provider = slackProvider;
524
- resolveProactiveHomeConversationMock.mockImplementation(async () => ({
525
- conversationId: "conv-A",
526
- createdNewConversation: false,
527
- }));
528
-
529
- await run(
530
- {
531
- platform: "slack",
532
- conversation_id: "D0123456789",
533
- text: "hello",
534
- },
535
- {
536
- workingDir: "/tmp",
537
- conversationId: "conv-A",
538
- assistantId: "ast-1",
539
- trustClass: "guardian" as const,
540
- },
541
- );
406
+ test("creates an Outlook draft without a To address when conversation_id is not an email", async () => {
407
+ provider = outlookProvider;
408
+ connection = {
409
+ id: "outlook-conn-1",
410
+ provider: "outlook",
411
+ } as OAuthConnection;
542
412
 
543
- expect(resolveProactiveHomeConversationMock).toHaveBeenCalledTimes(1);
544
- expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
545
- });
413
+ const result = await run(
414
+ {
415
+ platform: "outlook",
416
+ conversation_id: "drafts",
417
+ text: "Hey team, here is the update.",
418
+ subject: "Team update",
419
+ },
420
+ {
421
+ workingDir: "/tmp",
422
+ conversationId: "conv-1",
423
+ assistantId: "ast-alpha",
424
+ trustClass: "guardian" as const,
425
+ },
426
+ );
546
427
 
547
- test("records nothing for a provider that is not a channel", async () => {
548
- provider = outlookProvider;
549
- connection = {
550
- id: "outlook-conn-1",
551
- provider: "outlook",
552
- } as OAuthConnection;
553
-
554
- await run(
555
- {
556
- platform: "outlook",
557
- conversation_id: "user@example.com",
558
- text: "hello",
559
- },
560
- {
561
- workingDir: "/tmp",
562
- conversationId: "conv-A",
563
- assistantId: "ast-1",
564
- trustClass: "guardian" as const,
565
- },
566
- );
428
+ expect(result.isError).toBe(false);
429
+ expect(result.content).toContain("Outlook draft created");
430
+ expect(result.content).toContain("No recipient set");
431
+ expect(result.content).toContain("Open it:");
432
+ expect(sendMessageMock).not.toHaveBeenCalled();
433
+ expect(mockOutlookCreateDraft).toHaveBeenCalledWith(
434
+ connection,
435
+ expect.objectContaining({
436
+ subject: "Team update",
437
+ body: {
438
+ contentType: "text",
439
+ content: "Hey team, here is the update.",
440
+ },
441
+ }),
442
+ );
443
+ const draftArg = mockOutlookCreateDraft.mock.calls[0][1];
444
+ expect(draftArg.toRecipients).toBeUndefined();
445
+ });
567
446
 
568
- expect(resolveProactiveHomeConversationMock).not.toHaveBeenCalled();
569
- expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
570
- });
447
+ test("creates an Outlook reply draft instead of sending when in_reply_to is set", async () => {
448
+ provider = outlookProvider;
449
+ connection = {
450
+ id: "outlook-conn-1",
451
+ provider: "outlook",
452
+ } as OAuthConnection;
571
453
 
572
- test("creates an Outlook draft without a To address when conversation_id is not an email", async () => {
573
- provider = outlookProvider;
574
- connection = {
575
- id: "outlook-conn-1",
576
- provider: "outlook",
577
- } as OAuthConnection;
578
-
579
- const result = await run(
580
- {
581
- platform: "outlook",
582
- conversation_id: "drafts",
583
- text: "Hey team, here is the update.",
584
- subject: "Team update",
585
- },
586
- {
587
- workingDir: "/tmp",
588
- conversationId: "conv-1",
589
- assistantId: "ast-alpha",
590
- trustClass: "guardian" as const,
591
- },
592
- );
593
-
594
- expect(result.isError).toBe(false);
595
- expect(result.content).toContain("Outlook draft created");
596
- expect(result.content).toContain("No recipient set");
597
- expect(result.content).toContain("Open it:");
598
- expect(sendMessageMock).not.toHaveBeenCalled();
599
- expect(mockOutlookCreateDraft).toHaveBeenCalledWith(
600
- connection,
601
- expect.objectContaining({
602
- subject: "Team update",
603
- body: {
604
- contentType: "text",
605
- content: "Hey team, here is the update.",
454
+ const result = await run(
455
+ {
456
+ platform: "outlook",
457
+ conversation_id: "user@example.com",
458
+ text: "Thanks, that works.",
459
+ in_reply_to: "AAMk-original",
606
460
  },
607
- }),
608
- );
609
- const draftArg = mockOutlookCreateDraft.mock.calls[0][1];
610
- expect(draftArg.toRecipients).toBeUndefined();
611
- });
612
-
613
- test("creates an Outlook reply draft instead of sending when in_reply_to is set", async () => {
614
- provider = outlookProvider;
615
- connection = {
616
- id: "outlook-conn-1",
617
- provider: "outlook",
618
- } as OAuthConnection;
619
-
620
- const result = await run(
621
- {
622
- platform: "outlook",
623
- conversation_id: "user@example.com",
624
- text: "Thanks, that works.",
625
- in_reply_to: "AAMk-original",
626
- },
627
- {
628
- workingDir: "/tmp",
629
- conversationId: "conv-1",
630
- assistantId: "ast-alpha",
631
- trustClass: "guardian" as const,
632
- },
633
- );
634
-
635
- expect(result.isError).toBe(false);
636
- expect(mockOutlookCreateReplyDraft).toHaveBeenCalledWith(
637
- connection,
638
- "AAMk-original",
639
- "Thanks, that works.",
640
- );
641
- expect(sendMessageMock).not.toHaveBeenCalled();
642
- expect(result.content).toContain("outlook-reply-draft-1");
643
- });
461
+ {
462
+ workingDir: "/tmp",
463
+ conversationId: "conv-1",
464
+ assistantId: "ast-alpha",
465
+ trustClass: "guardian" as const,
466
+ },
467
+ );
644
468
 
645
- test("errors when Outlook is not connected", async () => {
646
- provider = outlookProvider;
647
- connection = undefined;
469
+ expect(result.isError).toBe(false);
470
+ expect(mockOutlookCreateReplyDraft).toHaveBeenCalledWith(
471
+ connection,
472
+ "AAMk-original",
473
+ "Thanks, that works.",
474
+ );
475
+ expect(sendMessageMock).not.toHaveBeenCalled();
476
+ expect(result.content).toContain("outlook-reply-draft-1");
477
+ });
648
478
 
649
- const result = await run(
650
- {
651
- platform: "outlook",
652
- conversation_id: "user@example.com",
653
- text: "hello",
654
- },
655
- {
656
- workingDir: "/tmp",
657
- conversationId: "conv-1",
658
- assistantId: "ast-alpha",
659
- trustClass: "guardian" as const,
660
- },
661
- );
479
+ test("errors when Outlook is not connected", async () => {
480
+ provider = outlookProvider;
481
+ connection = undefined;
662
482
 
663
- expect(result.isError).toBe(true);
664
- expect(result.content).toContain("OAuth connection");
665
- expect(mockOutlookCreateDraft).not.toHaveBeenCalled();
666
- });
483
+ const result = await run(
484
+ {
485
+ platform: "outlook",
486
+ conversation_id: "user@example.com",
487
+ text: "hello",
488
+ },
489
+ {
490
+ workingDir: "/tmp",
491
+ conversationId: "conv-1",
492
+ assistantId: "ast-alpha",
493
+ trustClass: "guardian" as const,
494
+ },
495
+ );
667
496
 
668
- test("a record failure does not fail the send", async () => {
669
- provider = telegramProvider;
670
- getConversationMock.mockImplementation(() => ({
671
- id: "home-1",
672
- createdAt: 1700000000000,
673
- }));
674
- recordDeliveredChannelPostMock.mockImplementation(async () => {
675
- throw new Error("DB write failed");
497
+ expect(result.isError).toBe(true);
498
+ expect(result.content).toContain("OAuth connection");
499
+ expect(mockOutlookCreateDraft).not.toHaveBeenCalled();
676
500
  });
677
-
678
- const result = await run(
679
- {
680
- platform: "telegram",
681
- conversation_id: "123456789",
682
- text: "hello",
683
- },
684
- {
685
- workingDir: "/tmp",
686
- conversationId: "conv-A",
687
- assistantId: "ast-1",
688
- trustClass: "guardian" as const,
689
- },
690
- );
691
-
692
- expect(result.isError).toBe(false);
693
- expect(result.content).toContain("Message sent");
694
501
  });
695
502
 
696
503
  /**