@vellumai/assistant 0.11.4 → 0.11.5-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 (358) 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__/container-cpu-sampler.test.ts +153 -0
  36. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  37. package/src/__tests__/conversation-attachments.test.ts +0 -1
  38. package/src/__tests__/conversation-error.test.ts +17 -0
  39. package/src/__tests__/conversation-lifecycle.test.ts +20 -26
  40. package/src/__tests__/conversation-pairing.test.ts +186 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
  42. package/src/__tests__/credential-broker-server-use.test.ts +24 -5
  43. package/src/__tests__/credential-routes.test.ts +22 -3
  44. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  45. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  46. package/src/__tests__/default-plugin-names-guard.test.ts +12 -1
  47. package/src/__tests__/dm-backfill.test.ts +63 -0
  48. package/src/__tests__/dynamic-skill-background-guard.test.ts +0 -2
  49. package/src/__tests__/edit-propagation.test.ts +107 -4
  50. package/src/__tests__/events-client-registration.test.ts +28 -0
  51. package/src/__tests__/file-write-tool.test.ts +4 -2
  52. package/src/__tests__/guardian-prompt-notice-privacy.test.ts +133 -0
  53. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +143 -97
  54. package/src/__tests__/helpers/gateway-classify-mock.ts +27 -6
  55. package/src/__tests__/host-proxy-interface.test.ts +11 -1
  56. package/src/__tests__/host-shell-tool.test.ts +23 -4
  57. package/src/__tests__/image-conversion.test.ts +143 -1
  58. package/src/__tests__/inline-skill-load-permissions.test.ts +47 -36
  59. package/src/__tests__/input-repairs.test.ts +207 -0
  60. package/src/__tests__/live-workspace-guard.test.ts +75 -0
  61. package/src/__tests__/mcp-abort-signal.test.ts +1 -1
  62. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  63. package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
  64. package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
  65. package/src/__tests__/mtime-cache.test.ts +2 -0
  66. package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
  67. package/src/__tests__/openai-provider.test.ts +22 -0
  68. package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
  69. package/src/__tests__/platform-bash-auto-approve.test.ts +0 -4
  70. package/src/__tests__/platform.test.ts +16 -1
  71. package/src/__tests__/plugin-api-resolve-credential.test.ts +22 -13
  72. package/src/__tests__/plugin-api-shim.test.ts +5 -0
  73. package/src/__tests__/plugin-api-store-credential.test.ts +268 -0
  74. package/src/__tests__/plugin-config-data-migration.test.ts +8 -1
  75. package/src/__tests__/plugin-disabled-state.test.ts +2 -0
  76. package/src/__tests__/plugin-effective-enabled-set.test.ts +55 -3
  77. package/src/__tests__/plugin-execution-context.test.ts +73 -0
  78. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  79. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -1
  80. package/src/__tests__/reaction-persistence.test.ts +200 -7
  81. package/src/__tests__/require-fresh-approval.test.ts +0 -4
  82. package/src/__tests__/resource-pressure-guard.test.ts +428 -0
  83. package/src/__tests__/resource-pressure-routes.test.ts +113 -0
  84. package/src/__tests__/risk-classification-boundary-guard.test.ts +115 -0
  85. package/src/__tests__/run-conversation-turn-persistence.test.ts +27 -1
  86. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  87. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  88. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  89. package/src/__tests__/tool-executor-lifecycle-events.test.ts +132 -5
  90. package/src/__tests__/tool-executor.test.ts +63 -38
  91. package/src/__tests__/tool-policy.test.ts +97 -1
  92. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +7 -4
  93. package/src/__tests__/user-plugin-loader.test.ts +2 -0
  94. package/src/__tests__/validate-input.test.ts +243 -7
  95. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  96. package/src/acp/__tests__/acp-claude-oauth.test.ts +97 -29
  97. package/src/acp/__tests__/prepare-agent-env.test.ts +376 -153
  98. package/src/acp/acp-claude-oauth.ts +38 -21
  99. package/src/acp/prepare-agent-env.ts +146 -55
  100. package/src/agent/loop-tool-dedup.test.ts +161 -0
  101. package/src/agent/loop.ts +68 -22
  102. package/src/api/constants/app-tools.ts +34 -0
  103. package/src/api/events/resource-pressure-status-changed.ts +53 -0
  104. package/src/api/index.ts +19 -0
  105. package/src/api/responses/resource-pressure-status.ts +25 -0
  106. package/src/approvals/guardian-channel-delivery.ts +70 -6
  107. package/src/approvals/guardian-request-resolvers.ts +32 -66
  108. package/src/channels/__tests__/message-audience.test.ts +49 -0
  109. package/src/channels/__tests__/types.test.ts +17 -3
  110. package/src/channels/message-audience.ts +40 -0
  111. package/src/channels/types.ts +16 -12
  112. package/src/cli/__tests__/catalog-search-help.test.ts +50 -0
  113. package/src/cli/commands/__tests__/channel-verification-sessions.test.ts +31 -0
  114. package/src/cli/commands/__tests__/conversations-search.test.ts +289 -0
  115. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  116. package/src/cli/commands/__tests__/inference-providers.test.ts +68 -0
  117. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  118. package/src/cli/commands/__tests__/plugins.test.ts +57 -1
  119. package/src/cli/commands/channel-verification-sessions.help.ts +11 -9
  120. package/src/cli/commands/channel-verification-sessions.ts +11 -21
  121. package/src/cli/commands/conversations.help.ts +34 -0
  122. package/src/cli/commands/conversations.ts +125 -0
  123. package/src/cli/commands/inference-providers.ts +9 -4
  124. package/src/cli/commands/inference.help.ts +9 -4
  125. package/src/cli/commands/keys.help.ts +13 -1
  126. package/src/cli/commands/keys.ts +26 -15
  127. package/src/cli/commands/memory/__tests__/memory-v2.test.ts +57 -7
  128. package/src/cli/commands/memory/index.help.ts +41 -27
  129. package/src/cli/commands/memory/index.ts +2 -0
  130. package/src/cli/commands/memory/memory-v2.ts +58 -54
  131. package/src/cli/commands/memory/memory-validate.ts +18 -0
  132. package/src/cli/commands/monitoring.ts +1 -0
  133. package/src/cli/commands/plugins.help.ts +28 -31
  134. package/src/cli/commands/plugins.ts +10 -0
  135. package/src/cli/commands/skills.help.ts +13 -16
  136. package/src/cli/commands/trust.ts +3 -14
  137. package/src/cli/lib/__tests__/install-from-github.test.ts +38 -0
  138. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +34 -0
  139. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +32 -1
  140. package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
  141. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +64 -0
  142. package/src/cli/lib/bundled-marketplace.json +13 -0
  143. package/src/cli/lib/daemon-credential-client.ts +25 -3
  144. package/src/cli/lib/install-from-github.ts +35 -24
  145. package/src/cli/lib/merge-plugin-tree.ts +22 -4
  146. package/src/cli/lib/plugin-surfaces.ts +27 -0
  147. package/src/config/feature-flag-registry.json +35 -19
  148. package/src/config/webhook-routing.ts +8 -0
  149. package/src/context/compactor.ts +31 -2
  150. package/src/daemon/__tests__/provider-rejection-log-fields.test.ts +272 -0
  151. package/src/daemon/conversation-agent-loop-handlers.ts +4 -1
  152. package/src/daemon/conversation-error.ts +13 -0
  153. package/src/daemon/conversation-messaging.ts +22 -2
  154. package/src/daemon/conversation-process.ts +11 -0
  155. package/src/daemon/conversation-store.ts +10 -0
  156. package/src/daemon/conversation-surfaces.ts +21 -8
  157. package/src/daemon/conversation.ts +12 -10
  158. package/src/daemon/lifecycle.ts +6 -2
  159. package/src/daemon/message-types/conversations.ts +5 -7
  160. package/src/daemon/provider-rejection-log-fields.ts +124 -0
  161. package/src/daemon/resource-pressure-guard-lifecycle.ts +66 -0
  162. package/src/daemon/resource-pressure-guard.ts +387 -0
  163. package/src/daemon/shutdown-handlers.ts +2 -0
  164. package/src/daemon/startup-error.ts +16 -0
  165. package/src/daemon/trust-context.ts +14 -10
  166. package/src/daemon/unsendable-image-notice.ts +76 -0
  167. package/src/hooks/registry.ts +65 -51
  168. package/src/ipc/__tests__/socket-path.test.ts +15 -7
  169. package/src/ipc/gateway-client.test.ts +1 -1
  170. package/src/ipc/gateway-client.ts +14 -21
  171. package/src/ipc/socket-cleanup.ts +2 -11
  172. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
  173. package/src/messaging/provider-message-metadata.ts +128 -0
  174. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +116 -68
  175. package/src/messaging/providers/channel-transport.ts +90 -13
  176. package/src/messaging/providers/discord/send.ts +16 -0
  177. package/src/messaging/providers/discord/transport.ts +11 -1
  178. package/src/messaging/providers/index.ts +109 -16
  179. package/src/messaging/providers/slack/message-metadata.ts +45 -0
  180. package/src/messaging/providers/slack/render-transcript.ts +1 -4
  181. package/src/messaging/providers/slack/send.test.ts +6 -9
  182. package/src/messaging/providers/slack/send.ts +40 -28
  183. package/src/messaging/providers/slack/transport.ts +22 -26
  184. package/src/messaging/providers/telegram-bot/send.test.ts +2 -8
  185. package/src/messaging/providers/telegram-bot/transport.ts +3 -6
  186. package/src/messaging/read-provider-metadata.test.ts +157 -0
  187. package/src/messaging/read-provider-metadata.ts +51 -0
  188. package/src/notifications/__tests__/assistant-reply-producer.test.ts +94 -0
  189. package/src/notifications/__tests__/edit-notification.test.ts +174 -2
  190. package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
  191. package/src/notifications/adapters/macos.ts +55 -0
  192. package/src/notifications/adapters/slack.ts +10 -5
  193. package/src/notifications/assistant-reply-producer.ts +37 -4
  194. package/src/notifications/conversation-pairing.ts +111 -21
  195. package/src/notifications/edit-notification.ts +38 -8
  196. package/src/notifications/emit-signal.ts +12 -14
  197. package/src/notifications/home-feed-side-effect.ts +218 -15
  198. package/src/notifications/types.ts +5 -0
  199. package/src/permissions/AGENTS.md +16 -0
  200. package/src/permissions/checker.test.ts +75 -122
  201. package/src/permissions/checker.ts +44 -560
  202. package/src/permissions/confirmation-guardian-request.test.ts +28 -3
  203. package/src/permissions/confirmation-guardian-request.ts +5 -1
  204. package/src/persistence/conversation-crud.ts +23 -20
  205. package/src/persistence/conversation-queries.ts +14 -1
  206. package/src/persistence/conversation-types.ts +22 -0
  207. package/src/persistence/delivery-crud.ts +122 -15
  208. package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
  209. package/src/plugin-api/conversation-turn.ts +27 -0
  210. package/src/plugin-api/credential-scope.test.ts +15 -0
  211. package/src/plugin-api/credential-scope.ts +14 -0
  212. package/src/plugin-api/index.ts +19 -1
  213. package/src/plugin-api/resolve-credential.ts +15 -10
  214. package/src/plugin-api/store-credential.ts +148 -0
  215. package/src/plugin-api/system-card.ts +36 -0
  216. package/src/plugin-api/vision-support.test.ts +82 -0
  217. package/src/plugin-api/vision-support.ts +14 -2
  218. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
  219. package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
  220. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
  221. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
  222. package/src/plugins/defaults/image-recovery/detect.ts +24 -1
  223. package/src/plugins/defaults/main.ts +15 -0
  224. package/src/plugins/defaults/memory/AGENTS.md +11 -3
  225. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -1
  226. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +9 -12
  227. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  228. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +12 -10
  229. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +114 -0
  230. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +8 -0
  231. package/src/plugins/defaults/memory/substrate/__tests__/edge-index.test.ts +0 -30
  232. package/src/plugins/defaults/memory/substrate/__tests__/ingest.test.ts +73 -1
  233. package/src/plugins/defaults/memory/substrate/__tests__/page-index.test.ts +37 -0
  234. package/src/plugins/defaults/memory/substrate/__tests__/page-links.test.ts +208 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +158 -1
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +1 -61
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +68 -6
  238. package/src/plugins/defaults/memory/substrate/edge-index.ts +0 -31
  239. package/src/plugins/defaults/memory/substrate/ingest.ts +59 -0
  240. package/src/plugins/defaults/memory/substrate/page-index.ts +35 -8
  241. package/src/plugins/defaults/memory/substrate/page-links.ts +133 -0
  242. package/src/plugins/defaults/memory/substrate/page-store.ts +10 -0
  243. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +124 -34
  244. package/src/plugins/defaults/memory/substrate/static-context.ts +0 -29
  245. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
  246. package/src/plugins/defaults/memory/v3/card.ts +9 -9
  247. package/src/plugins/defaults/memory/v3/core-set.test.ts +7 -0
  248. package/src/plugins/defaults/memory/v3/core-set.ts +5 -1
  249. package/src/plugins/defaults/memory/v3/edge.ts +13 -57
  250. package/src/plugins/mtime-cache.ts +1 -1
  251. package/src/plugins/pipeline.ts +14 -7
  252. package/src/plugins/plugin-execution-context.ts +35 -11
  253. package/src/plugins/plugin-tree-walk.ts +63 -4
  254. package/src/providers/anthropic/__tests__/pause-turn-continuation.test.ts +170 -0
  255. package/src/providers/anthropic/client.ts +332 -241
  256. package/src/providers/connection-resolution.ts +2 -1
  257. package/src/providers/content-blocks.ts +22 -0
  258. package/src/providers/gemini/client.ts +5 -2
  259. package/src/providers/inference/__tests__/adapter-factory-litellm.test.ts +47 -1
  260. package/src/providers/inference/__tests__/adapter-factory-ollama.test.ts +83 -0
  261. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +98 -1
  262. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +38 -0
  263. package/src/providers/inference/__tests__/base-url-security.test.ts +10 -0
  264. package/src/providers/inference/__tests__/connections-ollama.test.ts +90 -0
  265. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  266. package/src/providers/inference/adapter-factory.ts +33 -2
  267. package/src/providers/inference/auth.ts +13 -2
  268. package/src/providers/inference/credential-usage.ts +37 -0
  269. package/src/providers/inference/missing-credential-guard.ts +110 -0
  270. package/src/providers/inference/resolve-auth.ts +7 -7
  271. package/src/providers/media-resolve.ts +176 -15
  272. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +576 -46
  273. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +125 -2
  274. package/src/providers/openai/chat-completions-provider.ts +322 -42
  275. package/src/routes/route-host-protocol.ts +7 -0
  276. package/src/routes/worker.ts +31 -10
  277. package/src/runtime/AGENTS.md +35 -0
  278. package/src/runtime/__tests__/runtime-http-port-collision.test.ts +131 -0
  279. package/src/runtime/__tests__/web-presence.test.ts +234 -0
  280. package/src/runtime/assistant-event-hub.ts +38 -0
  281. package/src/runtime/channel-reply-delivery.ts +54 -31
  282. package/src/runtime/channel-retry-sweep.ts +6 -4
  283. package/src/runtime/effective-capabilities.test.ts +19 -0
  284. package/src/runtime/effective-capabilities.ts +25 -0
  285. package/src/runtime/guardian-reply-router.ts +6 -2
  286. package/src/runtime/http-errors.ts +1 -0
  287. package/src/runtime/http-server.ts +20 -5
  288. package/src/runtime/routes/__tests__/client-routes.test.ts +150 -0
  289. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +133 -0
  290. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  291. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +52 -1
  292. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -0
  293. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
  294. package/src/runtime/routes/__tests__/plugins-routes.test.ts +88 -35
  295. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +55 -6
  296. package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
  297. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +52 -1
  298. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +75 -4
  299. package/src/runtime/routes/approval-routes.ts +37 -4
  300. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +16 -22
  301. package/src/runtime/routes/canned-message-complete.ts +68 -29
  302. package/src/runtime/routes/client-routes.ts +113 -0
  303. package/src/runtime/routes/conversation-list-routes.ts +22 -7
  304. package/src/runtime/routes/conversation-management-routes.ts +15 -9
  305. package/src/runtime/routes/conversation-routes.ts +36 -22
  306. package/src/runtime/routes/credential-in-use.ts +85 -0
  307. package/src/runtime/routes/credential-routes.ts +55 -85
  308. package/src/runtime/routes/guardian-approval-interception.ts +22 -22
  309. package/src/runtime/routes/guardian-approval-reply-helpers.ts +14 -12
  310. package/src/runtime/routes/identity-routes.ts +5 -121
  311. package/src/runtime/routes/inbound-message-handler.ts +71 -27
  312. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -17
  313. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +77 -50
  314. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -97
  315. package/src/runtime/routes/inbound-stages/edit-intercept.ts +101 -77
  316. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +17 -8
  317. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +91 -7
  318. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +72 -70
  319. package/src/runtime/routes/index.ts +2 -0
  320. package/src/runtime/routes/inference-provider-connection-routes.ts +9 -7
  321. package/src/runtime/routes/monitoring-routes.ts +9 -1
  322. package/src/runtime/routes/plugins-routes.ts +17 -5
  323. package/src/runtime/routes/resource-pressure-routes.ts +22 -0
  324. package/src/runtime/routes/secret-routes.ts +39 -8
  325. package/src/runtime/routes/settings-routes.ts +6 -6
  326. package/src/runtime/routes/surface-action-routes.ts +20 -19
  327. package/src/runtime/routes/user-route-dispatcher.ts +31 -2
  328. package/src/runtime/routes/user-route-resolution.ts +18 -1
  329. package/src/runtime/slack-reply-session.test.ts +23 -13
  330. package/src/runtime/slack-reply-session.ts +38 -55
  331. package/src/runtime/web-presence.ts +90 -0
  332. package/src/skills/validate-input.ts +177 -40
  333. package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
  334. package/src/subagent/consult-context.ts +7 -23
  335. package/src/tools/credentials/broker.ts +24 -79
  336. package/src/tools/credentials/ref-parse.ts +35 -0
  337. package/src/tools/credentials/resolve.ts +4 -10
  338. package/src/tools/credentials/store.ts +168 -0
  339. package/src/tools/credentials/tool-policy.ts +67 -0
  340. package/src/tools/executor.ts +53 -15
  341. package/src/tools/network/__tests__/web-search.test.ts +41 -1
  342. package/src/tools/network/url-safety.ts +9 -16
  343. package/src/tools/network/web-search.ts +21 -3
  344. package/src/tools/permission-checker.ts +43 -52
  345. package/src/tools/schema-transforms.ts +40 -5
  346. package/src/tools/shared/input-repairs.ts +160 -0
  347. package/src/tools/skills/skill-tool-factory.ts +18 -4
  348. package/src/tools/subagent/spawn.ts +0 -1
  349. package/src/tools/tool-approval-handler.ts +16 -10
  350. package/src/tools/tool-types.ts +26 -13
  351. package/src/tools/types.ts +6 -6
  352. package/src/util/__tests__/cgroup-memory.test.ts +3 -0
  353. package/src/util/cgroup-memory.ts +3 -0
  354. package/src/util/container-cpu-sampler.ts +250 -0
  355. package/src/util/image-conversion.ts +178 -14
  356. package/src/util/platform.ts +93 -10
  357. package/src/permissions/ipc-risk-types.ts +0 -143
  358. package/src/permissions/risk-types.ts +0 -76
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Tests for the conversation-scoped shape of `GET /v1/pending-interactions`.
3
+ *
4
+ * The registry is the authority clients project their prompt UI from, so what
5
+ * this route says about a kind matters as much when nothing is outstanding as
6
+ * when something is. Covers:
7
+ * - an outstanding question is reported with its full batch,
8
+ * - no question reports `null` (the value that authorizes a client to retire
9
+ * a card), not an omitted key,
10
+ * - every conversation-scoped path names the key, including the early return
11
+ * for a conversation that cannot be resolved,
12
+ * - the newest question wins while a superseded one is still settling,
13
+ * - a question registered without entries is not reportable,
14
+ * - the diagnostic (unfiltered) mode is unchanged.
15
+ */
16
+
17
+ import { afterEach, describe, expect, mock, test } from "bun:test";
18
+
19
+ import type { QuestionEntry } from "../../../api/events/question-request.js";
20
+ import * as pendingInteractions from "../../pending-interactions.js";
21
+ import type { RouteDefinition, RouteHandlerArgs } from "../types.js";
22
+
23
+ // The conversation-key lookup is the only path into the unresolvable-conversation
24
+ // early return, and it reads SQLite. Stub it so this stays a unit test of the
25
+ // response shape. Imported dynamically below so the stub is in place first.
26
+ mock.module("../../../persistence/conversation-key-store.js", () => ({
27
+ getConversationByKey: () => undefined,
28
+ }));
29
+
30
+ const { ROUTES: APPROVAL_ROUTES } = await import("../approval-routes.js");
31
+
32
+ function findHandler(operationId: string): RouteDefinition["handler"] {
33
+ const route = APPROVAL_ROUTES.find((r) => r.operationId === operationId);
34
+ if (!route) {
35
+ throw new Error(`Route ${operationId} not found`);
36
+ }
37
+ return route.handler;
38
+ }
39
+
40
+ const handler = findHandler("pending_interactions");
41
+
42
+ interface ScopedResponse {
43
+ pendingConfirmation: unknown;
44
+ pendingSecret: unknown;
45
+ pendingQuestion: { requestId: string; entries: QuestionEntry[] } | null;
46
+ }
47
+
48
+ async function listForConversation(
49
+ conversationId: string,
50
+ ): Promise<ScopedResponse> {
51
+ const result = await handler({
52
+ queryParams: { conversationId },
53
+ } as unknown as RouteHandlerArgs);
54
+ return result as ScopedResponse;
55
+ }
56
+
57
+ const ENTRIES: QuestionEntry[] = [
58
+ {
59
+ id: "q1",
60
+ question: "Which draft should I send?",
61
+ options: [
62
+ { id: "a", label: "The short one" },
63
+ { id: "b", label: "The long one" },
64
+ ],
65
+ },
66
+ ];
67
+
68
+ function registerQuestion(requestId: string, entries = ENTRIES): void {
69
+ pendingInteractions.register(requestId, {
70
+ conversationId: "conv-1",
71
+ kind: "question",
72
+ toolUseId: `tool-${requestId}`,
73
+ questionDetails: { entries },
74
+ });
75
+ }
76
+
77
+ /** A question interaction that never got its batch attached. */
78
+ function registerQuestionWithoutDetails(requestId: string): void {
79
+ pendingInteractions.register(requestId, {
80
+ conversationId: "conv-1",
81
+ kind: "question",
82
+ toolUseId: `tool-${requestId}`,
83
+ });
84
+ }
85
+
86
+ afterEach(() => {
87
+ pendingInteractions.clear();
88
+ });
89
+
90
+ describe("pending-interactions, conversation-scoped", () => {
91
+ test("reports an outstanding question with its full batch", async () => {
92
+ // GIVEN a question interaction awaiting an answer in the conversation
93
+ registerQuestion("req-1");
94
+
95
+ // WHEN the conversation's pending interactions are read
96
+ const result = await listForConversation("conv-1");
97
+
98
+ // THEN the prompt is reported with everything the card needs to render
99
+ expect(result.pendingQuestion).toEqual({
100
+ requestId: "req-1",
101
+ entries: ENTRIES,
102
+ });
103
+ });
104
+
105
+ test("reports null once the question is resolved", async () => {
106
+ // GIVEN a question that has since been answered (registry entry consumed)
107
+ registerQuestion("req-1");
108
+ pendingInteractions.resolve("req-1", "answered");
109
+
110
+ // WHEN the conversation's pending interactions are read
111
+ const result = await listForConversation("conv-1");
112
+
113
+ // THEN the answer is a positive "nothing outstanding", which is what lets a
114
+ // client retire a card it is still showing. An omitted key would instead
115
+ // read as "this assistant has no opinion" and strand the card.
116
+ expect(result.pendingQuestion).toBeNull();
117
+ expect("pendingQuestion" in result).toBe(true);
118
+ });
119
+
120
+ test("names the key even when the conversation cannot be resolved", async () => {
121
+ // GIVEN a conversationKey that maps to no conversation
122
+ const result = (await handler({
123
+ queryParams: { conversationKey: "no-such-key" },
124
+ } as unknown as RouteHandlerArgs)) as ScopedResponse;
125
+
126
+ // THEN the early return still states all three kinds, so this path is not
127
+ // mistaken for an assistant that predates the field
128
+ expect("pendingQuestion" in result).toBe(true);
129
+ expect(result.pendingQuestion).toBeNull();
130
+ });
131
+
132
+ test("reports the newest question while a superseded one settles", async () => {
133
+ // GIVEN a superseded prompt that has not finished settling through its
134
+ // abort signal, so both entries sit in the registry at once
135
+ const newerEntries: QuestionEntry[] = [
136
+ { id: "q1", question: "Actually, which account?", options: [] },
137
+ ];
138
+ registerQuestion("req-old");
139
+ registerQuestion("req-new", newerEntries);
140
+
141
+ // WHEN the conversation's pending interactions are read
142
+ const result = await listForConversation("conv-1");
143
+
144
+ // THEN the prompt the user is actually being asked is the one reported
145
+ expect(result.pendingQuestion?.requestId).toBe("req-new");
146
+ });
147
+
148
+ test("does not report a question registered without its entries", async () => {
149
+ // GIVEN a question interaction carrying no renderable batch
150
+ registerQuestionWithoutDetails("req-1");
151
+
152
+ // WHEN the conversation's pending interactions are read
153
+ const result = await listForConversation("conv-1");
154
+
155
+ // THEN there is nothing a client could raise
156
+ expect(result.pendingQuestion).toBeNull();
157
+ });
158
+
159
+ test("leaves the unfiltered diagnostic listing unchanged", async () => {
160
+ // GIVEN pending interactions across the daemon
161
+ registerQuestion("req-1");
162
+
163
+ // WHEN the route is called with no conversation filter
164
+ const result = (await handler({
165
+ queryParams: {},
166
+ } as unknown as RouteHandlerArgs)) as {
167
+ interactions: Array<{ requestId: string; kind: string }>;
168
+ };
169
+
170
+ // THEN it still answers with the flat cross-conversation list
171
+ expect(result.interactions).toEqual([
172
+ expect.objectContaining({ requestId: "req-1", kind: "question" }),
173
+ ]);
174
+ });
175
+ });
@@ -38,9 +38,12 @@
38
38
  * `plugins:list` tag via the canonical resource-sync publisher (enable and
39
39
  * disable emit the SAME invalidation)
