@vellumai/assistant 0.12.1 → 0.12.2-staging.2

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 (577) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +20 -21
  3. package/docs/architecture/turn-actor.md +15 -8
  4. package/docs/guardian-request-flow.md +16 -0
  5. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  6. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  7. package/openapi.yaml +360 -51
  8. package/package.json +3 -1
  9. package/scripts/smoke-desktop-window-theme.ts +186 -0
  10. package/src/__tests__/always-loaded-tools-guard.test.ts +18 -10
  11. package/src/__tests__/anthropic-provider.test.ts +0 -63
  12. package/src/__tests__/app-compiler.test.ts +6 -0
  13. package/src/__tests__/app-control-tool-schemas.test.ts +5 -2
  14. package/src/__tests__/app-dir-path-guard.test.ts +1 -3
  15. package/src/__tests__/assistant-event-hub-machine-name.test.ts +6 -7
  16. package/src/__tests__/assistant-event-hub.test.ts +44 -0
  17. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  18. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +5 -2
  19. package/src/__tests__/computer-use-tools.test.ts +19 -7
  20. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  21. package/src/__tests__/config-schema.test.ts +28 -29
  22. package/src/__tests__/config-watcher.test.ts +23 -8
  23. package/src/__tests__/connection-model-compat.test.ts +2 -2
  24. package/src/__tests__/conversation-key-store-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-pairing.test.ts +26 -0
  26. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -10
  27. package/src/__tests__/conversation-queue.test.ts +48 -207
  28. package/src/__tests__/conversation-retry-discard.test.ts +54 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
  30. package/src/__tests__/conversation-skill-tools.test.ts +27 -0
  31. package/src/__tests__/conversation-surfaces-app-control.test.ts +8 -3
  32. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  35. package/src/__tests__/conversation-title-service.test.ts +14 -10
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +3 -5
  37. package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
  38. package/src/__tests__/credential-record-retire.test.ts +171 -1
  39. package/src/__tests__/credential-routes.test.ts +250 -3
  40. package/src/__tests__/credential-security-invariants.test.ts +5 -3
  41. package/src/__tests__/cu-unified-flow.test.ts +14 -4
  42. package/src/__tests__/document-sync-tags.test.ts +25 -0
  43. package/src/__tests__/drain-kick-guard.test.ts +0 -2
  44. package/src/__tests__/drain-requeue-on-contention.test.ts +0 -6
  45. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  46. package/src/__tests__/git-porcelain.test.ts +47 -0
  47. package/src/__tests__/heartbeat-service.test.ts +56 -40
  48. package/src/__tests__/helpers/set-workspace-mcp.ts +11 -0
  49. package/src/__tests__/host-bash-proxy.test.ts +26 -45
  50. package/src/__tests__/host-browser-proxy.test.ts +6 -17
  51. package/src/__tests__/host-cu-proxy.test.ts +596 -0
  52. package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
  53. package/src/__tests__/host-file-proxy.test.ts +11 -23
  54. package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
  55. package/src/__tests__/list-messages-camera-frame.test.ts +121 -0
  56. package/src/__tests__/llm-catalog-parity.test.ts +25 -0
  57. package/src/__tests__/mcp-auth-routes.test.ts +6 -9
  58. package/src/__tests__/mcp-config-secret-boundary.test.ts +78 -136
  59. package/src/__tests__/mcp-health-check.test.ts +8 -11
  60. package/src/__tests__/mcp-list-plugin-servers.test.ts +8 -10
  61. package/src/__tests__/message-deleted-hook-dispatch.test.ts +119 -0
  62. package/src/__tests__/messages-after-tiebreaker.test.ts +91 -0
  63. package/src/__tests__/messages-read-boundary-guard.test.ts +1 -1
  64. package/src/__tests__/messaging-send-tool.test.ts +355 -548
  65. package/src/__tests__/model-intents.test.ts +3 -0
  66. package/src/__tests__/notification-vellum-adapter.test.ts +85 -2
  67. package/src/__tests__/oauth-commands-routes.test.ts +168 -0
  68. package/src/__tests__/oauth-provider-profiles.test.ts +18 -0
  69. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  70. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  71. package/src/__tests__/openai-provider.test.ts +1 -0
  72. package/src/__tests__/outlook-messaging-provider.test.ts +6 -6
  73. package/src/__tests__/platform-callback-registration.test.ts +8 -16
  74. package/src/__tests__/platform-client-verify-credential.test.ts +2 -0
  75. package/src/__tests__/plugin-import-boundary-guard.test.ts +14 -0
  76. package/src/__tests__/qdrant-manager.test.ts +132 -0
  77. package/src/__tests__/resolve-app.test.ts +135 -0
  78. package/src/__tests__/scaffold-managed-skill-tool.test.ts +229 -6
  79. package/src/__tests__/script-proxy-certs.test.ts +35 -0
  80. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  81. package/src/__tests__/service-account-token.test.ts +247 -0
  82. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  83. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  84. package/src/__tests__/skills.test.ts +9 -4
  85. package/src/__tests__/source-fingerprint.test.ts +184 -0
  86. package/src/__tests__/subagent-tool-gate-mode.test.ts +70 -3
  87. package/src/__tests__/subagent-tools.test.ts +3 -2
  88. package/src/__tests__/telephony-tts-guard.test.ts +38 -3
  89. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  90. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  91. package/src/__tests__/unicode.test.ts +13 -0
  92. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +4 -0
  93. package/src/__tests__/watch-retro-tool-availability.test.ts +8 -17
  94. package/src/__tests__/workspace-git-service.test.ts +26 -0
  95. package/src/__tests__/workspace-migration-154-repair-retired-codex-gpt-5-4-model-ids.test.ts +336 -0
  96. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  97. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  98. package/src/acp/__tests__/agent-process.test.ts +258 -4
  99. package/src/acp/__tests__/auth-required.test.ts +96 -0
  100. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  101. package/src/acp/__tests__/client-handler.test.ts +90 -2
  102. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  103. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  104. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  105. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  106. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  107. package/src/acp/__tests__/session-manager.test.ts +895 -4
  108. package/src/acp/acp-claude-oauth.ts +328 -14
  109. package/src/acp/acp-credentials.ts +19 -0
  110. package/src/acp/agent-process.test.ts +4 -1
  111. package/src/acp/agent-process.ts +96 -10
  112. package/src/acp/auth-required.ts +50 -1
  113. package/src/acp/auto-install.test.ts +9 -7
  114. package/src/acp/claude-token-refresh.ts +150 -0
  115. package/src/acp/client-handler.ts +16 -3
  116. package/src/acp/model-config.test.ts +166 -0
  117. package/src/acp/model-config.ts +138 -0
  118. package/src/acp/prepare-agent-env.ts +21 -6
  119. package/src/acp/resolve-agent.test.ts +220 -6
  120. package/src/acp/resolve-agent.ts +88 -23
  121. package/src/acp/session-manager.test.ts +196 -3
  122. package/src/acp/session-manager.ts +441 -18
  123. package/src/acp/types.ts +50 -5
  124. package/src/agent/loop.ts +1 -0
  125. package/src/api/events/acp-session-model-update.test.ts +88 -0
  126. package/src/api/events/acp-session-model-update.ts +46 -0
  127. package/src/api/events/app-files-changed.ts +4 -3
  128. package/src/api/events/notification-intent.ts +2 -0
  129. package/src/api/events/user-message-echo.ts +7 -1
  130. package/src/api/index.ts +6 -0
  131. package/src/api/responses/conversation-message.ts +5 -0
  132. package/src/apps/app-store.ts +1 -1
  133. package/src/apps/resolve-app.ts +81 -0
  134. package/src/apps/source-fingerprint.ts +266 -0
  135. package/src/avatar/identity-avatar.ts +7 -2
  136. package/src/bundler/app-compiler.ts +15 -0
  137. package/src/calls/__tests__/barge-in-guard.test.ts +81 -0
  138. package/src/{__tests__ → calls/__tests__}/call-controller.test.ts +503 -82
  139. package/src/{__tests__ → calls/__tests__}/media-stream-audio-transcode.test.ts +1 -1
  140. package/src/{__tests__ → calls/__tests__}/media-stream-output.test.ts +37 -8
  141. package/src/{__tests__ → calls/__tests__}/media-stream-parser.test.ts +1 -1
  142. package/src/{__tests__ → calls/__tests__}/media-stream-server-integration.test.ts +150 -57
  143. package/src/{__tests__ → calls/__tests__}/media-stream-stt-session.test.ts +5 -5
  144. package/src/{__tests__ → calls/__tests__}/media-turn-detector.test.ts +1 -1
  145. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  146. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  147. package/src/calls/__tests__/voice-control-protocol.test.ts +120 -25
  148. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  149. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  150. package/src/{__tests__/voice-session-bridge.test.ts → calls/__tests__/voice-session-bridge-turn-flow.test.ts} +20 -12
  151. package/src/calls/__tests__/voice-session-bridge.test.ts +56 -1
  152. package/src/calls/__tests__/voice-triage-escalate.test.ts +105 -0
  153. package/src/calls/barge-in-guard.ts +118 -0
  154. package/src/calls/call-constants.ts +24 -4
  155. package/src/calls/call-controller.ts +499 -199
  156. package/src/calls/call-transport.ts +16 -0
  157. package/src/calls/media-stream-output.ts +32 -0
  158. package/src/calls/media-stream-server.ts +82 -30
  159. package/src/calls/media-stream-stt-session.ts +15 -0
  160. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  161. package/src/calls/resolve-call-tts-provider.ts +33 -2
  162. package/src/calls/tts-call-strategy.ts +9 -5
  163. package/src/calls/types.ts +2 -0
  164. package/src/calls/voice-control-protocol.ts +155 -21
  165. package/src/calls/voice-leg-coordinator.ts +189 -0
  166. package/src/calls/voice-progress-cadence.ts +423 -0
  167. package/src/calls/voice-session-bridge.ts +92 -72
  168. package/src/calls/voice-triage-escalate.ts +178 -59
  169. package/src/cli/commands/__tests__/apps.test.ts +245 -0
  170. package/src/cli/commands/__tests__/bash.test.ts +53 -0
  171. package/src/cli/commands/__tests__/roadmap.help.test.ts +19 -0
  172. package/src/cli/commands/apps.help.ts +67 -13
  173. package/src/cli/commands/apps.ts +183 -0
  174. package/src/cli/commands/bash.help.ts +8 -13
  175. package/src/cli/commands/bash.ts +7 -25
  176. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  177. package/src/cli/commands/channels/index.help.ts +70 -2
  178. package/src/cli/commands/channels/index.ts +16 -3
  179. package/src/cli/commands/channels/send.ts +128 -0
  180. package/src/cli/commands/credentials.help.ts +6 -2
  181. package/src/cli/commands/credentials.ts +2 -2
  182. package/src/cli/commands/mcp.help.ts +8 -8
  183. package/src/cli/commands/mcp.ts +1 -1
  184. package/src/cli/commands/oauth/status.ts +5 -0
  185. package/src/cli/commands/roadmap.help.ts +5 -1
  186. package/src/cli/commands/roadmap.ts +1 -1
  187. package/src/cli/lib/bundled-marketplace.json +103 -90
  188. package/src/config/__tests__/default-profile-catalog.test.ts +22 -0
  189. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  190. package/src/config/__tests__/platform-identity.test.ts +255 -0
  191. package/src/config/__tests__/platform-rehydration.test.ts +99 -9
  192. package/src/config/acp-agent-write.ts +43 -0
  193. package/src/config/acp-defaults.test.ts +13 -3
  194. package/src/config/acp-defaults.ts +23 -6
  195. package/src/config/acp-schema.test.ts +81 -0
  196. package/src/config/acp-schema.ts +30 -2
  197. package/src/config/bundled-skills/acp/SKILL.md +22 -14
  198. package/src/config/bundled-skills/acp/TOOLS.json +5 -1
  199. package/src/config/bundled-skills/app-builder/SKILL.md +12 -9
  200. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +1 -1
  201. package/src/config/bundled-skills/computer-use/SKILL.md +29 -2
  202. package/src/config/bundled-skills/computer-use/TOOLS.json +99 -0
  203. package/src/config/bundled-skills/computer-use/tools/computer-use-sequence.ts +12 -0
  204. package/src/config/bundled-skills/media-processing/services/preprocess.ts +2 -1
  205. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +2 -1
  206. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -1
  207. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  208. package/src/config/bundled-skills/messaging/TOOLS.json +3 -3
  209. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +67 -139
  210. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +1 -1
  211. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +2 -1
  212. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +2 -1
  213. package/src/config/bundled-skills/schedule/SKILL.md +3 -4
  214. package/src/config/bundled-skills/screen-annotation/SKILL.md +8 -1
  215. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +4 -1
  216. package/src/config/bundled-skills/skill-management/TOOLS.json +6 -2
  217. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -2
  218. package/src/config/default-profile-catalog.ts +1 -1
  219. package/src/config/env.ts +2 -2
  220. package/src/config/feature-flag-registry.json +33 -13
  221. package/src/config/loader.ts +2 -0
  222. package/src/config/memory-v3-gate.test.ts +31 -0
  223. package/src/config/memory-v3-gate.ts +20 -16
  224. package/src/config/platform-identity.ts +201 -0
  225. package/src/config/platform-rehydration.ts +11 -27
  226. package/src/config/schema.ts +2 -2
  227. package/src/config/schemas/__tests__/live-voice.test.ts +8 -158
  228. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  229. package/src/config/schemas/calls.ts +1 -28
  230. package/src/config/schemas/live-voice.ts +1 -173
  231. package/src/config/schemas/mcp.ts +1 -1
  232. package/src/config/schemas/memory-retrospective.ts +10 -0
  233. package/src/config/schemas/services.ts +18 -0
  234. package/src/config/schemas/skills.ts +0 -14
  235. package/src/config/schemas/voice.ts +150 -0
  236. package/src/credential-execution/process-manager.test.ts +18 -19
  237. package/src/credential-health/credential-health-service.ts +11 -17
  238. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -121
  239. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +4 -1
  240. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  241. package/src/daemon/app-change-notify.ts +45 -0
  242. package/src/daemon/config-watcher.ts +13 -194
  243. package/src/daemon/conversation-error.ts +3 -2
  244. package/src/daemon/conversation-media-retry.ts +2 -1
  245. package/src/daemon/conversation-process.ts +35 -64
  246. package/src/daemon/conversation-runtime-assembly.ts +20 -7
  247. package/src/daemon/conversation-skill-tools.ts +13 -31
  248. package/src/daemon/conversation-store.ts +1 -1
  249. package/src/daemon/conversation-surfaces.ts +21 -10
  250. package/src/daemon/conversation-tool-setup.ts +45 -59
  251. package/src/daemon/conversation.ts +0 -3
  252. package/src/daemon/dictation-profile-store.ts +2 -1
  253. package/src/daemon/handlers/shared.ts +2 -1
  254. package/src/daemon/handlers/skills.ts +6 -3
  255. package/src/daemon/host-bash-proxy.ts +8 -12
  256. package/src/daemon/host-browser-proxy.ts +15 -1
  257. package/src/daemon/host-cu-proxy.ts +224 -19
  258. package/src/daemon/host-file-proxy.ts +8 -10
  259. package/src/daemon/host-proxy-preactivation.ts +2 -1
  260. package/src/daemon/host-transfer-proxy.ts +14 -18
  261. package/src/daemon/lifecycle.ts +0 -5
  262. package/src/daemon/mcp-reload-service.ts +5 -5
  263. package/src/daemon/message-types/sync.ts +1 -0
  264. package/src/daemon/orphan-reaper.ts +1 -1
  265. package/src/daemon/providers-setup.ts +1 -1
  266. package/src/daemon/reaction-history-render.ts +2 -1
  267. package/src/daemon/shutdown-handlers.ts +1 -9
  268. package/src/daemon/skill-memory-refresh.ts +64 -0
  269. package/src/daemon/tool-side-effects.ts +7 -18
  270. package/src/desktop/__tests__/fake-desktop.ts +122 -0
  271. package/src/desktop/desktop-chrome-policy.test.ts +79 -0
  272. package/src/desktop/desktop-chrome-policy.ts +47 -0
  273. package/src/desktop/desktop-chrome-session.ts +37 -0
  274. package/src/desktop/desktop-dependencies.test.ts +102 -0
  275. package/src/desktop/desktop-dependencies.ts +308 -0
  276. package/src/desktop/desktop-feature.test.ts +18 -0
  277. package/src/desktop/desktop-feature.ts +19 -0
  278. package/src/desktop/desktop-panel-config.test.ts +135 -0
  279. package/src/desktop/desktop-panel-config.ts +149 -0
  280. package/src/desktop/desktop-session-manager.test.ts +730 -0
  281. package/src/desktop/desktop-session-manager.ts +850 -0
  282. package/src/desktop/desktop-stream-bridge.test.ts +395 -0
  283. package/src/desktop/desktop-stream-bridge.ts +214 -0
  284. package/src/desktop/desktop-wallpaper.test.ts +191 -0
  285. package/src/desktop/desktop-wallpaper.ts +113 -0
  286. package/src/desktop/desktop-window-drag.ts +177 -0
  287. package/src/desktop/desktop-window-manager-config.test.ts +230 -0
  288. package/src/desktop/desktop-window-manager-config.ts +238 -0
  289. package/src/desktop/desktop-window-theme.ts +170 -0
  290. package/src/heartbeat/heartbeat-service.ts +21 -3
  291. package/src/home/__tests__/feed-writer.test.ts +22 -0
  292. package/src/home/feed-writer.ts +11 -2
  293. package/src/hooks/types.ts +33 -0
  294. package/src/i18n/AGENTS.md +22 -0
  295. package/src/i18n/__tests__/i18n.test.ts +108 -0
  296. package/src/i18n/index.ts +16 -0
  297. package/src/i18n/locales.ts +93 -0
  298. package/src/i18n/messages.ts +61 -0
  299. package/src/i18n/title.ts +25 -0
  300. package/src/inbound/platform-callback-registration.ts +4 -5
  301. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
  302. package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
  303. package/src/live-voice/__tests__/live-voice-metrics.test.ts +0 -3
  304. package/src/live-voice/__tests__/live-voice-progress.test.ts +94 -15
  305. package/src/live-voice/__tests__/live-voice-sight-frame-echo.test.ts +238 -0
  306. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  307. package/src/live-voice/__tests__/protocol.test.ts +39 -0
  308. package/src/live-voice/__tests__/runtime-websocket-shell.test.ts +13 -69
  309. package/src/live-voice/__tests__/session-controls.test.ts +79 -0
  310. package/src/live-voice/live-voice-photo.ts +25 -6
  311. package/src/live-voice/live-voice-session.ts +297 -699
  312. package/src/live-voice/protocol.ts +60 -2
  313. package/src/live-voice/session-controls.ts +111 -0
  314. package/src/mcp/__tests__/effective-config.test.ts +2 -2
  315. package/src/mcp/__tests__/workspace-mcp-config.test.ts +135 -0
  316. package/src/mcp/effective-config.ts +3 -3
  317. package/src/mcp/mcp-header-store.ts +29 -45
  318. package/src/mcp/spec-schema.ts +111 -0
  319. package/src/mcp/startup.ts +8 -4
  320. package/src/mcp/workspace-mcp-config.ts +188 -0
  321. package/src/messaging/provider.ts +10 -3
  322. package/src/messaging/providers/__tests__/proactive-address.test.ts +95 -0
  323. package/src/messaging/providers/callback-routing.ts +18 -0
  324. package/src/messaging/providers/channel-transport.ts +56 -0
  325. package/src/messaging/providers/discord/transport.ts +17 -0
  326. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +6 -17
  327. package/src/messaging/providers/slack/adapter.ts +7 -44
  328. package/src/messaging/providers/slack/transport.ts +16 -0
  329. package/src/messaging/providers/telegram-bot/adapter.ts +1 -48
  330. package/src/messaging/providers/telegram-bot/client.ts +0 -26
  331. package/src/messaging/providers/telegram-bot/transport.ts +20 -0
  332. package/src/messaging/providers/whatsapp/adapter.ts +1 -48
  333. package/src/messaging/providers/whatsapp/transport.ts +18 -0
  334. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +8 -0
  335. package/src/notifications/__tests__/assistant-reply-producer.test.ts +55 -0
  336. package/src/notifications/__tests__/guardian-feed-projection.test.ts +158 -2
  337. package/src/notifications/__tests__/home-feed-side-effect.test.ts +153 -3
  338. package/src/notifications/__tests__/schedule-result-producer.test.ts +99 -3
  339. package/src/notifications/adapters/macos.ts +3 -0
  340. package/src/notifications/assistant-reply-producer.ts +8 -3
  341. package/src/notifications/conversation-pairing.ts +36 -16
  342. package/src/notifications/delivered-post-record.ts +11 -1
  343. package/src/notifications/guardian-feed-projection.ts +125 -5
  344. package/src/notifications/home-feed-side-effect.ts +55 -7
  345. package/src/notifications/schedule-result-producer.ts +66 -22
  346. package/src/oauth/AGENTS.md +2 -0
  347. package/src/oauth/__tests__/seed-providers-managed.test.ts +149 -0
  348. package/src/oauth/byo-connection.test.ts +105 -0
  349. package/src/oauth/byo-connection.ts +36 -6
  350. package/src/oauth/connection-resolver.test.ts +100 -0
  351. package/src/oauth/connection-resolver.ts +120 -3
  352. package/src/oauth/provider-serializer.ts +9 -0
  353. package/src/oauth/scope-utils.ts +24 -0
  354. package/src/oauth/seed-providers.ts +314 -27
  355. package/src/outbound-proxy/certs.ts +3 -5
  356. package/src/permissions/checker.test.ts +11 -6
  357. package/src/persistence/compaction-log-store-clickhouse.ts +3 -3
  358. package/src/persistence/conversation-crud.ts +71 -63
  359. package/src/persistence/conversation-lineage.ts +12 -4
  360. package/src/persistence/conversation-queries.ts +2 -2
  361. package/src/persistence/conversation-title-placeholders.ts +23 -0
  362. package/src/persistence/conversation-title-service.ts +9 -26
  363. package/src/persistence/embeddings/qdrant-manager.ts +163 -18
  364. package/src/persistence/llm-request-log-sink-clickhouse.ts +3 -3
  365. package/src/persistence/llm-request-log-source-clickhouse.ts +3 -3
  366. package/src/persistence/message-cursor.ts +78 -0
  367. package/src/persistence/message-reads.ts +30 -0
  368. package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
  369. package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
  370. package/src/persistence/schema/memory-core.ts +5 -0
  371. package/src/platform/client.test.ts +4 -2
  372. package/src/platform/client.ts +2 -10
  373. package/src/plugin-api/constants.ts +2 -0
  374. package/src/plugin-api/index.ts +1 -0
  375. package/src/plugin-api/types.ts +2 -0
  376. package/src/plugins/defaults/index.ts +2 -0
  377. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +46 -0
  378. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-backfill.test.ts +118 -0
  379. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-preserve.test.ts +83 -0
  380. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +9 -5
  381. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +20 -10
  382. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
  383. package/src/plugins/defaults/memory/__tests__/memory-retrospective-state.test.ts +219 -0
  384. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +31 -0
  385. package/src/plugins/defaults/memory/__tests__/memory-retrospective-trigger-check.test.ts +63 -0
  386. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  387. package/src/plugins/defaults/memory/context-search/agent-runner.ts +3 -2
  388. package/src/plugins/defaults/memory/context-search/format.ts +3 -2
  389. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +6 -3
  390. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +3 -2
  391. package/src/plugins/defaults/memory/graph/capability-seed.ts +6 -7
  392. package/src/plugins/defaults/memory/hooks/init.ts +10 -0
  393. package/src/plugins/defaults/memory/hooks/message-deleted.ts +21 -0
  394. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  395. package/src/plugins/defaults/memory/host-utils.ts +10 -0
  396. package/src/plugins/defaults/memory/jobs-worker.ts +13 -0
  397. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +34 -60
  398. package/src/plugins/defaults/memory/memory-retrospective-cursor-backfill.ts +126 -0
  399. package/src/plugins/defaults/memory/memory-retrospective-cursor-preserve.ts +79 -0
  400. package/src/plugins/defaults/memory/memory-retrospective-cursor.ts +28 -0
  401. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +3 -5
  402. package/src/plugins/defaults/memory/memory-retrospective-job.ts +12 -3
  403. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  404. package/src/plugins/defaults/memory/memory-retrospective-state.ts +312 -71
  405. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +2 -1
  406. package/src/plugins/defaults/memory/memory-retrospective-trigger-check.ts +5 -3
  407. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  408. package/src/plugins/defaults/memory/startup.ts +28 -2
  409. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +7 -9
  410. package/src/plugins/defaults/memory/substrate/page-index.ts +6 -1
  411. package/src/plugins/defaults/memory/substrate/skill-content.ts +2 -2
  412. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +4 -2
  413. package/src/plugins/defaults/memory/v1/graph/extraction.ts +4 -2
  414. package/src/plugins/defaults/memory/v1/graph/retriever.ts +4 -1
  415. package/src/plugins/defaults/memory/v2/reranker.ts +6 -1
  416. package/src/plugins/defaults/memory/v3/__tests__/card.test.ts +24 -0
  417. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +125 -0
  418. package/src/plugins/defaults/memory/v3/__tests__/sections.test.ts +26 -0
  419. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +30 -0
  420. package/src/plugins/defaults/memory/v3/card.ts +3 -2
  421. package/src/plugins/defaults/memory/v3/pool-select.ts +5 -3
  422. package/src/plugins/defaults/memory/v3/sections.ts +9 -3
  423. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +5 -2
  424. package/src/plugins/defaults/memory/worker.ts +6 -0
  425. package/src/plugins/mcp-servers.ts +15 -75
  426. package/src/providers/__tests__/connection-model-compat.test.ts +3 -3
  427. package/src/providers/__tests__/retry-callsite.test.ts +41 -0
  428. package/src/providers/__tests__/retry-outbound-sanitize.test.ts +176 -0
  429. package/src/providers/anthropic/client.ts +0 -47
  430. package/src/providers/gemini/client.ts +9 -23
  431. package/src/providers/gemini-thought-signature.test.ts +63 -0
  432. package/src/providers/gemini-thought-signature.ts +44 -0
  433. package/src/providers/inference/__tests__/endpoint-probe.test.ts +42 -3
  434. package/src/providers/inference/auth.ts +2 -3
  435. package/src/providers/inference/connection-availability.ts +27 -8
  436. package/src/providers/inference/endpoint-probe.ts +10 -1
  437. package/src/providers/inference/resolve-auth.ts +22 -4
  438. package/src/providers/inference/service-account-token.ts +248 -0
  439. package/src/providers/model-catalog.ts +34 -0
  440. package/src/providers/model-intents.ts +10 -5
  441. package/src/providers/openai/__tests__/chat-completions-output-token-limit.test.ts +141 -0
  442. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +146 -18
  443. package/src/providers/openai/__tests__/chat-completions-provider-thought-signature.test.ts +613 -0
  444. package/src/providers/openai/__tests__/google-thought-signature.test.ts +235 -0
  445. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +83 -1
  446. package/src/providers/openai/chat-completions-provider.ts +212 -79
  447. package/src/providers/openai/codex-models.ts +0 -4
  448. package/src/providers/openai/google-thought-signature.ts +320 -0
  449. package/src/providers/opencode/client.test.ts +73 -4
  450. package/src/providers/opencode/client.ts +60 -3
  451. package/src/providers/openrouter/client.ts +20 -0
  452. package/src/providers/outbound-request-sanitize.ts +96 -0
  453. package/src/providers/retry.ts +30 -19
  454. package/src/providers/unparseable-tool-args.ts +5 -1
  455. package/src/runtime/AGENTS.md +3 -1
  456. package/src/runtime/__tests__/channel-send.test.ts +443 -0
  457. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +89 -0
  458. package/src/runtime/__tests__/host-observe.test.ts +40 -1
  459. package/src/runtime/__tests__/live-voice-upgrade.test.ts +58 -0
  460. package/src/runtime/__tests__/runtime-ws-test-utils.ts +72 -0
  461. package/src/runtime/__tests__/watch-stream-upgrade.test.ts +59 -0
  462. package/src/runtime/agent-wake.ts +2 -1
  463. package/src/runtime/assistant-event-hub.ts +21 -3
  464. package/src/runtime/auth/same-actor.ts +38 -3
  465. package/src/runtime/channel-send.ts +316 -0
  466. package/src/runtime/host-observe.ts +11 -1
  467. package/src/runtime/http-server.ts +160 -151
  468. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  469. package/src/runtime/routes/__tests__/acp-routes.test.ts +159 -4
  470. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +275 -0
  471. package/src/runtime/routes/__tests__/channel-send-routes.test.ts +127 -0
  472. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  473. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +2 -2
  474. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
  475. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +16 -12
  476. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  477. package/src/runtime/routes/acp-routes.test.ts +15 -0
  478. package/src/runtime/routes/acp-routes.ts +72 -9
  479. package/src/runtime/routes/app-management-routes.ts +90 -2
  480. package/src/runtime/routes/app-routes.ts +1 -1
  481. package/src/runtime/routes/channel-send-routes.ts +85 -0
  482. package/src/runtime/routes/conversation-list-routes.ts +14 -3
  483. package/src/runtime/routes/conversation-query-routes.ts +40 -0
  484. package/src/runtime/routes/conversation-routes.ts +16 -3
  485. package/src/runtime/routes/credential-routes.ts +20 -14
  486. package/src/runtime/routes/desktop-setup-routes.test.ts +74 -0
  487. package/src/runtime/routes/desktop-setup-routes.ts +35 -0
  488. package/src/runtime/routes/diagnostics-routes.ts +61 -4
  489. package/src/runtime/routes/events-routes.ts +4 -0
  490. package/src/runtime/routes/host-cu-routes.ts +6 -0
  491. package/src/runtime/routes/index.ts +4 -2
  492. package/src/runtime/routes/log-export-routes.ts +2 -0
  493. package/src/runtime/routes/mcp-auth-routes.ts +39 -47
  494. package/src/runtime/routes/migration-routes.ts +6 -20
  495. package/src/runtime/routes/oauth-commands-routes.ts +50 -0
  496. package/src/runtime/routes/oauth-providers.ts +7 -0
  497. package/src/runtime/routes/platform-routes.ts +18 -15
  498. package/src/runtime/routes/workspace-routes.test.ts +55 -26
  499. package/src/runtime/routes/workspace-routes.ts +46 -63
  500. package/src/runtime/routes/workspace-tree-walk.test.ts +244 -0
  501. package/src/runtime/routes/workspace-tree-walk.ts +240 -0
  502. package/src/runtime/routes/workspace-utils.ts +17 -0
  503. package/src/runtime/services/__tests__/conversation-serializer.test.ts +28 -0
  504. package/src/runtime/services/conversation-serializer.ts +16 -3
  505. package/src/runtime/sync/resource-sync-events.ts +2 -1
  506. package/src/schedule/run-script.ts +2 -1
  507. package/src/schedule/schedule-store.ts +9 -2
  508. package/src/schedule/worker.ts +1 -1
  509. package/src/security/__tests__/ces-http-record-backend.test.ts +137 -0
  510. package/src/security/ces-http-record-backend.ts +120 -0
  511. package/src/security/ces-rpc-record-backend.ts +7 -0
  512. package/src/security/untrusted-content.ts +3 -1
  513. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  514. package/src/skills/workspace-skill-md-signature.ts +50 -0
  515. package/src/subagent/consult-context.ts +12 -25
  516. package/src/tools/__tests__/side-effect-abort-guard.test.ts +1 -0
  517. package/src/tools/acp/list-agents.test.ts +3 -1
  518. package/src/tools/acp/spawn.test.ts +146 -11
  519. package/src/tools/acp/spawn.ts +21 -3
  520. package/src/tools/acp/status.test.ts +94 -0
  521. package/src/tools/acp/status.ts +40 -3
  522. package/src/tools/app-control/skill-proxy-bridge.ts +2 -1
  523. package/src/tools/browser/browser-execution.ts +1 -1
  524. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +9 -3
  525. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +2 -1
  526. package/src/tools/browser/cdp-client/extension-cdp-client.ts +3 -2
  527. package/src/tools/browser/network-recorder.ts +2 -1
  528. package/src/tools/capability-offer.test.ts +22 -2
  529. package/src/tools/capability-offer.ts +16 -1
  530. package/src/tools/computer-use/definitions.ts +121 -2
  531. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -1
  532. package/src/tools/credentials/metadata-store.ts +100 -0
  533. package/src/tools/filesystem/search.ts +2 -1
  534. package/src/tools/host-filesystem/edit.test.ts +2 -1
  535. package/src/tools/host-filesystem/edit.ts +2 -2
  536. package/src/tools/host-filesystem/read.test.ts +2 -1
  537. package/src/tools/host-filesystem/read.ts +2 -2
  538. package/src/tools/host-filesystem/transfer.test.ts +2 -1
  539. package/src/tools/host-filesystem/transfer.ts +2 -2
  540. package/src/tools/host-filesystem/write.test.ts +2 -1
  541. package/src/tools/host-filesystem/write.ts +2 -2
  542. package/src/tools/host-terminal/host-shell.test.ts +16 -0
  543. package/src/tools/host-terminal/host-shell.ts +4 -20
  544. package/src/tools/network/web-search.ts +6 -1
  545. package/src/tools/policy-context.ts +6 -7
  546. package/src/tools/skills/scaffold-managed.ts +61 -3
  547. package/src/tools/subagent/spawn.ts +5 -8
  548. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +57 -0
  549. package/src/tools/terminal/sanitized-bash.ts +105 -0
  550. package/src/tools/ui-surface/channel-variants.ts +10 -59
  551. package/src/tools/watch/watch-retro-report.ts +0 -8
  552. package/src/types/host-capabilities.ts +5 -3
  553. package/src/util/__tests__/language-subtag.test.ts +23 -1
  554. package/src/util/ca-bundle.ts +13 -0
  555. package/src/util/language-subtag.ts +16 -0
  556. package/src/util/platform.ts +8 -0
  557. package/src/util/unicode.ts +15 -1
  558. package/src/watch/watch-retro.ts +6 -8
  559. package/src/watch/watch-timeline.ts +2 -2
  560. package/src/workflows/run-manager.ts +2 -1
  561. package/src/workspace/git-porcelain.ts +66 -0
  562. package/src/workspace/git-service.ts +6 -55
  563. package/src/workspace/migrations/154-repair-retired-codex-gpt-5-4-model-ids.ts +258 -0
  564. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  565. package/src/workspace/migrations/156-extract-workspace-mcp-json.ts +214 -0
  566. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  567. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +199 -0
  568. package/src/workspace/migrations/registry.ts +6 -0
  569. package/src/__tests__/app-source-watcher.test.ts +0 -218
  570. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
  571. package/src/__tests__/speaker-identification.test.ts +0 -56
  572. package/src/calls/speaker-identification.ts +0 -233
  573. package/src/daemon/app-source-watcher.ts +0 -218
  574. package/src/messaging/providers/whatsapp/client.ts +0 -29
  575. package/src/runtime/routes/debug-bash-routes.ts +0 -173
  576. package/src/util/fs-watcher-error.ts +0 -36
  577. /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
