@vellumai/assistant 0.11.8 → 0.11.9-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 (326) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/Dockerfile +8 -48
  3. package/docker-entrypoint.sh +5 -1
  4. package/docker-kata-apt-env.sh +3 -0
  5. package/docker-kata-apt-shims.sh +127 -0
  6. package/docker-kata-apt-wrapper.sh +45 -0
  7. package/docker-kata-chroot-exec.sh +35 -0
  8. package/docker-kata-pip.sh +8 -2
  9. package/docs/architecture/memory.md +8 -4
  10. package/docs/guardian-request-flow.md +18 -14
  11. package/docs/trusted-contact-access.md +1 -7
  12. package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
  13. package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  25. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  26. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  27. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  28. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  29. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  30. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  31. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  32. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  33. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  34. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  35. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  36. package/openapi.yaml +193 -4
  37. package/package.json +2 -2
  38. package/src/__tests__/access-request-card-view.test.ts +6 -5
  39. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  40. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
  41. package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
  42. package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
  43. package/src/__tests__/attachments-store.test.ts +27 -0
  44. package/src/__tests__/attachments.test.ts +43 -0
  45. package/src/__tests__/canned-reply-release.test.ts +48 -5
  46. package/src/__tests__/channel-delivery-store.test.ts +129 -18
  47. package/src/__tests__/channel-reply-delivery.test.ts +553 -93
  48. package/src/__tests__/channel-retry-sweep.test.ts +199 -0
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
  50. package/src/__tests__/conversation-error.test.ts +15 -0
  51. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  52. package/src/__tests__/conversation-pairing.test.ts +141 -1
  53. package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
  54. package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
  55. package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
  56. package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
  57. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  58. package/src/__tests__/credential-health-service.test.ts +98 -10
  59. package/src/__tests__/delete-propagation.test.ts +469 -0
  60. package/src/__tests__/dm-persistence.test.ts +16 -0
  61. package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
  62. package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
  63. package/src/__tests__/gemini-provider.test.ts +46 -0
  64. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  65. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  66. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  67. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  68. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  69. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  70. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  71. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  72. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  73. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  74. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  75. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  76. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  77. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  78. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  79. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  80. package/src/__tests__/openai-provider.test.ts +5 -5
  81. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  82. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  83. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  84. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  85. package/src/__tests__/pricing.test.ts +11 -0
  86. package/src/__tests__/process-message-display-content.test.ts +20 -9
  87. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  88. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  89. package/src/__tests__/reaction-persistence.test.ts +22 -198
  90. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  91. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  92. package/src/__tests__/skill-load-tool.test.ts +27 -0
  93. package/src/__tests__/skills.test.ts +34 -1
  94. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  95. package/src/__tests__/terminal-tools.test.ts +9 -0
  96. package/src/__tests__/thread-backfill.test.ts +5 -3
  97. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  98. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  99. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  100. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  101. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  102. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  103. package/src/agent/attachments.ts +13 -2
  104. package/src/agent/loop.ts +3 -19
  105. package/src/api/README.md +9 -5
  106. package/src/api/index.ts +9 -8
  107. package/src/api/package.json +1 -0
  108. package/src/api/responses/conversation-message.ts +8 -0
  109. package/src/api/responses/home.ts +7 -18
  110. package/src/api/surfaces.ts +114 -7
  111. package/src/approvals/AGENTS.md +1 -1
  112. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  113. package/src/channels/__tests__/types.test.ts +22 -1
  114. package/src/channels/gateway-guardian-requests.ts +0 -22
  115. package/src/channels/types.ts +8 -6
  116. package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
  117. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  118. package/src/cli/commands/channels/index.help.ts +19 -6
  119. package/src/cli/commands/channels/index.ts +6 -2
  120. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  121. package/src/cli/commands/db/index.help.ts +1 -1
  122. package/src/cli/commands/db/status.ts +172 -1
  123. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  124. package/src/cli/commands/platform/connect.ts +14 -5
  125. package/src/cli/commands/plugins.help.ts +6 -5
  126. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  127. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  128. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  129. package/src/cli/lib/bundled-marketplace.json +14 -0
  130. package/src/cli/lib/install-from-github.ts +9 -5
  131. package/src/cli/lib/install-from-platform.ts +12 -1
  132. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  133. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  134. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  135. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  136. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  137. package/src/config/call-site-defaults.ts +4 -1
  138. package/src/config/feature-flag-registry.json +21 -4
  139. package/src/config/schemas/memory-v3.ts +4 -3
  140. package/src/context/strip-injections.ts +17 -56
  141. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  142. package/src/conversations/message-consolidation.ts +4 -3
  143. package/src/credential-health/credential-health-service.ts +130 -0
  144. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  145. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  146. package/src/daemon/conversation-error.ts +2 -0
  147. package/src/daemon/conversation-messaging.ts +195 -47
  148. package/src/daemon/conversation-process.ts +20 -5
  149. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  150. package/src/daemon/conversation-store.ts +159 -11
  151. package/src/daemon/conversation-tool-setup.ts +9 -2
  152. package/src/daemon/conversation.ts +290 -19
  153. package/src/daemon/dictation-text-processing.ts +2 -7
  154. package/src/daemon/handlers/config-channels.ts +33 -3
  155. package/src/daemon/handlers/config-model.test.ts +1 -0
  156. package/src/daemon/handlers/shared.ts +9 -0
  157. package/src/daemon/port-oversized-content.test.ts +117 -0
  158. package/src/daemon/port-oversized-content.ts +110 -0
  159. package/src/daemon/process-message.ts +87 -16
  160. package/src/daemon/reaction-record.test.ts +23 -11
  161. package/src/daemon/reaction-record.ts +6 -9
  162. package/src/documents/document-store.ts +1 -5
  163. package/src/home/conversation-starter-validation.ts +1 -5
  164. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  165. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  166. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  167. package/src/live-voice/live-voice-photo.ts +544 -61
  168. package/src/live-voice/live-voice-session.ts +6 -2
  169. package/src/live-voice/protocol.ts +20 -1
  170. package/src/messaging/provider-message-metadata.ts +40 -1
  171. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  172. package/src/messaging/providers/channel-transport.ts +28 -0
  173. package/src/messaging/providers/discord/send.ts +3 -2
  174. package/src/messaging/providers/slack/api.ts +11 -1
  175. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  176. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  177. package/src/messaging/providers/slack/send.test.ts +58 -0
  178. package/src/messaging/providers/slack/send.ts +4 -4
  179. package/src/messaging/providers/slack/transport.ts +28 -2
  180. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  181. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  182. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  183. package/src/messaging/reaction-envelopes.test.ts +73 -0
  184. package/src/messaging/reaction-envelopes.ts +33 -5
  185. package/src/messaging/read-provider-metadata.ts +4 -3
  186. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  187. package/src/monitoring/recovery/db.ts +35 -0
  188. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  189. package/src/monitoring/recovery/run-recovery.ts +6 -2
  190. package/src/monitoring/recovery/stale-processing.ts +3 -20
  191. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  192. package/src/notifications/AGENTS.md +2 -2
  193. package/src/notifications/README.md +2 -2
  194. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  195. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  196. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  197. package/src/notifications/access-request-copy.ts +126 -114
  198. package/src/notifications/adapters/discord.ts +9 -5
  199. package/src/notifications/adapters/shared.ts +17 -4
  200. package/src/notifications/adapters/slack.ts +14 -4
  201. package/src/notifications/adapters/telegram.ts +8 -4
  202. package/src/notifications/approval-card-data.ts +5 -2
  203. package/src/notifications/assistant-reply-producer.ts +7 -7
  204. package/src/notifications/broadcaster.ts +57 -25
  205. package/src/notifications/conversation-pairing.ts +68 -2
  206. package/src/notifications/copy-composer.ts +13 -32
  207. package/src/notifications/decision-engine.ts +56 -237
  208. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  209. package/src/notifications/guardian-feed-projection.ts +0 -15
  210. package/src/notifications/guardian-question-mode.ts +147 -6
  211. package/src/notifications/notification-utils.ts +117 -1
  212. package/src/permissions/confirmation-guardian-request.ts +2 -2
  213. package/src/permissions/prompter.ts +1 -1
  214. package/src/persistence/conversation-crud.ts +93 -14
  215. package/src/persistence/conversation-queries.ts +144 -17
  216. package/src/persistence/conversation-types.ts +39 -5
  217. package/src/persistence/delivery-crud.ts +224 -28
  218. package/src/persistence/delivery-status.ts +21 -2
  219. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  220. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  221. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  222. package/src/persistence/schema/conversations.ts +71 -2
  223. package/src/persistence/schema-contract.test.ts +78 -0
  224. package/src/persistence/schema-contract.ts +96 -0
  225. package/src/persistence/steps.ts +4 -0
  226. package/src/platform/client.ts +55 -0
  227. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  228. package/src/plugins/defaults/injector-order.ts +2 -1
  229. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  230. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  231. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  232. package/src/plugins/defaults/memory/injectors.ts +1 -1
  233. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  234. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  235. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
  236. package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
  237. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  238. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  239. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
  240. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  241. package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
  242. package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
  243. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  244. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  245. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  246. package/src/plugins/mcp-servers.ts +14 -2
  247. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  248. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  249. package/src/providers/anthropic/client.ts +21 -24
  250. package/src/providers/call-site-routing.ts +5 -4
  251. package/src/providers/connection-resolution.ts +29 -1
  252. package/src/providers/content-block-size.test.ts +82 -0
  253. package/src/providers/content-block-size.ts +99 -0
  254. package/src/providers/file-block-text.test.ts +64 -0
  255. package/src/providers/file-block-text.ts +28 -0
  256. package/src/providers/gemini/client.ts +15 -9
  257. package/src/providers/inference/auth.ts +6 -6
  258. package/src/providers/media-resolve.ts +14 -0
  259. package/src/providers/model-catalog.ts +47 -34
  260. package/src/providers/openai/chat-completions-provider.ts +9 -25
  261. package/src/providers/openai/responses-provider.ts +11 -18
  262. package/src/providers/registry.ts +10 -7
  263. package/src/providers/routing-identity.ts +2 -1
  264. package/src/providers/types.ts +1 -4
  265. package/src/providers/vellum-model-routing.ts +3 -2
  266. package/src/runtime/AGENTS.md +40 -15
  267. package/src/runtime/approval-message-composer.ts +1 -6
  268. package/src/runtime/assistant-event-hub.ts +2 -39
  269. package/src/runtime/channel-approval-types.ts +1 -5
  270. package/src/runtime/channel-reply-delivery.ts +182 -115
  271. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  272. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  273. package/src/runtime/channel-retry-sweep.ts +102 -1
  274. package/src/runtime/finalize-event-delivery.ts +4 -4
  275. package/src/runtime/guardian-action-message-composer.ts +1 -4
  276. package/src/runtime/guardian-reply-router.ts +4 -75
  277. package/src/runtime/http-router.ts +1 -5
  278. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  279. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  280. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  281. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  282. package/src/runtime/routes/acp-routes.ts +1 -1
  283. package/src/runtime/routes/canned-reply-release.ts +19 -9
  284. package/src/runtime/routes/channel-route-shared.ts +0 -39
  285. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  286. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  287. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  288. package/src/runtime/routes/conversation-routes.ts +138 -59
  289. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  290. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  291. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  292. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  294. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  295. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  296. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  297. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  298. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  299. package/src/runtime/routes/index.ts +2 -0
  300. package/src/runtime/routes/platform-routes.ts +99 -10
  301. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  302. package/src/runtime/sync/resource-sync-events.ts +14 -37
  303. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  304. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  305. package/src/tools/client-os.ts +11 -1
  306. package/src/tools/host-filesystem/edit.ts +2 -2
  307. package/src/tools/host-filesystem/read.ts +2 -2
  308. package/src/tools/host-filesystem/transfer.ts +2 -2
  309. package/src/tools/host-filesystem/write.ts +2 -2
  310. package/src/tools/host-terminal/host-shell.ts +2 -2
  311. package/src/tools/skills/load.ts +1 -1
  312. package/src/tools/terminal/safe-env.ts +4 -0
  313. package/src/tools/tool-input-schemas.ts +2 -0
  314. package/src/tools/tool-manifest.ts +2 -0
  315. package/src/tools/ui-surface/definitions.ts +4 -3
  316. package/src/tools/watch/watch-retro-report.ts +205 -0
  317. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  318. package/src/watch/watch-retro.ts +190 -77
  319. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  320. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  321. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  322. package/src/workspace/migrations/registry.ts +4 -0
  323. package/docker-kata-pip-chroot.sh +0 -22
  324. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  325. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  326. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -323,6 +323,42 @@ describe("TelegramAdapter", () => {
323
323
  expect(call.text).toContain("XYZW");
324
324
  });
