@vellumai/assistant 0.12.0 → 0.12.1-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. package/AGENTS.md +5 -1
  2. package/ARCHITECTURE.md +11 -12
  3. package/docs/architecture/turn-actor.md +15 -8
  4. package/openapi.yaml +292 -2
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-window-theme.ts +186 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +18 -10
  8. package/src/__tests__/app-compiler.test.ts +6 -0
  9. package/src/__tests__/app-control-tool-schemas.test.ts +5 -2
  10. package/src/__tests__/app-dir-path-guard.test.ts +1 -3
  11. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +5 -2
  12. package/src/__tests__/computer-use-tools.test.ts +17 -5
  13. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  14. package/src/__tests__/config-schema.test.ts +28 -29
  15. package/src/__tests__/config-watcher.test.ts +23 -8
  16. package/src/__tests__/connection-model-compat.test.ts +2 -2
  17. package/src/__tests__/conversation-key-store-disk-view.test.ts +1 -1
  18. package/src/__tests__/conversation-pairing.test.ts +26 -0
  19. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -10
  20. package/src/__tests__/conversation-queue.test.ts +48 -207
  21. package/src/__tests__/conversation-retry-discard.test.ts +54 -1
  22. package/src/__tests__/conversation-skill-tools.test.ts +27 -0
  23. package/src/__tests__/conversation-surfaces-app-control.test.ts +8 -3
  24. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  25. package/src/__tests__/conversation-title-service.test.ts +14 -10
  26. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +3 -5
  27. package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
  28. package/src/__tests__/credential-record-retire.test.ts +171 -1
  29. package/src/__tests__/credential-routes.test.ts +212 -3
  30. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  31. package/src/__tests__/cu-unified-flow.test.ts +8 -2
  32. package/src/__tests__/drain-kick-guard.test.ts +0 -2
  33. package/src/__tests__/drain-requeue-on-contention.test.ts +0 -6
  34. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  35. package/src/__tests__/git-porcelain.test.ts +47 -0
  36. package/src/__tests__/helpers/set-workspace-mcp.ts +11 -0
  37. package/src/__tests__/host-bash-proxy.test.ts +26 -45
  38. package/src/__tests__/host-browser-proxy.test.ts +6 -17
  39. package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
  40. package/src/__tests__/host-file-proxy.test.ts +11 -23
  41. package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
  42. package/src/__tests__/llm-catalog-parity.test.ts +25 -0
  43. package/src/__tests__/mcp-auth-routes.test.ts +6 -9
  44. package/src/__tests__/mcp-config-secret-boundary.test.ts +78 -136
  45. package/src/__tests__/mcp-health-check.test.ts +8 -11
  46. package/src/__tests__/mcp-list-plugin-servers.test.ts +8 -10
  47. package/src/__tests__/message-deleted-hook-dispatch.test.ts +119 -0
  48. package/src/__tests__/messages-after-tiebreaker.test.ts +91 -0
  49. package/src/__tests__/messages-read-boundary-guard.test.ts +1 -1
  50. package/src/__tests__/messaging-send-tool.test.ts +355 -548
  51. package/src/__tests__/model-intents.test.ts +3 -0
  52. package/src/__tests__/outlook-messaging-provider.test.ts +6 -6
  53. package/src/__tests__/platform-client-verify-credential.test.ts +2 -0
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +13 -0
  55. package/src/__tests__/resolve-app.test.ts +135 -0
  56. package/src/__tests__/script-proxy-certs.test.ts +35 -0
  57. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  58. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  59. package/src/__tests__/source-fingerprint.test.ts +184 -0
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +19 -3
  61. package/src/__tests__/subagent-tools.test.ts +3 -2
  62. package/src/__tests__/telephony-tts-guard.test.ts +38 -3
  63. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +4 -0
  64. package/src/__tests__/watch-retro-tool-availability.test.ts +8 -17
  65. package/src/__tests__/workspace-git-service.test.ts +26 -0
  66. package/src/__tests__/workspace-migration-154-repair-retired-codex-gpt-5-4-model-ids.test.ts +336 -0
  67. package/src/acp/__tests__/agent-process.test.ts +258 -4
  68. package/src/acp/__tests__/auth-required.test.ts +96 -0
  69. package/src/acp/__tests__/client-handler.test.ts +90 -2
  70. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  71. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  72. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  73. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  74. package/src/acp/__tests__/session-manager.test.ts +895 -4
  75. package/src/acp/agent-process.test.ts +4 -1
  76. package/src/acp/agent-process.ts +94 -9
  77. package/src/acp/auth-required.ts +50 -1
  78. package/src/acp/auto-install.test.ts +9 -7
  79. package/src/acp/client-handler.ts +16 -3
  80. package/src/acp/model-config.test.ts +166 -0
  81. package/src/acp/model-config.ts +138 -0
  82. package/src/acp/resolve-agent.test.ts +220 -6
  83. package/src/acp/resolve-agent.ts +88 -23
  84. package/src/acp/session-manager.test.ts +196 -3
  85. package/src/acp/session-manager.ts +441 -18
  86. package/src/acp/types.ts +50 -5
  87. package/src/api/events/acp-session-model-update.test.ts +88 -0
  88. package/src/api/events/acp-session-model-update.ts +46 -0
  89. package/src/api/events/app-files-changed.ts +4 -3
  90. package/src/api/index.ts +6 -0
  91. package/src/apps/app-store.ts +1 -1
  92. package/src/apps/resolve-app.ts +81 -0
  93. package/src/apps/source-fingerprint.ts +266 -0
  94. package/src/bundler/app-compiler.ts +15 -0
  95. package/src/calls/__tests__/barge-in-guard.test.ts +81 -0
  96. package/src/{__tests__ → calls/__tests__}/call-controller.test.ts +503 -82
  97. package/src/{__tests__ → calls/__tests__}/media-stream-audio-transcode.test.ts +1 -1
  98. package/src/{__tests__ → calls/__tests__}/media-stream-output.test.ts +37 -8
  99. package/src/{__tests__ → calls/__tests__}/media-stream-parser.test.ts +1 -1
  100. package/src/{__tests__ → calls/__tests__}/media-stream-server-integration.test.ts +150 -57
  101. package/src/{__tests__ → calls/__tests__}/media-stream-stt-session.test.ts +5 -5
  102. package/src/{__tests__ → calls/__tests__}/media-turn-detector.test.ts +1 -1
  103. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  104. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  105. package/src/calls/__tests__/voice-control-protocol.test.ts +58 -25
  106. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  107. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  108. package/src/{__tests__/voice-session-bridge.test.ts → calls/__tests__/voice-session-bridge-turn-flow.test.ts} +20 -12
  109. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -1
  110. package/src/calls/__tests__/voice-triage-escalate.test.ts +105 -0
  111. package/src/calls/barge-in-guard.ts +118 -0
  112. package/src/calls/call-constants.ts +24 -4
  113. package/src/calls/call-controller.ts +499 -199
  114. package/src/calls/call-transport.ts +16 -0
  115. package/src/calls/media-stream-output.ts +32 -0
  116. package/src/calls/media-stream-server.ts +82 -30
  117. package/src/calls/media-stream-stt-session.ts +15 -0
  118. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  119. package/src/calls/resolve-call-tts-provider.ts +33 -2
  120. package/src/calls/tts-call-strategy.ts +9 -5
  121. package/src/calls/types.ts +2 -0
  122. package/src/calls/voice-control-protocol.ts +53 -21
  123. package/src/calls/voice-leg-coordinator.ts +189 -0
  124. package/src/calls/voice-progress-cadence.ts +423 -0
  125. package/src/calls/voice-session-bridge.ts +59 -46
  126. package/src/calls/voice-triage-escalate.ts +175 -56
  127. package/src/cli/commands/__tests__/apps.test.ts +245 -0
  128. package/src/cli/commands/__tests__/roadmap.help.test.ts +19 -0
  129. package/src/cli/commands/apps.help.ts +67 -13
  130. package/src/cli/commands/apps.ts +183 -0
  131. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  132. package/src/cli/commands/channels/index.help.ts +70 -2
  133. package/src/cli/commands/channels/index.ts +16 -3
  134. package/src/cli/commands/channels/send.ts +128 -0
  135. package/src/cli/commands/credentials.help.ts +6 -2
  136. package/src/cli/commands/credentials.ts +2 -2
  137. package/src/cli/commands/mcp.help.ts +8 -8
  138. package/src/cli/commands/mcp.ts +1 -1
  139. package/src/cli/commands/roadmap.help.ts +5 -1
  140. package/src/cli/commands/roadmap.ts +1 -1
  141. package/src/cli/lib/bundled-marketplace.json +103 -90
  142. package/src/config/__tests__/default-profile-catalog.test.ts +22 -0
  143. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  144. package/src/config/__tests__/platform-identity.test.ts +117 -0
  145. package/src/config/__tests__/platform-rehydration.test.ts +93 -0
  146. package/src/config/acp-agent-write.ts +43 -0
  147. package/src/config/acp-defaults.test.ts +13 -3
  148. package/src/config/acp-defaults.ts +23 -6
  149. package/src/config/acp-schema.test.ts +81 -0
  150. package/src/config/acp-schema.ts +30 -2
  151. package/src/config/bundled-skills/acp/SKILL.md +22 -14
  152. package/src/config/bundled-skills/acp/TOOLS.json +5 -1
  153. package/src/config/bundled-skills/app-builder/SKILL.md +12 -9
  154. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +1 -1
  155. package/src/config/bundled-skills/messaging/TOOLS.json +3 -3
  156. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +67 -139
  157. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +1 -1
  158. package/src/config/bundled-skills/screen-annotation/SKILL.md +8 -1
  159. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +4 -1
  160. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  161. package/src/config/default-profile-catalog.ts +1 -1
  162. package/src/config/env.ts +2 -2
  163. package/src/config/feature-flag-registry.json +8 -12
  164. package/src/config/memory-v3-gate.test.ts +31 -0
  165. package/src/config/memory-v3-gate.ts +20 -16
  166. package/src/config/platform-identity.ts +144 -0
  167. package/src/config/platform-rehydration.ts +75 -21
  168. package/src/config/schema.ts +2 -2
  169. package/src/config/schemas/__tests__/live-voice.test.ts +8 -158
  170. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  171. package/src/config/schemas/calls.ts +1 -28
  172. package/src/config/schemas/live-voice.ts +1 -173
  173. package/src/config/schemas/mcp.ts +1 -1
  174. package/src/config/schemas/memory-retrospective.ts +10 -0
  175. package/src/config/schemas/skills.ts +0 -14
  176. package/src/config/schemas/voice.ts +150 -0
  177. package/src/credential-execution/process-manager.test.ts +18 -19
  178. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -121
  179. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +4 -1
  180. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  181. package/src/daemon/app-change-notify.ts +45 -0
  182. package/src/daemon/config-watcher.ts +13 -194
  183. package/src/daemon/conversation-process.ts +35 -64
  184. package/src/daemon/conversation-skill-tools.ts +13 -31
  185. package/src/daemon/conversation-store.ts +1 -1
  186. package/src/daemon/conversation-surfaces.ts +10 -8
  187. package/src/daemon/conversation-tool-setup.ts +37 -43
  188. package/src/daemon/conversation.ts +0 -3
  189. package/src/daemon/host-bash-proxy.ts +8 -12
  190. package/src/daemon/host-browser-proxy.ts +15 -1
  191. package/src/daemon/host-file-proxy.ts +8 -10
  192. package/src/daemon/host-transfer-proxy.ts +14 -18
  193. package/src/daemon/lifecycle.ts +0 -5
  194. package/src/daemon/mcp-reload-service.ts +5 -5
  195. package/src/daemon/message-types/sync.ts +1 -0
  196. package/src/daemon/providers-setup.ts +1 -1
  197. package/src/daemon/shutdown-handlers.ts +1 -9
  198. package/src/daemon/skill-memory-refresh.ts +64 -0
  199. package/src/daemon/tool-side-effects.ts +7 -18
  200. package/src/desktop/__tests__/fake-desktop.ts +119 -0
  201. package/src/desktop/desktop-chrome-policy.test.ts +71 -0
  202. package/src/desktop/desktop-chrome-policy.ts +42 -0
  203. package/src/desktop/desktop-dependencies.test.ts +102 -0
  204. package/src/desktop/desktop-dependencies.ts +306 -0
  205. package/src/desktop/desktop-feature.test.ts +18 -0
  206. package/src/desktop/desktop-feature.ts +19 -0
  207. package/src/desktop/desktop-panel-config.test.ts +135 -0
  208. package/src/desktop/desktop-panel-config.ts +149 -0
  209. package/src/desktop/desktop-session-manager.test.ts +627 -0
  210. package/src/desktop/desktop-session-manager.ts +814 -0
  211. package/src/desktop/desktop-stream-bridge.test.ts +395 -0
  212. package/src/desktop/desktop-stream-bridge.ts +214 -0
  213. package/src/desktop/desktop-wallpaper.test.ts +191 -0
  214. package/src/desktop/desktop-wallpaper.ts +113 -0
  215. package/src/desktop/desktop-window-theme.ts +170 -0
  216. package/src/hooks/types.ts +33 -0
  217. package/src/i18n/AGENTS.md +22 -0
  218. package/src/i18n/__tests__/i18n.test.ts +108 -0
  219. package/src/i18n/index.ts +16 -0
  220. package/src/i18n/locales.ts +93 -0
  221. package/src/i18n/messages.ts +61 -0
  222. package/src/i18n/title.ts +25 -0
  223. package/src/inbound/platform-callback-registration.ts +4 -5
  224. package/src/live-voice/__tests__/live-voice-metrics.test.ts +0 -3
  225. package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
  226. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  227. package/src/live-voice/__tests__/runtime-websocket-shell.test.ts +13 -69
  228. package/src/live-voice/live-voice-session.ts +213 -693
  229. package/src/live-voice/protocol.ts +0 -2
  230. package/src/mcp/__tests__/effective-config.test.ts +2 -2
  231. package/src/mcp/__tests__/workspace-mcp-config.test.ts +135 -0
  232. package/src/mcp/effective-config.ts +3 -3
  233. package/src/mcp/mcp-header-store.ts +29 -45
  234. package/src/mcp/spec-schema.ts +111 -0
  235. package/src/mcp/startup.ts +8 -4
  236. package/src/mcp/workspace-mcp-config.ts +188 -0
  237. package/src/messaging/provider.ts +10 -3
  238. package/src/messaging/providers/__tests__/proactive-address.test.ts +95 -0
  239. package/src/messaging/providers/callback-routing.ts +18 -0
  240. package/src/messaging/providers/channel-transport.ts +56 -0
  241. package/src/messaging/providers/discord/transport.ts +17 -0
  242. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +6 -17
  243. package/src/messaging/providers/slack/adapter.ts +7 -44
  244. package/src/messaging/providers/slack/transport.ts +16 -0
  245. package/src/messaging/providers/telegram-bot/adapter.ts +1 -48
  246. package/src/messaging/providers/telegram-bot/client.ts +0 -26
  247. package/src/messaging/providers/telegram-bot/transport.ts +20 -0
  248. package/src/messaging/providers/whatsapp/adapter.ts +1 -48
  249. package/src/messaging/providers/whatsapp/transport.ts +18 -0
  250. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +8 -0
  251. package/src/notifications/__tests__/assistant-reply-producer.test.ts +55 -0
  252. package/src/notifications/__tests__/home-feed-side-effect.test.ts +153 -3
  253. package/src/notifications/assistant-reply-producer.ts +8 -3
  254. package/src/notifications/conversation-pairing.ts +36 -16
  255. package/src/notifications/delivered-post-record.ts +11 -1
  256. package/src/notifications/home-feed-side-effect.ts +55 -7
  257. package/src/outbound-proxy/certs.ts +3 -5
  258. package/src/permissions/checker.test.ts +11 -6
  259. package/src/persistence/compaction-log-store-clickhouse.ts +3 -3
  260. package/src/persistence/conversation-crud.ts +71 -63
  261. package/src/persistence/conversation-lineage.ts +12 -4
  262. package/src/persistence/conversation-title-placeholders.ts +23 -0
  263. package/src/persistence/conversation-title-service.ts +9 -26
  264. package/src/persistence/llm-request-log-sink-clickhouse.ts +3 -3
  265. package/src/persistence/llm-request-log-source-clickhouse.ts +3 -3
  266. package/src/persistence/message-cursor.ts +78 -0
  267. package/src/persistence/message-reads.ts +30 -0
  268. package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
  269. package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
  270. package/src/persistence/schema/memory-core.ts +5 -0
  271. package/src/platform/client.test.ts +2 -0
  272. package/src/platform/client.ts +2 -10
  273. package/src/plugin-api/constants.ts +2 -0
  274. package/src/plugin-api/index.ts +1 -0
  275. package/src/plugin-api/types.ts +2 -0
  276. package/src/plugins/defaults/index.ts +2 -0
  277. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +46 -0
  278. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-backfill.test.ts +118 -0
  279. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-preserve.test.ts +83 -0
  280. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +9 -5
  281. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +20 -10
  282. package/src/plugins/defaults/memory/__tests__/memory-retrospective-state.test.ts +219 -0
  283. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +31 -0
  284. package/src/plugins/defaults/memory/__tests__/memory-retrospective-trigger-check.test.ts +63 -0
  285. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  286. package/src/plugins/defaults/memory/graph/capability-seed.ts +4 -6
  287. package/src/plugins/defaults/memory/hooks/init.ts +10 -0
  288. package/src/plugins/defaults/memory/hooks/message-deleted.ts +21 -0
  289. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  290. package/src/plugins/defaults/memory/jobs-worker.ts +13 -0
  291. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +34 -60
  292. package/src/plugins/defaults/memory/memory-retrospective-cursor-backfill.ts +126 -0
  293. package/src/plugins/defaults/memory/memory-retrospective-cursor-preserve.ts +79 -0
  294. package/src/plugins/defaults/memory/memory-retrospective-cursor.ts +28 -0
  295. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +3 -5
  296. package/src/plugins/defaults/memory/memory-retrospective-job.ts +12 -3
  297. package/src/plugins/defaults/memory/memory-retrospective-state.ts +312 -71
  298. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +2 -1
  299. package/src/plugins/defaults/memory/memory-retrospective-trigger-check.ts +5 -3
  300. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  301. package/src/plugins/defaults/memory/startup.ts +6 -1
  302. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +7 -9
  303. package/src/plugins/defaults/memory/substrate/skill-content.ts +2 -2
  304. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +89 -0
  305. package/src/plugins/defaults/memory/worker.ts +6 -0
  306. package/src/plugins/mcp-servers.ts +15 -75
  307. package/src/providers/__tests__/connection-model-compat.test.ts +3 -3
  308. package/src/providers/gemini/client.ts +9 -23
  309. package/src/providers/gemini-thought-signature.test.ts +63 -0
  310. package/src/providers/gemini-thought-signature.ts +44 -0
  311. package/src/providers/model-catalog.ts +34 -0
  312. package/src/providers/model-intents.ts +10 -5
  313. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +146 -18
  314. package/src/providers/openai/__tests__/chat-completions-provider-thought-signature.test.ts +613 -0
  315. package/src/providers/openai/__tests__/google-thought-signature.test.ts +235 -0
  316. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +83 -1
  317. package/src/providers/openai/chat-completions-provider.ts +192 -78
  318. package/src/providers/openai/codex-models.ts +0 -4
  319. package/src/providers/openai/google-thought-signature.ts +320 -0
  320. package/src/runtime/AGENTS.md +2 -0
  321. package/src/runtime/__tests__/channel-send.test.ts +443 -0
  322. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +89 -0
  323. package/src/runtime/__tests__/live-voice-upgrade.test.ts +58 -0
  324. package/src/runtime/__tests__/runtime-ws-test-utils.ts +72 -0
  325. package/src/runtime/__tests__/watch-stream-upgrade.test.ts +59 -0
  326. package/src/runtime/auth/same-actor.ts +38 -3
  327. package/src/runtime/channel-send.ts +316 -0
  328. package/src/runtime/http-server.ts +160 -151
  329. package/src/runtime/routes/__tests__/acp-routes.test.ts +159 -4
  330. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +205 -0
  331. package/src/runtime/routes/__tests__/channel-send-routes.test.ts +127 -0
  332. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  333. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
  334. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +16 -12
  335. package/src/runtime/routes/acp-routes.test.ts +15 -0
  336. package/src/runtime/routes/acp-routes.ts +72 -9
  337. package/src/runtime/routes/app-management-routes.ts +73 -0
  338. package/src/runtime/routes/app-routes.ts +1 -1
  339. package/src/runtime/routes/channel-send-routes.ts +85 -0
  340. package/src/runtime/routes/conversation-list-routes.ts +14 -3
  341. package/src/runtime/routes/conversation-query-routes.ts +40 -0
  342. package/src/runtime/routes/credential-routes.ts +19 -10
  343. package/src/runtime/routes/desktop-setup-routes.test.ts +74 -0
  344. package/src/runtime/routes/desktop-setup-routes.ts +35 -0
  345. package/src/runtime/routes/diagnostics-routes.ts +55 -3
  346. package/src/runtime/routes/index.ts +4 -0
  347. package/src/runtime/routes/log-export-routes.ts +2 -0
  348. package/src/runtime/routes/mcp-auth-routes.ts +39 -47
  349. package/src/runtime/routes/migration-routes.ts +8 -21
  350. package/src/runtime/routes/platform-routes.ts +18 -15
  351. package/src/runtime/services/__tests__/conversation-serializer.test.ts +28 -0
  352. package/src/runtime/services/conversation-serializer.ts +16 -3
  353. package/src/runtime/sync/resource-sync-events.ts +2 -1
  354. package/src/schedule/worker.ts +1 -1
  355. package/src/security/__tests__/ces-http-record-backend.test.ts +137 -0
  356. package/src/security/ces-http-record-backend.ts +120 -0
  357. package/src/security/ces-rpc-record-backend.ts +7 -0
  358. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  359. package/src/skills/workspace-skill-md-signature.ts +50 -0
  360. package/src/subagent/consult-context.ts +12 -25
  361. package/src/tools/acp/list-agents.test.ts +3 -1
  362. package/src/tools/acp/spawn.test.ts +146 -11
  363. package/src/tools/acp/spawn.ts +21 -3
  364. package/src/tools/acp/status.test.ts +94 -0
  365. package/src/tools/acp/status.ts +40 -3
  366. package/src/tools/app-control/skill-proxy-bridge.ts +2 -1
  367. package/src/tools/capability-offer.test.ts +22 -2
  368. package/src/tools/capability-offer.ts +16 -1
  369. package/src/tools/computer-use/definitions.ts +4 -2
  370. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -1
  371. package/src/tools/credentials/metadata-store.ts +100 -0
  372. package/src/tools/host-filesystem/edit.test.ts +2 -1
  373. package/src/tools/host-filesystem/edit.ts +2 -2
  374. package/src/tools/host-filesystem/read.test.ts +2 -1
  375. package/src/tools/host-filesystem/read.ts +2 -2
  376. package/src/tools/host-filesystem/transfer.test.ts +2 -1
  377. package/src/tools/host-filesystem/transfer.ts +2 -2
  378. package/src/tools/host-filesystem/write.test.ts +2 -1
  379. package/src/tools/host-filesystem/write.ts +2 -2
  380. package/src/tools/host-terminal/host-shell.test.ts +16 -0
  381. package/src/tools/host-terminal/host-shell.ts +4 -20
  382. package/src/tools/policy-context.ts +6 -7
  383. package/src/tools/subagent/spawn.ts +5 -8
  384. package/src/tools/terminal/__tests__/safe-env.test.ts +6 -6
  385. package/src/tools/terminal/safe-env.ts +23 -3
  386. package/src/tools/watch/watch-retro-report.ts +0 -8
  387. package/src/util/__tests__/language-subtag.test.ts +23 -1
  388. package/src/util/ca-bundle.ts +13 -0
  389. package/src/util/language-subtag.ts +16 -0
  390. package/src/util/platform.ts +8 -0
  391. package/src/workspace/git-porcelain.ts +66 -0
  392. package/src/workspace/git-service.ts +5 -54
  393. package/src/workspace/migrations/154-repair-retired-codex-gpt-5-4-model-ids.ts +258 -0
  394. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  395. package/src/workspace/migrations/156-extract-workspace-mcp-json.ts +214 -0
  396. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  397. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +199 -0
  398. package/src/workspace/migrations/registry.ts +6 -0
  399. package/src/__tests__/app-source-watcher.test.ts +0 -218
  400. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
  401. package/src/__tests__/speaker-identification.test.ts +0 -56
  402. package/src/calls/speaker-identification.ts +0 -233
  403. package/src/daemon/app-source-watcher.ts +0 -218
  404. package/src/messaging/providers/whatsapp/client.ts +0 -29
  405. package/src/util/fs-watcher-error.ts +0 -36
  406. /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
