@vellumai/assistant 0.11.3 → 0.11.4-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) 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 +139 -37
  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 +17 -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 +177 -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__/ui-shape-teaching.test.ts +33 -0
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  69. package/src/__tests__/visible-app-context.test.ts +16 -9
  70. package/src/__tests__/voice-config-update.test.ts +40 -0
  71. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  72. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  73. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  74. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  75. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  76. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  77. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  78. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  79. package/src/acp/__tests__/auth-required.test.ts +161 -0
  80. package/src/acp/acp-claude-oauth.ts +19 -2
  81. package/src/acp/agent-process.test.ts +100 -0
  82. package/src/acp/agent-process.ts +29 -26
  83. package/src/acp/auth-required.ts +102 -0
  84. package/src/acp/session-manager.test.ts +119 -0
  85. package/src/acp/session-manager.ts +68 -2
  86. package/src/api/events/acp-auth-required.ts +55 -0
  87. package/src/api/index.ts +7 -0
  88. package/src/api/surfaces.ts +7 -3
  89. package/src/apps/app-store.ts +3 -0
  90. package/src/bundler/package-resolver.ts +2 -30
  91. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  92. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  93. package/src/calls/call-controller.ts +19 -3
  94. package/src/calls/call-setup-flow.ts +0 -1
  95. package/src/calls/media-stream-stt-session.ts +15 -0
  96. package/src/calls/voice-session-bridge.ts +71 -16
  97. package/src/calls/voice-triage-escalate.ts +104 -2
  98. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  99. package/src/channels/config.ts +13 -0
  100. package/src/channels/plugin-channel-declarations.ts +108 -0
  101. package/src/channels/types.ts +30 -0
  102. package/src/cli/AGENTS.md +5 -2
  103. package/src/cli/commands/credentials.help.ts +2 -2
  104. package/src/cli/commands/inference-providers.ts +1 -1
  105. package/src/cli/commands/mcp.help.ts +13 -4
  106. package/src/cli/commands/mcp.ts +9 -0
  107. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  108. package/src/cli/commands/memory/index.help.ts +43 -1
  109. package/src/cli/commands/memory/memory-v3.ts +64 -0
  110. package/src/cli/commands/stt.help.ts +27 -2
  111. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  112. package/src/cli/lib/bundled-marketplace.json +13 -0
  113. package/src/cli/lib/upgrade-plugin.ts +42 -0
  114. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  115. package/src/config/__tests__/default-provider.test.ts +6 -1
  116. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  117. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  118. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  119. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  120. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  121. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  122. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  123. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  124. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  125. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  126. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  127. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  128. package/src/config/call-site-defaults.ts +4 -5
  129. package/src/config/default-profile-catalog.ts +83 -12
  130. package/src/config/default-profile-names.ts +4 -1
  131. package/src/config/default-provider-resolution.ts +4 -0
  132. package/src/config/llm-context-resolution.ts +11 -3
  133. package/src/config/llm-resolver.ts +28 -1
  134. package/src/config/profile-materialization.ts +70 -22
  135. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  136. package/src/config/schemas/call-site-catalog.ts +4 -4
  137. package/src/config/schemas/live-voice.ts +57 -23
  138. package/src/config/schemas/llm.ts +59 -32
  139. package/src/config/schemas/mcp.ts +23 -0
  140. package/src/config/schemas/plugin-updates.ts +6 -2
  141. package/src/config/schemas/stt.ts +1 -0
  142. package/src/context/outbound-sanitize.ts +96 -1
  143. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  144. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  145. package/src/daemon/conversation-agent-loop.ts +17 -6
  146. package/src/daemon/conversation-messaging.ts +5 -1
  147. package/src/daemon/conversation-notifiers.ts +9 -1
  148. package/src/daemon/conversation-process.ts +36 -6
  149. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  150. package/src/daemon/conversation-surfaces.ts +27 -5
  151. package/src/daemon/conversation-tool-setup.ts +1 -2
  152. package/src/daemon/conversation.ts +48 -0
  153. package/src/daemon/interactive-turn-sender.ts +59 -0
  154. package/src/daemon/mcp-reload-service.ts +36 -6
  155. package/src/daemon/process-message.ts +24 -24
  156. package/src/daemon/providers-setup.ts +6 -3
  157. package/src/daemon/trust-context-types.ts +29 -0
  158. package/src/daemon/wake-conversation-ops.ts +3 -2
  159. package/src/documents/document-store.ts +138 -5
  160. package/src/hooks/hook-loader.ts +3 -3
  161. package/src/hooks/registry.ts +50 -6
  162. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  163. package/src/inbound/oauth-callback-url.ts +61 -0
  164. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  165. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  166. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  167. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  168. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  169. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  170. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  171. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  172. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  173. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  174. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  175. package/src/live-voice/live-voice-archive.ts +2 -0
  176. package/src/live-voice/live-voice-metrics.ts +57 -32
  177. package/src/live-voice/live-voice-photo.ts +1 -2
  178. package/src/live-voice/live-voice-session.ts +535 -314
  179. package/src/live-voice/progress-narration.ts +277 -0
  180. package/src/live-voice/protocol.ts +21 -1
  181. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  182. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  183. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  184. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  185. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  186. package/src/mcp/client.ts +16 -11
  187. package/src/mcp/effective-config.ts +113 -0
  188. package/src/mcp/manager.ts +11 -6
  189. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  190. package/src/mcp/mcp-oauth-provider.ts +205 -240
  191. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  192. package/src/monitoring/plugin-auto-update.ts +128 -24
  193. package/src/notifications/signal.ts +1 -0
  194. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  195. package/src/permissions/confirmation-guardian-request.ts +2 -2
  196. package/src/permissions/question-guardian-request.test.ts +14 -6
  197. package/src/permissions/question-guardian-request.ts +1 -2
  198. package/src/persistence/attachments-store.ts +8 -1
  199. package/src/persistence/bookmark-crud.ts +3 -7
  200. package/src/persistence/conversation-attention-store.ts +16 -45
  201. package/src/persistence/conversation-crud.ts +33 -4
  202. package/src/persistence/conversation-lineage.ts +9 -0
  203. package/src/persistence/conversation-queries.ts +108 -41
  204. package/src/persistence/delivery-crud.ts +38 -29
  205. package/src/persistence/external-conversation-store.ts +32 -4
  206. package/src/persistence/llm-request-log-store.ts +4 -10
  207. package/src/persistence/llm-usage-store.ts +8 -3
  208. package/src/persistence/message-reads.test.ts +197 -0
  209. package/src/persistence/message-reads.ts +211 -0
  210. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  211. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  212. package/src/persistence/real-user-turn-filter.ts +27 -3
  213. package/src/persistence/steps.ts +9 -0
  214. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  215. package/src/plugin-api/conversation-turn.ts +168 -5
  216. package/src/plugin-api/index.ts +21 -5
  217. package/src/plugin-api/vision-support.test.ts +1 -1
  218. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  219. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  220. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  224. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  225. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  226. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  227. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  228. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  229. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  230. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  231. package/src/plugins/defaults/memory/indexer.ts +3 -1
  232. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  233. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  234. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  238. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  239. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  240. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  241. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  242. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  243. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  244. package/src/plugins/defaults/memory/worker.ts +6 -3
  245. package/src/plugins/external-plugin-loader.ts +47 -0
  246. package/src/plugins/mcp-servers.ts +361 -0
  247. package/src/plugins/mtime-cache.ts +23 -49
  248. package/src/plugins/worker-plugin-surface.ts +33 -0
  249. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  250. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  251. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  252. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  253. package/src/providers/call-site-routing.ts +30 -3
  254. package/src/providers/connection-resolution.ts +194 -11
  255. package/src/providers/inference/auth.ts +6 -0
  256. package/src/providers/inference/connection-availability.ts +24 -2
  257. package/src/providers/inference/connections.ts +2 -0
  258. package/src/providers/model-catalog.ts +3 -3
  259. package/src/providers/model-intents.ts +28 -8
  260. package/src/providers/openai/chat-completions-provider.ts +5 -6
  261. package/src/providers/openai/codex-models.ts +2 -1
  262. package/src/providers/provider-send-message.ts +32 -3
  263. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  264. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  265. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  266. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  267. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  268. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  269. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  270. package/src/providers/speech-to-text/resolve.ts +25 -2
  271. package/src/routes/worker.ts +17 -5
  272. package/src/runtime/access-request-helper.ts +9 -12
  273. package/src/runtime/agent-wake.ts +3 -3
  274. package/src/runtime/pre-first-message-gate.ts +4 -0
  275. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  276. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  277. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  278. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  279. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  280. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  281. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  282. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  283. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  284. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  285. package/src/runtime/routes/channel-route-shared.ts +0 -6
  286. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  287. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  288. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  289. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  290. package/src/runtime/routes/default-provider-routes.ts +15 -0
  291. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  292. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  294. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  295. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  296. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  297. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  298. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  299. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  300. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  301. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  302. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  303. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  304. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  305. package/src/runtime/routes/log-export-routes.ts +3 -0
  306. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  307. package/src/runtime/routes/plugins-routes.ts +21 -3
  308. package/src/runtime/routes/stt-routes.ts +31 -25
  309. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  310. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  311. package/src/runtime/routes/user-route-import.ts +108 -0
  312. package/src/schedule/worker.ts +6 -0
  313. package/src/security/oauth2.ts +6 -22
  314. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  315. package/src/stt/__tests__/types.test.ts +94 -0
  316. package/src/stt/daemon-batch-transcriber.ts +10 -0
  317. package/src/stt/stt-stream-session.ts +8 -4
  318. package/src/stt/types.ts +103 -0
  319. package/src/subagent/manager.ts +1 -3
  320. package/src/subagent/types.ts +7 -6
  321. package/src/tools/acp/spawn.test.ts +97 -0
  322. package/src/tools/acp/spawn.ts +32 -0
  323. package/src/tools/document/document-tool.ts +12 -3
  324. package/src/tools/registry.ts +2 -1
  325. package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
  326. package/src/tools/workflows/run-workflow.ts +1 -2
  327. package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
  328. package/src/tts/reasoning-tag-filter.ts +89 -0
  329. package/src/util/think-tag-stream.ts +95 -0
  330. package/src/workspace/byok-default-profile-ensure.ts +76 -24
  331. package/src/workspace/custom-profile-ensure.ts +4 -24
  332. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  333. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  334. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  335. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  336. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  337. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  338. package/src/workspace/migrations/registry.ts +10 -0
  339. package/src/workspace/provider-commit-message-generator.ts +7 -5
  340. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  341. package/src/live-voice/front-decision.ts +0 -476
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Tests for reading plugin-declared MCP servers from a root `mcp.json`.
3
+ *
4
+ * The failure-isolation behaviour is the part worth pinning: the Agent
5
+ * Plugins spec says an invalid top-level manifest disables MCP for that
6
+ * plugin alone, and an invalid individual entry disables only that entry.
7
+ * A reader that threw on either would take every other plugin's servers
8
+ * out of `assistant mcp list` along with it.
9
+ */
10
+
11
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { describe, expect, test } from "bun:test";
15
+
16
+ import {
17
+ buildServerId,
18
+ hasLoadableManifest,
19
+ interpolatePluginPaths,
20
+ readPluginMcpServers,
21
+ } from "../mcp-servers.js";
22
+
23
+ /** Build a throwaway plugins directory with the given plugin layouts. */
24
+ function makePluginsDir(
25
+ plugins: Record<
26
+ string,
27
+ { mcpJson?: string; packageJson?: string; disabled?: boolean }
28
+ >,
29
+ ): string {
30
+ const root = mkdtempSync(join(tmpdir(), "vellum-plugin-mcp-"));
31
+ for (const [name, spec] of Object.entries(plugins)) {
32
+ const dir = join(root, name);
33
+ mkdirSync(dir, { recursive: true });
34
+ writeFileSync(
35
+ join(dir, "package.json"),
36
+ spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
37
+ );
38
+ if (spec.mcpJson !== undefined) {
39
+ writeFileSync(join(dir, "mcp.json"), spec.mcpJson);
40
+ }
41
+ if (spec.disabled) {
42
+ writeFileSync(join(dir, ".disabled"), "");
43
+ }
44
+ }
45
+ return root;
46
+ }
47
+
48
+ const VALID_MANIFEST = JSON.stringify({
49
+ $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
50
+ mcpServers: {
51
+ unabyss: { type: "streamable-http", url: "https://mcp.unabyss.com" },
52
+ },
53
+ });
54
+
55
+ describe("buildServerId", () => {
56
+ test("collapses the redundant case so tool names stay readable", () => {
57
+ // The id is embedded in every tool name as mcp__<id>__<tool>, so
58
+ // `unabyss__unabyss` would show up in the model's catalog.
59
+ expect(buildServerId("unabyss", "unabyss")).toEqual("unabyss");
60
+ });
61
+
62
+ test("qualifies with the plugin name when the keys differ", () => {
63
+ expect(buildServerId("acme", "deploy")).toEqual("acme__deploy");
64
+ });
65
+ });
66
+
67
+ describe("hasLoadableManifest", () => {
68
+ const base = {
69
+ name: "x",
70
+ target: "/p",
71
+ issues: [],
72
+ hasIcon: false,
73
+ source: "user" as const,
74
+ disabled: false,
75
+ };
76
+
77
+ test("accepts a manifest with a non-empty name, matching the runtime gate", () => {
78
+ expect(hasLoadableManifest({ ...base, packageJson: { name: "x" } })).toBe(
79
+ true,
80
+ );
81
+ });
82
+
83
+ test("rejects an unreadable or unparseable manifest", () => {
84
+ expect(hasLoadableManifest({ ...base, packageJson: null })).toBe(false);
85
+ });
86
+
87
+ test("rejects a manifest with no name", () => {
88
+ expect(
89
+ hasLoadableManifest({ ...base, packageJson: { version: "1.0.0" } }),
90
+ ).toBe(false);
91
+ });
92
+
93
+ test("rejects an empty name", () => {
94
+ expect(hasLoadableManifest({ ...base, packageJson: { name: "" } })).toBe(
95
+ false,
96
+ );
97
+ });
98
+ });
99
+
100
+ describe("interpolatePluginPaths", () => {
101
+ test("expands both spec-defined variables", () => {
102
+ expect(
103
+ interpolatePluginPaths("${PLUGIN_ROOT}/bin", "/p", "/p/data"),
104
+ ).toEqual("/p/bin");
105
+ expect(
106
+ interpolatePluginPaths("${PLUGIN_DATA}/db", "/p", "/p/data"),
107
+ ).toEqual("/p/data/db");
108
+ });
109
+
110
+ test("expands every occurrence, not just the first", () => {
111
+ expect(
112
+ interpolatePluginPaths("${PLUGIN_ROOT}:${PLUGIN_ROOT}", "/p", "/p/data"),
113
+ ).toEqual("/p:/p");
114
+ });
115
+
116
+ test("leaves unknown variables alone", () => {
117
+ expect(interpolatePluginPaths("${HOME}/x", "/p", "/p/data")).toEqual(
118
+ "${HOME}/x",
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("readPluginMcpServers", () => {
124
+ test("reads a streamable-http server from a plugin manifest", () => {
125
+ const dir = makePluginsDir({ unabyss: { mcpJson: VALID_MANIFEST } });
126
+ const { servers, issues } = readPluginMcpServers({
127
+ workspacePluginsDir: dir,
128
+ });
129
+
130
+ expect(issues).toEqual([]);
131
+ expect(servers).toHaveLength(1);
132
+ expect(servers[0].id).toEqual("unabyss");
133
+ expect(servers[0].pluginName).toEqual("unabyss");
134
+ expect(servers[0].config.transport).toEqual({
135
+ type: "streamable-http",
136
+ url: "https://mcp.unabyss.com",
137
+ });
138
+ });
139
+
140
+ test("defaults plugin servers to low risk", () => {
141
+ // mcp.json has no risk field, so a host default applies. It is `low`
142
+ // because the review happens at install time — curated marketplace,
143
+ // SHA-pinned — rather than on every call.
144
+ const dir = makePluginsDir({ unabyss: { mcpJson: VALID_MANIFEST } });
145
+ const { servers } = readPluginMcpServers({ workspacePluginsDir: dir });
146
+ expect(servers[0].config.defaultRiskLevel).toEqual("low");
147
+ });
148
+
149
+ test("ignores a directory whose package.json is missing", () => {
150
+ // `listAllPlugins` reports a malformed directory rather than dropping
151
+ // it, but the runtime loader will never load one, so honoring its
152
+ // mcp.json would advertise a server no plugin stands behind.
153
+ const root = mkdtempSync(join(tmpdir(), "vellum-plugin-mcp-"));
154
+ const dir = join(root, "no-manifest");
155
+ mkdirSync(dir, { recursive: true });
156
+ writeFileSync(join(dir, "mcp.json"), VALID_MANIFEST);
157
+
158
+ const { servers, issues } = readPluginMcpServers({
159
+ workspacePluginsDir: root,
160
+ });
161
+ expect(servers).toEqual([]);
162
+ expect(issues[0].message).toContain("package.json");
163
+ });
164
+
165
+ test("ignores a directory whose package.json is unparseable", () => {
166
+ const dir = makePluginsDir({
167
+ broken: { mcpJson: VALID_MANIFEST, packageJson: "{ not json" },
168
+ });
169
+ const { servers, issues } = readPluginMcpServers({
170
+ workspacePluginsDir: dir,
171
+ });
172
+ expect(servers).toEqual([]);
173
+ expect(issues[0].message).toContain("package.json");
174
+ });
175
+
176
+ test("ignores a directory whose package.json has no name", () => {
177
+ const dir = makePluginsDir({
178
+ nameless: {
179
+ mcpJson: VALID_MANIFEST,
180
+ packageJson: JSON.stringify({ version: "1.0.0" }),
181
+ },
182
+ });
183
+ const { servers, issues } = readPluginMcpServers({
184
+ workspacePluginsDir: dir,
185
+ });
186
+ expect(servers).toEqual([]);
187
+ expect(issues[0].message).toContain("package.json");
188
+ });
189
+
190
+ test("skips plugins with no mcp.json without complaining", () => {
191
+ const dir = makePluginsDir({ plain: {} });
192
+ expect(readPluginMcpServers({ workspacePluginsDir: dir })).toEqual({
193
+ servers: [],
194
+ issues: [],
195
+ });
196
+ });
197
+
198
+ test("skips disabled plugins entirely", () => {
199
+ const dir = makePluginsDir({
200
+ unabyss: { mcpJson: VALID_MANIFEST, disabled: true },
201
+ });
202
+ const { servers } = readPluginMcpServers({ workspacePluginsDir: dir });
203
+ expect(servers).toEqual([]);
204
+ });
205
+
206
+ test("an unparseable manifest disables only that plugin", () => {
207
+ const dir = makePluginsDir({
208
+ broken: { mcpJson: "{ not json" },
209
+ good: { mcpJson: VALID_MANIFEST },
210
+ });
211
+ const { servers, issues } = readPluginMcpServers({
212
+ workspacePluginsDir: dir,
213
+ });
214
+
215
+ expect(servers.map((s) => s.pluginName)).toEqual(["good"]);
216
+ expect(issues).toHaveLength(1);
217
+ expect(issues[0].pluginName).toEqual("broken");
218
+ expect(issues[0].message).toContain("invalid JSON");
219
+ });
220
+
221
+ test("a manifest missing mcpServers disables only that plugin", () => {
222
+ const dir = makePluginsDir({
223
+ broken: { mcpJson: JSON.stringify({ $schema: "x" }) },
224
+ good: { mcpJson: VALID_MANIFEST },
225
+ });
226
+ const { servers, issues } = readPluginMcpServers({
227
+ workspacePluginsDir: dir,
228
+ });
229
+
230
+ expect(servers.map((s) => s.pluginName)).toEqual(["good"]);
231
+ expect(issues[0].message).toContain("mcpServers");
232
+ });
233
+
234
+ test("an invalid entry disables only that entry, not its siblings", () => {
235
+ const dir = makePluginsDir({
236
+ acme: {
237
+ mcpJson: JSON.stringify({
238
+ mcpServers: {
239
+ good: { type: "streamable-http", url: "https://a.example" },
240
+ bad: { type: "streamable-http" },
241
+ alsoBad: { type: "carrier-pigeon", url: "https://b.example" },
242
+ },
243
+ }),
244
+ },
245
+ });
246
+ const { servers, issues } = readPluginMcpServers({
247
+ workspacePluginsDir: dir,
248
+ });
249
+
250
+ expect(servers.map((s) => s.serverKey)).toEqual(["good"]);
251
+ expect(issues.map((i) => i.serverKey).sort()).toEqual(["alsoBad", "bad"]);
252
+ });
253
+
254
+ test("projects a stdio server and expands its path variables", () => {
255
+ const dir = makePluginsDir({
256
+ local: {
257
+ mcpJson: JSON.stringify({
258
+ mcpServers: {
259
+ local: {
260
+ type: "stdio",
261
+ command: "node",
262
+ args: ["${PLUGIN_ROOT}/server.js"],
263
+ env: { STATE: "${PLUGIN_DATA}/state" },
264
+ },
265
+ },
266
+ }),
267
+ },
268
+ });
269
+ const { servers } = readPluginMcpServers({ workspacePluginsDir: dir });
270
+
271
+ const transport = servers[0].config.transport;
272
+ expect(transport.type).toEqual("stdio");
273
+ if (transport.type !== "stdio") {
274
+ throw new Error("expected a stdio transport");
275
+ }
276
+ expect(transport.command).toEqual("node");
277
+ expect(transport.args[0]).toEqual(join(dir, "local", "server.js"));
278
+ expect(transport.env?.STATE).toEqual(join(dir, "local", "data", "state"));
279
+ });
280
+
281
+ test("reports an ignored cwd rather than dropping it silently", () => {
282
+ const dir = makePluginsDir({
283
+ local: {
284
+ mcpJson: JSON.stringify({
285
+ mcpServers: {
286
+ local: { type: "stdio", command: "node", cwd: "${PLUGIN_ROOT}" },
287
+ },
288
+ }),
289
+ },
290
+ });
291
+ const { servers, issues } = readPluginMcpServers({
292
+ workspacePluginsDir: dir,
293
+ });
294
+
295
+ expect(servers).toHaveLength(1);
296
+ expect(issues).toHaveLength(1);
297
+ expect(issues[0].message).toContain("cwd");
298
+ });
299
+
300
+ test("two plugins claiming one id keep the first and report the second", () => {
301
+ // Both would otherwise register as `shared`, and whichever lost would
302
+ // simply be missing with no explanation.
303
+ const manifest = JSON.stringify({
304
+ mcpServers: { shared: { type: "sse", url: "https://s.example" } },
305
+ });
306
+ const dir = makePluginsDir({
307
+ "a-plugin": { mcpJson: manifest },
308
+ "b-plugin": { mcpJson: manifest },
309
+ });
310
+ const { servers, issues } = readPluginMcpServers({
311
+ workspacePluginsDir: dir,
312
+ });
313
+
314
+ expect(servers).toHaveLength(2);
315
+ expect(servers.map((s) => s.id).sort()).toEqual([
316
+ "a-plugin__shared",
317
+ "b-plugin__shared",
318
+ ]);
319
+ expect(issues).toEqual([]);
320
+ });
321
+
322
+ test("an id collision between two plugins keeps one and reports the other", () => {
323
+ // The id-collapsing rule makes this reachable: plugin `a` with key `b`
324
+ // qualifies to `a__b`, and plugin `a__b` with key `a__b` collapses to
325
+ // `a__b` too. Whichever loses would otherwise be silently missing.
326
+ const dir = makePluginsDir({
327
+ a: {
328
+ mcpJson: JSON.stringify({
329
+ mcpServers: { b: { type: "sse", url: "https://a.example" } },
330
+ }),
331
+ },
332
+ a__b: {
333
+ mcpJson: JSON.stringify({
334
+ mcpServers: { a__b: { type: "sse", url: "https://b.example" } },
335
+ }),
336
+ },
337
+ });
338
+ const { servers, issues } = readPluginMcpServers({
339
+ workspacePluginsDir: dir,
340
+ });
341
+
342
+ expect(servers.filter((s) => s.id === "a__b")).toHaveLength(1);
343
+ expect(issues.some((i) => i.message.includes("already declared"))).toBe(
344
+ true,
345
+ );
346
+ });
347
+
348
+ test("an empty server key is rejected rather than producing a nameless id", () => {
349
+ const dir = makePluginsDir({
350
+ acme: {
351
+ mcpJson: JSON.stringify({
352
+ mcpServers: { "": { type: "sse", url: "https://a.example" } },
353
+ }),
354
+ },
355
+ });
356
+ const { servers, issues } = readPluginMcpServers({
357
+ workspacePluginsDir: dir,
358
+ });
359
+
360
+ expect(servers).toEqual([]);
361
+ expect(issues[0].message).toContain("non-empty");
362
+ });
363
+
364
+ test("returns empty when the plugins directory does not exist", () => {
365
+ expect(
366
+ readPluginMcpServers({
367
+ workspacePluginsDir: join(tmpdir(), "vellum-does-not-exist-12345"),
368
+ }),
369
+ ).toEqual({ servers: [], issues: [] });
370
+ });
371
+ });
@@ -59,6 +59,10 @@ Everything else under the plugin root is **spine**:
59
59
  - Shared infra at the root: `logging`, `paths`, `embeddings`, `anisotropy`,
60
60
  `frontmatter`, `validation`, `llm-helpers`, `config`, `memory-db`,
61
61
  `host-utils`, `path-containment`, `prompt-override`, `memory-marker`,
62
+ `buffer-format` (the sole owner of the `memory/buffer.md` entry format:
63
+ the writer plus the one matcher every reader uses. It lives at the root
64
+ precisely so `substrate/`, `graph/`, and `graph-topology/` can all reach it
65
+ without a tier importing spine. Do not add a second matcher anywhere),
62
66
  `segmenter`, `message-media`, `worker`, `worker-control`,
63
67
  `memory-recall-log-store`, `activation-session-store` (the onboarding
64
68
  activation rail — **not** a memory tier despite the name),
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Tests for the single owner of the `memory/buffer.md` entry format.
3
+ *
4
+ * These cases pin exactly which lines count as an entry opening, covering the
5
+ * shapes a second matcher is most likely to get wrong: indentation, spacing
6
+ * after the bullet dash, and spacing inside the date. Every one of them is the
7
+ * difference between one fact and two.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+
12
+ import {
13
+ formatBufferTimestamp,
14
+ formatRememberEntry,
15
+ isBufferEntryStart,
16
+ matchBufferEntryStart,
17
+ splitBufferEntries,
18
+ } from "../buffer-format.js";
19
+
20
+ describe("matchBufferEntryStart", () => {
21
+ test("matches what formatRememberEntry writes, and captures both halves", () => {
22
+ const line = formatRememberEntry(
23
+ "Alice prefers dark mode",
24
+ new Date(2026, 0, 1, 9, 0),
25
+ ).trimEnd();
26
+ expect(line).toBe("- [Jan 1, 9:00 AM] Alice prefers dark mode");
27
+
28
+ const match = matchBufferEntryStart(line);
29
+ expect(match).toEqual({
30
+ timestamp: "Jan 1, 9:00 AM",
31
+ text: "Alice prefers dark mode",
32
+ });
33
+ });
34
+
35
+ test("round-trips every hour so the AM/PM and 12-hour edges hold", () => {
36
+ for (let hour = 0; hour < 24; hour++) {
37
+ const line = formatRememberEntry(
38
+ "fact",
39
+ new Date(2026, 0, 1, hour, 5),
40
+ ).trimEnd();
41
+ const match = matchBufferEntryStart(line);
42
+ expect(match).not.toBeNull();
43
+ expect(match!.timestamp).toBe(
44
+ formatBufferTimestamp(new Date(2026, 0, 1, hour, 5)),
45
+ );
46
+ expect(match!.text).toBe("fact");
47
+ }
48
+ });
49
+
50
+ test("an indented entry-shaped line is body text, not a new entry", () => {
51
+ // The writer puts only an entry's opening line at column 0 and nests the
52
+ // body under it, so anything indented is part of the fact above it.
53
+ // Reading it as an entry splits one fact into two nodes in the Memory tab.
54
+ expect(isBufferEntryStart(" - [Jan 1, 9:00 AM] indented")).toBe(false);
55
+ expect(isBufferEntryStart("\t- [Jan 1, 9:00 AM] tab-indented")).toBe(false);
56
+ });
57
+
58
+ test("only the writer's exact spacing counts as an entry", () => {
59
+ // A line the writer could not have produced is body text. Recognizing it
60
+ // would either split the fact holding it or hand a caller a timestamp in a
61
+ // shape that no real entry uses, which matters because the consolidation
62
+ // cutoff is compared against entry timestamps textually.
63
+ expect(isBufferEntryStart("-[Jan 1, 9:00 AM] no space after dash")).toBe(
64
+ false,
65
+ );
66
+ expect(isBufferEntryStart("- [Jan 1, 9:00 AM] two spaces")).toBe(false);
67
+ expect(isBufferEntryStart("- [Jan 1, 9:00 AM] double-spaced date")).toBe(
68
+ false,
69
+ );
70
+ expect(
71
+ isBufferEntryStart("- [Jan 1, 9:00AM] no space before meridiem"),
72
+ ).toBe(false);
73
+ });
74
+
75
+ test("day and month widths the writer produces all match", () => {
76
+ // `formatBufferTimestamp` pads neither the day nor the hour, so single and
77
+ // double digit forms both occur on disk.
78
+ for (const line of [
79
+ "- [Jan 1, 9:00 AM] single-digit day and hour",
80
+ "- [Dec 31, 12:05 PM] double-digit day and hour",
81
+ "- [Sep 9, 11:59 PM] boundary",
82
+ ]) {
83
+ expect(isBufferEntryStart(line)).toBe(true);
84
+ }
85
+ });
86
+
87
+ test("continuation bullets carrying other bracketed text are not entries", () => {
88
+ expect(isBufferEntryStart(" - [ ] a checklist item")).toBe(false);
89
+ expect(isBufferEntryStart("- [ ] a checklist item")).toBe(false);
90
+ expect(isBufferEntryStart("- [[wikilink]] bullet")).toBe(false);
91
+ expect(isBufferEntryStart(" plain prose")).toBe(false);
92
+ expect(isBufferEntryStart("")).toBe(false);
93
+ });
94
+
95
+ test("tolerates trailing whitespace, which editors add invisibly", () => {
96
+ expect(isBufferEntryStart("- [Jan 1, 9:00 AM] fact ")).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("formatRememberEntry round trip", () => {
101
+ const WRITTEN_AT = new Date(2026, 0, 1, 9, 0);
102
+
103
+ test("a fact whose body is itself entry-shaped survives as one entry", () => {
104
+ const content = "plan:\n- [Jan 2, 9:00 AM] step";
105
+ const written = formatRememberEntry(content, WRITTEN_AT);
106
+
107
+ // The body is nested, so it cannot be mistaken for the next fact.
108
+ expect(written).toBe(
109
+ "- [Jan 1, 9:00 AM] plan:\n - [Jan 2, 9:00 AM] step\n",
110
+ );
111
+ expect(splitBufferEntries(written.trimEnd().split("\n"))).toHaveLength(1);
112
+ });
113
+
114
+ test("any content round-trips to exactly one entry", () => {
115
+ // The delimiter is "canonical entry shape at column 0", so the property
116
+ // that matters is that no fact body can imitate one, whatever the user
117
+ // dictated.
118
+ const adversarial = [
119
+ "plan:\n- [Jan 2, 9:00 AM] step",
120
+ "plan:\n- [Jan 2, 9:00 AM] step\n- [Jan 3, 10:15 PM] another",
121
+ "- [Jan 2, 9:00 AM] body opens with an entry shape",
122
+ "top\n\n- [Dec 31, 12:05 PM] after a blank line",
123
+ "top\n - [Jan 2, 9:00 AM] already indented",
124
+ "top\n\t- [Jan 2, 9:00 AM] tab indented",
125
+ "top\n- [ ] checklist\n- [[wikilink]] doc\n- plain bullet",
126
+ "single line, no body",
127
+ ];
128
+
129
+ for (const content of adversarial) {
130
+ const written = formatRememberEntry(content, WRITTEN_AT);
131
+ expect(splitBufferEntries(written.trimEnd().split("\n"))).toHaveLength(1);
132
+ }
133
+ });
134
+
135
+ test("the fact text a reader recovers is unchanged by nesting", () => {
136
+ const content = "plan:\n- [Jan 2, 9:00 AM] step";
137
+ const groups = splitBufferEntries(
138
+ formatRememberEntry(content, WRITTEN_AT).trimEnd().split("\n"),
139
+ );
140
+ // Opening clause on the entry, body carried verbatim under it.
141
+ expect(groups[0]!.start?.text).toBe("plan:");
142
+ expect(groups[0]!.lines).toEqual([
143
+ "- [Jan 1, 9:00 AM] plan:",
144
+ " - [Jan 2, 9:00 AM] step",
145
+ ]);
146
+ });
147
+
148
+ test("blank body lines stay blank rather than becoming whitespace", () => {
149
+ const written = formatRememberEntry("a\n\nb", WRITTEN_AT);
150
+ expect(written).toBe("- [Jan 1, 9:00 AM] a\n\n b\n");
151
+ });
152
+ });
153
+
154
+ describe("splitBufferEntries", () => {
155
+ test("keeps a multiline fact's body with its opening line", () => {
156
+ const groups = splitBufferEntries([
157
+ "- [Jan 1, 9:00 AM] first",
158
+ "- [Jan 2, 9:00 AM] second",
159
+ " body of second",
160
+ " - [ ] checklist inside second",
161
+ " - [Jan 3, 9:00 AM] indented, still second",
162
+ ]);
163
+
164
+ expect(groups).toHaveLength(2);
165
+ expect(groups[0]!.lines).toEqual(["- [Jan 1, 9:00 AM] first"]);
166
+ expect(groups[1]!.lines).toHaveLength(4);
167
+ expect(groups[1]!.start?.text).toBe("second");
168
+ expect(groups[1]!.firstLine).toBe(1);
169
+ });
170
+
171
+ test("surfaces leading prose as a headless group rather than dropping it", () => {
172
+ const groups = splitBufferEntries([
173
+ "hand-written header",
174
+ "- [Jan 1, 9:00 AM] first",
175
+ ]);
176
+
177
+ expect(groups).toHaveLength(2);
178
+ expect(groups[0]!.start).toBeNull();
179
+ expect(groups[0]!.lines).toEqual(["hand-written header"]);
180
+ expect(groups[1]!.start).not.toBeNull();
181
+ });
182
+
183
+ test("a buffer with no entries at all is one headless group", () => {
184
+ const groups = splitBufferEntries(["just prose", "more prose"]);
185
+ expect(groups).toHaveLength(1);
186
+ expect(groups[0]!.start).toBeNull();
187
+ expect(groups[0]!.lines).toHaveLength(2);
188
+ });
189
+
190
+ test("preserves every input line exactly once, in order", () => {
191
+ const lines = [
192
+ "prose",
193
+ "- [Jan 1, 9:00 AM] a",
194
+ " body",
195
+ "",
196
+ "- [Jan 2, 9:00 AM] b",
197
+ ];
198
+ expect(splitBufferEntries(lines).flatMap((g) => g.lines)).toEqual(lines);
199
+ });
200
+
201
+ test("empty input yields no groups", () => {
202
+ expect(splitBufferEntries([])).toEqual([]);
203
+ });
204
+ });
@@ -1,5 +1,7 @@
1
1
  import { beforeEach, describe, expect, test } from "bun:test";
2
2
 
3
+ import { eq } from "drizzle-orm";
4
+
3
5
  import {
4
6
  createConversation,
5
7
  type MessageRow,
@@ -8,6 +10,7 @@ import { getDb } from "../../../../persistence/db-connection.js";
8
10
  import { initializeDb } from "../../../../persistence/db-init.js";
9
11
  import { messages } from "../../../../persistence/schema/index.js";
10
12
  import {
13
+ getRetrospectiveMessagesAfter,
11
14
  hasQualifyingUserMessageAfter,
12
15
  messagesHaveUserActivity,
13
16
  } from "../memory-retrospective-accounting.js";
@@ -197,3 +200,72 @@ describe("messagesHaveUserActivity", () => {
197
200
  expect(messagesHaveUserActivity([])).toBe(false);
198
201
  });
199
202
  });
203
+
204
+ describe("getRetrospectiveMessagesAfter", () => {
205
+ beforeEach(() => {
206
+ const db = getDb();
207
+ db.run(`DELETE FROM messages`);
208
+ db.run(`DELETE FROM conversations`);
209
+ createConversation({ id: CONV });
210
+ });
211
+
212
+ test("truncates at a stale mid-slice unfinalized row so the cursor never passes it", () => {
213
+ const first = insertRaw({ role: "user", content: TEXT, createdAt: 1_000 });
214
+ const stale = insertRaw({
215
+ role: "assistant",
216
+ content: TEXT,
217
+ createdAt: 2_000,
218
+ });
219
+ insertRaw({ role: "user", content: TEXT, createdAt: 3_000 });
220
+ insertRaw({ role: "assistant", content: TEXT, createdAt: 4_000 });
221
+ getDb()
222
+ .update(messages)
223
+ .set({ finalized: 0 })
224
+ .where(eq(messages.id, stale))
225
+ .run();
226
+
227
+ const slice = getRetrospectiveMessagesAfter(CONV, null);
228
+
229
+ // The slice stops BEFORE the stale row: taking the later finalized rows
230
+ // as the cutoff would advance the cursor past the stale row, and once it
231
+ // finalizes it would sit behind the cursor, unreviewed forever.
232
+ expect(slice.map((row) => row.id)).toEqual([first]);
233
+
234
+ getDb()
235
+ .update(messages)
236
+ .set({ finalized: 1 })
237
+ .where(eq(messages.id, stale))
238
+ .run();
239
+
240
+ // Once the row resolves, the slice continues past it.
241
+ expect(getRetrospectiveMessagesAfter(CONV, null)).toHaveLength(4);
242
+ });
243
+
244
+ test("excludes unfinalized rows so the cutoff stays on rows a fork holds", () => {
245
+ insertRaw({ role: "user", content: TEXT, createdAt: 1_000 });
246
+ const lastFinalized = insertRaw({
247
+ role: "assistant",
248
+ content: TEXT,
249
+ createdAt: 2_000,
250
+ });
251
+ const streaming = insertRaw({
252
+ role: "assistant",
253
+ content: TEXT,
254
+ createdAt: 3_000,
255
+ });
256
+ getDb()
257
+ .update(messages)
258
+ .set({ finalized: 0 })
259
+ .where(eq(messages.id, streaming))
260
+ .run();
261
+
262
+ const slice = getRetrospectiveMessagesAfter(CONV, null);
263
+
264
+ // The invariant: no slice member is unfinalized, and the cutoff the job
265
+ // would take (the last slice row) is a row the retrospective fork
266
+ // actually contains.
267
+ expect(slice.some((row) => row.finalized === 0)).toBe(false);
268
+ expect(slice.at(-1)?.id).toBe(lastFinalized);
269
+ expect(slice.map((row) => row.id)).not.toContain(streaming);
270
+ });
271
+ });
@@ -168,7 +168,10 @@ mock.module("../find-most-recent-retrospective-for.js", () => ({
168
168
  mock.module("../../../../persistence/conversation-crud.js", () => ({
169
169
  getMessagesAfter: (id: string, afterId: string | null) => {
170
170
  messagesAfterCalls.push({ conversationId: id, afterId });
171
- return newMessages;
171
+ // Real rows always carry `finalized` (NOT NULL, default 1); the slice
172
+ // filter drops unfinalized rows, so mock rows default to finalized
173
+ // unless a test explicitly stages a streaming row.
174
+ return newMessages.map((row) => ({ finalized: 1, ...row }));
172
175
  },
173
176
  getMessages: (id: string) => {
174
177
  if (messagesByConversationId[id]) {