@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
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `<channel_capabilities>` must not accumulate one copy per turn, and
3
+ * deduplicating it must not disturb the cross-turn byte-identity that prompt
4
+ * caching depends on (see `prompt-cache-cross-turn-stability.test.ts`, which
5
+ * owns that invariant end to end).
6
+ */
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import {
10
+ dedupeChannelCapabilityBlocks,
11
+ preModelCallSanitize,
12
+ } from "../context/outbound-sanitize.js";
13
+ import type { Message } from "../providers/types.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures, shaped like what runtime injection actually produces: the block is
17
+ // its OWN text content block, prepended to the turn-starting user message by
18
+ // `injectChannelCapabilityContext`, and then frozen into history.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const slackCapabilities = (dynamicUi = false): string =>
22
+ [
23
+ "<channel_capabilities>",
24
+ "channel: slack",
25
+ "dashboard_capable: false",
26
+ `supports_dynamic_ui: ${dynamicUi}`,
27
+ "supports_voice_input: false",
28
+ "",
29
+ "CHANNEL CONSTRAINTS:",
30
+ "- Do NOT reference the dashboard UI, settings panels, or visual preference pickers.",
31
+ '- Do NOT use app_create. Only use ui_show/ui_update for card surfaces with template: "task_progress"; present all other information as text.',
32
+ "- Present information as well-formatted text instead of dynamic UI.",
33
+ "</channel_capabilities>",
34
+ ].join("\n");
35
+
36
+ const CAPS = slackCapabilities();
37
+
38
+ const turnContextBlock = (turn: number): string =>
39
+ [
40
+ "<turn_context>",
41
+ `current_time: 2026-05-21 (Thursday) 10:${String(turn).padStart(2, "0")}:00 -05:00 (America/Chicago)`,
42
+ "interface: slack",
43
+ "trust_class: guardian",
44
+ "</turn_context>",
45
+ ].join("\n");
46
+
47
+ const userTurn = (turn: number, caps: string = CAPS): Message => ({
48
+ role: "user",
49
+ content: [
50
+ { type: "text", text: turnContextBlock(turn) },
51
+ { type: "text", text: caps },
52
+ { type: "text", text: `User message ${turn}.` },
53
+ ],
54
+ });
55
+
56
+ const assistantTurn = (turn: number): Message => ({
57
+ role: "assistant",
58
+ content: [{ type: "text", text: `Reply ${turn}.` }],
59
+ });
60
+
61
+ /** An N-turn conversation, every user row carrying its own injected copy. */
62
+ const conversation = (turns: number): Message[] => {
63
+ const messages: Message[] = [];
64
+ for (let turn = 1; turn <= turns; turn++) {
65
+ messages.push(userTurn(turn));
66
+ if (turn < turns) {
67
+ messages.push(assistantTurn(turn));
68
+ }
69
+ }
70
+ return messages;
71
+ };
72
+
73
+ const allText = (messages: Message[]): string =>
74
+ messages
75
+ .flatMap((m) =>
76
+ m.content.filter((b) => b.type === "text").map((b) => b.text),
77
+ )
78
+ .join("\n");
79
+
80
+ const countOccurrences = (haystack: string, needle: string): number =>
81
+ haystack.split(needle).length - 1;
82
+
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("<channel_capabilities> does not accumulate across a conversation", () => {
86
+ test("a 50-turn conversation carries exactly one copy", () => {
87
+ const raw = conversation(50);
88
+ expect(countOccurrences(allText(raw), "<channel_capabilities>")).toBe(50);
89
+
90
+ const text = allText(preModelCallSanitize(raw));
91
+ expect(countOccurrences(text, "<channel_capabilities>")).toBe(1);
92
+ expect(countOccurrences(text, "CHANNEL CONSTRAINTS:")).toBe(1);
93
+ });
94
+
95
+ test("the surviving copy is the first one, unmodified", () => {
96
+ const sanitized = preModelCallSanitize(conversation(6));
97
+ expect(sanitized[0].content).toEqual(conversation(6)[0].content);
98
+ expect(allText([sanitized[2]])).not.toContain("<channel_capabilities>");
99
+ });
100
+
101
+ test("outbound size stops growing with the repeated block", () => {
102
+ const rawGrowth =
103
+ allText(conversation(45)).length - allText(conversation(5)).length;
104
+ const sanitizedGrowth =
105
+ allText(preModelCallSanitize(conversation(45))).length -
106
+ allText(preModelCallSanitize(conversation(5))).length;
107
+
108
+ // Every extra raw turn pays for a full copy of the block; the deduplicated
109
+ // projection pays nothing for it.
110
+ expect((rawGrowth - sanitizedGrowth) / 40).toBeGreaterThanOrEqual(
111
+ CAPS.length,
112
+ );
113
+ });
114
+
115
+ test("everything else on the row is untouched", () => {
116
+ const sanitized = preModelCallSanitize(conversation(4));
117
+ // `<turn_context>` is deliberately left alone: it carries a fresh
118
+ // timestamp each turn and historical copies are read back by the
119
+ // compactor's tail resolution.
120
+ expect(countOccurrences(allText(sanitized), "<turn_context>")).toBe(4);
121
+ // The dropped block is the only thing that goes: the row keeps its
122
+ // `<turn_context>` and the user's own text, in order.
123
+ expect(sanitized[6].content).toEqual([
124
+ { type: "text", text: turnContextBlock(4) },
125
+ { type: "text", text: "User message 4." },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("cross-turn byte stability", () => {
131
+ test("a message renders identically no matter how many turns follow it", () => {
132
+ // The prompt cache is only readable when turn N's messages recur
133
+ // byte-identically at the same index in turn N+1. Deduplication must
134
+ // therefore depend only on what sits ABOVE a message, never on how far it
135
+ // is from the end.
136
+ const turn2 = preModelCallSanitize(conversation(2));
137
+ const turn20 = preModelCallSanitize(conversation(20));
138
+
139
+ turn2.forEach((message, index) => {
140
+ expect(JSON.stringify(turn20[index])).toBe(JSON.stringify(message));
141
+ });
142
+ });
143
+
144
+ test("the turn-starting message is not treated specially", () => {
145
+ const sanitized = preModelCallSanitize(conversation(3));
146
+ const turnStart = sanitized[sanitized.length - 1];
147
+ // Turn 3 opened this turn and still drops its duplicate copy, which is
148
+ // exactly what keeps it stable once turn 4 arrives.
149
+ expect(allText([turnStart])).not.toContain("<channel_capabilities>");
150
+ });
151
+
152
+ test("is idempotent", () => {
153
+ const once = preModelCallSanitize(conversation(6));
154
+ expect(preModelCallSanitize(once)).toEqual(once);
155
+ });
156
+ });
157
+
158
+ describe("changes and safety", () => {
159
+ test("capabilities that actually change mid-conversation are kept", () => {
160
+ const changed = slackCapabilities(true);
161
+ const history: Message[] = [
162
+ userTurn(1),
163
+ assistantTurn(1),
164
+ userTurn(2),
165
+ assistantTurn(2),
166
+ userTurn(3, changed),
167
+ assistantTurn(3),
168
+ userTurn(4, changed),
169
+ ];
170
+
171
+ const sanitized = dedupeChannelCapabilityBlocks(history);
172
+ const text = allText(sanitized);
173
+ expect(countOccurrences(text, "<channel_capabilities>")).toBe(2);
174
+ expect(text).toContain("supports_dynamic_ui: false");
175
+ expect(text).toContain("supports_dynamic_ui: true");
176
+ // Turn 2 and turn 4 are the repeats and are the ones that go.
177
+ expect(allText([sanitized[2]])).not.toContain("<channel_capabilities>");
178
+ expect(allText([sanitized[6]])).not.toContain("<channel_capabilities>");
179
+ });
180
+
181
+ test("a conversation with a single copy is returned untouched", () => {
182
+ const history = conversation(1);
183
+ expect(dedupeChannelCapabilityBlocks(history)).toBe(history);
184
+ });
185
+
186
+ test("user-authored text that merely mentions the tag is left alone", () => {
187
+ const authored =
188
+ "why does <channel_capabilities> say dashboard_capable: false?";
189
+ const history: Message[] = [
190
+ { role: "user", content: [{ type: "text", text: authored }] },
191
+ assistantTurn(1),
192
+ { role: "user", content: [{ type: "text", text: authored }] },
193
+ ];
194
+
195
+ expect(dedupeChannelCapabilityBlocks(history)).toBe(history);
196
+ });
197
+
198
+ test("a user row made only of the block is never emptied or dropped", () => {
199
+ const history: Message[] = [
200
+ userTurn(1),
201
+ assistantTurn(1),
202
+ { role: "user", content: [{ type: "text", text: CAPS }] },
203
+ assistantTurn(2),
204
+ userTurn(3),
205
+ ];
206
+
207
+ const sanitized = dedupeChannelCapabilityBlocks(history);
208
+ expect(sanitized).toHaveLength(5);
209
+ expect(sanitized[2].content).toEqual(history[2].content);
210
+ // The all-block row is retained, so it becomes the baseline the later
211
+ // duplicate is measured against and turn 3 still drops.
212
+ expect(allText([sanitized[4]])).not.toContain("<channel_capabilities>");
213
+ });
214
+ });
@@ -504,23 +504,23 @@ describe("channel-delivery-store", () => {
504
504
  expect(r1.conversationId).not.toBe(r2.conversationId);
505
505
  });
506
506
 
507
- test("same chat/channel but different assistantId uses different conversations", () => {
508
- const r1 = recordInbound("telegram", "chat-1", "msg-1", {
509
- assistantId: "asst-A",
510
- });
511
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
512
- assistantId: "asst-B",
513
- });
507
+ test("the same chat keeps the same conversation", () => {
508
+ const r1 = recordInbound("telegram", "chat-1", "msg-1");
509
+ const r2 = recordInbound("telegram", "chat-1", "msg-2");
514
510
 
515
- expect(r1.conversationId).not.toBe(r2.conversationId);
511
+ expect(r1.conversationId).toBe(r2.conversationId);
516
512
  });
517
513
 
518
- test("no assistantId defaults to self-scoped key", () => {
519
- const r1 = recordInbound("telegram", "chat-1", "msg-1");
520
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
521
- assistantId: "self",
522
- });
523
- expect(r1.conversationId).toBe(r2.conversationId);
514
+ test("binds under the self-scoped key", () => {
515
+ // The prefix is a stored format, not an internal detail. It reads like a
516
+ // leftover of the assistant id that used to be interpolated into it, so
517
+ // pin it: rows already written under `asst:self:` stop resolving if a
518
+ // later tidy-up drops it.
519
+ const { conversationId } = recordInbound("telegram", "chat-1", "msg-1");
520
+
521
+ expect(
522
+ getConversationByKey("asst:self:telegram:chat-1")?.conversationId,
523
+ ).toBe(conversationId);
524
524
  });
525
525
 
526
526
  test("external bindings allow multiple Slack thread anchors per channel", () => {
@@ -877,13 +877,13 @@ describe("loadConfig startup behavior", () => {
877
877
  raw.llm.defaultProvider,
878
878
  );
879
879
  expect(effective.balanced?.provider).toBe("openai");
880
- expect(effective.balanced?.model).toBe("gpt-5.4-mini");
880
+ expect(effective.balanced?.model).toBe("gpt-5.6-luna");
881
881
  expect(effective.balanced?.provider_connection).toBe("openai-personal");
882
882
  expect(effective.balanced?.source).toBe("managed");
883
883
  expect(effective["quality-optimized"]?.provider).toBe("openai");
884
- expect(effective["quality-optimized"]?.model).toBe("gpt-5.4");
884
+ expect(effective["quality-optimized"]?.model).toBe("gpt-5.6-sol");
885
885
  expect(effective["cost-optimized"]?.provider).toBe("openai");
886
- expect(effective["cost-optimized"]?.model).toBe("gpt-5.4-nano");
886
+ expect(effective["cost-optimized"]?.model).toBe("gpt-5.6-luna");
887
887
  });
888
888
 
889
889
  test("off-platform hatch with a provider outside the named matrix columns resolves through the shared BYOK templates", () => {
@@ -398,11 +398,19 @@ describe("AssistantConfigSchema", () => {
398
398
  }
399
399
  });
400
400
 
401
- test("rejects invalid provider", () => {
401
+ test("parses an unknown provider (read tolerance for entry names)", () => {
402
+ // The provider schema is an open string: a stored value outside the
403
+ // known set parses instead of stripping its profile, and dispatch
404
+ // resolves it as a connection entry name (or fails explainably).
405
+ // Write-time membership is enforced at the profiles route and the
406
+ // config-write choke point.
402
407
  const result = AssistantConfigSchema.safeParse({
403
- llm: { profiles: { custom: { provider: "invalid" } } },
408
+ llm: { profiles: { custom: { provider: "anthropic-work" } } },
404
409
  });
405
- expect(result.success).toBe(false);
410
+ expect(result.success).toBe(true);
411
+ if (result.success) {
412
+ expect(result.data.llm.profiles.custom?.provider).toBe("anthropic-work");
413
+ }
406
414
  });
407
415
 
408
416
  test("accepts the vellum and chatgpt routing identities with a routable model", () => {
@@ -940,8 +948,6 @@ describe("AssistantConfigSchema", () => {
940
948
  endpointDecisionTimeoutMs: 1200,
941
949
  endpointExtensionMs: 1500,
942
950
  endpointMaxExtensions: 2,
943
- ackFirstDeltaTimeoutMs: 2500,
944
- ackGenerationTimeoutMs: 600,
945
951
  progress: {
946
952
  enabled: true,
947
953
  opsThreshold: 3,
@@ -952,6 +958,12 @@ describe("AssistantConfigSchema", () => {
952
958
  generationTimeoutMs: 1500,
953
959
  },
954
960
  },
961
+ flux: {
962
+ turnEnd: { enabled: false },
963
+ model: "flux-general-en",
964
+ eotThreshold: 0.7,
965
+ eotTimeoutMs: 5000,
966
+ },
955
967
  maxSessionDurationSeconds: 1800,
956
968
  archiveAudio: false,
957
969
  });
@@ -976,6 +988,8 @@ describe("AssistantConfigSchema", () => {
976
988
  // Unspecified vad fields still get defaults
977
989
  expect(result.liveVoice.vad.maxTurnDurationMs).toBe(30000);
978
990
  expect(result.liveVoice.maxSessionDurationSeconds).toBe(900);
991
+ // A partial liveVoice override leaves Flux turn-end disabled by default.
992
+ expect(result.liveVoice.flux.turnEnd.enabled).toBe(false);
979
993
  });
980
994
 
981
995
  test("accepts a liveVoice.vad.bargeInMinSpeechMs of 0 (guard disabled)", () => {
@@ -2340,16 +2354,17 @@ describe("loadConfig with schema validation", () => {
2340
2354
  expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096);
2341
2355
  });
2342
2356
 
2343
- test("falls back to default for invalid provider", () => {
2344
- // Leaf-deletion recovery: the invalid provider leaf is stripped and the
2345
- // rest of the profile survives.
2357
+ test("keeps an unknown provider on load (read tolerance for entry names)", () => {
2358
+ // The open provider schema keeps the whole profile: an unknown value is
2359
+ // resolved as a connection entry name at dispatch (or fails there
2360
+ // explainably) rather than being leaf-stripped on read.
2346
2361
  writeConfig({
2347
2362
  llm: {
2348
- profiles: { custom: { provider: "invalid-provider", model: "gpt-4" } },
2363
+ profiles: { custom: { provider: "unknown-provider", model: "gpt-4" } },
2349
2364
  },
2350
2365
  });
2351
2366
  const config = loadConfig();
2352
- expect(config.llm.profiles.custom?.provider).toBeUndefined();
2367
+ expect(config.llm.profiles.custom?.provider).toBe("unknown-provider");
2353
2368
  expect(config.llm.profiles.custom?.model).toBe("gpt-4");
2354
2369
  });
2355
2370
 
@@ -325,6 +325,8 @@ mock.module("../persistence/llm-request-log-store.js", () => ({
325
325
 
326
326
  import type { Conversation } from "../daemon/conversation.js";
327
327
  import { runAgentLoopImpl } from "../daemon/conversation-agent-loop.js";
328
+ import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
329
+ import { asConversation } from "./helpers/mock-conversation.js";
328
330
 
329
331
  // ── Test helpers ─────────────────────────────────────────────────────
330
332
 
@@ -363,7 +365,7 @@ function makeCtx(
363
365
  ];
364
366
  };
365
367
 
366
- return {
368
+ return asConversation({
367
369
  conversationId: "test-conv",
368
370
  messages: [
369
371
  { role: "user", content: [{ type: "text", text: "Hello" }] },
@@ -433,12 +435,7 @@ function makeCtx(
433
435
  skillProjectionCache:
434
436
  new Map() as unknown as Conversation["skillProjectionCache"],
435
437
 
436
- usageStats: {
437
- totalInputTokens: 0,
438
- totalOutputTokens: 0,
439
- totalEstimatedCost: 0,
440
- model: "",
441
- },
438
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
442
439
  turnCount: 0,
443
440
 
444
441
  lastAssistantAttachments: [],
@@ -456,12 +453,12 @@ function makeCtx(
456
453
  getQueueDepth: () => 0,
457
454
  hasQueuedMessages: () => false,
458
455
  canHandoffAtCheckpoint: () => false,
459
- drainQueue: (_reason?: string) => {},
456
+ drainQueue: async (_reason?: QueueDrainReason) => {},
460
457
  // Forwards to drainQueue so tests that spy the drain observe the agent
461
458
  // loop's post-turn kick through the guarded entry point.
462
459
  kickDrainQueue(
463
- this: { drainQueue: (reason?: string) => unknown },
464
- reason: string = "loop_complete",
460
+ this: { drainQueue: (reason?: QueueDrainReason) => Promise<void> },
461
+ reason: QueueDrainReason = "loop_complete",
465
462
  _origin?: string,
466
463
  ) {
467
464
  return this.drainQueue(reason);
@@ -493,7 +490,7 @@ function makeCtx(
493
490
  } as unknown as Conversation["graphMemory"],
494
491
 
495
492
  ...overrides,
496
- } as unknown as Conversation;
493
+ });
497
494
  }
498
495
 
499
496
  // ── Tests ────────────────────────────────────────────────────────────
@@ -472,6 +472,8 @@ mock.module("../memory/archive-store.js", () => ({
472
472
  import { AgentLoop } from "../agent/loop.js";
473
473
  import type { Conversation } from "../daemon/conversation.js";
474
474
  import { runAgentLoopImpl } from "../daemon/conversation-agent-loop.js";
475
+ import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
476
+ import { asConversation } from "./helpers/mock-conversation.js";
475
477
  import {
476
478
  createMockProvider,
477
479
  type ScriptedResponse,
@@ -519,7 +521,7 @@ function makeCtx(
519
521
  toolExecutor,
520
522
  });
521
523
 
522
- const ctx = {
524
+ const ctx = asConversation({
523
525
  conversationId: "test-conv",
524
526
  messages: [
525
527
  { role: "user", content: [{ type: "text", text: "Hello" }] },
@@ -572,12 +574,7 @@ function makeCtx(
572
574
  skillProjectionCache:
573
575
  new Map() as unknown as Conversation["skillProjectionCache"],
574
576
 
575
- usageStats: {
576
- totalInputTokens: 0,
577
- totalOutputTokens: 0,
578
- totalEstimatedCost: 0,
579
- model: "",
580
- },
577
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
581
578
  turnCount: 0,
582
579
 
583
580
  lastAssistantAttachments: [],
@@ -595,12 +592,12 @@ function makeCtx(
595
592
  getQueueDepth: () => 0,
596
593
  hasQueuedMessages: () => false,
597
594
  canHandoffAtCheckpoint: () => false,
598
- drainQueue: (_reason?: string) => {},
595
+ drainQueue: async (_reason?: QueueDrainReason) => {},
599
596
  // Forwards to drainQueue so tests that spy the drain observe the agent
600
597
  // loop's post-turn kick through the guarded entry point.
601
598
  kickDrainQueue(
602
- this: { drainQueue: (reason?: string) => unknown },
603
- reason: string = "loop_complete",
599
+ this: { drainQueue: (reason?: QueueDrainReason) => Promise<void> },
600
+ reason: QueueDrainReason = "loop_complete",
604
601
  _origin?: string,
605
602
  ) {
606
603
  return this.drainQueue(reason);
@@ -632,7 +629,7 @@ function makeCtx(
632
629
  } as unknown as Conversation["graphMemory"],
633
630
 
634
631
  ...ctxOverrides,
635
- } as unknown as Conversation;
632
+ });
636
633
  // The convergence driver resolves the turn-scoped overflow ladder off the
637
634
  // manager; give every fake manager the ladder methods unless a test supplied
638
635
  // its own.
@@ -268,7 +268,18 @@ const setConversationHistoryStrippedAtMock = mock(
268
268
  const updateConversationSlackContextWatermarkMock = mock(
269
269
  (_conversationId: string, _watermarkTs: string, _compactedAt?: number) => {},
270
270
  );
271
- let mockConversationRow: Record<string, unknown> = {
271
+ interface MockConversationRow {
272
+ conversationType?: string;
273
+ source?: string;
274
+ contextSummary?: string | null;
275
+ contextCompactedMessageCount?: number;
276
+ contextCompactedAt?: number | null;
277
+ slackContextCompactionWatermarkTs?: string | null;
278
+ lastNotifiedInferenceProfile?: string | null;
279
+ processingStartedAt?: string | null;
280
+ [key: string]: unknown;
281
+ }
282
+ let mockConversationRow: MockConversationRow = {
272
283
  id: "conv-1",
273
284
  createdAt: 1_700_000_000_000,
274
285
  contextSummary: null,
@@ -675,7 +686,9 @@ import {
675
686
  applyCompactionResult,
676
687
  runAgentLoopImpl,
677
688
  } from "../daemon/conversation-agent-loop.js";
689
+ import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
678
690
  import { settleTurnTail } from "../daemon/turn-tail-chain.js";
691
+ import { asConversation } from "./helpers/mock-conversation.js";
679
692
  import {
680
693
  createMockProvider,
681
694
  type ScriptedResponse,
@@ -731,7 +744,7 @@ function makeCtx(
731
744
  toolExecutor,
732
745
  });
733
746
 
734
- const ctx = {
747
+ const ctx = asConversation({
735
748
  conversationId: "test-conv",
736
749
  messages: [
737
750
  { role: "user", content: [{ type: "text", text: "Hello" }] },
@@ -791,12 +804,7 @@ function makeCtx(
791
804
  skillProjectionCache:
792
805
  new Map() as unknown as Conversation["skillProjectionCache"],
793
806
 
794
- usageStats: {
795
- totalInputTokens: 0,
796
- totalOutputTokens: 0,
797
- totalEstimatedCost: 0,
798
- model: "",
799
- },
807
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
800
808
  turnCount: 0,
801
809
 
802
810
  lastAssistantAttachments: [],
@@ -814,12 +822,12 @@ function makeCtx(
814
822
  getQueueDepth: () => 0,
815
823
  hasQueuedMessages: () => false,
816
824
  canHandoffAtCheckpoint: () => false,
817
- drainQueue: (_reason?: string) => {},
825
+ drainQueue: async (_reason?: QueueDrainReason) => {},
818
826
  // Forwards to drainQueue so tests that spy the drain observe the agent
819
827
  // loop's post-turn kick through the guarded entry point.
820
828
  kickDrainQueue(
821
- this: { drainQueue: (reason?: string) => unknown },
822
- reason: string = "loop_complete",
829
+ this: { drainQueue: (reason?: QueueDrainReason) => Promise<void> },
830
+ reason: QueueDrainReason = "loop_complete",
823
831
  _origin?: string,
824
832
  ) {
825
833
  return this.drainQueue(reason);
@@ -851,7 +859,7 @@ function makeCtx(
851
859
  } as unknown as Conversation["graphMemory"],
852
860
 
853
861
  ...ctxOverrides,
854
- } as unknown as Conversation;
862
+ });
855
863
  // Reactive overflow recovery resolves the turn-scoped reduction ladder off
856
864
  // the manager; give every fake manager the ladder methods unless a test
857
865
  // supplied its own.
@@ -1795,7 +1803,7 @@ describe("session-agent-loop", () => {
1795
1803
  ],
1796
1804
  toolExecutor: async () => ({ content: "file content", isError: false }),
1797
1805
  canHandoffAtCheckpoint: () => true,
1798
- } as unknown as Partial<Conversation>);
1806
+ });
1799
1807
 
1800
1808
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1801
1809
 
@@ -1826,7 +1834,7 @@ describe("session-agent-loop", () => {
1826
1834
  ],
1827
1835
  toolExecutor: async () => ({ content: "content", isError: false }),
1828
1836
  canHandoffAtCheckpoint: () => false,
1829
- } as unknown as Partial<Conversation>);
1837
+ });
1830
1838
 
1831
1839
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1832
1840
 
@@ -1975,13 +1983,13 @@ describe("session-agent-loop", () => {
1975
1983
 
1976
1984
  test("drains queue after completion", async () => {
1977
1985
  // GIVEN a real loop that answers in a single text turn
1978
- let drainReason: string | undefined;
1986
+ let drainReason: QueueDrainReason | undefined;
1979
1987
  const ctx = makeCtx({
1980
1988
  providerResponses: [textResponse("ok")],
1981
- drainQueue: (reason: string) => {
1989
+ drainQueue: async (reason?: QueueDrainReason) => {
1982
1990
  drainReason = reason;
1983
1991
  },
1984
- } as unknown as Partial<Conversation>);
1992
+ });
1985
1993
 
1986
1994
  // WHEN the orchestrator runs the turn to completion
1987
1995
  await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
@@ -2018,10 +2026,10 @@ describe("session-agent-loop", () => {
2018
2026
  abortController,
2019
2027
  // Fire the watchdog quickly instead of the ~45s production default.
2020
2028
  abortWatchdogMs: 30,
2021
- drainQueue: (reason: string) => {
2029
+ drainQueue: async (reason?: QueueDrainReason) => {
2022
2030
  drainReason = reason;
2023
2031
  },
2024
- } as unknown as Partial<Conversation>);
2032
+ });
2025
2033
 
2026
2034
  try {
2027
2035
  // WHEN the orchestrator runs the turn
@@ -2520,7 +2528,7 @@ describe("session-agent-loop", () => {
2520
2528
  },
2521
2529
  ],
2522
2530
  toolExecutor: async () => ({ content: "file content", isError: false }),
2523
- } as unknown as Partial<Conversation>);
2531
+ });
2524
2532
 
2525
2533
  await runAgentLoopImpl(ctx, "hello", "msg-user-daily", () => {});
2526
2534
 
@@ -832,3 +832,66 @@ describe("conversation-attention-store", () => {
832
832
  });
833
833
  });
834
834
  });
835
+
836
+ describe("attention watermarks and streaming rows", () => {
837
+ test("state init from an upgraded database anchors on the latest finalized reply", () => {
838
+ clearTables();
839
+ ensureConversation("conv-1");
840
+ insertAssistantMessage("conv-1", "msg-done", 1_000);
841
+ getDb()
842
+ .insert(messages)
843
+ .values({
844
+ id: "msg-streaming",
845
+ conversationId: "conv-1",
846
+ role: "assistant",
847
+ content: "still being written",
848
+ createdAt: 2_000,
849
+ metadata: null,
850
+ finalized: 0,
851
+ })
852
+ .run();
853
+
854
+ // No attention row exists, so the seen signal initializes state from the
855
+ // messages table. The streaming row is newest but is not a reply yet;
856
+ // anchoring the watermark on it would classify the finished reply as
857
+ // already seen the moment it completes.
858
+ recordConversationSeenSignal({
859
+ conversationId: "conv-1",
860
+ sourceChannel: "vellum",
861
+ signalType: "macos_conversation_opened",
862
+ confidence: "explicit",
863
+ source: "desktop-client",
864
+ });
865
+
866
+ const state = getAttentionStateByConversationIds(["conv-1"]).get("conv-1")!;
867
+ expect(state.latestAssistantMessageId).toBe("msg-done");
868
+ expect(state.lastSeenAssistantMessageId).toBe("msg-done");
869
+ });
870
+ });
871
+
872
+ describe("markConversationUnread with only a streaming reply", () => {
873
+ test("reports no assistant message rather than rewinding onto the streaming row", () => {
874
+ clearTables();
875
+ ensureConversation("conv-1");
876
+ getDb()
877
+ .insert(messages)
878
+ .values({
879
+ id: "msg-streaming-only",
880
+ conversationId: "conv-1",
881
+ role: "assistant",
882
+ content: "still being written",
883
+ createdAt: 1_000,
884
+ metadata: null,
885
+ finalized: 0,
886
+ })
887
+ .run();
888
+
889
+ // The write-path projection anchors at the finalize seam
890
+ // (`reserveMessage` deliberately skips it), so attention state for this
891
+ // conversation says "no reply yet". The rewind fallback must agree with
892
+ // the projection rather than anchoring on the streaming row.
893
+ expect(() => markConversationUnread("conv-1")).toThrow(
894
+ /no assistant message/i,
895
+ );
896
+ });
897
+ });
@@ -10,10 +10,6 @@ mock.module("../config/env.js", () => ({
10
10
  hasUngatedHttpAuthDisabled: () => false,
11
11
  }));
12
12
 
13
- mock.module("../daemon/conversation-store.js", () => ({
14
- destroyActiveConversation: () => {},
15
- }));
16
-
17
13
  mock.module("../daemon/handlers/conversations.js", () => ({
18
14
  cancelGeneration: () => true,
19
15
  clearAllConversations: async () => 0,