@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
@@ -1,4 +1,6 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, mock, spyOn, test } from "bun:test";
2
+
3
+ import { eq } from "drizzle-orm";
2
4
 
3
5
  import {
4
6
  createMockProvider,
@@ -10,12 +12,18 @@ import { waitFor } from "../../__tests__/helpers/wait-for.js";
10
12
  setConfig("memory", { enabled: false });
11
13
 
12
14
  import type { AssistantEvent } from "../../api/index.js";
13
- import { Conversation } from "../../daemon/conversation.js";
15
+ import {
16
+ Conversation,
17
+ ProcessingClaimLostError,
18
+ } from "../../daemon/conversation.js";
14
19
  import {
15
20
  deleteConversation,
16
21
  setConversation,
17
22
  } from "../../daemon/conversation-registry.js";
18
23
  import { applySightFrameRetention } from "../../daemon/conversation-runtime-assembly.js";
24
+ import { destroyActiveConversation } from "../../daemon/conversation-store.js";
25
+ import * as portOversized from "../../daemon/port-oversized-content.js";
26
+ import * as attachmentsStore from "../../persistence/attachments-store.js";
19
27
  import {
20
28
  getAttachmentById,
21
29
  getAttachmentsForMessage,
@@ -25,23 +33,135 @@ import {
25
33
  import {
26
34
  addMessage,
27
35
  createConversation,
36
+ deleteConversation as deleteConversationRows,
37
+ getConversation,
28
38
  getMessages,
29
39
  type MessageRow,
30
40
  selectSightFrameCaptureTimes,
31
41
  } from "../../persistence/conversation-crud.js";
32
42
  import { sightFrameAttachmentIdsFromMetadata } from "../../persistence/conversation-types.js";
43
+ import * as dbConnection from "../../persistence/db-connection.js";
44
+ import { getDb } from "../../persistence/db-connection.js";
33
45
  import { initializeDb } from "../../persistence/db-init.js";
46
+ import { attachments, conversations } from "../../persistence/schema/index.js";
47
+ import * as slowSyncLog from "../../persistence/slow-sync-log.js";
34
48
  import { mediaBlockAttachmentId, type Message } from "../../providers/types.js";
35
49
  import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
36
50
  import {
51
+ _drainFrameReclaimRechecksForTests,
52
+ _nextFrameReclaimRecheckDelayForTests,
53
+ _pendingFrameReclaimCountForTests,
37
54
  _setProcessingWaitMsForTests,
38
55
  _standaloneImageQueueSizeForTests,
56
+ persistAmbientSightFrame,
39
57
  persistLiveVoicePhoto,
40
- persistLiveVoiceSightFrame,
41
58
  } from "../live-voice-photo.js";
42
59
 
43
60
  await initializeDb();
44
61
 
62
+ /**
63
+ * Run something in the window the persist opens between materializing an
64
+ * attachment and inserting its row.
65
+ *
66
+ * `offloadOversizedText` is the last step the persist awaits before the
67
+ * insert, so a hook here lands inside that window without a timer to race.
68
+ * Armed per test and cleared as it fires; every other test leaves it null
69
+ * and gets the real function.
70
+ */
71
+ let duringPersistBeforeInsert: (() => void) | null = null;
72
+ const realPortOversized = { ...portOversized };
73
+ mock.module("../../daemon/port-oversized-content.js", () => ({
74
+ ...realPortOversized,
75
+ offloadOversizedText: (
76
+ ...args: Parameters<typeof realPortOversized.offloadOversizedText>
77
+ ) => {
78
+ const hook = duringPersistBeforeInsert;
79
+ duringPersistBeforeInsert = null;
80
+ hook?.();
81
+ return realPortOversized.offloadOversizedText(...args);
82
+ },
83
+ }));
84
+
85
+ /**
86
+ * Fail the next message insert the way SQLite reports contention, running
87
+ * something first.
88
+ *
89
+ * `SQLITE_BUSY` is the class `withSqliteRetry` retries, so the attempt this
90
+ * refuses is followed by an awaited backoff and another attempt. The hook runs
91
+ * in place of the statement, which is a window no row was written in. Armed
92
+ * per test and cleared as it fires; every other test leaves it null and the
93
+ * insert runs for real the first time.
94
+ */
95
+ let beforeFailingInsertAttempt: (() => void) | null = null;
96
+
97
+ /**
98
+ * Make every store read throw, which is what leaves a persist unable to say
99
+ * whether its row landed.
100
+ */
101
+ let storeUnreadable = false;
102
+
103
+ /**
104
+ * Break the store the instant a row has committed rather than before it, which
105
+ * is the one shape where "could not read" and "the frame is in the transcript"
106
+ * are both true.
107
+ */
108
+ let breakStoreAfterInsert = false;
109
+
110
+ const realSlowSyncLog = { ...slowSyncLog };
111
+ mock.module("../../persistence/slow-sync-log.js", () => ({
112
+ ...realSlowSyncLog,
113
+ timeSyncSection: <T>(
114
+ label: string,
115
+ fn: () => T,
116
+ detail?: (result: T) => Record<string, unknown>,
117
+ ): T => {
118
+ if (label === "messages:insert" && beforeFailingInsertAttempt) {
119
+ const hook = beforeFailingInsertAttempt;
120
+ beforeFailingInsertAttempt = null;
121
+ hook();
122
+ throw Object.assign(new Error("database is locked"), {
123
+ code: "SQLITE_BUSY",
124
+ });
125
+ }
126
+ const result = realSlowSyncLog.timeSyncSection(label, fn, detail);
127
+ if (label === "messages:insert" && breakStoreAfterInsert) {
128
+ breakStoreAfterInsert = false;
129
+ storeUnreadable = true;
130
+ }
131
+ return result;
132
+ },
133
+ }));
134
+
135
+ /**
136
+ * Fail the next orphan delete the way contention does, which is the one thing
137
+ * standing between a dropped frame and its bytes.
138
+ */
139
+ let failNextReclaims = 0;
140
+ const realAttachmentsStore = { ...attachmentsStore };
141
+ mock.module("../../persistence/attachments-store.js", () => ({
142
+ ...realAttachmentsStore,
143
+ deleteOrphanAttachments: (ids: string[]) => {
144
+ if (failNextReclaims > 0) {
145
+ failNextReclaims -= 1;
146
+ throw Object.assign(new Error("database is locked"), {
147
+ code: "SQLITE_BUSY",
148
+ });
149
+ }
150
+ return realAttachmentsStore.deleteOrphanAttachments(ids);
151
+ },
152
+ }));
153
+
154
+ const realDbConnection = { ...dbConnection };
155
+ mock.module("../../persistence/db-connection.js", () => ({
156
+ ...realDbConnection,
157
+ getDb: () => {
158
+ if (storeUnreadable) {
159
+ throw new Error("database is not readable");
160
+ }
161
+ return realDbConnection.getDb();
162
+ },
163
+ }));
164
+
45
165
  const IMAGE_BASE64 =
46
166
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
47
167
 
@@ -92,12 +212,25 @@ function watchProcessing(conversation: Conversation): {
92
212
  maxDepth: number;
93
213
  } {
94
214
  const seen = { calls: [] as boolean[], depth: 0, maxDepth: 0 };
95
- const original = conversation.setProcessing.bind(conversation);
96
- conversation.setProcessing = (value: boolean): void => {
215
+ const record = (value: boolean): void => {
97
216
  seen.calls.push(value);
98
217
  seen.depth += value ? 1 : -1;
99
218
  seen.maxDepth = Math.max(seen.maxDepth, seen.depth);
100
- original(value);
219
+ };
220
+ const originalSet = conversation.setProcessing.bind(conversation);
221
+ conversation.setProcessing = (value: boolean): void => {
222
+ record(value);
223
+ originalSet(value);
224
+ };
225
+ // Standalone images take the flag through the owned acquire, and release it
226
+ // through `releaseProcessing`, which clears via `setProcessing` above.
227
+ const originalAcquire = conversation.acquireProcessing.bind(conversation);
228
+ conversation.acquireProcessing = (): number | null => {
229
+ const owner = originalAcquire();
230
+ if (owner !== null) {
231
+ record(true);
232
+ }
233
+ return owner;
101
234
  };
102
235
  return seen;
103
236
  }
@@ -112,6 +245,36 @@ function frameStored(attachmentId: string): boolean {
112
245
  return getAttachmentById(attachmentId) !== null;
113
246
  }
114
247
 
248
+ /** Collect the row announcements the hub carries for one conversation. */
249
+ function watchEchoes(conversationId: string) {
250
+ const published: AssistantEvent[] = [];
251
+ const subscription = assistantEventHub.subscribe({
252
+ type: "process",
253
+ filter: { conversationId },
254
+ callback: (event) => {
255
+ published.push(event.message);
256
+ },
257
+ });
258
+ return {
259
+ echoes: () => published.filter((e) => e.type === "user_message_echo"),
260
+ dispose: () => subscription.dispose(),
261
+ };
262
+ }
263
+
264
+ /** Every attachment row in the store, so a test can spot one made since. */
265
+ function allAttachmentIds(): string[] {
266
+ return getDb()
267
+ .select({ id: attachments.id })
268
+ .from(attachments)
269
+ .all()
270
+ .map((row) => row.id);
271
+ }
272
+
273
+ function conversationCount(): number {
274
+ return getDb().select({ id: conversations.id }).from(conversations).all()
275
+ .length;
276
+ }
277
+
115
278
  /**
116
279
  * Attachment ids the conversation's LIVE history is attributable by, which is
117
280
  * the array a turn sends rather than the rows a reload would rebuild.
@@ -129,30 +292,27 @@ function liveImageIds(conversation: Conversation): Array<string | undefined> {
129
292
  }
130
293
 
131
294
  /**
132
- * Let a turn try to start in the instant right after the persist reads the
133
- * flag free. The turn models a correct acquirer: it claims only what it finds
134
- * free, so it succeeds exactly when the persist left a gap between its read
135
- * and its take.
295
+ * Let a turn try to start in the instant right after the persist takes the
296
+ * flag. The turn models a correct acquirer: it claims only what it finds free,
297
+ * so it succeeds exactly when the persist left the flag takeable.
136
298
  */
137
299
  function armTurnStartInTheGap(conversation: Conversation): {
138
300
  claimed: () => boolean;
139
301
  } {
140
302
  let armed = true;
141
303
  let claimed = false;
142
- const readFlag = conversation.isProcessing.bind(conversation);
143
- const writeFlag = conversation.setProcessing.bind(conversation);
144
- conversation.isProcessing = (): boolean => {
145
- const busy = readFlag();
146
- if (armed && !busy) {
304
+ const acquire = conversation.acquireProcessing.bind(conversation);
305
+ conversation.acquireProcessing = (): number | null => {
306
+ const owner = acquire();
307
+ if (armed && owner !== null) {
147
308
  armed = false;
148
309
  queueMicrotask(() => {
149
- if (!readFlag()) {
150
- writeFlag(true);
310
+ if (acquire() !== null) {
151
311
  claimed = true;
152
312
  }
153
313
  });
154
314
  }
155
- return busy;
315
+ return owner;
156
316
  };
157
317
  return { claimed: () => claimed };
158
318
  }
@@ -300,7 +460,7 @@ describe("persistLiveVoicePhoto", () => {
300
460
  });
301
461
  });
302
462
 
303
- describe("persistLiveVoiceSightFrame", () => {
463
+ describe("persistAmbientSightFrame", () => {
304
464
  test("tags the row with the attachment it carries", async () => {
305
465
  const live = liveConversation("Live voice sight frame");
306
466
  try {
@@ -310,7 +470,11 @@ describe("persistLiveVoiceSightFrame", () => {
310
470
  IMAGE_BASE64,
311
471
  );
312
472
 
313
- const result = await persistLiveVoiceSightFrame(live.id, attachment.id);
473
+ const result = await persistAmbientSightFrame(
474
+ live.id,
475
+ attachment.id,
476
+ "voice",
477
+ );
314
478
  expect(result.ok).toBe(true);
315
479
 
316
480
  const [message] = getMessages(live.id);
@@ -340,7 +504,7 @@ describe("persistLiveVoiceSightFrame", () => {
340
504
  );
341
505
 
342
506
  expect(
343
- (await persistLiveVoiceSightFrame(live.id, attachment.id)).ok,
507
+ (await persistAmbientSightFrame(live.id, attachment.id, "voice")).ok,
344
508
  ).toBe(true);
345
509
 
346
510
  expect(metadataOf(getMessages(live.id)[0]).scripted).toBe(true);
@@ -349,6 +513,28 @@ describe("persistLiveVoiceSightFrame", () => {
349
513
  }
350
514
  });
351
515
 
516
+ test("marks a keep taken on a call as a voice session turn", async () => {
517
+ // The mark says a reply to this row is spoken back over a session that is
518
+ // still open, which decides whether a finished reply raises a push. Only
519
+ // the voice surface may claim it, and it has to keep claiming it.
520
+ const live = liveConversation("Live voice sight frame voice mark");
521
+ try {
522
+ const attachment = await uploadAttachment(
523
+ "frame.png",
524
+ "image/png",
525
+ IMAGE_BASE64,
526
+ );
527
+
528
+ expect(
529
+ (await persistAmbientSightFrame(live.id, attachment.id, "voice")).ok,
530
+ ).toBe(true);
531
+
532
+ expect(metadataOf(getMessages(live.id)[0]).voiceSessionTurn).toBe(true);
533
+ } finally {
534
+ live.dispose();
535
+ }
536
+ });
537
+
352
538
  test("tags the id the attachment was cloned into, not the one it arrived as", async () => {
353
539
  // An attachment already linked to another conversation is cloned into this
354
540
  // one under a fresh id, and both the persisted block and the link carry the
@@ -358,9 +544,9 @@ describe("persistLiveVoiceSightFrame", () => {
358
544
  try {
359
545
  const arrivedAs = await attachmentLinkedElsewhere(source.id);
360
546
 
361
- expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
362
- true,
363
- );
547
+ expect(
548
+ (await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
549
+ ).toBe(true);
364
550
 
365
551
  const [row] = getMessages(live.id);
366
552
  const stored = storedImageId(row);
@@ -388,9 +574,9 @@ describe("persistLiveVoiceSightFrame", () => {
388
574
  try {
389
575
  const arrivedAs = await attachmentLinkedElsewhere(source.id);
390
576
 
391
- expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
392
- true,
393
- );
577
+ expect(
578
+ (await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
579
+ ).toBe(true);
394
580
 
395
581
  const stored = storedImageId(getMessages(live.id)[0]);
396
582
  expect(stored).toBeDefined();
@@ -410,14 +596,14 @@ describe("persistLiveVoiceSightFrame", () => {
410
596
  const live = liveConversation("Live voice live retention");
411
597
  try {
412
598
  const arrivedAs = await attachmentLinkedElsewhere(source.id);
413
- expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
414
- true,
415
- );
599
+ expect(
600
+ (await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
601
+ ).toBe(true);
416
602
  for (const name of ["live-fresh-a.png", "live-fresh-b.png"]) {
417
603
  const fresh = await uploadFrame(name);
418
- expect((await persistLiveVoiceSightFrame(live.id, fresh)).ok).toBe(
419
- true,
420
- );
604
+ expect(
605
+ (await persistAmbientSightFrame(live.id, fresh, "voice")).ok,
606
+ ).toBe(true);
421
607
  }
422
608
 
423
609
  const live_ = live.activeConversation;
@@ -442,14 +628,14 @@ describe("persistLiveVoiceSightFrame", () => {
442
628
  const live = liveConversation("Live voice cloned retention");
443
629
  try {
444
630
  const arrivedAs = await attachmentLinkedElsewhere(source.id);
445
- expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
446
- true,
447
- );
631
+ expect(
632
+ (await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
633
+ ).toBe(true);
448
634
  for (const name of ["later-a.png", "later-b.png"]) {
449
635
  const fresh = await uploadAttachment(name, "image/png", IMAGE_BASE64);
450
- expect((await persistLiveVoiceSightFrame(live.id, fresh.id)).ok).toBe(
451
- true,
452
- );
636
+ expect(
637
+ (await persistAmbientSightFrame(live.id, fresh.id, "voice")).ok,
638
+ ).toBe(true);
453
639
  }
454
640
 
455
641
  const assembled: Message[] = getMessages(live.id).map((row) => ({
@@ -471,7 +657,9 @@ describe("persistLiveVoiceSightFrame", () => {
471
657
  test("reports a frame whose attachment does not resolve", async () => {
472
658
  const live = liveConversation("Live voice sight frame missing");
473
659
  try {
474
- expect(await persistLiveVoiceSightFrame(live.id, "att-missing")).toEqual({
660
+ expect(
661
+ await persistAmbientSightFrame(live.id, "att-missing", "voice"),
662
+ ).toEqual({
475
663
  ok: false,
476
664
  });
477
665
  expect(getMessages(live.id)).toHaveLength(0);
@@ -493,7 +681,7 @@ describe("persistLiveVoiceSightFrame", () => {
493
681
  );
494
682
 
495
683
  live.activeConversation.setProcessing(true);
496
- const pending = persistLiveVoiceSightFrame(live.id, attachment.id);
684
+ const pending = persistAmbientSightFrame(live.id, attachment.id, "voice");
497
685
  await new Promise((resolve) => setTimeout(resolve, 150));
498
686
  expect(getMessages(live.id)).toHaveLength(0);
499
687
 
@@ -526,7 +714,7 @@ describe("standalone image persists are serialized per conversation", () => {
526
714
  const frame = await uploadFrame("ambient.png");
527
715
 
528
716
  const photoWrite = persistLiveVoicePhoto(live.id, photo);
529
- const frameWrite = persistLiveVoiceSightFrame(live.id, frame);
717
+ const frameWrite = persistAmbientSightFrame(live.id, frame, "voice");
530
718
 
531
719
  expect(await photoWrite).toMatchObject({ ok: true });
532
720
  expect(await frameWrite).toMatchObject({ ok: true });
@@ -553,11 +741,11 @@ describe("standalone image persists are serialized per conversation", () => {
553
741
  const second = await uploadFrame("second.png");
554
742
 
555
743
  live.activeConversation.setProcessing(true);
556
- const firstKeep = persistLiveVoiceSightFrame(live.id, first);
744
+ const firstKeep = persistAmbientSightFrame(live.id, first, "voice");
557
745
  // Long enough for the first keep to reach the idle wait, so the second
558
746
  // queues behind a job that has already begun rather than replacing it.
559
747
  await sleep(30);
560
- const secondKeep = persistLiveVoiceSightFrame(live.id, second);
748
+ const secondKeep = persistAmbientSightFrame(live.id, second, "voice");
561
749
  await sleep(30);
562
750
  live.activeConversation.setProcessing(false);
563
751
 
@@ -583,7 +771,7 @@ describe("standalone image persists are serialized per conversation", () => {
583
771
 
584
772
  // A turn is running, so the keep goes into the wait.
585
773
  live.activeConversation.setProcessing(true);
586
- const keep = persistLiveVoiceSightFrame(live.id, frame);
774
+ const keep = persistAmbientSightFrame(live.id, frame, "voice");
587
775
  await sleep(150);
588
776
 
589
777
  // From here, the next observation of a free flag lets a turn try to
@@ -603,6 +791,292 @@ describe("standalone image persists are serialized per conversation", () => {
603
791
  }
604
792
  });
605
793
 
794
+ test("a frame does not write into or release a turn that claimed the flag", async () => {
795
+ // A turn claims the flag unconditionally, which is how every turn starts.
796
+ // The frame's write fence reads the claim as no longer its own and refuses
797
+ // the row, and its release is refused for the same reason, so the turn
798
+ // keeps a conversation nothing else wrote into.
799
+ const live = liveConversation("Live voice keep flag claimed away");
800
+ try {
801
+ const frame = await uploadFrame("claimed-away.png");
802
+
803
+ duringPersistBeforeInsert = () => {
804
+ live.activeConversation.setProcessing(true);
805
+ };
806
+
807
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
808
+ ok: false,
809
+ });
810
+
811
+ expect(getMessages(live.id)).toHaveLength(0);
812
+ // The turn still holds it: the frame released only its own claim.
813
+ expect(live.activeConversation.isProcessing()).toBe(true);
814
+ } finally {
815
+ duringPersistBeforeInsert = null;
816
+ live.activeConversation.setProcessing(false);
817
+ live.dispose();
818
+ }
819
+ });
820
+
821
+ test("an acquire keeps the flag when its advisory write cannot land", async () => {
822
+ // The window a strict set opened: reverting the flag because an advisory
823
+ // write lost a race to contention publishes an idle conversation for as
824
+ // long as the caller's retry sleeps, and anything polling for idle takes
825
+ // it while that caller believes its own turn is starting.
826
+ const live = liveConversation("Live voice acquire mirror failure");
827
+ try {
828
+ storeUnreadable = true;
829
+ const owner = live.activeConversation.acquireProcessing();
830
+ storeUnreadable = false;
831
+
832
+ expect(owner).not.toBeNull();
833
+ expect(live.activeConversation.isProcessing()).toBe(true);
834
+ // Held, so nothing else can take it.
835
+ expect(live.activeConversation.acquireProcessing()).toBeNull();
836
+
837
+ // A claim that is not the live one releases nothing.
838
+ expect(live.activeConversation.releaseProcessing(owner! - 1)).toBe(false);
839
+ expect(live.activeConversation.isProcessing()).toBe(true);
840
+
841
+ expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
842
+ expect(live.activeConversation.isProcessing()).toBe(false);
843
+ } finally {
844
+ storeUnreadable = false;
845
+ live.dispose();
846
+ }
847
+ });
848
+
849
+ test("an acquire does not proceed until its processing marker lands", async () => {
850
+ // `processing_started_at` is what a reconnecting client and the
851
+ // out-of-process retrospective worker read to decide a turn is live, so a
852
+ // turn that writes rows while the column is null lets a client stop
853
+ // waiting mid-turn and lets the worker fork partial history.
854
+ const live = liveConversation("Live voice marker fence");
855
+ try {
856
+ const owner = live.activeConversation.acquireProcessing();
857
+ expect(owner).not.toBeNull();
858
+
859
+ // Held for the whole write, so no second acquirer can slip in behind a
860
+ // marker that is still landing.
861
+ expect(live.activeConversation.isProcessing()).toBe(true);
862
+ await live.activeConversation.ensureProcessingMarker(owner!);
863
+
864
+ expect(
865
+ getDb()
866
+ .select({ startedAt: conversations.processingStartedAt })
867
+ .from(conversations)
868
+ .where(eq(conversations.id, live.id))
869
+ .get()?.startedAt,
870
+ ).not.toBeNull();
871
+
872
+ expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
873
+ } finally {
874
+ live.dispose();
875
+ }
876
+ });
877
+
878
+ test("a claim whose marker will not land is given back", async () => {
879
+ // The fence has to fail closed: a hold nobody can see is worse than an
880
+ // operation that reports it could not start.
881
+ const live = liveConversation("Live voice marker fence failure");
882
+ try {
883
+ storeUnreadable = true;
884
+ const owner = live.activeConversation.acquireProcessing();
885
+ expect(owner).not.toBeNull();
886
+
887
+ await expect(
888
+ live.activeConversation.ensureProcessingMarker(owner!),
889
+ ).rejects.toThrow();
890
+ storeUnreadable = false;
891
+
892
+ // The caller gives the hold back, and the flag is free again.
893
+ expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
894
+ expect(live.activeConversation.isProcessing()).toBe(false);
895
+ } finally {
896
+ storeUnreadable = false;
897
+ live.dispose();
898
+ }
899
+ });
900
+
901
+ test("a deferred reclaim collects the clone, not just the id it was handed", async () => {
902
+ // A frame already linked to another conversation is CLONED into this one
903
+ // before the insert precondition runs. If the precondition then refuses
904
+ // and the persist's own clone cleanup hits contention, retrying under the
905
+ // caller's id reclaims nothing: that row is still linked where it came
906
+ // from, and the clone nobody names survives every pass.
907
+ const source = liveConversation("Live voice clone reclaim source");
908
+ const live = liveConversation("Live voice clone reclaim");
909
+ try {
910
+ const arrivedAs = await attachmentLinkedElsewhere(source.id);
911
+ const beforePersist = new Set(allAttachmentIds());
912
+
913
+ // Replaced mid-persist, so the precondition refuses after the clone was
914
+ // made, and the clone cleanup that follows fails once on contention.
915
+ duringPersistBeforeInsert = () => {
916
+ deleteConversationRows(live.id);
917
+ createConversation({ id: live.id, title: "Recreated" });
918
+ // The persist's own clone cleanup fails, and so does the reclaim that
919
+ // follows it, so the record reaches the deferred pass.
920
+ failNextReclaims = 2;
921
+ };
922
+
923
+ expect(
924
+ await persistAmbientSightFrame(live.id, arrivedAs, "voice"),
925
+ ).toEqual({ ok: false });
926
+
927
+ expect(getMessages(live.id)).toHaveLength(0);
928
+ expect(_pendingFrameReclaimCountForTests()).toBe(1);
929
+ // The clone outlived the failed cleanup, so something still owes it.
930
+ const clones = allAttachmentIds().filter((id) => !beforePersist.has(id));
931
+ expect(clones).toHaveLength(1);
932
+ expect(frameStored(clones[0])).toBe(true);
933
+
934
+ _drainFrameReclaimRechecksForTests();
935
+
936
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
937
+ // The clone is gone, and the original it was copied from is untouched
938
+ // and still linked to the message that owns it.
939
+ expect(frameStored(clones[0])).toBe(false);
940
+ expect(frameStored(arrivedAs)).toBe(true);
941
+ expect(
942
+ getAttachmentsForMessage(getMessages(source.id)[0].id),
943
+ ).toHaveLength(1);
944
+ } finally {
945
+ duringPersistBeforeInsert = null;
946
+ failNextReclaims = 0;
947
+ live.dispose();
948
+ source.dispose();
949
+ }
950
+ });
951
+
952
+ test("a fence whose claim was cleared under it reports the loss", async () => {
953
+ // The caller does its work between the fence and its release, so a fence
954
+ // that answered "fine" for a hold Stop or a teardown already cleared would
955
+ // let that work run under a dead claim while a new turn acquires and
956
+ // writes alongside it.
957
+ const live = liveConversation("Live voice fence claim cleared");
958
+ try {
959
+ const owner = live.activeConversation.acquireProcessing();
960
+ expect(owner).not.toBeNull();
961
+
962
+ // The teardown shape: an unconditional clear while the marker is still
963
+ // the live claim's.
964
+ live.activeConversation.setProcessing(false);
965
+
966
+ await expect(
967
+ live.activeConversation.ensureProcessingMarker(owner!),
968
+ ).rejects.toThrow(ProcessingClaimLostError);
969
+ } finally {
970
+ live.dispose();
971
+ }
972
+ });
973
+
974
+ test("a fence whose claim is taken away after the write reports the loss", async () => {
975
+ // The other half of the same window: the marker lands, and the hold is
976
+ // claimed away before the awaiter resumes. The fence answers for the
977
+ // present, not for the moment it started waiting.
978
+ const live = liveConversation("Live voice fence claim stolen");
979
+ try {
980
+ const owner = live.activeConversation.acquireProcessing();
981
+ expect(owner).not.toBeNull();
982
+
983
+ const fence = live.activeConversation.ensureProcessingMarker(owner!);
984
+ // A turn claiming the flag, which is how every turn starts.
985
+ live.activeConversation.setProcessing(true);
986
+
987
+ await expect(fence).rejects.toThrow(ProcessingClaimLostError);
988
+ } finally {
989
+ live.activeConversation.setProcessing(false);
990
+ live.dispose();
991
+ }
992
+ });
993
+
994
+ test("a fenced acquire reads a lost claim as a busy conversation", async () => {
995
+ // Callers owe one answer for "this conversation is someone else's",
996
+ // however it became so, and the fenced acquire is what collapses the two
997
+ // routes into it. The claim is given back on the way out.
998
+ const live = liveConversation("Live voice fenced acquire loss");
999
+ try {
1000
+ const marker = spyOn(
1001
+ live.activeConversation,
1002
+ "ensureProcessingMarker",
1003
+ ).mockImplementation(async () => {
1004
+ throw new ProcessingClaimLostError(live.id);
1005
+ });
1006
+
1007
+ expect(
1008
+ await live.activeConversation.acquireProcessingFenced(),
1009
+ ).toBeNull();
1010
+ // Not left held: the next acquire finds it free.
1011
+ expect(live.activeConversation.isProcessing()).toBe(false);
1012
+
1013
+ marker.mockRestore();
1014
+ expect(
1015
+ await live.activeConversation.acquireProcessingFenced(),
1016
+ ).not.toBeNull();
1017
+ } finally {
1018
+ live.activeConversation.setProcessing(false);
1019
+ live.dispose();
1020
+ }
1021
+ });
1022
+
1023
+ test("a fenced acquire that cannot persist its marker leaves nothing held", async () => {
1024
+ // The P2 shape. A marker that will not land is a real failure, and the
1025
+ // claim has to be gone before the throw reaches the caller, or the
1026
+ // conversation is stuck processing with sends queuing behind a dead hold.
1027
+ const live = liveConversation("Live voice fenced acquire marker failure");
1028
+ try {
1029
+ const marker = spyOn(
1030
+ live.activeConversation,
1031
+ "ensureProcessingMarker",
1032
+ ).mockImplementation(async () => {
1033
+ throw new Error("database is not readable");
1034
+ });
1035
+
1036
+ await expect(
1037
+ live.activeConversation.acquireProcessingFenced(),
1038
+ ).rejects.toThrow("database is not readable");
1039
+
1040
+ expect(live.activeConversation.isProcessing()).toBe(false);
1041
+ marker.mockRestore();
1042
+ } finally {
1043
+ live.activeConversation.setProcessing(false);
1044
+ live.dispose();
1045
+ }
1046
+ });
1047
+
1048
+ test("a keep does not write under a claim a Stop took away mid-write", async () => {
1049
+ // Stop on a standalone hold force-clears the flag, because no turn owns
1050
+ // the claim to signal, and the next request acquires. The incarnation is
1051
+ // unchanged, so without the ownership term the frame writes under a dead
1052
+ // claim alongside the turn that now holds the conversation, and the
1053
+ // refused release afterwards cannot undo the row.
1054
+ const live = liveConversation("Live voice keep stopped mid-write");
1055
+ try {
1056
+ const frame = await uploadFrame("stopped-mid-write.png");
1057
+
1058
+ duringPersistBeforeInsert = () => {
1059
+ // What `abortConversation` does for a hold with no live turn behind
1060
+ // it, followed by the next request taking the conversation.
1061
+ live.activeConversation.setProcessing(false);
1062
+ expect(live.activeConversation.acquireProcessing()).not.toBeNull();
1063
+ };
1064
+
1065
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
1066
+ ok: false,
1067
+ });
1068
+
1069
+ expect(getMessages(live.id)).toHaveLength(0);
1070
+ expect(frameStored(frame)).toBe(false);
1071
+ // The claim taken during the Stop is untouched by the frame's release.
1072
+ expect(live.activeConversation.isProcessing()).toBe(true);
1073
+ } finally {
1074
+ duringPersistBeforeInsert = null;
1075
+ live.activeConversation.setProcessing(false);
1076
+ live.dispose();
1077
+ }
1078
+ });
1079
+
606
1080
  test("a newer keep replaces one that is still waiting to start", async () => {
607
1081
  // The chain is bounded this way: keeps arrive every few seconds while each
608
1082
  // job can wait out a turn for far longer, so a queued keep that a newer
@@ -614,10 +1088,10 @@ describe("standalone image persists are serialized per conversation", () => {
614
1088
  const newest = await uploadFrame("newest.png");
615
1089
 
616
1090
  live.activeConversation.setProcessing(true);
617
- const runningKeep = persistLiveVoiceSightFrame(live.id, running);
1091
+ const runningKeep = persistAmbientSightFrame(live.id, running, "voice");
618
1092
  await sleep(30);
619
- const staleKeep = persistLiveVoiceSightFrame(live.id, stale);
620
- const newestKeep = persistLiveVoiceSightFrame(live.id, newest);
1093
+ const staleKeep = persistAmbientSightFrame(live.id, stale, "voice");
1094
+ const newestKeep = persistAmbientSightFrame(live.id, newest, "voice");
621
1095
  await sleep(30);
622
1096
  live.activeConversation.setProcessing(false);
623
1097
 
@@ -683,12 +1157,14 @@ describe("standalone image persists are serialized per conversation", () => {
683
1157
 
684
1158
  // A turn that outlasts the wait.
685
1159
  live.activeConversation.setProcessing(true);
686
- expect(await persistLiveVoiceSightFrame(live.id, frame)).toEqual({
1160
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
687
1161
  ok: false,
688
1162
  });
689
1163
 
690
1164
  expect(frameStored(frame)).toBe(false);
691
1165
  expect(getMessages(live.id)).toHaveLength(0);
1166
+ // The delete took first try, so nothing is left waiting on the store.
1167
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
692
1168
  } finally {
693
1169
  live.activeConversation.setProcessing(false);
694
1170
  live.dispose();
@@ -696,6 +1172,46 @@ describe("standalone image persists are serialized per conversation", () => {
696
1172
  }
697
1173
  });
698
1174
 
1175
+ test("a reclaim the store refuses is retried until it takes", async () => {
1176
+ // The refusal already told the client the daemon owns the upload, so a
1177
+ // delete that fails on the same contention everything else here waits out
1178
+ // cannot just be logged: the record is the only handle left on the bytes.
1179
+ const restoreWait = _setProcessingWaitMsForTests(120);
1180
+ const live = liveConversation("Live voice keep reclaim contention");
1181
+ try {
1182
+ const frame = await uploadFrame("reclaim-contention.png");
1183
+
1184
+ // A turn that outlasts the wait, so the keep is dropped and its upload
1185
+ // becomes the daemon's to collect.
1186
+ live.activeConversation.setProcessing(true);
1187
+ failNextReclaims = 1;
1188
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
1189
+ ok: false,
1190
+ });
1191
+
1192
+ expect(frameStored(frame)).toBe(true);
1193
+ expect(_pendingFrameReclaimCountForTests()).toBe(1);
1194
+
1195
+ // The recheck's own delete can fail too, and that keeps the record
1196
+ // rather than spending it.
1197
+ failNextReclaims = 1;
1198
+ _drainFrameReclaimRechecksForTests();
1199
+ expect(frameStored(frame)).toBe(true);
1200
+ expect(_pendingFrameReclaimCountForTests()).toBe(1);
1201
+
1202
+ _drainFrameReclaimRechecksForTests();
1203
+
1204
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
1205
+ expect(frameStored(frame)).toBe(false);
1206
+ expect(getMessages(live.id)).toHaveLength(0);
1207
+ } finally {
1208
+ failNextReclaims = 0;
1209
+ live.activeConversation.setProcessing(false);
1210
+ live.dispose();
1211
+ _setProcessingWaitMsForTests(restoreWait);
1212
+ }
1213
+ });
1214
+
699
1215
  test("a photo whose wait runs out keeps its upload", async () => {
700
1216
  // A photo is a deliberate upload the user watched themselves make, so a
701
1217
  // failed persist is theirs to retry rather than the daemon's to delete.
@@ -718,12 +1234,356 @@ describe("standalone image persists are serialized per conversation", () => {
718
1234
  }
719
1235
  });
720
1236
 
1237
+ test("a deletion is final for a keep still queued behind another", async () => {
1238
+ // The persist reaches its conversation through `getOrCreateConversation`,
1239
+ // which writes the row back for an id it finds missing. A keep that was
1240
+ // queued when the delete landed would therefore bring the conversation
1241
+ // back holding nothing but camera frames, so the job re-reads before it
1242
+ // touches anything.
1243
+ const restoreWait = _setProcessingWaitMsForTests(150);
1244
+ const live = liveConversation("Live voice keep deleted mid-queue");
1245
+ const holder = await uploadFrame("voice-holder.png");
1246
+ const queued = await uploadFrame("voice-queued.png");
1247
+ try {
1248
+ live.activeConversation.setProcessing(true);
1249
+ const holderKeep = persistAmbientSightFrame(live.id, holder, "voice");
1250
+ await sleep(30);
1251
+ const queuedKeep = persistAmbientSightFrame(live.id, queued, "voice");
1252
+
1253
+ destroyActiveConversation(live.id, { keepSubagentRecords: true });
1254
+ deleteConversationRows(live.id);
1255
+ const countAfterDelete = conversationCount();
1256
+
1257
+ expect(await holderKeep).toEqual({ ok: false });
1258
+ expect(await queuedKeep).toEqual({ ok: false });
1259
+
1260
+ expect(getConversation(live.id)).toBeNull();
1261
+ expect(conversationCount()).toBe(countAfterDelete);
1262
+ expect(getMessages(live.id)).toHaveLength(0);
1263
+ expect(frameStored(queued)).toBe(false);
1264
+ } finally {
1265
+ _setProcessingWaitMsForTests(restoreWait);
1266
+ }
1267
+ });
1268
+
1269
+ test("a keep does not land in a conversation recreated under its id", async () => {
1270
+ // The job holds its instance across the idle wait rather than re-reading,
1271
+ // so a delete alone is caught only by the messages foreign key. A recreate
1272
+ // under the same id restores that foreign key's target, and the frame
1273
+ // would land in a conversation made after the deletion.
1274
+ const live = liveConversation("Live voice keep recreated id");
1275
+ try {
1276
+ const frame = await uploadFrame("recreated.png");
1277
+
1278
+ // A turn the keep has to wait out, which is the window the delete and
1279
+ // the recreate land in.
1280
+ live.activeConversation.setProcessing(true);
1281
+ const keep = persistAmbientSightFrame(live.id, frame, "voice");
1282
+ await sleep(30);
1283
+
1284
+ // The same id, naming a conversation the keep was never taken in.
1285
+ deleteConversationRows(live.id);
1286
+ createConversation({ id: live.id, title: "Recreated" });
1287
+ live.activeConversation.setProcessing(false);
1288
+
1289
+ expect(await keep).toEqual({ ok: false });
1290
+
1291
+ expect(getMessages(live.id)).toHaveLength(0);
1292
+ expect(frameStored(frame)).toBe(false);
1293
+ } finally {
1294
+ live.dispose();
1295
+ }
1296
+ });
1297
+
1298
+ test("a keep waiting its turn in the chain does not land in a replacement", async () => {
1299
+ // The incarnation a frame is accepted for is read before it is queued, not
1300
+ // when its job starts. The chain can hold a keep behind another image for
1301
+ // as long as a turn runs, and a job that read the id when it began would
1302
+ // read the replacement and then check it against itself.
1303
+ const live = liveConversation("Live voice keep queued behind another");
1304
+ const holder = await uploadFrame("chain-holder.png");
1305
+ const queued = await uploadFrame("chain-queued.png");
1306
+ try {
1307
+ // A turn the first keep waits out, which is what keeps the second one
1308
+ // chained rather than started.
1309
+ live.activeConversation.setProcessing(true);
1310
+ const holderKeep = persistAmbientSightFrame(live.id, holder, "voice");
1311
+ await sleep(30);
1312
+ const queuedKeep = persistAmbientSightFrame(live.id, queued, "voice");
1313
+
1314
+ // The same id, naming a conversation neither frame was taken in.
1315
+ deleteConversationRows(live.id);
1316
+ createConversation({ id: live.id, title: "Recreated" });
1317
+ live.activeConversation.setProcessing(false);
1318
+
1319
+ expect(await holderKeep).toEqual({ ok: false });
1320
+ expect(await queuedKeep).toEqual({ ok: false });
1321
+
1322
+ expect(getMessages(live.id)).toHaveLength(0);
1323
+ expect(frameStored(holder)).toBe(false);
1324
+ expect(frameStored(queued)).toBe(false);
1325
+ } finally {
1326
+ live.dispose();
1327
+ }
1328
+ });
1329
+
1330
+ test("a keep is refused by a conversation recreated in the same millisecond", async () => {
1331
+ // `created_at` is the whole of an incarnation's identity, so two rows
1332
+ // stamped from one wall-clock millisecond would read as the same
1333
+ // conversation and every fence would pass. The stamp is issued
1334
+ // monotonically, which is what keeps the two apart.
1335
+ const live = liveConversation("Live voice same millisecond recreate");
1336
+ try {
1337
+ const frame = await uploadFrame("same-millisecond.png");
1338
+ const takenIn = getConversation(live.id)!.createdAt;
1339
+
1340
+ live.activeConversation.setProcessing(true);
1341
+ const keep = persistAmbientSightFrame(live.id, frame, "voice");
1342
+ await sleep(30);
1343
+
1344
+ const clock = spyOn(Date, "now").mockReturnValue(takenIn);
1345
+ try {
1346
+ deleteConversationRows(live.id);
1347
+ createConversation({ id: live.id, title: "Recreated" });
1348
+ } finally {
1349
+ clock.mockRestore();
1350
+ }
1351
+ expect(getConversation(live.id)!.createdAt).not.toBe(takenIn);
1352
+
1353
+ live.activeConversation.setProcessing(false);
1354
+
1355
+ expect(await keep).toEqual({ ok: false });
1356
+ expect(getMessages(live.id)).toHaveLength(0);
1357
+ expect(frameStored(frame)).toBe(false);
1358
+ } finally {
1359
+ live.dispose();
1360
+ }
1361
+ });
1362
+
1363
+ test("a keep does not land in a conversation replaced during its write", async () => {
1364
+ // The check before the persist answers only for the moment it ran. The
1365
+ // persist then materializes the frame and builds its content, all awaited,
1366
+ // and a delete and recreate under the same id landing in any of those
1367
+ // windows restores the messages foreign key's target, so nothing else
1368
+ // stops the frame joining a conversation it was never taken in.
1369
+ const live = liveConversation("Live voice keep replaced mid-write");
1370
+ try {
1371
+ const frame = await uploadFrame("mid-write.png");
1372
+
1373
+ duringPersistBeforeInsert = () => {
1374
+ deleteConversationRows(live.id);
1375
+ createConversation({ id: live.id, title: "Recreated" });
1376
+ };
1377
+
1378
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
1379
+ ok: false,
1380
+ });
1381
+
1382
+ expect(getMessages(live.id)).toHaveLength(0);
1383
+ // Nothing else would ever collect it: the client's abandon-delete fires
1384
+ // on a refused send, and this send was accepted.
1385
+ expect(frameStored(frame)).toBe(false);
1386
+ } finally {
1387
+ duringPersistBeforeInsert = null;
1388
+ live.dispose();
1389
+ }
1390
+ });
1391
+
1392
+ test("a keep is refused by a replacement that lands between insert attempts", async () => {
1393
+ // Contention retries the insert after an awaited backoff, so an answer
1394
+ // given once for the call speaks only for the first attempt. A delete and
1395
+ // recreate inside that sleep leaves the retry writing into a row the frame
1396
+ // was never accepted for.
1397
+ const live = liveConversation("Live voice keep replaced between attempts");
1398
+ try {
1399
+ const frame = await uploadFrame("between-attempts.png");
1400
+
1401
+ beforeFailingInsertAttempt = () => {
1402
+ deleteConversationRows(live.id);
1403
+ createConversation({ id: live.id, title: "Recreated" });
1404
+ };
1405
+
1406
+ expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
1407
+ ok: false,
1408
+ });
1409
+
1410
+ expect(getMessages(live.id)).toHaveLength(0);
1411
+ expect(frameStored(frame)).toBe(false);
1412
+ } finally {
1413
+ beforeFailingInsertAttempt = null;
1414
+ live.dispose();
1415
+ }
1416
+ });
1417
+
1418
+ test("a keep whose first insert attempt hit contention still lands", async () => {
1419
+ // The control on the check above: a retry the conversation outlived is the
1420
+ // ordinary path `withSqliteRetry` exists for, and the frame has to survive
1421
+ // it exactly as it always has.
1422
+ const live = liveConversation("Live voice keep retried insert");
1423
+ try {
1424
+ const frame = await uploadFrame("retried.png");
1425
+
1426
+ beforeFailingInsertAttempt = () => {};
1427
+
1428
+ const result = await persistAmbientSightFrame(live.id, frame, "voice");
1429
+ expect(result.ok).toBe(true);
1430
+
1431
+ const rows = getMessages(live.id);
1432
+ expect(rows).toHaveLength(1);
1433
+ expect(result.messageId).toBe(rows[0].id);
1434
+ expect(frameStored(frame)).toBe(true);
1435
+ } finally {
1436
+ beforeFailingInsertAttempt = null;
1437
+ live.dispose();
1438
+ }
1439
+ });
1440
+
1441
+ test("a frame refused under an unreadable store is collected once it answers", async () => {
1442
+ // `persisted: false` tells the client the daemon has dealt with the
1443
+ // upload, and nothing else ever collects an attachment no caller names:
1444
+ // collection is candidate-driven with no sweep. So a refusal the store
1445
+ // could not explain has to come back to the question rather than leave the
1446
+ // bytes for good.
1447
+ const live = liveConversation("Live voice keep unreadable store");
1448
+ const watch = watchEchoes(live.id);
1449
+ try {
1450
+ const frame = await uploadFrame("unreadable.png");
1451
+
1452
+ storeUnreadable = true;
1453
+ const result = await persistAmbientSightFrame(live.id, frame, "voice");
1454
+ storeUnreadable = false;
1455
+
1456
+ expect(result).toEqual({ ok: false });
1457
+ // Not reclaimed on the spot: "could not read" is not "no row".
1458
+ expect(frameStored(frame)).toBe(true);
1459
+ expect(_pendingFrameReclaimCountForTests()).toBe(1);
1460
+
1461
+ _drainFrameReclaimRechecksForTests();
1462
+
1463
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
1464
+ expect(frameStored(frame)).toBe(false);
1465
+ expect(getMessages(live.id)).toHaveLength(0);
1466
+ // No row landed, so there is nothing to tell the client about and the
1467
+ // refusal it already has stands.
1468
+ expect(watch.echoes()).toHaveLength(0);
1469
+ } finally {
1470
+ storeUnreadable = false;
1471
+ watch.dispose();
1472
+ live.dispose();
1473
+ }
1474
+ });
1475
+
1476
+ test("a frame whose row landed is kept and announced when the store answers", async () => {
1477
+ // The other half of the same question. A persist can fail after its row
1478
+ // committed, and that row references the bytes through content the link
1479
+ // write never got to protect, so a recheck that finds the row must leave
1480
+ // the attachment alone. The client was told the frame was dropped, so
1481
+ // without the announce the row it never heard about waits for a reload.
1482
+ const live = liveConversation("Live voice keep row landed unreadable");
1483
+ const watch = watchEchoes(live.id);
1484
+ try {
1485
+ const frame = await uploadFrame("landed.png");
1486
+
1487
+ breakStoreAfterInsert = true;
1488
+ const result = await persistAmbientSightFrame(live.id, frame, "voice");
1489
+ storeUnreadable = false;
1490
+
1491
+ expect(result).toEqual({ ok: false });
1492
+ expect(_pendingFrameReclaimCountForTests()).toBe(1);
1493
+
1494
+ _drainFrameReclaimRechecksForTests();
1495
+
1496
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
1497
+ // The row is in the transcript, so its bytes are spoken for.
1498
+ const rows = getMessages(live.id);
1499
+ expect(rows).toHaveLength(1);
1500
+ expect(frameStored(frame)).toBe(true);
1501
+
1502
+ await waitFor(() => watch.echoes().length > 0, {
1503
+ message: "Timed out waiting for the deferred row announcement",
1504
+ });
1505
+ expect(watch.echoes()).toMatchObject([
1506
+ {
1507
+ type: "user_message_echo",
1508
+ text: "(camera frame)",
1509
+ conversationId: live.id,
1510
+ messageId: rows[0].id,
1511
+ },
1512
+ ]);
1513
+ } finally {
1514
+ breakStoreAfterInsert = false;
1515
+ storeUnreadable = false;
1516
+ watch.dispose();
1517
+ live.dispose();
1518
+ }
1519
+ });
1520
+
1521
+ test("a frame outlasting the quick passes waits, then settles when the store returns", async () => {
1522
+ // Ten passes was once the whole budget, and dropping a record there loses
1523
+ // its upload for good: nothing else in the daemon collects an attachment
1524
+ // no caller names. The passes only get further apart.
1525
+ const live = liveConversation("Live voice keep long outage");
1526
+ const watch = watchEchoes(live.id);
1527
+ try {
1528
+ const landed = await uploadFrame("outage-landed.png");
1529
+ const dropped = await uploadFrame("outage-dropped.png");
1530
+
1531
+ // One frame whose row commits before the store goes, one that never
1532
+ // reaches an insert at all, so both branches wait out the same outage.
1533
+ breakStoreAfterInsert = true;
1534
+ expect(await persistAmbientSightFrame(live.id, landed, "voice")).toEqual({
1535
+ ok: false,
1536
+ });
1537
+ expect(await persistAmbientSightFrame(live.id, dropped, "voice")).toEqual(
1538
+ { ok: false },
1539
+ );
1540
+
1541
+ expect(_pendingFrameReclaimCountForTests()).toBe(2);
1542
+ expect(_nextFrameReclaimRecheckDelayForTests()).toBe(30_000);
1543
+
1544
+ // Well past the ten passes the records used to be given up at.
1545
+ for (let pass = 0; pass < 14; pass += 1) {
1546
+ _drainFrameReclaimRechecksForTests();
1547
+ }
1548
+
1549
+ expect(_pendingFrameReclaimCountForTests()).toBe(2);
1550
+ // Asking less often rather than not at all.
1551
+ expect(_nextFrameReclaimRecheckDelayForTests()).toBe(300_000);
1552
+
1553
+ // The store comes back long after the old cap.
1554
+ storeUnreadable = false;
1555
+ // Neither upload was given up while it was down.
1556
+ expect(frameStored(landed)).toBe(true);
1557
+ expect(frameStored(dropped)).toBe(true);
1558
+
1559
+ _drainFrameReclaimRechecksForTests();
1560
+
1561
+ expect(_pendingFrameReclaimCountForTests()).toBe(0);
1562
+ expect(frameStored(landed)).toBe(true);
1563
+ expect(frameStored(dropped)).toBe(false);
1564
+
1565
+ const rows = getMessages(live.id);
1566
+ expect(rows).toHaveLength(1);
1567
+ await waitFor(() => watch.echoes().length > 0, {
1568
+ message: "Timed out waiting for the post-outage row announcement",
1569
+ });
1570
+ expect(watch.echoes()).toMatchObject([
1571
+ { type: "user_message_echo", messageId: rows[0].id },
1572
+ ]);
1573
+ } finally {
1574
+ breakStoreAfterInsert = false;
1575
+ storeUnreadable = false;
1576
+ watch.dispose();
1577
+ live.dispose();
1578
+ }
1579
+ });
1580
+
721
1581
  test("the conversation's chain is dropped once it drains", async () => {
722
1582
  const live = liveConversation("Live voice chain cleanup");
723
1583
  try {
724
1584
  const frame = await uploadFrame("cleanup.png");
725
1585
 
726
- const pending = persistLiveVoiceSightFrame(live.id, frame);
1586
+ const pending = persistAmbientSightFrame(live.id, frame, "voice");
727
1587
  expect(_standaloneImageQueueSizeForTests()).toBe(1);
728
1588
  expect(await pending).toMatchObject({ ok: true });
729
1589