@@ -1,17 +1,55 @@
1
1
  /**
2
2
  * Regression tests for AcpSessionManager state population — specifically
3
3
  * that `parentConversationId` is set on every session state at spawn time
4
- * and is therefore visible through `getStatus()` from t=0.
4
+ * and is therefore visible through `getStatus()` from t=0, plus the model a
5
+ * spawn resolves, applies through the adapter, and publishes, and the
6
+ * unsolicited updates an adapter sends when the model changes from the
7
+ * transcript.
5
8
  */
6
9
 
7
- import { describe, expect, mock, test } from "bun:test";
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
8
11
 
12
+ import type { SessionConfigOption } from "@agentclientprotocol/sdk";
13
+ import * as acp from "@agentclientprotocol/sdk";
14
+
15
+ import type { AssistantEvent } from "../../api/index.js";
16
+ import { initializeDb } from "../../persistence/db-init.js";
17
+ import { claudeCredentialRefused } from "../acp-auth-marker-store.js";
18
+ import { AcpAuthRequiredError, isAcpAuthRequired } from "../auth-required.js";
19
+ import type { VellumAcpClientHandler } from "../client-handler.js";
9
20
  import type { AcpSessionState } from "../types.js";
21
+ import { AcpConfigOptionRefusedError } from "../types.js";
22
+ import { installAcpConfigStub } from "./helpers/acp-config-stub.js";
23
+ import {
24
+ MODEL_OPTION_MODELS,
25
+ modelOption,
26
+ nonModelOption,
27
+ } from "./helpers/acp-model-option.js";
10
28
 
