@vellumai/assistant 0.11.1 → 0.11.2-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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Producer-to-finalizer state-chain coverage for the memory retrospective
3
+ * (LUM-3013).
4
+ *
5
+ * These tests run the REAL chain: `runForkBasedRetrospective` (real fork +
6
+ * real `memory_retrospective_state` rows in the test DB) invokes the REAL
7
+ * `wakeAgentForOpportunity`, which runs a REAL `AgentLoop` whose provider is
8
+ * scripted per test. Provider rejections therefore exercise the loop's actual
9
+ * catch/`stopTurn` path (no manually emitted events), and finalization reads
10
+ * and writes the actual persisted rows.
11
+ *
12
+ * What is stubbed, and why it is outside the contract under proof:
13
+ * - The wake's conversation resolution: the module mock below re-exports the
14
+ * real `wakeAgentForOpportunity` with an injected `resolveTarget` that
15
+ * yields a structural conversation double wrapping the real `AgentLoop`
16
+ * (the default resolver is daemon-hydration machinery, not state logic).
17
+ * - `recordUsage` (cost ledger) and `resolveEffectiveContextWindow` /
18
+ * disk-pressure status (config lookups): observability and gating leaves.
19
+ *
20
+ * The LUM-3013 data-integrity contract proven here:
21
+ * 1. A provider rejection reaches `invoked: false` BEFORE any state
22
+ * mutation: no cursor advance, no remembered-log change, no
23
+ * prior-retrospective deletion; the orphan fork is cleaned up.
24
+ * 2. The failed window stays retryable: a later run over the same window
25
+ * succeeds and advances state.
26
+ * 3. A usable-output control advances state (cursor, remembered log) and
27
+ * GCs the superseded prior retrospective.
28
+ * 4. A run whose checkpoint went live before a later rejection stays
29
+ * `invoked: true` (its side effects landed and are honored).
30
+ */
31
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
32
+
33
+ import { AgentLoop } from "../../../../agent/loop.js";
34
+ import type { Conversation } from "../../../../daemon/conversation.js";
35
+ import {
36
+ addMessage,
37
+ createConversation,
38
+ forkConversationForRetrospective,
39
+ getConversation,
40
+ getMessages,
41
+ } from "../../../../persistence/conversation-crud.js";
42
+ import { getDb, getMemoryDb } from "../../../../persistence/db-connection.js";
43
+ import { initializeDb } from "../../../../persistence/db-init.js";
44
+ import {
45
+ conversations,
46
+ memoryJobs,
47
+ memoryRetrospectiveState,
48
+ messages as messagesTable,
49
+ } from "../../../../persistence/schema/index.js";
50
+ import type {
51
+ Message,
52
+ Provider,
53
+ ProviderResponse,
54
+ SendMessageOptions,
55
+ ToolDefinition,
56
+ } from "../../../../providers/types.js";
57
+ // Capture the real wake before the module mock below re-links the module
58
+ // for every importer (including memory-retrospective-job).
59
+ import { wakeAgentForOpportunity as importedWake } from "../../../../runtime/agent-wake.js";
60
+ import * as realAgentWakeModule from "../../../../runtime/agent-wake.js";
61
+ import {
62
+ MEMORY_RETROSPECTIVE_FORK_SOURCE,
63
+ MEMORY_RETROSPECTIVE_GROUP_ID,
64
+ } from "../memory-retrospective-constants.js";
65
+ import { runForkBasedRetrospective } from "../memory-retrospective-job.js";
66
+ import {
67
+ getRetrospectiveState,
68
+ upsertRetrospectiveState,
69
+ } from "../memory-retrospective-state.js";
70
+
71
+ const realWake = importedWake;
72
+
73
+ // ── Scripted provider ────────────────────────────────────────────────
74
+ //
75
+ // Each entry is either a canned response or a thrown rejection, consumed in
76
+ // order; the last entry repeats. Reassigned per test.
77
+ type ProviderStep = { response: ProviderResponse } | { reject: Error };
78
+ let providerScript: ProviderStep[] = [];
79
+ let providerCallCount = 0;
80
+
81
+ const scriptedProvider: Provider = {
82
+ name: "mock-provider",
83
+ async sendMessage(
84
+ _messages: Message[],
85
+ _options?: SendMessageOptions,
86
+ ): Promise<ProviderResponse> {
87
+ const step =
88
+ providerScript[providerCallCount] ??
89
+ providerScript[providerScript.length - 1];
90
+ providerCallCount++;
91
+ if (!step) {
92
+ throw new Error("provider script exhausted");
93
+ }
94
+ if ("reject" in step) {
95
+ throw step.reject;
96
+ }
97
+ return step.response;
98
+ },
99
+ };
100
+
101
+ function textResponse(text: string): ProviderResponse {
102
+ return {
103
+ content: [{ type: "text", text }],
104
+ model: "mock-model",
105
+ usage: { inputTokens: 10, outputTokens: 5 },
106
+ stopReason: "end_turn",
107
+ };
108
+ }
109
+
110
+ function rememberToolUseResponse(fact: string): ProviderResponse {
111
+ return {
112
+ content: [
113
+ {
114
+ type: "tool_use",
115
+ id: "tu-1",
116
+ name: "remember",
117
+ input: { content: fact },
118
+ },
119
+ ],
120
+ model: "mock-model",
121
+ usage: { inputTokens: 10, outputTokens: 5 },
122
+ stopReason: "tool_use",
123
+ };
124
+ }
125
+
126
+ const rememberToolDef: ToolDefinition[] = [
127
+ {
128
+ name: "remember",
129
+ description: "Save a fact to memory",
130
+ input_schema: {
131
+ type: "object",
132
+ properties: { content: { type: "string" } },
133
+ },
134
+ },
135
+ ];
136
+
137
+ // ── Conversation double wrapping a REAL AgentLoop ────────────────────
138
+ //
139
+ // Structural double for the fork conversation the wake resolves: only the
140
+ // members the wake touches, with `agentLoop` a real `AgentLoop` over the
141
+ // scripted provider so provider failures exercise the loop's genuine
142
+ // catch/stopTurn path. Tail persistence, state reads/writes, and fork
143
+ // cleanup all run against the real test DB.
144
+ function makeForkConversationDouble(forkId: string): Conversation {
145
+ const rows = getMessages(forkId);
146
+ const messages: Message[] = rows.map((row) => {
147
+ let content: unknown = row.content;
148
+ if (typeof content === "string") {
149
+ try {
150
+ content = JSON.parse(content);
151
+ } catch {
152
+ // Keep the raw string when not JSON.
153
+ }
154
+ }
155
+ return { role: row.role, content } as Message;
156
+ });
157
+
158
+ const agentLoop = new AgentLoop({
159
+ provider: scriptedProvider,
160
+ systemPrompt: "chain-test system prompt",
161
+ conversationId: forkId,
162
+ tools: rememberToolDef,
163
+ toolExecutor: async () => ({ content: "Saved.", isError: false }),
164
+ });
165
+
166
+ let processing = false;
167
+ let activeAllowedTools: Set<string> | undefined;
168
+ let persistentTrust: unknown;
169
+ let turnTrust: unknown;
170
+
171
+ const conversation = {
172
+ conversationId: forkId,
173
+ agentLoop,
174
+ provider: scriptedProvider,
175
+ usageStats: {},
176
+ messages,
177
+ getMessages: () => messages,
178
+ isProcessing: () => processing,
179
+ setProcessing: (on: boolean) => {
180
+ processing = on;
181
+ },
182
+ waitForIdle: async () => true,
183
+ get subagentAllowedTools() {
184
+ return activeAllowedTools;
185
+ },
186
+ setSubagentAllowedTools: (tools: Set<string> | undefined) => {
187
+ activeAllowedTools = tools;
188
+ },
189
+ preactivatedSkillIds: undefined,
190
+ setPreactivatedSkillIds: () => {},
191
+ subagentToolGateMode: undefined,
192
+ toolContextPin: undefined,
193
+ currentTurnRequestOrigin: undefined,
194
+ wakePersonaOverride: undefined,
195
+ currentCallSite: undefined,
196
+ currentTurnOverrideProfile: undefined,
197
+ hasNoClient: false,
198
+ get currentTurnTrustContext() {
199
+ return turnTrust;
200
+ },
201
+ set currentTurnTrustContext(value: unknown) {
202
+ turnTrust = value;
203
+ },
204
+ get trustContext() {
205
+ return persistentTrust;
206
+ },
207
+ setTrustContext: (ctx: unknown) => {
208
+ persistentTrust = ctx ?? undefined;
209
+ },
210
+ maybeCompact: async () => null,
211
+ contextWindowManager: {
212
+ estimateInputTokens: () => 1_000,
213
+ },
214
+ getTurnChannelContext: () => null,
215
+ getTurnInterfaceContext: () => null,
216
+ buildCurrentSystemPrompt: () => "chain-test system prompt",
217
+ modelOverride: undefined,
218
+ drainQueue: async () => {},
219
+ };
220
+ return conversation as unknown as Conversation;
221
+ }
222
+
223
+ // ── Module mocks ─────────────────────────────────────────────────────
224
+
225
+ // Re-export the REAL wake with the conversation resolver injected, so the
226
+ // retrospective job's unmodified `wakeAgentForOpportunity(...)` call runs the
227
+ // complete production wake logic against the real-AgentLoop double above.
228
+ mock.module("../../../../runtime/agent-wake.js", () => ({
229
+ ...realAgentWakeModule,
230
+ wakeAgentForOpportunity: (
231
+ opts: Parameters<typeof realWake>[0],
232
+ ): ReturnType<typeof realWake> =>
233
+ realWake(opts, {
234
+ resolveTarget: async (wakeOpts) =>
235
+ makeForkConversationDouble(wakeOpts.conversationId),
236
+ }),
237
+ }));
238
+
239
+ // Config-lookup leaves outside the state contract under proof.
240
+ mock.module("../../../../config/llm-context-resolution.js", () => ({
241
+ resolveEffectiveContextWindow: () => ({ maxInputTokens: 200_000 }),
242
+ }));
243
+ mock.module("../../../../daemon/conversation-usage.js", () => ({
244
+ recordUsage: () => {},
245
+ }));
246
+ mock.module("../../../../daemon/disk-pressure-guard.js", () => ({
247
+ getDiskPressureStatus: () => ({
248
+ enabled: false,
249
+ state: "disabled",
250
+ locked: false,
251
+ acknowledged: false,
252
+ overrideActive: false,
253
+ effectivelyLocked: false,
254
+ lockId: null,
255
+ usagePercent: null,
256
+ thresholdPercent: 95,
257
+ path: null,
258
+ lastCheckedAt: null,
259
+ blockedCapabilities: [],
260
+ error: null,
261
+ }),
262
+ }));
263
+
264
+ await initializeDb();
265
+
266
+ // ── Fixtures ─────────────────────────────────────────────────────────
267
+
268
+ const chainConfig = {
269
+ memory: {
270
+ v2: { enabled: true },
271
+ retrospective: {
272
+ keepSupersededRuns: false,
273
+ matchConversationProfile: false,
274
+ promptPath: null,
275
+ requireUserActivity: false,
276
+ sweepIntervalMs: 8 * 60 * 60 * 1000,
277
+ sweepLookbackMs: 7 * 24 * 60 * 60 * 1000,
278
+ },
279
+ },
280
+ ui: {},
281
+ } as unknown as Parameters<typeof runForkBasedRetrospective>[1];
282
+
283
+ const PRIOR_FACT = "User's cat is named Mochi";
284
+
285
+ interface ChainFixture {
286
+ sourceId: string;
287
+ priorRetroId: string;
288
+ seededCursorId: string;
289
+ newMessageId: string;
290
+ }
291
+
292
+ /**
293
+ * Stage the full pre-run world in the real DB: a source conversation with
294
+ * processed history, a genuine prior retrospective fork, a seeded state row
295
+ * (cursor + remembered log), and one new unprocessed user message.
296
+ */
297
+ async function stageChainFixture(): Promise<ChainFixture> {
298
+ const source = await createConversation("Chain source");
299
+ const first = await addMessage(
300
+ source.id,
301
+ "user",
302
+ JSON.stringify([{ type: "text", text: "Hi, my cat is named Mochi." }]),
303
+ );
304
+ await addMessage(
305
+ source.id,
306
+ "assistant",
307
+ JSON.stringify([{ type: "text", text: "Noted!" }]),
308
+ );
309
+
310
+ const prior = await forkConversationForRetrospective({
311
+ conversationId: source.id,
312
+ throughMessageId: first.id,
313
+ source: MEMORY_RETROSPECTIVE_FORK_SOURCE,
314
+ title: "Chain source (Retrospective)",
315
+ conversationType: "background",
316
+ groupId: MEMORY_RETROSPECTIVE_GROUP_ID,
317
+ });
318
+
319
+ const processedThrough = await addMessage(
320
+ source.id,
321
+ "assistant",
322
+ JSON.stringify([{ type: "text", text: "Anything else?" }]),
323
+ );
324
+ await upsertRetrospectiveState({
325
+ conversationId: source.id,
326
+ lastProcessedMessageId: processedThrough.id,
327
+ lastRunAt: Date.now() - 60 * 60 * 1000,
328
+ rememberedLog: [PRIOR_FACT],
329
+ });
330
+
331
+ const fresh = await addMessage(
332
+ source.id,
333
+ "user",
334
+ JSON.stringify([{ type: "text", text: "I just moved to Lisbon." }]),
335
+ );
336
+
337
+ return {
338
+ sourceId: source.id,
339
+ priorRetroId: prior.id,
340
+ seededCursorId: processedThrough.id,
341
+ newMessageId: fresh.id,
342
+ };
343
+ }
344
+
345
+ /** Fork conversations currently rooted at the given source. */
346
+ function listRetroForksOf(sourceId: string): string[] {
347
+ const db = getDb();
348
+ return db
349
+ .select({ id: conversations.id })
350
+ .from(conversations)
351
+ .all()
352
+ .map((row) => row.id)
353
+ .filter((id) => {
354
+ const conv = getConversation(id);
355
+ return (
356
+ conv?.source === MEMORY_RETROSPECTIVE_FORK_SOURCE &&
357
+ conv.forkParentConversationId === sourceId
358
+ );
359
+ });
360
+ }
361
+
362
+ function resetTables(): void {
363
+ const db = getDb();
364
+ getMemoryDb()!.delete(memoryRetrospectiveState).run();
365
+ getMemoryDb()!.delete(memoryJobs).run();
366
+ db.delete(messagesTable).run();
367
+ db.delete(conversations).run();
368
+ }
369
+
370
+ beforeEach(() => {
371
+ resetTables();
372
+ providerScript = [];
373
+ providerCallCount = 0;
374
+ });
375
+
376
+ // ── Tests ────────────────────────────────────────────────────────────
377
+
378
+ describe("memory retrospective wake state chain (real AgentLoop)", () => {
379
+ test("provider rejection: no cursor advance, no log mutation, prior kept, fork cleaned up, window retryable", async () => {
380
+ const fixture = await stageChainFixture();
381
+ const stateBefore = getRetrospectiveState(fixture.sourceId);
382
+
383
+ // The real loop swallows this into agent_loop_exit("error"); nothing is
384
+ // scripted at the event level.
385
+ providerScript = [
386
+ { reject: new Error("400 this model does not support image input") },
387
+ ];
388
+
389
+ const outcome = await runForkBasedRetrospective(
390
+ fixture.sourceId,
391
+ chainConfig,
392
+ );
393
+
394
+ expect(outcome.kind).toBe("wake_failed");
395
+ if (outcome.kind === "wake_failed") {
396
+ expect(outcome.reason).toBe("run_error");
397
+ }
398
+
399
+ // (1) No successful finalization: cursor and remembered log unchanged.
400
+ const stateAfter = getRetrospectiveState(fixture.sourceId);
401
+ expect(stateAfter?.lastProcessedMessageId).toBe(fixture.seededCursorId);
402
+ expect(stateAfter?.rememberedLog).toEqual([PRIOR_FACT]);
403
+ // Cooldown applies to the failed attempt.
404
+ expect(stateAfter!.lastRunAt).toBeGreaterThan(stateBefore!.lastRunAt!);
405
+
406
+ // Prior retrospective (the dedup-baseline fallback) survives.
407
+ expect(getConversation(fixture.priorRetroId)).not.toBeNull();
408
+ // This run's own orphan fork is deleted; the prior is the only fork left.
409
+ expect(listRetroForksOf(fixture.sourceId)).toEqual([fixture.priorRetroId]);
410
+
411
+ // (2) The window stays retryable: the same slice succeeds on retry.
412
+ providerScript = [
413
+ { response: textResponse("Reviewed. Nothing new worth saving.") },
414
+ ];
415
+ providerCallCount = 0;
416
+ const retry = await runForkBasedRetrospective(
417
+ fixture.sourceId,
418
+ chainConfig,
419
+ );
420
+ expect(retry.kind).toBe("invoked");
421
+ const stateRetried = getRetrospectiveState(fixture.sourceId);
422
+ expect(stateRetried?.lastProcessedMessageId).toBe(fixture.newMessageId);
423
+ });
424
+
425
+ test("clean empty reply (HTTP-200, content: []): fails closed, state preserved, window retryable", async () => {
426
+ const fixture = await stageChainFixture();
427
+
428
+ // A well-formed provider success whose finalized output is unusable:
429
+ // empty content array, clean end_turn. The real loop exits
430
+ // no_tool_calls; the retrospective's requireUsableOutput turns that
431
+ // into a failed, retryable pass instead of consuming the window.
432
+ providerScript = [
433
+ {
434
+ response: {
435
+ content: [],
436
+ model: "mock-model",
437
+ usage: { inputTokens: 10, outputTokens: 0 },
438
+ stopReason: "end_turn",
439
+ },
440
+ },
441
+ ];
442
+
443
+ const outcome = await runForkBasedRetrospective(
444
+ fixture.sourceId,
445
+ chainConfig,
446
+ );
447
+
448
+ expect(outcome.kind).toBe("wake_failed");
449
+ if (outcome.kind === "wake_failed") {
450
+ expect(outcome.reason).toBe("no_output");
451
+ }
452
+
453
+ // No finalization: cursor, log, and the prior retrospective untouched;
454
+ // the orphan fork is cleaned up.
455
+ const state = getRetrospectiveState(fixture.sourceId);
456
+ expect(state?.lastProcessedMessageId).toBe(fixture.seededCursorId);
457
+ expect(state?.rememberedLog).toEqual([PRIOR_FACT]);
458
+ expect(getConversation(fixture.priorRetroId)).not.toBeNull();
459
+ expect(listRetroForksOf(fixture.sourceId)).toEqual([fixture.priorRetroId]);
460
+
461
+ // The window stays retryable and a usable reply consumes it.
462
+ providerScript = [{ response: textResponse("Reviewed; nothing new.") }];
463
+ providerCallCount = 0;
464
+ const retry = await runForkBasedRetrospective(
465
+ fixture.sourceId,
466
+ chainConfig,
467
+ );
468
+ expect(retry.kind).toBe("invoked");
469
+ expect(
470
+ getRetrospectiveState(fixture.sourceId)?.lastProcessedMessageId,
471
+ ).toBe(fixture.newMessageId);
472
+ });
473
+
474
+ test("usable-output control: cursor advances, remembered log grows, prior retrospective is GC'd", async () => {
475
+ const fixture = await stageChainFixture();
476
+
477
+ providerScript = [
478
+ { response: rememberToolUseResponse("User moved to Lisbon") },
479
+ { response: textResponse("Saved the move to memory.") },
480
+ ];
481
+
482
+ const outcome = await runForkBasedRetrospective(
483
+ fixture.sourceId,
484
+ chainConfig,
485
+ );
486
+
487
+ expect(outcome.kind).toBe("invoked");
488
+ if (outcome.kind === "invoked") {
489
+ expect(outcome.cutoffMessageId).toBe(fixture.newMessageId);
490
+ }
491
+
492
+ // (3) Real finalization against real rows: cursor advanced, this run's
493
+ // remember (extracted from the fork's persisted tail) appended to the
494
+ // cumulative log, superseded prior GC'd.
495
+ const state = getRetrospectiveState(fixture.sourceId);
496
+ expect(state?.lastProcessedMessageId).toBe(fixture.newMessageId);
497
+ expect(state?.rememberedLog).toEqual([PRIOR_FACT, "User moved to Lisbon"]);
498
+ expect(getConversation(fixture.priorRetroId)).toBeNull();
499
+ });
500
+
501
+ test("rejection after a live checkpoint stays invoked: landed side effects are honored", async () => {
502
+ const fixture = await stageChainFixture();
503
+
504
+ // First call: a full remember tool round (checkpoint goes live and the
505
+ // tail is persisted). Second call: provider rejection, swallowed by the
506
+ // real loop into a no-output stop.
507
+ providerScript = [
508
+ { response: rememberToolUseResponse("User moved to Lisbon") },
509
+ { reject: new Error("500 provider died mid-run") },
510
+ ];
511
+
512
+ const outcome = await runForkBasedRetrospective(
513
+ fixture.sourceId,
514
+ chainConfig,
515
+ );
516
+
517
+ // (4) The run's `remember` executed, so it must not read as skipped:
518
+ // the wake reports invoked and finalization honors the landed work.
519
+ expect(outcome.kind).toBe("invoked");
520
+ const state = getRetrospectiveState(fixture.sourceId);
521
+ expect(state?.lastProcessedMessageId).toBe(fixture.newMessageId);
522
+ expect(state?.rememberedLog).toEqual([PRIOR_FACT, "User moved to Lisbon"]);
523
+ });
524
+ });
@@ -156,7 +156,7 @@ const HOST_TIER_IMPORT_ALLOWLIST: readonly HostTierImportExemption[] = [
156
156
  { path: "src/runtime/routes/consolidation-routes.ts", tiers: ["substrate"] },
157
157
  {
158
158
  path: "src/runtime/routes/conversation-query-routes.ts",
159
- tiers: ["substrate", "v2", "v3"],
159
+ tiers: ["v2", "v3"],
160
160
  },
161
161
  { path: "src/runtime/routes/global-search-routes.ts", tiers: ["v1"] },
162
162
  { path: "src/runtime/routes/secret-routes.ts", tiers: ["substrate"] },
@@ -498,9 +498,11 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
498
498
  ).toBe(false);
