@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
@@ -162,6 +162,7 @@ mock.module("../ipc/gateway-client.js", () => ({
162
162
  ipcCall: ipcCallMock,
163
163
  }));
164
164
 
165
+ import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
165
166
  import type { AuthContext } from "../runtime/auth/types.js";
166
167
  import { handleSendMessage } from "../runtime/routes/conversation-routes.js";
167
168
  import { callHandler } from "./helpers/call-route-handler.js";
@@ -226,6 +227,7 @@ function makeConversation() {
226
227
  const events: unknown[] = [];
227
228
  const messages: unknown[] = [];
228
229
  let processing = false;
230
+ let owner = 0;
229
231
  const conversation = {
230
232
  conversationId: "conv-slash-test",
231
233
  messages,
@@ -244,6 +246,23 @@ function makeConversation() {
244
246
  isProcessing: () => processing,
245
247
  setProcessing: (value: boolean) => {
246
248
  processing = value;
249
+ owner = value ? owner + 1 : 0;
250
+ },
251
+ acquireProcessingFenced: async () => {
252
+ if (processing) {
253
+ return null;
254
+ }
255
+ processing = true;
256
+ owner += 1;
257
+ return owner;
258
+ },
259
+ releaseProcessing: (claim: number) => {
260
+ if (claim !== owner) {
261
+ return false;
262
+ }
263
+ processing = false;
264
+ owner = 0;
265
+ return true;
247
266
  },
248
267
  hasAnyPendingConfirmation: () => false,
249
268
  denyAllPendingConfirmations: () => {},
@@ -530,6 +549,36 @@ describe("handleSendMessage canned wake-up greeting", () => {
530
549
  }
531
550
  });
532
551
 
552
+ test("queues a first greeting whose flag went away during the awaits", async () => {
553
+ // The canned wake-up greeting used to claim the flag unconditionally, so
554
+ // it greeted over a turn already writing and both mutated one history.
555
+ // It declines instead and falls through, the way its sibling branches do,
556
+ // and the gate below answers as a busy conversation owes.
557
+ const { conversation, persistUserMessage, runAgentLoop } =
558
+ makeConversation();
559
+ conversation.setProcessing(true);
560
+ const stub = conversation as unknown as { isProcessing: () => boolean };
561
+ stub.isProcessing = () => false;
562
+ // What the real one does while another holder has the flag, which is what
563
+ // the fall-through below runs into.
564
+ persistUserMessage.mockImplementationOnce(async () => {
565
+ throw new Error(CONVERSATION_BUSY_MESSAGE);
566
+ });
567
+
568
+ const res = await callHandler(
569
+ (args) => handleSendMessage(args, makeDeps(conversation)),
570
+ makeRequest("Wake up, my friend!"),
571
+ undefined,
572
+ 202,
573
+ );
574
+
575
+ expect(res.status).toBe(202);
576
+ expect(await res.json()).toMatchObject({ accepted: true, queued: true });
577
+ expect(runAgentLoop).not.toHaveBeenCalled();
578
+ // No greeting rows: the branch never ran.
579
+ expect(addMessageMock).not.toHaveBeenCalled();
580
+ });
581
+
533
582
  test("persists the clientMessageId on the user row", async () => {
534
583
  const { conversation, runAgentLoop } = makeConversation();
535
584
  const res = await callHandler(
@@ -550,3 +599,79 @@ describe("handleSendMessage canned wake-up greeting", () => {
550
599
  expect(options?.clientMessageId).toBe("nonce-wake-123");
551
600
  });
552
601
  });
602
+
603
+ describe("handleSendMessage flag taken after its queue decision", () => {
604
+ beforeEach(() => {
605
+ addMessageMock.mockClear();
606
+ });
607
+
608
+ test("queues a plain send whose flag went away during the awaits", async () => {
609
+ // The route decides to queue or run at the top, then awaits guardian
610
+ // cleanup, history scoping and slash resolution. A camera frame taking the
611
+ // flag inside those awaits leaves the persist rejecting busy, and the send
612
+ // has to reach the queue the early decision would have used rather than
613
+ // failing in the client's face.
614
+ const { conversation, persistUserMessage, runAgentLoop } =
615
+ makeConversation();
616
+ persistUserMessage.mockImplementationOnce(async () => {
617
+ throw new Error(CONVERSATION_BUSY_MESSAGE);
618
+ });
619
+
620
+ const res = await callHandler(
621
+ (args) => handleSendMessage(args, makeDeps(conversation)),
622
+ makeRequest("what is this"),
623
+ undefined,
624
+ 202,
625
+ );
626
+
627
+ expect(res.status).toBe(202);
628
+ expect(await res.json()).toMatchObject({ accepted: true, queued: true });
629
+ expect(runAgentLoop).not.toHaveBeenCalled();
630
+ });
631
+
632
+ test("still fails a send whose persist failed for any other reason", async () => {
633
+ // The control: only the busy rejection queues. Everything else is a real
634
+ // failure and must not be answered as an accepted message.
635
+ const { conversation } = makeConversation();
636
+ const { persistUserMessage } = makeConversation();
637
+ void persistUserMessage;
638
+ (
639
+ conversation as unknown as {
640
+ persistUserMessage: ReturnType<typeof mock>;
641
+ }
642
+ ).persistUserMessage = mock(async () => {
643
+ throw new Error("disk on fire");
644
+ });
645
+
646
+ await expect(
647
+ callHandler(
648
+ (args) => handleSendMessage(args, makeDeps(conversation)),
649
+ makeRequest("what is this"),
650
+ undefined,
651
+ 202,
652
+ ),
653
+ ).rejects.toThrow("disk on fire");
654
+ });
655
+
656
+ test("queues a slash command whose flag went away during the awaits", async () => {
657
+ // Same window, the branch that hand-rolls its own claim. It takes the flag
658
+ // rather than setting it, so a hold taken since is answered by the queue
659
+ // instead of being claimed away from the turn that owns it.
660
+ const { conversation, runAgentLoop } = makeConversation();
661
+ conversation.setProcessing(true);
662
+ // The early gate reads idle, the claim below does not.
663
+ const stub = conversation as unknown as { isProcessing: () => boolean };
664
+ stub.isProcessing = () => false;
665
+
666
+ const res = await callHandler(
667
+ (args) => handleSendMessage(args, makeDeps(conversation)),
668
+ makeRequest("/context"),
669
+ undefined,
670
+ 202,
671
+ );
672
+
673
+ expect(res.status).toBe(202);
674
+ expect(await res.json()).toMatchObject({ accepted: true, queued: true });
675
+ expect(runAgentLoop).not.toHaveBeenCalled();
676
+ });
677
+ });
@@ -885,6 +885,28 @@ describe("trust-gating via channel capabilities", () => {
885
885
  expect(injected).not.toContain("CHANNEL CONSTRAINTS");
886
886
  });
887
887
 
888
+ test("vellum channel with Linux client OS injects host_bash guidance", () => {
889
+ const caps: ChannelCapabilities = {
890
+ channel: "vellum",
891
+ dashboardCapable: true,
892
+ supportsDynamicUi: true,
893
+ supportsVoiceInput: true,
894
+ clientOS: "web",
895
+ };
896
+ const message: Message = {
897
+ role: "user",
898
+ content: [{ type: "text", text: "List my downloads" }],
899
+ };
900
+
901
+ const result = injectChannelCapabilityContext(message, caps, "linux");
902
+
903
+ expect(result).not.toBe(message);
904
+ const injected = (result.content[0] as { type: "text"; text: string }).text;
905
+ expect(injected).toContain("client_os: linux");
906
+ expect(injected).toContain("On Linux, prefer CLI via `host_bash`");
907
+ expect(injected).not.toContain("CHANNEL CONSTRAINTS");
908
+ });
909
+
888
910
  test("non-dashboard channel adds constraint rules preventing UI references", () => {
889
911
  const caps = resolveChannelCapabilities("telegram");
890
912
  const message: Message = {
@@ -6009,6 +6031,63 @@ describe("assembleSlackChronologicalMessages", () => {
6009
6031
  expect(replyText).not.toContain("→ M");
6010
6032
  });
6011
6033
 
6034
+ test("post-reconciliation: an assistant row on the neutral envelope renders chronologically too", () => {
6035
+ // A Slack reply row is written with the neutral envelope every channel
6036
+ // writes; the chronological assembler reads it through the envelope's
6037
+ // Slack view, so it takes the same path as a legacy `slackMeta` row.
6038
+ const SLACK_CHANNEL_ID_2 = "C0THREAD";
6039
+ const ASSISTANT_TS = "1700001000.000111";
6040
+ const REPLY_TS = "1700001020.000222";
6041
+ const SLACK_CAPS_CHANNEL: ChannelCapabilities = {
6042
+ channel: "slack",
6043
+ dashboardCapable: false,
6044
+ supportsDynamicUi: false,
6045
+ supportsVoiceInput: false,
6046
+ chatType: "channel",
6047
+ };
6048
+ const userReplyMeta: SlackMessageMetadata = {
6049
+ source: "slack",
6050
+ channelId: SLACK_CHANNEL_ID_2,
6051
+ channelTs: REPLY_TS,
6052
+ threadTs: ASSISTANT_TS,
6053
+ displayName: "@alice",
6054
+ eventKind: "message",
6055
+ };
6056
+ const rows: SlackTranscriptInputRow[] = [
6057
+ row(
6058
+ "assistant",
6059
+ "Earlier reply",
6060
+ 1700001000_000,
6061
+ JSON.stringify({
6062
+ userMessageChannel: "slack",
6063
+ assistantMessageChannel: "slack",
6064
+ providerMeta: JSON.stringify({
6065
+ source: "slack",
6066
+ conversationExternalId: SLACK_CHANNEL_ID_2,
6067
+ messageId: ASSISTANT_TS,
6068
+ eventKind: "message",
6069
+ }),
6070
+ }),
6071
+ ),
6072
+ row(
6073
+ "user",
6074
+ "Following up",
6075
+ 1700001020_000,
6076
+ metadataEnvelope(userReplyMeta),
6077
+ ),
6078
+ ];
6079
+
6080
+ const result = assembleSlackChronologicalMessages(rows, SLACK_CAPS_CHANNEL);
6081
+ expect(result).not.toBeNull();
6082
+ expect(result!.length).toBe(2);
6083
+ const assistantText = (result![0].content[0] as { text: string }).text;
6084
+ expect(assistantText).toContain("Earlier reply");
6085
+ const replyText = (result![1].content[0] as { text: string }).text;
6086
+ expect(replyText).toBe(
6087
+ `[11/14/23 22:30 @alice]: ${slackExternal("Following up", "@alice")}`,
6088
+ );
6089
+ });
6090
+
6012
6091
  test("post-reconciliation: assistant row appears in active-thread focus block", () => {
6013
6092
  // The active-thread focus block at
6014
6093
  // `conversation-runtime-assembly.ts:1387` filters out rows with null
@@ -1,15 +1,23 @@
1
1
  /**
2
+ * When an acquire writes a `conversations` row, and when it refuses to.
3
+ *
2
4
  * Regression: an ephemeral `getOrCreateConversation` call (the empty-state
3
5
  * greeting side-chain via POST /v1/btw) must NOT persist a `conversations`
4
6
  * row. A persisted row surfaces as an "Untitled" conversation with the literal
5
7
  * id "greeting" in every client's sidebar. A normal (non-ephemeral) call still
6
8
  * creates the row so real conversations remain sidebar-visible.
7
9
  *
8
- * The DB layer is mocked so the assertion targets exactly the row-creation
9
- * decision inside `getOrCreateConversation` (`createConversation` /
10
+ * `getConversationIfExists` refuses for a different reason: its callers run
11
+ * queued work whose conversation can be deleted before the job starts, and
12
+ * writing the row back would bring the conversation home. The window that
13
+ * matters is the provider, prompt, and tool setup the acquire awaits, which a
14
+ * delete can land in the middle of.
15
+ *
16
+ * The DB layer is mocked so the assertions target exactly the row-creation
17
+ * decision inside the acquire (`createConversation` /
10
18
  * `ensureConversationExists`) rather than the surrounding provider wiring.
11
19
  */
12
- import { describe, expect, mock, test } from "bun:test";
20
+ import { describe, expect, mock, spyOn, test } from "bun:test";
13
21
 
14
22
  import type { Message } from "../providers/types.js";
15
23
  import { setConfig } from "./helpers/set-config.js";
@@ -46,8 +54,51 @@ setConfig("llm", {
46
54
  });
47
55
  setConfig("memory", { enabled: false, v2: { enabled: false } });
48
56
 
57
+ /**
58
+ * Whether the `conversations` row reads as present. Default false, which is
59
+ * the brand-new conversation the row-creation tests below need.
60
+ */
61
+ let conversationRowPresent = false;
62
+
63
+ /**
64
+ * The `created_at` the row reads with, which is what tells one incarnation of
65
+ * an id from the next. Every write below stamps a fresh one, the way an insert
66
+ * does.
67
+ */
68
+ let conversationCreatedAt = 1000;
69
+
70
+ /**
71
+ * Set to model a delete that lands while the acquire is awaiting its setup
72
+ * work: the row reads present when the acquire starts and gone by the time it
73
+ * would insert.
74
+ */
75
+ let deleteDuringSetup = false;
76
+
77
+ /**
78
+ * Set to model a delete that lands while the acquire is hydrating the instance
79
+ * it just built, which is the window before it reaches the registry.
80
+ */
81
+ let deleteDuringHydration = false;
82
+
83
+ /**
84
+ * Set to hold an acquire inside its setup work until the test resolves it,
85
+ * which is what lets a second acquire join the first one's flight and lets a
86
+ * delete land while both are waiting on it.
87
+ */
88
+ let holdSetup: Promise<void> | null = null;
89
+
49
90
  mock.module("../prompts/system-prompt.js", () => ({
50
- buildSystemPrompt: () => "system prompt",
91
+ buildSystemPrompt: async () => {
92
+ // Awaited by the acquire between its first read of the row and the insert,
93
+ // so this is the window a delete has to land in.
94
+ if (deleteDuringSetup) {
95
+ conversationRowPresent = false;
96
+ }
97
+ if (holdSetup) {
98
+ await holdSetup;
99
+ }
100
+ return "system prompt";
101
+ },
51
102
  }));
52
103
 
53
104
  mock.module("../prompts/persona-resolver.js", () => ({
@@ -77,17 +128,45 @@ mock.module("../workspace/git-service.js", () => ({
77
128
  }),
78
129
  }));
79
130
 
80
- // The row-creation spies. `getConversation` returns null so the conversation
81
- // reads as brand-new — the branch that would create a row.
82
- const mockCreateConversation = mock((_opts?: unknown) => ({ id: "conv-x" }));
83
- const mockEnsureConversationExists = mock((_id: string) => true);
131
+ /** Write the row the way an insert does, under a fresh `created_at`. */
132
+ function writeConversationRow(): void {
133
+ conversationRowPresent = true;
134
+ conversationCreatedAt += 1;
135
+ }
136
+
137
+ // The row-creation spies. `getConversation` answers from
138
+ // `conversationRowPresent`, which starts false so the conversation reads as
139
+ // brand-new: the branch that would create a row. Both spies leave the row
140
+ // readable under a `created_at` of their own, which is what lets a test tell
141
+ // the conversation an acquire was asked about from one written since.
142
+ const mockCreateConversation = mock((_opts?: unknown) => {
143
+ writeConversationRow();
144
+ return { id: "conv-x" };
145
+ });
146
+ const mockEnsureConversationExists = mock((_id: string) => {
147
+ // Matches the real one, which reports false and writes nothing when the row
148
+ // is already there.
149
+ if (conversationRowPresent) {
150
+ return false;
151
+ }
152
+ writeConversationRow();
153
+ return true;
154
+ });
84
155
 
85
156
  mock.module("../persistence/conversation-crud.js", () => ({
86
157
  ADOPTABLE_CONVERSATION_ID_RE: /^[A-Za-z0-9_-]{1,128}$/,
87
158
  createConversation: mockCreateConversation,
88
159
  ensureConversationExists: mockEnsureConversationExists,
89
- getConversation: () => null,
90
- getMessages: () => [],
160
+ getConversation: (id: string) =>
161
+ conversationRowPresent ? { id, createdAt: conversationCreatedAt } : null,
162
+ getMessages: () => {
163
+ // Read by `loadFromDb`, which the acquire awaits after building the
164
+ // instance and before it reaches the registry.
165
+ if (deleteDuringHydration) {
166
+ conversationRowPresent = false;
167
+ }
168
+ return [];
169
+ },
91
170
  setConversationProcessingStartedAt: () => {},
92
171
  isConversationProcessing: () => false,
93
172
  }));
@@ -139,11 +218,28 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
139
218
  getSummaryFromContextMessage: () => null,
140
219
  }));
141
220
 
221
+ import { Conversation } from "../daemon/conversation.js";
222
+ import {
223
+ conversationCount,
224
+ findConversation,
225
+ } from "../daemon/conversation-registry.js";
142
226
  import {
143
227
  clearAllActiveConversations,
228
+ getConversationIfExists,
144
229
  getOrCreateConversation,
145
230
  } from "../daemon/conversation-store.js";
146
231
 
232
+ function resetAcquireState(): void {
233
+ clearAllActiveConversations();
234
+ mockCreateConversation.mockClear();
235
+ mockEnsureConversationExists.mockClear();
236
+ conversationRowPresent = false;
237
+ conversationCreatedAt += 1;
238
+ deleteDuringSetup = false;
239
+ deleteDuringHydration = false;
240
+ holdSetup = null;
241
+ }
242
+
147
243
  describe("getOrCreateConversation ephemeral flag", () => {
148
244
  test("ephemeral call does not persist a conversations row", async () => {
149
245
  clearAllActiveConversations();
@@ -172,3 +268,220 @@ describe("getOrCreateConversation ephemeral flag", () => {
172
268
  );
173
269
  });
174
270
  });
271
+
272
+ describe("getConversationIfExists", () => {
273
+ test("returns null for a conversation whose row is already gone", async () => {
274
+ resetAcquireState();
275
+
276
+ expect(await getConversationIfExists("deleted-conversation")).toBeNull();
277
+
278
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
279
+ expect(mockCreateConversation).not.toHaveBeenCalled();
280
+ });
281
+
282
+ test("returns null when the delete lands during provider and tool setup", async () => {
283
+ // The window the up-front read cannot cover: the acquire awaits provider
284
+ // resolution, the system prompt, and tool setup before it reaches the
285
+ // insert, and a delete inside those awaits used to be answered by writing
286
+ // the row back.
287
+ resetAcquireState();
288
+ conversationRowPresent = true;
289
+ deleteDuringSetup = true;
290
+
291
+ expect(await getConversationIfExists("deleted-mid-setup")).toBeNull();
292
+
293
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
294
+ expect(mockCreateConversation).not.toHaveBeenCalled();
295
+ });
296
+
297
+ test("builds the conversation when its row survives the setup", async () => {
298
+ resetAcquireState();
299
+ conversationRowPresent = true;
300
+
301
+ const conversation = await getConversationIfExists("live-conversation");
302
+
303
+ expect(conversation).not.toBeNull();
304
+ // The row was there all along, so nothing had to write one.
305
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
306
+ expect(mockCreateConversation).not.toHaveBeenCalled();
307
+ });
308
+
309
+ test("does not inherit an instance from the acquire it joined", async () => {
310
+ // Both acquires share one flight, and they do not share a contract. The
311
+ // creating caller builds its conversation whatever became of the row,
312
+ // which is its own business; an observer that took the same instance
313
+ // would be answering someone else's question.
314
+ resetAcquireState();
315
+ conversationRowPresent = true;
316
+ let releaseSetup = () => {};
317
+ holdSetup = new Promise<void>((resolve) => {
318
+ releaseSetup = resolve;
319
+ });
320
+
321
+ // Ephemeral, so the creating acquire never writes the row back and the
322
+ // delete below stands.
323
+ const creating = getOrCreateConversation("joined-conversation", {
324
+ ephemeral: true,
325
+ });
326
+ // Reaches the flight the creating acquire registered before its first
327
+ // await, and waits on it.
328
+ const observing = getConversationIfExists("joined-conversation");
329
+
330
+ conversationRowPresent = false;
331
+ releaseSetup();
332
+
333
+ expect(await creating).not.toBeNull();
334
+ expect(await observing).toBeNull();
335
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
336
+ expect(mockCreateConversation).not.toHaveBeenCalled();
337
+ });
338
+
339
+ test("does not accept a row the acquire it joined wrote back", async () => {
340
+ // The creating caller it shares flight with writes the row back when the
341
+ // delete lands inside that caller's setup, so by the time this one looks
342
+ // the id names a row again. Reading presence alone cannot tell that row
343
+ // from the one this caller was asked about, and persisting into it would
344
+ // put the frame in a conversation created after the deletion.
345
+ resetAcquireState();
346
+ conversationRowPresent = true;
347
+ let releaseSetup = () => {};
348
+ holdSetup = new Promise<void>((resolve) => {
349
+ releaseSetup = resolve;
350
+ });
351
+
352
+ const creating = getOrCreateConversation("recreated-conversation");
353
+ const observing = getConversationIfExists("recreated-conversation");
354
+
355
+ conversationRowPresent = false;
356
+ releaseSetup();
357
+
358
+ // The creating caller builds whatever became of the row, which is its own
359
+ // contract and unchanged.
360
+ expect(await creating).not.toBeNull();
361
+ expect(mockEnsureConversationExists).toHaveBeenCalledTimes(1);
362
+ expect(conversationRowPresent).toBe(true);
363
+
364
+ expect(await observing).toBeNull();
365
+ });
366
+
367
+ test("disposes rather than registers an instance whose row went away", async () => {
368
+ // Hydration awaits, so the row can go between the read that let this build
369
+ // and the registry write. Registering leaves an active conversation with
370
+ // no row behind it, which later session work finds and reuses.
371
+ resetAcquireState();
372
+ conversationRowPresent = true;
373
+ deleteDuringHydration = true;
374
+ const disposeSpy = spyOn(Conversation.prototype, "dispose");
375
+
376
+ try {
377
+ expect(await getConversationIfExists("hydrated-conversation")).toBeNull();
378
+
379
+ expect(findConversation("hydrated-conversation")).toBeUndefined();
380
+ expect(conversationCount()).toBe(0);
381
+ expect(conversationRowPresent).toBe(false);
382
+ // The instance it built is torn down rather than left to leak its
383
+ // timers and proxies.
384
+ expect(disposeSpy).toHaveBeenCalledTimes(1);
385
+ } finally {
386
+ disposeSpy.mockRestore();
387
+ }
388
+ });
389
+ });
390
+
391
+ describe("acquisitions sharing one flight", () => {
392
+ test("creating callers behind a declined flight build one conversation", async () => {
393
+ // A flight that declines to create leaves everyone waiting on it with no
394
+ // answer, and each of them was asked to build. Building unconditionally
395
+ // from there gives one id two instances: the registry keeps whichever was
396
+ // written last, and the other one runs with its own processing flag and
397
+ // its own queue.
398
+ resetAcquireState();
399
+ conversationRowPresent = true;
400
+ let releaseSetup = () => {};
401
+ holdSetup = new Promise<void>((resolve) => {
402
+ releaseSetup = resolve;
403
+ });
404
+
405
+ // Passes its up-front read, publishes the flight, then waits inside setup.
406
+ const observing = getConversationIfExists("herd-conversation");
407
+ // Both reach that flight and wait on it.
408
+ const firstCreating = getOrCreateConversation("herd-conversation");
409
+ const secondCreating = getOrCreateConversation("herd-conversation");
410
+
411
+ // The delete the observing acquire will refuse on when it wakes.
412
+ conversationRowPresent = false;
413
+ releaseSetup();
414
+
415
+ expect(await observing).toBeNull();
416
+ const first = await firstCreating;
417
+ const second = await secondCreating;
418
+
419
+ // One acquire built; the other joined the flight it published.
420
+ expect(first).toBe(second);
421
+ expect(findConversation("herd-conversation")).toBe(first);
422
+ expect(conversationCount()).toBe(1);
423
+ });
424
+
425
+ test("a joiner holding a later incarnation does not inherit the decline", async () => {
426
+ // The flight it joined was asked about the row that has since been
427
+ // deleted, and reports that. This caller was accepted for the row written
428
+ // after it, which is still there, so taking the null would drop work the
429
+ // conversation on screen can still accept.
430
+ resetAcquireState();
431
+ conversationRowPresent = true;
432
+ let releaseSetup = () => {};
433
+ holdSetup = new Promise<void>((resolve) => {
434
+ releaseSetup = resolve;
435
+ });
436
+
437
+ // Passes its up-front read, publishes the flight, then waits inside setup.
438
+ const doomed = getConversationIfExists("rejoin-conversation");
439
+
440
+ // The delete the flight above will refuse on, and the recreate the caller
441
+ // below is accepted for.
442
+ conversationRowPresent = false;
443
+ writeConversationRow();
444
+ const acceptedIncarnation = conversationCreatedAt;
445
+
446
+ // Reads the recreated row, finds no instance, and joins the flight above.
447
+ const joining = getConversationIfExists("rejoin-conversation");
448
+ releaseSetup();
449
+
450
+ expect(await doomed).toBeNull();
451
+
452
+ const conversation = await joining;
453
+ expect(conversation).not.toBeNull();
454
+ expect(findConversation("rejoin-conversation")).toBe(conversation!);
455
+ // The row it was accepted for, untouched: a non-creating acquire that
456
+ // finds its own row present writes nothing back.
457
+ expect(conversationCreatedAt).toBe(acceptedIncarnation);
458
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
459
+ expect(mockCreateConversation).not.toHaveBeenCalled();
460
+ });
461
+
462
+ test("a joiner whose own row is gone still answers null", async () => {
463
+ // The control on the retry above. Reconsulting is for a caller whose row
464
+ // outlived the flight's, and a caller with no row at all has to keep
465
+ // getting the answer its callers reclaim uploads on.
466
+ resetAcquireState();
467
+ conversationRowPresent = true;
468
+ let releaseSetup = () => {};
469
+ holdSetup = new Promise<void>((resolve) => {
470
+ releaseSetup = resolve;
471
+ });
472
+
473
+ const doomed = getConversationIfExists("rejoin-absent-conversation");
474
+ // Joins while the row is still the one both were asked about.
475
+ const joining = getConversationIfExists("rejoin-absent-conversation");
476
+
477
+ // Deleted for good, with nothing written back.
478
+ conversationRowPresent = false;
479
+ releaseSetup();
480
+
481
+ expect(await doomed).toBeNull();
482
+ expect(await joining).toBeNull();
483
+ expect(conversationCount()).toBe(0);
484
+ expect(mockEnsureConversationExists).not.toHaveBeenCalled();
485
+ expect(mockCreateConversation).not.toHaveBeenCalled();
486
+ });
487
+ });