@@ -161,12 +161,6 @@ function makeFakeContext(opts: {
161
161
  trustClass: "guardian" as const,
162
162
  guardianPrincipalId: "user-1",
163
163
  },
164
- setTrustContext(
165
- this: { trustContext?: TrustContext },
166
- trustContext: TrustContext | null,
167
- ) {
168
- this.trustContext = trustContext ?? undefined;
169
- },
170
164
  setTransportHints() {},
171
165
  applyHostEnvFromTransport() {},
172
166
  ensureHostProxiesForTurn() {},
@@ -415,10 +409,10 @@ describe("drainQueue preactivation re-add for host-proxy interfaces", () => {
415
409
  "U-contact",
416
410
  );
417
411
  expect(ctx.currentTurnTrustContext?.sourceChannel).toBe("slack");
418
- // The drain is where a queued message commits to a run, so the resting
419
- // slot names the sender too: history is scoped from it, and a slot still
420
- // naming the guardian would hand the contact's turn the guardian's rows.
421
- expect(ctx.trustContext).toBe(contactTrust);
412
+ // The slot itself is left alone; only the turn's view is corrected. A
413
+ // drain stamping it would attribute the conversation to a sender whose
414
+ // turn can still lose the processing lock at persist time.
415
+ expect(ctx.trustContext?.trustClass).toBe("guardian");
422
416
  });