499
499
  });
500
500
 
501
- test("config on with empty Qdrant hits → no v2 block, v1 fallback skipped", async () => {
501
+ test("config on with empty Qdrant hits → no retrieved concepts in the v2 block, v1 fallback skipped", async () => {
502
502
  // No `stageTurn` call — every channel returns `{ points: [] }` so the
503
- // candidate set is empty and `injectMemoryV2Block` returns block=null.
503
+ // candidate set is empty and retrieval contributes nothing. Always-candidate
504
+ // skills are pinned rather than retrieved, so the block may still carry
505
+ // their cards; what must be absent is any retrieved concept section.
504
506
  const memory = makeMemory();
505
507
  const config = makeConfig(true);
506
508
  const messages = makeMessages();
@@ -512,8 +514,11 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
512
514
  noopEvent,
513
515
  );
514
516
 
515
- expect(result.injectedBlockText).toBeNull();
516
- expect(result.runMessages).toEqual(messages);
517
+ expect(result.injectedBlockText ?? "").not.toContain("# memory/concepts/");
518
+ expect(JSON.stringify(result.runMessages)).not.toContain(
519
+ "memory/concepts/",
520
+ );
521
+ expect(retrieveForTurnMock).not.toHaveBeenCalled();
517
522
  });
518
523
  });
