@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
@@ -326,3 +326,68 @@ describe("SubagentManager terminal disposal", () => {
326
326
  asInternals(manager).stopSweep();
327
327
  });
328
328
  });
329
+
330
+ describe("SubagentManager.abort usage", () => {
331
+ test("emits the conversation's latest usage on abort, not zeros", () => {
332
+ const manager = new SubagentManager();
333
+ const sent: ServerMessage[] = [];
334
+ const sender = (msg: ServerMessage) => sent.push(msg);
335
+
336
+ const subagentId = "sa-abort-usage";
337
+ // state.usage starts at {0,0,0}; the live (fake) conversation has accrued
338
+ // usage (makeFakeConversation → {100, 50, 0.005}). Wire `sender` as the
339
+ // stored parent sender so `setStatus` routes the terminal event through it.
340
+ injectFakeSubagent(manager, subagentId, makeState(subagentId), sender);
341
+
342
+ const aborted = manager.abort(subagentId, sender, undefined, {
343
+ suppressNotification: true,
344
+ });
345
+ expect(aborted).toBe(true);
346
+
347
+ const statusMsg = sent.find(
348
+ (m): m is Extract<ServerMessage, { type: "subagent_status_changed" }> =>
349
+ m.type === "subagent_status_changed",
350
+ );
351
+ expect(statusMsg).toBeDefined();
352
+ expect(statusMsg!.status).toBe("aborted");
353
+ // The emitted usage is the conversation's accrued total — NOT the {0,0,0}
354
+ // init — so the client doesn't flush the token panel to zero on stop.
355
+ expect(statusMsg!.usage).toEqual({
356
+ inputTokens: 100,
357
+ outputTokens: 50,
358
+ estimatedCost: 0.005,
359
+ });
360
+
361
+ asInternals(manager).stopSweep();
362
+ });
363
+
364
+ test("keeps the last-known state.usage when the conversation was already released", () => {
365
+ const manager = new SubagentManager();
366
+ const sent: ServerMessage[] = [];
367
+ const sender = (msg: ServerMessage) => sent.push(msg);
368
+
369
+ const subagentId = "sa-abort-no-conv";
370
+ // No live conversation (released), but state carries a last-known usage —
371
+ // the abort must surface that, not overwrite it.
372
+ const state = makeState(subagentId, {
373
+ usage: { inputTokens: 320, outputTokens: 80, estimatedCost: 0.004 },
374
+ });
375
+ injectFakeSubagent(manager, subagentId, state, sender, null);
376
+
377
+ manager.abort(subagentId, sender, undefined, {
378
+ suppressNotification: true,
379
+ });
380
+
381
+ const statusMsg = sent.find(
382
+ (m): m is Extract<ServerMessage, { type: "subagent_status_changed" }> =>
383
+ m.type === "subagent_status_changed",
384
+ );
385
+ expect(statusMsg!.usage).toEqual({
386
+ inputTokens: 320,
387
+ outputTokens: 80,
388
+ estimatedCost: 0.004,
389
+ });
390
+
391
+ asInternals(manager).stopSweep();
392
+ });
393
+ });
@@ -4,6 +4,8 @@ import { describe, expect, mock, test } from "bun:test";
4
4
 
5
5
  // Mock conversation-crud before importing tool executors that depend on it.