11
29
  // Records every `cancel(protocolSessionId)` the manager dispatches to a fake
12
30
  // process, so tests can assert which sessions were cancelled.
13
31
  const cancelCalls: string[] = [];
14
32
 
33
+ /** Config options each `createSession` reports, one entry per call. */
34
+ let scriptedConfigOptions: SessionConfigOption[][] = [];
35
+ /**
36
+ * When set, `createSession` stalls on this gate before it answers, so a test
37
+ * can drive a notification into the still-open call.
38
+ */
39
+ let createSessionGate: Promise<void> | null = null;
40
+ /** Every `setConfigOption` the manager dispatched to a fake process. */
41
+ const setConfigOptionCalls: Array<{
42
+ sessionId: string;
43
+ configId: string;
44
+ value: string | boolean;
45
+ }> = [];
46
+ /** What `setConfigOption` answers with; an Error is thrown instead. */
47
+ let setConfigOptionResult: SessionConfigOption[] | Error = [];
48
+ /** Answers per call when set, so one round trip can be slower than the next. */
49
+ let setConfigOptionResponder:
50
+ | ((value: string | boolean) => Promise<SessionConfigOption[]>)
51
+ | null = null;
52
+
15
53
  // Stub the agent-process module so spawn() does not actually launch a child
16
54
  // process. Each fake instance records the cwd it was spawned in and resolves
