@vellumai/assistant 0.11.4 → 0.11.5-staging.2

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 (367) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +29 -4
  3. package/README.md +1 -1
  4. package/docs/architecture/memory.md +17 -5
  5. package/docs/architecture/security.md +96 -152
  6. package/docs/guardian-request-flow.md +13 -2
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  15. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
  16. package/node_modules/@vellumai/gateway-client/src/http-delivery.ts +7 -14
  17. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  18. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +29 -31
  19. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  23. package/openapi.yaml +193 -3
  24. package/package.json +1 -1
  25. package/src/__tests__/anthropic-provider.test.ts +10 -3
  26. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  27. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  28. package/src/__tests__/channel-approval-routes.test.ts +4 -0
  29. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -6
  30. package/src/__tests__/channel-reply-delivery.test.ts +26 -13
  31. package/src/__tests__/checker.test.ts +20 -314
  32. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +6 -2
  33. package/src/__tests__/client-os-metadata-persistence.test.ts +16 -0
  34. package/src/__tests__/compactor-retained-image-validation.test.ts +142 -0
  35. package/src/__tests__/config-loader-backfill.test.ts +19 -10
  36. package/src/__tests__/container-cpu-sampler.test.ts +153 -0
  37. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  38. package/src/__tests__/conversation-attachments.test.ts +0 -1
  39. package/src/__tests__/conversation-error.test.ts +17 -0
  40. package/src/__tests__/conversation-lifecycle.test.ts +20 -26
  41. package/src/__tests__/conversation-pairing.test.ts +186 -0
  42. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
  43. package/src/__tests__/credential-broker-server-use.test.ts +24 -5
  44. package/src/__tests__/credential-routes.test.ts +22 -3
  45. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  46. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  47. package/src/__tests__/default-plugin-names-guard.test.ts +12 -1
  48. package/src/__tests__/dm-backfill.test.ts +63 -0
  49. package/src/__tests__/dynamic-skill-background-guard.test.ts +0 -2
  50. package/src/__tests__/edit-propagation.test.ts +107 -4
  51. package/src/__tests__/events-client-registration.test.ts +28 -0
  52. package/src/__tests__/file-write-tool.test.ts +4 -2
  53. package/src/__tests__/guardian-prompt-notice-privacy.test.ts +133 -0
  54. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +143 -97
  55. package/src/__tests__/helpers/gateway-classify-mock.ts +27 -6
  56. package/src/__tests__/host-proxy-interface.test.ts +11 -1
  57. package/src/__tests__/host-shell-tool.test.ts +23 -4
  58. package/src/__tests__/image-conversion.test.ts +143 -1
  59. package/src/__tests__/inline-skill-load-permissions.test.ts +47 -36
  60. package/src/__tests__/input-repairs.test.ts +207 -0
  61. package/src/__tests__/live-workspace-guard.test.ts +75 -0
  62. package/src/__tests__/managed-profile-guard.test.ts +4 -2
  63. package/src/__tests__/mcp-abort-signal.test.ts +1 -1
  64. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  65. package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
  66. package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
  67. package/src/__tests__/mtime-cache.test.ts +2 -0
  68. package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
  69. package/src/__tests__/openai-provider.test.ts +22 -0
  70. package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
  71. package/src/__tests__/platform-bash-auto-approve.test.ts +0 -4
  72. package/src/__tests__/platform.test.ts +16 -1
  73. package/src/__tests__/plugin-api-resolve-credential.test.ts +22 -13
  74. package/src/__tests__/plugin-api-shim.test.ts +5 -0
  75. package/src/__tests__/plugin-api-store-credential.test.ts +268 -0
  76. package/src/__tests__/plugin-config-data-migration.test.ts +8 -1
  77. package/src/__tests__/plugin-disabled-state.test.ts +2 -0
  78. package/src/__tests__/plugin-effective-enabled-set.test.ts +55 -3
  79. package/src/__tests__/plugin-execution-context.test.ts +73 -0
  80. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  81. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -1
  82. package/src/__tests__/reaction-persistence.test.ts +200 -7
  83. package/src/__tests__/require-fresh-approval.test.ts +0 -4
  84. package/src/__tests__/resource-pressure-guard.test.ts +428 -0
  85. package/src/__tests__/resource-pressure-routes.test.ts +113 -0
  86. package/src/__tests__/risk-classification-boundary-guard.test.ts +115 -0
  87. package/src/__tests__/run-conversation-turn-persistence.test.ts +27 -1
  88. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  89. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  90. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  91. package/src/__tests__/tool-executor-lifecycle-events.test.ts +132 -5
  92. package/src/__tests__/tool-executor.test.ts +63 -38
  93. package/src/__tests__/tool-policy.test.ts +97 -1
  94. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +7 -4
  95. package/src/__tests__/user-plugin-loader.test.ts +2 -0
  96. package/src/__tests__/validate-input.test.ts +243 -7
  97. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  98. package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -0
  99. package/src/__tests__/voice-session-bridge.test.ts +42 -0
  100. package/src/acp/__tests__/acp-claude-oauth.test.ts +97 -29
  101. package/src/acp/__tests__/prepare-agent-env.test.ts +376 -153
  102. package/src/acp/acp-claude-oauth.ts +38 -21
  103. package/src/acp/prepare-agent-env.ts +146 -55
  104. package/src/agent/loop-tool-dedup.test.ts +161 -0
  105. package/src/agent/loop.ts +68 -22
  106. package/src/api/constants/app-tools.ts +34 -0
  107. package/src/api/events/resource-pressure-status-changed.ts +53 -0
  108. package/src/api/index.ts +19 -0
  109. package/src/api/responses/resource-pressure-status.ts +25 -0
  110. package/src/approvals/guardian-channel-delivery.ts +70 -6
  111. package/src/approvals/guardian-request-resolvers.ts +32 -66
  112. package/src/calls/__tests__/voice-session-bridge.test.ts +114 -0
  113. package/src/calls/voice-session-bridge.ts +60 -6
  114. package/src/channels/__tests__/message-audience.test.ts +49 -0
  115. package/src/channels/__tests__/types.test.ts +17 -3
  116. package/src/channels/message-audience.ts +40 -0
  117. package/src/channels/types.ts +16 -12
  118. package/src/cli/__tests__/catalog-search-help.test.ts +50 -0
  119. package/src/cli/commands/__tests__/channel-verification-sessions.test.ts +31 -0
  120. package/src/cli/commands/__tests__/conversations-search.test.ts +289 -0
  121. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  122. package/src/cli/commands/__tests__/inference-providers.test.ts +68 -0
  123. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  124. package/src/cli/commands/__tests__/plugins.test.ts +57 -1
  125. package/src/cli/commands/channel-verification-sessions.help.ts +11 -9
  126. package/src/cli/commands/channel-verification-sessions.ts +11 -21
  127. package/src/cli/commands/conversations.help.ts +34 -0
  128. package/src/cli/commands/conversations.ts +125 -0
  129. package/src/cli/commands/inference-providers.ts +9 -4
  130. package/src/cli/commands/inference.help.ts +9 -4
  131. package/src/cli/commands/keys.help.ts +13 -1
  132. package/src/cli/commands/keys.ts +26 -15
  133. package/src/cli/commands/memory/__tests__/memory-v2.test.ts +57 -7
  134. package/src/cli/commands/memory/index.help.ts +41 -27
  135. package/src/cli/commands/memory/index.ts +2 -0
  136. package/src/cli/commands/memory/memory-v2.ts +58 -54
  137. package/src/cli/commands/memory/memory-validate.ts +18 -0
  138. package/src/cli/commands/monitoring.ts +1 -0
  139. package/src/cli/commands/plugins.help.ts +28 -31
  140. package/src/cli/commands/plugins.ts +10 -0
  141. package/src/cli/commands/skills.help.ts +13 -16
  142. package/src/cli/commands/trust.ts +3 -14
  143. package/src/cli/lib/__tests__/install-from-github.test.ts +38 -0
  144. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +34 -0
  145. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +32 -1
  146. package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
  147. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +64 -0
  148. package/src/cli/lib/bundled-marketplace.json +13 -0
  149. package/src/cli/lib/daemon-credential-client.ts +25 -3
  150. package/src/cli/lib/install-from-github.ts +35 -24
  151. package/src/cli/lib/merge-plugin-tree.ts +22 -4
  152. package/src/cli/lib/plugin-surfaces.ts +27 -0
  153. package/src/config/__tests__/balanced-model-experiment.test.ts +7 -7
  154. package/src/config/__tests__/default-profile-catalog.test.ts +3 -3
  155. package/src/config/default-profile-catalog.ts +5 -6
  156. package/src/config/feature-flag-registry.json +32 -32
  157. package/src/config/webhook-routing.ts +8 -0
  158. package/src/context/compactor.ts +31 -2
  159. package/src/daemon/__tests__/provider-rejection-log-fields.test.ts +272 -0
  160. package/src/daemon/conversation-agent-loop-handlers.ts +4 -1
  161. package/src/daemon/conversation-error.ts +13 -0
  162. package/src/daemon/conversation-messaging.ts +22 -2
  163. package/src/daemon/conversation-process.ts +11 -0
  164. package/src/daemon/conversation-store.ts +10 -0
  165. package/src/daemon/conversation-surfaces.ts +21 -8
  166. package/src/daemon/conversation.ts +12 -10
  167. package/src/daemon/lifecycle.ts +6 -2
  168. package/src/daemon/message-types/conversations.ts +5 -7
  169. package/src/daemon/provider-rejection-log-fields.ts +124 -0
  170. package/src/daemon/resource-pressure-guard-lifecycle.ts +66 -0
  171. package/src/daemon/resource-pressure-guard.ts +387 -0
  172. package/src/daemon/shutdown-handlers.ts +2 -0
  173. package/src/daemon/startup-error.ts +16 -0
  174. package/src/daemon/trust-context.ts +14 -10
  175. package/src/daemon/unsendable-image-notice.ts +76 -0
  176. package/src/hooks/registry.ts +65 -51
  177. package/src/ipc/__tests__/socket-path.test.ts +15 -7
  178. package/src/ipc/gateway-client.test.ts +1 -1
  179. package/src/ipc/gateway-client.ts +14 -21
  180. package/src/ipc/socket-cleanup.ts +2 -11
  181. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
  182. package/src/messaging/provider-message-metadata.ts +128 -0
  183. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +116 -68
  184. package/src/messaging/providers/channel-transport.ts +90 -13
  185. package/src/messaging/providers/discord/send.ts +16 -0
  186. package/src/messaging/providers/discord/transport.ts +11 -1
  187. package/src/messaging/providers/index.ts +109 -16
  188. package/src/messaging/providers/slack/message-metadata.ts +45 -0
  189. package/src/messaging/providers/slack/render-transcript.ts +1 -4
  190. package/src/messaging/providers/slack/send.test.ts +6 -9
  191. package/src/messaging/providers/slack/send.ts +40 -28
  192. package/src/messaging/providers/slack/transport.ts +22 -26
  193. package/src/messaging/providers/telegram-bot/send.test.ts +2 -8
  194. package/src/messaging/providers/telegram-bot/transport.ts +3 -6
  195. package/src/messaging/read-provider-metadata.test.ts +157 -0
  196. package/src/messaging/read-provider-metadata.ts +51 -0
  197. package/src/notifications/__tests__/assistant-reply-producer.test.ts +94 -0
  198. package/src/notifications/__tests__/edit-notification.test.ts +174 -2
  199. package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
  200. package/src/notifications/adapters/macos.ts +55 -0
  201. package/src/notifications/adapters/slack.ts +10 -5
  202. package/src/notifications/assistant-reply-producer.ts +37 -4
  203. package/src/notifications/conversation-pairing.ts +111 -21
  204. package/src/notifications/edit-notification.ts +38 -8
  205. package/src/notifications/emit-signal.ts +12 -14
  206. package/src/notifications/home-feed-side-effect.ts +218 -15
  207. package/src/notifications/types.ts +5 -0
  208. package/src/permissions/AGENTS.md +16 -0
  209. package/src/permissions/checker.test.ts +75 -122
  210. package/src/permissions/checker.ts +44 -560
  211. package/src/permissions/confirmation-guardian-request.test.ts +28 -3
  212. package/src/permissions/confirmation-guardian-request.ts +5 -1
  213. package/src/persistence/conversation-crud.ts +23 -20
  214. package/src/persistence/conversation-queries.ts +14 -1
  215. package/src/persistence/conversation-types.ts +22 -0
  216. package/src/persistence/delivery-crud.ts +122 -15
  217. package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
  218. package/src/plugin-api/conversation-turn.ts +27 -0
  219. package/src/plugin-api/credential-scope.test.ts +15 -0
  220. package/src/plugin-api/credential-scope.ts +14 -0
  221. package/src/plugin-api/index.ts +19 -1
  222. package/src/plugin-api/resolve-credential.ts +15 -10
  223. package/src/plugin-api/store-credential.ts +148 -0
  224. package/src/plugin-api/system-card.ts +36 -0
  225. package/src/plugin-api/vision-support.test.ts +82 -0
  226. package/src/plugin-api/vision-support.ts +14 -2
  227. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
  228. package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
  229. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
  230. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
  231. package/src/plugins/defaults/image-recovery/detect.ts +24 -1
  232. package/src/plugins/defaults/main.ts +15 -0
  233. package/src/plugins/defaults/memory/AGENTS.md +11 -3
  234. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -1
  235. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +9 -12
  236. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  237. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +12 -10
  238. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +114 -0
  239. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +8 -0
  240. package/src/plugins/defaults/memory/substrate/__tests__/edge-index.test.ts +0 -30
  241. package/src/plugins/defaults/memory/substrate/__tests__/ingest.test.ts +73 -1
  242. package/src/plugins/defaults/memory/substrate/__tests__/page-index.test.ts +37 -0
  243. package/src/plugins/defaults/memory/substrate/__tests__/page-links.test.ts +208 -0
  244. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +158 -1
  245. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +1 -61
  246. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +68 -6
  247. package/src/plugins/defaults/memory/substrate/edge-index.ts +0 -31
  248. package/src/plugins/defaults/memory/substrate/ingest.ts +59 -0
  249. package/src/plugins/defaults/memory/substrate/page-index.ts +35 -8
  250. package/src/plugins/defaults/memory/substrate/page-links.ts +133 -0
  251. package/src/plugins/defaults/memory/substrate/page-store.ts +10 -0
  252. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +124 -34
  253. package/src/plugins/defaults/memory/substrate/static-context.ts +0 -29
  254. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
  255. package/src/plugins/defaults/memory/v3/card.ts +9 -9
  256. package/src/plugins/defaults/memory/v3/core-set.test.ts +7 -0
  257. package/src/plugins/defaults/memory/v3/core-set.ts +5 -1
  258. package/src/plugins/defaults/memory/v3/edge.ts +13 -57
  259. package/src/plugins/mtime-cache.ts +1 -1
  260. package/src/plugins/pipeline.ts +14 -7
  261. package/src/plugins/plugin-execution-context.ts +35 -11
  262. package/src/plugins/plugin-tree-walk.ts +63 -4
  263. package/src/providers/anthropic/__tests__/pause-turn-continuation.test.ts +170 -0
  264. package/src/providers/anthropic/client.ts +332 -241
  265. package/src/providers/connection-resolution.ts +2 -1
  266. package/src/providers/content-blocks.ts +22 -0
  267. package/src/providers/gemini/client.ts +5 -2
  268. package/src/providers/inference/__tests__/adapter-factory-litellm.test.ts +47 -1
  269. package/src/providers/inference/__tests__/adapter-factory-ollama.test.ts +83 -0
  270. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +98 -1
  271. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +38 -0
  272. package/src/providers/inference/__tests__/base-url-security.test.ts +10 -0
  273. package/src/providers/inference/__tests__/connections-ollama.test.ts +90 -0
  274. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  275. package/src/providers/inference/adapter-factory.ts +33 -2
  276. package/src/providers/inference/auth.ts +13 -2
  277. package/src/providers/inference/credential-usage.ts +37 -0
  278. package/src/providers/inference/missing-credential-guard.ts +110 -0
  279. package/src/providers/inference/resolve-auth.ts +7 -7
  280. package/src/providers/media-resolve.ts +176 -15
  281. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +576 -46
  282. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +125 -2
  283. package/src/providers/openai/chat-completions-provider.ts +322 -42
  284. package/src/routes/route-host-protocol.ts +7 -0
  285. package/src/routes/worker.ts +31 -10
  286. package/src/runtime/AGENTS.md +35 -0
  287. package/src/runtime/__tests__/runtime-http-port-collision.test.ts +131 -0
  288. package/src/runtime/__tests__/web-presence.test.ts +234 -0
  289. package/src/runtime/assistant-event-hub.ts +38 -0
  290. package/src/runtime/channel-reply-delivery.ts +54 -31
  291. package/src/runtime/channel-retry-sweep.ts +6 -4
  292. package/src/runtime/effective-capabilities.test.ts +19 -0
  293. package/src/runtime/effective-capabilities.ts +25 -0
  294. package/src/runtime/guardian-reply-router.ts +6 -2
  295. package/src/runtime/http-errors.ts +1 -0
  296. package/src/runtime/http-server.ts +20 -5
  297. package/src/runtime/routes/__tests__/client-routes.test.ts +150 -0
  298. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +133 -0
  299. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  300. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +52 -1
  301. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -0
  302. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
  303. package/src/runtime/routes/__tests__/plugins-routes.test.ts +88 -35
  304. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +55 -6
  305. package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
  306. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +52 -1
  307. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +75 -4
  308. package/src/runtime/routes/approval-routes.ts +37 -4
  309. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +16 -22
  310. package/src/runtime/routes/canned-message-complete.ts +68 -29
  311. package/src/runtime/routes/client-routes.ts +113 -0
  312. package/src/runtime/routes/conversation-list-routes.ts +22 -7
  313. package/src/runtime/routes/conversation-management-routes.ts +15 -9
  314. package/src/runtime/routes/conversation-routes.ts +36 -22
  315. package/src/runtime/routes/credential-in-use.ts +85 -0
  316. package/src/runtime/routes/credential-routes.ts +55 -85
  317. package/src/runtime/routes/guardian-approval-interception.ts +22 -22
  318. package/src/runtime/routes/guardian-approval-reply-helpers.ts +14 -12
  319. package/src/runtime/routes/identity-routes.ts +5 -121
  320. package/src/runtime/routes/inbound-message-handler.ts +71 -27
  321. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -17
  322. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +77 -50
  323. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -97
  324. package/src/runtime/routes/inbound-stages/edit-intercept.ts +101 -77
  325. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +17 -8
  326. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +91 -7
  327. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +72 -70
  328. package/src/runtime/routes/index.ts +2 -0
  329. package/src/runtime/routes/inference-provider-connection-routes.ts +9 -7
  330. package/src/runtime/routes/monitoring-routes.ts +9 -1
  331. package/src/runtime/routes/plugins-routes.ts +17 -5
  332. package/src/runtime/routes/resource-pressure-routes.ts +22 -0
  333. package/src/runtime/routes/secret-routes.ts +39 -8
  334. package/src/runtime/routes/settings-routes.ts +6 -6
  335. package/src/runtime/routes/surface-action-routes.ts +20 -19
  336. package/src/runtime/routes/user-route-dispatcher.ts +31 -2
  337. package/src/runtime/routes/user-route-resolution.ts +18 -1
  338. package/src/runtime/slack-reply-session.test.ts +23 -13
  339. package/src/runtime/slack-reply-session.ts +38 -55
  340. package/src/runtime/web-presence.ts +90 -0
  341. package/src/skills/validate-input.ts +177 -40
  342. package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
  343. package/src/subagent/consult-context.ts +7 -23
  344. package/src/tools/credentials/broker.ts +24 -79
  345. package/src/tools/credentials/ref-parse.ts +35 -0
  346. package/src/tools/credentials/resolve.ts +4 -10
  347. package/src/tools/credentials/store.ts +168 -0
  348. package/src/tools/credentials/tool-policy.ts +67 -0
  349. package/src/tools/executor.ts +53 -15
  350. package/src/tools/network/__tests__/web-search.test.ts +41 -1
  351. package/src/tools/network/url-safety.ts +9 -16
  352. package/src/tools/network/web-search.ts +21 -3
  353. package/src/tools/permission-checker.ts +43 -52
  354. package/src/tools/schema-transforms.ts +40 -5
  355. package/src/tools/shared/input-repairs.ts +160 -0
  356. package/src/tools/skills/skill-tool-factory.ts +18 -4
  357. package/src/tools/subagent/spawn.ts +0 -1
  358. package/src/tools/tool-approval-handler.ts +16 -10
  359. package/src/tools/tool-types.ts +26 -13
  360. package/src/tools/types.ts +6 -6
  361. package/src/util/__tests__/cgroup-memory.test.ts +3 -0
  362. package/src/util/cgroup-memory.ts +3 -0
  363. package/src/util/container-cpu-sampler.ts +250 -0
  364. package/src/util/image-conversion.ts +178 -14
  365. package/src/util/platform.ts +93 -10
  366. package/src/permissions/ipc-risk-types.ts +0 -143
  367. package/src/permissions/risk-types.ts +0 -76
