@vellumai/assistant 0.11.7 → 0.11.8-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 (446) hide show
  1. package/ARCHITECTURE.md +10 -10
  2. package/Dockerfile +1 -0
  3. package/docs/architecture/security.md +1 -1
  4. package/docs/error-handling.md +1 -2
  5. package/docs/guardian-request-flow.md +51 -5
  6. package/docs/trusted-contact-access.md +13 -12
  7. package/knip.json +1 -0
  8. package/node_modules/@vellumai/avatar-catalog/package.json +19 -0
  9. package/{src/avatar/character-components.ts → node_modules/@vellumai/avatar-catalog/src/catalog.ts} +12 -44
  10. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +19 -0
  11. package/node_modules/@vellumai/avatar-catalog/src/index.ts +25 -0
  12. package/node_modules/@vellumai/avatar-catalog/src/types.ts +47 -0
  13. package/node_modules/@vellumai/avatar-catalog/tsconfig.json +20 -0
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  18. package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +43 -12
  19. package/node_modules/@vellumai/environments/package.json +2 -1
  20. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  21. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  24. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  25. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  26. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
  27. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
  28. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
  29. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
  30. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
  31. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
  32. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
  33. package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
  34. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
  35. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  36. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  37. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  38. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  39. package/openapi.yaml +481 -9
  40. package/package.json +3 -1
  41. package/scripts/sync-llm-catalog.ts +3 -0
  42. package/src/__tests__/agent-loop-callsite-precedence.test.ts +16 -0
  43. package/src/__tests__/agent-loop-compacted-history-transform.test.ts +304 -0
  44. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +3 -0
  45. package/src/__tests__/agent-wake-override-profile.test.ts +3 -0
  46. package/src/__tests__/anthropic-provider.test.ts +56 -0
  47. package/src/__tests__/approval-cascade.test.ts +0 -13
  48. package/src/__tests__/attachments-store.test.ts +117 -6
  49. package/src/__tests__/attachments.test.ts +122 -0
  50. package/src/__tests__/bundled-asset.test.ts +22 -1
  51. package/src/__tests__/channel-approval.test.ts +14 -7
  52. package/src/__tests__/channel-approvals.test.ts +3 -3
  53. package/src/__tests__/channel-delivery-store.test.ts +58 -0
  54. package/src/__tests__/channel-inbound-disk-pressure.test.ts +1 -0
  55. package/src/__tests__/channel-invite-transport.test.ts +1 -0
  56. package/src/__tests__/channel-readiness-slack-remote.test.ts +3 -3
  57. package/src/__tests__/compaction-events.test.ts +0 -12
  58. package/src/__tests__/compactor-call-site-logging.test.ts +7 -1
  59. package/src/__tests__/compactor-retained-image-validation.test.ts +15 -0
  60. package/src/__tests__/config-schema.test.ts +35 -0
  61. package/src/__tests__/contacts-write.test.ts +141 -3
  62. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -13
  63. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -12
  64. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -13
  65. package/src/__tests__/conversation-agent-loop.test.ts +95 -13
  66. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -13
  67. package/src/__tests__/conversation-load-history-repair.test.ts +418 -0
  68. package/src/__tests__/conversation-media-retry.test.ts +33 -2
  69. package/src/__tests__/conversation-pairing.test.ts +54 -0
  70. package/src/__tests__/conversation-process-callsite.test.ts +0 -12
  71. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -13
  72. package/src/__tests__/conversation-queue.test.ts +4 -13
  73. package/src/__tests__/conversation-retry-route.test.ts +5 -0
  74. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  75. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  76. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  77. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  78. package/src/__tests__/conversation-sight-frames.test.ts +615 -0
  79. package/src/__tests__/conversation-slash-queue.test.ts +0 -13
  80. package/src/__tests__/conversation-slash-unknown.test.ts +0 -13
  81. package/src/__tests__/conversation-speed-override.test.ts +0 -12
  82. package/src/__tests__/conversation-store-ephemeral.test.ts +0 -12
  83. package/src/__tests__/conversation-summarize-route.test.ts +5 -0
  84. package/src/__tests__/conversation-summarize-up-to.test.ts +102 -0
  85. package/src/__tests__/conversation-wait-for-idle.test.ts +0 -12
  86. package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -5
  87. package/src/__tests__/conversation-workspace-injection.test.ts +0 -17
  88. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -17
  89. package/src/__tests__/credential-health-service.test.ts +45 -0
  90. package/src/__tests__/credential-record-retire.test.ts +256 -0
  91. package/src/__tests__/credential-record-write-through.test.ts +8 -4
  92. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  93. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  94. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  95. package/src/__tests__/delete-propagation.test.ts +121 -3
  96. package/src/__tests__/evict-conversations-for-reload.test.ts +46 -6
  97. package/src/__tests__/file-ops-service.test.ts +8 -3
  98. package/src/__tests__/gemini-provider.test.ts +64 -0
  99. package/src/__tests__/guardian-action-service-scope.test.ts +118 -0
  100. package/src/__tests__/guardian-card-withdrawal.test.ts +299 -5
  101. package/src/__tests__/guardian-expiry-notifier.test.ts +117 -13
  102. package/src/__tests__/guardian-gateway-sim.test.ts +119 -0
  103. package/src/__tests__/guardian-gateway-sim.ts +56 -20
  104. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +6 -0
  105. package/src/__tests__/guardian-request-status-sync.test.ts +162 -0
  106. package/src/__tests__/guardian-wait-controller.test.ts +6 -0
  107. package/src/__tests__/headless-browser-mode.test.ts +27 -0
  108. package/src/__tests__/helpers/mock-conversation.ts +2 -0
  109. package/src/__tests__/list-messages-system-card.test.ts +70 -0
  110. package/src/__tests__/llm-catalog-parity.test.ts +2 -0
  111. package/src/__tests__/managed-profile-guard.test.ts +2 -2
  112. package/src/__tests__/media-resolve-image-validation.test.ts +106 -0
  113. package/src/__tests__/media-stream-server-integration.test.ts +6 -0
  114. package/src/__tests__/non-member-access-request.test.ts +12 -0
  115. package/src/__tests__/notification-discord-adapter.test.ts +76 -4
  116. package/src/__tests__/oauth-commands-routes.test.ts +21 -0
  117. package/src/__tests__/openai-provider.test.ts +63 -0
  118. package/src/__tests__/outbound-slack-persistence.test.ts +114 -2
  119. package/src/__tests__/persist-unsendable-image-downscale.test.ts +63 -1
  120. package/src/__tests__/persist-unsendable-image.test.ts +19 -0
  121. package/src/__tests__/plugin-app-serve-routes.test.ts +2 -2
  122. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  123. package/src/__tests__/process-message-background-slack.test.ts +1 -1
  124. package/src/__tests__/process-message-display-content.test.ts +1 -1
  125. package/src/__tests__/processing-flag-persist-failure.test.ts +0 -5
  126. package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +0 -17
  127. package/src/__tests__/qdrant-manager.test.ts +6 -0
  128. package/src/__tests__/reaction-persistence.test.ts +58 -62
  129. package/src/__tests__/retry-thinking-adaptive-only.test.ts +67 -0
  130. package/src/__tests__/server-history-render.test.ts +22 -0
  131. package/src/__tests__/sight-frame-retention.test.ts +362 -0
  132. package/src/__tests__/slack-reaction-approvals.test.ts +44 -24
  133. package/src/__tests__/slack-reaction-guardian-approval.test.ts +29 -1
  134. package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
  135. package/src/__tests__/subagent-manager-notify.test.ts +2 -0
  136. package/src/__tests__/subagent-notify-parent.test.ts +2 -0
  137. package/src/__tests__/subagent-notify-stale-rebuild.test.ts +85 -0
  138. package/src/__tests__/subagent-tool-gate-mode.test.ts +4 -4
  139. package/src/__tests__/terminal-tools.test.ts +9 -9
  140. package/src/__tests__/tool-result-spool.test.ts +5 -8
  141. package/src/__tests__/usage-attribution.test.ts +26 -0
  142. package/src/acp/__tests__/acp-auth-marker-store.test.ts +191 -0
  143. package/src/acp/__tests__/acp-claude-oauth.test.ts +262 -0
  144. package/src/acp/__tests__/acp-connect-card-state.test.ts +17 -0
  145. package/src/acp/__tests__/helpers/acp-history-db.ts +10 -3
  146. package/src/acp/acp-auth-marker-store.ts +196 -0
  147. package/src/acp/acp-claude-oauth.ts +154 -4
  148. package/src/acp/acp-connect-card-state.ts +49 -8
  149. package/src/acp/agent-process.ts +1 -0
  150. package/src/acp/auto-install.ts +1 -0
  151. package/src/acp/client-handler.ts +1 -0
  152. package/src/acp/prepare-agent-env.ts +79 -1
  153. package/src/acp/resolve-agent.ts +12 -0
  154. package/src/acp/session-manager.test.ts +183 -0
  155. package/src/acp/session-manager.ts +110 -6
  156. package/src/acp/types.ts +17 -0
  157. package/src/agent/attachments.ts +7 -2
  158. package/src/agent/loop.ts +59 -1
  159. package/src/api/events/contact-form-closed.ts +32 -0
  160. package/src/api/events/contact-record-request.ts +63 -0
  161. package/src/api/events/contact-request.ts +10 -3
  162. package/src/api/index.ts +20 -0
  163. package/src/api/responses/conversation-message.ts +20 -0
  164. package/src/api/responses/home.ts +101 -0
  165. package/src/approvals/AGENTS.md +1 -1
  166. package/src/approvals/guardian-card-withdrawal.ts +127 -30
  167. package/src/approvals/guardian-decision-primitive.ts +9 -0
  168. package/src/approvals/guardian-expiry-notifier.ts +22 -16
  169. package/src/approvals/guardian-request-status-sync.ts +98 -0
  170. package/src/avatar/svg-compositor.ts +1 -1
  171. package/src/avatar/traits-png-sync.ts +1 -1
  172. package/src/bundler/app-compiler.ts +1 -0
  173. package/src/bundler/compiler-tools.ts +1 -0
  174. package/src/bundler/package-resolver.ts +1 -0
  175. package/src/calls/__tests__/voice-session-bridge.test.ts +183 -3
  176. package/src/calls/guardian-action-sweep.ts +6 -1
  177. package/src/calls/voice-session-bridge.ts +46 -1
  178. package/src/channels/__tests__/gateway-guardian-requests.test.ts +13 -18
  179. package/src/channels/gateway-guardian-requests.ts +37 -18
  180. package/src/cli/__tests__/contacts-record-prompt-cli.test.ts +329 -0
  181. package/src/cli/commands/__tests__/browser.test.ts +65 -1
  182. package/src/cli/commands/browser.ts +30 -10
  183. package/src/cli/commands/contacts.help.ts +173 -11
  184. package/src/cli/commands/contacts.ts +343 -4
  185. package/src/cli/commands/db/refresh.ts +1 -0
  186. package/src/cli/commands/oauth/index.help.ts +6 -3
  187. package/src/cli/commands/oauth/request.test.ts +37 -0
  188. package/src/cli/commands/oauth/request.ts +7 -6
  189. package/src/cli/lib/bundled-marketplace.json +1 -1
  190. package/src/cli/lib/install-from-github.ts +20 -19
  191. package/src/cli/lib/install-plugin-dependencies.ts +3 -5
  192. package/src/cli/lib/merge-plugin-tree.ts +1 -0
  193. package/src/cli/lib/publish-plugin.ts +4 -1
  194. package/src/config/__tests__/balanced-model-experiment.test.ts +63 -31
  195. package/src/config/__tests__/default-profile-catalog.test.ts +2 -2
  196. package/src/config/balanced-model-experiment.ts +15 -9
  197. package/src/config/bundled-skills/contacts/SKILL.md +61 -18
  198. package/src/config/default-profile-catalog.ts +12 -11
  199. package/src/config/feature-flag-registry.json +22 -13
  200. package/src/config/schema.ts +2 -0
  201. package/src/config/schemas/__tests__/live-voice.test.ts +13 -0
  202. package/src/config/schemas/live-voice.ts +7 -0
  203. package/src/config/schemas/sight.ts +45 -0
  204. package/src/config/skills.ts +2 -1
  205. package/src/contacts/__tests__/mirror-reconciler.test.ts +352 -0
  206. package/src/contacts/contact-store.ts +47 -4
  207. package/src/contacts/contacts-write.ts +5 -2
  208. package/src/contacts/mirror-reconciler.ts +210 -0
  209. package/src/contacts/mirror-tombstones.ts +42 -0
  210. package/src/contacts/notify-contacts-changed.ts +11 -2
  211. package/src/context/compactor.ts +47 -19
  212. package/src/context/tool-result-spool.ts +21 -14
  213. package/src/conversations/message-consolidation.ts +2 -2
  214. package/src/credential-health/credential-health-service.ts +37 -6
  215. package/src/daemon/conversation-agent-loop-handlers.ts +19 -2
  216. package/src/daemon/conversation-agent-loop.ts +100 -1
  217. package/src/daemon/conversation-media-retry.ts +11 -6
  218. package/src/daemon/conversation-messaging.ts +293 -30
  219. package/src/daemon/conversation-runtime-assembly.ts +67 -0
  220. package/src/daemon/conversation-sight-frames.ts +187 -0
  221. package/src/daemon/conversation-store.ts +18 -28
  222. package/src/daemon/conversation-surfaces.ts +12 -2
  223. package/src/daemon/conversation-tool-setup.ts +39 -10
  224. package/src/daemon/conversation.ts +159 -16
  225. package/src/daemon/daemon-control.ts +6 -17
  226. package/src/daemon/handlers/config-model.test.ts +1 -0
  227. package/src/daemon/handlers/conversations.ts +10 -11
  228. package/src/daemon/install-assistant-command.ts +1 -0
  229. package/src/daemon/lifecycle.ts +10 -13
  230. package/src/daemon/message-types/sync.ts +4 -0
  231. package/src/daemon/process-message.ts +2 -17
  232. package/src/daemon/reaction-history-render.test.ts +162 -0
  233. package/src/daemon/reaction-history-render.ts +115 -0
  234. package/src/daemon/reaction-record.test.ts +162 -0
  235. package/src/daemon/reaction-record.ts +83 -0
  236. package/src/daemon/shutdown-handlers.ts +0 -2
  237. package/src/daemon/summarize-boundary.ts +12 -0
  238. package/src/daemon/video-thumbnail.ts +1 -1
  239. package/src/home/feed-types.ts +4 -0
  240. package/src/home/feed-writer.ts +25 -0
  241. package/src/live-voice/__tests__/live-voice-attach-frame.test.ts +679 -0
  242. package/src/live-voice/__tests__/live-voice-attach-image.test.ts +8 -5
  243. package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
  244. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
  245. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
  246. package/src/live-voice/__tests__/live-voice-vad.test.ts +109 -0
  247. package/src/live-voice/live-voice-photo.ts +420 -65
  248. package/src/live-voice/live-voice-session.ts +311 -27
  249. package/src/live-voice/protocol.ts +147 -11
  250. package/src/messaging/provider-message-metadata.ts +7 -0
  251. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +113 -1
  252. package/src/messaging/providers/channel-transport.ts +37 -0
  253. package/src/messaging/providers/discord/api.ts +4 -4
  254. package/src/messaging/providers/discord/send.test.ts +177 -2
  255. package/src/messaging/providers/discord/send.ts +197 -10
  256. package/src/messaging/providers/discord/transport.ts +10 -0
  257. package/src/messaging/providers/index.ts +42 -0
  258. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +129 -3
  259. package/src/messaging/providers/slack/__tests__/auth.test.ts +35 -0
  260. package/src/messaging/providers/slack/__tests__/message-content.test.ts +152 -0
  261. package/src/messaging/providers/slack/adapter.ts +112 -27
  262. package/src/messaging/providers/slack/api.test.ts +10 -0
  263. package/src/messaging/providers/slack/auth.ts +32 -6
  264. package/src/messaging/providers/slack/client.ts +0 -15
  265. package/src/messaging/providers/slack/message-content.ts +137 -0
  266. package/src/messaging/providers/slack/send.test.ts +43 -2
  267. package/src/messaging/providers/slack/send.ts +18 -5
  268. package/src/messaging/providers/slack/transport.ts +10 -0
  269. package/src/messaging/providers/slack/types.ts +21 -2
  270. package/src/messaging/providers/telegram-bot/adapter.ts +5 -1
  271. package/src/messaging/providers/telegram-bot/send.test.ts +49 -2
  272. package/src/messaging/providers/telegram-bot/send.ts +41 -1
  273. package/src/messaging/providers/telegram-bot/transport.ts +10 -0
  274. package/src/messaging/providers/telegram-bot/withdraw.ts +13 -5
  275. package/src/messaging/reaction-envelopes.ts +79 -0
  276. package/src/monitoring/control.ts +2 -2
  277. package/src/monitoring/page-cache.ts +5 -1
  278. package/src/notifications/AGENTS.md +1 -1
  279. package/src/notifications/__tests__/guardian-feed-projection.test.ts +360 -0
  280. package/src/notifications/__tests__/home-feed-side-effect.test.ts +8 -0
  281. package/src/notifications/adapters/discord.ts +62 -9
  282. package/src/notifications/conversation-pairing.ts +31 -4
  283. package/src/notifications/emit-signal.ts +8 -5
  284. package/src/notifications/guardian-delivery-recorder.ts +47 -6
  285. package/src/notifications/guardian-feed-projection.ts +492 -0
  286. package/src/notifications/home-feed-side-effect.ts +59 -11
  287. package/src/oauth/byo-connection.test.ts +22 -0
  288. package/src/oauth/byo-connection.ts +11 -5
  289. package/src/oauth/connection.ts +3 -2
  290. package/src/oauth/platform-connection.test.ts +35 -0
  291. package/src/oauth/platform-connection.ts +18 -13
  292. package/src/oauth/scope-utils.ts +48 -0
  293. package/src/oauth/seed-providers.ts +12 -0
  294. package/src/outbound-proxy/certs.ts +5 -4
  295. package/src/permissions/question-guardian-request.test.ts +7 -1
  296. package/src/persistence/attachments-store.ts +134 -31
  297. package/src/persistence/conversation-crud.ts +137 -4
  298. package/src/persistence/conversation-types.ts +109 -0
  299. package/src/persistence/db-connection.ts +3 -21
  300. package/src/persistence/delivery-crud.ts +42 -12
  301. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  302. package/src/persistence/embeddings/embedding-local.ts +7 -112
  303. package/src/persistence/embeddings/qdrant-manager.ts +4 -1
  304. package/src/persistence/llm-request-log-store.ts +1 -1
  305. package/src/persistence/migrations/370-acp-session-history-auth-error-code.ts +36 -0
  306. package/src/persistence/migrations/371-acp-session-history-auth-error-credential.ts +40 -0
  307. package/src/persistence/migrations/372-create-acp-refused-credentials.ts +34 -0
  308. package/src/persistence/migrations/373-acp-auth-marker-index.ts +27 -0
  309. package/src/persistence/migrations/__tests__/370-acp-session-history-auth-error-code.test.ts +65 -0
  310. package/src/persistence/migrations/__tests__/371-acp-session-history-auth-error-credential.test.ts +68 -0
  311. package/src/persistence/migrations/__tests__/372-create-acp-refused-credentials.test.ts +64 -0
  312. package/src/persistence/migrations/__tests__/373-acp-auth-marker-index.test.ts +82 -0
  313. package/src/persistence/schema/acp.ts +25 -0
  314. package/src/persistence/steps.ts +8 -0
  315. package/src/platform/platform-patch-queue.test.ts +43 -41
  316. package/src/plugin-api/index.ts +12 -0
  317. package/src/plugins/defaults/compaction/window-manager.ts +2 -0
  318. package/src/plugins/defaults/image-recovery/recover.ts +12 -1
  319. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
  320. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +3 -0
  321. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +3 -0
  322. package/src/plugins/defaults/memory/jobs-worker.ts +1 -2
  323. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
  324. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +4 -7
  325. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
  326. package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
  327. package/src/plugins/defaults/memory/worker-control.ts +1 -2
  328. package/src/providers/content-block-schema.ts +1 -0
  329. package/src/providers/fireworks/client.ts +9 -1
  330. package/src/providers/gemini/client.ts +22 -15
  331. package/src/providers/media-resolve.ts +24 -10
  332. package/src/providers/model-catalog.ts +91 -0
  333. package/src/providers/openai/chat-completions-provider.ts +44 -5
  334. package/src/providers/retry.ts +16 -11
  335. package/src/providers/types.ts +56 -0
  336. package/src/routes/__tests__/control.test.ts +4 -3
  337. package/src/routes/control.ts +1 -1
  338. package/src/routes/route-host-client.ts +1 -1
  339. package/src/runtime/AGENTS.md +30 -12
  340. package/src/runtime/__tests__/agent-wake.test.ts +71 -6
  341. package/src/runtime/__tests__/guardian-form-registry.test.ts +242 -0
  342. package/src/runtime/access-request-helper.ts +15 -7
  343. package/src/runtime/agent-wake.ts +16 -4
  344. package/src/runtime/channel-approval-types.ts +8 -5
  345. package/src/runtime/channel-invite-transport.ts +2 -0
  346. package/src/runtime/channel-invite-transports/discord.ts +27 -0
  347. package/src/runtime/channel-readiness-service.ts +4 -4
  348. package/src/runtime/channel-reply-delivery.ts +72 -28
  349. package/src/runtime/guardian-action-service.ts +9 -5
  350. package/src/runtime/guardian-form-registry.ts +256 -0
  351. package/src/runtime/guardian-reply-router.ts +33 -14
  352. package/src/runtime/invite-instruction-generator.ts +14 -2
  353. package/src/runtime/migrations/vbundle-metadata-merge.ts +4 -7
  354. package/src/runtime/no-response.ts +13 -74
  355. package/src/runtime/routes/__tests__/acp-routes.test.ts +527 -0
  356. package/src/runtime/routes/__tests__/app-management-shared-apps.test.ts +74 -0
  357. package/src/runtime/routes/__tests__/contact-form-kinds.test.ts +22 -0
  358. package/src/runtime/routes/__tests__/contact-record-prompt-route.test.ts +303 -0
  359. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +3 -4
  360. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +6 -0
  361. package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +3 -4
  362. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +5 -6
  363. package/src/runtime/routes/acp-routes-list.test.ts +58 -0
  364. package/src/runtime/routes/acp-routes.ts +253 -47
  365. package/src/runtime/routes/app-management-routes.ts +80 -47
  366. package/src/runtime/routes/approval-routes.ts +9 -0
  367. package/src/runtime/routes/archive-utils.ts +6 -1
  368. package/src/runtime/routes/avatar-routes.ts +1 -1
  369. package/src/runtime/routes/channel-route-shared.ts +18 -20
  370. package/src/runtime/routes/contact-prompt-routes.ts +271 -105
  371. package/src/runtime/routes/conversation-routes.ts +41 -32
  372. package/src/runtime/routes/credential-prompt-routes.ts +10 -1
  373. package/src/runtime/routes/debug-bash-routes.ts +1 -0
  374. package/src/runtime/routes/guardian-expiry-sweep.ts +89 -31
  375. package/src/runtime/routes/guardian-form-routes.ts +115 -0
  376. package/src/runtime/routes/inbound-message-handler.ts +82 -29
  377. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -3
  378. package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
  379. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +6 -0
  380. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +6 -1
  381. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +50 -17
  382. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +97 -94
  383. package/src/runtime/routes/index.ts +2 -0
  384. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  385. package/src/runtime/routes/migration-routes.ts +1 -3
  386. package/src/runtime/routes/monitoring-routes.ts +0 -3
  387. package/src/runtime/routes/oauth-commands-routes.ts +18 -5
  388. package/src/runtime/routes/route-host-worker-routes.ts +1 -1
  389. package/src/runtime/routes/schedule-worker-routes.ts +1 -1
  390. package/src/runtime/tool-grant-request-helper.ts +4 -0
  391. package/src/schedule/run-script.ts +1 -0
  392. package/src/schedule/scheduler.ts +1 -1
  393. package/src/schedule/worker-control.ts +2 -2
  394. package/src/security/oauth2.ts +2 -11
  395. package/src/security/secure-keys.ts +55 -8
  396. package/src/skills/catalog-install.ts +7 -4
  397. package/src/skills/clawhub.ts +1 -0
  398. package/src/skills/inline-command-runner.ts +1 -0
  399. package/src/stt/__tests__/room-noise-floor.test.ts +108 -0
  400. package/src/stt/room-noise-floor.ts +140 -0
  401. package/src/subagent/notify.ts +49 -3
  402. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  403. package/src/tools/acp/spawn.ts +2 -2
  404. package/src/tools/browser/__tests__/browser-status.test.ts +9 -0
  405. package/src/tools/browser/browser-execution.ts +10 -43
  406. package/src/tools/browser/browser-manager.ts +1 -0
  407. package/src/tools/browser/browser-status-constants.ts +6 -5
  408. package/src/tools/browser/runtime-check.ts +2 -0
  409. package/src/tools/channel/react-to-message.test.ts +193 -0
  410. package/src/tools/channel/react-to-message.ts +159 -0
  411. package/src/tools/credentials/metadata-store.ts +115 -19
  412. package/src/tools/credentials/store.ts +0 -2
  413. package/src/tools/filesystem/read.ts +2 -2
  414. package/src/tools/host-filesystem/read.ts +2 -2
  415. package/src/tools/host-terminal/host-shell.test.ts +24 -0
  416. package/src/tools/host-terminal/host-shell.ts +11 -38
  417. package/src/tools/shared/filesystem/file-ops-service.ts +10 -7
  418. package/src/tools/shared/shell-output.test.ts +104 -0
  419. package/src/tools/shared/shell-output.ts +112 -39
  420. package/src/tools/skills/sandbox-runner.ts +1 -0
  421. package/src/tools/system/request-permission.test.ts +34 -0
  422. package/src/tools/system/request-permission.ts +29 -5
  423. package/src/tools/terminal/shell.test.ts +63 -0
  424. package/src/tools/terminal/shell.ts +12 -44
  425. package/src/tools/tool-input-schemas.ts +2 -0
  426. package/src/tools/tool-manifest.ts +2 -0
  427. package/src/usage/attribution.ts +12 -4
  428. package/src/util/__tests__/worker-ownership.test.ts +77 -0
  429. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  430. package/src/util/bun-runtime.ts +44 -13
  431. package/src/util/bundled-asset.ts +9 -1
  432. package/src/util/disk-usage.ts +1 -0
  433. package/src/util/file-use.ts +4 -1
  434. package/src/util/guardian-form-timeouts.ts +50 -0
  435. package/src/util/image-conversion.ts +1 -0
  436. package/src/util/oauth-request-body.test.ts +59 -0
  437. package/src/util/oauth-request-body.ts +56 -5
  438. package/src/util/platform.ts +69 -73
  439. package/src/util/process-table.ts +16 -5
  440. package/src/util/read-stdin.ts +5 -0
  441. package/src/util/spawn.ts +1 -0
  442. package/src/util/sqlite3-runtime.ts +25 -5
  443. package/src/util/worker-ownership.ts +193 -0
  444. package/src/util/worker-process.ts +375 -37
  445. package/src/workspace/git-service.ts +4 -12
  446. package/src/workspace/migrations/010-app-dir-rename.ts +1 -0