6
6
  mock.module("../memory/conversation-crud.js", () => ({
7
+ setConversationProcessingStartedAt: () => {},
8
+ isConversationProcessing: () => false,
7
9
  setConversationOriginChannelIfUnset: () => {},
8
10
  updateConversationContextWindow: () => {},
9
11
  deleteMessageById: () => {},
@@ -3,6 +3,8 @@ import { mock } from "bun:test";
3
3
 
4
4
  // Mock conversation-crud before importing tool executors that depend on it.
5
5
  mock.module("../memory/conversation-crud.js", () => ({
6
+ setConversationProcessingStartedAt: () => {},
7
+ isConversationProcessing: () => false,
6
8
  setConversationOriginChannelIfUnset: () => {},
7
9
  updateConversationContextWindow: () => {},
8
10
  deleteMessageById: () => {},
@@ -29,6 +29,8 @@ mock.module("../config/loader.js", () => ({
29
29
  }),
30
30
  }));
31
31
  mock.module("../memory/conversation-crud.js", () => ({
32
+ setConversationProcessingStartedAt: () => {},
33
+ isConversationProcessing: () => false,
32
34
  setConversationOriginChannelIfUnset: () => {},
33
35
  updateConversationContextWindow: () => {},
34
36
  deleteMessageById: () => {},
@@ -50,6 +50,8 @@ const mockGetMessages = mock((_conversationId: string) => [
50
50
  ]);
51
51
 
52
52
  mock.module("../memory/conversation-crud.js", () => ({
53
+ setConversationProcessingStartedAt: () => {},
54
+ isConversationProcessing: () => false,
53
55
  getMessages: mockGetMessages,
54
56
  getConversation: (_id: string) => null,
55
57
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
@@ -64,6 +64,8 @@ const mockGetConversation = mock(
64
64
  },
65
65
  );
66
66
  mock.module("../memory/conversation-crud.js", () => ({
67
+ setConversationProcessingStartedAt: () => {},
68
+ isConversationProcessing: () => false,
67
69
  getConversation: mockGetConversation,
68
70
  }));
69
71
 
@@ -78,6 +78,8 @@ mock.module("../permissions/checker.js", () => ({
78
78
  }));
79
79
 
80
80
  mock.module("../memory/conversation-crud.js", () => ({
81
+ setConversationProcessingStartedAt: () => {},
82
+ isConversationProcessing: () => false,
81
83
  createConversation: (title: string) => ({ id: "conversation-1", title }),
82
84
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
83
85
  }));
@@ -17,6 +17,7 @@ const mockConfig = {
17
17
  shellDefaultTimeoutSec: 120,
18
18
  shellMaxTimeoutSec: 600,
19
19
  permissionTimeoutSec: 300,
20
+ questionResponseTimeoutSec: 1800,
20
21
  },
21
22
  sandbox: {
22
23
  enabled: false,
@@ -1300,27 +1301,31 @@ describe("ToolExecutionResult includes risk metadata from classifier assessment"
1300
1301
 
1301
1302
  describe("computePerToolTimeoutMs ask_question budget", () => {
1302
1303
  // Regression guard: ask_question blocks on user input inside execute() via
1303
- // QuestionPrompter, which waits up to permissionTimeoutSec. The executor's
1304
- // generic toolExecutionTimeoutSec wrapper must give ask_question a budget
1305
- // strictly larger than that prompt timeout — otherwise the wrapper fires
1306
- // first and orphans the still-pending prompt behind the confusing "may
1304
+ // QuestionPrompter, which waits up to questionResponseTimeoutSec. The
1305
+ // executor's generic toolExecutionTimeoutSec wrapper must give ask_question a
1306
+ // budget strictly larger than that prompt timeout — otherwise the wrapper
1307
+ // fires first and orphans the still-pending prompt behind the confusing "may
1307
1308
  // still be running in the background" error. These assertions fail if the
1308
- // special case is removed and ask_question falls back to the generic budget.
1309
- test("execution-timeout budget exceeds the prompt's own permissionTimeoutSec", () => {
1310
- const { permissionTimeoutSec } = mockConfig.timeouts;
1309
+ // special case is removed and ask_question falls back to the generic budget,
1310
+ // or if the executor budget and the prompter timeout drift onto different
1311
+ // config knobs.
1312
+ test("execution-timeout budget exceeds the prompt's own questionResponseTimeoutSec", () => {
1313
+ const { questionResponseTimeoutSec } = mockConfig.timeouts;
1311
1314
  const askQuestionBudgetMs = computePerToolTimeoutMs("ask_question", {});
1312
1315
 
1313
- expect(askQuestionBudgetMs).toBeGreaterThan(permissionTimeoutSec * 1000);
1314
- expect(askQuestionBudgetMs).toBe((permissionTimeoutSec + 5) * 1000);
1316
+ expect(askQuestionBudgetMs).toBeGreaterThan(
1317
+ questionResponseTimeoutSec * 1000,
1318
+ );
1319
+ expect(askQuestionBudgetMs).toBe((questionResponseTimeoutSec + 5) * 1000);
1315
1320
  });
1316
1321
 
1317
1322
  test("the generic budget that would otherwise apply is shorter than the prompt timeout", () => {
1318
- const { permissionTimeoutSec } = mockConfig.timeouts;
1323
+ const { questionResponseTimeoutSec } = mockConfig.timeouts;
1319
1324
  const genericBudgetMs = computePerToolTimeoutMs("some_other_tool", {});
1320
1325
 
1321
1326
  // This is the collision the ask_question special case fixes: the generic
1322
1327
  // execution-timeout budget is shorter than the prompter's own wait, so
1323
1328
  // without the special case the wrapper trips first.
1324
- expect(genericBudgetMs).toBeLessThan(permissionTimeoutSec * 1000);
1329
+ expect(genericBudgetMs).toBeLessThan(questionResponseTimeoutSec * 1000);
1325
1330
  });
1326
1331
  });
@@ -60,6 +60,8 @@ const reserveMessageMock = mock(
60
60
  const updateMessageContentMock = mock((_id: string, _content: string) => {});
61
61
 
62
62
  mock.module("../memory/conversation-crud.js", () => ({
63
+ setConversationProcessingStartedAt: () => {},
64
+ isConversationProcessing: () => false,
63
65
  getConversation: () => null,
64
66
  getMessageById: () => null,
65
67
  updateMessageContent: updateMessageContentMock,
@@ -40,6 +40,8 @@ mock.module("../config/loader.js", () => ({
40
40
  }));
41
41
 
42
42
  mock.module("../memory/conversation-crud.js", () => ({
43
+ setConversationProcessingStartedAt: () => {},
44
+ isConversationProcessing: () => false,
43
45
  addMessage: () => ({ id: "mock-msg-id" }),
44
46
  getMessageById: () => null,
45
47
  updateMessageContent: () => {},
@@ -43,6 +43,8 @@ let mockedRowContent = "";
43
43
  const updates: Array<{ id: string; content: string }> = [];
44
44
 
45
45
  mock.module("../memory/conversation-crud.js", () => ({
46
+ setConversationProcessingStartedAt: () => {},
47
+ isConversationProcessing: () => false,
46
48
  addMessage: () => ({ id: "mock-msg-id" }),
47
49
  getMessageById: (id: string) =>
48
50
  mockedRowContent ? { id, content: mockedRowContent } : null,
@@ -335,7 +335,7 @@ describe("(b) prompt-path flow: confirmation_request bridges to guardian", () =>
335
335
  };
336
336
  });
337
337
 
338
- test("trusted-contact confirmation_request emits guardian.question and creates delivery records", () => {
338
+ test("trusted-contact confirmation_request emits guardian.question and creates delivery records", async () => {
339
339
  const canonicalRequest = createCanonicalGuardianRequest({
340
340
  id: `req-bridge-${Date.now()}`,
341
341
  kind: "tool_approval",
@@ -352,7 +352,7 @@ describe("(b) prompt-path flow: confirmation_request bridges to guardian", () =>
352
352
 
353
353
  const trustContext = makeTrustedContactTrustContext();
354
354
 
355
- const result = bridgeConfirmationRequestToGuardian({
355
+ const result = await bridgeConfirmationRequestToGuardian({
356
356
  canonicalRequest,
357
357
  trustContext,
358
358
  conversationId: "conv-bridge-1",
@@ -371,7 +371,7 @@ describe("(b) prompt-path flow: confirmation_request bridges to guardian", () =>
371
371
  expect(payload.requesterIdentifier).toBe("@requester");
372
372
  });
373
373
 
374
- test("bridge + tool_grant_request both use guardian.question for unified routing", () => {
374
+ test("bridge + tool_grant_request both use guardian.question for unified routing", async () => {
375
375
  // The confirmation_request bridge and tool_grant_request helper both
376
376
  // use 'guardian.question' as the notification signal, ensuring consistent
377
377
  // guardian routing regardless of the approval path.
@@ -391,7 +391,7 @@ describe("(b) prompt-path flow: confirmation_request bridges to guardian", () =>
391
391
 
392
392
  const trustContext = makeTrustedContactTrustContext();
393
393
 
394
- bridgeConfirmationRequestToGuardian({
394
+ await bridgeConfirmationRequestToGuardian({
395
395
  canonicalRequest,
396
396
  trustContext,
397
397
  conversationId: "conv-unified-1",
@@ -432,7 +432,7 @@ describe("(c) no-binding flow: trusted contact fails fast without guardian bindi
432
432
  expect(state.promptWaitingAllowed).toBe(false);
433
433
  });
434
434
 
435
- test("bridge skips when no guardian binding exists for channel", () => {
435
+ test("bridge skips when no guardian binding exists for channel", async () => {
436
436
  const canonicalRequest = createCanonicalGuardianRequest({
437
437
  id: `req-nobinding-${Date.now()}`,
438
438
  kind: "tool_approval",
@@ -449,7 +449,7 @@ describe("(c) no-binding flow: trusted contact fails fast without guardian bindi
449
449
 
450
450
  const trustContext = makeTrustedContactTrustContext();
451
451
 
452
- const result = bridgeConfirmationRequestToGuardian({
452
+ const result = await bridgeConfirmationRequestToGuardian({
453
453
  canonicalRequest,
454
454
  trustContext,
455
455
  conversationId: "conv-nobinding",
@@ -543,7 +543,7 @@ describe("(d) unknown actor flow: fail-closed with no interactive approval", ()
543
543
  expect(resolveRoutingState(withoutRoute).canBeInteractive).toBe(false);
544
544
  });
545
545
 
546
- test("bridge skips unknown actor sessions entirely", () => {
546
+ test("bridge skips unknown actor sessions entirely", async () => {
547
547
  const canonicalRequest = createCanonicalGuardianRequest({
548
548
  id: `req-unknown-${Date.now()}`,
549
549
  kind: "tool_approval",
@@ -563,7 +563,7 @@ describe("(d) unknown actor flow: fail-closed with no interactive approval", ()
563
563
  trustClass: "unknown",
564
564
  };
565
565
 
566
- const result = bridgeConfirmationRequestToGuardian({
566
+ const result = await bridgeConfirmationRequestToGuardian({
567
567
  canonicalRequest,
568
568
  trustContext,
569
569
  conversationId: "conv-unknown",
@@ -965,7 +965,7 @@ describe("cross-milestone integration checks", () => {
965
965
  );
966
966
  });
967
967
 
968
- test("M2+M4: bridge and tool_grant_request target the same guardian identity", () => {
968
+ test("M2+M4: bridge and tool_grant_request target the same guardian identity", async () => {
969
969
  // Both the confirmation_request bridge (M2) and tool grant request escalation (M4)
970
970
  // use the guardian binding's guardianExternalUserId to route notifications.
971
971
  // Verify this consistency:
@@ -986,7 +986,7 @@ describe("cross-milestone integration checks", () => {
986
986
 
987
987
  const trustContext = makeTrustedContactTrustContext();
988
988
 
989
- const bridgeResult = bridgeConfirmationRequestToGuardian({
989
+ const bridgeResult = await bridgeConfirmationRequestToGuardian({
990
990
  canonicalRequest,
991
991
  trustContext,
992
992
  conversationId: "conv-consistency",
@@ -109,6 +109,25 @@ mock.module("../calls/channel-admission-reader.js", () => ({
109
109
  getChannelAdmissionPolicy: async () => mockAdmissionPolicy,
110
110
  }));
111
111
 
112
+ // Mock the inbound trust reader used by the media-stream preflight. Captures
113
+ // its args so tests can assert the inbound caller's verdict is fetched, and
114
+ // returns mockInboundVerdict which is threaded into the preflight routeSetup.
115
+ let mockInboundVerdict: unknown = null;
116
+ let lastInboundVerdictArgs: Record<string, unknown> | null = null;
117
+ mock.module("../calls/inbound-trust-reader.js", () => ({
118
+ getInboundTrustVerdict: async (args: Record<string, unknown>) => {
119
+ lastInboundVerdictArgs = args;
120
+ return mockInboundVerdict;
121
+ },
122
+ getPhoneCallerVerdict: async (otherPartyNumber: string | undefined) => {
123
+ lastInboundVerdictArgs = {
124
+ channelType: "phone",
125
+ actorExternalId: otherPartyNumber || undefined,
126
+ };
127
+ return mockInboundVerdict;
128
+ },
129
+ }));
130
+
112
131
  mock.module("../config/env.js", () => ({
113
132
  isHttpAuthDisabled: () => true,
114
133
  getGatewayInternalBaseUrl: () => "http://gateway.internal:7830",
@@ -470,6 +489,8 @@ describe("twilio webhook routes", () => {
470
489
  // Reset admission policy + captured routeSetup context between tests
471
490
  mockAdmissionPolicy = null;
472
491
  lastRouteSetupCtx = null;
492
+ mockInboundVerdict = null;
493
+ lastInboundVerdictArgs = null;
473
494
  // Reset routeSetup mock to default normal_call
474
495
  mockRouteSetupResult = {
475
496
  outcome: { action: "normal_call", isInbound: true },
@@ -1269,6 +1290,81 @@ describe("twilio webhook routes", () => {
1269
1290
  expect(lastRouteSetupCtx?.admissionPolicy).toBe("guardian_only");
1270
1291
  });
1271
1292
 
1293
+ test("media-stream inbound: fetches the caller's verdict (From) and threads it into the preflight routeSetup", async () => {
1294
+ mockConfigObj.services.stt.provider = "openai-whisper" as any;
1295
+ mockInboundVerdict = {
1296
+ channelType: "phone",
1297
+ actorExternalId: "+14155551234",
1298
+ contactId: "contact-1",
1299
+ channelId: "channel-1",
1300
+ status: "verified",
1301
+ policy: "allow",
1302
+ resolutionFailed: false,
1303
+ };
1304
+
1305
+ const req = makeInboundVoiceRequest({
1306
+ CallSid: "CA_ms_verdict_inbound_1",
1307
+ From: "+14155551234",
1308
+ To: "+15550001111",
1309
+ });
1310
+
1311
+ const res = await handleVoiceWebhook(req);
1312
+ expect(res.status).toBe(200);
1313
+
1314
+ // Inbound: verdict fetched for the caller (From) on the phone channel.
1315
+ expect(lastInboundVerdictArgs).toEqual({
1316
+ channelType: "phone",
1317
+ actorExternalId: "+14155551234",
1318
+ });
1319
+ // Verdict threaded into the preflight routeSetup.
1320
+ expect(lastRouteSetupCtx?.verdict).toEqual(mockInboundVerdict);
1321
+ });
1322
+
1323
+ test("media-stream inbound: a blocked/denied member verdict is classified deny in the preflight", async () => {
1324
+ mockConfigObj.services.stt.provider = "openai-whisper" as any;
1325
+ mockInboundVerdict = {
1326
+ channelType: "phone",
1327
+ actorExternalId: "+14155551234",
1328
+ contactId: "contact-1",
1329
+ channelId: "channel-1",
1330
+ status: "blocked",
1331
+ policy: "deny",
1332
+ resolutionFailed: false,
1333
+ };
1334
+ // The real router returns `deny` for a blocked member verdict; the mock
1335
+ // reflects that outcome. deny is supported on media-stream, so the
1336
+ // preflight still emits Stream TwiML (denial spoken at stream start).
1337
+ mockRouteSetupResult = {
1338
+ outcome: {
1339
+ action: "deny",
1340
+ message:
1341
+ "This number is not authorized to reach the assistant right now.",
1342
+ logReason: "Inbound voice ACL: member blocked",
1343
+ },
1344
+ resolved: {
1345
+ assistantId: "self",
1346
+ isInbound: true,
1347
+ otherPartyNumber: "+14155551234",
1348
+ actorTrust: { trustClass: "unknown", memberRecord: null },
1349
+ },
1350
+ };
1351
+
1352
+ const req = makeInboundVoiceRequest({
1353
+ CallSid: "CA_ms_verdict_deny_1",
1354
+ From: "+14155551234",
1355
+ To: "+15550001111",
1356
+ });
1357
+
1358
+ const res = await handleVoiceWebhook(req);
1359
+ expect(res.status).toBe(200);
1360
+
1361
+ // Verdict was threaded into routeSetup, which denied the caller.
1362
+ expect(lastRouteSetupCtx?.verdict).toEqual(mockInboundVerdict);
1363
+ const twiml = await res.text();
1364
+ expect(twiml).toContain("<Stream");
1365
+ expect(twiml).not.toContain("<ConversationRelay");
1366
+ });
1367
+
1272
1368
  test("media-stream: floor-denied caller classified deny still produces Stream TwiML (deny handled at stream level)", async () => {
1273
1369
  mockConfigObj.services.stt.provider = "openai-whisper" as any;
1274
1370
  mockAdmissionPolicy = "guardian_only";
@@ -72,6 +72,8 @@ mock.module("../permissions/checker.js", () => ({
72
72
  }));
73
73
 
74
74
  mock.module("../memory/conversation-crud.js", () => ({
75
+ setConversationProcessingStartedAt: () => {},
76
+ isConversationProcessing: () => false,
75
77
  createConversation: (title: string) => ({ id: "conversation-1", title }),
76
78
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
77
79
  }));
@@ -57,6 +57,8 @@ mock.module("../config/loader.js", () => ({
57
57
  }));
58
58
 
59
59
  mock.module("../memory/conversation-crud.js", () => ({
60
+ setConversationProcessingStartedAt: () => {},
61
+ isConversationProcessing: () => false,
60
62
  addMessage: () => ({ id: "mock-msg-id" }),
61
63
  getMessageById: () => null,
62
64
  updateMessageContent: () => {},
@@ -26,7 +26,7 @@
26
26
  * and skipped (no silent provider-safe rewrite — operator must rename).
27
27
  * - Multiple workspace tools register in a single batch.
28
28
  */
29
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
29
+ import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
30
30
  import { tmpdir } from "node:os";
31
31
  import { join } from "node:path";
32
32
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
@@ -42,7 +42,10 @@ import {
42
42
  registerTool,
43
43
  } from "../tools/registry.js";
44
44
  import type { Tool, ToolContext, ToolExecutionResult } from "../tools/types.js";
45
- import { loadWorkspaceTools } from "../tools/workspace-tools/loader.js";
45
+ import {
46
+ __resetWorkspaceToolCacheForTesting,
47
+ loadWorkspaceTools,
48
+ } from "../tools/workspace-tools/loader.js";
46
49
 
47
50
  // Per-test counter so each writeTool() call lands in a unique tempdir,
48
51
  // defeating bun's per-URL ESM cache between tests. Without this, a
@@ -87,6 +90,23 @@ function writeRemovedSentinel(name: string): void {
87
90
  writeFileSync(join(toolsDir, `${name}.removed`), "");
88
91
  }
89
92
 
93
+ /** Delete `<workspaceDir>/tools/<name><ext>` (defaults to `.ts`). */
94
+ function removeToolFile(name: string, ext = ".ts"): void {
95
+ rmSync(join(currentWorkspaceDir, "tools", `${name}${ext}`), { force: true });
96
+ }
97
+
98
+ /**
99
+ * Overwrite an existing tool file and bump its mtime into the future so the
100
+ * reconcile's mtime gate re-imports it even when the rewrite lands within
101
+ * the same millisecond as the original write.
102
+ */
103
+ function rewriteTool(name: string, body: string, ext = ".ts"): void {
104
+ const path = join(currentWorkspaceDir, "tools", `${name}${ext}`);
105
+ writeFileSync(path, body);
106
+ const future = new Date(Date.now() + 5000);
107
+ utimesSync(path, future, future);
108
+ }
109
+
90
110
  function makeFakeCoreTool(name: string): Tool {
91
111
  return {
92
112
  name,
@@ -94,6 +114,9 @@ function makeFakeCoreTool(name: string): Tool {
94
114
  category: "test",
95
115
  defaultRiskLevel: RiskLevel.Low,
96
116
  executionTarget: "sandbox",
117
+ // Match the finalized shape the registry stores (defaults filled), so
118
+ // `getCoreToolOverride(name)` toEqual comparisons hold after registration.
119
+ exclusive: false,
97
120
  input_schema: { type: "object", properties: {}, required: [] },
98
121
  async execute(
99
122
  _input: Record<string, unknown>,
@@ -133,6 +156,7 @@ export default {
133
156
  describe("workspace tool loader", () => {
134
157
  beforeEach(() => {
135
158
  __clearRegistryForTesting();
159
+ __resetWorkspaceToolCacheForTesting();
136
160
  freshWorkspace();
137
161
  });
138
162
 
@@ -316,4 +340,173 @@ export default 42;
316
340
  const names = getWorkspaceToolNames().sort();
317
341
  expect(names).toEqual(["alpha", "beta", "gamma"]);
318
342
  });
343
+
344
+ // ── Reconcile-on-read behavior ─────────────────────────────────────────
345
+ //
346
+ // loadWorkspaceTools() is idempotent and re-derives registry state from
347
+ // disk on every call. These cases cover the deltas a repeat call applies,
348
+ // which is what replaces the old filesystem watcher.
349
+
350
+ test("repeat call with no disk changes is a no-op (does not throw or duplicate)", async () => {
351
+ writeTool("stable_tool", WELL_FORMED_BODY);
352
+
353
+ await loadWorkspaceTools();
354
+ // A second reconcile must not throw on the already-registered name —
355
+ // the mtime cache recognizes the unchanged file and skips re-import.
356
+ await loadWorkspaceTools();
357
+
358
+ expect(getTool("stable_tool")).toBeDefined();
359
+ expect(getWorkspaceToolNames()).toEqual(["stable_tool"]);
360
+ });
361
+
362
+ test("a file added after the first reconcile registers on the next", async () => {
363
+ writeTool("first", WELL_FORMED_BODY);
364
+ await loadWorkspaceTools();
365
+ expect(getWorkspaceToolNames()).toEqual(["first"]);
366
+
367
+ writeTool("second", WELL_FORMED_BODY);
368
+ await loadWorkspaceTools();
369
+
370
+ expect(getWorkspaceToolNames().sort()).toEqual(["first", "second"]);
371
+ });
372
+
373
+ test("a changed file is re-imported on the next reconcile", async () => {
374
+ writeTool("mutable", WELL_FORMED_BODY);
375
+ await loadWorkspaceTools();
376
+ expect(getTool("mutable")?.description).toBe("from workspace");
377
+
378
+ rewriteTool(
379
+ "mutable",
380
+ `
381
+ export default {
382
+ description: "edited in place",
383
+ defaultRiskLevel: "low",
384
+ input_schema: { type: "object", properties: {}, required: [] },
385
+ async execute() {
386
+ return { content: "edited", isError: false };
387
+ },
388
+ };
389
+ `,
390
+ );
391
+ await loadWorkspaceTools();
392
+
393
+ expect(getTool("mutable")?.description).toBe("edited in place");
394
+ });
395
+
396
+ test("a deleted net-new tool file is unregistered on the next reconcile", async () => {
397
+ writeTool("ephemeral", WELL_FORMED_BODY);
398
+ await loadWorkspaceTools();
399
+ expect(getTool("ephemeral")).toBeDefined();
400
+
401
+ removeToolFile("ephemeral");
402
+ await loadWorkspaceTools();
403
+
404
+ expect(getTool("ephemeral")).toBeUndefined();
405
+ expect(getWorkspaceToolNames()).toEqual([]);
406
+ });
407
+
408
+ test("deleting an override file restores the stashed core tool", async () => {
409
+ const core = makeFakeCoreTool("restore_me");
410
+ registerTool(core);
411
+ writeTool("restore_me", WELL_FORMED_BODY);
412
+
413
+ await loadWorkspaceTools();
414
+ expect(getToolOwner("restore_me")?.kind).toBe("workspace");
415
+
416
+ removeToolFile("restore_me");
417
+ await loadWorkspaceTools();
418
+
419
+ expect(getToolOwner("restore_me")).toBeUndefined();
420
+ expect(getTool("restore_me")).toEqual(core);
421
+ expect(getCoreToolOverride("restore_me")).toBeUndefined();
422
+ });
423
+
424
+ test("deleting a .removed sentinel restores the stripped core tool", async () => {
425
+ const core = makeFakeCoreTool("strip_then_restore");
426
+ registerTool(core);
427
+ writeRemovedSentinel("strip_then_restore");
428
+
429
+ await loadWorkspaceTools();
430
+ expect(getTool("strip_then_restore")).toBeUndefined();
431
+ expect(getStrippedCoreToolNames()).toContain("strip_then_restore");
432
+
433
+ removeToolFile("strip_then_restore", ".removed");
434
+ await loadWorkspaceTools();
435
+
436
+ expect(getTool("strip_then_restore")).toEqual(core);
437
+ expect(getStrippedCoreToolNames()).not.toContain("strip_then_restore");
438
+ });
439
+
440
+ test("the registered name is the filename stem, ignoring the file's own name field", async () => {
441
+ // The default export sets a different `name` — the loader must pin the
442
+ // registered name to the stem ("stem_wins") so the mtime cache and the
443
+ // unregister-on-delete path stay keyed by the same name.
444
+ writeTool(
445
+ "stem_wins",
446
+ `
447
+ export default {
448
+ name: "different_name",
449
+ description: "name field should be ignored",
450
+ defaultRiskLevel: "low",
451
+ input_schema: { type: "object", properties: {}, required: [] },
452
+ async execute() {
453
+ return { content: "ok", isError: false };
454
+ },
455
+ };
456
+ `,
457
+ );
458
+
459
+ await loadWorkspaceTools();
460
+
461
+ expect(getTool("stem_wins")).toBeDefined();
462
+ expect(getTool("different_name")).toBeUndefined();
463
+ expect(getWorkspaceToolNames()).toEqual(["stem_wins"]);
464
+
465
+ // Deleting the file unregisters by stem — no leaked "different_name".
466
+ removeToolFile("stem_wins");
467
+ await loadWorkspaceTools();
468
+ expect(getTool("stem_wins")).toBeUndefined();
469
+ expect(getTool("different_name")).toBeUndefined();
470
+ });
471
+
472
+ test("per-tool isolation on reconcile: a bad file does not drop a valid edited tool", async () => {
473
+ writeTool("good_edit", WELL_FORMED_BODY);
474
+ await loadWorkspaceTools();
475
+ expect(getTool("good_edit")?.description).toBe("from workspace");
476
+
477
+ // Add a file that throws at import, and edit the good tool, in the same
478
+ // reconcile. The broken file must not prevent the edited tool from
479
+ // re-registering.
480
+ writeTool("broken_now", `throw new Error("boom at import");`);
481
+ rewriteTool(
482
+ "good_edit",
483
+ `
484
+ export default {
485
+ description: "edited and still here",
486
+ defaultRiskLevel: "low",
487
+ input_schema: { type: "object", properties: {}, required: [] },
488
+ async execute() {
489
+ return { content: "ok", isError: false };
490
+ },
491
+ };
492
+ `,
493
+ );
494
+ await loadWorkspaceTools();
495
+
496
+ expect(getTool("broken_now")).toBeUndefined();
497
+ expect(getTool("good_edit")?.description).toBe("edited and still here");
498
+ });
499
+
500
+ test("an edit that breaks an existing tool keeps the prior registration", async () => {
501
+ writeTool("was_good", WELL_FORMED_BODY);
502
+ await loadWorkspaceTools();
503
+ expect(getTool("was_good")?.description).toBe("from workspace");
504
+
505
+ // Rewrite the file into something that throws at import. The prior,
506
+ // working registration must stay in place rather than being torn down.
507
+ rewriteTool("was_good", `throw new Error("now broken");`);
508
+ await loadWorkspaceTools();
509
+
510
+ expect(getTool("was_good")?.description).toBe("from workspace");
511
+ });
319
512
  });