@@ -28,7 +28,7 @@ mock.module("./api.js", () => ({
28
28
  // the (unmocked) shared transport, so thrown test errors must be real
29
29
  // instances or every branch under test would silently take the generic path.
30
30
  const { SlackApiError } = await import("./web-api-transport.js");
31
- const { sendSlackAssistantThreadStatus, sendSlackReply } =
31
+ const { sendSlackAssistantThreadStatus, sendSlackReply, updateSlackMessage } =
32
32
  await import("./send.js");
33
33
 
34
34
  describe("sendSlackAssistantThreadStatus", () => {
@@ -80,9 +80,8 @@ describe("sendSlackAssistantThreadStatus", () => {
80
80
  });
81
81
  });
82
82
 
83
- describe("sendSlackReply update path", () => {
83
+ describe("updateSlackMessage", () => {
84
84
  const messageTs = "1700000000.000100";
85
- const threadTs = "1700000000.000001";
86
85
  const blocks: KnownBlock[] = [
87
86
  { type: "section", text: { type: "mrkdwn", text: "Final reply" } },
88
87
  ];
@@ -104,9 +103,7 @@ describe("sendSlackReply update path", () => {
104
103
  })
105
104
  .mockImplementationOnce(async () => ({ ok: true, ts: messageTs }));
106
105
 
107
- const result = await sendSlackReply("C123", "Final reply", {
108
- messageTs,
109
- threadTs,
106
+ const result = await updateSlackMessage("C123", messageTs, "Final reply", {
110
107
  blocks,
111
108
  });
112
109
 
@@ -138,7 +135,7 @@ describe("sendSlackReply update path", () => {
138
135
  });
139
136
 
140
137
  await expect(
141
- sendSlackReply("C123", "Final reply", { messageTs, threadTs, blocks }),
138
+ updateSlackMessage("C123", messageTs, "Final reply", { blocks }),
142
139
  ).rejects.toThrow();
143
140
 
144
141
  // Two in-place chat.update attempts (with then without blocks), then give
@@ -155,7 +152,7 @@ describe("sendSlackReply update path", () => {
155
152
  });
156
153
 
157
154
  await expect(
158
- sendSlackReply("C123", "Final reply", { messageTs, threadTs, blocks }),
155
+ updateSlackMessage("C123", messageTs, "Final reply", { blocks }),
159
156
  ).rejects.toThrow();
160
157
 
161
158
  // A single failed chat.update, no chat.postMessage fallback: a transient
@@ -175,7 +172,7 @@ describe("sendSlackReply update path", () => {
175
172
  });
176
173
 
177
174
  await expect(
178
- sendSlackReply("C123", "Final reply", { messageTs, threadTs, blocks }),
175
+ updateSlackMessage("C123", messageTs, "Final reply", { blocks }),
179
176
  ).rejects.toThrow();
180
177
 
181
178
  expect(callSlackApiMock).toHaveBeenCalledTimes(1);
@@ -9,6 +9,7 @@
9
9
  import type { Button, KnownBlock } from "@slack/types";
10
10
  import type {
11
11
  ApprovalUIMetadata,
12
+ MessageAudience,
12
13
  SlackStreamOp,
13
14
  } from "@vellumai/gateway-client";
14
15
 
@@ -133,9 +134,8 @@ export interface SlackSendOptions {
133
134
  blocks?: KnownBlock[];
134
135
  approval?: ApprovalUIMetadata;
135
136
  useBlocks?: boolean;
136
- ephemeral?: boolean;
137
- user?: string;
138
- messageTs?: string;
137
+ /** Absent means the whole room sees it. */
138
+ audience?: MessageAudience;
139
139
  }
140
140
 
141
141
  export interface SlackSendResult {
@@ -210,15 +210,40 @@ function buildApprovalFallbackText(
210
210
  }
211
211
 
212
212
  /**
213
- * Send a Slack text message with optional Block Kit formatting.
213
+ * Post a Slack text message with optional Block Kit formatting.
214
214
  *
215
- * When `messageTs` is set this is strictly an in-place edit (`chat.update`),
216
- * mirroring `editMessage()` in ./withdraw.ts: a failed edit throws and is
217
- * never converted into a fresh `chat.postMessage`. Posting on failure would
218
- * leave the original message beside a duplicate ("ghost") reply; re-delivery
219
- * after a transient failure is the delivery layer's responsibility, not this
220
- * function's.
215
+ * Always posts. Replacing an existing message is {@link updateSlackMessage}.
221
216
  */
217
+ /**
218
+ * Replace a Slack message in place via `chat.update`.
219
+ *
220
+ * A failed update throws rather than posting a fresh message, mirroring
221
+ * `editMessage()` in ./withdraw.ts: posting on failure leaves the original
222
+ * beside a duplicate reply. Re-delivery after a transient failure belongs to
223
+ * the delivery layer, not here.
224
+ */
225
+ export async function updateSlackMessage(
226
+ chatId: string,
227
+ messageId: string,
228
+ text: string,
229
+ options?: { blocks?: readonly KnownBlock[]; useBlocks?: boolean },
230
+ ): Promise<SlackSendResult> {
231
+ const blocks = resolveBlocks(
232
+ text,
233
+ options?.blocks as KnownBlock[] | undefined,
234
+ undefined,
235
+ options?.useBlocks,
236
+ );
237
+ const result = await sendWithBlockFallback(
238
+ "chat.update",
239
+ { channel: chatId, text, ts: messageId },
240
+ blocks,
241
+ { fallbackWithoutBlocks: true },
242
+ );
243
+ log.info({ chatId, messageId }, "Slack message updated");
244
+ return result;
245
+ }
246
+
222
247
  export async function sendSlackReply(
223
248
  chatId: string,
224
249
  text: string,
@@ -231,18 +256,6 @@ export async function sendSlackReply(
231
256
  options?.useBlocks,
232
257
  );
233
258
 
234
- const messageTs = options?.messageTs;
235
- if (typeof messageTs === "string" && messageTs.length > 0) {
236
- const result = await sendWithBlockFallback(
237
- "chat.update",
238
- { channel: chatId, text, ts: messageTs },
239
- blocks,
240
- { fallbackWithoutBlocks: true },
241
- );
242
- log.info({ chatId, messageTs }, "Slack message updated");
243
- return result;
244
- }
245
-
246
259
  const postBase: Record<string, unknown> = { channel: chatId, text };
247
260
  if (options?.threadTs) {
248
261
  postBase.thread_ts = options.threadTs;
@@ -261,13 +274,12 @@ export async function sendSlackReply(
261
274
  fallbackText: approvalFallbackText,
262
275
  };
263
276
 
264
- if (options?.ephemeral) {
265
- if (!options.user) {
266
- throw new Error("user is required for ephemeral messages");
267
- }
277
+ // No guard for a missing reader: the audience carries the id with it, so
278
+ // "restricted to one reader, but which one" cannot be expressed.
279
+ if (options?.audience) {
268
280
  return sendWithBlockFallback(
269
281
  "chat.postEphemeral",
270
- { ...postBase, user: options.user },
282
+ { ...postBase, user: options.audience.userId },
271
283
  blocks,
272
284
  fallbackOptions,
273
285
  );
@@ -372,7 +384,7 @@ export async function sendSlackAssistantThreadStatus(
372
384
  channel: string,
373
385
  threadTs: string,
374
386
  status: string,
375
- loadingMessages?: string[],
387
+ loadingMessages?: readonly string[],
376
388
  ): Promise<void> {
377
389
  try {
378
390
  const body: Record<string, unknown> = {
@@ -8,6 +8,7 @@ import {
8
8
  sendSlackReaction,
9
9
  sendSlackReply,
10
10
  sendSlackStreamOp,
11
+ updateSlackMessage,
11
12
  } from "./send.js";
12
13
 
13
14
  const log = getLogger("slack-transport");
@@ -16,19 +17,16 @@ export const slackTransport: ChannelTransport = {
16
17
  channel: "slack",
17
18
 
18
19
  async deliver(ctx, payload) {
19
- const { chatId, text, attachments, blocks } = payload;
20
+ const { chatId, text, attachments } = payload;
20
21
  const threadTs = ctx.params.threadTs;
21
22
 
22
23
  let sentTs: string | undefined;
23
24
  if (text) {
24
25
  const result = await sendSlackReply(chatId, text, {
25
26
  threadTs,
26
- blocks,
27
27
  approval: payload.approval,
28
28
  useBlocks: payload.useBlocks,
29
- ephemeral: payload.ephemeral,
30
- user: payload.user,
31
- messageTs: payload.messageTs,
29
+ audience: payload.audience,
32
30
  });
33
31
  sentTs = result.ts;
34
32
  } else if (payload.approval) {
@@ -54,27 +52,29 @@ export const slackTransport: ChannelTransport = {
54
52
  return { ok: true, ts: sentTs };
55
53
  },
56
54
 
57
- async sendReaction(_ctx, payload) {
58
- const reaction = payload.reaction;
59
- if (!reaction) {
60
- return { ok: true };
61
- }
55
+ async edit(_ctx, target) {
56
+ const result = await updateSlackMessage(
57
+ target.chatId,
58
+ target.messageId,
59
+ target.text,
60
+ { blocks: target.blocks, useBlocks: target.useBlocks },
61
+ );
62
+ return { ok: true, ts: result.ts };
63
+ },
64
+
65
+ async react(_ctx, target) {
62
66
  await sendSlackReaction(
63
- payload.chatId,
64
- reaction.name,
65
- reaction.messageTs,
66
- reaction.action,
67
+ target.chatId,
68
+ target.emoji,
69
+ target.messageId,
70
+ target.action,
67
71
  );
68
72
  return { ok: true };
69
73
  },
70
74
 
71
- async setThreadStatus(_ctx, payload) {
72
- const status = payload.assistantThreadStatus;
73
- if (!status) {
74
- return { ok: true };
75
- }
75
+ async setThreadStatus(_ctx, status) {
76
76
  await sendSlackAssistantThreadStatus(
77
- status.channel,
77
+ status.chatId,
78
78
  status.threadTs,
79
79
  status.status,
80
80
  status.loadingMessages,
@@ -82,11 +82,7 @@ export const slackTransport: ChannelTransport = {
82
82
  return { ok: true };
83
83
  },
84
84
 
85
- async streamReply(_ctx, payload) {
86
- const op = payload.slackStream;
87
- if (!op) {
88
- return { ok: true };
89
- }
90
- return sendSlackStreamOp(payload.chatId, op);
85
+ async streamReply(_ctx, chatId, op) {
86
+ return sendSlackStreamOp(chatId, op);
91
87
  },
92
88
  };
@@ -272,10 +272,7 @@ describe("telegramTransport topic targeting", () => {
272
272
  });
273
273
 
274
274
  test("typing indicators target the topic", async () => {
275
- await telegramTransport.sendTyping!(
276
- topicCtx,
277
- payload({ chatAction: "typing" }),
278
- );
275
+ await telegramTransport.typing!(topicCtx, "123");
279
276
 
280
277
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendChatAction", {
281
278
  chat_id: "123",
@@ -291,10 +288,7 @@ describe("telegramTransport topic targeting", () => {
291
288
  };
292
289
 
293
290
  await telegramTransport.deliver(bareCtx, payload({ useBlocks: false }));
294
- await telegramTransport.sendTyping!(
295
- bareCtx,
296
- payload({ chatAction: "typing" }),
297
- );
291
+ await telegramTransport.typing!(bareCtx, "123");
298
292
 
299
293
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendMessage", {
300
294
  chat_id: "123",
@@ -67,12 +67,9 @@ export const telegramTransport: ChannelTransport = {
67
67
  return { ok: true };
68
68
  },
69
69
 
70
- async sendTyping(ctx, payload) {
71
- await sendTelegramTypingIndicator(payload.chatId, threadOptions(ctx));
72
- log.debug(
73
- { chatId: payload.chatId },
74
- "Telegram typing indicator delivered (direct)",
75
- );
70
+ async typing(ctx, chatId) {
71
+ await sendTelegramTypingIndicator(chatId, threadOptions(ctx));
72
+ log.debug({ chatId }, "Telegram typing indicator delivered (direct)");
76
73
  return { ok: true };
77
74
  },
78
75
  };
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ groupingMessageId,
5
+ type ProviderMessageMetadata,
6
+ } from "./provider-message-metadata.js";
7
+ import type { SlackMessageMetadata } from "./providers/slack/message-metadata.js";
8
+ import { readProviderMetadata } from "./read-provider-metadata.js";
9
+
10
+ const CHANNEL = "C0123CHANNEL";
11
+ const MESSAGE_TS = "1700000000.000100";
12
+ const TARGET_TS = "1699999999.000001";
13
+
14
+ function slackRow(
15
+ overrides: Partial<SlackMessageMetadata> = {},
16
+ ): SlackMessageMetadata {
17
+ return {
18
+ source: "slack",
19
+ channelId: CHANNEL,
20
+ channelTs: MESSAGE_TS,
21
+ eventKind: "message",
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("readProviderMetadata", () => {
27
+ it("reads a row that only has the legacy Slack envelope", () => {
28
+ // Slack's own envelope is mapped on read, so no row is rewritten.
29
+ const metadata = JSON.stringify({
30
+ slackMeta: JSON.stringify(slackRow({ threadTs: TARGET_TS })),
31
+ });
32
+
33
+ expect(readProviderMetadata(metadata)).toEqual({
34
+ source: "slack",
35
+ conversationExternalId: CHANNEL,
36
+ messageId: MESSAGE_TS,
37
+ threadId: TARGET_TS,
38
+ eventKind: "message",
39
+ } satisfies ProviderMessageMetadata);
40
+ });
41
+
42
+ it("carries the actor's provider id, not just a display name", () => {
43
+ // Trust is keyed on the actor everywhere else, and a channel describing
44
+ // who reacted needs an identity rather than a label.
45
+ const metadata = JSON.stringify({
46
+ slackMeta: JSON.stringify(
47
+ slackRow({ actorExternalUserId: "U0123", displayName: "Jason" }),
48
+ ),
49
+ });
50
+
51
+ const meta = readProviderMetadata(metadata);
52
+ expect(meta?.actorExternalId).toBe("U0123");
53
+ expect(meta?.displayName).toBe("Jason");
54
+ });
55
+
56
+ it("maps a reaction's target onto the neutral name", () => {
57
+ const metadata = JSON.stringify({
58
+ slackMeta: JSON.stringify(
59
+ slackRow({
60
+ eventKind: "reaction",
61
+ reaction: {
62
+ emoji: "thumbsup",
63
+ targetChannelTs: TARGET_TS,
64
+ op: "added",
65
+ actorDisplayName: "Jason",
66
+ },
67
+ }),
68
+ ),
69
+ });
70
+
71
+ const meta = readProviderMetadata(metadata);
72
+ expect(meta?.eventKind).toBe("reaction");
73
+ // No channel gives a reaction an id of its own, so the row must not claim
74
+ // its target's: two rows sharing one id break resolution by message id.
75
+ expect(meta?.messageId).toBeUndefined();
76
+ expect(meta?.reaction).toEqual({
77
+ targetMessageId: TARGET_TS,
78
+ emoji: "thumbsup",
79
+ op: "added",
80
+ actorDisplayName: "Jason",
81
+ });
82
+ });
83
+
84
+ it("leaves threadId absent when the row is not in a thread", () => {
85
+ // A thread id here asserts that a thread exists. Synthesizing one from
86
+ // the message's own id is what keyed conversations on threads that never
87
+ // did (LUM-3330, LUM-3340), so absence has to survive the mapping.
88
+ const metadata = JSON.stringify({
89
+ slackMeta: JSON.stringify(slackRow()),
90
+ });
91
+
92
+ const meta = readProviderMetadata(metadata);
93
+ expect(meta).not.toBeNull();
94
+ expect(meta?.threadId).toBeUndefined();
95
+ expect(meta?.messageId).toBe(MESSAGE_TS);
96
+ });
97
+
98
+ it("reads a channel this repo has no code for", () => {
99
+ // The point of the neutral shape. A plugin channel cannot add an adapter
100
+ // here, so it describes its own rows directly and every channel-agnostic
101
+ // reader understands them with no Vellum code naming that channel.
102
+ const metadata = JSON.stringify({
103
+ providerMeta: JSON.stringify({
104
+ source: "plugin",
105
+ conversationExternalId: "room-42",
106
+ messageId: "evt-9",
107
+ threadId: "evt-1",
108
+ eventKind: "reaction",
109
+ reaction: {
110
+ targetMessageId: "evt-1",
111
+ emoji: "tada",
112
+ op: "added",
113
+ },
114
+ } satisfies ProviderMessageMetadata),
115
+ });
116
+
117
+ const meta = readProviderMetadata(metadata);
118
+ expect(meta?.source).toBe("plugin");
119
+ expect(meta?.reaction?.targetMessageId).toBe("evt-1");
120
+ // Grouping is what history assembly keys on, so a reaction from an
121
+ // unknown channel lands beside the message it was attached to.
122
+ expect(groupingMessageId(meta!)).toBe("evt-1");
123
+ });
124
+
125
+ it("groups a message by its own id", () => {
126
+ const metadata = JSON.stringify({
127
+ slackMeta: JSON.stringify(slackRow()),
128
+ });
129
+
130
+ expect(groupingMessageId(readProviderMetadata(metadata)!)).toBe(MESSAGE_TS);
131
+ });
132
+
133
+ it("keeps provider fields the neutral schema does not name", () => {
134
+ // The schema passes through, so a channel can carry its own fields on the
135
+ // same object instead of getting a second envelope.
136
+ const metadata = JSON.stringify({
137
+ providerMeta: JSON.stringify({
138
+ source: "slack",
139
+ conversationExternalId: CHANNEL,
140
+ messageId: MESSAGE_TS,
141
+ eventKind: "message",
142
+ slackFiles: [{ name: "diagram.png" }],
143
+ }),
144
+ });
145
+
146
+ expect(
147
+ (readProviderMetadata(metadata) as Record<string, unknown> | null)
148
+ ?.slackFiles,
149
+ ).toEqual([{ name: "diagram.png" }]);
150
+ });
151
+
152
+ it("reads as null when the row carries neither envelope", () => {
153
+ expect(readProviderMetadata(JSON.stringify({ other: 1 }))).toBeNull();
154
+ expect(readProviderMetadata("not json")).toBeNull();
155
+ expect(readProviderMetadata(null)).toBeNull();
156
+ });
157
+ });
@@ -0,0 +1,51 @@
1
+ import { safeParseRecord } from "../util/json.js";
2
+ import {
3
+ type ProviderMessageMetadata,
4
+ readProviderMessageMetadata,
5
+ } from "./provider-message-metadata.js";
6
+ import {
7
+ readSlackMetadataFromMessageMetadata,
8
+ slackMetadataAsProviderMetadata,
9
+ } from "./providers/slack/message-metadata.js";
10
+
11
+ /**
12
+ * Read a stored row's channel metadata in terms no single provider owns.
13
+ *
14
+ * Two ways a row can carry it, in this order.
15
+ *
16
+ * `providerMeta` is the neutral shape written directly. This is the path that
17
+ * matters most: a channel we have no code for, a plugin channel above all,
18
+ * can describe its own rows in it and every channel-agnostic reader
19
+ * understands them. History assembly, the conversation route and the
20
+ * transcript renderer then work for a channel nobody here has heard of.
21
+ *
22
+ * `slackMeta` is Slack's own envelope, mapped on read. It holds fields no
23
+ * other channel has an equivalent for, so Slack keeps writing it and loses
24
+ * nothing. A channel that writes `providerMeta` needs no adapter at all.
25
+ *
26
+ * Normalizing on read is what allows both. Writing stays where provider
27
+ * detail legitimately lives, and nothing is stored twice.
28
+ *
29
+ * A row with no recognized envelope, or one that fails validation, reads as
30
+ * null: callers already treat absent metadata as "not a channel row".
31
+ */
32
+ export function readProviderMetadata(
33
+ metadata: string | null | undefined,
34
+ opts?: { allowFlatLegacy?: boolean },
35
+ ): ProviderMessageMetadata | null {
36
+ if (!metadata) {
37
+ return null;
38
+ }
39
+
40
+ // Malformed neutral metadata falls through to the provider envelopes rather
41
+ // than failing the read outright.
42
+ const neutral = readProviderMessageMetadata(
43
+ safeParseRecord(metadata).providerMeta,
44
+ );
45
+ if (neutral) {
46
+ return neutral;
47
+ }
48
+
49
+ const slackMeta = readSlackMetadataFromMessageMetadata(metadata, opts);
50
+ return slackMeta ? slackMetadataAsProviderMetadata(slackMeta) : null;
51
+ }
@@ -94,6 +94,23 @@ mock.module("../../runtime/desktop-presence.js", () => ({
94
94
  },
95
95
  }));
96
96
 
97
+ // Defaults to unfocused, so every other case in this file exercises the
98
+ // unsuppressed path.
99
+ let webFocused = false;
100
+ let webPresenceShouldThrow = false;
101
+ const webPresenceArgs: unknown[][] = [];
102
+ const realWebPresence = await import("../../runtime/web-presence.js");
103
+ mock.module("../../runtime/web-presence.js", () => ({
104
+ ...realWebPresence,
105
+ isWebConversationFocused: (...args: unknown[]) => {
106
+ webPresenceArgs.push(args);
107
+ if (webPresenceShouldThrow) {
108
+ throw new Error("simulated presence read failure");
109
+ }
110
+ return webFocused;
111
+ },
112
+ }));
113
+
97
114
  const realAttentionStore =
98
115
  await import("../../persistence/conversation-attention-store.js");
99
116
  mock.module("../../persistence/conversation-attention-store.js", () => ({
@@ -183,6 +200,22 @@ function makeMacOriginatedMessage(): MessageRow {
183
200
  });
184
201
  }
185
202
 
203
+ /**
204
+ * A turn opened from a plain browser tab, as opposed to the Electron desktop
205
+ * renderer sharing the same web bundle: `client.os` is `"web"` only when
206
+ * `detectClientOs()` fails to resolve an Electron host OS first.
207
+ */
208
+ function makeWebOriginatedMessage(): MessageRow {
209
+ return makeMessage({
210
+ metadata: JSON.stringify({
211
+ userMessageChannel: "vellum",
212
+ userMessageInterface: "web",
213
+ client: { os: "web" },
214
+ clientOsFromRequest: true,
215
+ }),
216
+ });
217
+ }
218
+
186
219
  function makeAssistantRow(content: ContentBlock[]): MessageRow {
187
220
  return makeMessage({
188
221
  id: ASSISTANT_MESSAGE_ID,
@@ -250,9 +283,12 @@ beforeEach(() => {
250
283
  messageLookups.length = 0;
251
284
  attachmentLookups.length = 0;
252
285
  desktopPresenceArgs.length = 0;
286
+ webPresenceArgs.length = 0;
253
287
  assistantAttachments = [];
254
288
  desktopAttended = false;
255
289
  desktopPresenceShouldThrow = false;
290
+ webFocused = false;
291
+ webPresenceShouldThrow = false;
256
292
  getConversationShouldThrow = false;
257
293
  conversationRow = makeConversation();
258
294
  assistantRow = makeAssistantRow([
@@ -433,6 +469,64 @@ describe("emitAssistantReplyNotification", () => {
433
469
  });
434
470
  });
435
471
 
472
+ // Same pre-gate as "desktop presence" above, scoped to a browser tab
473
+ // instead of the whole app.
474
+ describe("web presence", () => {
475
+ beforeEach(() => {
476
+ initiatingRow = makeWebOriginatedMessage();
477
+ webFocused = true;
478
+ });
479
+
480
+ test("marks the signal source-active while a web tab reports itself focused on this conversation", async () => {
481
+ await run();
482
+
483
+ expect(emitCalls).toHaveLength(1);
484
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(true);
485
+ // Scoped to this conversation, unlike desktop attendance: a focused tab
486
+ // only speaks for the conversation it is actually looking at.
487
+ expect(webPresenceArgs).toEqual([[CONVERSATION_ID]]);
488
+ });
489
+
490
+ test("leaves the signal live when no web tab is focused on this conversation", async () => {
491
+ webFocused = false;
492
+
493
+ await run();
494
+
495
+ expect(emitCalls).toHaveLength(1);
496
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(false);
497
+ });
498
+
499
+ test("leaves the signal live when the web presence flag is off", async () => {
500
+ setOverridesForTesting({ "web-presence-suppression": false });
501
+
502
+ await run();
503
+
504
+ expect(emitCalls).toHaveLength(1);
505
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(false);
506
+ expect(webPresenceArgs).toEqual([]);
507
+ });
508
+
509
+ test("leaves the signal live when the web presence read throws", async () => {
510
+ webPresenceShouldThrow = true;
511
+
512
+ await run();
513
+
514
+ expect(emitCalls).toHaveLength(1);
515
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(false);
516
+ expect(warnCalls).toHaveLength(1);
517
+ });
518
+
519
+ test("marks the signal source-active for a turn opened from the macOS app when a web tab is focused here", async () => {
520
+ initiatingRow = makeMacOriginatedMessage();
521
+
522
+ await run();
523
+
524
+ expect(emitCalls).toHaveLength(1);
525
+ expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(true);
526
+ expect(webPresenceArgs).toEqual([[CONVERSATION_ID]]);
527
+ });
528
+ });
529
+
436
530
  test("omits requestedTitle when the conversation has no title", async () => {
437
531
  conversationRow = makeConversation({ title: " " });
438
532