@vellumai/assistant 0.10.1 → 0.10.2-dev.202606241651.2d2b40d

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/docs/workspace-tools.md +42 -33
  2. package/eslint-rules/cli-no-daemon-internals.js +6 -0
  3. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-delivery-contract.test.ts +91 -0
  4. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +31 -0
  5. package/node_modules/@vellumai/gateway-client/src/guardian-delivery-contract.ts +48 -0
  6. package/node_modules/@vellumai/gateway-client/src/index.ts +14 -0
  7. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +17 -0
  8. package/openapi.yaml +74 -1
  9. package/package.json +1 -1
  10. package/scripts/test.sh +36 -15
  11. package/src/__tests__/actor-token-service.test.ts +36 -14
  12. package/src/__tests__/agent-loop-override-profile.test.ts +1 -0
  13. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  14. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  15. package/src/__tests__/annotate-activity-metadata.test.ts +2 -0
  16. package/src/__tests__/annotate-risk-options.test.ts +2 -0
  17. package/src/__tests__/approval-cascade.test.ts +2 -0
  18. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -0
  19. package/src/__tests__/btw-routes.test.ts +2 -0
  20. package/src/__tests__/build-persisted-content.test.ts +2 -0
  21. package/src/__tests__/call-controller.test.ts +19 -0
  22. package/src/__tests__/channel-guardian.test.ts +94 -58
  23. package/src/__tests__/channel-reply-delivery.test.ts +2 -0
  24. package/src/__tests__/compaction-events.test.ts +2 -0
  25. package/src/__tests__/compaction.benchmark.test.ts +2 -0
  26. package/src/__tests__/compactor-call-site-logging.test.ts +2 -0
  27. package/src/__tests__/compactor-low-watermark-cut.test.ts +2 -0
  28. package/src/__tests__/compactor-preserved-tail-count.test.ts +2 -0
  29. package/src/__tests__/compactor-summary-call-truncation.test.ts +2 -0
  30. package/src/__tests__/compactor-web-search-strip.test.ts +2 -0
  31. package/src/__tests__/computer-use-tools.test.ts +13 -0
  32. package/src/__tests__/config-loader-backfill.test.ts +5 -1
  33. package/src/__tests__/config-schema.test.ts +1 -0
  34. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +31 -29
  35. package/src/__tests__/contacts-relay-reads.test.ts +13 -15
  36. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  37. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +2 -0
  38. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  40. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  41. package/src/__tests__/conversation-analysis-routes.test.ts +2 -0
  42. package/src/__tests__/conversation-app-control-lifecycle.test.ts +2 -0
  43. package/src/__tests__/conversation-confirmation-signals.test.ts +2 -0
  44. package/src/__tests__/conversation-history-web-search.test.ts +2 -0
  45. package/src/__tests__/conversation-load-history-repair.test.ts +2 -0
  46. package/src/__tests__/conversation-load-history-stripped.test.ts +2 -0
  47. package/src/__tests__/conversation-pairing.test.ts +2 -0
  48. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +2 -0
  49. package/src/__tests__/conversation-process-callsite.test.ts +2 -0
  50. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  51. package/src/__tests__/conversation-queue.test.ts +91 -0
  52. package/src/__tests__/conversation-routes-guardian-reply.test.ts +14 -0
  53. package/src/__tests__/conversation-routes-slash-commands.test.ts +14 -0
  54. package/src/__tests__/conversation-slash-queue.test.ts +2 -0
  55. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  56. package/src/__tests__/conversation-speed-override.test.ts +2 -0
  57. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +65 -0
  58. package/src/__tests__/conversation-title-service.test.ts +2 -0
  59. package/src/__tests__/conversation-tool-setup-attribution.test.ts +47 -0
  60. package/src/__tests__/conversation-usage.test.ts +2 -0
  61. package/src/__tests__/conversation-workspace-cache-state.test.ts +2 -0
  62. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  63. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  64. package/src/__tests__/credential-security-invariants.test.ts +0 -1
  65. package/src/__tests__/db-migration-rollback.test.ts +205 -171
  66. package/src/__tests__/db-test-helpers.ts +5 -4
  67. package/src/__tests__/deterministic-verification-control-plane.test.ts +4 -2
  68. package/src/__tests__/disk-pressure-guard.test.ts +41 -0
  69. package/src/__tests__/dm-persistence.test.ts +2 -0
  70. package/src/__tests__/emit-signal-routing-intent.test.ts +10 -5
  71. package/src/__tests__/events-dev-bypass-actor.test.ts +7 -1
  72. package/src/__tests__/filing-service.test.ts +2 -0
  73. package/src/__tests__/guardian-binding-drift-heal.test.ts +75 -10
  74. package/src/__tests__/guardian-dispatch.test.ts +95 -1
  75. package/src/__tests__/guardian-outbound-http.test.ts +13 -0
  76. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  77. package/src/__tests__/heartbeat-service.test.ts +2 -0
  78. package/src/__tests__/helpers/channel-test-adapter.ts +1 -7
  79. package/src/__tests__/host-app-control-routes.test.ts +24 -30
  80. package/src/__tests__/host-bash-routes.test.ts +31 -41
  81. package/src/__tests__/host-browser-routes.test.ts +26 -32
  82. package/src/__tests__/host-cu-proxy.test.ts +299 -0
  83. package/src/__tests__/host-cu-routes-targeted.test.ts +25 -33
  84. package/src/__tests__/host-file-routes-targeted.test.ts +40 -52
  85. package/src/__tests__/host-transfer-routes-targeted.test.ts +31 -43
  86. package/src/__tests__/http-user-message-parity.test.ts +167 -8
  87. package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
  88. package/src/__tests__/invite-redemption-service.test.ts +43 -0
  89. package/src/__tests__/llm-context-normalization.test.ts +105 -0
  90. package/src/__tests__/llm-usage-store.test.ts +25 -0
  91. package/src/__tests__/media-stream-server-integration.test.ts +127 -0
  92. package/src/__tests__/memory-retrieval-hook.test.ts +2 -0
  93. package/src/__tests__/messaging-send-tool.test.ts +2 -0
  94. package/src/__tests__/migration-import-from-url.test.ts +2 -2
  95. package/src/__tests__/native-web-search.test.ts +2 -0
  96. package/src/__tests__/non-member-access-request.test.ts +189 -17
  97. package/src/__tests__/notification-broadcaster.test.ts +4 -0
  98. package/src/__tests__/notification-decision-recipient-context.test.ts +33 -32
  99. package/src/__tests__/notification-deep-link.test.ts +6 -0
  100. package/src/__tests__/notification-guardian-path.test.ts +19 -0
  101. package/src/__tests__/outbound-slack-persistence.test.ts +2 -0
  102. package/src/__tests__/pending-interactions-resolved-event.test.ts +7 -4
  103. package/src/__tests__/persistence-secret-redaction.test.ts +2 -0
  104. package/src/__tests__/plugin-bootstrap.test.ts +3 -73
  105. package/src/__tests__/plugin-route-contribution.test.ts +4 -17
  106. package/src/__tests__/plugin-tool-contribution.test.ts +3 -18
  107. package/src/__tests__/plugin-types.test.ts +0 -2
  108. package/src/__tests__/process-message-background-slack.test.ts +2 -0
  109. package/src/__tests__/process-message-display-content.test.ts +2 -0
  110. package/src/__tests__/provider-usage-tracking.test.ts +39 -0
  111. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +2 -0
  112. package/src/__tests__/registry.test.ts +3 -0
  113. package/src/__tests__/relay-server.test.ts +694 -25
  114. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  115. package/src/__tests__/secret-ingress-http.test.ts +14 -0
  116. package/src/__tests__/send-endpoint-busy.test.ts +30 -8
  117. package/src/__tests__/skills.test.ts +44 -0
  118. package/src/__tests__/slack-inbound-verification.test.ts +47 -2
  119. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +102 -0
  120. package/src/__tests__/steer-on-enqueue-question.test.ts +181 -0
  121. package/src/__tests__/stt-hints.test.ts +44 -13
  122. package/src/__tests__/subagent-detail.test.ts +27 -0
  123. package/src/__tests__/subagent-disposal.test.ts +65 -0
  124. package/src/__tests__/subagent-notify-parent.test.ts +2 -0
  125. package/src/__tests__/subagent-spawn-tool-fork.test.ts +2 -0
  126. package/src/__tests__/subagent-tools.test.ts +2 -0
  127. package/src/__tests__/suggestion-routes.test.ts +2 -0
  128. package/src/__tests__/title-generate-hook.test.ts +2 -0
  129. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
  130. package/src/__tests__/tool-executor.test.ts +16 -11
  131. package/src/__tests__/tool-preview-lifecycle.test.ts +2 -0
  132. package/src/__tests__/tool-result-metadata-plumbing.test.ts +2 -0
  133. package/src/__tests__/tool-start-timestamp.test.ts +2 -0
  134. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +10 -10
  135. package/src/__tests__/twilio-routes.test.ts +96 -0
  136. package/src/__tests__/verification-control-plane-policy.test.ts +2 -0
  137. package/src/__tests__/web-search-backend-failure.test.ts +2 -0
  138. package/src/__tests__/workspace-tool-loader.test.ts +195 -2
  139. package/src/agent/loop-exclusive-tool.test.ts +150 -0
  140. package/src/agent/loop.ts +56 -0
  141. package/src/api/constants/sse-replay.ts +41 -0
  142. package/src/api/index.ts +6 -0
  143. package/src/api/responses/llm-request-log-entry.ts +25 -0
  144. package/src/api/responses/subagent-detail.ts +17 -0
  145. package/src/calls/__tests__/relay-setup-router.test.ts +262 -4
  146. package/src/calls/call-domain.ts +3 -3
  147. package/src/calls/guardian-dispatch.ts +10 -8
  148. package/src/calls/inbound-trust-reader.ts +17 -1
  149. package/src/calls/media-stream-server.ts +21 -0
  150. package/src/calls/relay-server.ts +167 -50
  151. package/src/calls/relay-setup-router.ts +37 -7
  152. package/src/calls/relay-verification.ts +4 -4
  153. package/src/calls/stt-hints.ts +9 -12
  154. package/src/calls/twilio-routes.ts +14 -4
  155. package/src/cli/commands/__tests__/cache.test.ts +8 -1
  156. package/src/cli/commands/cache.ts +194 -181
  157. package/src/cli/commands/db/__tests__/repair.test.ts +6 -5
  158. package/src/cli/commands/db/status.ts +37 -1
  159. package/src/cli/commands/mcp.ts +252 -218
  160. package/src/cli/commands/memory/__tests__/worker.test.ts +302 -0
  161. package/src/cli/commands/memory/index.ts +2 -0
  162. package/src/cli/commands/memory/worker.ts +175 -0
  163. package/src/cli/commands/plugins.ts +75 -3
  164. package/src/cli/lib/__tests__/install-from-github.test.ts +102 -0
  165. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +160 -1
  166. package/src/cli/lib/list-installed-plugins.ts +179 -1
  167. package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +143 -0
  168. package/src/config/bundled-skills/computer-use/TOOLS.json +6 -1
  169. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +27 -17
  170. package/src/config/bundled-skills/contacts/tools/contact-search.ts +13 -3
  171. package/src/config/feature-flag-registry.json +0 -8
  172. package/src/config/loader.ts +36 -5
  173. package/src/config/schemas/__tests__/memory-v3.test.ts +1 -0
  174. package/src/config/schemas/memory-lifecycle.ts +12 -0
  175. package/src/config/schemas/memory-v3.ts +7 -0
  176. package/src/config/schemas/memory.ts +4 -0
  177. package/src/config/schemas/timeouts.ts +8 -0
  178. package/src/config/seed-inference-profiles.ts +14 -5
  179. package/src/config/skills.ts +27 -5
  180. package/src/contacts/__tests__/guardian-delivery-reader.test.ts +312 -0
  181. package/src/contacts/contacts-write.ts +3 -0
  182. package/src/contacts/guardian-delivery-reader.ts +223 -0
  183. package/src/daemon/conversation-agent-loop.ts +9 -0
  184. package/src/daemon/conversation-process.ts +39 -17
  185. package/src/daemon/conversation-surfaces.ts +8 -0
  186. package/src/daemon/conversation-tool-setup.ts +49 -16
  187. package/src/daemon/conversation.ts +21 -2
  188. package/src/daemon/disk-pressure-guard.ts +12 -2
  189. package/src/daemon/event-loop-watchdog.ts +28 -1
  190. package/src/daemon/external-plugins-bootstrap.ts +4 -34
  191. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +25 -0
  192. package/src/daemon/handlers/__tests__/config-channels.test.ts +225 -0
  193. package/src/daemon/handlers/config-a2a.ts +6 -14
  194. package/src/daemon/handlers/config-channels.ts +78 -22
  195. package/src/daemon/handlers/conversations.ts +77 -0
  196. package/src/daemon/host-cu-proxy.ts +102 -11
  197. package/src/daemon/lifecycle.ts +4 -0
  198. package/src/daemon/memory-v2-startup.test.ts +72 -0
  199. package/src/daemon/memory-v2-startup.ts +87 -19
  200. package/src/daemon/server.ts +0 -4
  201. package/src/daemon/shutdown-handlers.ts +20 -0
  202. package/src/daemon/tool-setup-types.ts +9 -0
  203. package/src/ipc/__tests__/clients-list-ipc.test.ts +1 -1
  204. package/src/ipc/assistant-server.ts +2 -2
  205. package/src/memory/__tests__/301-create-watchdog-events.test.ts +110 -0
  206. package/src/memory/__tests__/memory-retrospective-job.test.ts +8 -0
  207. package/src/memory/__tests__/prompt-override.test.ts +192 -0
  208. package/src/memory/__tests__/watchdog-events-store.test.ts +161 -0
  209. package/src/memory/conversation-crud.ts +38 -0
  210. package/src/memory/db-connection.ts +22 -3
  211. package/src/memory/db-init.ts +36 -502
  212. package/src/memory/db-singleton.ts +6 -4
  213. package/src/memory/jobs-worker.ts +58 -0
  214. package/src/memory/llm-usage-store.ts +48 -20
  215. package/src/memory/memory-retrospective-job.ts +9 -8
  216. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +13 -3
  217. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -27
  218. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +130 -56
  219. package/src/memory/migrations/300-add-processing-started-at.ts +30 -0
  220. package/src/memory/migrations/301-create-watchdog-events.ts +45 -0
  221. package/src/memory/migrations/__tests__/014-backfill-inbox-thread-state.test.ts +108 -0
  222. package/src/memory/migrations/__tests__/136-drop-assistant-id-columns.test.ts +82 -0
  223. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +224 -0
  224. package/src/memory/migrations/__tests__/run-migrations.test.ts +2 -2
  225. package/src/memory/migrations/run-migrations.ts +90 -6
  226. package/src/memory/migrations/schema-introspection.ts +14 -0
  227. package/src/memory/migrations/validate-migration-state.ts +101 -66
  228. package/src/memory/prompt-override.ts +129 -0
  229. package/src/memory/schema/conversations.ts +9 -0
  230. package/src/memory/schema/infrastructure.ts +20 -0
  231. package/src/memory/steps.ts +573 -0
  232. package/src/memory/v2/__tests__/cli-command-store.test.ts +25 -0
  233. package/src/memory/v2/__tests__/skill-store.test.ts +80 -0
  234. package/src/memory/v2/cli-command-store.ts +75 -38
  235. package/src/memory/v2/prompts/consolidation.ts +13 -82
  236. package/src/memory/v2/prompts/router.ts +21 -93
  237. package/src/memory/v2/skill-store.ts +68 -31
  238. package/src/memory/watchdog-events-store.ts +87 -0
  239. package/src/memory/worker-control.ts +118 -0
  240. package/src/memory/worker-process.ts +72 -0
  241. package/src/notifications/__tests__/broadcaster.test.ts +16 -8
  242. package/src/notifications/__tests__/connected-channels.test.ts +114 -0
  243. package/src/notifications/__tests__/decision-engine.test.ts +78 -9
  244. package/src/notifications/__tests__/destination-resolver.test.ts +256 -0
  245. package/src/notifications/broadcaster.ts +8 -1
  246. package/src/notifications/decision-engine.ts +15 -7
  247. package/src/notifications/destination-resolver.ts +68 -24
  248. package/src/notifications/emit-signal.ts +39 -14
  249. package/src/onboarding/checkin-event.test.ts +220 -0
  250. package/src/onboarding/checkin-event.ts +321 -0
  251. package/src/onboarding/schedule-checkin.ts +190 -0
  252. package/src/permissions/question-prompter.test.ts +1 -1
  253. package/src/permissions/question-prompter.ts +7 -4
  254. package/src/plugin-api/index.ts +6 -6
  255. package/src/plugin-api/types.ts +3 -5
  256. package/src/plugin-api/vision-support.test.ts +28 -4
  257. package/src/plugin-api/vision-support.ts +66 -31
  258. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +161 -0
  259. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +106 -0
  260. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +60 -0
  261. package/src/plugins/defaults/advisor/consult.ts +110 -6
  262. package/src/plugins/defaults/advisor/context-pack.ts +288 -0
  263. package/src/plugins/defaults/advisor/steering.ts +14 -2
  264. package/src/plugins/defaults/advisor/tools/advisor.ts +32 -5
  265. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +47 -7
  266. package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +10 -11
  267. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +12 -20
  268. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +42 -11
  269. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +11 -2
  270. package/src/plugins/defaults/memory-v3-shadow/pool-select.test.ts +146 -0
  271. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +29 -1
  272. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +8 -1
  273. package/src/plugins/mtime-cache.ts +7 -2
  274. package/src/plugins/types.ts +0 -2
  275. package/src/providers/anthropic/client.ts +5 -0
  276. package/src/providers/call-site-routing.ts +4 -0
  277. package/src/providers/model-catalog.ts +16 -0
  278. package/src/providers/openai/responses-provider.ts +5 -0
  279. package/src/providers/openrouter/client.ts +5 -0
  280. package/src/providers/provider-send-message.ts +4 -0
  281. package/src/providers/ratelimit.ts +4 -0
  282. package/src/providers/retry.ts +4 -0
  283. package/src/providers/types.ts +9 -0
  284. package/src/providers/usage-tracking.ts +4 -0
  285. package/src/runtime/__tests__/channel-verification-service.test.ts +133 -0
  286. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +181 -0
  287. package/src/runtime/__tests__/is-guardian-bound-for-channel.test.ts +66 -0
  288. package/src/runtime/__tests__/local-principal-trust.test.ts +164 -0
  289. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +335 -3
  290. package/src/runtime/access-request-helper.ts +19 -39
  291. package/src/runtime/actor-trust-resolver.ts +2 -2
  292. package/src/runtime/anchored-guardian.test.ts +156 -0
  293. package/src/runtime/anchored-guardian.ts +135 -0
  294. package/src/runtime/assistant-event-hub.ts +1 -1
  295. package/src/runtime/assistant-stream-state.ts +9 -2
  296. package/src/runtime/auth/__tests__/require-bound-guardian.test.ts +99 -0
  297. package/src/runtime/auth/require-bound-guardian.ts +21 -11
  298. package/src/runtime/channel-verification-service.ts +56 -31
  299. package/src/runtime/confirmation-request-guardian-bridge.ts +3 -3
  300. package/src/runtime/guardian-vellum-migration.ts +66 -7
  301. package/src/runtime/invite-redemption-service.ts +50 -18
  302. package/src/runtime/local-actor-identity.ts +76 -11
  303. package/src/runtime/local-principal-trust.ts +52 -0
  304. package/src/runtime/pending-interactions.ts +11 -1
  305. package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +56 -5
  306. package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
  307. package/src/runtime/routes/__tests__/contact-routes.test.ts +212 -0
  308. package/src/runtime/routes/__tests__/global-search-routes.test.ts +93 -0
  309. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +215 -1
  310. package/src/runtime/routes/browser-routes.ts +1 -1
  311. package/src/runtime/routes/channel-verification-routes.ts +3 -3
  312. package/src/runtime/routes/contact-routes.ts +8 -32
  313. package/src/runtime/routes/conversation-cli-routes.ts +4 -5
  314. package/src/runtime/routes/conversation-list-routes.ts +4 -7
  315. package/src/runtime/routes/conversation-routes.ts +74 -81
  316. package/src/runtime/routes/events-routes.ts +2 -2
  317. package/src/runtime/routes/global-search-routes.ts +3 -1
  318. package/src/runtime/routes/guardian-action-routes.ts +4 -5
  319. package/src/runtime/routes/host-app-control-routes.ts +5 -4
  320. package/src/runtime/routes/host-bash-routes.ts +5 -4
  321. package/src/runtime/routes/host-browser-routes.ts +9 -11
  322. package/src/runtime/routes/host-cu-routes.ts +5 -4
  323. package/src/runtime/routes/host-file-routes.ts +5 -4
  324. package/src/runtime/routes/host-transfer-routes.ts +6 -6
  325. package/src/runtime/routes/http-adapter.ts +1 -1
  326. package/src/runtime/routes/identity-routes.ts +3 -2
  327. package/src/runtime/routes/inbound-message-handler.ts +5 -5
  328. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +97 -5
  329. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +61 -49
  330. package/src/runtime/routes/inbound-stages/background-dispatch.ts +16 -4
  331. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +7 -7
  332. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +21 -8
  333. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +14 -3
  334. package/src/runtime/routes/index.ts +2 -0
  335. package/src/runtime/routes/llm-context-normalization.ts +71 -0
  336. package/src/runtime/routes/mcp-auth-routes.ts +38 -15
  337. package/src/runtime/routes/migration-rollback-routes.ts +4 -3
  338. package/src/runtime/routes/migration-routes.ts +4 -1
  339. package/src/runtime/routes/onboarding-checkin-routes.ts +86 -0
  340. package/src/runtime/routes/subagents-routes.ts +5 -0
  341. package/src/runtime/routes/surface-action-routes.ts +51 -55
  342. package/src/runtime/services/__tests__/conversation-serializer.test.ts +1 -0
  343. package/src/runtime/services/conversation-serializer.ts +7 -9
  344. package/src/runtime/tool-grant-request-helper.ts +3 -3
  345. package/src/runtime/trust-verdict-consumer.ts +85 -9
  346. package/src/runtime/verification-outbound-actions.ts +18 -18
  347. package/src/signals/user-message.ts +16 -0
  348. package/src/subagent/manager.ts +9 -0
  349. package/src/telemetry/types.ts +34 -1
  350. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  351. package/src/telemetry/usage-telemetry-reporter.ts +87 -3
  352. package/src/tools/ask-question/ask-question-tool.test.ts +29 -0
  353. package/src/tools/ask-question/ask-question-tool.ts +13 -0
  354. package/src/tools/computer-use/definitions.ts +8 -2
  355. package/src/tools/executor.ts +4 -4
  356. package/src/tools/registry.ts +18 -0
  357. package/src/tools/tool-approval-handler.ts +1 -1
  358. package/src/tools/tool-defaults.ts +9 -2
  359. package/src/tools/types.ts +17 -2
  360. package/src/tools/workspace-tools/loader.ts +348 -244
  361. package/src/util/platform.ts +5 -0
  362. package/src/util/telemetry-db-path.ts +24 -0
  363. package/src/workspace/migrations/017-seed-persona-dirs.ts +3 -34
  364. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +3 -24
  365. package/src/__tests__/workspace-tools-watcher-flag.test.ts +0 -70
  366. package/src/daemon/workspace-tools-watcher.ts +0 -328
  367. package/src/memory/migrations/registry.ts +0 -573