40
40
  * - Threads `x-vellum-client-id` into the published event's `originClientId`
41
- * - Pokes the plugin-declared schedule reconcile on a successful toggle (and
42
- * not on a failed one), so the plugin's rows disarm / re-arm immediately
43
- * instead of at the reconciler's next backstop sweep
41
+ * - Enable pokes the imperative plugin source reconcile, which activates a
42
+ * plugin the boot scan skipped for its sentinel and converges its
43
+ * schedules and MCP servers
44
+ * - Disable pokes the plugin-declared schedule reconcile on a successful
45
+ * toggle (and not on a failed one), so the plugin's rows disarm
46
+ * immediately instead of at the reconciler's next backstop sweep
44
47
  * - A broadcast failure does not fail a successful toggle (the publisher
45
48
  * swallows hub errors)
46
49
  * - Maps `InvalidPluginNameError` → BadRequestError (400)
@@ -316,8 +319,8 @@ mock.module("../../../cli/lib/toggle-plugin.js", () => ({
316
319
  enablePlugin: enablePluginSpy,
317
320
  }));
318
321
 
319
- // Spy on the plugin-schedule reconcile the toggle handlers poke. The handlers
320
- // import it lazily at call time, so this mock intercepts the dynamic import
322
+ // Spy on the plugin-schedule reconcile the disable handler pokes. The handler
323
+ // imports it lazily at call time, so this mock intercepts the dynamic import
321
324
  // and keeps the reconciler's persistence graph out of the test.