423
417
 
424
418
  test("buildPassthroughBatch refuses to coalesce two channel senders", async () => {
@@ -450,17 +450,6 @@ async function waitForCondition(
450
450
  }
451
451
  }
452
452
 
453
- /** Count the history reloads a conversation performs from this point on. */
454
- function countHistoryReloads(conversation: Conversation): () => number {
455
- const originalLoad = conversation.loadFromDb.bind(conversation);
456
- let reloads = 0;
457
- conversation.loadFromDb = async () => {
458
- reloads++;
459
- return originalLoad();
460
- };
461
- return () => reloads;
462
- }
463
-
464
453
  /**
465
454
  * Resolve the Nth pending AgentLoop.run() call. Fires the minimal events
466
455
  * that `runAgentLoop` expects (usage + message_complete) so the conversation
@@ -726,22 +715,21 @@ describe("Conversation message queue", () => {
726
715
  await new Promise((r) => setTimeout(r, 10));
727
716
  });
728
717
 
729
- test("a drained turn commits its sender as the resting actor and re-scopes history", async () => {
730
- // A contact's channel turn leaves the resting slot, and the resident
731
- // history scoped from it, naming the contact. The guardian's message
732
- // queued behind that turn must run as the guardian everywhere the slot is
733
- // read, not only in the per-turn snapshot: the `<turn_context>` actor
734
- // section is derived at turn start and history is scoped from the slot,
735
- // so a drain that stamped only the snapshot rendered the guardian's own
736
- // web turn as a trusted contact's and hid the guardian's rows from it.
718
+ test("the turn-context actor section describes the turn's actor, not the conversation's resting actor", async () => {
719
+ // The drain carries its sender on the per-turn field and leaves the
720
+ // resting slot naming the previous actor. The actor section is frozen at
721
+ // turn start, so it has to read the turn's actor: reading the slot is what
722
+ // told the guardian's own drained turn it was talking to a trusted
723
+ // contact.
737
724
  const conversation = makeConversation();
738
725
  await conversation.loadFromDb();
739
726
 
740
- conversation.setTrustContext({
741
- trustClass: "trusted_contact",
742
- sourceChannel: "slack",
727
+ const contact = {
728
+ trustClass: "trusted_contact" as const,
729
+ sourceChannel: "slack" as const,
743
730
  requesterExternalUserId: "U-contact",
744
- });
731
+ };
732
+ conversation.setTrustContext(contact);
745
733
 
746
734
  const p1 = conversation.processMessage({
747
735
  content: "msg-1",
@@ -756,7 +744,6 @@ describe("Conversation message queue", () => {
756
744
  sourceChannel: "vellum" as const,
757
745
  requesterExternalUserId: "guardian-principal",
758
746
  };
759
- const reloads = countHistoryReloads(conversation);
760
747
  conversation.enqueueMessage({
761
748
  content: "msg-2",
762
749
  requestId: "req-2",
@@ -767,83 +754,30 @@ describe("Conversation message queue", () => {
767
754
  await p1;
768
755
  await waitForPendingRun(2);
769
756
 
770
- // Read while run 2 is in flight: the slot, the snapshot, and the frozen
771
- // actor section all describe the guardian, and the history was reloaded
772
- // for that scope exactly once.
773
- expect(conversation.getTrustContext()).toBe(guardian);
757
+ // Guard the test itself: the slot must disagree with the turn, or the
758
+ // assertion below passes for the wrong reason.
759
+ expect(conversation.getTrustContext()).toBe(contact);
774
760
  expect(conversation.currentTurnTrustContext).toBe(guardian);
761
+ // A guardian turn renders no actor section, whatever the slot says.
775
762
  expect(conversation.currentTurnInboundActorContext).toBeNull();
776
- expect(reloads()).toBe(1);
777
763
 
778
764
  await resolveRun(1);
779
765
  await new Promise((r) => setTimeout(r, 10));
780
766
  });
781
767
 
782
- test("a batched drain commits the head's sender as the resting actor", async () => {
783
- // Same commitment on the batched path, which coalesces only messages that
784
- // share a trust identity, so the head's sender is the batch's actor.
768
+ test("the actor section names the drained contact while the slot holds the guardian", async () => {
769
+ // The inverse direction, so the assertion above cannot pass merely because
770
+ // the section is always absent: a contact's message drained behind the
771
+ // guardian's turn must be described as that contact.
785
772
  const conversation = makeConversation();
786
773
  await conversation.loadFromDb();
787
774
 
788
- conversation.setTrustContext({
789
- trustClass: "trusted_contact",
790
- sourceChannel: "slack",
791
- requesterExternalUserId: "U-contact",
792
- });
793
-
794
- const p1 = conversation.processMessage({
795
- content: "msg-1",
796
- attachments: [],
797
- onEvent: () => {},
798
- requestId: "req-1",
799
- });
800
- await waitForPendingRun(1);
801
-
802
775
  const guardian = {
803
776
  trustClass: "guardian" as const,
804
777
  sourceChannel: "vellum" as const,
805
778
  requesterExternalUserId: "guardian-principal",
806
779
  };
807
- const reloads = countHistoryReloads(conversation);
808
- conversation.enqueueMessage({
809
- content: "msg-2",
810
- requestId: "req-2",
811
- trustContext: guardian,
812
- });
813
- conversation.enqueueMessage({
814
- content: "msg-3",
815
- requestId: "req-3",
816
- trustContext: guardian,
817
- });
818
-
819
- await resolveRun(0);
820
- await p1;
821
- await waitForPendingRun(2);
822
-
823
- // One batched run for both siblings, committed as the guardian.
824
- expect(pendingRuns.length).toBe(2);
825
- expect(conversation.getTrustContext()).toBe(guardian);
826
- expect(conversation.currentTurnTrustContext).toBe(guardian);
827
- expect(conversation.currentTurnInboundActorContext).toBeNull();
828
- expect(reloads()).toBe(1);
829
-
830
- await resolveRun(1);
831
- await new Promise((r) => setTimeout(r, 10));
832
- });
833
-
834
- test("a drained turn keeps its sender when the slot moves during the history reload", async () => {
835
- // The commit captures the sender before the reload awaits. A writer that
836
- // moves the slot inside that await (a wake's stamp, a pointer elevation)
837
- // must not become the turn's actor: the history was reloaded for the
838
- // sender, and running someone else's trust over it is the escalation.
839
- const conversation = makeConversation();
840
- await conversation.loadFromDb();
841
-
842
- conversation.setTrustContext({
843
- trustClass: "trusted_contact",
844
- sourceChannel: "slack",
845
- requesterExternalUserId: "U-contact",
846
- });
780
+ conversation.setTrustContext(guardian);
847
781
 
848
782
  const p1 = conversation.processMessage({
849
783
  content: "msg-1",
@@ -853,49 +787,39 @@ describe("Conversation message queue", () => {
853
787
  });
854
788
  await waitForPendingRun(1);
855
789
 
856
- const guardian = {
857
- trustClass: "guardian" as const,
858
- sourceChannel: "vellum" as const,
859
- requesterExternalUserId: "guardian-principal",
860
- };
861
- const intruder = {
862
- trustClass: "unknown" as const,
863
- sourceChannel: "telegram" as const,
864
- requesterExternalUserId: "T-stranger",
790
+ const contact = {
791
+ trustClass: "trusted_contact" as const,
792
+ sourceChannel: "slack" as const,
793
+ requesterExternalUserId: "U-contact",
865
794
  };
866
795
  conversation.enqueueMessage({
867
796
  content: "msg-2",
868
797
  requestId: "req-2",
869
- trustContext: guardian,
798
+ trustContext: contact,
870
799
  });
871
800
 
872
- // The drain's reload is the await the writer lands inside.
873
- const originalLoad = conversation.loadFromDb.bind(conversation);
874
- let movedDuringReload = false;
875
- conversation.loadFromDb = async () => {
876
- const result = await originalLoad();
877
- conversation.setTrustContext(intruder);
878
- movedDuringReload = true;
879
- return result;
880
- };
881
-
882
801
  await resolveRun(0);
883
802
  await p1;
884
803
  await waitForPendingRun(2);
885
804
 
886
- expect(movedDuringReload).toBe(true);
887
- expect(conversation.getTrustContext()).toBe(intruder);
888
- expect(conversation.currentTurnTrustContext).toBe(guardian);
889
- expect(conversation.currentTurnInboundActorContext).toBeNull();
805
+ expect(conversation.getTrustContext()).toBe(guardian);
806
+ expect(conversation.currentTurnTrustContext).toBe(contact);
807
+ expect(conversation.currentTurnInboundActorContext).toMatchObject({
808
+ trustClass: "trusted_contact",
809
+ sourceChannel: "slack",
810
+ canonicalActorIdentity: "U-contact",
811
+ });
890
812
 
891
813
  await resolveRun(1);
892
814
  await new Promise((r) => setTimeout(r, 10));
893
815
  });
894
816
 
895
- test("a drain whose history reload fails puts the resting actor back", async () => {
896
- // A reload that fails starts no turn: the message is requeued for the
897
- // next drain, so the slot must not keep naming a sender whose turn never
898
- // began, or conversation-level readers report an owner that is not there.
817
+ test("a processMessage turn whose history reload fails puts the resting actor back", async () => {
818
+ // `processMessage` is the commitment point, so it stamps the slot before
819
+ // scoping history. A reload that throws starts no turn, and leaving the
820
+ // stamp would attribute the conversation to a sender that never ran: an
821
+ // actorless dispatch afterwards (a deferred wake) resolves the resting
822
+ // slot and would inherit it.
899
823
  const conversation = makeConversation();
900
824
  await conversation.loadFromDb();
901
825
 
@@ -906,110 +830,27 @@ describe("Conversation message queue", () => {
906
830
  };
907
831
  conversation.setTrustContext(contact);
908
832
 
909
- const p1 = conversation.processMessage({
910
- content: "msg-1",
911
- attachments: [],
912
- onEvent: () => {},
913
- requestId: "req-1",
914
- });
915
- await waitForPendingRun(1);
916
-
917
- const guardian = {
918
- trustClass: "guardian" as const,
919
- sourceChannel: "vellum" as const,
920
- requesterExternalUserId: "guardian-principal",
921
- };
922
- conversation.enqueueMessage({
923
- content: "msg-2",
924
- requestId: "req-2",
925
- trustContext: guardian,
926
- });
927
-
928
- let reloadAttempts = 0;
929
833
  conversation.loadFromDb = async () => {
930
- reloadAttempts++;
931
834
  throw new Error("history store exploded");
932
835
  };
933
836
 
934
- // Finish the first turn; the drain (and its one retry) fail on the reload.
935
- await resolveRun(0);
936
- await p1;
937
- await waitForCondition(() => reloadAttempts >= 2);
938
- await new Promise((r) => setTimeout(r, 20));
939
-
940
- expect(pendingRuns.length).toBe(1);
941
- expect(conversation.getQueueDepth()).toBe(1);
942
- expect(conversation.getTrustContext()).toBe(contact);
943
- });
944
-
945
- test("the turn-context actor section follows the turn's actor when the slot moves before the loop opens", async () => {
946
- // The actor section is frozen at turn start from the turn's own actor,
947
- // not from the resting slot. The drain stamps the slot and then awaits
948
- // (persist) before the loop opens; an out-of-band writer landing in that
949
- // window (pointer elevation restore, a wake's restore) moves the slot
950
- // without owning the turn, and a read of the slot there would describe
951
- // that writer's actor to the model.
952
- const conversation = makeConversation();
953
- await conversation.loadFromDb();
954
-
955
- const contact = {
956
- trustClass: "trusted_contact" as const,
957
- sourceChannel: "slack" as const,
958
- requesterExternalUserId: "U-contact",
959
- };
960
- conversation.setTrustContext(contact);
961
-
962
- const p1 = conversation.processMessage({
963
- content: "msg-1",
964
- attachments: [],
965
- onEvent: () => {},
966
- requestId: "req-1",
967
- });
968
- await waitForPendingRun(1);
969
-
970
837
  const guardian = {
971
838
  trustClass: "guardian" as const,
972
839
  sourceChannel: "vellum" as const,
973
840
  requesterExternalUserId: "guardian-principal",
974
841
  };
975
- conversation.enqueueMessage({
976
- content: "msg-2",
977
- requestId: "req-2",
978
- trustContext: guardian,
979
- });
980
-
981
- // Land the out-of-band slot write inside the drain's window, after its
982
- // stamp and before the loop opens: the drain awaits persistUserMessage
983
- // there. The resting slot alone is moved; the per-turn snapshot is the
984
- // drain's to carry into the run.
985
- const originalPersist = conversation.persistUserMessage.bind(conversation);
986
- let slotMoved = false;
987
- (
988
- conversation as unknown as {
989
- persistUserMessage: typeof conversation.persistUserMessage;
990
- }
991
- ).persistUserMessage = async (opts) => {
992
- const result = await originalPersist(opts);
993
- conversation.setTrustContext(contact);
994
- slotMoved = true;
995
- return result;
996
- };
997
-
998
- await resolveRun(0);
999
- await p1;
1000
- await waitForPendingRun(2);
842
+ await expect(
843
+ conversation.processMessage({
844
+ content: "msg-1",
845
+ attachments: [],
846
+ onEvent: () => {},
847
+ requestId: "req-1",
848
+ trustContext: guardian,
849
+ }),
850
+ ).rejects.toThrow("history store exploded");
1001
851
 
1002
- // Guard the test itself: the injection must have run, and the slot must
1003
- // disagree with the turn, or the assertion below passes for the wrong
1004
- // reason.
1005
- expect(slotMoved).toBe(true);
1006
852
  expect(conversation.getTrustContext()).toBe(contact);
1007
- expect(conversation.currentTurnTrustContext).toBe(guardian);
1008
- // The guardian's turn renders no actor section, whatever the slot says.
1009
- expect(conversation.currentTurnInboundActorContext).toBeNull();
1010
-
1011
- await resolveRun(1);
1012
- await new Promise((r) => setTimeout(r, 10));
853
+ expect(pendingRuns.length).toBe(0);
1013
854
  });
1014
855
 
1015
856
  test("a processMessage turn keeps its turn-start trust when the slot moves before the loop opens", async () => {
@@ -10,13 +10,26 @@ import {
10
10
  discardLastAssistantDisplayTurn,
11
11
  extractUserPromptText,
12
12
  } from "../daemon/conversation-history.js";
13
+ import { registerPluginHooks } from "../hooks/registry.js";
13
14
  import { addMessage, getMessages } from "../persistence/conversation-crud.js";
14
- import { getDb } from "../persistence/db-connection.js";
15
+ import { getDb, getMemorySqlite } from "../persistence/db-connection.js";
15
16
  import { initializeDb } from "../persistence/db-init.js";
16
17
  import { conversations, messages } from "../persistence/schema/index.js";
18
+ import memoryMessageDeleted from "../plugins/defaults/memory/hooks/message-deleted.js";
19
+ import {
20
+ ensureRetrospectiveCursorColumn,
21
+ getRetrospectiveState,
22
+ } from "../plugins/defaults/memory/memory-retrospective-state.js";
23
+ import type { PluginHooks } from "../plugins/types.js";
17
24
 
18
25
  await initializeDb();
19
26
 
27
+ // Register the memory plugin's `message-deleted` hook the way boot does, so
28
+ // the delete primitive's dispatch reaches the cursor bookkeeping.
29
+ registerPluginHooks("default-memory", {
30
+ "message-deleted": memoryMessageDeleted,
31
+ } as PluginHooks);
32
+
20
33
  const CONV_ID = "conv-retry-discard-test";
21
34
 
22
35
  function seedConversation(): void {
@@ -211,3 +224,43 @@ describe("extractUserPromptText", () => {
211
224
  expect(extractUserPromptText([])).toBe("");
212
225
  });
213
226
  });
227
+
228
+ describe("discardLastAssistantDisplayTurn keeps the memory-retrospective cursor placeable", () => {
229
+ beforeEach(() => {
230
+ ensureRetrospectiveCursorColumn("test");
231
+ getMemorySqlite()!.exec(`DELETE FROM memory_retrospective_state`);
232
+ });
233
+
234
+ test("a cursor stored without its timestamp gets it from the reply being discarded", async () => {
235
+ const [, assistantId] = await seed([
236
+ { role: "user", content: text("hello") },
237
+ { role: "assistant", content: text("first attempt") },
238
+ ]);
239
+ const replyCreatedAt = getMessages(CONV_ID).find(
240
+ (m) => m.id === assistantId,
241
+ )!.createdAt;
242
+ // A state row as a build without the timestamp column wrote it: the
243
+ // retrospective reviewed through the reply, so the cursor sits on it.
244
+ getMemorySqlite()!
245
+ .query(
246
+ `INSERT INTO memory_retrospective_state
247
+ (conversation_id, last_processed_message_id, last_run_at, remembered_log, last_processed_created_at)
248
+ VALUES (?, ?, 1, NULL, NULL)`,
249
+ )
250
+ .run(CONV_ID, assistantId);
251
+
252
+ discardLastAssistantDisplayTurn(CONV_ID);
253
+
254
+ expect(getMessages(CONV_ID).some((m) => m.id === assistantId)).toBe(false);
255
+ // The hook chain is fire-and-forget from the delete primitive, so the
256
+ // stamp lands shortly after the discard returns.
257
+ const deadline = Date.now() + 2_000;
258
+ let stamped =
259
+ getRetrospectiveState(CONV_ID)?.lastProcessedCreatedAt ?? null;
260
+ while (stamped === null && Date.now() < deadline) {
261
+ await Bun.sleep(10);
262
+ stamped = getRetrospectiveState(CONV_ID)?.lastProcessedCreatedAt ?? null;
263
+ }
264
+ expect(stamped).toBe(replyCreatedAt);
265
+ });
266
+ });
@@ -545,6 +545,33 @@ describe("projectSkillTools", () => {
545
545
  expect(mockRegisteredTools.has("deploy")).toBe(true);
546
546
  });
547
547
 
548
+ test("catalog membership changes are visible on the next projection with a persistent cache", () => {
549
+ const cache = {};
550
+ mockCatalog = [];
551
+ mockManifests = { deploy: makeManifest(["deploy_run"]) };
552
+
553
+ const history: Message[] = [
554
+ ...skillLoadMessages('<loaded_skill id="deploy" />'),
555
+ ];
556
+
557
+ const result1 = projectSkillTools(history, {
558
+ previouslyActiveSkillIds: sessionState,
559
+ cache,
560
+ });
561
+ expect(result1.allowedToolNames.size).toBe(0);
562
+ expect(sessionState.has("deploy")).toBe(false);
563
+
564
+ mockCatalog = [makeSkill("deploy")];
565
+
566
+ const result2 = projectSkillTools(history, {
567
+ previouslyActiveSkillIds: sessionState,
568
+ cache,
569
+ });
570
+ expect(result2.allowedToolNames).toEqual(new Set(["deploy_run"]));
571
+ expect(sessionState.has("deploy")).toBe(true);
572
+ expect(mockRegisteredTools.has("deploy")).toBe(true);
573
+ });
574
+
548
575
  test("skill with manifest failure on turn 1 is registered when manifest is available on turn 2", () => {
549
576
  mockCatalog = [makeSkill("deploy")];
550
577
  // No manifest — will fail to load
@@ -146,8 +146,10 @@ describe("surfaceProxyResolver — app-control tool routing", () => {
146
146
  });
147
147
 
148
148
  expect(result.isError).toBe(true);
149
- expect(result.content).toContain("not available");
150
- expect(result.content).toContain("app-control");
149
+ expect(result.content).toContain(
150
+ "The Vellum desktop app is needed to control apps on your computer",
151
+ );
152
+ expect(result.content).toContain("https://www.vellum.ai/downloads");
151
153
  // No envelope dispatched.
152
154
  expect(sentMessages).toHaveLength(0);
153
155
  });
@@ -163,7 +165,10 @@ describe("surfaceProxyResolver — app-control tool routing", () => {
163
165
  });
164
166
 
165
167
  expect(result.isError).toBe(true);
166
- expect(result.content).toContain("not available");
168
+ expect(result.content).toContain(
169
+ "The Vellum desktop app is needed to control apps on your computer",
170
+ );
171
+ expect(result.content).toContain("https://www.vellum.ai/downloads");
167
172
  expect(sentMessages).toHaveLength(0);
168
173
 
169
174
  proxy.dispose();
@@ -21,6 +21,7 @@ import {
21
21
  function makeContext(
22
22
  sent: AssistantEvent[] = [],
23
23
  channelCapabilities?: { channel: string; supportsDynamicUi: boolean },
24
+ opts?: { hasNoClient?: boolean },
24
25
  ): Conversation {
25
26
  return asConversation({
26
27
  conversationId: "session-1",
@@ -37,6 +38,7 @@ function makeContext(
37
38
  accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
38
39
  surfaceActionRequestIds: new Set<string>(),
39
40
  currentTurnSurfaces: [],
41
+ hasNoClient: opts?.hasNoClient ?? false,
40
42
  isProcessing: () => false,
41
43
  enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
42
44
  getQueueDepth: () => 0,
@@ -65,6 +67,71 @@ describe("task_progress surface compatibility", () => {
65
67
  expect(sent).toHaveLength(0);
66
68
  });
67
69
 
70
+ test("persists a clientless choice from a non-rendering channel without waiting", async () => {
71
+ const sent: AssistantEvent[] = [];
72
+ const ctx = makeContext(
73
+ sent,
74
+ {
75
+ channel: "phone",
76
+ supportsDynamicUi: false,
77
+ },
78
+ { hasNoClient: true },
79
+ );
80
+
81
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
82
+ surface_type: "choice",
83
+ title: "Choose a focus",
84
+ data: {
85
+ options: [
86
+ { id: "inbox", title: "Inbox" },
87
+ { id: "calendar", title: "Calendar" },
88
+ ],
89
+ },
90
+ });
91
+
92
+ expect(result.isError).toBe(false);
93
+ expect(result.yieldToUser).toBeUndefined();
94
+ const { surfaceId } = JSON.parse(result.content) as { surfaceId: string };
95
+ expect(ctx.currentTurnSurfaces.some((s) => s.surfaceId === surfaceId)).toBe(
96
+ true,
97
+ );
98
+ expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false);
99
+ expect(sent.some((msg) => msg.type === "ui_surface_show")).toBe(true);
100
+ });
101
+
102
+ test("persists a clientless update in the current-turn surface snapshot", async () => {
103
+ const sent: AssistantEvent[] = [];
104
+ const ctx = makeContext(
105
+ sent,
106
+ {
107
+ channel: "phone",
108
+ supportsDynamicUi: false,
109
+ },
110
+ { hasNoClient: true },
111
+ );
112
+ const shown = await surfaceProxyResolver(ctx, "ui_show", {
113
+ surface_type: "card",
114
+ title: "Background work",
115
+ data: {
116
+ template: "task_progress",
117
+ templateData: { status: "in_progress", steps: [] },
118
+ },
119
+ });
120
+ const { surfaceId } = JSON.parse(shown.content) as { surfaceId: string };
121
+
122
+ const result = await surfaceProxyResolver(ctx, "ui_update", {
123
+ surface_id: surfaceId,
124
+ data: { templateData: { status: "completed" } },
125
+ });
126
+
127
+ expect(result.isError).toBe(false);
128
+ const data = ctx.currentTurnSurfaces.find((s) => s.surfaceId === surfaceId)
129
+ ?.data as CardSurfaceData;
130
+ expect((data.templateData as Record<string, unknown>).status).toBe(
131
+ "completed",
132
+ );
133
+ });
134
+
68
135
  test("blocks ui_update when channel lacks dynamic UI support", async () => {
69
136
  const sent: AssistantEvent[] = [];
70
137
  const ctx = makeContext(sent, {