@vellumai/assistant 0.11.3 → 0.11.4-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 (324) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +140 -38
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +55 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  67. package/src/__tests__/visible-app-context.test.ts +16 -9
  68. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  69. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  70. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  71. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  72. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  73. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  74. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  75. package/src/acp/__tests__/auth-required.test.ts +161 -0
  76. package/src/acp/acp-claude-oauth.ts +19 -2
  77. package/src/acp/agent-process.test.ts +100 -0
  78. package/src/acp/agent-process.ts +29 -26
  79. package/src/acp/auth-required.ts +102 -0
  80. package/src/acp/session-manager.test.ts +119 -0
  81. package/src/acp/session-manager.ts +68 -2
  82. package/src/api/events/acp-auth-required.ts +55 -0
  83. package/src/api/index.ts +7 -0
  84. package/src/apps/app-store.ts +3 -0
  85. package/src/bundler/package-resolver.ts +2 -30
  86. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  87. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  88. package/src/calls/call-controller.ts +9 -2
  89. package/src/calls/call-setup-flow.ts +0 -1
  90. package/src/calls/media-stream-stt-session.ts +15 -0
  91. package/src/calls/voice-session-bridge.ts +71 -16
  92. package/src/calls/voice-triage-escalate.ts +104 -2
  93. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  94. package/src/channels/config.ts +13 -0
  95. package/src/channels/plugin-channel-declarations.ts +108 -0
  96. package/src/channels/types.ts +30 -0
  97. package/src/cli/AGENTS.md +5 -2
  98. package/src/cli/commands/credentials.help.ts +2 -2
  99. package/src/cli/commands/inference-providers.ts +1 -1
  100. package/src/cli/commands/mcp.help.ts +13 -4
  101. package/src/cli/commands/mcp.ts +9 -0
  102. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  103. package/src/cli/commands/memory/index.help.ts +43 -1
  104. package/src/cli/commands/memory/memory-v3.ts +64 -0
  105. package/src/cli/commands/stt.help.ts +27 -2
  106. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  107. package/src/cli/lib/bundled-marketplace.json +13 -0
  108. package/src/cli/lib/upgrade-plugin.ts +42 -0
  109. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  110. package/src/config/__tests__/default-provider.test.ts +6 -1
  111. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  112. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  113. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  114. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  115. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  116. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  117. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  118. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  119. package/src/config/call-site-defaults.ts +4 -5
  120. package/src/config/default-profile-catalog.ts +82 -11
  121. package/src/config/default-profile-names.ts +4 -1
  122. package/src/config/default-provider-resolution.ts +4 -0
  123. package/src/config/llm-context-resolution.ts +11 -3
  124. package/src/config/llm-resolver.ts +28 -1
  125. package/src/config/profile-materialization.ts +70 -22
  126. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  127. package/src/config/schemas/call-site-catalog.ts +4 -4
  128. package/src/config/schemas/live-voice.ts +57 -23
  129. package/src/config/schemas/llm.ts +59 -32
  130. package/src/config/schemas/mcp.ts +23 -0
  131. package/src/config/schemas/plugin-updates.ts +6 -2
  132. package/src/config/schemas/stt.ts +1 -0
  133. package/src/context/outbound-sanitize.ts +96 -1
  134. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  135. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  136. package/src/daemon/conversation-agent-loop.ts +17 -6
  137. package/src/daemon/conversation-messaging.ts +5 -1
  138. package/src/daemon/conversation-notifiers.ts +9 -1
  139. package/src/daemon/conversation-process.ts +9 -6
  140. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  141. package/src/daemon/conversation-tool-setup.ts +1 -2
  142. package/src/daemon/conversation.ts +48 -0
  143. package/src/daemon/mcp-reload-service.ts +36 -6
  144. package/src/daemon/process-message.ts +13 -3
  145. package/src/daemon/providers-setup.ts +6 -3
  146. package/src/daemon/trust-context-types.ts +29 -0
  147. package/src/daemon/wake-conversation-ops.ts +3 -2
  148. package/src/documents/document-store.ts +138 -5
  149. package/src/hooks/hook-loader.ts +3 -3
  150. package/src/hooks/registry.ts +50 -6
  151. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  152. package/src/inbound/oauth-callback-url.ts +61 -0
  153. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  154. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  155. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  156. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  157. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  158. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  159. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  160. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  161. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  162. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  163. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  164. package/src/live-voice/live-voice-archive.ts +2 -0
  165. package/src/live-voice/live-voice-metrics.ts +57 -32
  166. package/src/live-voice/live-voice-photo.ts +1 -2
  167. package/src/live-voice/live-voice-session.ts +535 -314
  168. package/src/live-voice/progress-narration.ts +277 -0
  169. package/src/live-voice/protocol.ts +21 -1
  170. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  171. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  172. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  173. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  174. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  175. package/src/mcp/client.ts +16 -11
  176. package/src/mcp/effective-config.ts +113 -0
  177. package/src/mcp/manager.ts +11 -6
  178. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  179. package/src/mcp/mcp-oauth-provider.ts +205 -240
  180. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  181. package/src/monitoring/plugin-auto-update.ts +128 -24
  182. package/src/notifications/signal.ts +1 -0
  183. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  184. package/src/permissions/confirmation-guardian-request.ts +2 -2
  185. package/src/permissions/question-guardian-request.test.ts +14 -6
  186. package/src/permissions/question-guardian-request.ts +1 -2
  187. package/src/persistence/attachments-store.ts +8 -1
  188. package/src/persistence/bookmark-crud.ts +3 -7
  189. package/src/persistence/conversation-attention-store.ts +16 -45
  190. package/src/persistence/conversation-crud.ts +33 -4
  191. package/src/persistence/conversation-lineage.ts +9 -0
  192. package/src/persistence/conversation-queries.ts +108 -41
  193. package/src/persistence/delivery-crud.ts +38 -29
  194. package/src/persistence/external-conversation-store.ts +32 -4
  195. package/src/persistence/llm-request-log-store.ts +4 -10
  196. package/src/persistence/llm-usage-store.ts +8 -3
  197. package/src/persistence/message-reads.test.ts +197 -0
  198. package/src/persistence/message-reads.ts +211 -0
  199. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  200. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  201. package/src/persistence/real-user-turn-filter.ts +27 -3
  202. package/src/persistence/steps.ts +9 -0
  203. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  204. package/src/plugin-api/conversation-turn.ts +168 -5
  205. package/src/plugin-api/index.ts +21 -5
  206. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  207. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  208. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  209. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  210. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  211. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  212. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  213. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  214. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  215. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  216. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  217. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  218. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  219. package/src/plugins/defaults/memory/indexer.ts +3 -1
  220. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  221. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  222. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  223. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  224. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  225. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  226. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  227. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  228. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  229. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  230. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  231. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  232. package/src/plugins/defaults/memory/worker.ts +6 -3
  233. package/src/plugins/external-plugin-loader.ts +47 -0
  234. package/src/plugins/mcp-servers.ts +361 -0
  235. package/src/plugins/mtime-cache.ts +23 -49
  236. package/src/plugins/worker-plugin-surface.ts +33 -0
  237. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  238. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  239. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  240. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  241. package/src/providers/call-site-routing.ts +30 -3
  242. package/src/providers/connection-resolution.ts +194 -11
  243. package/src/providers/inference/auth.ts +6 -0
  244. package/src/providers/inference/connection-availability.ts +24 -2
  245. package/src/providers/inference/connections.ts +2 -0
  246. package/src/providers/model-intents.ts +26 -6
  247. package/src/providers/openai/codex-models.ts +2 -1
  248. package/src/providers/provider-send-message.ts +32 -3
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  250. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  251. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  252. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  253. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  254. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  255. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  256. package/src/providers/speech-to-text/resolve.ts +25 -2
  257. package/src/routes/worker.ts +17 -5
  258. package/src/runtime/access-request-helper.ts +9 -12
  259. package/src/runtime/agent-wake.ts +3 -3
  260. package/src/runtime/pre-first-message-gate.ts +4 -0
  261. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  262. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  263. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  264. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  265. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  266. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  267. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  268. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  269. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  270. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  271. package/src/runtime/routes/channel-route-shared.ts +0 -6
  272. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  273. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  274. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  275. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  276. package/src/runtime/routes/default-provider-routes.ts +15 -0
  277. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  278. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  279. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  280. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  281. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  282. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  283. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  284. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  285. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  286. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  287. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  288. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  289. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  291. package/src/runtime/routes/log-export-routes.ts +3 -0
  292. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  293. package/src/runtime/routes/plugins-routes.ts +21 -3
  294. package/src/runtime/routes/stt-routes.ts +31 -25
  295. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  296. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  297. package/src/runtime/routes/user-route-import.ts +108 -0
  298. package/src/schedule/worker.ts +6 -0
  299. package/src/security/oauth2.ts +6 -22
  300. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  301. package/src/stt/__tests__/types.test.ts +94 -0
  302. package/src/stt/daemon-batch-transcriber.ts +10 -0
  303. package/src/stt/stt-stream-session.ts +8 -4
  304. package/src/stt/types.ts +103 -0
  305. package/src/subagent/manager.ts +1 -3
  306. package/src/subagent/types.ts +7 -6
  307. package/src/tools/acp/spawn.test.ts +97 -0
  308. package/src/tools/acp/spawn.ts +32 -0
  309. package/src/tools/document/document-tool.ts +12 -3
  310. package/src/tools/registry.ts +2 -1
  311. package/src/tools/workflows/run-workflow.ts +1 -2
  312. package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
  313. package/src/tts/reasoning-tag-filter.ts +110 -0
  314. package/src/workspace/byok-default-profile-ensure.ts +61 -18
  315. package/src/workspace/custom-profile-ensure.ts +4 -24
  316. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  317. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  318. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  319. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  320. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  321. package/src/workspace/migrations/registry.ts +8 -0
  322. package/src/workspace/provider-commit-message-generator.ts +7 -5
  323. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  324. package/src/live-voice/front-decision.ts +0 -476