322
325
  const reconcilePluginSchedulesSpy = mock(() => Promise.resolve());
323
326
 
@@ -325,6 +328,21 @@ mock.module("../../../schedule/plugin-schedule-reconciler.js", () => ({
325
328
  reconcilePluginSchedules: reconcilePluginSchedulesSpy,
326
329
  }));
327
330
 
331
+ // Spy on the imperative plugin source reconcile so the enable route's
332
+ // activation poke is assertable without scanning a real plugins tree. Only
333
+ // that one export is swapped; the rest of the module passes through real,
334
+ // because other modules in this route's graph import their own bindings from
335
+ // it.
336
+ const reconcilePluginSourcesNowSpy = mock(() => Promise.resolve());
337
+
338
+ const actualMtimeCache: typeof import("../../../plugins/mtime-cache.js") =
339
+ await import("../../../plugins/mtime-cache.js");
340
+
341
+ mock.module("../../../plugins/mtime-cache.js", () => ({
342
+ ...actualMtimeCache,
343
+ reconcilePluginSourcesNow: reconcilePluginSourcesNowSpy,
344
+ }));
345
+
328
346
  // Spy on broadcastMessage so we can assert the sync_changed invalidation the
329
347
  // enable/disable handlers emit. The handlers publish through the canonical
330
348
  // `publishPluginsChanged` → `publishSyncInvalidation` path (both left real), so