325
325
 
326
+ test("a question with no options is sent as text with its typed-reply instruction", async () => {
327
+ const adapter = new TelegramAdapter();
328
+ const payload = makePayload({
329
+ sourceEventName: "guardian.question",
330
+ copy: {
331
+ title: "Question",
332
+ body: "What time works?",
333
+ deliveryText: "What time works?",
334
+ },
335
+ contextPayload: {
336
+ requestId: "req-voice-1",
337
+ requestCode: "DEF456",
338
+ requestKind: "pending_question",
339
+ questionText: "What time works?",
340
+ },
341
+ approvalContext: {
342
+ requestId: "req-voice-1",
343
+ actions: [],
344
+ plainTextFallback:
345
+ 'Reference code: DEF456. Reply "DEF456 <your answer>".',
346
+ intent: "question",
347
+ },
348
+ });
349
+
350
+ const result = await adapter.send(payload, makeDestination());
351
+
352
+ expect(result.success).toBe(true);
353
+ expect(sendCalls).toHaveLength(1);
354
+ // No buttons to draw, so no keyboard is attempted and the instruction
355
+ // joins the text: this is the only place the guardian learns the code.
356
+ expect(sendCalls[0]?.approval).toBeUndefined();
357
+ expect(sendCalls[0]?.text).toBe(
358
+ 'What time works?\n\nReference code: DEF456. Reply "DEF456 <your answer>".',
359
+ );
360
+ });
361
+
326
362
  describe("update", () => {
327
363
  test("edits the delivered message in place and keeps its id", async () => {
328
364
  const adapter = new TelegramAdapter();
@@ -1962,7 +1962,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
1962
1962
  test('DeepSeek V4 Pro accepts "max" unclamped', async () => {
1963
1963
  const fw = new FireworksProvider(
1964
1964
  "fw-key",
1965
- "accounts/fireworks/models/deepseek-v4-pro",
1965
+ "accounts/fireworks/models/deepseek-v4-pro-0813",
1966
1966
  );
1967
1967
  await fw.sendMessage([userMsg("hi")], {
1968
1968
  systemPrompt: "system",
@@ -1974,7 +1974,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
1974
1974
  test('DeepSeek V4 Pro accepts "xhigh" unclamped', async () => {
1975
1975
  const fw = new FireworksProvider(
1976
1976
  "fw-key",
1977
- "accounts/fireworks/models/deepseek-v4-pro",
1977
+ "accounts/fireworks/models/deepseek-v4-pro-0813",
1978
1978
  );
1979
1979
  await fw.sendMessage([userMsg("hi")], {
1980
1980
  systemPrompt: "system",
@@ -2015,7 +2015,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
2015
2015
  test("effort below ceiling is forwarded verbatim", async () => {
2016
2016
  const fw = new FireworksProvider(
2017
2017
  "fw-key",
2018
- "accounts/fireworks/models/deepseek-v4-pro",
2018
+ "accounts/fireworks/models/deepseek-v4-pro-0813",
2019
2019
  );
2020
2020
  await fw.sendMessage([userMsg("hi")], {
2021
2021
  systemPrompt: "system",
@@ -2045,7 +2045,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
2045
2045
  systemPrompt: "system",
2046
2046
  config: {
2047
2047
  effort: "max",
2048
- model: "accounts/fireworks/models/deepseek-v4-pro",
2048
+ model: "accounts/fireworks/models/deepseek-v4-pro-0813",
2049
2049
  },
2050
2050
  });
2051
2051
  expect(lastCreateParams!.reasoning_effort).toBe("max");
@@ -2110,7 +2110,7 @@ describe("FireworksProvider sparse reasoning_effort support (GLM 5.3)", () => {
2110
2110
 
2111
2111
  test("models without supportedEfforts are unaffected", async () => {
2112
2112
  expect(
2113
- await send("accounts/fireworks/models/deepseek-v4-pro", "medium"),
2113
+ await send("accounts/fireworks/models/deepseek-v4-pro-0813", "medium"),
2114
2114
  ).toBe("medium");
2115
2115
  });
2116
2116
  });
@@ -138,6 +138,13 @@ mock.module("../persistence/conversation-crud.js", () => ({
138
138
  ),
139
139
  }));
140
140
 
141
+ const recordedOutboundPosts: Array<Record<string, string>> = [];
142
+ mock.module("../persistence/delivery-crud.js", () => ({
143
+ recordOutboundPost: (post: Record<string, string>) => {
144
+ recordedOutboundPosts.push(post);
145
+ },
146
+ }));
147
+
141
148
  mock.module("../persistence/llm-request-log-store.js", () => ({
142
149
  recordRequestLog: () => {},
143
150
  backfillMessageIdOnLogs: () => {},
@@ -176,7 +183,7 @@ import {
176
183
  handleLlmCallStarted,
177
184
  handleMessageComplete,
178
185
  } from "../daemon/conversation-agent-loop-handlers.js";
179
- import { readSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
186
+ import { readSlackMetadataFromMessageMetadata } from "../messaging/providers/slack/message-metadata.js";
180
187
  import { deliverReplyViaCallback } from "../runtime/channel-reply-delivery.js";
181
188
  import { setConfig } from "./helpers/set-config.js";
182
189
 
@@ -266,6 +273,7 @@ describe("outbound assistant Slack metadata persistence", () => {
266
273
  persistedRows.length = 0;
267
274
  setConfig("ui", {});
268
275
  nextDeliveryTs = null;
276
+ recordedOutboundPosts.length = 0;
269
277
  state = createEventHandlerState();
270
278
  state.turnStartedAt = 1_700_000_000_000;
271
279
  });
@@ -276,13 +284,14 @@ describe("outbound assistant Slack metadata persistence", () => {
276
284
  nextDeliveryTs = null;
277
285
  });
278
286
 
279
- test("stamps slackMeta with threadTs from the turn's inbound thread id", async () => {
287
+ test("stamps the neutral envelope with the turn's inbound thread id", async () => {
280
288
  const conversationId = "conv-slack-threaded";
281
289
  const channelId = "C123CHANNEL";
282
290
 
283
- // The turn arrived in a thread — its inbound thread ts is captured on the
291
+ // The turn arrived in a thread: its inbound thread ts is captured on the
284
292
  // trust context (`sourceThreadId`) at ingress, and the reply is stamped
285
- // from that turn-local value.
293
+ // from that turn-local value. A Slack reply row writes the same neutral
294
+ // envelope every channel writes; Slack's own fields ride its passthrough.
286
295
  const deps = makeDeps(conversationId, {
287
296
  assistantMessageChannel: "slack",
288
297
  requesterChatId: channelId,
@@ -292,27 +301,29 @@ describe("outbound assistant Slack metadata persistence", () => {
292
301
  await handleMessageComplete(state, deps, makeMessageCompleteEvent("hi"));
293
302
 
294
303
  const persisted = lastAssistantPersisted();
295
- const slackMetaRaw = persisted.metadata?.slackMeta;
296
- expect(typeof slackMetaRaw).toBe("string");
304
+ expect(persisted.metadata?.slackMeta).toBeUndefined();
305
+ const providerMetaRaw = persisted.metadata?.providerMeta;
306
+ expect(typeof providerMetaRaw).toBe("string");
297
307
 
298
- const slackMeta = JSON.parse(slackMetaRaw as string) as Record<
308
+ const providerMeta = JSON.parse(providerMetaRaw as string) as Record<
299
309
  string,
300
310
  unknown
301
311
  >;
302
- expect(slackMeta.source).toBe("slack");
303
- expect(slackMeta.eventKind).toBe("message");
304
- expect(slackMeta.channelId).toBe(channelId);
305
- expect(slackMeta.threadTs).toBe("1234.5678");
312
+ expect(providerMeta.source).toBe("slack");
313
+ expect(providerMeta.eventKind).toBe("message");
314
+ expect(providerMeta.conversationExternalId).toBe(channelId);
315
+ expect(providerMeta.threadId).toBe("1234.5678");
306
316
 
307
317
  // Persistence runs BEFORE the Slack adapter posts the message, so the
308
- // authoritative `ts` (-> `channelTs`) is not yet known at this layer.
309
- // The post-send reconciliation in `deliverReplyViaCallback` fills the
310
- // field once the gateway returns the Slack-assigned ts (covered by
311
- // `channel-reply-delivery.test.ts`). Until that runs, the partial
312
- // metadata is intentionally rejected by `readSlackMetadata` so callers
313
- // that try to use it before reconciliation get a clear null.
314
- expect(slackMeta.channelTs).toBeUndefined();
315
- expect(readSlackMetadata(slackMetaRaw as string)).toBeNull();
318
+ // authoritative ts (the envelope's `messageId`) is not yet known at this
319
+ // layer. The post-send reconciliation in `deliverReplyViaCallback` fills
320
+ // it once the gateway returns the Slack-assigned ts (covered by
321
+ // `channel-reply-delivery.test.ts`). Until then the row has no Slack
322
+ // view, so a Slack reader gets a clear null rather than a half row.
323
+ expect(providerMeta.messageId).toBeUndefined();
324
+ expect(
325
+ readSlackMetadataFromMessageMetadata(JSON.stringify(persisted.metadata)),
326
+ ).toBeNull();
316
327
  });
317
328
 
318
329
  test("stamps assistant Slack rows with effective timestamp timezone and no speaker suffix", async () => {
@@ -335,16 +346,12 @@ describe("outbound assistant Slack metadata persistence", () => {
335
346
  );
336
347
 
337
348
  const persisted = lastAssistantPersisted();
338
- const slackMetaRaw = persisted.metadata?.slackMeta;
339
- expect(typeof slackMetaRaw).toBe("string");
340
-
341
- const slackMeta = JSON.parse(slackMetaRaw as string) as Record<
342
- string,
343
- unknown
344
- >;
345
- expect(slackMeta.timestampTimezone).toBe("America/Denver");
346
- expect(slackMeta.timestampTimezoneLabel).toBe("MT");
347
- expect(slackMeta.speakerTimezoneLabel).toBeUndefined();
349
+ const providerMeta = JSON.parse(
350
+ persisted.metadata?.providerMeta as string,
351
+ ) as Record<string, unknown>;
352
+ expect(providerMeta.timestampTimezone).toBe("America/Denver");
353
+ expect(providerMeta.timestampTimezoneLabel).toBe("MT");
354
+ expect(providerMeta.speakerTimezoneLabel).toBeUndefined();
348
355
  });
349
356
 
350
357
  test("falls back to the turn client timezone when no configured user timezone is set", async () => {
@@ -364,14 +371,14 @@ describe("outbound assistant Slack metadata persistence", () => {
364
371
  );
365
372
 
366
373
  const persisted = lastAssistantPersisted();
367
- const slackMeta = JSON.parse(
368
- persisted.metadata?.slackMeta as string,
374
+ const providerMeta = JSON.parse(
375
+ persisted.metadata?.providerMeta as string,
369
376
  ) as Record<string, unknown>;
370
- expect(slackMeta.timestampTimezone).toBe("America/Los_Angeles");
371
- expect(slackMeta.timestampTimezoneLabel).toBe("PT");
377
+ expect(providerMeta.timestampTimezone).toBe("America/Los_Angeles");
378
+ expect(providerMeta.timestampTimezoneLabel).toBe("PT");
372
379
  });
373
380
 
374
- test("post-send reconciliation preserves assistant Slack timezone metadata", async () => {
381
+ test("post-send reconciliation gives the row its Slack view, timezone fields intact", async () => {
375
382
  setConfig("ui", { userTimezone: "America/Denver" });
376
383
  const conversationId = "conv-slack-reconcile-timezone";
377
384
  const channelId = "C999RECONCILE";
@@ -389,9 +396,9 @@ describe("outbound assistant Slack metadata persistence", () => {
389
396
  );
390
397
 
391
398
  const persisted = lastAssistantPersisted();
392
- const beforeRaw = persisted.metadata?.slackMeta;
393
- expect(typeof beforeRaw).toBe("string");
394
- expect(readSlackMetadata(beforeRaw as string)).toBeNull();
399
+ expect(
400
+ readSlackMetadataFromMessageMetadata(JSON.stringify(persisted.metadata)),
401
+ ).toBeNull();
395
402
 
396
403
  nextDeliveryTs = "1772678280.000200";
397
404
  await deliverReplyViaCallback(
@@ -406,19 +413,28 @@ describe("outbound assistant Slack metadata persistence", () => {
406
413
  (candidate) => candidate.id === persisted.id,
407
414
  );
408
415
  expect(typeof row?.metadata).toBe("string");
409
- const envelope = JSON.parse(row!.metadata!) as Record<string, unknown>;
410
- const reconciled = readSlackMetadata(envelope.slackMeta as string);
411
- expect(reconciled).not.toBeNull();
412
- expect(reconciled!.channelTs).toBe("1772678280.000200");
413
- expect(reconciled!.timestampTimezone).toBe("America/Denver");
414
- expect(reconciled!.timestampTimezoneLabel).toBe("MT");
415
- expect(reconciled!.speakerTimezoneLabel).toBeUndefined();
416
+ // The Slack renderers read the reconciled row through its Slack view.
417
+ const view = readSlackMetadataFromMessageMetadata(row!.metadata!);
418
+ expect(view).not.toBeNull();
419
+ expect(view!.channelId).toBe(channelId);
420
+ expect(view!.channelTs).toBe("1772678280.000200");
421
+ expect(view!.timestampTimezone).toBe("America/Denver");
422
+ expect(view!.timestampTimezoneLabel).toBe("MT");
423
+ expect(view!.speakerTimezoneLabel).toBeUndefined();
424
+ // And the post is in the outbound index, exactly as any channel's is.
425
+ expect(recordedOutboundPosts).toContainEqual({
426
+ sourceChannel: "slack",
427
+ externalChatId: channelId,
428
+ providerMessageId: "1772678280.000200",
429
+ messageId: persisted.id,
430
+ conversationId,
431
+ });
416
432
  });
417
433
 
418
- test("stamps slackMeta WITHOUT threadTs for top-level Slack replies", async () => {
434
+ test("stamps no threadId for top-level Slack replies", async () => {
419
435
  const conversationId = "conv-slack-toplevel";
420
436
  const channelId = "C456NOTHREAD";
421
- // The turn arrived at the channel root — no `sourceThreadId` on the trust
437
+ // The turn arrived at the channel root: no `sourceThreadId` on the trust
422
438
  // context, so the reply targets the channel root, not a thread.
423
439
 
424
440
  const deps = makeDeps(conversationId, {
@@ -429,20 +445,14 @@ describe("outbound assistant Slack metadata persistence", () => {
429
445
  await handleMessageComplete(state, deps, makeMessageCompleteEvent("hello"));
430
446
 
431
447
  const persisted = lastAssistantPersisted();
432
- const slackMetaRaw = persisted.metadata?.slackMeta;
433
- expect(typeof slackMetaRaw).toBe("string");
434
-
435
- const slackMeta = JSON.parse(slackMetaRaw as string) as Record<
436
- string,
437
- unknown
438
- >;
439
- expect(slackMeta.source).toBe("slack");
440
- expect(slackMeta.eventKind).toBe("message");
441
- expect(slackMeta.channelId).toBe(channelId);
442
- // threadTs is intentionally absent — top-level reply.
443
- expect(slackMeta.threadTs).toBeUndefined();
444
- // channelTs is still absent for the same persistence-vs-send reason.
445
- expect(slackMeta.channelTs).toBeUndefined();
448
+ const providerMeta = JSON.parse(
449
+ persisted.metadata?.providerMeta as string,
450
+ ) as Record<string, unknown>;
451
+ expect(providerMeta.source).toBe("slack");
452
+ expect(providerMeta.eventKind).toBe("message");
453
+ expect(providerMeta.conversationExternalId).toBe(channelId);
454
+ expect(providerMeta.threadId).toBeUndefined();
455
+ expect(providerMeta.messageId).toBeUndefined();
446
456
  });
447
457
 
448
458
  test("envelope resolves from the turn's actor when the slot has moved", async () => {
@@ -471,17 +481,17 @@ describe("outbound assistant Slack metadata persistence", () => {
471
481
 
472
482
  const persisted = lastAssistantPersisted();
473
483
  expect(persisted.metadata?.provenanceTrustClass).toBe("trusted_contact");
474
- const slackMeta = JSON.parse(
475
- persisted.metadata?.slackMeta as string,
484
+ const providerMeta = JSON.parse(
485
+ persisted.metadata?.providerMeta as string,
476
486
  ) as Record<string, unknown>;
477
- expect(slackMeta.channelId).toBe("C-CONTACT");
478
- expect(slackMeta.threadTs).toBe("1723300000.000100");
487
+ expect(providerMeta.conversationExternalId).toBe("C-CONTACT");
488
+ expect(providerMeta.threadId).toBe("1723300000.000100");
479
489
  });
480
490
 
481
491
  test("ignores a non-ts sourceThreadId", async () => {
482
492
  const conversationId = "conv-slack-bad-thread";
483
493
  const channelId = "C789BAD";
484
- // A malformed thread id must not be stamped as a threadTs (isSlackTs guard).
494
+ // A malformed thread id must not be stamped as a thread (isSlackTs guard).
485
495
  const deps = makeDeps(conversationId, {
486
496
  assistantMessageChannel: "slack",
487
497
  requesterChatId: channelId,
@@ -495,13 +505,10 @@ describe("outbound assistant Slack metadata persistence", () => {
495
505
  );
496
506
 
497
507
  const persisted = lastAssistantPersisted();
498
- const slackMetaRaw = persisted.metadata?.slackMeta;
499
- expect(typeof slackMetaRaw).toBe("string");
500
- const slackMeta = JSON.parse(slackMetaRaw as string) as Record<
501
- string,
502
- unknown
503
- >;
504
- expect(slackMeta.threadTs).toBeUndefined();
508
+ const providerMeta = JSON.parse(
509
+ persisted.metadata?.providerMeta as string,
510
+ ) as Record<string, unknown>;
511
+ expect(providerMeta.threadId).toBeUndefined();
505
512
  });
506
513
 
507
514
  test("does NOT stamp slackMeta on non-Slack outbound assistant messages", async () => {
@@ -613,6 +620,12 @@ describe("outbound assistant Slack metadata persistence", () => {
613
620
  ) as Record<string, unknown>;
614
621
  expect(metaAfter.messageId).toBe("1234567890123456789");
615
622
  expect(metaAfter.additionalMessageIds).toEqual(["8888888888888888888"]);
623
+ // Both ids also land in the channel_outbound_posts resolution index,
624
+ // written by the same reconciliation so envelope and index cannot drift.
625
+ expect(recordedOutboundPosts.map((post) => post.providerMessageId)).toEqual(
626
+ ["1234567890123456789", "8888888888888888888"],
627
+ );
628
+ expect(recordedOutboundPosts[0].messageId).toBe(persisted.id);
616
629
 
617
630
  // A redelivery reporting an already-recorded id changes nothing.
618
631
  nextDeliveryTs = "8888888888888888888";
@@ -1,111 +1,129 @@
1
1
  /**
2
- * Regression: `persistUserMessage` calls `ctx.setProcessing(true)`, which
3
- * persists the flag to the DB and can throw (e.g. SQLITE_BUSY). That call must
4
- * run inside the function's try/catch so a failure unwinds the request-id and
5
- * abort-controller bookkeeping instead of stranding it on the conversation.
6
- * A later failure while clearing the flag must not mask the original error.
2
+ * How `persistUserMessage` takes and gives back the conversation's processing
3
+ * flag.
4
+ *
5
+ * It acquires rather than sets: the read and the take are one step, and the
6
+ * hold it takes is released only by the claim that took it. That is what keeps
7
+ * a turn that is starting from publishing an idle conversation, and what keeps
8
+ * an earlier holder from releasing a turn that claimed the flag away.
9
+ *
10
+ * A failure anywhere after the acquire must unwind the request-id and
11
+ * abort-controller bookkeeping rather than stranding it on the conversation,
12
+ * and a failure while releasing must not mask the original error.
7
13
  */
8
14
  import { describe, expect, test } from "bun:test";
9
15
 
10
16
  import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
11
- import { persistUserMessage } from "../daemon/conversation-messaging.js";
17
+ import {
18
+ CONVERSATION_BUSY_MESSAGE,
19
+ persistUserMessage,
20
+ } from "../daemon/conversation-messaging.js";
12
21
 
13
- type SetProcessingBehavior = (value: boolean) => void;
22
+ interface ContextBehavior {
23
+ /** Answered in place of taking the flag. `undefined` takes it for real. */
24
+ acquire?: () => Promise<number | null>;
25
+ /** Runs before the release completes, so it can throw. */
26
+ onRelease?: (owner: number) => void;
27
+ }
14
28
 
15
- function makeContext(
16
- setProcessing: SetProcessingBehavior,
17
- ): MessagingConversationContext & {
18
- processing: boolean;
29
+ function makeContext(behavior: ContextBehavior = {}): {
30
+ ctx: MessagingConversationContext;
31
+ processing: () => boolean;
32
+ releases: number[];
19
33
  } {
20
34
  let processing = false;
35
+ let owner = 0;
36
+ const releases: number[] = [];
21
37
  const ctx = {
22
- conversationId: "conv-set-processing-failure",
38
+ conversationId: "conv-processing-ownership",
23
39
  messages: [],
24
40
  abortController: null,
25
41
  currentRequestId: undefined,
26
42
  queue: {} as never,
27
- get processing() {
28
- return processing;
29
- },
30
43
  isProcessing: () => processing,
31
44
  setProcessing: (value: boolean) => {
32
- setProcessing(value);
33
45
  processing = value;
34
46
  },
47
+ acquireProcessingFenced: async () => {
48
+ if (behavior.acquire) {
49
+ return behavior.acquire();
50
+ }
51
+ if (processing) {
52
+ return null;
53
+ }
54
+ processing = true;
55
+ owner += 1;
56
+ return owner;
57
+ },
58
+ releaseProcessing: (claim: number) => {
59
+ releases.push(claim);
60
+ behavior.onRelease?.(claim);
61
+ if (claim !== owner) {
62
+ return false;
63
+ }
64
+ processing = false;
65
+ return true;
66
+ },
35
67
  getTurnChannelContext: () => null,
36
68
  getTurnInterfaceContext: () => null,
37
- } as unknown as MessagingConversationContext & { processing: boolean };
38
- return ctx;
69
+ } as unknown as MessagingConversationContext;
70
+ return { ctx, processing: () => processing, releases };
39
71
  }
40
72
 
41
- describe("persistUserMessage setProcessing failure", () => {
42
- test("clears request-id and abort bookkeeping when setProcessing(true) throws", async () => {
43
- const ctx = makeContext((value) => {
44
- if (value) {
45
- throw new Error("database is locked (SQLITE_BUSY)");
46
- }
47
- });
73
+ describe("persistUserMessage processing ownership", () => {
74
+ test("unwinds its bookkeeping and its hold when the persist fails", async () => {
75
+ // No database stands behind this context, so the body throws. The flag it
76
+ // took has to come back with it.
77
+ const { ctx, processing, releases } = makeContext();
48
78
 
49
79
  await expect(
50
80
  persistUserMessage(ctx, { content: "hello", requestId: "req-1" }),
51
- ).rejects.toThrow("database is locked");
81
+ ).rejects.toThrow();
52
82
 
53
83
  expect(ctx.currentRequestId).toBeUndefined();
54
84
  expect(ctx.abortController).toBeNull();
55
- expect(ctx.isProcessing()).toBe(false);
85
+ expect(processing()).toBe(false);
86
+ expect(releases).toEqual([1]);
56
87
  });
57
88
 
58
- test("retries a transient SQLITE_BUSY from setProcessing(true)", async () => {
59
- // A coded SQLITE_BUSY is transient WAL contention: `persistUserMessage`
60
- // wraps the acquiring `setProcessing(true)` in `withSqliteRetry`, so it is
61
- // re-attempted rather than failing the send immediately.
62
- let trueCalls = 0;
63
- const ctx = makeContext((value) => {
64
- if (value) {
65
- trueCalls += 1;
66
- throw Object.assign(new Error("database is locked"), {
67
- code: "SQLITE_BUSY",
68
- });
69
- }
70
- });
89
+ test("refuses a conversation another holder is already processing", async () => {
90
+ const { ctx, releases } = makeContext();
91
+ ctx.setProcessing(true);
71
92
 
72
93
  await expect(
73
94
  persistUserMessage(ctx, { content: "hello", requestId: "req-busy" }),
74
- ).rejects.toThrow("database is locked");
95
+ ).rejects.toThrow(CONVERSATION_BUSY_MESSAGE);
75
96
 
76
- // Initial attempt + 3 retries before withSqliteRetry gives up.
77
- expect(trueCalls).toBe(4);
78
- expect(ctx.currentRequestId).toBeUndefined();
79
- expect(ctx.abortController).toBeNull();
97
+ // Nothing was taken, so nothing is given back.
98
+ expect(releases).toEqual([]);
80
99
  });
81
100
 
82
- test("does not retry a non-transient setProcessing(true) failure", async () => {
83
- // No SQLite code / a non-BUSY code → not contention → fail fast, no retry.
84
- let trueCalls = 0;
85
- const ctx = makeContext((value) => {
86
- if (value) {
87
- trueCalls += 1;
88
- throw new Error("boom");
89
- }
90
- });
101
+ test("reports busy rather than stealing a flag the acquire finds taken", async () => {
102
+ // The acquire is the only thing that decides: a caller cannot set its way
103
+ // past a holder.
104
+ const { ctx, releases } = makeContext({ acquire: async () => null });
91
105
 
92
106
  await expect(
93
- persistUserMessage(ctx, { content: "hello", requestId: "req-boom" }),
94
- ).rejects.toThrow("boom");
107
+ persistUserMessage(ctx, { content: "hello", requestId: "req-taken" }),
108
+ ).rejects.toThrow(CONVERSATION_BUSY_MESSAGE);
95
109
 
96
- expect(trueCalls).toBe(1);
110
+ expect(ctx.currentRequestId).toBeUndefined();
111
+ expect(ctx.abortController).toBeNull();
112
+ expect(releases).toEqual([]);
97
113
  });
98
114
 
99
- test("a failing clear does not mask the original persist error", async () => {
100
- // setProcessing throws on both the set and the clear; the original error
101
- // must still propagate and the bookkeeping must still be reset.
102
- const ctx = makeContext(() => {
103
- throw new Error("database is locked (SQLITE_BUSY)");
115
+ test("a failing release does not mask the original error", async () => {
116
+ const { ctx } = makeContext({
117
+ onRelease: () => {
118
+ throw new Error("release exploded");
119
+ },
104
120
  });
105
121
 
106
- await expect(
107
- persistUserMessage(ctx, { content: "hello", requestId: "req-2" }),
108
- ).rejects.toThrow("database is locked");
122
+ const attempt = persistUserMessage(ctx, {
123
+ content: "hello",
124
+ requestId: "req-2",
125
+ });
126
+ await expect(attempt).rejects.not.toThrow("release exploded");
109
127
 
110
128
  expect(ctx.currentRequestId).toBeUndefined();
111
129
  expect(ctx.abortController).toBeNull();
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ let platformAssistantId = "asst_123";
4
+ let fetchImpl: (
5
+ url: string,
6
+ init?: RequestInit,
7
+ ) => Promise<Response> = async () => new Response("{}", { status: 200 });
8
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
9
+
10
+ mock.module("../platform/feature-gate.js", () => ({
11
+ arePlatformFeaturesEnabled: () => true,
12
+ }));
13
+
14
+ mock.module("../providers/platform-proxy/context.js", () => ({
15
+ resolveManagedProxyContext: async () => ({
16
+ enabled: true,
17
+ platformBaseUrl: "https://platform.test/",
18
+ assistantApiKey: "assistant-key",
19
+ }),
20
+ }));
21
+
22
+ mock.module("../config/env.js", () => ({
23
+ getPlatformAssistantId: () => platformAssistantId,
24
+ }));
25
+
26
+ mock.module("../security/secure-keys.js", () => ({
27
+ getSecureKeyAsync: async () => null,
28
+ }));
29
+
30
+ const { VellumPlatformClient } = await import("../platform/client.js");
31
+
32
+ const originalFetch = globalThis.fetch;
33
+
34
+ beforeEach(() => {
35
+ platformAssistantId = "asst_123";
36
+ fetchCalls.length = 0;
37
+ fetchImpl = async () => new Response("{}", { status: 200 });
38
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
39
+ const url = typeof input === "string" ? input : input.toString();
40
+ fetchCalls.push({ url, init });
41
+ return fetchImpl(url, init);
42
+ }) as typeof fetch;
43
+ });
44
+
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch;
47
+ });
48
+
49
+ async function createClient() {
50
+ const client = await VellumPlatformClient.create();
51
+ if (!client) {
52
+ throw new Error("client prerequisites were not satisfied");
53
+ }
54
+ return client;
55
+ }
56
+
57
+ describe("VellumPlatformClient.verifyCredential", () => {
58
+ test("an accepted request is a valid credential, asked on the owner-consent carrier with the stored key", async () => {
59
+ const client = await createClient();
60
+
61
+ expect(await client.verifyCredential()).toBe("valid");
62
+
63
+ expect(fetchCalls).toHaveLength(1);
64
+ expect(fetchCalls[0]?.url).toBe(
65
+ "https://platform.test/v1/assistants/asst_123/owner-consent/",
66
+ );
67
+ const headers = new Headers(fetchCalls[0]?.init?.headers);
68
+ expect(headers.get("Authorization")).toBe("Api-Key assistant-key");
69
+ // The heartbeat awaits this check, so it must carry a deadline.
70
+ expect(fetchCalls[0]?.init?.signal).toBeInstanceOf(AbortSignal);
71
+ });
72
+
73
+ // Only the platform's own refusal may drive a rotation.
74
+ test.each([401, 403])("a %i is the settled rejection", async (status) => {
75
+ fetchImpl = async () => new Response("", { status });
76
+ const client = await createClient();
77
+ expect(await client.verifyCredential()).toBe("rejected");
78
+ });
79
+
80
+ test("a server error is unsettled, not a rejection", async () => {
81
+ fetchImpl = async () => new Response("", { status: 503 });
82
+ const client = await createClient();
83
+ expect(await client.verifyCredential()).toBe("unknown");
84
+ });
85
+
86
+ test("an unreachable platform is unsettled, not a rejection", async () => {
87
+ fetchImpl = async () => {
88
+ throw new TypeError("fetch failed");
89
+ };
90
+ const client = await createClient();
91
+ expect(await client.verifyCredential()).toBe("unknown");
92
+ });
93
+
94
+ test("without a platform assistant id there is nothing to verify", async () => {
95
+ platformAssistantId = "";
96
+ const client = await createClient();
97
+ expect(await client.verifyCredential()).toBe("unknown");
98
+ expect(fetchCalls).toHaveLength(0);
99
+ });
100
+ });