@@ -1,102 +1,178 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ /**
2
+ * `runConversationTurn` against the real conversation store, the real agent
3
+ * loop and the real database. The only thing stood in for is the provider's
4
+ * HTTP boundary — `resolveProviderFromConnection` hands back a scripted mock
5
+ * — so everything these tests read back (the `conversations` row, the user
6
+ * message and the metadata stamped on it, the channel binding) is what
7
+ * production wrote, not a stub mirroring what production is believed to do.
8
+ */
9
+ import {
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
2
18
 
19
+ import type { AssistantEvent } from "../api/index.js";
20
+ import { clearAllActiveConversations } from "../daemon/conversation-store.js";
21
+ import {
22
+ conversationMetadataSyncTag,
23
+ SYNC_TAGS,
24
+ } from "../daemon/message-types/sync.js";
3
25
  import {
4
26
  createConversation,
5
- ensureConversationExists,
6
27
  getConversation,
28
+ getMessages,
29
+ parseMessageMetadata,
7
30
  } from "../persistence/conversation-crud.js";
31
+ import { getConversationByKey } from "../persistence/conversation-key-store.js";
8
32
  import {
9
33
  isEchoSuppressedUserMessage,
10
34
  isReplyPushIneligibleUserMessage,
11
35
  } from "../persistence/conversation-types.js";
12
36
  import { getDb } from "../persistence/db-connection.js";
13
37
  import { initializeDb } from "../persistence/db-init.js";
38
+ import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
39
+ import { getBindingByConversation } from "../persistence/external-conversation-store.js";
40
+ import { createConnection } from "../providers/inference/connections.js";
41
+ import * as providerRegistry from "../providers/registry.js";
42
+ import type { Provider, ProviderResponse } from "../providers/types.js";
43
+ import { createMockProvider, textResponse } from "./helpers/mock-provider.js";
44
+ import { setConfig } from "./helpers/set-config.js";
45
+ import { waitFor } from "./helpers/wait-for.js";
14
46
 
15
47
  await initializeDb();
16
48
 
17
- // Capture the list-invalidation calls `runConversationTurn` fires when it
18
- // creates a brand-new conversation row, without pulling in the real sync
19
- // publisher (which reaches for live SSE subscribers).
20
- const listChangedCalls: Array<{ kind: string; conversationId: string }> = [];
21
- mock.module("../runtime/sync/resource-sync-events.js", () => ({
22
- publishConversationListAndMetadataChanged: (
23
- kind: string,
24
- conversationId: string,
25
- ) => {
26
- listChangedCalls.push({ kind, conversationId });
27
- },
28
- }));
29
-
30
- // Stub the heavy machinery: the in-memory conversation build (provider wiring,
31
- // system prompt, history hydration) and the SSE event fan-out. The agent turn
32
- // itself is a no-op — this test only asserts that the `conversations` row is
33
- // persisted before the turn runs. The real `getOrCreateConversation` now
34
- // creates the DB row before hydrating, so the mock mirrors that by calling
35
- // `ensureConversationExists`. The persistence module is intentionally NOT
36
- // mocked so the real `ensureConversationExists` runs against the real DB.
37
- let lastProcessMessageConversationId: string | undefined;
38
- let lastProcessMessageOptions: Record<string, unknown> | undefined;
39
- let lastEnqueueOptions: Record<string, unknown> | undefined;
40
- let conversationIsProcessing = false;
41
- mock.module("../daemon/conversation-store.js", () => ({
42
- getOrCreateConversation: async (
43
- conversationId: string,
44
- options?: { conversationType?: string },
45
- ) => {
46
- if (!getConversation(conversationId)) {
47
- if (options?.conversationType) {
48
- createConversation({
49
- id: conversationId,
50
- conversationType: options.conversationType as
51
- | "standard"
52
- | "background",
53
- });
54
- } else {
55
- ensureConversationExists(conversationId, "vellum");
56
- }
57
- }
58
- return {
59
- abortController: undefined,
60
- isProcessing: () => conversationIsProcessing,
61
- async processMessage(processOptions: Record<string, unknown>) {
62
- // The row must already exist by the time the turn persists its user
63
- // message — record the id so the FK precondition can be asserted.
64
- lastProcessMessageConversationId = conversationId;
65
- lastProcessMessageOptions = processOptions;
66
- return "user-message-id";
67
- },
68
- enqueueMessage: (enqueueOptions: Record<string, unknown>) => {
69
- lastEnqueueOptions = enqueueOptions;
70
- return { rejected: false };
71
- },
72
- };
49
+ // A conversation is built against the default provider, which resolves from
50
+ // config through a connection row. Both are seeded for real so the store takes
51
+ // the same path it takes in the daemon; only the provider the connection
52
+ // resolves to is scripted.
53
+ setConfig("llm", {
54
+ profiles: {
55
+ test: {
56
+ provider: "anthropic",
57
+ provider_connection: "test-conn",
58
+ model: "claude-opus-4-6",
59
+ },
73
60
  },
74
- }));
61
+ activeProfile: "test",
62
+ });
63
+ setConfig("memory", { enabled: false, v2: { enabled: false } });
64
+
65
+ createConnection(getDb(), {
66
+ name: "test-conn",
67
+ provider: "anthropic",
68
+ auth: { type: "api_key", credential: "credential/test/api_key" },
69
+ });
70
+
71
+ // The SSE fan-out is the one collaborator with nowhere to write in a test —
72
+ // every publish the turn makes (including the list invalidations asserted
73
+ // below, which reach clients through the real `resource-sync-events`) funnels
74
+ // through here.
75
+ const broadcasts: AssistantEvent[] = [];
76
+ mock.module(
77
+ "../runtime/assistant-event-hub.js",
78
+ (): Partial<typeof import("../runtime/assistant-event-hub.js")> => ({
79
+ broadcastMessage: (msg: AssistantEvent) => {
80
+ broadcasts.push(msg);
81
+ },
82
+ }),
83
+ );
75
84
 
76
- mock.module("../runtime/assistant-event-hub.js", () => ({
77
- broadcastMessage: () => {},
78
- }));
85
+ const { provider: scriptedProvider } = createMockProvider([
86
+ textResponse("scripted reply"),
87
+ ]);
79
88
 
80
- mock.module("../providers/media-resolve.js", () => ({
81
- resolveMediaSourceData: () => null,
82
- }));
89
+ let gate: Promise<void> | undefined;
90
+ let releaseGate: (() => void) | undefined;
91
+
92
+ /**
93
+ * Park the next turn inside the provider call until released, so a second turn
94
+ * arrives while the conversation is genuinely processing rather than while a
95
+ * stub reports that it is.
96
+ */
97
+ function holdTheTurnOpen(): void {
98
+ let resolveGate!: () => void;
99
+ gate = new Promise<void>((resolve) => {
100
+ resolveGate = resolve;
101
+ });
102
+ releaseGate = () => {
103
+ gate = undefined;
104
+ releaseGate = undefined;
105
+ resolveGate();
106
+ };
107
+ }
108
+
109
+ // A test that fails before releasing must not leave the next one parked on a
110
+ // gate it never opened.
111
+ afterEach(() => releaseGate?.());
112
+
113
+ const provider: Provider = {
114
+ name: "scripted",
115
+ async sendMessage(messages, options): Promise<ProviderResponse> {
116
+ await gate;
117
+ return scriptedProvider.sendMessage(messages, options);
118
+ },
119
+ };
120
+
121
+ spyOn(providerRegistry, "resolveProviderFromConnection").mockResolvedValue(
122
+ provider,
123
+ );
83
124
 
84
- // Import under test AFTER the mocks are registered so its dynamic imports
85
- // resolve to the stubs above.
86
125
  const { runConversationTurn } =
87
126
  await import("../plugin-api/conversation-turn.js");
88
127
 
128
+ /**
129
+ * The list-level invalidations a turn published, as the tag sets clients
130
+ * consume. Only a shape-changing reason carries the list umbrella tag, so a
131
+ * reason that merely edits an existing row (a generated title) is excluded —
132
+ * these are the announcements that a conversation appeared or vanished.
133
+ */
134
+ function listInvalidations(): string[][] {
135
+ return broadcasts
136
+ .filter((msg) => msg.type === "sync_changed")
137
+ .map((msg) => msg.tags)
138
+ .filter((tags) => tags.includes(SYNC_TAGS.conversationsList));
139
+ }
140
+
141
+ /** The tag set `publishConversationListAndMetadataChanged` emits for a new row. */
142
+ function creationTags(conversationId: string): string[] {
143
+ return [
144
+ SYNC_TAGS.conversationsList,
145
+ conversationMetadataSyncTag(conversationId),
146
+ ];
147
+ }
148
+
149
+ /** User message rows of a conversation, oldest first. */
150
+ function userRows(conversationId: string) {
151
+ return getMessages(conversationId).filter((row) => row.role === "user");
152
+ }
153
+
154
+ /** The metadata a turn persisted on a message row, parsed as consumers read it. */
155
+ function metadataOf(
156
+ row: { metadata: string | null } | undefined,
157
+ ): Record<string, unknown> | undefined {
158
+ return parseMessageMetadata(row?.metadata ?? null);
159
+ }
160
+
161
+ function resetDb(): void {
162
+ // In-memory conversations outlive the rows they were hydrated from, and a
163
+ // reused id would otherwise hand the next test a Conversation whose
164
+ // `conversations` row this wipe just deleted.
165
+ clearAllActiveConversations();
166
+ const db = getDb();
167
+ db.run("DELETE FROM messages");
168
+ db.run("DELETE FROM external_conversation_bindings");
169
+ db.run("DELETE FROM conversation_keys");
170
+ db.run("DELETE FROM conversations");
171
+ broadcasts.length = 0;
172
+ }
173
+
89
174
  describe("runConversationTurn persistence", () => {
90
- beforeEach(() => {
91
- const db = getDb();
92
- db.run("DELETE FROM messages");
93
- db.run("DELETE FROM conversations");
94
- listChangedCalls.length = 0;
95
- lastProcessMessageConversationId = undefined;
96
- lastProcessMessageOptions = undefined;
97
- lastEnqueueOptions = undefined;
98
- conversationIsProcessing = false;
99
- });
175
+ beforeEach(resetDb);
100
176
 
101
177
  test("persists a conversations row for a freshly-minted conversation", async () => {
102
178
  const result = await runConversationTurn({
@@ -104,16 +180,20 @@ describe("runConversationTurn persistence", () => {
104
180
  });
105
181
 
106
182
  // The row exists on disk — not just as an in-memory Conversation object —
107
- // so the user-message persist inside the turn has its FK target.
108
- const row = getConversation(result.conversationId);
109
- expect(row?.id).toBe(result.conversationId);
110
- expect(lastProcessMessageConversationId).toBe(result.conversationId);
183
+ // and the user message the turn persisted found its FK target there.
184
+ expect(getConversation(result.conversationId)?.id).toBe(
185
+ result.conversationId,
186
+ );
187
+ expect(userRows(result.conversationId).map((row) => row.id)).toEqual([
188
+ result.userMessageId,
189
+ ]);
111
190
 
112
191
  // Siblings/sidebars are told about the new conversation, mirroring the
113
192
  // send-message route.
114
- expect(listChangedCalls).toEqual([
115
- { kind: "created", conversationId: result.conversationId },
116
- ]);
193
+ expect(listInvalidations()).toEqual([creationTags(result.conversationId)]);
194
+ expect(
195
+ broadcasts.filter((msg) => msg.type === "conversation_list_invalidated"),
196
+ ).toEqual([{ type: "conversation_list_invalidated", reason: "created" }]);
117
197
  });
118
198
 
119
199
  test("adopts a caller-supplied conversation id verbatim when no row exists", async () => {
@@ -126,12 +206,12 @@ describe("runConversationTurn persistence", () => {
126
206
 
127
207
  expect(result.conversationId).toBe(conversationId);
128
208
  expect(getConversation(conversationId)?.id).toBe(conversationId);
129
- expect(listChangedCalls).toEqual([{ kind: "created", conversationId }]);
209
+ expect(listInvalidations()).toEqual([creationTags(conversationId)]);
130
210
  });
131
211
 
132
212
  test("is a no-op for an already-persisted conversation row", async () => {
133
213
  const existing = createConversation({ title: "already here" });
134
- listChangedCalls.length = 0;
214
+ broadcasts.length = 0;
135
215
 
136
216
  const result = await runConversationTurn({
137
217
  conversationId: existing.id,
@@ -141,7 +221,7 @@ describe("runConversationTurn persistence", () => {
141
221
  expect(result.conversationId).toBe(existing.id);
142
222
  // Row is untouched and no duplicate "created" invalidation fires.
143
223
  expect(getConversation(existing.id)?.title).toBe("already here");
144
- expect(listChangedCalls).toEqual([]);
224
+ expect(listInvalidations()).toEqual([]);
145
225
  });
146
226
  });
147
227
 
@@ -151,28 +231,24 @@ describe("runConversationTurn persistence", () => {
151
231
  // eligibility predicate's verdict on the stamped metadata, so the marker and
152
232
  // the gate that reads it cannot drift apart.
153
233
  describe("runConversationTurn provenance", () => {
154
- beforeEach(() => {
155
- const db = getDb();
156
- db.run("DELETE FROM messages");
157
- db.run("DELETE FROM conversations");
158
- lastProcessMessageOptions = undefined;
159
- lastEnqueueOptions = undefined;
160
- conversationIsProcessing = false;
161
- });
234
+ beforeEach(resetDb);
162
235
 
163
236
  test("stamps the initiating row automated so its reply raises no push", async () => {
164
237
  const existing = createConversation({ title: "standard conversation" });
165
238
 
166
- await runConversationTurn({
239
+ const result = await runConversationTurn({
167
240
  conversationId: existing.id,
168
241
  content: [{ type: "text", text: "transcript excerpt" }],
169
242
  });
170
243
 
171
- const metadata = lastProcessMessageOptions?.metadata as Record<
172
- string,
173
- unknown
174
- >;
175
- expect(metadata).toEqual({ automated: true });
244
+ // The scripted reply came back, so the marker below was stamped by a turn
245
+ // that ran rather than by one that fell over into an error row.
246
+ expect(result.content).toMatchObject([
247
+ { type: "text", text: "scripted reply" },
248
+ ]);
249
+
250
+ const metadata = metadataOf(userRows(existing.id)[0]);
251
+ expect(metadata).toMatchObject({ automated: true });
176
252
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
177
253
  // Not an echo-suppression marker: the row still renders in the transcript.
178
254
  expect(isEchoSuppressedUserMessage(metadata)).toBe(false);
@@ -180,16 +256,269 @@ describe("runConversationTurn provenance", () => {
180
256
 
181
257
  test("stamps the same marker on a turn queued behind a busy conversation", async () => {
182
258
  const existing = createConversation({ title: "busy conversation" });
183
- conversationIsProcessing = true;
259
+ holdTheTurnOpen();
260
+
261
+ const inFlight = runConversationTurn({
262
+ conversationId: existing.id,
263
+ content: [{ type: "text", text: "first excerpt" }],
264
+ });
265
+ await waitFor(() => userRows(existing.id).length === 1, {
266
+ timeoutMs: 5_000,
267
+ message: "the first turn never reached the provider",
268
+ });
184
269
 
185
270
  const result = await runConversationTurn({
186
271
  conversationId: existing.id,
187
272
  content: [{ type: "text", text: "transcript excerpt" }],
188
273
  });
189
-
190
274
  expect(result.queued).toBe(true);
191
- const metadata = lastEnqueueOptions?.metadata as Record<string, unknown>;
192
- expect(metadata).toEqual({ automated: true });
275
+
276
+ // The queue drains after the in-flight turn releases, and the marker has
277
+ // to survive that trip: it is stamped now but read off the persisted row
278
+ // much later.
279
+ releaseGate?.();
280
+ await inFlight;
281
+ await waitFor(() => userRows(existing.id).length === 2, {
282
+ timeoutMs: 5_000,
283
+ message: "the queued turn never drained",
284
+ });
285
+
286
+ const metadata = metadataOf(userRows(existing.id)[1]);
287
+ expect(metadata).toMatchObject({ automated: true });
193
288
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
289
+ }, 20_000);
290
+ });
291
+
292
+ /**
293
+ * Addressing a turn by chat rather than by conversation id.
294
+ *
295
+ * The point of these is not that the turn runs (that is the same code path
296
+ * either way) but that the conversation it runs in is the one the rest of
297
+ * the assistant already addresses by those coordinates. A caller keeping its
298
+ * own chat-to-conversation map would pass every test above and still be
299
+ * invisible to conversation reset, to the deny lanes, and to the channel
300
+ * metadata the conversation list renders.
301
+ */
302
+ describe("runConversationTurn channel binding", () => {
303
+ const CHANNEL = {
304
+ sourceChannel: "plugin" as const,
305
+ externalChatId: "imessage:+12025550142",
306
+ displayName: "Ada",
307
+ };
308
+
309
+ beforeEach(resetDb);
310
+
311
+ test("resolves the chat to the conversation inbound would have used", async () => {
312
+ const result = await runConversationTurn({
313
+ channel: CHANNEL,
314
+ content: [{ type: "text", text: "hello" }],
315
+ });
316
+
317
+ // The public name for this conversation, which is what reset and the deny
318
+ // lanes address it by.
319
+ expect(
320
+ getConversationByKey(
321
+ buildScopedConversationKey(
322
+ CHANNEL.sourceChannel,
323
+ CHANNEL.externalChatId,
324
+ ),
325
+ )?.conversationId,
326
+ ).toBe(result.conversationId);
327
+ });
328
+
329
+ test("keeps the same chat in the same conversation across turns", async () => {
330
+ const first = await runConversationTurn({
331
+ channel: CHANNEL,
332
+ content: [{ type: "text", text: "hello" }],
333
+ });
334
+ const second = await runConversationTurn({
335
+ channel: CHANNEL,
336
+ content: [{ type: "text", text: "still me" }],
337
+ });
338
+
339
+ expect(second.conversationId).toBe(first.conversationId);
340
+ // Only the first turn minted a conversation.
341
+ expect(listInvalidations()).toEqual([creationTags(first.conversationId)]);
342
+ });
343
+
344
+ test("gives a different chat its own conversation", async () => {
345
+ const ada = await runConversationTurn({
346
+ channel: CHANNEL,
347
+ content: [{ type: "text", text: "hello" }],
348
+ });
349
+ const grace = await runConversationTurn({
350
+ channel: { ...CHANNEL, externalChatId: "imessage:+12025550188" },
351
+ content: [{ type: "text", text: "hello" }],
352
+ });
353
+
354
+ expect(grace.conversationId).not.toBe(ada.conversationId);
355
+ });
356
+
357
+ test("records the channel metadata the conversation list reads", async () => {
358
+ const result = await runConversationTurn({
359
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
360
+ content: [{ type: "text", text: "hello" }],
361
+ });
362
+
363
+ const binding = getBindingByConversation(result.conversationId);
364
+ expect(binding).toMatchObject({
365
+ sourceChannel: "plugin",
366
+ externalChatId: CHANNEL.externalChatId,
367
+ externalUserId: "+12025550142",
368
+ displayName: "Ada",
369
+ });
370
+ });
371
+
372
+ test("follows the vendor when a display name changes", async () => {
373
+ await runConversationTurn({
374
+ channel: CHANNEL,
375
+ content: [{ type: "text", text: "hello" }],
376
+ });
377
+ const result = await runConversationTurn({
378
+ channel: { ...CHANNEL, displayName: "Ada L." },
379
+ content: [{ type: "text", text: "renamed" }],
380
+ });
381
+
382
+ expect(getBindingByConversation(result.conversationId)?.displayName).toBe(
383
+ "Ada L.",
384
+ );
385
+ });
386
+
387
+ test("runs the turn on the channel it was addressed by", async () => {
388
+ // Runtime assembly reads the per-turn channel first and only then the
389
+ // conversation's origin, so a turn into a conversation whose origin was
390
+ // never recorded would otherwise run as `vellum`. That is the wrong
391
+ // channel for the message rows and for the permission cascade the tools
392
+ // are approved against.
393
+ const result = await runConversationTurn({
394
+ channel: CHANNEL,
395
+ content: [{ type: "text", text: "hello" }],
396
+ });
397
+
398
+ const rows = getMessages(result.conversationId);
399
+ expect(metadataOf(rows[0])).toMatchObject({
400
+ userMessageChannel: "plugin",
401
+ assistantMessageChannel: "plugin",
402
+ });
403
+ expect(rows[1].role).toBe("assistant");
404
+ expect(metadataOf(rows[1])).toMatchObject({
405
+ userMessageChannel: "plugin",
406
+ assistantMessageChannel: "plugin",
407
+ });
408
+ });
409
+
410
+ test("a queued turn carries its own channel rather than inheriting one", async () => {
411
+ // The drain happens after this call returns and reads the channel off the
412
+ // queued message, falling back to whichever turn was in flight. Here that
413
+ // fallback would be `vellum` — someone else's channel on a conversation
414
+ // shared with another channel.
415
+ const conversationId = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
416
+ holdTheTurnOpen();
417
+
418
+ const inFlight = runConversationTurn({
419
+ conversationId,
420
+ content: [{ type: "text", text: "native turn" }],
421
+ });
422
+ await waitFor(() => userRows(conversationId).length === 1, {
423
+ timeoutMs: 5_000,
424
+ message: "the first turn never reached the provider",
425
+ });
426
+
427
+ const result = await runConversationTurn({
428
+ conversationId,
429
+ channel: CHANNEL,
430
+ content: [{ type: "text", text: "hello" }],
431
+ });
432
+ expect(result.queued).toBe(true);
433
+
434
+ releaseGate?.();
435
+ await inFlight;
436
+ await waitFor(() => userRows(conversationId).length === 2, {
437
+ timeoutMs: 5_000,
438
+ message: "the queued turn never drained",
439
+ });
440
+
441
+ expect(metadataOf(userRows(conversationId)[1])).toMatchObject({
442
+ userMessageChannel: "plugin",
443
+ assistantMessageChannel: "plugin",
444
+ });
445
+ }, 20_000);
446
+
447
+ test("leaves the channel unset when the turn names none", async () => {
448
+ // Clearing rather than leaving whatever the last turn set: a stale
449
+ // context would report this turn on a channel it has nothing to do with.
450
+ await runConversationTurn({
451
+ channel: CHANNEL,
452
+ content: [{ type: "text", text: "hello" }],
453
+ });
454
+
455
+ const result = await runConversationTurn({
456
+ content: [{ type: "text", text: "hello" }],
457
+ });
458
+
459
+ const rows = getMessages(result.conversationId);
460
+ const userMetadata = metadataOf(rows[0]);
461
+ expect(userMetadata).toMatchObject({ automated: true });
462
+ expect(userMetadata?.userMessageChannel).toBeUndefined();
463
+ // The conversation carries no origin either, so the turn falls back to the
464
+ // native channel rather than to the previous turn's.
465
+ expect(metadataOf(rows[1])).toMatchObject({
466
+ userMessageChannel: "vellum",
467
+ assistantMessageChannel: "vellum",
468
+ });
469
+ });
470
+
471
+ test("keeps a known sender when a later turn omits it", async () => {
472
+ // A plugin that knows only the chat coordinates this time is silent about
473
+ // the sender, not asserting it has none. Erasing the stored name on that
474
+ // silence drops it from the conversation and session APIs.
475
+ await runConversationTurn({
476
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
477
+ content: [{ type: "text", text: "hello" }],
478
+ });
479
+
480
+ const result = await runConversationTurn({
481
+ channel: {
482
+ sourceChannel: CHANNEL.sourceChannel,
483
+ externalChatId: CHANNEL.externalChatId,
484
+ },
485
+ content: [{ type: "text", text: "still me" }],
486
+ });
487
+
488
+ expect(getBindingByConversation(result.conversationId)).toMatchObject({
489
+ externalUserId: "+12025550142",
490
+ displayName: "Ada",
491
+ });
492
+ });
493
+
494
+ test("clears a sender the caller explicitly says is gone", async () => {
495
+ await runConversationTurn({
496
+ channel: CHANNEL,
497
+ content: [{ type: "text", text: "hello" }],
498
+ });
499
+
500
+ const result = await runConversationTurn({
501
+ channel: { ...CHANNEL, displayName: null },
502
+ content: [{ type: "text", text: "anonymous now" }],
503
+ });
504
+
505
+ expect(
506
+ getBindingByConversation(result.conversationId)?.displayName,
507
+ ).toBeNull();
508
+ });
509
+
510
+ test("an explicit conversation id wins over the address", async () => {
511
+ // Two different conversations were named. Re-resolving would overrule the
512
+ // caller, so the explicit one is honoured and nothing is bound behind it.
513
+ const existing = createConversation({ title: "chosen" });
514
+
515
+ const result = await runConversationTurn({
516
+ conversationId: existing.id,
517
+ channel: CHANNEL,
518
+ content: [{ type: "text", text: "hello" }],
519
+ });
520
+
521
+ expect(result.conversationId).toBe(existing.id);
522
+ expect(getBindingByConversation(existing.id)).toBeNull();
194
523
  });
195
524
  });
@@ -375,19 +375,24 @@ describe("scoped-approval-grants / expiry", () => {
375
375
  });
376
376
 
377
377
  test("already-consumed grants are not affected by expiry sweep", () => {
378
- const _pastExpiry = Date.now() - 1_000;
378
+ const expiresAt = Date.now() + 60_000;
379
379
  createScopedApprovalGrant(
380
380
  grantParams({
381
381
  scopeMode: "request_id",
382
382
  requestId: "req-consumed",
383
- expiresAt: Date.now() + 60_000,
383
+ expiresAt,
384
384
  }),
385
385
  );
386
- consumeScopedApprovalGrantByRequestId("req-consumed", "c1");
386
+ const consumed = consumeScopedApprovalGrantByRequestId(
387
+ "req-consumed",
388
+ "c1",
389
+ expiresAt - 1,
390
+ );
391
+ expect(consumed.ok).toBe(true);
387
392
 
388
- // Force the expiry time to the past for the consumed grant (simulating time passing)
389
- // The sweep should not touch consumed grants
390
- const count = expireScopedApprovalGrants();
393
+ // Sweep after the grant's expiresAt. Status is consumed, so the active-only
394
+ // expiry update must leave it alone.
395
+ const count = expireScopedApprovalGrants(expiresAt);
391
396
  expect(count).toBe(0);
392
397
  });
393
398
  });
@@ -62,7 +62,6 @@ function makeParams(
62
62
  sourceChannel: "slack" as const,
63
63
  } as any,
64
64
  replyCallbackUrl: undefined,
65
- canonicalAssistantId: "self",
66
65
  ...overrides,
67
66
  };
68
67
  }