17
55
  // every protocol method synchronously. The mock is process-global (Bun's
@@ -26,8 +64,30 @@ mock.module("../agent-process.js", () => ({
26
64
  ) {}
27
65
  spawn(_cwd: string): void {}
28
66
  async initialize(): Promise<void> {}
29
- async createSession(_cwd: string): Promise<string> {
30
- return `proto-${this.agentId}`;
67
+ async createSession(
68
+ _cwd: string,
69
+ ): Promise<{ sessionId: string; configOptions: SessionConfigOption[] }> {
70
+ if (createSessionGate) {
71
+ await createSessionGate;
72
+ }
73
+ return {
74
+ sessionId: `proto-${this.agentId}`,
75
+ configOptions: scriptedConfigOptions.shift() ?? [],
76
+ };
77
+ }
78
+ async setConfigOption(
79
+ sessionId: string,
80
+ configId: string,
81
+ value: string | boolean,
82
+ ): Promise<SessionConfigOption[]> {
83
+ setConfigOptionCalls.push({ sessionId, configId, value });
84
+ if (setConfigOptionResponder) {
85
+ return setConfigOptionResponder(value);
86
+ }
87
+ if (setConfigOptionResult instanceof Error) {
88
+ throw setConfigOptionResult;
89
+ }
90
+ return setConfigOptionResult;
31
91
  }
32
92
  async prompt(): Promise<{ stopReason: string }> {
33
93
  // Never resolves — keeps the session alive in `running` state for
@@ -47,7 +107,21 @@ mock.module("../agent-process.js", () => ({
47
107
  },
48
108
  }));
49
109
 
110
+ // Installed before the manager is imported so its own `getConfig` calls
111
+ // resolve through the stub rather than the real workspace config.
112
+ await installAcpConfigStub();
50
113
  const { AcpSessionManager } = await import("../session-manager.js");
114
+ await initializeDb();
115
+
116
+ type Manager = InstanceType<typeof AcpSessionManager>;
117
+
118
+ beforeEach(() => {
119
+ scriptedConfigOptions = [];
120
+ createSessionGate = null;
121
+ setConfigOptionCalls.length = 0;
122
+ setConfigOptionResult = [];
123
+ setConfigOptionResponder = null;
124
+ });
51
125
 
52
126
  describe("AcpSessionManager — parentConversationId population", () => {
53
127
  const noopSend = () => {};
@@ -158,3 +232,820 @@ describe("AcpSessionManager — cancelForParent", () => {
158
232
  expect(cancelCalls).toEqual([]);
159
233
  });
160
234
  });
235
+
236
+ describe("AcpSessionManager: model selection at spawn", () => {
237
+ /**
238
+ * Spawns one session with the ladder rungs the case cares about set, and
239
+ * returns the events it emitted alongside the resulting state.
240
+ */
241
+ async function spawnWithModel(opts: {
242
+ conversationId: string;
243
+ agentId?: string;
244
+ agentModel?: string;
245
+ requestedModel?: string;
246
+ }): Promise<{
247
+ state: AcpSessionState;
248
+ sent: AssistantEvent[];
249
+ requestedModel?: string;
250
+ effectiveModel?: string;
251
+ modelWarning?: string;
252
+ }> {
253
+ const manager = new AcpSessionManager(5);
254
+ const sent: AssistantEvent[] = [];
255
+ const { acpSessionId, requestedModel, effectiveModel, modelWarning } =
256
+ await manager.spawn(
257
+ opts.agentId ?? "agent-model",
258
+ { command: "echo", args: ["hi"], model: opts.agentModel },
259
+ "task",
260
+ "/tmp",
261
+ opts.conversationId,
262
+ (msg) => sent.push(msg),
263
+ opts.requestedModel ? { model: opts.requestedModel } : {},
264
+ );
265
+ return {
266
+ state: manager.getStatus(acpSessionId) as AcpSessionState,
267
+ sent,
268
+ requestedModel,
269
+ effectiveModel,
270
+ modelWarning,
271
+ };
272
+ }
273
+
274
+ /** The model value the adapter was asked to select, if it was asked. */
275
+ function selectedValue(): string | boolean | undefined {
276
+ return setConfigOptionCalls[0]?.value;
277
+ }
278
+
279
+ test("state carries the model and options the adapter reported", async () => {
280
+ scriptedConfigOptions = [[modelOption("opus")]];
281
+
282
+ const { effectiveModel, state } = await spawnWithModel({
283
+ conversationId: "conv-report",
284
+ });
285
+
286
+ expect(state.model).toBe("opus");
287
+ expect(effectiveModel).toBe("opus");
288
+ expect(state.availableModels).toEqual(MODEL_OPTION_MODELS);
289
+ // Nothing was requested and the adapter is already on a model, so it was
290
+ // never asked to change.
291
+ expect(setConfigOptionCalls).toEqual([]);
292
+ });
293
+
294
+ test("the spawn result owns requested-model normalization", async () => {
295
+ scriptedConfigOptions = [[modelOption("default")]];
296
+
297
+ const manager = new AcpSessionManager(5);
298
+ const result = await manager.spawn(
299
+ "agent-model",
300
+ { command: "echo", args: ["hi"] },
301
+ "task",
302
+ "/tmp",
303
+ "conv-normalized-request",
304
+ () => {},
305
+ { model: " opus " },
306
+ );
307
+
308
+ expect(result.requestedModel).toBe("opus");
309
+ expect(selectedValue()).toBe("opus");
310
+ });
311
+
312
+ test("the model event follows the spawned event", async () => {
313
+ scriptedConfigOptions = [[modelOption("opus")]];
314
+
315
+ const { sent } = await spawnWithModel({ conversationId: "conv-event" });
316
+
317
+ expect(sent.map((e) => e.type)).toEqual([
318
+ "acp_session_spawned",
319
+ "acp_session_model_update",
320
+ ]);
321
+ expect(sent[1]).toMatchObject({
322
+ model: "opus",
323
+ availableModels: MODEL_OPTION_MODELS,
324
+ });
325
+ });
326
+
327
+ test("an adapter with no model selector publishes nothing and stays unset", async () => {
328
+ const { state, sent } = await spawnWithModel({
329
+ conversationId: "conv-no-selector",
330
+ requestedModel: "opus",
331
+ });
332
+
333
+ expect(state.model).toBeUndefined();
334
+ expect(state.availableModels).toBeUndefined();
335
+ expect(setConfigOptionCalls).toEqual([]);
336
+ expect(sent.map((e) => e.type)).toEqual(["acp_session_spawned"]);
337
+ // A model that cannot be applied is not a failed spawn.
338
+ expect(state.status).toBe("running");
339
+ });
340
+
341
+ test("a caller who asked for a model is told the agent cannot select one", async () => {
342
+ const { modelWarning } = await spawnWithModel({
343
+ conversationId: "conv-no-selector-warned",
344
+ requestedModel: "opus",
345
+ });
346
+
347
+ expect(modelWarning).toBe(
348
+ 'Agent "agent-model" does not support model selection, so the ' +
349
+ "session is running on the agent's own model.",
350
+ );
351
+ });
352
+
353
+ test("an inherited model on an agent with no selector warns nobody", async () => {
354
+ const { modelWarning } = await spawnWithModel({
355
+ conversationId: "conv-no-selector-inherited",
356
+ agentModel: "opus",
357
+ });
358
+
359
+ expect(modelWarning).toBeUndefined();
360
+ });
361
+
362
+ test("an explicit request outranks the agent's own model", async () => {
363
+ scriptedConfigOptions = [[modelOption("default")]];
364
+
365
+ await spawnWithModel({
366
+ conversationId: "conv-ladder",
367
+ agentModel: "fable",
368
+ requestedModel: "opus",
369
+ });
370
+
371
+ expect(setConfigOptionCalls).toEqual([
372
+ { sessionId: "proto-agent-model", configId: "model", value: "opus" },
373
+ ]);
374
+ });
375
+
376
+ test("the agent's own model applies when the ask names none", async () => {
377
+ scriptedConfigOptions = [[modelOption("default")]];
378
+
379
+ await spawnWithModel({
380
+ conversationId: "conv-ladder",
381
+ agentModel: "fable",
382
+ });
383
+
384
+ expect(selectedValue()).toBe("fable");
385
+ });
386
+
387
+ test("a spawn with no rung set leaves the adapter on its own model", async () => {
388
+ scriptedConfigOptions = [[modelOption("default")]];
389
+
390
+ const { state } = await spawnWithModel({ conversationId: "conv-ladder" });
391
+
392
+ expect(setConfigOptionCalls).toEqual([]);
393
+ expect(state.model).toBe("default");
394
+ });
395
+
396
+ test("the pin records the value the adapter confirmed", async () => {
397
+ scriptedConfigOptions = [[modelOption("default")]];
398
+ // The adapter resolves the alias it was handed to a full model id.
399
+ setConfigOptionResult = [modelOption("claude-opus-4-5")];
400
+
401
+ const { effectiveModel, state } = await spawnWithModel({
402
+ conversationId: "conv-pin",
403
+ requestedModel: "opus",
404
+ });
405
+
406
+ expect(state.model).toBe("claude-opus-4-5");
407
+ expect(effectiveModel).toBe("claude-opus-4-5");
408
+ });
409
+
410
+ test("an inherited model the adapter refuses warns nobody", async () => {
411
+ scriptedConfigOptions = [[modelOption("opus")]];
412
+ setConfigOptionResult = new AcpConfigOptionRefusedError(
413
+ "Invalid value for config option model: nope",
414
+ );
415
+
416
+ const { modelWarning, state } = await spawnWithModel({
417
+ conversationId: "conv-refused-inherited",
418
+ agentModel: "nope",
419
+ });
420
+
421
+ // Logged, not surfaced: nobody typed this model here.
422
+ expect(modelWarning).toBeUndefined();
423
+ expect(state.model).toBe("opus");
424
+ });
425
+
426
+ test("a refused model warns and runs unpinned", async () => {
427
+ scriptedConfigOptions = [[modelOption("opus")]];
428
+ setConfigOptionResult = new AcpConfigOptionRefusedError(
429
+ "Invalid value for config option model: nope",
430
+ );
431
+
432
+ const manager = new AcpSessionManager(5);
433
+ const sent: AssistantEvent[] = [];
434
+ const result = await manager.spawn(
435
+ "agent-model",
436
+ { command: "echo", args: ["hi"] },
437
+ "task",
438
+ "/tmp",
439
+ "conv-refused",
440
+ (msg) => sent.push(msg),
441
+ { model: "nope" },
442
+ );
443
+
444
+ expect(result.modelWarning).toBe(
445
+ "Invalid value for config option model: nope",
446
+ );
447
+ expect(result.effectiveModel).toBe("opus");
448
+ const state = manager.getStatus(result.acpSessionId) as AcpSessionState;
449
+ // The run is live on whatever the adapter chose for itself.
450
+ expect(state.status).toBe("running");
451
+ expect(state.model).toBe("opus");
452
+ expect(sent.map((e) => e.type)).toEqual([
453
+ "acp_session_spawned",
454
+ "acp_session_model_update",
455
+ ]);
456
+ });
457
+
458
+ test("an authentication failure during the pin surfaces as auth required", async () => {
459
+ scriptedConfigOptions = [[modelOption("default")]];
460
+ setConfigOptionResult = new AcpAuthRequiredError(
461
+ "agent-model",
462
+ "Claude refused the configured credential",
463
+ );
464
+
465
+ const manager = new AcpSessionManager(5);
466
+ const sent: AssistantEvent[] = [];
467
+ const spawned = manager.spawn(
468
+ "agent-model",
469
+ { command: "echo", args: ["hi"] },
470
+ "task",
471
+ "/tmp",
472
+ "conv-pin-auth",
473
+ (msg) => sent.push(msg),
474
+ { model: "opus" },
475
+ );
476
+
477
+ const failure = await spawned.then(
478
+ () => undefined,
479
+ (err: unknown) => err,
480
+ );
481
+ expect(isAcpAuthRequired(failure)).toBe(true);
482
+ expect(sent).toEqual([]);
483
+ expect(manager.getStatus()).toEqual([]);
484
+ });
485
+
486
+ test("Claude's message-shaped 401 during the pin surfaces as auth required", async () => {
487
+ scriptedConfigOptions = [[modelOption("default")]];
488
+ // Not the structured auth_required answer: the CLI's own words, which the
489
+ // adapter relays as a generic internal error carrying them in `data`, and
490
+ // which the spawn boundary only recognises once the manager classifies.
491
+ setConfigOptionResult = new acp.RequestError(-32603, "Internal error", {
492
+ details: "Failed to authenticate. Please run /login",
493
+ });
494
+
495
+ const manager = new AcpSessionManager(5);
496
+ const sent: AssistantEvent[] = [];
497
+ const spawned = manager.spawn(
498
+ "claude",
499
+ {
500
+ command: "claude-agent-acp",
501
+ args: [],
502
+ credentialDigest: "digest-pin-401",
503
+ },
504
+ "task",
505
+ "/tmp",
506
+ "conv-pin-auth-message",
507
+ (msg) => sent.push(msg),
508
+ { model: "opus" },
509
+ );
510
+
511
+ const failure = await spawned.then(
512
+ () => undefined,
513
+ (err: unknown) => err,
514
+ );
515
+ expect(failure).toBeInstanceOf(AcpAuthRequiredError);
516
+ expect(isAcpAuthRequired(failure)).toBe(true);
517
+ expect((failure as Error).message).toBe(
518
+ "Failed to authenticate. Please run /login",
519
+ );
520
+ // The refused credential is written down, so no later spawn resolves it.
521
+ expect(claudeCredentialRefused("digest-pin-401")).toBe(true);
522
+ expect(sent).toEqual([]);
523
+ expect(manager.getStatus()).toEqual([]);
524
+ });
525
+
526
+ test("a message-shaped 401 with no payload classifies off the message", async () => {
527
+ scriptedConfigOptions = [[modelOption("default")]];
528
+ setConfigOptionResult = new Error(
529
+ "Failed to authenticate. Please run /login",
530
+ );
531
+
532
+ const failure = await new AcpSessionManager(5)
533
+ .spawn(
534
+ "claude",
535
+ { command: "claude-agent-acp", args: [] },
536
+ "task",
537
+ "/tmp",
538
+ "conv-pin-auth-plain",
539
+ () => {},
540
+ { model: "opus" },
541
+ )
542
+ .then(
543
+ () => undefined,
544
+ (err: unknown) => err,
545
+ );
546
+
547
+ expect(failure).toBeInstanceOf(AcpAuthRequiredError);
548
+ expect((failure as Error).message).toBe(
549
+ "Failed to authenticate. Please run /login",
550
+ );
551
+ });
552
+
553
+ test("the message-shaped 401 is still recognised when the adapter runs by full path", async () => {
554
+ scriptedConfigOptions = [[modelOption("default")]];
555
+ setConfigOptionResult = new acp.RequestError(-32603, "Internal error", {
556
+ details: "Not logged in",
557
+ });
558
+
559
+ const manager = new AcpSessionManager(5);
560
+ const failure = await manager
561
+ .spawn(
562
+ "claude",
563
+ { command: "/opt/bin/claude-agent-acp", args: [] },
564
+ "task",
565
+ "/tmp",
566
+ "conv-pin-auth-path",
567
+ () => {},
568
+ { model: "opus" },
569
+ )
570
+ .then(
571
+ () => undefined,
572
+ (err: unknown) => err,
573
+ );
574
+
575
+ expect(isAcpAuthRequired(failure)).toBe(true);
576
+ expect(manager.getStatus()).toEqual([]);
577
+ });
578
+
579
+ test("a pin the connection cannot carry tears the spawn down", async () => {
580
+ scriptedConfigOptions = [[modelOption("default")]];
581
+ // A plain Error is how the SDK rejects a request when the stream closes
582
+ // or the process exits: not the adapter's answer, so not a refusal.
583
+ setConfigOptionResult = new Error("ACP connection closed");
584
+
585
+ const manager = new AcpSessionManager(5);
586
+ const sent: AssistantEvent[] = [];
587
+ await expect(
588
+ manager.spawn(
589
+ "agent-model",
590
+ { command: "echo", args: ["hi"] },
591
+ "task",
592
+ "/tmp",
593
+ "conv-pin-transport",
594
+ (msg) => sent.push(msg),
595
+ { model: "opus" },
596
+ ),
597
+ ).rejects.toThrow("ACP connection closed");
598
+
599
+ // The pin was attempted, then nothing was announced or prompted, and
600
+ // nothing is left registered.
601
+ expect(setConfigOptionCalls).toHaveLength(1);
602
+ expect(sent).toEqual([]);
603
+ expect(manager.getStatus()).toEqual([]);
604
+ });
605
+
606
+ test("a pin answered without the selector warns and leaves no model", async () => {
607
+ scriptedConfigOptions = [[modelOption("sonnet")]];
608
+ // The adapter takes the call, then answers with a set that advertises no
609
+ // model selection at all.
610
+ setConfigOptionResult = [nonModelOption()];
611
+
612
+ const manager = new AcpSessionManager(5);
613
+ const sent: AssistantEvent[] = [];
614
+ const result = await manager.spawn(
615
+ "agent-model",
616
+ { command: "echo", args: ["hi"] },
617
+ "task",
618
+ "/tmp",
619
+ "conv-pin-dropped",
620
+ (msg) => sent.push(msg),
621
+ { model: "opus" },
622
+ );
623
+
624
+ expect(result.modelWarning).toBe(
625
+ 'Agent "agent-model" does not support model selection, so the ' +
626
+ "session is running on the agent's own model.",
627
+ );
628
+ const state = manager.getStatus(result.acpSessionId) as AcpSessionState;
629
+ expect(state.model).toBeUndefined();
630
+ expect(state.availableModels).toEqual([]);
631
+ // The withdrawal goes out before the session is announced, which no
632
+ // client holds an entry for yet, and the spawn's own model event stays
633
+ // silent for an adapter with nothing to select.
634
+ expect(sent.map((e) => e.type)).toEqual([
635
+ "acp_session_model_update",
636
+ "acp_session_spawned",
637
+ ]);
638
+ expect(sent[0]).toEqual({
639
+ type: "acp_session_model_update",
640
+ acpSessionId: result.acpSessionId,
641
+ modelRevisionEpoch: state.modelRevisionEpoch!,
642
+ modelRevision: 1,
643
+ availableModels: [],
644
+ });
645
+ });
646
+
647
+ test("a selector announced while the pin was open is withdrawn from clients", async () => {
648
+ scriptedConfigOptions = [[modelOption("sonnet")]];
649
+ let release = () => {};
650
+ const held = new Promise<void>((resolve) => {
651
+ release = resolve;
652
+ });
653
+ setConfigOptionResponder = async () => {
654
+ await held;
655
+ return [nonModelOption()];
656
+ };
657
+
658
+ const manager = new AcpSessionManager(5);
659
+ const sent: AssistantEvent[] = [];
660
+ const spawned = manager.spawn(
661
+ "agent-model",
662
+ { command: "echo", args: ["hi"] },
663
+ "task",
664
+ "/tmp",
665
+ "conv-pin-announced",
666
+ (msg) => sent.push(msg),
667
+ { model: "opus" },
668
+ );
669
+ await waitForConfigOptionCalls(1);
670
+ const acpSessionId = manager.getActiveAndPendingIds()[0]!;
671
+ // The adapter reports a selector of its own while the pin is still open,
672
+ // which reaches clients right away.
673
+ await emitConfigOptions(manager, acpSessionId, [modelOption("opus")]);
674
+ release();
675
+ await spawned;
676
+
677
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
678
+ expect(state.model).toBeUndefined();
679
+ expect(state.availableModels).toEqual([]);
680
+ expect(sent.filter((e) => e.type === "acp_session_model_update")).toEqual([
681
+ {
682
+ type: "acp_session_model_update",
683
+ acpSessionId,
684
+ modelRevisionEpoch: state.modelRevisionEpoch!,
685
+ modelRevision: 1,
686
+ model: "opus",
687
+ availableModels: MODEL_OPTION_MODELS,
688
+ },
689
+ {
690
+ type: "acp_session_model_update",
691
+ acpSessionId,
692
+ modelRevisionEpoch: state.modelRevisionEpoch!,
693
+ modelRevision: 2,
694
+ availableModels: [],
695
+ },
696
+ ]);
697
+ });
698
+
699
+ test("an inherited model whose pin loses the selector warns nobody", async () => {
700
+ scriptedConfigOptions = [[modelOption("sonnet")]];
701
+ setConfigOptionResult = [nonModelOption()];
702
+
703
+ const { modelWarning, state } = await spawnWithModel({
704
+ conversationId: "conv-pin-dropped-inherited",
705
+ agentModel: "opus",
706
+ });
707
+
708
+ expect(modelWarning).toBeUndefined();
709
+ expect(state.model).toBeUndefined();
710
+ });
711
+ });
712
+
713
+ /** The client handler the manager wired for a session, to drive updates. */
714
+ function clientHandlerFor(
715
+ manager: Manager,
716
+ acpSessionId: string,
717
+ ): VellumAcpClientHandler {
718
+ const entry = (
719
+ manager as unknown as {
720
+ sessions: Map<string, { clientHandler: VellumAcpClientHandler }>;
721
+ }
722
+ ).sessions.get(acpSessionId);
723
+ if (!entry) {
724
+ throw new Error(`No ACP session registered for "${acpSessionId}"`);
725
+ }
726
+ return entry.clientHandler;
727
+ }
728
+
729
+ /**
730
+ * Drives a `config_option_update` through a session's real client handler,
731
+ * the way an adapter reports a model change it made on its own.
732
+ */
733
+ async function emitConfigOptions(
734
+ manager: Manager,
735
+ acpSessionId: string,
736
+ options: SessionConfigOption[],
737
+ sessionId = "proto-agent-model",
738
+ ): Promise<void> {
739
+ await clientHandlerFor(manager, acpSessionId).sessionUpdate({
740
+ sessionId,
741
+ update: { sessionUpdate: "config_option_update", configOptions: options },
742
+ });
743
+ }
744
+
745
+ /**
746
+ * A running session whose adapter advertises the model selector and sits on
747
+ * `sonnet`, with the spawn events already drained so a test asserts only what
748
+ * its own action emitted.
749
+ */
750
+ async function spawnSwitchable(conversationId: string): Promise<{
751
+ manager: Manager;
752
+ acpSessionId: string;
753
+ sent: AssistantEvent[];
754
+ }> {
755
+ scriptedConfigOptions = [[modelOption("sonnet")]];
756
+ const manager = new AcpSessionManager(5);
757
+ const sent: AssistantEvent[] = [];
758
+ const { acpSessionId } = await manager.spawn(
759
+ "agent-model",
760
+ { command: "echo", args: ["hi"] },
761
+ "task",
762
+ "/tmp",
763
+ conversationId,
764
+ (msg) => sent.push(msg),
765
+ );
766
+ sent.length = 0;
767
+ return { manager, acpSessionId, sent };
768
+ }
769
+
770
+ /** Resolves once the manager has dispatched `count` `setConfigOption` calls. */
771
+ async function waitForConfigOptionCalls(count: number): Promise<void> {
772
+ while (setConfigOptionCalls.length < count) {
773
+ await new Promise((resolve) => setTimeout(resolve, 0));
774
+ }
775
+ }
776
+
777
+ describe("AcpSessionManager: unsolicited model updates", () => {
778
+ test("a model changed from the transcript updates state and publishes", async () => {
779
+ const { manager, acpSessionId, sent } = await spawnSwitchable("conv-typed");
780
+
781
+ await emitConfigOptions(manager, acpSessionId, [modelOption("opus")]);
782
+
783
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
784
+ expect(state.model).toBe("opus");
785
+ expect(state.modelRevision).toBe(2);
786
+ expect(sent).toEqual([
787
+ {
788
+ type: "acp_session_model_update",
789
+ acpSessionId,
790
+ modelRevisionEpoch: state.modelRevisionEpoch!,
791
+ modelRevision: 2,
792
+ model: "opus",
793
+ availableModels: MODEL_OPTION_MODELS,
794
+ },
795
+ ]);
796
+ // Reporting is not choosing: the adapter is never asked to set it back.
797
+ expect(setConfigOptionCalls).toEqual([]);
798
+ });
799
+
800
+ test("a selector that disappears clears the model, the options, and the picker", async () => {
801
+ const { manager, acpSessionId, sent } =
802
+ await spawnSwitchable("conv-dropped");
803
+
804
+ await emitConfigOptions(manager, acpSessionId, [nonModelOption()]);
805
+
806
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
807
+ expect(state.model).toBeUndefined();
808
+ expect(state.availableModels).toEqual([]);
809
+ expect(sent).toEqual([
810
+ {
811
+ type: "acp_session_model_update",
812
+ acpSessionId,
813
+ modelRevisionEpoch: state.modelRevisionEpoch!,
814
+ modelRevision: 2,
815
+ availableModels: [],
816
+ },
817
+ ]);
818
+ expect(setConfigOptionCalls).toEqual([]);
819
+ });
820
+
821
+ test("an update repeating the current model still publishes", async () => {
822
+ const { manager, acpSessionId, sent } =
823
+ await spawnSwitchable("conv-unchanged");
824
+
825
+ await emitConfigOptions(manager, acpSessionId, [modelOption("sonnet")]);
826
+
827
+ expect(sent.map((e) => e.type)).toEqual(["acp_session_model_update"]);
828
+ });
829
+
830
+ test("a notification after the session went terminal changes nothing", async () => {
831
+ const { manager, acpSessionId, sent } =
832
+ await spawnSwitchable("conv-terminal");
833
+ const handler = clientHandlerFor(manager, acpSessionId);
834
+ await manager.cancel(acpSessionId);
835
+ sent.length = 0;
836
+
837
+ await handler.sessionUpdate({
838
+ sessionId: "proto-agent-model",
839
+ update: {
840
+ sessionUpdate: "config_option_update",
841
+ configOptions: [modelOption("opus")],
842
+ },
843
+ });
844
+
845
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
846
+ expect(state.status).toBe("cancelled");
847
+ expect(state.model).toBe("sonnet");
848
+ expect(sent).toEqual([]);
849
+ });
850
+
851
+ test("a selector appearing mid-run publishes the picker", async () => {
852
+ scriptedConfigOptions = [[nonModelOption()]];
853
+ const manager = new AcpSessionManager(5);
854
+ const sent: AssistantEvent[] = [];
855
+ const { acpSessionId } = await manager.spawn(
856
+ "agent-model",
857
+ { command: "echo", args: ["hi"] },
858
+ "task",
859
+ "/tmp",
860
+ "conv-selector-midrun",
861
+ (msg) => sent.push(msg),
862
+ );
863
+ expect(sent.map((e) => e.type)).toEqual(["acp_session_spawned"]);
864
+ sent.length = 0;
865
+
866
+ await emitConfigOptions(manager, acpSessionId, [modelOption("opus")]);
867
+
868
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
869
+ expect(sent).toEqual([
870
+ {
871
+ type: "acp_session_model_update",
872
+ acpSessionId,
873
+ modelRevisionEpoch: state.modelRevisionEpoch!,
874
+ modelRevision: 1,
875
+ model: "opus",
876
+ availableModels: MODEL_OPTION_MODELS,
877
+ },
878
+ ]);
879
+ });
880
+
881
+ test("a selector announced during session/new survives a response that omits it", async () => {
882
+ // The adapter answers session/new with no config options at all.
883
+ scriptedConfigOptions = [[]];
884
+ setConfigOptionResult = [modelOption("opus")];
885
+ let release = () => {};
886
+ createSessionGate = new Promise<void>((resolve) => {
887
+ release = resolve;
888
+ });
889
+
890
+ const manager = new AcpSessionManager(5);
891
+ const sent: AssistantEvent[] = [];
892
+ const spawning = manager.spawn(
893
+ "agent-model",
894
+ { command: "echo", args: ["hi"], model: "opus" },
895
+ "task",
896
+ "/tmp",
897
+ "conv-open-race",
898
+ (msg: AssistantEvent) => sent.push(msg),
899
+ );
900
+ const acpSessionId = (manager.getStatus() as AcpSessionState[])[0].id;
901
+
902
+ // The adapter announces its selector while session/new is still open.
903
+ await emitConfigOptions(manager, acpSessionId, [modelOption("sonnet")]);
904
+ release();
905
+ await spawning;
906
+
907
+ // The selector is still there for the agent's own model to be pinned
908
+ // through, and the picker still reaches the client after the spawn.
909
+ expect(setConfigOptionCalls).toEqual([
910
+ { sessionId: "proto-agent-model", configId: "model", value: "opus" },
911
+ ]);
912
+ expect((manager.getStatus(acpSessionId) as AcpSessionState).model).toBe(
913
+ "opus",
914
+ );
915
+ expect(sent.map((e) => e.type)).toEqual([
916
+ "acp_session_model_update",
917
+ "acp_session_spawned",
918
+ "acp_session_model_update",
919
+ ]);
920
+ expect(sent[2]).toMatchObject({
921
+ model: "opus",
922
+ availableModels: MODEL_OPTION_MODELS,
923
+ });
924
+ });
925
+
926
+ test("an opening response naming a selector outranks one announced mid-call", async () => {
927
+ scriptedConfigOptions = [[modelOption("opus")]];
928
+ let release = () => {};
929
+ createSessionGate = new Promise<void>((resolve) => {
930
+ release = resolve;
931
+ });
932
+
933
+ const manager = new AcpSessionManager(5);
934
+ const sent: AssistantEvent[] = [];
935
+ const spawning = manager.spawn(
936
+ "agent-model",
937
+ { command: "echo", args: ["hi"] },
938
+ "task",
939
+ "/tmp",
940
+ "conv-open-response",
941
+ (msg: AssistantEvent) => sent.push(msg),
942
+ );
943
+ const acpSessionId = (manager.getStatus() as AcpSessionState[])[0].id;
944
+
945
+ await emitConfigOptions(manager, acpSessionId, [modelOption("sonnet")]);
946
+ release();
947
+ await spawning;
948
+
949
+ // Nothing was requested and the response says the session is on opus, so
950
+ // the adapter is never asked to change.
951
+ expect((manager.getStatus(acpSessionId) as AcpSessionState).model).toBe(
952
+ "opus",
953
+ );
954
+ expect(setConfigOptionCalls).toEqual([]);
955
+ expect(sent[sent.length - 1]).toMatchObject({
956
+ type: "acp_session_model_update",
957
+ model: "opus",
958
+ availableModels: MODEL_OPTION_MODELS,
959
+ });
960
+ });
961
+ });
962
+
963
+ describe("AcpSessionManager: a resumed id and the process it replaced", () => {
964
+ /**
965
+ * A cancel frees the id, so a resume can register a fresh entry under it
966
+ * while the stopped spawn's pin is still open. Everything the old process
967
+ * says after that reaches the manager through the old entry's own closures,
968
+ * and those closures own nothing the replacement holds: not its state, not
969
+ * its ring buffer, not its clients.
970
+ */
971
+ test("late frames from the replaced process touch nothing the replacement owns", async () => {
972
+ scriptedConfigOptions = [[modelOption("sonnet")]];
973
+ let release = () => {};
974
+ const held = new Promise<void>((resolve) => {
975
+ release = resolve;
976
+ });
977
+ setConfigOptionResponder = async () => {
978
+ await held;
979
+ // The withdrawal a vanished selector publishes, which is the frame that
980
+ // would otherwise go out over the replacement's id.
981
+ return [];
982
+ };
983
+
984
+ const manager = new AcpSessionManager(5);
985
+ const controller = new AbortController();
986
+ const sent: AssistantEvent[] = [];
987
+ const spawned = manager.spawn(
988
+ "agent-model",
989
+ { command: "echo", args: ["hi"] },
990
+ "task",
991
+ "/tmp",
992
+ "conv-replaced",
993
+ (msg) => sent.push(msg),
994
+ { model: "opus" },
995
+ { signal: controller.signal },
996
+ );
997
+ await waitForConfigOptionCalls(1);
998
+ const acpSessionId = manager.getActiveAndPendingIds()[0]!;
999
+ const oldHandler = clientHandlerFor(manager, acpSessionId);
1000
+
1001
+ // The user stops the turn and a resume takes the id over, both while the
1002
+ // pin's round trip is still open.
1003
+ controller.abort();
1004
+ const internals = manager as unknown as {
1005
+ registerSession: (opts: Record<string, unknown>) => {
1006
+ state: AcpSessionState;
1007
+ };
1008
+ eventBuffers: Map<string, unknown[]>;
1009
+ };
1010
+ const replacement = internals.registerSession({
1011
+ acpSessionId,
1012
+ agentId: "agent-model",
1013
+ agentConfig: { command: "echo", args: ["hi"] },
1014
+ parentConversationId: "conv-resumed",
1015
+ cwd: "/tmp",
1016
+ startedAt: Date.now(),
1017
+ sendToVellum: () => {},
1018
+ });
1019
+ replacement.state.status = "running";
1020
+ sent.length = 0;
1021
+
1022
+ release();
1023
+ await expect(spawned).rejects.toThrow();
1024
+
1025
+ // The old adapter goes on talking: a model change typed into its
1026
+ // transcript, then a message chunk.
1027
+ await oldHandler.sessionUpdate({
1028
+ sessionId: "proto-agent-model",
1029
+ update: {
1030
+ sessionUpdate: "config_option_update",
1031
+ configOptions: [modelOption("haiku")],
1032
+ },
1033
+ });
1034
+ await oldHandler.sessionUpdate({
1035
+ sessionId: "proto-agent-model",
1036
+ update: {
1037
+ sessionUpdate: "agent_message_chunk",
1038
+ content: { type: "text", text: "from the old process" },
1039
+ },
1040
+ });
1041
+
1042
+ // The replacement is on nothing the old process said, and its buffer and
1043
+ // its clients heard none of it.
1044
+ const state = manager.getStatus(acpSessionId) as AcpSessionState;
1045
+ expect(state.parentConversationId).toBe("conv-resumed");
1046
+ expect(state.model).toBeUndefined();
1047
+ expect(state.availableModels).toBeUndefined();
1048
+ expect(internals.eventBuffers.get(acpSessionId)).toEqual([]);
1049
+ expect(sent).toEqual([]);
1050
+ });
1051
+ });