@@ -1,4 +1,5 @@
1
- import type { ContactWithChannels } from "../../../../contacts/types.js";
1
+ import type { ContactRead } from "@vellumai/gateway-client/gateway-ipc-contracts";
2
+
2
3
  import { cliIpcCall } from "../../../../ipc/cli-client.js";
3
4
  import { resolveGuardianName } from "../../../../prompts/user-reference.js";
4
5
  import type {
@@ -6,6 +7,12 @@ import type {
6
7
  ToolExecutionResult,
7
8
  } from "../../../../tools/types.js";
8
9
 
10
+ function guardianAwareName(contact: Pick<ContactRead, "role" | "displayName">) {
11
+ return contact.role === "guardian"
12
+ ? resolveGuardianName(contact.displayName)
13
+ : contact.displayName;
14
+ }
15
+
9
16
  export async function executeContactMerge(
10
17
  input: Record<string, unknown>,
11
18
  _context: ToolContext,
@@ -22,10 +29,10 @@ export async function executeContactMerge(
22
29
 
23
30
  // Validate both contacts exist before merging
24
31
  const [keepRes, mergeRes] = await Promise.all([
25
- cliIpcCall<{ contact: ContactWithChannels }>("getContact", {
32
+ cliIpcCall<{ contact: ContactRead }>("getContact", {
26
33
  pathParams: { id: keepId },
27
34
  }),
28
- cliIpcCall<{ contact: ContactWithChannels }>("getContact", {
35
+ cliIpcCall<{ contact: ContactRead }>("getContact", {
29
36
  pathParams: { id: mergeId },
30
37
  }),
31
38
  ]);
@@ -42,7 +49,7 @@ export async function executeContactMerge(
42
49
 
43
50
  const mergeResult = await cliIpcCall<{
44
51
  ok: boolean;
45
- contact: ContactWithChannels;
52
+ contact: ContactRead;
46
53
  }>("merge_contacts", {
47
54
  body: { keepId, mergeId },
48
55
  });
@@ -51,19 +58,22 @@ export async function executeContactMerge(
51
58
  return { content: `Error: ${mergeResult.error}`, isError: true };
52
59
  }
53
60
 
54
- const merged = mergeResult.result!.contact;
55
- const displayName =
56
- merged.role === "guardian"
57
- ? resolveGuardianName(merged.displayName)
58
- : merged.displayName;
59
- const keepName =
60
- keepContact.role === "guardian"
61
- ? resolveGuardianName(keepContact.displayName)
62
- : keepContact.displayName;
63
- const mergeName =
64
- mergeContact.role === "guardian"
65
- ? resolveGuardianName(mergeContact.displayName)
66
- : mergeContact.displayName;
61
+ const mergedId = mergeResult.result!.contact.id;
62
+
63
+ // Re-read the surviving contact through the gateway-relayed read so role and
64
+ // interactionCount come from the gateway ContactRead.
65
+ const mergedRes = await cliIpcCall<{ contact: ContactRead }>("getContact", {
66
+ pathParams: { id: mergedId },
67
+ });
68
+
69
+ if (!mergedRes.ok) {
70
+ return { content: `Error: ${mergedRes.error}`, isError: true };
71
+ }
72
+
73
+ const merged = mergedRes.result!.contact;
74
+ const displayName = guardianAwareName(merged);
75
+ const keepName = guardianAwareName(keepContact);
76
+ const mergeName = guardianAwareName(mergeContact);
67
77
 
68
78
  const channelList = merged.channels
69
79
  .map(
@@ -1,4 +1,5 @@
1
- import type { ContactWithChannels } from "../../../../contacts/types.js";
1
+ import type { ContactRead } from "@vellumai/gateway-client/gateway-ipc-contracts";
2
+
2
3
  import { cliIpcCall } from "../../../../ipc/cli-client.js";
3
4
  import { resolveGuardianName } from "../../../../prompts/user-reference.js";
4
5
  import type {
@@ -6,7 +7,16 @@ import type {
6
7
  ToolExecutionResult,
7
8
  } from "../../../../tools/types.js";
8
9
 
9
- function formatContactSummary(c: ContactWithChannels): string {
10
+ // The search route may carry an optional per-channel `externalChatId` not modeled
11
+ // on the gateway `ContactRead` channel contract.
12
+ type SearchChannel = ContactRead["channels"][number] & {
13
+ externalChatId?: string | null;
14
+ };
15
+ type SearchContact = Omit<ContactRead, "channels"> & {
16
+ channels: SearchChannel[];
17
+ };
18
+
19
+ function formatContactSummary(c: SearchContact): string {
10
20
  const displayName =
11
21
  c.role === "guardian" ? resolveGuardianName(c.displayName) : c.displayName;
12
22
  const parts = [`- **${displayName}** (ID: ${c.id})`];
@@ -45,7 +55,7 @@ export async function executeContactSearch(
45
55
  };
46
56
  }
47
57
 
48
- const res = await cliIpcCall<ContactWithChannels[]>("search_contacts", {
58
+ const res = await cliIpcCall<SearchContact[]>("search_contacts", {
49
59
  body: { query, channelAddress, channelType, limit },
50
60
  });
51
61
 
@@ -66,14 +66,6 @@
66
66
  "description": "Gate per-turn conversation trace (user/assistant/tool-call/tool-response) collection. The daemon attaches a trace to its turn telemetry only when this flag AND the owner's share_diagnostics consent are both on. Defaults off (fail-closed) — the live value is delivered from LaunchDarkly.",
67
67
  "defaultEnabled": false
68
68
  },
69
- {
70
- "id": "workspace-tools-watcher",
71
- "scope": "assistant",
72
- "key": "workspace-tools-watcher",
73
- "label": "Workspace tools file watcher",
74
- "description": "Hot-reload workspace tool overrides under `<workspaceDir>/tools/` by watching the directory and re-registering tools as files change. When disabled, workspace tools still load from disk once at daemon startup, but live edits require a restart to take effect.",
75
- "defaultEnabled": false
76
- },
77
69
  {
78
70
  "id": "settings-developer-nav",
79
71
  "scope": "assistant",
@@ -300,14 +300,20 @@ function validateWithSchema(raw: Record<string, unknown>): AssistantConfig {
300
300
  }
301
301
 
302
302
  // Strip invalid fields by setting them to undefined so Zod defaults apply,
303
- // then re-parse. We walk the error paths and delete the offending keys.
303
+ // then re-parse. We walk the error paths and delete the offending keys,
304
+ // pruning any ancestor object the deletion leaves empty. Pruning matters for
305
+ // nested overrides like `llm.callSites.<id>.profile`: stripping just the
306
+ // invalid `.profile` leaf would leave `llm.callSites.<id> = {}`, which the
307
+ // resolver treats as a present (non-default) override and so skips the
308
+ // shipped call-site default — silently downgrading the call site to the
309
+ // active profile. Removing the emptied object lets that default apply.
304
310
  const cleaned = structuredClone(raw);
305
311
  for (const issue of result.error.issues) {
306
312
  if (issue.path.length === 0) {
307
313
  // Top-level error — return full defaults
308
314
  return cloneDefaultConfig();
309
315
  }
310
- deleteNestedKey(cleaned, issue.path as (string | number)[]);
316
+ deleteNestedKey(cleaned, issue.path as (string | number)[], true);
311
317
  }
312
318
 
313
319
  const retry = AssistantConfigSchema.safeParse(cleaned);
@@ -320,17 +326,42 @@ function validateWithSchema(raw: Record<string, unknown>): AssistantConfig {
320
326
  return cloneDefaultConfig();
321
327
  }
322
328
 
329
+ /**
330
+ * Delete the key at `path` from `obj`. When `pruneEmptyAncestors` is set, also
331
+ * remove any ancestor object the deletion leaves empty, walking up until the
332
+ * first ancestor that still holds other keys. Only empty plain objects are
333
+ * pruned (arrays are left alone), and a still-populated ancestor stops the walk
334
+ * so a container holding other config is never removed.
335
+ */
323
336
  function deleteNestedKey(
324
337
  obj: Record<string, unknown>,
325
338
  path: (string | number)[],
339
+ pruneEmptyAncestors = false,
326
340
  ): void {
341
+ // Record each (container, key) hop on the way down so we can prune upward
342
+ // after deleting the leaf.
343
+ const chain: Array<{ container: Record<string, unknown>; key: string }> = [];
327
344
  let current: unknown = obj;
328
345
  for (let i = 0; i < path.length - 1; i++) {
329
346
  if (current == null || typeof current !== "object") return;
330
- current = (current as Record<string, unknown>)[String(path[i])];
347
+ const key = String(path[i]);
348
+ chain.push({ container: current as Record<string, unknown>, key });
349
+ current = (current as Record<string, unknown>)[key];
331
350
  }
332
- if (current != null && typeof current === "object") {
333
- delete (current as Record<string, unknown>)[String(path[path.length - 1])];
351
+ if (current == null || typeof current !== "object") return;
352
+ delete (current as Record<string, unknown>)[String(path[path.length - 1])];
353
+
354
+ if (!pruneEmptyAncestors) return;
355
+ // Remove ancestors emptied by the deletion, deepest first; stop at the first
356
+ // that still has keys.
357
+ for (let i = chain.length - 1; i >= 0; i--) {
358
+ const { container, key } = chain[i];
359
+ const child = container[key];
360
+ if (isPlainObject(child) && Object.keys(child).length === 0) {
361
+ delete container[key];
362
+ } else {
363
+ break;
364
+ }
334
365
  }
335
366
  }
336
367
 
@@ -22,6 +22,7 @@ describe("MemoryV3ConfigSchema", () => {
22
22
  needleK: 100,
23
23
  denseK: 100,
24
24
  replyQueryK: 12,
25
+ selectorPromptPath: null,
25
26
  edge: { hubDegree: 30, seedCount: 18, perSeed: 6, cap: 45 },
26
27
  });
27
28
  });
@@ -78,6 +78,17 @@ export const MemoryJobsConfigSchema = MemoryJobsConfigInputSchema.transform(
78
78
  },
79
79
  ).describe("Memory background job processing configuration");
80
80
 
81
+ export const MemoryWorkerConfigSchema = z
82
+ .object({
83
+ enabled: z
84
+ .boolean({ error: "memory.worker.enabled must be a boolean" })
85
+ .default(false)
86
+ .describe(
87
+ "Whether the memory jobs worker runs as a separate OS process spawned at assistant startup (the `assistant memory worker` implementation) instead of on the assistant's main event loop. Only affects startup; shutdown stops whichever worker is actually running.",
88
+ ),
89
+ })
90
+ .describe("Memory jobs worker process configuration");
91
+
81
92
  export const MemoryRetentionConfigSchema = z
82
93
  .object({
83
94
  keepRawForever: z
@@ -185,6 +196,7 @@ export const MemoryMaintenanceConfigSchema = z
185
196
  );
186
197
 
187
198
  export type MemoryJobsConfig = z.infer<typeof MemoryJobsConfigSchema>;
199
+ export type MemoryWorkerConfig = z.infer<typeof MemoryWorkerConfigSchema>;
188
200
  export type MemoryRetentionConfig = z.infer<typeof MemoryRetentionConfigSchema>;
189
201
  export type MemoryCleanupConfig = z.infer<typeof MemoryCleanupConfigSchema>;
190
202
  export type MemoryMaintenanceConfig = z.infer<
@@ -259,6 +259,13 @@ export const MemoryV3ConfigSchema = z
259
259
  .describe(
260
260
  "Per-lane article budget for the reply-query finder pass: needle and dense each re-run over the assistant's previous message as separate queries (never concatenated with the user's message). 0 disables the pass. Deliberately small next to needleK/denseK — the pass adds the assistant-side retrieval signal, not a second full sweep.",
261
261
  ),
262
+ selectorPromptPath: z
263
+ .string({ error: "memory.v3.selectorPromptPath must be a string" })
264
+ .nullable()
265
+ .default(null)
266
+ .describe(
267
+ "Optional path to a file whose contents replace the bundled per-turn selector system prompt (the instructions that tell the selector which candidate pages to keep). Absolute paths are used as-is, a leading `~/` is expanded to the home directory, otherwise the path is resolved under the workspace root. The selector prompt takes no placeholders — the candidate pool is supplied separately as the user message — so the file is used verbatim. If the file is missing, unreadable, empty, or over 1 MiB, the bundled prompt is used and a warning is logged.",
268
+ ),
262
269
  edge: MemoryV3EdgeSchema.default(MemoryV3EdgeSchema.parse({})),
263
270
  })
264
271
  .describe("Memory v3 — section-grain lane retrieval");
@@ -5,6 +5,7 @@ import {
5
5
  MemoryJobsConfigSchema,
6
6
  MemoryMaintenanceConfigSchema,
7
7
  MemoryRetentionConfigSchema,
8
+ MemoryWorkerConfigSchema,
8
9
  } from "./memory-lifecycle.js";
9
10
  import {
10
11
  MemoryExtractionConfigSchema,
@@ -39,6 +40,9 @@ export const MemoryConfigSchema = z
39
40
  MemorySegmentationConfigSchema.parse({}),
40
41
  ),
41
42
  jobs: MemoryJobsConfigSchema.default(MemoryJobsConfigSchema.parse({})),
43
+ worker: MemoryWorkerConfigSchema.default(
44
+ MemoryWorkerConfigSchema.parse({}),
45
+ ),
42
46
  retention: MemoryRetentionConfigSchema.default(
43
47
  MemoryRetentionConfigSchema.parse({}),
44
48
  ),
@@ -28,6 +28,14 @@ export const TimeoutConfigSchema = z
28
28
  .describe(
29
29
  "How long to wait for user permission approval before timing out (seconds)",
30
30
  ),
31
+ questionResponseTimeoutSec: z
32
+ .number({ error: "timeouts.questionResponseTimeoutSec must be a number" })
33
+ .finite("timeouts.questionResponseTimeoutSec must be finite")
34
+ .positive("timeouts.questionResponseTimeoutSec must be a positive number")
35
+ .default(1800)
36
+ .describe(
37
+ "Backstop timeout for an unanswered ask_question prompt (seconds). The primary way an interactive user dismisses a prompt is by moving on — enqueuing another message supersedes it — so this only bounds a prompt left open with no response and no follow-up message.",
38
+ ),
31
39
  toolExecutionTimeoutSec: z
32
40
  .number({ error: "timeouts.toolExecutionTimeoutSec must be a number" })
33
41
  .finite("timeouts.toolExecutionTimeoutSec must be finite")
@@ -88,15 +88,24 @@ const MANAGED_PROFILE_TEMPLATES: Record<string, ManagedProfileTemplate> = {
88
88
  // profile there's nothing stronger to consult, so the advisor defaults off.
89
89
  advisorEnabled: false,
90
90
  },
91
+ // Served by DeepSeek V4 Flash on Fireworks via managed platform inference: a
92
+ // fast, low-cost open model. `model` is pinned explicitly rather than
93
+ // resolved via the `latency-optimized` intent (which still maps to Kimi K2.5
94
+ // on Fireworks and Anthropic Haiku elsewhere).
95
+ //
96
+ // `effort: "none"` (not "low") because Fireworks is not thinking-aware: the
97
+ // disabled `thinking` config is stripped before the request, so a non-"none"
98
+ // effort would be sent as `reasoning_effort` and make this profile pay for
99
+ // reasoning despite thinking being off. "none" keeps Speed non-reasoning.
91
100
  "cost-optimized": {
92
- intent: "latency-optimized",
93
- provider: "anthropic",
94
- connectionName: "anthropic-managed",
101
+ model: "accounts/fireworks/models/deepseek-v4-flash",
102
+ provider: "fireworks",
103
+ connectionName: "fireworks-managed",
95
104
  source: "managed",
96
105
  label: "Speed",
97
- description: "Fastest responses at lower cost",
106
+ description: "Fastest responses at lower cost (DeepSeek V4 Flash)",
98
107
  maxTokens: 8192,
99
- effort: "low",
108
+ effort: "none",
100
109
  thinking: { enabled: false, streamThinking: false },
101
110
  contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
102
111
  },
@@ -688,6 +688,9 @@ function discoverSkillDirectories(skillsDir: string): string[] {
688
688
  * parseable `package.json` whose `name` equals the directory name. This
689
689
  * mirrors the external plugin loader's recognition gate, which skips any
690
690
  * directory whose `manifest.name` does not match its directory name.
691
+ *
692
+ * The caller is responsible for the missing-`package.json` case (it emits a
693
+ * diagnostic warning); this function only judges a manifest that is present.
691
694
  */
692
695
  function isRecognizedPluginDir(pluginDir: string, dirName: string): boolean {
693
696
  const manifestPath = join(pluginDir, "package.json");
@@ -735,12 +738,31 @@ function discoverPluginResidentSkills(): SkillSummary[] {
735
738
  for (const entry of entries) {
736
739
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
737
740
  const pluginDir = join(pluginsDir, entry.name);
741
+
742
+ // A directory under `plugins/` with no `package.json` is not a plugin the
743
+ // runtime can load, so its skills are never surfaced. This is an easy
744
+ // footgun — a plugin dropped in without its manifest looks installed but
745
+ // silently contributes nothing — so warn loudly with the path rather than
746
+ // skipping in silence, to make the misconfiguration diagnosable.
747
+ if (!existsSync(join(pluginDir, "package.json"))) {
748
+ log.warn(
749
+ { pluginDir },
750
+ "Plugin directory is missing package.json — skipping; its skills will not be available. Add a package.json whose `name` matches the directory.",
751
+ );
752
+ continue;
753
+ }
754
+
755
+ // Honor the `.disabled` sentinel the runtime plugin scan checks
756
+ // (`plugins/mtime-cache.ts`): a disabled plugin contributes no hooks or
757
+ // tools, so its resident skills must not be loadable either.
758
+ if (existsSync(join(pluginDir, ".disabled"))) continue;
759
+
738
760
  // Mirror the plugin loader's recognition gate: a directory is a real
739
- // installed plugin only if it carries a parseable `package.json` whose
740
- // `name` matches the directory. This rejects staging dirs, stray files,
741
- // and malformed/mismatched clones (e.g. an un-adapted `caveman-installer`)
742
- // that the loader itself would skip, so the catalog never surfaces skills
743
- // from a directory the runtime would refuse to load.
761
+ // installed plugin only if its `package.json` `name` matches the directory.
762
+ // This rejects staging dirs and malformed/mismatched clones (e.g. an
763
+ // un-adapted `caveman-installer`) that the loader itself would skip, so the
764
+ // catalog never surfaces skills from a directory the runtime would refuse
765
+ // to load.
744
766
  if (!isRecognizedPluginDir(pluginDir, entry.name)) continue;
745
767
 
746
768
  const skillsDir = join(pluginDir, "skills");
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Tests for the gateway-backed guardian delivery reader.
3
+ *
4
+ * Guardian binding is near-static, so the reader caches behind a minutes-scale
5
+ * TTL, clears event-driven on invalidation, and coalesces concurrent cold-cache
6
+ * reads single-flight. These tests pin the parse contract plus all three cache
7
+ * behaviors (TTL hit, invalidation, single-flight) and the failure-no-poison
8
+ * rule.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ import type { GuardianDelivery } from "@vellumai/gateway-client";
14
+
15
+ // ── Controllable IPC mock ────────────────────────────────────────────────────
16
+
17
+ type IpcHandler = (params?: Record<string, unknown>) => unknown;
18
+
19
+ const ipcHandlers = new Map<string, IpcHandler>();
20
+ const ipcCallLog: Array<{
21
+ method: string;
22
+ params?: Record<string, unknown>;
23
+ timeoutMs?: number;
24
+ }> = [];
25
+
26
+ mock.module("../../ipc/gateway-client.js", () => ({
27
+ ipcCall: async (
28
+ method: string,
29
+ params?: Record<string, unknown>,
30
+ timeoutMs?: number,
31
+ ) => {
32
+ ipcCallLog.push({ method, params, timeoutMs });
33
+ const handler = ipcHandlers.get(method);
34
+ return handler ? handler(params) : undefined;
35
+ },
36
+ ipcCallPersistent: async () => undefined,
37
+ resetPersistentClient: () => {},
38
+ }));
39
+
40
+ import { emitContactChange } from "../contact-events.js";
41
+ import {
42
+ __resetGuardianDeliveryCacheForTest,
43
+ anyGuardian,
44
+ getGuardianDelivery,
45
+ getGuardianDeliveryFresh,
46
+ guardianForChannel,
47
+ invalidateGuardianDeliveryCache,
48
+ } from "../guardian-delivery-reader.js";
49
+
50
+ const METHOD = "resolve_guardian_delivery";
51
+
52
+ function countCalls(method: string): number {
53
+ return ipcCallLog.filter((c) => c.method === method).length;
54
+ }
55
+
56
+ const telegramGuardian: GuardianDelivery = {
57
+ channelType: "telegram",
58
+ contactId: "contact-123",
59
+ address: "@guardian",
60
+ status: "active",
61
+ };
62
+
63
+ const emailGuardian: GuardianDelivery = {
64
+ channelType: "email",
65
+ contactId: "contact-456",
66
+ address: "guardian@example.com",
67
+ status: "active",
68
+ };
69
+
70
+ describe("getGuardianDelivery", () => {
71
+ beforeEach(() => {
72
+ __resetGuardianDeliveryCacheForTest();
73
+ ipcHandlers.clear();
74
+ ipcCallLog.length = 0;
75
+ });
76
+
77
+ test("returns the parsed guardian list", async () => {
78
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
79
+
80
+ expect(await getGuardianDelivery()).toEqual([telegramGuardian]);
81
+ });
82
+
83
+ test("bounds the IPC read with a short timeout", async () => {
84
+ ipcHandlers.set(METHOD, () => ({ guardians: [] }));
85
+
86
+ await getGuardianDelivery();
87
+
88
+ const call = ipcCallLog.find((c) => c.method === METHOD);
89
+ expect(call?.timeoutMs).toBe(2_000);
90
+ });
91
+
92
+ test("returns null when IPC transport fails (undefined)", async () => {
93
+ ipcHandlers.set(METHOD, () => undefined);
94
+ expect(await getGuardianDelivery()).toBeNull();
95
+ });
96
+
97
+ test("returns null when the IPC call throws", async () => {
98
+ ipcHandlers.set(METHOD, () => {
99
+ throw new Error("socket exploded");
100
+ });
101
+ expect(await getGuardianDelivery()).toBeNull();
102
+ });
103
+
104
+ test("returns null for a malformed response shape", async () => {
105
+ ipcHandlers.set(METHOD, () => ({ guardians: "not-an-array" }));
106
+ expect(await getGuardianDelivery()).toBeNull();
107
+ });
108
+
109
+ test("two calls within the TTL issue only ONE IPC call (cache hit)", async () => {
110
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
111
+
112
+ await getGuardianDelivery();
113
+ await getGuardianDelivery();
114
+
115
+ expect(countCalls(METHOD)).toBe(1);
116
+ });
117
+
118
+ test("caches per channelTypes filter key", async () => {
119
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
120
+
121
+ await getGuardianDelivery();
122
+ await getGuardianDelivery({ channelTypes: ["telegram"] });
123
+
124
+ // Distinct keys ("ALL" vs "telegram") miss each other → two IPC calls.
125
+ expect(countCalls(METHOD)).toBe(2);
126
+ });
127
+
128
+ test("invalidateGuardianDeliveryCache() forces the next call to re-fetch", async () => {
129
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
130
+
131
+ await getGuardianDelivery();
132
+ invalidateGuardianDeliveryCache();
133
+ await getGuardianDelivery();
134
+
135
+ expect(countCalls(METHOD)).toBe(2);
136
+ });
137
+
138
+ test("a contact-change event clears the cache", async () => {
139
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
140
+
141
+ await getGuardianDelivery();
142
+ emitContactChange();
143
+ await getGuardianDelivery();
144
+
145
+ expect(countCalls(METHOD)).toBe(2);
146
+ });
147
+
148
+ test("a burst of concurrent cold-cache calls issues only ONE IPC call (single-flight)", async () => {
149
+ let resolveIpc: ((value: unknown) => void) | undefined;
150
+ ipcHandlers.set(
151
+ METHOD,
152
+ () =>
153
+ new Promise((resolve) => {
154
+ resolveIpc = resolve;
155
+ }),
156
+ );
157
+
158
+ const burst = Promise.all([
159
+ getGuardianDelivery(),
160
+ getGuardianDelivery(),
161
+ getGuardianDelivery(),
162
+ ]);
163
+ resolveIpc?.({ guardians: [telegramGuardian] });
164
+ const results = await burst;
165
+
166
+ expect(countCalls(METHOD)).toBe(1);
167
+ expect(results).toEqual([
168
+ [telegramGuardian],
169
+ [telegramGuardian],
170
+ [telegramGuardian],
171
+ ]);
172
+ });
173
+
174
+ test("an invalidation DURING an in-flight fetch is not masked — the next call re-fetches", async () => {
175
+ let resolveIpc: ((value: unknown) => void) | undefined;
176
+ ipcHandlers.set(
177
+ METHOD,
178
+ () =>
179
+ new Promise((resolve) => {
180
+ resolveIpc = resolve;
181
+ }),
182
+ );
183
+
184
+ // Start a cold fetch, invalidate before it resolves, then resolve it.
185
+ const inFlight = getGuardianDelivery();
186
+ invalidateGuardianDeliveryCache();
187
+ resolveIpc?.({ guardians: [telegramGuardian] });
188
+ expect(await inFlight).toEqual([telegramGuardian]);
189
+
190
+ // The pre-invalidation result must NOT have been cached: the next read
191
+ // issues a fresh IPC rather than serving the now-stale value.
192
+ ipcHandlers.set(METHOD, () => ({ guardians: [emailGuardian] }));
193
+ expect(await getGuardianDelivery()).toEqual([emailGuardian]);
194
+ expect(countCalls(METHOD)).toBe(2);
195
+ });
196
+
197
+ test("a failure does NOT poison the cache — the next call retries", async () => {
198
+ ipcHandlers.set(METHOD, () => undefined);
199
+ expect(await getGuardianDelivery()).toBeNull();
200
+
201
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
202
+ expect(await getGuardianDelivery()).toEqual([telegramGuardian]);
203
+ expect(countCalls(METHOD)).toBe(2);
204
+ });
205
+
206
+ test("fresh read ignores a stale cached entry and re-fetches", async () => {
207
+ // Seed the cache with an empty list (the stale gateway-side view).
208
+ ipcHandlers.set(METHOD, () => ({ guardians: [] }));
209
+ expect(await getGuardianDelivery()).toEqual([]);
210
+
211
+ // A cached read still serves the stale empty list (no new IPC)...
212
+ expect(await getGuardianDelivery()).toEqual([]);
213
+ expect(countCalls(METHOD)).toBe(1);
214
+
215
+ // ...but a fresh read bypasses the cache and sees the now-present guardian.
216
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
217
+ expect(await getGuardianDeliveryFresh()).toEqual([telegramGuardian]);
218
+ expect(countCalls(METHOD)).toBe(2);
219
+ });
220
+
221
+ test("fresh read updates the cache with the fresh result", async () => {
222
+ ipcHandlers.set(METHOD, () => ({ guardians: [] }));
223
+ await getGuardianDelivery();
224
+
225
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
226
+ await getGuardianDeliveryFresh();
227
+
228
+ // A subsequent cached read serves the refreshed value without a new IPC.
229
+ expect(await getGuardianDelivery()).toEqual([telegramGuardian]);
230
+ expect(countCalls(METHOD)).toBe(2);
231
+ });
232
+
233
+ test("getGuardianDeliveryFresh bypasses a stale cached empty list", async () => {
234
+ ipcHandlers.set(METHOD, () => ({ guardians: [] }));
235
+ expect(
236
+ await getGuardianDelivery({ channelTypes: ["telegram"] }),
237
+ ).toEqual([]);
238
+
239
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
240
+ expect(
241
+ await getGuardianDeliveryFresh({ channelTypes: ["telegram"] }),
242
+ ).toEqual([telegramGuardian]);
243
+ expect(countCalls(METHOD)).toBe(2);
244
+ });
245
+
246
+ test("a burst of forceRefresh reads still coalesces single-flight", async () => {
247
+ let resolveIpc: ((value: unknown) => void) | undefined;
248
+ ipcHandlers.set(
249
+ METHOD,
250
+ () =>
251
+ new Promise((resolve) => {
252
+ resolveIpc = resolve;
253
+ }),
254
+ );
255
+
256
+ const burst = Promise.all([
257
+ getGuardianDeliveryFresh(),
258
+ getGuardianDeliveryFresh(),
259
+ getGuardianDeliveryFresh(),
260
+ ]);
261
+ resolveIpc?.({ guardians: [telegramGuardian] });
262
+ await burst;
263
+
264
+ expect(countCalls(METHOD)).toBe(1);
265
+ });
266
+
267
+ test("a fresh read does NOT coalesce with an in-flight non-force fetch (issues its own IPC)", async () => {
268
+ // A normal read starts a fetch that will resolve to the pre-write empty
269
+ // list and is still in flight when the fresh read arrives.
270
+ let resolveStale: ((value: unknown) => void) | undefined;
271
+ ipcHandlers.set(
272
+ METHOD,
273
+ () =>
274
+ new Promise((resolve) => {
275
+ resolveStale = resolve;
276
+ }),
277
+ );
278
+ const stale = getGuardianDelivery();
279
+
280
+ // The gateway-side write lands (not reflected in the in-flight fetch). The
281
+ // fresh read must NOT reuse the stale in-flight promise — it issues its own
282
+ // IPC observing the post-write guardian.
283
+ ipcHandlers.set(METHOD, () => ({ guardians: [telegramGuardian] }));
284
+ const fresh = await getGuardianDeliveryFresh();
285
+ expect(fresh).toEqual([telegramGuardian]);
286
+
287
+ // Release the stale fetch last; it must not have masked the fresh result.
288
+ resolveStale?.({ guardians: [] });
289
+ expect(await stale).toEqual([]);
290
+ expect(countCalls(METHOD)).toBe(2);
291
+ });
292
+ });
293
+
294
+ describe("selectors", () => {
295
+ test("guardianForChannel picks the first active match for the type", () => {
296
+ const inactive: GuardianDelivery = {
297
+ ...telegramGuardian,
298
+ contactId: "contact-999",
299
+ status: "revoked",
300
+ };
301
+ const list = [inactive, telegramGuardian, emailGuardian];
302
+
303
+ expect(guardianForChannel(list, "telegram")).toBe(telegramGuardian);
304
+ expect(guardianForChannel(list, "email")).toBe(emailGuardian);
305
+ expect(guardianForChannel(list, "phone")).toBeUndefined();
306
+ });
307
+
308
+ test("anyGuardian returns the first overall", () => {
309
+ expect(anyGuardian([emailGuardian, telegramGuardian])).toBe(emailGuardian);
310
+ expect(anyGuardian([])).toBeUndefined();
311
+ });
312
+ });