@vellumai/assistant 0.8.1 → 0.8.3

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 (630) hide show
  1. package/ARCHITECTURE.md +13 -19
  2. package/Dockerfile +75 -1
  3. package/bun.lock +11 -1
  4. package/docker-entrypoint.sh +17 -0
  5. package/docker-init-apt-root.sh +167 -0
  6. package/docker-kata-apt-env.sh +39 -0
  7. package/docs/plugins.md +88 -47
  8. package/docs/skills.md +9 -7
  9. package/examples/plugins/echo/README.md +27 -27
  10. package/examples/plugins/echo/package.json +3 -0
  11. package/examples/plugins/echo/register.ts +31 -31
  12. package/node_modules/@vellumai/slack-text/src/index.test.ts +114 -14
  13. package/node_modules/@vellumai/slack-text/src/index.ts +82 -18
  14. package/openapi.yaml +642 -5
  15. package/package.json +3 -1
  16. package/scripts/generate-openapi.ts +83 -10
  17. package/scripts/sync-llm-catalog.ts +2 -2
  18. package/scripts/sync-web-search-catalog.ts +47 -25
  19. package/src/__tests__/agent-image-optimize.test.ts +11 -3
  20. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  21. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  22. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +131 -0
  23. package/src/__tests__/anthropic-provider.test.ts +45 -0
  24. package/src/__tests__/app-builder-tool-scripts.test.ts +9 -3
  25. package/src/__tests__/app-executors.test.ts +220 -4
  26. package/src/__tests__/auto-analysis-end-to-end.test.ts +35 -0
  27. package/src/__tests__/bundled-asset.test.ts +6 -6
  28. package/src/__tests__/channel-availability-routes.test.ts +206 -0
  29. package/src/__tests__/channel-delivery-store.test.ts +289 -1
  30. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -1
  31. package/src/__tests__/clawhub.test.ts +75 -16
  32. package/src/__tests__/compactor-tail-resolution.test.ts +147 -0
  33. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  34. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  35. package/src/__tests__/config-schema.test.ts +21 -0
  36. package/src/__tests__/config-set-route.test.ts +80 -0
  37. package/src/__tests__/config-sounds-sync.test.ts +97 -0
  38. package/src/__tests__/config-watcher-skill-reseed.test.ts +453 -0
  39. package/src/__tests__/context-search-conversations-source.test.ts +117 -2
  40. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  41. package/src/__tests__/context-search-workspace-source.test.ts +7 -0
  42. package/src/__tests__/context-token-estimator.test.ts +31 -65
  43. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  44. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  45. package/src/__tests__/conversation-agent-loop-overflow.test.ts +92 -92
  46. package/src/__tests__/conversation-agent-loop.test.ts +59 -1
  47. package/src/__tests__/conversation-error.test.ts +42 -3
  48. package/src/__tests__/conversation-fork-crud.test.ts +82 -0
  49. package/src/__tests__/conversation-inference-profile-route.test.ts +40 -4
  50. package/src/__tests__/conversation-lifecycle.test.ts +173 -0
  51. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  52. package/src/__tests__/conversation-message-sync-tags.test.ts +97 -0
  53. package/src/__tests__/conversation-pairing.test.ts +54 -0
  54. package/src/__tests__/conversation-process-callsite.test.ts +4 -1
  55. package/src/__tests__/conversation-provider-retry-repair.test.ts +5 -1
  56. package/src/__tests__/conversation-queue.test.ts +4 -1
  57. package/src/__tests__/conversation-runtime-assembly.test.ts +102 -13
  58. package/src/__tests__/conversation-slash-queue.test.ts +59 -1
  59. package/src/__tests__/conversation-slash-unknown.test.ts +4 -1
  60. package/src/__tests__/conversation-surfaces-table-action.test.ts +360 -0
  61. package/src/__tests__/conversation-sync-tags.test.ts +235 -0
  62. package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
  63. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
  64. package/src/__tests__/credential-security-invariants.test.ts +3 -2
  65. package/src/__tests__/date-context.test.ts +45 -0
  66. package/src/__tests__/db-slack-external-content-normalization.test.ts +301 -0
  67. package/src/__tests__/delete-managed-skill-tool.test.ts +55 -13
  68. package/src/__tests__/disk-pressure-tools.test.ts +1 -0
  69. package/src/__tests__/dm-backfill.test.ts +121 -10
  70. package/src/__tests__/document-tool-security.test.ts +258 -0
  71. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  72. package/src/__tests__/edit-propagation.test.ts +33 -0
  73. package/src/__tests__/empty-response-pipeline.test.ts +0 -4
  74. package/src/__tests__/external-plugin-loader.test.ts +151 -55
  75. package/src/__tests__/filing-service.test.ts +140 -0
  76. package/src/__tests__/get-skill-detail-audit.test.ts +0 -4
  77. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  78. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  79. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +43 -62
  80. package/src/__tests__/heartbeat-service.test.ts +24 -164
  81. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  82. package/src/__tests__/helpers/tar-fixtures.ts +39 -0
  83. package/src/__tests__/helpers/wait-for.ts +21 -0
  84. package/src/__tests__/history-repair-pipeline.test.ts +0 -3
  85. package/src/__tests__/history-repair.test.ts +73 -0
  86. package/src/__tests__/host-app-control-proxy.test.ts +507 -10
  87. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  88. package/src/__tests__/image-credentials.test.ts +1 -1
  89. package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
  90. package/src/__tests__/inference-no-mode-boot-e2e.test.ts +1 -1
  91. package/src/__tests__/inference-profile-reaper.test.ts +4 -2
  92. package/src/__tests__/inference-profile-session-handler.test.ts +18 -6
  93. package/src/__tests__/inference-profile-session-ipc.test.ts +17 -5
  94. package/src/__tests__/injector-background-turn.test.ts +153 -0
  95. package/src/__tests__/injector-chain.test.ts +15 -8
  96. package/src/__tests__/install-skill-routing.test.ts +155 -37
  97. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +99 -3
  98. package/src/__tests__/list-messages-page-latest.test.ts +55 -0
  99. package/src/__tests__/llm-call-pipeline.test.ts +0 -3
  100. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  101. package/src/__tests__/llm-catalog-parity.test.ts +58 -13
  102. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  103. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  104. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +36 -0
  105. package/src/__tests__/llm-request-log-source-factory.test.ts +29 -53
  106. package/src/__tests__/llm-resolver.test.ts +255 -2
  107. package/src/__tests__/llm-usage-store.test.ts +114 -0
  108. package/src/__tests__/managed-profile-guard.test.ts +41 -29
  109. package/src/__tests__/managed-skill-lifecycle.test.ts +109 -18
  110. package/src/__tests__/managed-store.test.ts +84 -192
  111. package/src/__tests__/media-generate-image.test.ts +1 -1
  112. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -2
  113. package/src/__tests__/messages-after-tiebreaker.test.ts +122 -0
  114. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  115. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  116. package/src/__tests__/notification-deep-link.test.ts +15 -0
  117. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  118. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  119. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  120. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  121. package/src/__tests__/oauth-commands-routes.test.ts +168 -16
  122. package/src/__tests__/oauth-provider-profiles.test.ts +9 -0
  123. package/src/__tests__/openai-provider.test.ts +242 -3
  124. package/src/__tests__/openai-responses-cutover-guard.test.ts +17 -9
  125. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  126. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  127. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -2
  128. package/src/__tests__/persistence-pipeline.test.ts +0 -2
  129. package/src/__tests__/{managed-proxy-context.test.ts → platform-proxy-context.test.ts} +7 -2
  130. package/src/__tests__/platform.test.ts +2 -0
  131. package/src/__tests__/plugin-api-shim.test.ts +125 -0
  132. package/src/__tests__/plugin-bootstrap.test.ts +10 -36
  133. package/src/__tests__/plugin-external-api.test.ts +68 -0
  134. package/src/__tests__/plugin-registry.test.ts +0 -77
  135. package/src/__tests__/plugin-route-contribution.test.ts +0 -1
  136. package/src/__tests__/plugin-skill-contribution.test.ts +0 -2
  137. package/src/__tests__/plugin-tool-contribution.test.ts +16 -15
  138. package/src/__tests__/plugin-types.test.ts +3 -13
  139. package/src/__tests__/process-message-background-slack.test.ts +8 -1
  140. package/src/__tests__/process-message-display-content.test.ts +421 -0
  141. package/src/__tests__/provider-catalog-visibility.test.ts +158 -0
  142. package/src/__tests__/provider-error-scenarios.test.ts +111 -0
  143. package/src/__tests__/{provider-managed-proxy-integration.test.ts → provider-platform-proxy-integration.test.ts} +33 -31
  144. package/src/__tests__/scaffold-managed-skill-tool.test.ts +65 -13
  145. package/src/__tests__/schedule-routes.test.ts +50 -3
  146. package/src/__tests__/schedule-store.test.ts +94 -0
  147. package/src/__tests__/scheduler-reuse-conversation.test.ts +54 -7
  148. package/src/__tests__/schema-transforms.test.ts +20 -0
  149. package/src/__tests__/search-skills-unified.test.ts +0 -5
  150. package/src/__tests__/{secret-routes-managed-proxy.test.ts → secret-routes-platform-proxy.test.ts} +1 -1
  151. package/src/__tests__/server-history-render.test.ts +43 -0
  152. package/src/__tests__/skill-load-feature-flag.test.ts +0 -12
  153. package/src/__tests__/skill-load-tool.test.ts +27 -89
  154. package/src/__tests__/skill-memory.test.ts +23 -3
  155. package/src/__tests__/skills-file-content-endpoint.test.ts +9 -38
  156. package/src/__tests__/skills-files-catalog-fallback.test.ts +0 -3
  157. package/src/__tests__/skills-install-extract.test.ts +49 -38
  158. package/src/__tests__/skills-install-staging.test.ts +159 -0
  159. package/src/__tests__/skills-uninstall.test.ts +9 -41
  160. package/src/__tests__/skills.test.ts +51 -58
  161. package/src/__tests__/slack-channel-config.test.ts +9 -0
  162. package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
  163. package/src/__tests__/system-prompt.test.ts +670 -63
  164. package/src/__tests__/terminal-tools.test.ts +28 -1
  165. package/src/__tests__/thread-backfill.test.ts +557 -27
  166. package/src/__tests__/title-generate-pipeline.test.ts +0 -13
  167. package/src/__tests__/token-estimate-pipeline.test.ts +0 -3
  168. package/src/__tests__/tool-error-pipeline.test.ts +0 -3
  169. package/src/__tests__/tool-execute-pipeline.test.ts +0 -5
  170. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -1
  171. package/src/__tests__/tool-executor.test.ts +16 -4
  172. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -12
  173. package/src/__tests__/turn-events-store.test.ts +256 -0
  174. package/src/__tests__/twilio-routes.test.ts +4 -0
  175. package/src/__tests__/user-plugin-loader.test.ts +0 -7
  176. package/src/__tests__/voice-session-bridge.test.ts +198 -0
  177. package/src/__tests__/web-search-catalog-parity.test.ts +32 -10
  178. package/src/__tests__/workspace-migration-057-repair-stale-gemini-model-ids.test.ts +115 -3
  179. package/src/__tests__/workspace-migration-072-seed-reply-suggestion-callsite.test.ts +50 -0
  180. package/src/__tests__/workspace-migration-073-repair-recall-callsite-empty-profile.test.ts +153 -0
  181. package/src/__tests__/workspace-migration-085-memory-v2-bm25-b-reembed-disabled-v2-pages.test.ts +220 -0
  182. package/src/__tests__/workspace-migration-086-revert-stale-gemini-mis-rewrites.test.ts +269 -0
  183. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  184. package/src/__tests__/workspace-migration-remove-legacy-skills-index.test.ts +309 -0
  185. package/src/__tests__/workspace-migrations-runner.test.ts +111 -3
  186. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  187. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  188. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  189. package/src/a2a/__tests__/task-store.test.ts +246 -0
  190. package/src/a2a/agent-card.ts +58 -0
  191. package/src/a2a/feature-gate.ts +8 -0
  192. package/src/a2a/protocol-constants.ts +21 -0
  193. package/src/a2a/protocol-errors.ts +50 -0
  194. package/src/a2a/protocol-types.ts +162 -0
  195. package/src/a2a/task-store.ts +168 -0
  196. package/src/acp/resolve-agent.ts +1 -1
  197. package/src/agent/image-optimize.ts +13 -5
  198. package/src/agent/loop.ts +167 -18
  199. package/src/calls/voice-session-bridge.ts +61 -42
  200. package/src/channels/config.ts +9 -0
  201. package/src/channels/types.ts +122 -0
  202. package/src/cli/__tests__/unknown-command.test.ts +24 -0
  203. package/src/cli/commands/__tests__/changelog.test.ts +304 -319
  204. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  205. package/src/cli/commands/__tests__/schedules.test.ts +960 -0
  206. package/src/cli/commands/changelog.ts +106 -42
  207. package/src/cli/commands/conversations.ts +102 -17
  208. package/src/cli/commands/default-action.ts +10 -53
  209. package/src/cli/commands/notifications.ts +388 -346
  210. package/src/cli/commands/plugins.ts +252 -0
  211. package/src/cli/commands/schedules.ts +683 -0
  212. package/src/cli/commands/telemetry.ts +40 -0
  213. package/src/cli/lib/__tests__/cli-colors.test.ts +48 -0
  214. package/src/cli/lib/__tests__/confirm-prompt.test.ts +159 -0
  215. package/src/cli/lib/__tests__/install-from-github.test.ts +355 -0
  216. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +154 -0
  217. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  218. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +124 -0
  219. package/src/cli/lib/__tests__/unknown-command.test.ts +106 -0
  220. package/src/cli/lib/cli-colors.ts +12 -0
  221. package/src/cli/lib/confirm-prompt.ts +79 -0
  222. package/src/cli/lib/install-from-github.ts +303 -0
  223. package/src/cli/lib/list-installed-plugins.ts +137 -0
  224. package/src/cli/lib/search-plugins.ts +163 -0
  225. package/src/cli/lib/uninstall-plugin.ts +82 -0
  226. package/src/cli/lib/unknown-command.ts +111 -0
  227. package/src/cli/program.ts +52 -2
  228. package/src/config/assistant-feature-flags.ts +24 -54
  229. package/src/config/bundled-skills/app-builder/SKILL.md +140 -22
  230. package/src/config/bundled-skills/app-builder/TOOLS.json +7 -0
  231. package/src/config/bundled-skills/computer-use/TOOLS.json +15 -52
  232. package/src/config/bundled-skills/document/SKILL.md +23 -3
  233. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  234. package/src/config/bundled-skills/document/tools/document-delete.ts +12 -0
  235. package/src/config/bundled-skills/document/tools/document-list.ts +12 -0
  236. package/src/config/bundled-skills/document/tools/document-read.ts +12 -0
  237. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  238. package/src/config/bundled-skills/skill-management/SKILL.md +2 -2
  239. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  240. package/src/config/bundled-tool-registry.ts +6 -0
  241. package/src/config/call-site-defaults.ts +105 -0
  242. package/src/config/feature-flag-registry.json +41 -9
  243. package/src/config/llm-resolver.ts +52 -1
  244. package/src/config/loader.ts +64 -38
  245. package/src/config/schema.ts +9 -10
  246. package/src/config/schemas/__tests__/llm-request-logs.test.ts +36 -0
  247. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  248. package/src/config/schemas/channels.ts +17 -0
  249. package/src/config/schemas/compaction.ts +28 -0
  250. package/src/config/schemas/conversations.ts +10 -0
  251. package/src/config/schemas/heartbeat.ts +23 -0
  252. package/src/config/schemas/llm-request-logs.ts +31 -7
  253. package/src/config/schemas/llm.ts +1 -0
  254. package/src/config/schemas/memory-retrieval.ts +18 -0
  255. package/src/config/schemas/memory-retrospective.ts +1 -1
  256. package/src/config/schemas/memory-v2.ts +4 -4
  257. package/src/config/schemas/memory.ts +3 -1
  258. package/src/config/schemas/tools.ts +14 -0
  259. package/src/config/seed-inference-profiles.ts +99 -29
  260. package/src/config/skills.ts +3 -96
  261. package/src/context/compactor.ts +1107 -0
  262. package/src/context/token-estimator.ts +34 -36
  263. package/src/context/window-manager.ts +197 -1520
  264. package/src/credential-execution/managed-catalog.ts +37 -0
  265. package/src/credential-health/credential-health-service.ts +280 -19
  266. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +33 -18
  267. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +138 -0
  268. package/src/daemon/__tests__/conversation-tool-setup.test.ts +74 -0
  269. package/src/daemon/approval-generators.ts +8 -6
  270. package/src/daemon/config-watcher.ts +94 -31
  271. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  272. package/src/daemon/conversation-agent-loop.ts +198 -11
  273. package/src/daemon/conversation-error.ts +171 -37
  274. package/src/daemon/conversation-lifecycle.ts +53 -40
  275. package/src/daemon/conversation-messaging.ts +25 -6
  276. package/src/daemon/conversation-process.ts +49 -12
  277. package/src/daemon/conversation-runtime-assembly.ts +25 -1
  278. package/src/daemon/conversation-slash.ts +12 -5
  279. package/src/daemon/conversation-store.ts +11 -4
  280. package/src/daemon/conversation-tool-setup.ts +39 -7
  281. package/src/daemon/conversation.ts +33 -8
  282. package/src/daemon/date-context.ts +40 -0
  283. package/src/daemon/external-plugins-bootstrap.ts +217 -181
  284. package/src/daemon/first-greeting.ts +22 -2
  285. package/src/daemon/guardian-action-generators.ts +1 -125
  286. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  287. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  288. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  289. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  290. package/src/daemon/handlers/config-a2a.ts +289 -0
  291. package/src/daemon/handlers/config-model.ts +6 -5
  292. package/src/daemon/handlers/config-slack-channel.ts +15 -3
  293. package/src/daemon/handlers/conversations.ts +1 -0
  294. package/src/daemon/handlers/shared.ts +14 -5
  295. package/src/daemon/handlers/skills.ts +111 -108
  296. package/src/daemon/history-repair.ts +28 -1
  297. package/src/daemon/host-app-control-proxy.ts +153 -27
  298. package/src/daemon/host-proxy-preactivation.ts +85 -18
  299. package/src/daemon/lifecycle.ts +89 -91
  300. package/src/daemon/meet-host-supervisor.ts +5 -4
  301. package/src/daemon/memory-v2-startup.ts +85 -0
  302. package/src/daemon/message-protocol.ts +1 -0
  303. package/src/daemon/message-types/conversations.ts +25 -0
  304. package/src/daemon/message-types/messages.ts +61 -0
  305. package/src/daemon/message-types/notifications.ts +21 -0
  306. package/src/daemon/message-types/subagents.ts +1 -0
  307. package/src/daemon/message-types/sync.ts +1 -0
  308. package/src/daemon/pkb-reminder-builder.test.ts +11 -54
  309. package/src/daemon/pkb-reminder-builder.ts +5 -20
  310. package/src/daemon/plugin-source-watcher.ts +146 -0
  311. package/src/daemon/process-message.ts +24 -3
  312. package/src/daemon/server.ts +11 -2
  313. package/src/daemon/skill-memory-refresh.ts +33 -0
  314. package/src/daemon/wake-target-adapter.ts +2 -0
  315. package/src/documents/document-store.ts +221 -3
  316. package/src/embedded/plugin-api.ts +40 -0
  317. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  318. package/src/export/transcript-formatter.ts +54 -20
  319. package/src/filing/filing-service.ts +39 -0
  320. package/src/heartbeat/__tests__/heartbeat-service.test.ts +135 -6
  321. package/src/heartbeat/heartbeat-run-store.ts +2 -1
  322. package/src/heartbeat/heartbeat-service.ts +73 -189
  323. package/src/home/__tests__/feed-types.test.ts +80 -0
  324. package/src/home/feed-types.ts +36 -2
  325. package/src/home/post-connect-feed.ts +1 -0
  326. package/src/index.ts +18 -1
  327. package/src/ipc/cli-client.ts +147 -45
  328. package/src/live-voice/__tests__/live-voice-stt.test.ts +57 -0
  329. package/src/mcp/client.ts +20 -4
  330. package/src/media/image-credentials.ts +3 -3
  331. package/src/memory/__tests__/bookmark-crud.test.ts +33 -27
  332. package/src/memory/__tests__/conversation-queries.test.ts +483 -0
  333. package/src/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +113 -0
  334. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  335. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  336. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +119 -14
  337. package/src/memory/__tests__/message-content.test.ts +35 -0
  338. package/src/memory/bookmark-crud.ts +42 -10
  339. package/src/memory/context-search/sources/conversations.ts +62 -2
  340. package/src/memory/context-search/sources/workspace.ts +4 -0
  341. package/src/memory/conversation-crud.ts +63 -19
  342. package/src/memory/conversation-queries.ts +197 -11
  343. package/src/memory/conversation-title-service.ts +26 -4
  344. package/src/memory/db-init.ts +12 -0
  345. package/src/memory/delivery-crud.ts +152 -5
  346. package/src/memory/embedding-backend.ts +4 -4
  347. package/src/memory/external-conversation-store.ts +66 -5
  348. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +150 -12
  349. package/src/memory/graph/conversation-graph-memory.ts +49 -21
  350. package/src/memory/graph/tools.ts +9 -40
  351. package/src/memory/indexer.ts +34 -29
  352. package/src/memory/invite-store.ts +53 -0
  353. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +73 -0
  354. package/src/memory/jobs/embed-concept-page.ts +20 -11
  355. package/src/memory/jobs-worker.ts +6 -1
  356. package/src/memory/llm-request-log-source-clickhouse.ts +24 -12
  357. package/src/memory/llm-request-log-source.ts +19 -52
  358. package/src/memory/llm-request-log-store.ts +92 -1
  359. package/src/memory/llm-usage-store.ts +125 -5
  360. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  361. package/src/memory/memory-retrospective-job.ts +33 -6
  362. package/src/memory/memory-retrospective-startup-cleanup.ts +72 -5
  363. package/src/memory/message-content.ts +1 -1
  364. package/src/memory/migrations/109-external-conversation-bindings.ts +15 -4
  365. package/src/memory/migrations/229-delete-private-conversations.test.ts +38 -1
  366. package/src/memory/migrations/229-delete-private-conversations.ts +7 -0
  367. package/src/memory/migrations/247-external-conversation-binding-thread-id.ts +78 -0
  368. package/src/memory/migrations/248-create-onboarding-events.ts +21 -0
  369. package/src/memory/migrations/249-normalize-slack-external-content.ts +240 -0
  370. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  371. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  372. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  373. package/src/memory/migrations/index.ts +9 -0
  374. package/src/memory/migrations/registry.ts +16 -0
  375. package/src/memory/onboarding-events-store.ts +106 -0
  376. package/src/memory/schema/a2a.ts +15 -0
  377. package/src/memory/schema/bookmarks.ts +0 -2
  378. package/src/memory/schema/calls.ts +1 -0
  379. package/src/memory/schema/index.ts +1 -0
  380. package/src/memory/schema/inference.ts +3 -3
  381. package/src/memory/schema/infrastructure.ts +13 -0
  382. package/src/memory/turn-events-store.ts +127 -2
  383. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  384. package/src/memory/v2/__tests__/activation.test.ts +0 -8
  385. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  386. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  387. package/src/memory/v2/__tests__/injection.test.ts +288 -11
  388. package/src/memory/v2/__tests__/migration.test.ts +87 -0
  389. package/src/memory/v2/__tests__/page-index.test.ts +83 -0
  390. package/src/memory/v2/__tests__/prompts-router.test.ts +58 -6
  391. package/src/memory/v2/__tests__/qdrant.test.ts +66 -3
  392. package/src/memory/v2/__tests__/router.test.ts +15 -0
  393. package/src/memory/v2/__tests__/skill-store.test.ts +387 -8
  394. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  395. package/src/memory/v2/activation-store.ts +14 -16
  396. package/src/memory/v2/cli-command-content.ts +19 -0
  397. package/src/memory/v2/cli-command-store.ts +304 -0
  398. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  399. package/src/memory/v2/injection.ts +81 -26
  400. package/src/memory/v2/migration.ts +49 -19
  401. package/src/memory/v2/page-index.ts +63 -8
  402. package/src/memory/v2/prompts/router.ts +11 -8
  403. package/src/memory/v2/prompts/sweep.ts +2 -2
  404. package/src/memory/v2/qdrant.ts +135 -7
  405. package/src/memory/v2/router.ts +9 -8
  406. package/src/memory/v2/skill-store.ts +120 -35
  407. package/src/memory/v2/static-context.ts +4 -4
  408. package/src/memory/v2/types.ts +23 -0
  409. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  410. package/src/messaging/providers/a2a/deliver.ts +156 -0
  411. package/src/messaging/providers/gmail/client.ts +9 -2
  412. package/src/messaging/providers/index.ts +11 -2
  413. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +45 -5
  414. package/src/messaging/providers/slack/__tests__/download.test.ts +231 -0
  415. package/src/messaging/providers/slack/adapter.ts +43 -5
  416. package/src/messaging/providers/slack/client.ts +27 -0
  417. package/src/messaging/providers/slack/deep-link.ts +65 -0
  418. package/src/messaging/providers/slack/download.ts +104 -0
  419. package/src/messaging/providers/slack/message-metadata.test.ts +32 -0
  420. package/src/messaging/providers/slack/message-metadata.ts +27 -0
  421. package/src/messaging/providers/slack/render-transcript.test.ts +134 -0
  422. package/src/messaging/providers/slack/render-transcript.ts +69 -5
  423. package/src/messaging/providers/slack/types.ts +20 -1
  424. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  425. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  426. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  427. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  428. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  429. package/src/notifications/adapters/macos.ts +12 -2
  430. package/src/notifications/broadcaster.ts +29 -4
  431. package/src/notifications/conversation-pairing.ts +2 -1
  432. package/src/notifications/copy-composer.ts +17 -64
  433. package/src/notifications/decision-engine.ts +113 -45
  434. package/src/notifications/deterministic-checks.ts +96 -0
  435. package/src/notifications/emit-signal.ts +21 -1
  436. package/src/notifications/home-feed-side-effect.ts +138 -5
  437. package/src/notifications/signal.ts +3 -5
  438. package/src/notifications/types.ts +8 -0
  439. package/src/oauth/connection-resolver.ts +8 -4
  440. package/src/oauth/platform-connection.test.ts +43 -3
  441. package/src/oauth/platform-connection.ts +19 -6
  442. package/src/oauth/seed-providers.ts +10 -1
  443. package/src/permissions/checker.ts +2 -0
  444. package/src/permissions/ipc-risk-types.ts +1 -0
  445. package/src/permissions/question-prompter.test.ts +416 -0
  446. package/src/permissions/question-prompter.ts +294 -0
  447. package/src/platform/client.test.ts +1 -1
  448. package/src/platform/client.ts +1 -1
  449. package/src/plugin-api/constants.ts +26 -0
  450. package/src/plugin-api/index.ts +34 -1
  451. package/src/plugin-api/types.ts +104 -22
  452. package/src/plugins/defaults/circuit-breaker.ts +0 -5
  453. package/src/plugins/defaults/compaction.ts +0 -4
  454. package/src/plugins/defaults/empty-response.ts +0 -2
  455. package/src/plugins/defaults/history-repair.ts +0 -2
  456. package/src/plugins/defaults/injectors.ts +74 -22
  457. package/src/plugins/defaults/llm-call.ts +0 -2
  458. package/src/plugins/defaults/memory-retrieval.ts +0 -1
  459. package/src/plugins/defaults/overflow-reduce.ts +0 -1
  460. package/src/plugins/defaults/persistence.ts +0 -2
  461. package/src/plugins/defaults/title-generate.ts +0 -5
  462. package/src/plugins/defaults/token-estimate.ts +0 -2
  463. package/src/plugins/defaults/tool-error.ts +0 -7
  464. package/src/plugins/defaults/tool-execute.ts +0 -2
  465. package/src/plugins/defaults/tool-result-truncate.ts +0 -4
  466. package/src/plugins/ensure-plugin-api-shim.ts +96 -0
  467. package/src/plugins/external-api.ts +104 -0
  468. package/src/plugins/external-plugin-loader.ts +187 -42
  469. package/src/plugins/feature-gate.ts +22 -0
  470. package/src/plugins/pipeline.ts +37 -0
  471. package/src/plugins/registry.ts +48 -80
  472. package/src/plugins/types.ts +40 -26
  473. package/src/plugins/user-loader.ts +21 -2
  474. package/src/proactive-artifact/aux-message-injector.ts +11 -0
  475. package/src/proactive-artifact/job.test.ts +37 -5
  476. package/src/prompts/__tests__/system-prompt.test.ts +10 -43
  477. package/src/prompts/__tests__/task-progress-hint-section.test.ts +95 -0
  478. package/src/prompts/normalize-onboarding.ts +27 -0
  479. package/src/prompts/sections.ts +302 -0
  480. package/src/prompts/system-prompt.ts +63 -174
  481. package/src/prompts/templates/BOOTSTRAP.md +17 -1
  482. package/src/prompts/templates/system-sections.ts +164 -0
  483. package/src/providers/__tests__/inference.test.ts +24 -7
  484. package/src/providers/anthropic/client.ts +28 -28
  485. package/src/providers/call-site-routing.ts +24 -6
  486. package/src/providers/connection-resolution.ts +68 -11
  487. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  488. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  489. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  490. package/src/providers/inference/adapter-factory.ts +32 -6
  491. package/src/providers/inference/auth.ts +12 -0
  492. package/src/providers/inference/backfill.ts +14 -1
  493. package/src/providers/inference/connections.ts +159 -34
  494. package/src/providers/inference/resolve-auth.ts +14 -4
  495. package/src/providers/model-catalog.ts +249 -12
  496. package/src/providers/model-intents.ts +3 -3
  497. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  498. package/src/providers/openai/chat-completions-provider.ts +169 -8
  499. package/src/providers/openrouter/client.ts +49 -4
  500. package/src/providers/{managed-proxy → platform-proxy}/constants.ts +4 -2
  501. package/src/providers/{managed-proxy → platform-proxy}/context.ts +3 -3
  502. package/src/providers/provider-availability.ts +17 -2
  503. package/src/providers/provider-catalog-visibility.ts +38 -0
  504. package/src/providers/provider-send-message.ts +27 -12
  505. package/src/providers/registry.ts +52 -15
  506. package/src/providers/retry.ts +47 -1
  507. package/src/runtime/__tests__/agent-wake.test.ts +152 -0
  508. package/src/runtime/agent-wake.ts +103 -15
  509. package/src/runtime/auth/route-policy.ts +21 -1
  510. package/src/runtime/btw-sidechain.ts +2 -0
  511. package/src/runtime/http-server.ts +7 -16
  512. package/src/runtime/http-types.ts +19 -47
  513. package/src/runtime/migrations/origin-mode.ts +1 -1
  514. package/src/runtime/pending-interactions.ts +1 -0
  515. package/src/runtime/routes/__tests__/bookmark-routes.test.ts +17 -0
  516. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  517. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +5 -1
  518. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +172 -23
  519. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  520. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  521. package/src/runtime/routes/__tests__/question-routes.test.ts +395 -0
  522. package/src/runtime/routes/__tests__/tts-routes.test.ts +64 -1
  523. package/src/runtime/routes/acp-routes-list.test.ts +143 -0
  524. package/src/runtime/routes/acp-routes.ts +5 -3
  525. package/src/runtime/routes/auth-routes.ts +1 -1
  526. package/src/runtime/routes/bookmark-routes.ts +5 -3
  527. package/src/runtime/routes/btw-routes.ts +5 -1
  528. package/src/runtime/routes/channel-availability-routes.ts +126 -0
  529. package/src/runtime/routes/consolidation-routes.ts +100 -0
  530. package/src/runtime/routes/conversation-cli-routes.ts +44 -3
  531. package/src/runtime/routes/conversation-list-routes.ts +3 -20
  532. package/src/runtime/routes/conversation-management-routes.ts +17 -42
  533. package/src/runtime/routes/conversation-query-routes.ts +99 -35
  534. package/src/runtime/routes/conversation-routes.ts +97 -11
  535. package/src/runtime/routes/documents-routes.ts +25 -86
  536. package/src/runtime/routes/group-routes.ts +5 -0
  537. package/src/runtime/routes/inbound-conversation.ts +28 -8
  538. package/src/runtime/routes/inbound-message-handler.ts +236 -41
  539. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +111 -0
  540. package/src/runtime/routes/inbound-stages/background-dispatch.ts +32 -1
  541. package/src/runtime/routes/inbound-stages/edit-intercept.ts +17 -4
  542. package/src/runtime/routes/index.ts +8 -0
  543. package/src/runtime/routes/inference-profile-session-handler.ts +17 -44
  544. package/src/runtime/routes/inference-profile-session-reaper.ts +7 -21
  545. package/src/runtime/routes/inference-provider-connection-routes.ts +199 -22
  546. package/src/runtime/routes/integrations/a2a.ts +235 -0
  547. package/src/runtime/routes/integrations/slack/share.ts +4 -52
  548. package/src/runtime/routes/integrations/slack/token.ts +43 -0
  549. package/src/runtime/routes/integrations/twilio.ts +6 -13
  550. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  551. package/src/runtime/routes/notification-routes.ts +1 -1
  552. package/src/runtime/routes/oauth-commands-routes.ts +105 -15
  553. package/src/runtime/routes/oauth-lifecycle-routes.ts +43 -0
  554. package/src/runtime/routes/question-routes.ts +259 -0
  555. package/src/runtime/routes/rename-conversation-routes.ts +2 -33
  556. package/src/runtime/routes/schedule-routes.ts +4 -7
  557. package/src/runtime/routes/subagents-routes.ts +98 -18
  558. package/src/runtime/routes/telemetry-routes.ts +27 -0
  559. package/src/runtime/routes/tts-routes.ts +27 -2
  560. package/src/runtime/routes/workspace-routes.test.ts +43 -0
  561. package/src/runtime/routes/workspace-routes.ts +28 -0
  562. package/src/runtime/services/conversation-serializer.ts +39 -7
  563. package/src/runtime/sync/resource-sync-events.ts +93 -1
  564. package/src/schedule/schedule-store.ts +27 -2
  565. package/src/schedule/scheduler.ts +9 -1
  566. package/src/security/__tests__/untrusted-content.test.ts +86 -0
  567. package/src/security/untrusted-content.ts +93 -8
  568. package/src/skills/catalog-files.ts +1 -1
  569. package/src/skills/catalog-install.ts +233 -116
  570. package/src/skills/clawhub.ts +70 -13
  571. package/src/skills/managed-store.ts +4 -119
  572. package/src/skills/skillssh-registry.ts +27 -48
  573. package/src/subagent/manager.ts +17 -7
  574. package/src/telemetry/types.ts +113 -1
  575. package/src/telemetry/usage-telemetry-reporter.test.ts +312 -5
  576. package/src/telemetry/usage-telemetry-reporter.ts +113 -7
  577. package/src/tools/apps/executors.ts +58 -7
  578. package/src/tools/ask-question/ask-question-tool.test.ts +509 -0
  579. package/src/tools/ask-question/ask-question-tool.ts +304 -0
  580. package/src/tools/browser/browser-execution.ts +15 -11
  581. package/src/tools/computer-use/definitions.ts +3 -3
  582. package/src/tools/credentials/vault.ts +1 -1
  583. package/src/tools/document/document-tool.ts +124 -1
  584. package/src/tools/filesystem/edit.ts +1 -1
  585. package/src/tools/filesystem/list.ts +1 -1
  586. package/src/tools/filesystem/read.ts +1 -1
  587. package/src/tools/filesystem/write.ts +5 -2
  588. package/src/tools/host-filesystem/transfer.ts +1 -1
  589. package/src/tools/host-terminal/host-shell.ts +1 -1
  590. package/src/tools/memory/register.ts +1 -9
  591. package/src/tools/permission-checker.ts +1 -1
  592. package/src/tools/registry.ts +17 -7
  593. package/src/tools/schedule/create.ts +2 -2
  594. package/src/tools/schema-transforms.ts +7 -2
  595. package/src/tools/side-effects.ts +1 -0
  596. package/src/tools/skills/delete-managed.ts +4 -4
  597. package/src/tools/skills/execute.ts +1 -1
  598. package/src/tools/skills/scaffold-managed.ts +3 -2
  599. package/src/tools/subagent/notify-parent.ts +1 -1
  600. package/src/tools/system/request-permission.ts +2 -2
  601. package/src/tools/terminal/safe-env.ts +60 -1
  602. package/src/tools/tool-manifest.ts +2 -0
  603. package/src/tools/types.ts +107 -21
  604. package/src/tools/ui-surface/definitions.ts +6 -5
  605. package/src/tts/__tests__/provider-adapters.test.ts +76 -2
  606. package/src/tts/providers/elevenlabs-provider.ts +75 -1
  607. package/src/types/onboarding-context.ts +2 -0
  608. package/src/util/errors.ts +17 -0
  609. package/src/util/platform.ts +10 -0
  610. package/src/watcher/__tests__/engine.test.ts +22 -0
  611. package/src/watcher/engine.ts +6 -2
  612. package/src/workspace/migrations/057-repair-stale-gemini-model-ids.ts +80 -15
  613. package/src/workspace/migrations/072-seed-reply-suggestion-callsite.ts +35 -22
  614. package/src/workspace/migrations/073-repair-recall-callsite-empty-profile.ts +3 -1
  615. package/src/workspace/migrations/083-system-prompt-prefix-to-file.ts +191 -0
  616. package/src/workspace/migrations/084-remove-legacy-skills-index.ts +276 -0
  617. package/src/workspace/migrations/085-memory-v2-bm25-b-reembed-disabled-v2-pages.ts +137 -0
  618. package/src/workspace/migrations/086-revert-stale-gemini-mis-rewrites.ts +198 -0
  619. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  620. package/src/workspace/migrations/registry.ts +10 -0
  621. package/src/workspace/migrations/runner.ts +39 -9
  622. package/src/workspace/migrations/types.ts +4 -0
  623. package/examples/plugins/echo/bun.lock +0 -25
  624. package/src/__tests__/context-window-manager.test.ts +0 -2481
  625. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  626. package/src/context/__tests__/compact-prompt.test.ts +0 -63
  627. package/src/context/prompts/compact.md +0 -26
  628. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  629. package/src/prompts/__tests__/build-cli-reference-section.test.ts +0 -37
  630. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -0,0 +1,416 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type {
4
+ QuestionRequest,
5
+ ServerMessage,
6
+ } from "../daemon/message-protocol.js";
7
+ import type {
8
+ QuestionBatchSubmission,
9
+ QuestionPromptResult,
10
+ } from "./question-prompter.js";
11
+
12
+ // Use a tiny timeout so the setTimeout branch fires quickly in tests
13
+ const mockConfig = {
14
+ timeouts: { permissionTimeoutSec: 0.05 },
15
+ };
16
+ mock.module("../config/loader.js", () => ({
17
+ getConfig: () => mockConfig,
18
+ loadConfig: () => mockConfig,
19
+ invalidateConfigCache: () => {},
20
+ }));
21
+
22
+ mock.module("../util/logger.js", () => ({
23
+ getLogger: () => ({
24
+ info: () => {},
25
+ warn: () => {},
26
+ error: () => {},
27
+ debug: () => {},
28
+ trace: () => {},
29
+ fatal: () => {},
30
+ child: () => ({
31
+ info: () => {},
32
+ warn: () => {},
33
+ error: () => {},
34
+ debug: () => {},
35
+ }),
36
+ }),
37
+ }));
38
+
39
+ // Use a real Map so QuestionPrompter can store and retrieve callbacks.
40
+ interface MockInteraction {
41
+ rpcResolve?: (v: unknown) => void;
42
+ rpcReject?: (e: unknown) => void;
43
+ timer?: ReturnType<typeof setTimeout>;
44
+ metadata?: {
45
+ orderedIds: string[];
46
+ optionsById: Record<string, string[]>;
47
+ };
48
+ }
49
+ const _piStore = new Map<string, MockInteraction>();
50
+ mock.module("../runtime/pending-interactions.js", () => ({
51
+ register: (id: string, entry: MockInteraction) => _piStore.set(id, entry),
52
+ resolve: (id: string) => {
53
+ const e = _piStore.get(id);
54
+ if (e?.timer != null) clearTimeout(e.timer);
55
+ _piStore.delete(id);
56
+ return e;
57
+ },
58
+ get: (id: string) => _piStore.get(id),
59
+ getAll: () => [..._piStore.values()],
60
+ getByConversation: () => [],
61
+ getByKind: () => [],
62
+ removeByConversation: () => {},
63
+ clear: () => _piStore.clear(),
64
+ }));
65
+
66
+ const {
67
+ QuestionPrompter,
68
+ QuestionBatchValidationError,
69
+ buildBatchEntries,
70
+ } = await import("./question-prompter.js");
71
+
72
+ function makePrompter() {
73
+ const sent: ServerMessage[] = [];
74
+ const prompter = new QuestionPrompter({
75
+ broadcastMessage: (msg) => sent.push(msg),
76
+ });
77
+ return { prompter, sent };
78
+ }
79
+
80
+ /**
81
+ * Drive a pending question interaction the same way the
82
+ * `/v1/question-response` route does: look up the metadata, run
83
+ * `buildBatchEntries`, deregister, then fire `rpcResolve`. Centralizing the
84
+ * sequence in one helper keeps the tests focused on observable behavior and
85
+ * mirrors the production resolution path.
86
+ */
87
+ function resolveBatch(
88
+ requestId: string,
89
+ submissions: QuestionBatchSubmission[],
90
+ ): QuestionPromptResult {
91
+ const interaction = _piStore.get(requestId);
92
+ if (!interaction?.metadata) {
93
+ throw new Error(`No pending question interaction for ${requestId}`);
94
+ }
95
+ const { orderedIds, optionsById } = interaction.metadata;
96
+ const entries = buildBatchEntries(
97
+ orderedIds,
98
+ (qid, oid) => (optionsById[qid] ?? []).includes(oid),
99
+ new Set(Object.keys(optionsById)),
100
+ submissions,
101
+ );
102
+ const result: QuestionPromptResult = { entries, overall: "completed" };
103
+ if (interaction.timer != null) clearTimeout(interaction.timer);
104
+ _piStore.delete(requestId);
105
+ interaction.rpcResolve?.(result);
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Close a pending question card: every entry reported as `skipped`, overall
111
+ * status `closed`. Mirrors the route's `kind: "close"` branch.
112
+ */
113
+ function closeBatch(requestId: string): QuestionPromptResult {
114
+ const interaction = _piStore.get(requestId);
115
+ if (!interaction?.metadata) {
116
+ throw new Error(`No pending question interaction for ${requestId}`);
117
+ }
118
+ const result: QuestionPromptResult = {
119
+ entries: interaction.metadata.orderedIds.map((id) => ({
120
+ questionId: id,
121
+ decision: "skipped" as const,
122
+ })),
123
+ overall: "closed",
124
+ };
125
+ if (interaction.timer != null) clearTimeout(interaction.timer);
126
+ _piStore.delete(requestId);
127
+ interaction.rpcResolve?.(result);
128
+ return result;
129
+ }
130
+
131
+ const fruitOptions = [
132
+ { id: "a", label: "Apple" },
133
+ { id: "b", label: "Banana" },
134
+ ];
135
+
136
+ const singleQuestionParams = {
137
+ conversationId: "conv-1",
138
+ questions: [
139
+ {
140
+ question: "Pick one",
141
+ options: fruitOptions,
142
+ },
143
+ ],
144
+ };
145
+
146
+ const threeQuestionParams = {
147
+ conversationId: "conv-1",
148
+ questions: [
149
+ { question: "Q1?", options: fruitOptions },
150
+ {
151
+ question: "Q2?",
152
+ options: [
153
+ { id: "x", label: "X" },
154
+ { id: "y", label: "Y" },
155
+ ],
156
+ },
157
+ {
158
+ question: "Q3?",
159
+ options: [
160
+ { id: "p", label: "P" },
161
+ { id: "q", label: "Q" },
162
+ ],
163
+ freeTextPlaceholder: "or type",
164
+ },
165
+ ],
166
+ };
167
+
168
+ describe("QuestionPrompter", () => {
169
+ beforeEach(() => {
170
+ _piStore.clear();
171
+ });
172
+
173
+ test("happy path: option resolution via the shared batch helpers", async () => {
174
+ const { prompter, sent } = makePrompter();
175
+
176
+ const promise = prompter.prompt(singleQuestionParams);
177
+
178
+ expect(sent).toHaveLength(1);
179
+ const req = sent[0] as QuestionRequest;
180
+ expect(req.type).toBe("question_request");
181
+ expect(req.questions).toHaveLength(1);
182
+ expect(req.questions[0]?.id).toBe("q1");
183
+
184
+ resolveBatch(req.requestId, [
185
+ { questionId: "q1", kind: "option", optionId: "a" },
186
+ ]);
187
+
188
+ const result = await promise;
189
+ expect(result).toEqual({
190
+ entries: [{ questionId: "q1", decision: "option", optionId: "a" }],
191
+ overall: "completed",
192
+ });
193
+ expect(_piStore.has(req.requestId)).toBe(false);
194
+ });
195
+
196
+ test("free-text resolution", async () => {
197
+ const { prompter, sent } = makePrompter();
198
+
199
+ const promise = prompter.prompt({
200
+ conversationId: "conv-1",
201
+ questions: [
202
+ {
203
+ question: "Pick one",
204
+ options: fruitOptions,
205
+ freeTextPlaceholder: "Type a fruit",
206
+ },
207
+ ],
208
+ });
209
+
210
+ const req = sent[0] as QuestionRequest;
211
+ expect(req.freeTextPlaceholder).toBe("Type a fruit");
212
+ expect(req.questions[0]?.freeTextPlaceholder).toBe("Type a fruit");
213
+
214
+ resolveBatch(req.requestId, [
215
+ { questionId: "q1", kind: "free_text", text: "Cherry" },
216
+ ]);
217
+
218
+ const result = await promise;
219
+ expect(result).toEqual({
220
+ entries: [{ questionId: "q1", decision: "free_text", text: "Cherry" }],
221
+ overall: "completed",
222
+ });
223
+ });
224
+
225
+ test("batched broadcast: assigns sequential q1..qN ids and mirrors questions[0] in flat fields", async () => {
226
+ const { prompter, sent } = makePrompter();
227
+
228
+ void prompter.prompt(threeQuestionParams);
229
+
230
+ expect(sent).toHaveLength(1);
231
+ const req = sent[0] as QuestionRequest;
232
+ expect(req.questions.map((q) => q.id)).toEqual(["q1", "q2", "q3"]);
233
+ // Flat fields mirror the first entry for backwards compat.
234
+ expect(req.question).toBe("Q1?");
235
+ expect(req.options).toEqual(fruitOptions);
236
+ });
237
+
238
+ test("three-question batch: two options + one free-text → ordered entries", async () => {
239
+ const { prompter, sent } = makePrompter();
240
+
241
+ const promise = prompter.prompt(threeQuestionParams);
242
+ const req = sent[0] as QuestionRequest;
243
+
244
+ resolveBatch(req.requestId, [
245
+ { questionId: "q2", kind: "option", optionId: "y" },
246
+ { questionId: "q1", kind: "option", optionId: "a" },
247
+ { questionId: "q3", kind: "free_text", text: "noon-ish" },
248
+ ]);
249
+
250
+ const result = await promise;
251
+ expect(result.overall).toBe("completed");
252
+ // Result entries are in the original questions[] order, regardless of
253
+ // the order submissions arrive in.
254
+ expect(result.entries).toEqual([
255
+ { questionId: "q1", decision: "option", optionId: "a" },
256
+ { questionId: "q2", decision: "option", optionId: "y" },
257
+ { questionId: "q3", decision: "free_text", text: "noon-ish" },
258
+ ]);
259
+ });
260
+
261
+ test("three-question batch: all skipped via submitted entries", async () => {
262
+ const { prompter, sent } = makePrompter();
263
+
264
+ const promise = prompter.prompt(threeQuestionParams);
265
+ const req = sent[0] as QuestionRequest;
266
+
267
+ resolveBatch(req.requestId, [
268
+ { questionId: "q1", kind: "skip" },
269
+ { questionId: "q2", kind: "skip" },
270
+ { questionId: "q3", kind: "skip" },
271
+ ]);
272
+
273
+ const result = await promise;
274
+ expect(result.overall).toBe("completed");
275
+ expect(result.entries.every((e) => e.decision === "skipped")).toBe(true);
276
+ });
277
+
278
+ test("close path: all entries skipped with overall=closed", async () => {
279
+ const { prompter, sent } = makePrompter();
280
+
281
+ const promise = prompter.prompt(threeQuestionParams);
282
+ const req = sent[0] as QuestionRequest;
283
+
284
+ closeBatch(req.requestId);
285
+
286
+ const result = await promise;
287
+ expect(result.overall).toBe("closed");
288
+ expect(result.entries).toEqual([
289
+ { questionId: "q1", decision: "skipped" },
290
+ { questionId: "q2", decision: "skipped" },
291
+ { questionId: "q3", decision: "skipped" },
292
+ ]);
293
+ });
294
+
295
+ test("buildBatchEntries rejects unknown questionId", () => {
296
+ expect(() =>
297
+ buildBatchEntries(
298
+ ["q1"],
299
+ () => true,
300
+ new Set(["q1"]),
301
+ [{ questionId: "qX", kind: "option", optionId: "a" }],
302
+ ),
303
+ ).toThrow(QuestionBatchValidationError);
304
+ });
305
+
306
+ test("buildBatchEntries rejects missing entry", () => {
307
+ expect(() =>
308
+ buildBatchEntries(
309
+ ["q1", "q2", "q3"],
310
+ () => true,
311
+ new Set(["q1", "q2", "q3"]),
312
+ [
313
+ { questionId: "q1", kind: "option", optionId: "a" },
314
+ { questionId: "q2", kind: "option", optionId: "x" },
315
+ ],
316
+ ),
317
+ ).toThrow(QuestionBatchValidationError);
318
+ });
319
+
320
+ test("buildBatchEntries rejects unknown optionId", () => {
321
+ expect(() =>
322
+ buildBatchEntries(
323
+ ["q1"],
324
+ (qid, oid) => qid === "q1" && (oid === "a" || oid === "b"),
325
+ new Set(["q1"]),
326
+ [{ questionId: "q1", kind: "option", optionId: "nope" }],
327
+ ),
328
+ ).toThrow(QuestionBatchValidationError);
329
+ });
330
+
331
+ test("timeout fires with overall: timed_out and timed_out entries", async () => {
332
+ const { prompter } = makePrompter();
333
+ const result = await prompter.prompt(threeQuestionParams);
334
+ expect(result.overall).toBe("timed_out");
335
+ expect(result.entries).toEqual([
336
+ { questionId: "q1", decision: "timed_out" },
337
+ { questionId: "q2", decision: "timed_out" },
338
+ { questionId: "q3", decision: "timed_out" },
339
+ ]);
340
+ });
341
+
342
+ test("abort signal triggers overall: aborted with per-entry aborted decisions", async () => {
343
+ const { prompter, sent } = makePrompter();
344
+ const ac = new AbortController();
345
+
346
+ const promise = prompter.prompt({
347
+ ...threeQuestionParams,
348
+ signal: ac.signal,
349
+ });
350
+ const req = sent[0] as QuestionRequest;
351
+ expect(_piStore.has(req.requestId)).toBe(true);
352
+
353
+ ac.abort();
354
+ const result = await promise;
355
+ expect(result.overall).toBe("aborted");
356
+ expect(result.entries).toEqual([
357
+ { questionId: "q1", decision: "aborted" },
358
+ { questionId: "q2", decision: "aborted" },
359
+ { questionId: "q3", decision: "aborted" },
360
+ ]);
361
+ expect(_piStore.has(req.requestId)).toBe(false);
362
+ });
363
+
364
+ test("abort after removeByConversation still resolves the Promise (no hang)", async () => {
365
+ // Regression test for the race exposed by post-merge review of #30581:
366
+ // when `removeByConversation()` (auto-deny on enqueue) deregisters the
367
+ // question interaction before the abort signal fires, the abort handler
368
+ // must still resolve the prompt Promise. Previously, the handler used
369
+ // `pendingInteractions.resolve(id) === undefined` as the idempotency
370
+ // guard — which returned `undefined` after the registry was cleared,
371
+ // causing the handler to early-return and the Promise to hang forever.
372
+ // Now an internal `settled` flag guards every resolution path.
373
+ const { prompter, sent } = makePrompter();
374
+ const ac = new AbortController();
375
+
376
+ const promise = prompter.prompt({
377
+ ...threeQuestionParams,
378
+ signal: ac.signal,
379
+ });
380
+ const req = sent[0] as QuestionRequest;
381
+ expect(_piStore.has(req.requestId)).toBe(true);
382
+
383
+ // Simulate `removeByConversation` clearing the registry entry before
384
+ // the abort signal fires.
385
+ const interaction = _piStore.get(req.requestId);
386
+ if (interaction?.timer != null) clearTimeout(interaction.timer);
387
+ _piStore.delete(req.requestId);
388
+
389
+ ac.abort();
390
+ const result = await promise;
391
+ expect(result.overall).toBe("aborted");
392
+ expect(result.entries).toEqual([
393
+ { questionId: "q1", decision: "aborted" },
394
+ { questionId: "q2", decision: "aborted" },
395
+ { questionId: "q3", decision: "aborted" },
396
+ ]);
397
+ });
398
+
399
+ test("pre-aborted signal short-circuits before broadcasting with aborted entries", async () => {
400
+ const { prompter, sent } = makePrompter();
401
+ const ac = new AbortController();
402
+ ac.abort();
403
+
404
+ const result = await prompter.prompt({
405
+ ...threeQuestionParams,
406
+ signal: ac.signal,
407
+ });
408
+ expect(result.overall).toBe("aborted");
409
+ expect(result.entries).toEqual([
410
+ { questionId: "q1", decision: "aborted" },
411
+ { questionId: "q2", decision: "aborted" },
412
+ { questionId: "q3", decision: "aborted" },
413
+ ]);
414
+ expect(sent).toHaveLength(0);
415
+ });
416
+ });
@@ -0,0 +1,294 @@
1
+ import { v4 as uuid } from "uuid";
2
+
3
+ import { getConfig } from "../config/loader.js";
4
+ import type {
5
+ QuestionOption,
6
+ QuestionRequest,
7
+ ServerMessage,
8
+ } from "../daemon/message-protocol.js";
9
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
10
+ import { AssistantError, ErrorCode } from "../util/errors.js";
11
+ import { getLogger } from "../util/logger.js";
12
+
13
+ const log = getLogger("question-prompter");
14
+
15
+ /**
16
+ * Thrown when a batched submission fails validation (unknown questionId,
17
+ * missing entries, unknown optionId, duplicate questionId). The route layer
18
+ * maps this to a 400.
19
+ */
20
+ export class QuestionBatchValidationError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "QuestionBatchValidationError";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * One per-question entry in a batched question result.
29
+ *
30
+ * `decision` records how the user responded to that specific question:
31
+ * - `"option"` / `"free_text"` — direct answer.
32
+ * - `"skipped"` — the user explicitly skipped this question, or the card
33
+ * was closed before any answer was submitted.
34
+ * - `"timed_out"` — the prompt timer fired before the client submitted.
35
+ * - `"aborted"` — the prompter's abort signal fired before any answer
36
+ * was submitted.
37
+ *
38
+ * `questionId` matches the daemon-assigned id (`q1`, `q2`...) that the
39
+ * prompter attached to the broadcast.
40
+ */
41
+ export interface QuestionPromptEntryResult {
42
+ questionId: string;
43
+ decision: "option" | "free_text" | "skipped" | "timed_out" | "aborted";
44
+ optionId?: string;
45
+ text?: string;
46
+ }
47
+
48
+ /**
49
+ * Aggregate result for a single `prompt()` call. `entries` is ordered to
50
+ * match the original `questions` array; `overall` summarizes how the
51
+ * card lifecycle ended.
52
+ */
53
+ export interface QuestionPromptResult {
54
+ entries: QuestionPromptEntryResult[];
55
+ overall: "completed" | "closed" | "timed_out" | "aborted";
56
+ }
57
+
58
+ export interface QuestionPromptParamsEntry {
59
+ question: string;
60
+ description?: string;
61
+ options: QuestionOption[];
62
+ freeTextPlaceholder?: string;
63
+ }
64
+
65
+ export interface QuestionPromptParams {
66
+ conversationId: string;
67
+ /** One or more clarifying questions to broadcast as a single card. */
68
+ questions: QuestionPromptParamsEntry[];
69
+ toolUseId?: string;
70
+ signal?: AbortSignal;
71
+ }
72
+
73
+ /** One per-question submission inside a batch from the client. */
74
+ export type QuestionBatchSubmission =
75
+ | { questionId: string; kind: "option"; optionId: string }
76
+ | { questionId: string; kind: "free_text"; text: string }
77
+ | { questionId: string; kind: "skip" };
78
+
79
+ /**
80
+ * Validate a batched submission against the original ordered ids and per-id
81
+ * option-id sets, and return the ordered per-entry result. The lookup helpers
82
+ * are passed in so callers can back the metadata with whatever container
83
+ * they prefer (Set/Map, plain Record, etc.).
84
+ *
85
+ * Throws {@link QuestionBatchValidationError} if validation fails.
86
+ */
87
+ export function buildBatchEntries(
88
+ orderedIds: readonly string[],
89
+ isKnownOption: (questionId: string, optionId: string) => boolean,
90
+ knownQuestionIds: ReadonlySet<string>,
91
+ submissions: readonly QuestionBatchSubmission[],
92
+ ): QuestionPromptEntryResult[] {
93
+ const submittedIds = new Set<string>();
94
+ for (const s of submissions) {
95
+ if (!knownQuestionIds.has(s.questionId)) {
96
+ throw new QuestionBatchValidationError(
97
+ `Unknown questionId in batch: ${s.questionId}`,
98
+ );
99
+ }
100
+ if (submittedIds.has(s.questionId)) {
101
+ throw new QuestionBatchValidationError(
102
+ `Duplicate questionId in batch: ${s.questionId}`,
103
+ );
104
+ }
105
+ submittedIds.add(s.questionId);
106
+ if (s.kind === "option" && !isKnownOption(s.questionId, s.optionId)) {
107
+ throw new QuestionBatchValidationError(
108
+ `Unknown optionId "${s.optionId}" for question ${s.questionId}`,
109
+ );
110
+ }
111
+ }
112
+ for (const id of orderedIds) {
113
+ if (!submittedIds.has(id)) {
114
+ throw new QuestionBatchValidationError(
115
+ `Missing response for questionId ${id}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ const byId = new Map<string, QuestionBatchSubmission>();
121
+ for (const s of submissions) byId.set(s.questionId, s);
122
+
123
+ return orderedIds.map((id) => {
124
+ const s = byId.get(id)!;
125
+ if (s.kind === "option") {
126
+ return { questionId: id, decision: "option", optionId: s.optionId };
127
+ }
128
+ if (s.kind === "free_text") {
129
+ return { questionId: id, decision: "free_text", text: s.text };
130
+ }
131
+ return { questionId: id, decision: "skipped" };
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Shape of the per-batch bookkeeping stashed on `PendingInteraction.metadata`.
137
+ * The route reads this to validate batched submissions without needing a
138
+ * reference to the prompter that registered them.
139
+ */
140
+ export interface QuestionBatchMetadata {
141
+ orderedIds: string[];
142
+ optionsById: Record<string, string[]>;
143
+ }
144
+
145
+ /**
146
+ * Broadcast an ask-question request to all connected clients and wait for the
147
+ * user's reply. All lifecycle state (rpcResolve, rpcReject, timer, batch
148
+ * metadata) lives on the `pendingInteractions` entry — `/v1/question-response`
149
+ * resolves the entry directly without holding a reference back to the prompter
150
+ * that registered it.
151
+ *
152
+ * Batching: a single `prompt()` call broadcasts one or more questions, and
153
+ * the prompter waits for exactly one resolution call carrying the full
154
+ * ordered response array. The web UI collects per-question answers
155
+ * locally, lets the user revise freely while the card is open, and POSTs
156
+ * the whole batch to `/v1/question-response` when the user is done — no
157
+ * per-question accumulator, no partial state machine.
158
+ *
159
+ * Timeout reuses `getConfig().timeouts.permissionTimeoutSec` (default 5 min) —
160
+ * questions are user-prompts in the same UX family as permission prompts and
161
+ * secret prompts, so they share the same idle-timeout knob.
162
+ */
163
+ export class QuestionPrompter {
164
+ constructor(
165
+ private deps: { broadcastMessage(msg: ServerMessage): void },
166
+ ) {}
167
+
168
+ async prompt(params: QuestionPromptParams): Promise<QuestionPromptResult> {
169
+ const { conversationId, questions, toolUseId, signal } = params;
170
+
171
+ if (questions.length === 0) {
172
+ throw new AssistantError(
173
+ "QuestionPrompter.prompt requires at least one question",
174
+ ErrorCode.INTERNAL_ERROR,
175
+ );
176
+ }
177
+
178
+ // Assign per-question ids (`q1`, `q2`, ...) — daemon-side only; the LLM
179
+ // never sees these. Build the on-wire entries in the same pass.
180
+ const entries = questions.map((q, i) => ({
181
+ id: `q${i + 1}`,
182
+ question: q.question,
183
+ description: q.description,
184
+ options: q.options,
185
+ freeTextPlaceholder: q.freeTextPlaceholder,
186
+ }));
187
+ const orderedIds = entries.map((e) => e.id);
188
+ const optionsById: Record<string, string[]> = {};
189
+ for (const e of entries) {
190
+ optionsById[e.id] = e.options.map((o) => o.id);
191
+ }
192
+
193
+ if (signal?.aborted) {
194
+ return {
195
+ entries: orderedIds.map((id) => ({
196
+ questionId: id,
197
+ decision: "aborted",
198
+ })),
199
+ overall: "aborted",
200
+ };
201
+ }
202
+
203
+ const requestId = uuid();
204
+
205
+ return new Promise<QuestionPromptResult>((resolve, reject) => {
206
+ const timeoutMs = getConfig().timeouts.permissionTimeoutSec * 1000;
207
+
208
+ // Closure-scoped idempotency guard. Every resolution path (timeout,
209
+ // abort, route resolution via `rpcResolve`/`rpcReject`) routes through
210
+ // `finish()`, which tears down the timer + abort listener exactly
211
+ // once. We cannot use `pendingInteractions.resolve(requestId) ===
212
+ // undefined` as the guard because `removeByConversation()` (called
213
+ // during auto-deny on enqueue) can deregister the entry before any of
214
+ // our local handlers fire — using the registry as the guard in that
215
+ // case would leave the Promise unresolved and the tool hung.
216
+ let settled = false;
217
+ let onAbort: (() => void) | undefined;
218
+ const finish = (fn: () => void): void => {
219
+ if (settled) return;
220
+ settled = true;
221
+ clearTimeout(timer);
222
+ if (signal && onAbort) {
223
+ signal.removeEventListener("abort", onAbort);
224
+ }
225
+ // Idempotent: a no-op if the entry was already removed (e.g. by
226
+ // `removeByConversation`) or by an earlier path.
227
+ pendingInteractions.resolve(requestId);
228
+ fn();
229
+ };
230
+
231
+ const timer = setTimeout(() => {
232
+ log.warn({ requestId, conversationId }, "Question prompt timed out");
233
+ finish(() =>
234
+ resolve({
235
+ entries: orderedIds.map((id) => ({
236
+ questionId: id,
237
+ decision: "timed_out",
238
+ })),
239
+ overall: "timed_out",
240
+ }),
241
+ );
242
+ }, timeoutMs);
243
+
244
+ if (signal) {
245
+ onAbort = () => {
246
+ finish(() =>
247
+ resolve({
248
+ entries: orderedIds.map((id) => ({
249
+ questionId: id,
250
+ decision: "aborted",
251
+ })),
252
+ overall: "aborted",
253
+ }),
254
+ );
255
+ };
256
+ signal.addEventListener("abort", onAbort, { once: true });
257
+ }
258
+
259
+ // Stash the per-question metadata on the interaction so the route can
260
+ // validate batched submissions without holding a prompter reference.
261
+ // Route resolution funnels through `finish()` so the same teardown +
262
+ // idempotency guard applies whether the response comes from the route,
263
+ // a timeout, or an abort.
264
+ pendingInteractions.register(requestId, {
265
+ conversationId,
266
+ kind: "question",
267
+ rpcResolve: (value: unknown) =>
268
+ finish(() => resolve(value as QuestionPromptResult)),
269
+ rpcReject: (err: unknown) => finish(() => reject(err)),
270
+ timer,
271
+ toolUseId,
272
+ metadata: { orderedIds, optionsById } satisfies QuestionBatchMetadata,
273
+ });
274
+
275
+ // Populate both shapes on the wire: `questions[]` is the canonical
276
+ // batched payload, and the flat fields mirror `questions[0]` for
277
+ // backwards compat with clients that haven't adopted `questions[]`.
278
+ const head = entries[0]!;
279
+ const msg: QuestionRequest = {
280
+ type: "question_request",
281
+ requestId,
282
+ questions: entries,
283
+ question: head.question,
284
+ description: head.description,
285
+ options: head.options,
286
+ freeTextPlaceholder: head.freeTextPlaceholder,
287
+ conversationId,
288
+ toolUseId,
289
+ };
290
+
291
+ this.deps.broadcastMessage(msg);
292
+ });
293
+ }
294
+ }