@@ -8,6 +8,8 @@ const LIVE_VOICE_CLIENT_FRAME_TYPES = [
8
8
  "end",
9
9
  "update_config",
10
10
  "attach_image",
11
+ "attach_frame",
12
+ "sight_frame",
11
13
  "text",
12
14
  ] as const;
13
15
 
@@ -194,18 +196,89 @@ export interface LiveVoiceClientUpdateConfigFrame {
194
196
  * caps size, and stores the blob, and routing an image through this socket
195
197
  * would duplicate all of it on a transport tuned for 50 ms audio frames.
196
198
  *
197
- * The id is *parked*, not dispatched. It rides the next turn's own user
198
- * message (see {@link LiveVoiceSession}'s pending-attachment handling), so the
199
- * photo and the words spoken about it are one message rather than two, which
200
- * is what lets "what's this?" resolve, and what keeps the image attached to the
201
- * newest user message, the only one a context-overflow retry preserves media on
202
- * (`conversation-media-retry.ts`).
199
+ * The id is persisted the moment it arrives, as a standalone user message that
200
+ * runs no turn (see `live-voice-photo.ts`). A snap the user watched themselves
201
+ * take has to show up in the conversation whether or not they ever speak about
202
+ * it, and landing it in history immediately is what makes shutter-then-speak
203
+ * and speak-then-shutter answer the same way.
204
+ *
205
+ * Deliberately not {@link LiveVoiceClientAttachFrameFrame}, which parks its id
206
+ * for the next spoken turn instead. The two frames look alike on the wire and
207
+ * answer opposite questions: a deliberate snap must appear now; an ambient
208
+ * camera frame is worthless without the words that follow it.
203
209
  */
204
210
  export interface LiveVoiceClientAttachImageFrame {
205
211
  readonly type: "attach_image";
206
212
  readonly attachmentId: string;
207
213
  }
208
214
 
215
+ /**
216
+ * An ambient camera frame the client picked for the assistant to see, already
217
+ * uploaded over the normal attachment route, and sent here as an id alone for
218
+ * the same reason {@link LiveVoiceClientAttachImageFrame} sends one.
219
+ *
220
+ * The id is *parked* on the session rather than persisted. It rides the next
221
+ * spoken turn's own user message, and the slot is cleared as that turn starts,
222
+ * so a frame that arrives mid-turn belongs to the turn after it. Nothing the
223
+ * user asked for happened here: a frame nobody ever speaks over must leave no
224
+ * trace in the conversation, and a frame that does get words must be part of
225
+ * the same message as those words. Riding the newest user message is also what
226
+ * carries it through a context-overflow retry, the only message media survives
227
+ * on (`conversation-media-retry.ts`).
228
+ *
229
+ * Latest wins. A second frame overwrites the first, because what matters is
230
+ * what the camera is pointed at when the user speaks.
231
+ *
232
+ * A `null` id is the other direction: unpark, meaning the client's camera is
233
+ * no longer on and the slot must not keep answering for it. It clears the slot
234
+ * and gives the frame up, so a viewfinder the user closed leaves nothing
235
+ * staged behind it. Sending it needs no id of its own because the slot only
236
+ * ever holds one frame and only the client that parked it can be closing.
237
+ *
238
+ * `null` and a missing field are different things. An absent `attachmentId` is
239
+ * still a malformed frame, because a client that meant to unpark says so.
240
+ *
241
+ * Version skew: a build carrying the park half but not this one refuses the
242
+ * null with the attributable recoverable error, which the web client logs and
243
+ * drops. Only dev builds cut between the two changes are affected, and there
244
+ * the cost is a frame the session gives up at close instead of at unpark.
245
+ */
246
+ export interface LiveVoiceClientAttachFrameFrame {
247
+ readonly type: "attach_frame";
248
+ readonly attachmentId: string | null;
249
+ }
250
+
251
+ /**
252
+ * An ambient camera frame the client's gate kept, already uploaded over the
253
+ * normal attachment route and sent here as an id alone for the same reason
254
+ * {@link LiveVoiceClientAttachImageFrame} sends one.
255
+ *
256
+ * Three client frames carry an image and they mean three different things:
257
+ *
258
+ * - `attach_image` is a photo the user deliberately snapped. It persists
259
+ * standalone the moment it arrives and drives the client's receipt strip.
260
+ * - `attach_frame` parks one frame on the session for the next spoken turn's
261
+ * own user message to carry, and persists nothing by itself.
262
+ * - `sight_frame` persists an ambient keep immediately as its own user
263
+ * message, tagged as a camera frame, and runs no turn.
264
+ *
265
+ * A keep is tagged so retention can age it out of the model's context (newest
266
+ * few stay images, older ones become timestamped stubs) while the transcript
267
+ * keeps every one of them. The transcript is therefore the record of what the
268
+ * assistant saw, and the model correlates a frame with speech by adjacency
269
+ * rather than by any attachment to a turn.
270
+ *
271
+ * A frame the attachment store does not know is refused with `frameType:
272
+ * "sight_frame"` and `recoverable: true`: the session is fine and only this
273
+ * frame failed. Attributing it is what lets the client retract the preview it
274
+ * already showed instead of filing the error with the transient transcriber
275
+ * and TTS blips that share `recoverable`.
276
+ */
277
+ export interface LiveVoiceClientSightFrameFrame {
278
+ readonly type: "sight_frame";
279
+ readonly attachmentId: string;
280
+ }
281
+
209
282
  /**
210
283
  * A user turn the client already has as text, taken without the microphone.
211
284
  *
@@ -253,6 +326,8 @@ export type LiveVoiceClientFrame =
253
326
  | LiveVoiceClientEndFrame
254
327
  | LiveVoiceClientUpdateConfigFrame
255
328
  | LiveVoiceClientAttachImageFrame
329
+ | LiveVoiceClientAttachFrameFrame
330
+ | LiveVoiceClientSightFrameFrame
256
331
  | LiveVoiceClientTextTurnFrame;
257
332
 
258
333
  interface LiveVoiceBinaryAudioFrame {
@@ -513,11 +588,12 @@ export interface LiveVoiceErrorServerFrame extends LiveVoiceServerFrameBase {
513
588
  * daemons predating the field.
514
589
  *
515
590
  * It exists so an `unknown_type` is attributable. A client that sends more
516
- * than one optional frame (today: `update_config` and `attach_image`) gets
517
- * the same code for either, and without this has to assume which one was
518
- * refused. The wrong assumption is silent in both directions: settings stop
519
- * applying for a session, or a photo the user watched themselves take is
520
- * dropped with nothing said.
591
+ * than one optional frame (today: `update_config`, `attach_image`,
592
+ * `attach_frame`, `sight_frame`, and `text`) gets the same code for any of
593
+ * them, and without this has to assume which one was refused. The wrong
594
+ * assumption is silent in both directions: settings stop applying for a
595
+ * session, or a photo the user watched themselves take is dropped with
596
+ * nothing said.
521
597
  */
522
598
  readonly frameType?: string;
523
599
  /**
@@ -654,6 +730,10 @@ export function validateLiveVoiceClientFrame(
654
730
  return validateUpdateConfigFrame(value);
655
731
  case "attach_image":
656
732
  return validateAttachImageFrame(value);
733
+ case "attach_frame":
734
+ return validateAttachFrameFrame(value);
735
+ case "sight_frame":
736
+ return validateSightFrameFrame(value);
657
737
  case "text":
658
738
  return validateTextTurnFrame(value);
659
739
  }
@@ -750,6 +830,62 @@ function validateAttachImageFrame(
750
830
  };
751
831
  }
752
832
 
833
+ function validateAttachFrameFrame(
834
+ value: Record<string, unknown>,
835
+ ): LiveVoiceParseResult<LiveVoiceClientAttachFrameFrame> {
836
+ if (!("attachmentId" in value)) {
837
+ return protocolError(
838
+ "missing_required_field",
839
+ "attach_frame frame is missing required field attachmentId",
840
+ "attachmentId",
841
+ "attach_frame",
842
+ );
843
+ }
844
+
845
+ if (value.attachmentId !== null && !isNonEmptyString(value.attachmentId)) {
846
+ return protocolError(
847
+ "invalid_field",
848
+ "attach_frame frame field attachmentId must be a non-empty string or null",
849
+ "attachmentId",
850
+ "attach_frame",
851
+ );
852
+ }
853
+
854
+ return {
855
+ ok: true,
856
+ frame: { type: "attach_frame", attachmentId: value.attachmentId },
857
+ };
858
+ }
859
+
860
+ function validateSightFrameFrame(
861
+ value: Record<string, unknown>,
862
+ ): LiveVoiceParseResult<LiveVoiceClientSightFrameFrame> {
863
+ if (!("attachmentId" in value)) {
864
+ return protocolError(
865
+ "missing_required_field",
866
+ "sight_frame frame is missing required field attachmentId",
867
+ "attachmentId",
868
+ "sight_frame",
869
+ );
870
+ }
871
+
872
+ // No null form, unlike `attach_frame`. Nothing is staged for a keep to give
873
+ // up: it persisted on arrival, so a closing viewfinder has nothing to clear.
874
+ if (!isNonEmptyString(value.attachmentId)) {
875
+ return protocolError(
876
+ "invalid_field",
877
+ "sight_frame frame field attachmentId must be a non-empty string",
878
+ "attachmentId",
879
+ "sight_frame",
880
+ );
881
+ }
882
+
883
+ return {
884
+ ok: true,
885
+ frame: { type: "sight_frame", attachmentId: value.attachmentId },
886
+ };
887
+ }
888
+
753
889
  function validateUpdateConfigFrame(
754
890
  value: Record<string, unknown>,
755
891
  ): LiveVoiceParseResult<LiveVoiceClientUpdateConfigFrame> {
@@ -61,6 +61,13 @@ export const providerMessageMetadataSchema = z
61
61
  * reaction. The message it acts on is `reaction.targetMessageId`.
62
62
  */
63
63
  messageId: z.string().optional(),
64
+ /**
65
+ * Provider ids of the further posts this row's delivery produced beyond
66
+ * `messageId`: one stored reply split at tool boundaries or length
67
+ * limits posts several provider messages, and they all belong to this
68
+ * one row. A reaction naming any of them resolves here.
69
+ */
70
+ additionalMessageIds: z.array(z.string()).optional(),
64
71
  /**
65
72
  * Provider id of the thread this row sits in, absent when it is not in one.
66
73
  * Never synthesized from `messageId`: a value here asserts that a thread
@@ -8,7 +8,9 @@ const slack = {
8
8
  sendSlackReply: mock((..._args: unknown[]) =>
9
9
  Promise.resolve({ ts: "slack-ts" }),
10
10
  ),
11
- sendSlackReaction: mock((..._args: unknown[]) => Promise.resolve()),
11
+ sendSlackReaction: mock((..._args: unknown[]) =>
12
+ Promise.resolve({ ok: true }),
13
+ ),
12
14
  sendSlackAgentSessionStatus: mock((..._args: unknown[]) => Promise.resolve()),
13
15
  sendSlackAttachments: mock((..._args: unknown[]) =>
14
16
  Promise.resolve({ allFailed: false, failureCount: 0 }),
@@ -22,6 +24,9 @@ const slack = {
22
24
  };
23
25
  const telegram = {
24
26
  editTelegramMessage: mock((..._args: unknown[]) => Promise.resolve()),
27
+ sendTelegramReaction: mock((..._args: unknown[]) =>
28
+ Promise.resolve({ ok: true }),
29
+ ),
25
30
  sendTelegramReply: mock((..._args: unknown[]) => Promise.resolve()),
26
31
  sendTelegramRichReply: mock((..._args: unknown[]) => Promise.resolve()),
27
32
  sendTelegramTypingIndicator: mock((..._args: unknown[]) => Promise.resolve()),
@@ -46,6 +51,9 @@ const discord = {
46
51
  Promise.resolve(true),
47
52
  ),
48
53
  editDiscordMessage: mock((..._args: unknown[]) => Promise.resolve()),
54
+ sendDiscordReaction: mock((..._args: unknown[]) =>
55
+ Promise.resolve({ ok: true }),
56
+ ),
49
57
  sendDiscordAttachments: mock((..._args: unknown[]) =>
50
58
  Promise.resolve({ allFailed: false, failureCount: 0, totalCount: 0 }),
51
59
  ),
@@ -63,9 +71,11 @@ mock.module("../../../util/logger.js", () => ({
63
71
  const {
64
72
  deliverDirect,
65
73
  editChannelMessage,
74
+ sendChannelReaction,
66
75
  sendChannelStreamOp,
67
76
  setChannelActivity,
68
77
  supportsChannelActivity,
78
+ supportsChannelReaction,
69
79
  isDirectDelivery,
70
80
  getTransportForCallback,
71
81
  } = await import("../index.js");
@@ -203,6 +213,108 @@ describe("Slack sub-operation selection", () => {
203
213
  });
204
214
  });
205
215
 
216
+ describe("react dispatch", () => {
217
+ test("sendChannelReaction routes to the Slack reaction sender", async () => {
218
+ const result = await sendChannelReaction("slack", {
219
+ chatId: "C1",
220
+ messageId: "123.456",
221
+ emoji: "thumbsup",
222
+ action: "add",
223
+ });
224
+ expect(result.ok).toBe(true);
225
+ expect(slack.sendSlackReaction).toHaveBeenCalledWith(
226
+ "C1",
227
+ "thumbsup",
228
+ "123.456",
229
+ "add",
230
+ );
231
+ });
232
+
233
+ test("supportsChannelReaction follows the transport declaration", () => {
234
+ expect(supportsChannelReaction("slack")).toBe(true);
235
+ expect(supportsChannelReaction("telegram")).toBe(true);
236
+ expect(supportsChannelReaction("discord")).toBe(true);
237
+ expect(supportsChannelReaction("whatsapp")).toBe(false);
238
+ expect(supportsChannelReaction("some-plugin-channel")).toBe(false);
239
+ expect(supportsChannelReaction(undefined)).toBe(false);
240
+ });
241
+
242
+ test("sendChannelReaction routes to the Telegram reaction sender", async () => {
243
+ const result = await sendChannelReaction("telegram", {
244
+ chatId: "12345",
245
+ messageId: "678",
246
+ emoji: "👍",
247
+ action: "add",
248
+ });
249
+ expect(result.ok).toBe(true);
250
+ expect(telegram.sendTelegramReaction).toHaveBeenCalledWith(
251
+ "12345",
252
+ "👍",
253
+ "678",
254
+ "add",
255
+ );
256
+ });
257
+
258
+ test("a Discord thread reaction addresses the thread channel, not the parent", async () => {
259
+ await sendChannelReaction("discord", {
260
+ chatId: "PARENT",
261
+ threadId: "THREAD",
262
+ messageId: "M1",
263
+ emoji: "👍",
264
+ action: "add",
265
+ });
266
+ expect(discord.sendDiscordReaction).toHaveBeenCalledWith(
267
+ "THREAD",
268
+ "👍",
269
+ "M1",
270
+ "add",
271
+ );
272
+ });
273
+
274
+ test("Slack ignores the thread coordinate: chatId plus ts addresses the message", async () => {
275
+ await sendChannelReaction("slack", {
276
+ chatId: "C1",
277
+ threadId: "1716000000.000001",
278
+ messageId: "1716000000.000002",
279
+ emoji: "thumbsup",
280
+ action: "add",
281
+ });
282
+ expect(slack.sendSlackReaction).toHaveBeenCalledWith(
283
+ "C1",
284
+ "thumbsup",
285
+ "1716000000.000002",
286
+ "add",
287
+ );
288
+ });
289
+
290
+ test("sendChannelReaction routes to the Discord reaction sender", async () => {
291
+ const result = await sendChannelReaction("discord", {
292
+ chatId: "C9",
293
+ messageId: "M9",
294
+ emoji: "<:vex:12345>",
295
+ action: "remove",
296
+ });
297
+ expect(result.ok).toBe(true);
298
+ expect(discord.sendDiscordReaction).toHaveBeenCalledWith(
299
+ "C9",
300
+ "<:vex:12345>",
301
+ "M9",
302
+ "remove",
303
+ );
304
+ });
305
+
306
+ test("a channel without react resolves to nothing and sends nothing", async () => {
307
+ const result = await sendChannelReaction("whatsapp", {
308
+ chatId: "W1",
309
+ messageId: "m1",
310
+ emoji: "thumbsup",
311
+ action: "add",
312
+ });
313
+ expect(result).toEqual({ ok: true });
314
+ expect(whatsapp.sendWhatsAppReply).not.toHaveBeenCalled();
315
+ });
316
+ });
317
+
206
318
  describe("capability gating across channels", () => {
207
319
  test("Slack renders a muted edit as its own context block", async () => {
208
320
  await editChannelMessage(`${BASE}/deliver/slack`, {
@@ -71,6 +71,31 @@ export interface EditTarget {
71
71
  readonly emphasis?: "muted";
72
72
  }
73
73
 
74
+ /**
75
+ * The message an emoji reaction lands on, and what to do there.
76
+ *
77
+ * `emoji` is spelled in the channel's own vocabulary (a Slack emoji name, a
78
+ * unicode emoji elsewhere): Vellum names the operation and the channel
79
+ * decides what its argument means, the same way `EditTarget.text` renders
80
+ * per channel.
81
+ */
82
+ export interface ReactionTarget {
83
+ readonly chatId: string;
84
+ /** The target message, in the channel's own id space. */
85
+ readonly messageId: string;
86
+ /**
87
+ * Provider id of the thread the target message sits in, absent when it is
88
+ * not in one. Discord needs it: `chatId` is the delivery address (a thread
89
+ * message's parent channel), but a thread is its own channel and the
90
+ * reaction route must name the channel the message actually lives in.
91
+ * Slack and Telegram address reactions by `chatId` + `messageId` alone
92
+ * and ignore it.
93
+ */
94
+ readonly threadId?: string;
95
+ readonly emoji: string;
96
+ readonly action: "add" | "remove";
97
+ }
98
+
74
99
  /**
75
100
  * Direct outbound delivery for one channel, wrapping the channel's provider-API
76
101
  * send functions behind a uniform surface. Transports are registered statically
@@ -107,6 +132,18 @@ export interface ChannelTransport {
107
132
  target: EditTarget,
108
133
  ): Promise<ChannelDeliveryResult>;
109
134
 
135
+ /**
136
+ * Add or remove one of the assistant's own emoji reactions on a message.
137
+ *
138
+ * Takes no callback context: a reaction addresses its message absolutely
139
+ * (`chatId` + `messageId`) and carries no per-callback thread state, which
140
+ * is also what lets a producer with no callback URL, a tool running inside
141
+ * a turn, reach it. A channel whose platform has no reaction affordance
142
+ * omits the method, and the tool surface reads that omission as the
143
+ * capability's absence.
144
+ */
145
+ react?(target: ReactionTarget): Promise<ChannelDeliveryResult>;
146
+
110
147
  /**
111
148
  * Show how busy the assistant is, in whatever form the channel has.
112
149
  *
@@ -267,9 +267,9 @@ export function resetDiscordDmChannelCache(): void {
267
267
 
268
268
  /** Call a Discord REST route with a JSON body. */
269
269
  export async function callDiscordApi<T>(
270
- method: "POST" | "PATCH",
270
+ method: "POST" | "PATCH" | "PUT" | "DELETE",
271
271
  route: string,
272
- body: Record<string, unknown>,
272
+ body?: Record<string, unknown>,
273
273
  ): Promise<T | undefined> {
274
274
  const botToken = await resolveBotToken();
275
275
  return discordCall<T>(route, () =>
@@ -277,10 +277,10 @@ export async function callDiscordApi<T>(
277
277
  method,
278
278
  headers: {
279
279
  Authorization: `Bot ${botToken}`,
280
- "Content-Type": "application/json",
281
280
  "User-Agent": DISCORD_USER_AGENT,
281
+ ...(body ? { "Content-Type": "application/json" } : {}),
282
282
  },
283
- body: JSON.stringify(body),
283
+ ...(body ? { body: JSON.stringify(body) } : {}),
284
284
  signal: AbortSignal.timeout(DISCORD_DEFAULT_TIMEOUT_MS),
285
285
  }),
286
286
  );
@@ -30,8 +30,13 @@ mock.module("../../../util/logger.js", () => ({
30
30
  getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
31
31
  }));
32
32
 
33
- const { sendDiscordReply, sendDiscordAttachments, editDiscordMessage } =
34
- await import("./send.js");
33
+ const {
34
+ sendDiscordReply,
35
+ sendDiscordAttachments,
36
+ sendDiscordReaction,
37
+ editDiscordMessage,
38
+ DiscordPartialSendError,
39
+ } = await import("./send.js");
35
40
 
36
41
  const originalFetch = globalThis.fetch;
37
42
 
@@ -71,6 +76,40 @@ afterEach(() => {
71
76
  globalThis.fetch = originalFetch;
72
77
  });
73
78
 
79
+ describe("sendDiscordReaction", () => {
80
+ test("adds via PUT on the @me reaction route, emoji URL-encoded", async () => {
81
+ stubFetch(204, null);
82
+ const result = await sendDiscordReaction("C1", "👍", "M1", "add");
83
+ expect(result).toEqual({ ok: true });
84
+ expect(calls[0].method).toBe("PUT");
85
+ expect(calls[0].url).toBe(
86
+ `https://discord.com/api/v10/channels/C1/messages/M1/reactions/${encodeURIComponent("👍")}/@me`,
87
+ );
88
+ expect(calls[0].body).toBeUndefined();
89
+ });
90
+
91
+ test("a custom emoji mention form travels as bare name:id", async () => {
92
+ stubFetch(204, null);
93
+ await sendDiscordReaction("C1", "<:vex:12345>", "M1", "add");
94
+ expect(calls[0].url).toContain(
95
+ `/reactions/${encodeURIComponent("vex:12345")}/@me`,
96
+ );
97
+ });
98
+
99
+ test("remove routes as DELETE", async () => {
100
+ stubFetch(204, null);
101
+ const result = await sendDiscordReaction("C1", "👍", "M1", "remove");
102
+ expect(result).toEqual({ ok: true });
103
+ expect(calls[0].method).toBe("DELETE");
104
+ });
105
+
106
+ test("a rejection reports ok false without throwing", async () => {
107
+ stubFetch(403, { message: "Missing Permissions", code: 50013 });
108
+ const result = await sendDiscordReaction("C1", "👍", "M1", "add");
109
+ expect(result).toEqual({ ok: false });
110
+ });
111
+ });
112
+
74
113
  describe("sendDiscordReply", () => {
75
114
  test("posts to the channel's messages route with bot auth", async () => {
76
115
  await sendDiscordReply({ channelId: "C1" }, "hello");
@@ -123,6 +162,136 @@ describe("sendDiscordReply", () => {
123
162
  await sendDiscordReply({ channelId: "C1" }, " ");
124
163
  expect(calls).toHaveLength(0);
125
164
  });
165
+
166
+ test("an approval card carries its buttons on the shared apr: convention", async () => {
167
+ await sendDiscordReply({ channelId: "C1" }, "Approve?", {
168
+ requestId: "req-1",
169
+ actions: [
170
+ { id: "approve_once", label: "Approve once" },
171
+ { id: "reject", label: "Reject" },
172
+ ],
173
+ plainTextFallback: "reply approve or reject",
174
+ });
175
+
176
+ const body = JSON.parse(calls[0].body as string);
177
+ expect(body.components).toEqual([
178
+ {
179
+ type: 1,
180
+ components: [
181
+ {
182
+ type: 2,
183
+ style: 1,
184
+ label: "Approve once",
185
+ custom_id: "apr:req-1:approve_once",
186
+ },
187
+ {
188
+ type: 2,
189
+ style: 4,
190
+ label: "Reject",
191
+ custom_id: "apr:req-1:reject",
192
+ },
193
+ ],
194
+ },
195
+ ]);
196
+ });
197
+
198
+ test("buttons ride only the final chunk of a long card", async () => {
199
+ const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join("\n");
200
+ await sendDiscordReply({ channelId: "C1" }, long, {
201
+ requestId: "req-1",
202
+ actions: [{ id: "approve_once", label: "Approve once" }],
203
+ plainTextFallback: "reply approve",
204
+ });
205
+
206
+ expect(calls.length).toBeGreaterThan(1);
207
+ const withComponents = calls.filter(
208
+ (c) => JSON.parse(c.body as string).components !== undefined,
209
+ );
210
+ // The final chunk's id is the one the delivery row records, so the
211
+ // message a press arrives on is the message the row can find.
212
+ expect(withComponents).toHaveLength(1);
213
+ expect(calls.indexOf(withComponents[0])).toBe(calls.length - 1);
214
+ });
215
+
216
+ test("an action's own emphasis outranks positional styling", async () => {
217
+ await sendDiscordReply({ channelId: "C1" }, "Meet?", {
218
+ requestId: "req-1",
219
+ actions: [
220
+ { id: "accept", label: "Accept", emphasis: "secondary" },
221
+ { id: "later", label: "Later" },
222
+ ],
223
+ plainTextFallback: "reply accept or later",
224
+ });
225
+
226
+ const row = JSON.parse(calls[0].body as string).components[0];
227
+ expect(row.components[0].style).toBe(2);
228
+ expect(row.components[1].style).toBe(2);
229
+ });
230
+
231
+ test("a failure after the first chunk names the undelivered remainder", async () => {
232
+ const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join("\n");
233
+ let posts = 0;
234
+ globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
235
+ posts += 1;
236
+ calls.push({
237
+ url: String(url),
238
+ method: init?.method ?? "GET",
239
+ headers: {},
240
+ body: init?.body,
241
+ });
242
+ if (posts > 1) {
243
+ return new Response(JSON.stringify({ message: "rejected" }), {
244
+ status: 400,
245
+ headers: { "content-type": "application/json" },
246
+ });
247
+ }
248
+ return new Response(JSON.stringify({ id: "msg-1" }), {
249
+ status: 200,
250
+ headers: { "content-type": "application/json" },
251
+ });
252
+ }) as unknown as typeof fetch;
253
+
254
+ let thrown: unknown;
255
+ try {
256
+ await sendDiscordReply({ channelId: "C1" }, long);
257
+ } catch (err) {
258
+ thrown = err;
259
+ }
260
+
261
+ expect(thrown).toBeInstanceOf(DiscordPartialSendError);
262
+ const partial = thrown as InstanceType<typeof DiscordPartialSendError>;
263
+ expect(partial.chunksSent).toBe(1);
264
+ expect(partial.lastMessageId).toBe("msg-1");
265
+ // The delivered prefix plus the named remainder reassemble the text, so
266
+ // a caller completing the card sends no line twice and drops none.
267
+ const delivered = JSON.parse(calls[0].body as string).content;
268
+ expect(`${delivered}\n${partial.remainingText}`).toBe(long);
269
+ });
270
+
271
+ test("a failure on the first chunk propagates plainly, so full fallback is safe", async () => {
272
+ stubFetch(400, { message: "rejected" });
273
+
274
+ let thrown: unknown;
275
+ try {
276
+ await sendDiscordReply({ channelId: "C1" }, "short card");
277
+ } catch (err) {
278
+ thrown = err;
279
+ }
280
+
281
+ expect(thrown).toBeInstanceOf(Error);
282
+ expect(thrown).not.toBeInstanceOf(DiscordPartialSendError);
283
+ });
284
+
285
+ test("a custom_id past Discord's cap throws instead of sending a dead button", async () => {
286
+ await expect(
287
+ sendDiscordReply({ channelId: "C1" }, "Approve?", {
288
+ requestId: "r".repeat(120),
289
+ actions: [{ id: "approve_once", label: "Approve once" }],
290
+ plainTextFallback: "reply approve",
291
+ }),
292
+ ).rejects.toThrow("100-character limit");
293
+ expect(calls).toHaveLength(0);
294
+ });
126
295
  });
127
296
 
128
297
  describe("sendDiscordAttachments", () => {
@@ -183,6 +352,12 @@ describe("editDiscordMessage", () => {
183
352
  const bodyOf = (index = 0): Record<string, unknown> =>
184
353
  JSON.parse(String(calls[index]?.body)) as Record<string, unknown>;
185
354
 
355
+ test("an edit always strips components, so a settled card keeps no live buttons", async () => {
356
+ await editDiscordMessage({ channelId: "C1" }, "m1", "Approved");
357
+ const body = JSON.parse(calls[0].body as string);
358
+ expect(body.components).toEqual([]);
359
+ });
360
+
186
361
  test("patches the message rather than posting a new one", async () => {
187
362
  await editDiscordMessage({ channelId: "C1" }, "M9", "revised");
188
363