@@ -2573,8 +2591,10 @@ function toggleResult(
2573
2591
  };
2574
2592
  }
2575
2593
 
2576
- function invokeEnable(args: RouteHandlerArgs = {}): { ok: boolean } {
2577
- return enableHandler(args) as { ok: boolean };
2594
+ async function invokeEnable(
2595
+ args: RouteHandlerArgs = {},
2596
+ ): Promise<{ ok: boolean }> {
2597
+ return (await enableHandler(args)) as { ok: boolean };
2578
2598
  }
2579
2599
 
2580
2600
  function invokeDisable(args: RouteHandlerArgs = {}): { ok: boolean } {
@@ -2582,18 +2602,22 @@ function invokeDisable(args: RouteHandlerArgs = {}): { ok: boolean } {
2582
2602
  }
2583
2603
 
2584
2604
  /**
2585
- * Wait for the toggle handlers' fire-and-forget reconcile poke to land. The
2586
- * lazy `import()` resolves off the microtask queue, so the assertion cannot
2587
- * run on the handler's synchronous return.
2605
+ * Wait for a toggle handler's fire-and-forget reconcile poke to land. The poke
2606
+ * runs off the microtask queue (the disable path also resolves a lazy
2607
+ * `import()` first), so the assertion cannot run on the handler's synchronous
2608
+ * return.
2588
2609
  */
2589
- async function waitForScheduleReconcile(): Promise<void> {
2610
+ async function waitForReconcile(
2611
+ spy: { mock: { calls: unknown[] } },
2612
+ label: string,
2613
+ ): Promise<void> {
2590
2614
  for (let i = 0; i < 50; i++) {
2591
- if (reconcilePluginSchedulesSpy.mock.calls.length > 0) {
2615
+ if (spy.mock.calls.length > 0) {
2592
2616
  return;
2593
2617
  }
2594
2618
  await new Promise((resolve) => setTimeout(resolve, 2));
2595
2619
  }
2596
- throw new Error("plugin schedule reconcile was never triggered");
2620
+ throw new Error(`${label} was never triggered`);
2597
2621
  }
2598
2622
 
2599
2623
  /** Assert the spy received exactly one sync_changed carrying `plugins:list`. */
@@ -2609,30 +2633,50 @@ describe("POST /v1/plugins/:name/enable", () => {
2609
2633
  enablePluginSpy.mockReset();
2610
2634
  broadcastMessageSpy.mockReset();
2611
2635
  reconcilePluginSchedulesSpy.mockClear();
2636
+ reconcilePluginSourcesNowSpy.mockClear();
2612
2637
  });
2613
2638
 
2614
- test("reconciles plugin-declared schedules so the plugin's rows re-arm now", async () => {
2639
+ test("awaits the source reconcile so a boot-disabled plugin activates before the route returns", async () => {
2615
2640
  enablePluginSpy.mockImplementation((name) => toggleResult(name, "enable"));
2616
2641
 
2617
- invokeEnable({ pathParams: { name: "simple-memory" } });
2642
+ await invokeEnable({ pathParams: { name: "simple-memory" } });
2643
+
2644
+ // The boot scan skipped this plugin for its sentinel, so clearing the
2645
+ // sentinel only brings its hooks, tools, MCP servers and schedules up if
2646
+ // the source reconcile runs. The route awaits it, so by the time the
2647
+ // response lands the reconcile has completed; it converges the schedules
2648
+ // itself, so the route does not poke them separately.
2649
+ expect(reconcilePluginSourcesNowSpy).toHaveBeenCalledTimes(1);
2650
+ expect(reconcilePluginSchedulesSpy).not.toHaveBeenCalled();
2651
+ });
2652
+
2653
+ test("a failed toggle does not run the source reconcile", async () => {
2654
+ enablePluginSpy.mockImplementation((name) => {
2655
+ throw new PluginDirectoryNotFoundError(name);
2656
+ });
2618
2657
 
2619
- await waitForScheduleReconcile();
2658
+ await expect(
2659
+ invokeEnable({ pathParams: { name: "ghost" } }),
2660
+ ).rejects.toThrow(NotFoundError);
2661
+ expect(reconcilePluginSourcesNowSpy).not.toHaveBeenCalled();
2620
2662
  });
2621
2663
 
2622
- test("enables the plugin and broadcasts sync_changed(plugins:list)", () => {
2664
+ test("enables the plugin and broadcasts sync_changed(plugins:list)", async () => {
2623
2665
  enablePluginSpy.mockImplementation((name) => toggleResult(name, "enable"));
2624
2666
 
2625
- const result = invokeEnable({ pathParams: { name: "simple-memory" } });
2667
+ const result = await invokeEnable({
2668
+ pathParams: { name: "simple-memory" },
2669
+ });
2626
2670
 
2627
2671
  expect(result).toEqual({ ok: true });
2628
2672
  expect(enablePluginSpy.mock.calls[0]?.[0]).toBe("simple-memory");
2629
2673
  expectPluginsListBroadcast();
2630
2674
  });
2631
2675
 
2632
- test("threads x-vellum-client-id into the published event's originClientId", () => {
2676
+ test("threads x-vellum-client-id into the published event's originClientId", async () => {
2633
2677
  enablePluginSpy.mockImplementation((name) => toggleResult(name, "enable"));
2634
2678
 
2635
- invokeEnable({
2679
+ await invokeEnable({
2636
2680
  pathParams: { name: "simple-memory" },
2637
2681
  headers: { "x-vellum-client-id": "client-abc" },
2638
2682
  });
@@ -2646,7 +2690,7 @@ describe("POST /v1/plugins/:name/enable", () => {
2646
2690
  });
2647
2691
  });
2648
2692
 
2649
- test("a broadcast failure does not fail a successful toggle", () => {
2693
+ test("a broadcast failure does not fail a successful toggle", async () => {
2650
2694
  enablePluginSpy.mockImplementation((name) => toggleResult(name, "enable"));
2651
2695
  // The sentinel was already flipped; a hub throw AFTER that must not surface
2652
2696
  // as a 500 — the canonical publisher swallows broadcast errors.
@@ -2654,40 +2698,42 @@ describe("POST /v1/plugins/:name/enable", () => {
2654
2698
  throw new Error("hub unavailable");
2655
2699
  });
2656
2700
 
2657
- const result = invokeEnable({ pathParams: { name: "simple-memory" } });
2701
+ const result = await invokeEnable({
2702
+ pathParams: { name: "simple-memory" },
2703
+ });
2658
2704
  expect(result).toEqual({ ok: true });
2659
2705
  });
2660
2706
 
2661
- test("PluginAlreadyInStateException → ConflictError (409), no broadcast", () => {
2707
+ test("PluginAlreadyInStateException → ConflictError (409), no broadcast", async () => {
2662
2708
  enablePluginSpy.mockImplementation((name) => {
2663
2709
  throw new PluginAlreadyInStateException(name, "enable");
2664
2710
  });
2665
2711
 
2666
- expect(() =>
2712
+ await expect(
2667
2713
  invokeEnable({ pathParams: { name: "simple-memory" } }),
2668
- ).toThrow(ConflictError);
2714
+ ).rejects.toThrow(ConflictError);
2669
2715
  // A no-op toggle must not fan out a spurious invalidation.
2670
2716
  expect(broadcastMessageSpy).not.toHaveBeenCalled();
2671
2717
  });
2672
2718
 
2673
- test("PluginDirectoryNotFoundError → NotFoundError (404)", () => {
2719
+ test("PluginDirectoryNotFoundError → NotFoundError (404)", async () => {
2674
2720
  enablePluginSpy.mockImplementation((name) => {
2675
2721
  throw new PluginDirectoryNotFoundError(name);
2676
2722
  });
2677
2723
 
2678
- expect(() => invokeEnable({ pathParams: { name: "ghost" } })).toThrow(
2679
- NotFoundError,
2680
- );
2724
+ await expect(
2725
+ invokeEnable({ pathParams: { name: "ghost" } }),
2726
+ ).rejects.toThrow(NotFoundError);
2681
2727
  });
2682
2728
 
2683
- test("InvalidPluginNameError → BadRequestError (400)", () => {
2729
+ test("InvalidPluginNameError → BadRequestError (400)", async () => {
2684
2730
  enablePluginSpy.mockImplementation(() => {
2685
2731
  throw new ToggleInvalidPluginNameError("../escape");
2686
2732
  });
2687
2733
 
2688
- expect(() => invokeEnable({ pathParams: { name: "../escape" } })).toThrow(
2689
- BadRequestError,
2690
- );
2734
+ await expect(
2735
+ invokeEnable({ pathParams: { name: "../escape" } }),
2736
+ ).rejects.toThrow(BadRequestError);
2691
2737
  });
2692
2738
  });
2693
2739
 
@@ -2696,6 +2742,7 @@ describe("POST /v1/plugins/:name/disable", () => {
2696
2742
  disablePluginSpy.mockReset();
2697
2743
  broadcastMessageSpy.mockReset();
2698
2744
  reconcilePluginSchedulesSpy.mockClear();
2745
+ reconcilePluginSourcesNowSpy.mockClear();
2699
2746
  });
2700
2747
 
2701
2748
  test("reconciles plugin-declared schedules so the plugin's rows disarm now", async () => {
@@ -2706,8 +2753,14 @@ describe("POST /v1/plugins/:name/disable", () => {
2706
2753
  invokeDisable({ pathParams: { name: "simple-memory" } });
2707
2754
 
2708
2755
  // Without this poke the rows stay armed until the reconciler's next
2709
- // backstop sweep.
2710
- await waitForScheduleReconcile();
2756
+ // backstop sweep. Disable stays on the schedule poke: the sentinel filters
2757
+ // the plugin out at read time, and a source reconcile here would run its
2758
+ // shutdown hooks.
2759
+ await waitForReconcile(
2760
+ reconcilePluginSchedulesSpy,
2761
+ "plugin schedule reconcile",
2762
+ );
2763
+ expect(reconcilePluginSourcesNowSpy).not.toHaveBeenCalled();
2711
2764
  });
2712
2765
 
2713
2766
  test("a failed toggle does not poke the schedule reconcile", async () => {
@@ -26,6 +26,10 @@ interface StubConversation {
26
26
  handleSurfaceUndoCalled?: boolean;
27
27
  handleSurfaceUndoThrows?: Error;
28
28
  trustContext?: { trustClass: string; sourceChannel: string };
29
+ /** Drives `isProcessing()`; the route must not stamp trust either way. */
30
+ processing?: boolean;
31
+ /** Trust the route threaded into `handleSurfaceAction`. */
32
+ lastRequesterTrustContext?: { trustClass: string; sourceChannel: string };
29
33
  surfaceActionCalls: Array<{
30
34
  surfaceId: string;
31
35
  actionId: string;
@@ -160,7 +164,9 @@ function makeStub(id: string): StubConversation {
160
164
  actionId: string,
161
165
  data: unknown,
162
166
  sourceActorPrincipalId?: string,
167
+ requesterTrustContext?: { trustClass: string; sourceChannel: string },
163
168
  ) => {
169
+ stub.lastRequesterTrustContext = requesterTrustContext;
164
170
  stub.surfaceActionCalls.push({
165
171
  surfaceId,
166
172
  actionId,
@@ -181,6 +187,7 @@ function makeStub(id: string): StubConversation {
181
187
  setTrustContext: (ctx: { trustClass: string; sourceChannel: string }) => {
182
188
  stub.trustContext = ctx;
183
189
  },
190
+ isProcessing: () => stub.processing === true,
184
191
  });
185
192
  return stub;
186
193
  }
@@ -487,8 +494,8 @@ describe("triggerSurfaceAction trust context", () => {
487
494
  headers: { "x-vellum-actor-principal-id": GUARDIAN_PRINCIPAL },
488
495
  });
489
496
 
490
- expect(live.trustContext?.trustClass).toBe("guardian");
491
- expect(live.trustContext?.sourceChannel).toBe("vellum");
497
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("guardian");
498
+ expect(live.lastRequesterTrustContext?.sourceChannel).toBe("vellum");
492
499
  // First-pass resolve already granted guardian, so the drift helper is skipped.
493
500
  expect(reResolveCalls).toEqual([]);
494
501
  });
@@ -506,7 +513,7 @@ describe("triggerSurfaceAction trust context", () => {
506
513
  });
507
514
 
508
515
  expect(reResolveCalls).toEqual([VELLUM_PRINCIPAL_OLD]);
509
- expect(live.trustContext?.trustClass).toBe("unknown");
516
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("unknown");
510
517
  });
511
518
 
512
519
  test("reset drift: helper returns guardian → route adopts it", async () => {
@@ -522,7 +529,7 @@ describe("triggerSurfaceAction trust context", () => {
522
529
  });
523
530
 
524
531
  expect(reResolveCalls).toEqual([VELLUM_PRINCIPAL_OLD]);
525
- expect(live.trustContext?.trustClass).toBe("guardian");
532
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("guardian");
526
533
  });
527
534
 
528
535
  test("dev-bypass resolves the real guardian principal from the gateway, helper not called", async () => {
@@ -539,7 +546,7 @@ describe("triggerSurfaceAction trust context", () => {
539
546
 
540
547
  // The synthetic dev-bypass principal is translated to the real guardian,
541
548
  // yielding a guardian trust context without consulting the drift helper.
542
- expect(live.trustContext?.trustClass).toBe("guardian");
549
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("guardian");
543
550
  expect(reResolveCalls).toEqual([]);
544
551
  });
545
552
 
@@ -559,7 +566,49 @@ describe("triggerSurfaceAction trust context", () => {
559
566
  headers: { "x-vellum-actor-principal-id": "dev-bypass" },
560
567
  });
561
568
 
562
- expect(live.trustContext?.trustClass).toBe("unknown");
569
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("unknown");
570
+ });
571
+
572
+ test("idle conversation: the requester is threaded to the action and the slot is left to the dispatch", async () => {
573
+ mockGuardianList = [guardianDelivery(GUARDIAN_PRINCIPAL)];
574
+ const live = makeStub("conv-idle");
575
+ live.processing = false;
576
+ memoryBySurface = live;
577
+
578
+ const handler = findHandler("triggerSurfaceAction");
579
+ await handler({
580
+ body: { surfaceId: "surf-idle", actionId: "act-idle" },
581
+ headers: { "x-vellum-actor-principal-id": GUARDIAN_PRINCIPAL },
582
+ });
583
+
584
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("guardian");
585
+ // The route resolves; `handleSurfaceAction` stamps, and only where it
586
+ // commits to running the turn. Stamping here would repoint the actor of a
587
+ // turn that starts between this call and the dispatch.
588
+ expect(live.trustContext).toBeUndefined();
589
+ });
590
+
591
+ test("busy conversation: the running turn's actor is left alone and the click still travels as its own actor", async () => {
592
+ mockGuardianList = [guardianDelivery(GUARDIAN_PRINCIPAL)];
593
+ const live = makeStub("conv-busy");
594
+ // A turn is already running under a different actor.
595
+ const runningTurnActor = {
596
+ trustClass: "untrusted",
597
+ sourceChannel: "sms",
598
+ };
599
+ live.trustContext = runningTurnActor;
600
+ live.processing = true;
601
+ memoryBySurface = live;
602
+
603
+ const handler = findHandler("triggerSurfaceAction");
604
+ await handler({
605
+ body: { surfaceId: "surf-busy", actionId: "act-busy" },
606
+ headers: { "x-vellum-actor-principal-id": GUARDIAN_PRINCIPAL },
607
+ });
608
+
609
+ // Identical object, so a re-write of an equal value still fails this.
610
+ expect(live.trustContext).toBe(runningTurnActor);
611
+ expect(live.lastRequesterTrustContext?.trustClass).toBe("guardian");
563
612
  });
564
613
  });
565
614
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tests which system cards terminate a turn.
3
+ *
4
+ * Clients treat `message_complete` as terminal: it clears the processing state
5
+ * and closes the turn. A card that is itself the reply (a slash-command
6
+ * result) must emit it; a card a plugin posts while a turn is still running
7
+ * must not, or the client ends a turn that is still streaming.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ import { setConfig } from "../../../__tests__/helpers/set-config.js";
13
+ import type { AssistantEvent } from "../../../api/index.js";
14
+
15
+ // Keep the memory system off so addMessage skips indexing side effects.
16
+ setConfig("memory", { enabled: false });
17
+
18
+ // Capture what reaches connected clients, keeping the rest of the hub's
19
+ // surface intact for the module graph's other importers.
20
+ const eventHub = await import("../../assistant-event-hub.js");
21
+ const broadcastEvents: AssistantEvent[] = [];
22
+
23
+ /** The terminal turn events among everything broadcast so far. */
24
+ function completions(): AssistantEvent[] {
25
+ return broadcastEvents.filter((e) => e.type === "message_complete");
26
+ }
27
+
28
+ mock.module("../../assistant-event-hub.js", () => ({
29
+ ...eventHub,
30
+ broadcastMessage: (event: AssistantEvent) => {
31
+ broadcastEvents.push(event);
32
+ },
33
+ }));
34
+
35
+ const { createConversation } =
36
+ await import("../../../persistence/conversation-crud.js");
37
+ const { initializeDb } = await import("../../../persistence/db-init.js");
38
+ const { persistSystemCard } = await import("../canned-message-complete.js");
39
+
40
+ await initializeDb();
41
+
42
+ describe("persistSystemCard turn termination", () => {
43
+ beforeEach(() => {
44
+ broadcastEvents.length = 0;
45
+ });
46
+
47
+ test("announces a card that is the reply as a completed turn", async () => {
48
+ /**
49
+ * Tests that a card standing in for the assistant's reply closes the turn
50
+ * so the client stops showing it as processing.
51
+ */
52
+
53
+ // GIVEN a conversation
54
+ const conv = createConversation();
55
+
56
+ // WHEN a card that ends the turn is persisted
57
+ const card = await persistSystemCard({
58
+ conversationId: conv.id,
59
+ text: "Compacted 12 messages.",
60
+ metadata: {},
61
+ endsTurn: true,
62
+ });
63
+
64
+ // THEN clients are told the turn completed, pointing at the card row
65
+ expect(completions()).toEqual([
66
+ {
67
+ type: "message_complete",
68
+ conversationId: conv.id,
69
+ messageId: card.id,
70
+ },
71
+ ]);
72
+ });
73
+
74
+ test("leaves an in-flight turn running when a card is posted mid-turn", async () => {
75
+ /**
76
+ * Tests that a card written while the model is still working does not end
77
+ * the turn in the client, which would clear processing state and let a
78
+ * second message start while the first turn is still streaming.
79
+ */
80
+
81
+ // GIVEN a conversation
82
+ const conv = createConversation();
83
+
84
+ // WHEN a non-terminal card is persisted
85
+ const card = await persistSystemCard({
86
+ conversationId: conv.id,
87
+ text: "The image you attached was not sent to the model.",
88
+ metadata: { plugin: "image-fallback" },
89
+ endsTurn: false,
90
+ });
91
+
92
+ // THEN the card is persisted
93
+ expect(card.id).toBeTruthy();
94
+
95
+ // AND no terminal turn event reaches clients
96
+ expect(completions()).toEqual([]);
97
+
98
+ // AND clients are still told to refetch the transcript so the card shows
99
+ expect(
100
+ broadcastEvents.some(
101
+ (e) =>
102
+ e.type === "sync_changed" &&
103
+ e.tags.includes(`conversation:${conv.id}:messages`),
104
+ ),
105
+ ).toBe(true);
106
+ });
107
+ });