519
524
 
@@ -25,6 +25,7 @@ import type { SqliteRetryOptions } from "../../../util/sqlite-retry.js";
25
25
  import * as hostSqliteRetry from "../../../util/sqlite-retry.js";
26
26
  import * as hostStripCommentLines from "../../../util/strip-comment-lines.js";
27
27
  import * as hostTruncate from "../../../util/truncate.js";
28
+ import * as hostWorkerCompute from "../../../util/worker-compute.js";
28
29
  import * as hostWorkerMemory from "../../../util/worker-memory.js";
29
30
 
30
31
  export { BackendUnavailableError } from "../../../util/errors.js";
@@ -78,3 +79,7 @@ export function truncate(str: string, maxLen: number, suffix?: string): string {
78
79
  export function workerMemoryEnv(): Record<string, string | undefined> {
79
80
  return hostWorkerMemory.workerMemoryEnv();
80
81
  }
82
+
83
+ export function workerComputeEnv(): Record<string, string> {
84
+ return hostWorkerCompute.workerComputeEnv();
85
+ }
@@ -23,7 +23,7 @@
23
23
  // accounting; once newer real messages arrive it is copied into the next
24
24
  // run's fork as inert prefix context.
25
25
 
26
- import { and, eq, like } from "drizzle-orm";
26
+ import { and, eq, gt, like, or } from "drizzle-orm";
27
27
 
28
28
  import {
29
29
  countMessagesAfter,
@@ -85,6 +85,110 @@ export function countRetrospectiveMessagesAfter(
85
85
  return Math.max(0, total - cardCount);
86
86
  }
87
87
 
88
+ /**
89
+ * True when any block in a parsed content array is something other than a
90
+ * tool result (`tool_result` / `web_search_tool_result`). Both are transport
91
+ * blocks for tool output riding on user-role rows, not user-authored
92
+ * activity; an empty array carries nothing.
93
+ */
94
+ function blocksCarryNonToolResult(
95
+ blocks: ReadonlyArray<{ type?: unknown }>,
96
+ ): boolean {
97
+ return blocks.some(
98
+ (block) =>
99
+ block.type !== "tool_result" && block.type !== "web_search_tool_result",
100
+ );
101
+ }
102
+
103
+ /**
104
+ * True when the slice contains at least one user-authored message: a
105
+ * user-role row whose resolved content carries a non-tool_result block. Tool
106
+ * results ride on user-role rows, so a bare role check would count any
107
+ * tool-using assistant stretch as user activity.
108
+ */
109
+ export function messagesHaveUserActivity(
110
+ rows: ReadonlyArray<Pick<MessageRow, "role" | "content">>,
111
+ ): boolean {
112
+ return rows.some(
113
+ (row) => row.role === "user" && blocksCarryNonToolResult(row.content),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Existence probe for user-authored activity strictly after the
119
+ * `(createdAt, id)` cursor: at least one user-role row whose content carries
120
+ * a non-tool_result block. Powers the `memory.retrospective.requireUserActivity`
121
+ * enqueue gate, so only user rows are loaded. Cursor semantics mirror
122
+ * `countMessagesAfter`: a null/`""` reference scans the whole conversation,
123
+ * and a vanished reference means no new work.
124
+ *
125
+ * Operates on the raw `content` column: rows that do not parse to a block
126
+ * array (file-backed `{ ref }` rows, legacy plain strings) count as
127
+ * user-authored, so the gate fails toward running the retrospective.
128
+ */
129
+ export function hasQualifyingUserMessageAfter(
130
+ conversationId: string,
131
+ afterMessageId: string | null,
132
+ ): boolean {
133
+ const cursorId =
134
+ afterMessageId === null || afterMessageId === "" ? null : afterMessageId;
135
+ const db = getDb();
136
+
137
+ let ref: { createdAt: number } | undefined;
138
+ if (cursorId !== null) {
139
+ ref = db
140
+ .select({ createdAt: messages.createdAt })
141
+ .from(messages)
142
+ .where(eq(messages.id, cursorId))
143
+ .get();
144
+ if (!ref) {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ const afterCursor =
150
+ cursorId !== null && ref
151
+ ? [
152
+ or(
153
+ gt(messages.createdAt, ref.createdAt),
154
+ and(
155
+ eq(messages.createdAt, ref.createdAt),
156
+ gt(messages.id, cursorId),
157
+ ),
158
+ ),
159
+ ]
160
+ : [];
161
+ const rows = db
162
+ .select({ content: messages.content })
163
+ .from(messages)
164
+ .where(
165
+ and(
166
+ eq(messages.conversationId, conversationId),
167
+ eq(messages.role, "user"),
168
+ ...afterCursor,
169
+ ),
170
+ )
171
+ .all();
172
+
173
+ return rows.some((row) => rawUserContentCarriesActivity(row.content));
174
+ }
175
+
176
+ /** Raw-column twin of the block check used by {@link messagesHaveUserActivity}. */
177
+ function rawUserContentCarriesActivity(raw: string | null): boolean {
178
+ if (raw === null || raw === "") {
179
+ return false;
180
+ }
181
+ try {
182
+ const parsed: unknown = JSON.parse(raw);
183
+ if (!Array.isArray(parsed)) {
184
+ return true;
185
+ }
186
+ return blocksCarryNonToolResult(parsed as Array<{ type?: unknown }>);
187
+ } catch {
188
+ return true;
189
+ }
190
+ }
191
+
88
192
  /**
89
193
  * Count skill-card rows strictly after the `(createdAt, id)` cursor. A LIKE
90
194
  * prefilter keeps the scan from loading every row's metadata (cards are rare