@vellumai/assistant 0.8.8 → 0.8.9-dev.202606091926.ebb2d62

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 (490) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/docs/activation-funnel-telemetry.md +310 -0
  4. package/examples/plugins/echo/README.md +61 -60
  5. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  6. package/examples/plugins/echo/hooks/stop.ts +16 -0
  7. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  8. package/examples/plugins/echo/package.json +1 -2
  9. package/examples/plugins/echo/src/emit.ts +19 -0
  10. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  11. package/openapi.yaml +273 -121
  12. package/package.json +2 -2
  13. package/scripts/sync-llm-catalog.ts +2 -0
  14. package/src/__tests__/activation-early-marking.test.ts +120 -0
  15. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  16. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  17. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  18. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  19. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  20. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  21. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  22. package/src/__tests__/agent-loop.test.ts +441 -96
  23. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  24. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  25. package/src/__tests__/anthropic-provider.test.ts +23 -8
  26. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  27. package/src/__tests__/app-control-flow.test.ts +1 -1
  28. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  29. package/src/__tests__/app-executors.test.ts +132 -0
  30. package/src/__tests__/approval-cascade.test.ts +6 -5
  31. package/src/__tests__/approval-routes-http.test.ts +4 -1
  32. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  33. package/src/__tests__/build-persisted-content.test.ts +75 -1
  34. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  35. package/src/__tests__/channel-approvals.test.ts +1 -1
  36. package/src/__tests__/compaction-circuit.test.ts +258 -0
  37. package/src/__tests__/compaction-direct.test.ts +146 -0
  38. package/src/__tests__/compaction-events.test.ts +7 -7
  39. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  40. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  41. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  42. package/src/__tests__/context-overflow-reducer.test.ts +265 -125
  43. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  44. package/src/__tests__/conversation-abort-tool-results.test.ts +7 -5
  45. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  46. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  47. package/src/__tests__/conversation-agent-loop-overflow.test.ts +158 -234
  48. package/src/__tests__/conversation-agent-loop.test.ts +342 -623
  49. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  50. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -5
  51. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  52. package/src/__tests__/conversation-error.test.ts +15 -1
  53. package/src/__tests__/conversation-history-web-search.test.ts +6 -1
  54. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  55. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +44 -4
  56. package/src/__tests__/conversation-process-callsite.test.ts +15 -15
  57. package/src/__tests__/conversation-provider-retry-repair.test.ts +66 -65
  58. package/src/__tests__/conversation-queue.test.ts +10 -10
  59. package/src/__tests__/conversation-runtime-assembly.test.ts +1059 -231
  60. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  61. package/src/__tests__/conversation-slash-queue.test.ts +7 -5
  62. package/src/__tests__/conversation-slash-unknown.test.ts +7 -5
  63. package/src/__tests__/conversation-speed-override.test.ts +11 -10
  64. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  65. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  66. package/src/__tests__/conversation-surfaces-app-control.test.ts +48 -1
  67. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  68. package/src/__tests__/conversation-undo.test.ts +2 -2
  69. package/src/__tests__/conversation-workspace-cache-state.test.ts +24 -21
  70. package/src/__tests__/conversation-workspace-injection.test.ts +69 -7
  71. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +15 -12
  72. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  73. package/src/__tests__/corrected-target.test.ts +93 -0
  74. package/src/__tests__/credential-execution-feature-gates.test.ts +5 -4
  75. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  76. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  77. package/src/__tests__/db-acp-history.test.ts +101 -0
  78. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  79. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  80. package/src/__tests__/empty-response-hook.test.ts +1 -1
  81. package/src/__tests__/file-write-tool.test.ts +63 -0
  82. package/src/__tests__/gateway-only-guard.test.ts +17 -2
  83. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  84. package/src/__tests__/guardian-routing-invariants.test.ts +93 -4
  85. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  86. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  87. package/src/__tests__/heartbeat-service.test.ts +1 -0
  88. package/src/__tests__/history-repair-hook.test.ts +5 -1
  89. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  90. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  91. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  92. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  93. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  94. package/src/__tests__/injector-background-turn.test.ts +13 -23
  95. package/src/__tests__/injector-chain.test.ts +268 -44
  96. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  97. package/src/__tests__/injector-document-comments.test.ts +97 -114
  98. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  99. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  100. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  101. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  102. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  103. package/src/__tests__/llm-resolver.test.ts +73 -0
  104. package/src/__tests__/memory-retrieval-hook.test.ts +156 -38
  105. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  106. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  107. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  108. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  109. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  110. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  111. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  112. package/src/__tests__/plugin-bootstrap.test.ts +91 -81
  113. package/src/__tests__/plugin-registry.test.ts +3 -49
  114. package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
  115. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  116. package/src/__tests__/plugin-types.test.ts +0 -70
  117. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  118. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  119. package/src/__tests__/published-app-updater.test.ts +138 -0
  120. package/src/__tests__/reaction-persistence.test.ts +1 -1
  121. package/src/__tests__/retry-thinking-adaptive-only.test.ts +245 -0
  122. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  123. package/src/__tests__/server-history-render.test.ts +39 -0
  124. package/src/__tests__/skill-feature-flags-integration.test.ts +31 -0
  125. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  126. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  127. package/src/__tests__/subagent-detail.test.ts +25 -7
  128. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  129. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  130. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  131. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  132. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  133. package/src/__tests__/title-generate-hook.test.ts +5 -1
  134. package/src/__tests__/tool-error-hook.test.ts +1 -1
  135. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  136. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  137. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  138. package/src/__tests__/web-fetch.test.ts +45 -0
  139. package/src/acp/__tests__/agent-process.test.ts +161 -0
  140. package/src/acp/__tests__/client-handler.test.ts +40 -0
  141. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  142. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  143. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  144. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  145. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  146. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  147. package/src/acp/agent-process.ts +61 -1
  148. package/src/acp/auto-install.test.ts +280 -0
  149. package/src/acp/auto-install.ts +232 -0
  150. package/src/acp/client-handler.ts +31 -0
  151. package/src/acp/prepare-agent-env.ts +80 -27
  152. package/src/acp/resolve-agent.test.ts +188 -28
  153. package/src/acp/resolve-agent.ts +119 -42
  154. package/src/acp/resume-hint.ts +23 -0
  155. package/src/acp/session-manager.ts +507 -73
  156. package/src/agent/compaction-circuit.ts +60 -102
  157. package/src/agent/loop.ts +403 -251
  158. package/src/api/events/tool-result.ts +9 -0
  159. package/src/api/events/tool-use-start.ts +7 -0
  160. package/src/api/responses/conversation-message.ts +72 -13
  161. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  162. package/src/approvals/guardian-request-resolvers.ts +27 -1
  163. package/src/background-wake/next-wake.ts +1 -0
  164. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  165. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  166. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  167. package/src/cli/commands/memory-v3.ts +57 -199
  168. package/src/cli/commands/plugins.ts +43 -37
  169. package/src/cli/lib/__tests__/install-from-github.test.ts +790 -111
  170. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  171. package/src/cli/lib/__tests__/plugin-details.test.ts +381 -0
  172. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +270 -0
  173. package/src/cli/lib/__tests__/search-plugins.test.ts +233 -32
  174. package/src/cli/lib/install-from-github.ts +797 -60
  175. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  176. package/src/cli/lib/plugin-details.ts +416 -0
  177. package/src/cli/lib/plugin-marketplace.ts +215 -0
  178. package/src/cli/lib/search-plugins.ts +201 -29
  179. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  180. package/src/config/acp-defaults.test.ts +10 -0
  181. package/src/config/acp-defaults.ts +9 -3
  182. package/src/config/acp-schema.ts +1 -7
  183. package/src/config/bundled-skills/acp/SKILL.md +80 -41
  184. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  185. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  186. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  187. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  188. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  189. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  190. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  191. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  192. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  193. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  194. package/src/config/bundled-tool-registry.ts +4 -0
  195. package/src/config/call-site-defaults.ts +0 -3
  196. package/src/config/feature-flag-registry.json +10 -56
  197. package/src/config/llm-resolver.ts +39 -7
  198. package/src/config/loader.ts +5 -22
  199. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  200. package/src/config/schemas/call-site-catalog.ts +0 -21
  201. package/src/config/schemas/heartbeat.ts +9 -0
  202. package/src/config/schemas/llm.ts +17 -3
  203. package/src/config/schemas/memory-v3.ts +58 -8
  204. package/src/config/seed-inference-profiles.ts +18 -0
  205. package/src/context/compactor.ts +22 -4
  206. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  207. package/src/context/strip-injections.ts +8 -2
  208. package/src/daemon/conversation-agent-loop-handlers.ts +120 -42
  209. package/src/daemon/conversation-agent-loop.ts +308 -1297
  210. package/src/daemon/conversation-error.ts +31 -4
  211. package/src/daemon/conversation-history.ts +1 -1
  212. package/src/daemon/conversation-lifecycle.ts +11 -255
  213. package/src/daemon/conversation-media-retry.ts +19 -6
  214. package/src/daemon/conversation-messaging.ts +17 -0
  215. package/src/daemon/conversation-process.ts +22 -141
  216. package/src/daemon/conversation-queue-manager.ts +8 -0
  217. package/src/daemon/conversation-registry.ts +159 -0
  218. package/src/daemon/conversation-runtime-assembly.ts +361 -393
  219. package/src/daemon/conversation-store.ts +9 -90
  220. package/src/daemon/conversation-surfaces.ts +165 -11
  221. package/src/daemon/conversation-workspace.ts +17 -0
  222. package/src/daemon/conversation.ts +444 -62
  223. package/src/daemon/disk-pressure-policy.ts +0 -1
  224. package/src/daemon/external-plugins-bootstrap.ts +36 -46
  225. package/src/daemon/handlers/config-channels.ts +11 -2
  226. package/src/daemon/handlers/conversations.ts +3 -1
  227. package/src/daemon/handlers/shared.ts +9 -1
  228. package/src/daemon/handlers/skills.ts +4 -1
  229. package/src/daemon/host-app-control-proxy.ts +72 -57
  230. package/src/daemon/host-proxy-preactivation.ts +1 -3
  231. package/src/daemon/lifecycle.ts +21 -7
  232. package/src/daemon/persist-unsendable-image.ts +62 -25
  233. package/src/daemon/process-message.ts +1 -1
  234. package/src/daemon/server.ts +2 -0
  235. package/src/daemon/tool-side-effects.ts +15 -0
  236. package/src/daemon/wake-conversation-ops.ts +269 -0
  237. package/src/embedded/plugin-api.ts +2 -2
  238. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  239. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  240. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  241. package/src/heartbeat/heartbeat-service.ts +26 -0
  242. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  243. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  244. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  245. package/src/ipc/skill-routes/memory.ts +4 -2
  246. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  247. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  248. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  249. package/src/memory/activation-session-store.ts +43 -0
  250. package/src/memory/conversation-crud.ts +29 -19
  251. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  252. package/src/memory/db-init.ts +6 -0
  253. package/src/memory/embedding-backend.ts +5 -22
  254. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  255. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  256. package/src/memory/jobs-worker.ts +25 -1
  257. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  258. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  259. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  260. package/src/memory/migrations/index.ts +3 -0
  261. package/src/memory/onboarding-events-store.ts +66 -18
  262. package/src/memory/schema/acp.ts +4 -0
  263. package/src/memory/schema/infrastructure.ts +13 -0
  264. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  265. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  266. package/src/memory/v2/consolidation-job.ts +2 -63
  267. package/src/memory/v2/types.ts +5 -0
  268. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  269. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  270. package/src/notifications/access-request-copy.ts +298 -0
  271. package/src/notifications/adapters/slack.ts +3 -3
  272. package/src/notifications/adapters/telegram.ts +9 -2
  273. package/src/notifications/copy-composer.ts +49 -267
  274. package/src/notifications/decision-engine.ts +16 -35
  275. package/src/notifications/home-feed-side-effect.ts +1 -6
  276. package/src/plugin-api/constants.ts +4 -0
  277. package/src/plugin-api/index.ts +40 -11
  278. package/src/plugin-api/types.ts +108 -0
  279. package/src/plugins/defaults/compaction/compact.ts +106 -0
  280. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +250 -42
  281. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  282. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  283. package/src/plugins/defaults/compaction/package.json +1 -2
  284. package/src/{context → plugins/defaults/compaction}/window-manager.ts +95 -25
  285. package/src/plugins/defaults/empty-response/package.json +0 -1
  286. package/src/plugins/defaults/history-repair/package.json +0 -1
  287. package/src/plugins/defaults/index.ts +156 -26
  288. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  289. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +285 -0
  290. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  291. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  292. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  293. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  294. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +71 -0
  295. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  296. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  297. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +147 -134
  298. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +456 -0
  299. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +662 -0
  300. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  301. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +91 -0
  302. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  303. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  304. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  305. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +74 -19
  306. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  307. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +592 -0
  308. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -24
  309. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +91 -0
  310. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  311. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  312. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  313. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  314. package/src/plugins/defaults/memory-v3-shadow/injector.ts +76 -0
  315. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +555 -0
  316. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +266 -0
  317. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  318. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +71 -0
  319. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  320. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +16 -8
  321. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  322. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  323. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  324. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +156 -0
  325. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +382 -0
  326. package/src/plugins/defaults/memory-v3-shadow/types.ts +92 -0
  327. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +4 -1
  328. package/src/plugins/defaults/title-generate/package.json +0 -1
  329. package/src/plugins/defaults/tool-error/package.json +0 -1
  330. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  331. package/src/plugins/external-api.ts +12 -2
  332. package/src/plugins/pipeline.ts +6 -293
  333. package/src/plugins/registry.ts +9 -37
  334. package/src/plugins/types.ts +76 -381
  335. package/src/plugins/user-loader.ts +30 -127
  336. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  337. package/src/prompts/system-prompt.ts +61 -10
  338. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +71 -4
  339. package/src/providers/anthropic/client.ts +9 -10
  340. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  341. package/src/providers/inference/logit-bias.ts +55 -0
  342. package/src/providers/model-catalog.ts +57 -0
  343. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  344. package/src/providers/openai/chat-completions-provider.ts +44 -1
  345. package/src/providers/retry.ts +57 -7
  346. package/src/providers/types.ts +6 -0
  347. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  348. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  349. package/src/runtime/agent-wake.ts +108 -209
  350. package/src/runtime/assistant-event-hub.ts +1 -1
  351. package/src/runtime/channel-approvals.ts +1 -1
  352. package/src/runtime/interactive-ui.ts +1 -1
  353. package/src/runtime/routes/__tests__/acp-routes.test.ts +466 -55
  354. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  355. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  356. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  357. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  358. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  359. package/src/runtime/routes/acp-routes.test.ts +185 -32
  360. package/src/runtime/routes/acp-routes.ts +328 -30
  361. package/src/runtime/routes/app-management-routes.ts +3 -0
  362. package/src/runtime/routes/approval-routes.ts +1 -1
  363. package/src/runtime/routes/browser-routes.ts +1 -1
  364. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  365. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  366. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  367. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  368. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  369. package/src/runtime/routes/conversation-routes.ts +30 -2
  370. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  371. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  372. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  373. package/src/runtime/routes/host-cu-routes.ts +1 -1
  374. package/src/runtime/routes/identity-routes.ts +18 -3
  375. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  376. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  377. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  378. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  379. package/src/runtime/routes/memory-v3-routes.ts +65 -305
  380. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  381. package/src/runtime/routes/playground/helpers.ts +1 -1
  382. package/src/runtime/routes/plugins-routes.ts +337 -35
  383. package/src/runtime/routes/stt-routes.ts +45 -12
  384. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  385. package/src/runtime/routes/work-items-routes.ts +2 -4
  386. package/src/runtime/routes/workspace-routes.ts +50 -15
  387. package/src/runtime/services/conversation-serializer.ts +1 -1
  388. package/src/runtime/verification-outbound-actions.ts +147 -2
  389. package/src/runtime/verification-templates.ts +29 -3
  390. package/src/services/published-app-updater.ts +30 -8
  391. package/src/signals/cancel.ts +2 -4
  392. package/src/subagent/manager.ts +21 -5
  393. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  394. package/src/telemetry/activation-funnel.ts +167 -0
  395. package/src/telemetry/types.ts +13 -0
  396. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  397. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  398. package/src/tools/acp/context.ts +20 -0
  399. package/src/tools/acp/list-agents.test.ts +9 -19
  400. package/src/tools/acp/list-agents.ts +3 -15
  401. package/src/tools/acp/spawn.test.ts +173 -202
  402. package/src/tools/acp/spawn.ts +37 -172
  403. package/src/tools/acp/steer.test.ts +105 -8
  404. package/src/tools/acp/steer.ts +48 -17
  405. package/src/tools/apps/executors.ts +166 -50
  406. package/src/tools/browser/browser-execution.ts +12 -2
  407. package/src/tools/filesystem/write.ts +34 -0
  408. package/src/tools/network/web-fetch.ts +65 -24
  409. package/src/tools/skills/load.ts +1 -1
  410. package/src/tools/subagent/spawn.ts +2 -4
  411. package/src/tools/ui-surface/definitions.ts +32 -5
  412. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  413. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  414. package/docs/plugins.md +0 -832
  415. package/examples/plugins/echo/register.ts +0 -143
  416. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  417. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  418. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  419. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  420. package/src/__tests__/pipeline-runner.test.ts +0 -554
  421. package/src/daemon/wake-target-adapter.ts +0 -253
  422. package/src/memory/v3/__tests__/assign.test.ts +0 -242
  423. package/src/memory/v3/__tests__/capabilities.test.ts +0 -118
  424. package/src/memory/v3/__tests__/core.test.ts +0 -39
  425. package/src/memory/v3/__tests__/health.test.ts +0 -219
  426. package/src/memory/v3/__tests__/maintain-job.test.ts +0 -288
  427. package/src/memory/v3/__tests__/needle.test.ts +0 -107
  428. package/src/memory/v3/__tests__/orchestrate.test.ts +0 -436
  429. package/src/memory/v3/__tests__/provider-blocks.test.ts +0 -13
  430. package/src/memory/v3/__tests__/reconcile.test.ts +0 -274
  431. package/src/memory/v3/__tests__/render-injection.test.ts +0 -61
  432. package/src/memory/v3/__tests__/router.test.ts +0 -332
  433. package/src/memory/v3/__tests__/selector.test.ts +0 -470
  434. package/src/memory/v3/__tests__/shadow-plugin.test.ts +0 -432
  435. package/src/memory/v3/__tests__/snapshot.test.ts +0 -168
  436. package/src/memory/v3/__tests__/tree.test.ts +0 -192
  437. package/src/memory/v3/assign.ts +0 -268
  438. package/src/memory/v3/capabilities.ts +0 -124
  439. package/src/memory/v3/core.ts +0 -26
  440. package/src/memory/v3/health.ts +0 -0
  441. package/src/memory/v3/maintain-job.ts +0 -314
  442. package/src/memory/v3/needle.ts +0 -115
  443. package/src/memory/v3/orchestrate.ts +0 -126
  444. package/src/memory/v3/page-content.ts +0 -34
  445. package/src/memory/v3/provider-blocks.ts +0 -26
  446. package/src/memory/v3/reconcile.ts +0 -523
  447. package/src/memory/v3/router.ts +0 -190
  448. package/src/memory/v3/selection-log-store.ts +0 -84
  449. package/src/memory/v3/selector.ts +0 -226
  450. package/src/memory/v3/shadow-plugin.ts +0 -411
  451. package/src/memory/v3/snapshot.ts +0 -209
  452. package/src/memory/v3/tree.ts +0 -174
  453. package/src/memory/v3/types.ts +0 -59
  454. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  455. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  456. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  457. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  458. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  459. package/src/plugins/defaults/compaction/register.ts +0 -35
  460. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  461. package/src/plugins/defaults/empty-response/register.ts +0 -23
  462. package/src/plugins/defaults/history-repair/register.ts +0 -24
  463. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +0 -216
  464. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  465. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  466. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  467. package/src/plugins/defaults/title-generate/register.ts +0 -35
  468. package/src/plugins/defaults/tool-error/register.ts +0 -23
  469. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  470. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  471. package/src/proactive-artifact/decision.test.ts +0 -226
  472. package/src/proactive-artifact/decision.ts +0 -165
  473. package/src/proactive-artifact/index.ts +0 -7
  474. package/src/proactive-artifact/job.test.ts +0 -962
  475. package/src/proactive-artifact/job.ts +0 -372
  476. package/src/proactive-artifact/message-copy.ts +0 -58
  477. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  478. package/src/proactive-artifact/trigger-state.ts +0 -123
  479. package/src/util/map-limit.ts +0 -27
  480. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  481. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  482. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  483. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  484. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  485. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  486. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  487. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  488. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  489. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  490. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
@@ -2,17 +2,30 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
+ import { eq } from "drizzle-orm";
6
+
5
7
  // This test exercises v1 PKB injection. `config.memory.v2.enabled` (default
6
8
  // `true`) makes the PKB injector go silent — force it off here so the v1
7
9
  // injection chain assertions stay meaningful.
8
10
  const realLoaderForAssemblyTest = await import("../config/loader.js");
9
11
  const realGetConfigForAssemblyTest = realLoaderForAssemblyTest.getConfig;
12
+ // Per-test overrides for `ui.userTimezone` / `ui.detectedTimezone` so the
13
+ // config self-resolution inside `applyRuntimeInjections` can be exercised.
14
+ let assemblyConfiguredUserTimezone: string | null | undefined;
15
+ let assemblyDetectedTimezone: string | null | undefined;
10
16
  mock.module("../config/loader.js", () => ({
11
17
  ...realLoaderForAssemblyTest,
12
18
  getConfig: () => {
13
19
  const real = realGetConfigForAssemblyTest();
14
20
  return {
15
21
  ...real,
22
+ ui: {
23
+ ...real.ui,
24
+ userTimezone:
25
+ assemblyConfiguredUserTimezone ?? real.ui?.userTimezone ?? null,
26
+ detectedTimezone:
27
+ assemblyDetectedTimezone ?? real.ui?.detectedTimezone ?? null,
28
+ },
16
29
  memory: { ...real.memory, v2: { ...real.memory.v2, enabled: false } },
17
30
  slack: { ...real.slack, botUserId: "U_BOT" },
18
31
  };
@@ -35,17 +48,36 @@ mock.module("../memory/pkb/pkb-search.js", () => ({
35
48
  },
36
49
  }));
37
50
 
51
+ // `applyRuntimeInjections` computes the `<turn_context>` `current_time` live via
52
+ // `formatTurnTimestamp`; pin it so the rendered block matches the deterministic
53
+ // `buildUnifiedTurnContextBlock` expectations below. The rest of the module
54
+ // (timezone canonicalization/resolution) keeps its real behavior.
55
+ const FIXED_TURN_TIMESTAMP = "2026-04-02T12:00:00Z";
56
+ // Conversation id the runtime-injection tests register their live conversation
57
+ // under; passed explicitly now that `applyRuntimeInjections` requires the
58
+ // caller to name the conversation it resolves through.
59
+ const FALLBACK_CONVERSATION_ID = "runtime-assembly-fallback";
60
+ const realDateContextForAssemblyTest =
61
+ await import("../daemon/date-context.js");
62
+ mock.module("../daemon/date-context.js", () => ({
63
+ ...realDateContextForAssemblyTest,
64
+ formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
65
+ }));
66
+
67
+ import { LLMSchema } from "../config/schemas/llm.js";
68
+ import {
69
+ clearConversations,
70
+ setConversation,
71
+ } from "../daemon/conversation-registry.js";
38
72
  import type {
39
73
  ChannelCapabilities,
40
74
  SlackTranscriptInputRow,
41
- UnifiedTurnContextOptions,
42
75
  } from "../daemon/conversation-runtime-assembly.js";
43
76
  import {
44
77
  applyRuntimeInjections,
45
78
  assembleSlackActiveThreadFocusBlock,
46
79
  assembleSlackChronologicalMessages,
47
80
  buildSubagentStatusBlock,
48
- buildUnifiedTurnContextBlock,
49
81
  getSlackCompactionWatermarkForPrefix,
50
82
  injectChannelCapabilityContext,
51
83
  injectChannelCommandContext,
@@ -55,24 +87,42 @@ import {
55
87
  loadSlackChronologicalContext,
56
88
  loadSlackChronologicalMessages,
57
89
  resolveChannelCapabilities,
90
+ resolveTurnInboundActorContext,
91
+ resolveTurnModelProfileLabel,
58
92
  stripChannelCapabilityContext,
59
93
  stripInjectionsForCompaction,
60
94
  stripNowScratchpad,
61
95
  } from "../daemon/conversation-runtime-assembly.js";
96
+ import type { SurfaceData, SurfaceType } from "../daemon/message-protocol.js";
62
97
  import { buildPkbReminder } from "../daemon/pkb-reminder-builder.js";
98
+ import type { TrustContext } from "../daemon/trust-context.js";
63
99
  import type { MessageRow } from "../memory/conversation-crud.js";
100
+ import { getDb } from "../memory/db-connection.js";
101
+ import { initializeDb } from "../memory/db-init.js";
64
102
  import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
65
103
  import { getPkbRoot } from "../memory/pkb/types.js";
104
+ import { conversations, messages } from "../memory/schema.js";
66
105
  import {
67
106
  type SlackMessageMetadata,
68
107
  writeSlackMetadata,
69
108
  } from "../messaging/providers/slack/message-metadata.js";
70
109
  import { parentAlias } from "../messaging/providers/slack/render-transcript.js";
110
+ import postCompact from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
111
+ import {
112
+ buildUnifiedTurnContextBlock,
113
+ type UnifiedTurnContextOptions,
114
+ } from "../plugins/defaults/memory-retrieval/unified-turn-context.js";
71
115
  import type { Message } from "../providers/types.js";
72
116
  import { wrapUntrustedContent } from "../security/untrusted-content.js";
117
+ import { getSubagentManager } from "../subagent/index.js";
73
118
  import type { SubagentState } from "../subagent/types.js";
74
119
  import { getWorkspacePromptPath } from "../util/platform.js";
75
120
 
121
+ // `applyRuntimeInjections` self-resolves the Slack active-thread focus block by
122
+ // reading the live conversation's persisted message rows, so the schema must
123
+ // exist before any Slack-channel assembly test runs.
124
+ initializeDb();
125
+
76
126
  // The pkb-reminder injector derives PKB-active state from the workspace itself
77
127
  // — `readPkbContext()` returning content behind the personal-memory trust gate
78
128
  // — rather than from a threaded flag. Tests that need the reminder to fire seed
@@ -103,6 +153,66 @@ function clearNowScratchpad(): void {
103
153
  rmSync(getWorkspacePromptPath("NOW.md"), { force: true });
104
154
  }
105
155
 
156
+ // The workspace-context injector sources its block off the live `Conversation`
157
+ // looked up by `conversationId`. Register a fake instance carrying both a
158
+ // non-dirty workspace context (non-null content, not dirty) so
159
+ // `resolveWorkspaceTopLevelContext` returns it verbatim without rescanning the
160
+ // filesystem, and an active dynamic-page surface, so `applyRuntimeInjections`
161
+ // resolves the `<workspace>` and `<active_workspace>` blocks from it;
162
+ // `clearConversations()` between tests keeps suites that assert the blocks are
163
+ // absent unaffected.
164
+ function seedActiveSurfaceConversation(
165
+ conversationId: string,
166
+ workspaceText: string,
167
+ surfaceId: string,
168
+ data: SurfaceData,
169
+ channelCapabilities?: ChannelCapabilities,
170
+ commandIntent?: { type: string; payload?: string; languageCode?: string },
171
+ currentTurnTemporalSnapshot?: {
172
+ clientTimezone: string | null;
173
+ },
174
+ ): void {
175
+ setConversation(conversationId, {
176
+ conversationId,
177
+ workingDir: "/sandbox",
178
+ workspaceTopLevelContext: workspaceText,
179
+ workspaceTopLevelDirty: false,
180
+ currentActiveSurfaceId: surfaceId,
181
+ surfaceState: new Map<
182
+ string,
183
+ { surfaceType: SurfaceType; data: SurfaceData }
184
+ >([[surfaceId, { surfaceType: "dynamic_page", data }]]),
185
+ channelCapabilities: channelCapabilities ?? undefined,
186
+ commandIntent,
187
+ currentTurnTemporalSnapshot,
188
+ } as never);
189
+ }
190
+
191
+ function clearWorkspaceContext(): void {
192
+ clearConversations();
193
+ }
194
+
195
+ // The `<channel_capabilities>` branch and the Slack gates source the channel
196
+ // capabilities off the live `Conversation` looked up by `conversationId`.
197
+ // Register a fake fallback instance carrying the given capabilities (and a
198
+ // non-dirty empty workspace + empty surface so the other live-sourced branches
199
+ // stay inert) so `applyRuntimeInjections` resolves them; `clearConversations()`
200
+ // between tests keeps suites asserting absence unaffected.
201
+ function seedChannelCapabilitiesConversation(
202
+ caps: ChannelCapabilities | null,
203
+ transportHints?: string[],
204
+ ): void {
205
+ setConversation("runtime-assembly-fallback", {
206
+ conversationId: "runtime-assembly-fallback",
207
+ workingDir: "/sandbox",
208
+ workspaceTopLevelContext: "",
209
+ workspaceTopLevelDirty: false,
210
+ surfaceState: new Map(),
211
+ channelCapabilities: caps ?? undefined,
212
+ transportHints,
213
+ } as never);
214
+ }
215
+
106
216
  // ---------------------------------------------------------------------------
107
217
  // resolveChannelCapabilities
108
218
  // ---------------------------------------------------------------------------
@@ -575,6 +685,8 @@ describe("stripChannelCapabilityContext", () => {
575
685
  // ---------------------------------------------------------------------------
576
686
 
577
687
  describe("applyRuntimeInjections with channelCapabilities", () => {
688
+ afterEach(clearConversations);
689
+
578
690
  const baseMessages: Message[] = [
579
691
  {
580
692
  role: "user",
@@ -590,8 +702,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
590
702
  supportsVoiceInput: false,
591
703
  };
592
704
 
705
+ seedChannelCapabilitiesConversation(caps);
593
706
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
594
- channelCapabilities: caps,
707
+ conversationId: FALLBACK_CONVERSATION_ID,
595
708
  });
596
709
 
597
710
  expect(result.length).toBe(1);
@@ -603,8 +716,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
603
716
  });
604
717
 
605
718
  test("does not inject when channelCapabilities is null", async () => {
719
+ seedChannelCapabilitiesConversation(null);
606
720
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
607
- channelCapabilities: null,
721
+ conversationId: FALLBACK_CONVERSATION_ID,
608
722
  });
609
723
 
610
724
  expect(result.length).toBe(1);
@@ -612,7 +726,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
612
726
  });
613
727
 
614
728
  test("does not inject when channelCapabilities is omitted", async () => {
615
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
729
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
730
+ conversationId: FALLBACK_CONVERSATION_ID,
731
+ });
616
732
 
617
733
  expect(result.length).toBe(1);
618
734
  expect(result[0].content.length).toBe(1);
@@ -626,8 +742,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
626
742
  supportsVoiceInput: false,
627
743
  };
628
744
 
745
+ seedChannelCapabilitiesConversation(caps);
629
746
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
630
- channelCapabilities: caps,
747
+ conversationId: FALLBACK_CONVERSATION_ID,
631
748
  });
632
749
 
633
750
  expect(result.length).toBe(1);
@@ -755,30 +872,25 @@ describe("applyRuntimeInjections — injection mode", () => {
755
872
  },
756
873
  ];
757
874
 
875
+ const channelCapabilities: ChannelCapabilities = {
876
+ channel: "telegram",
877
+ dashboardCapable: false,
878
+ supportsDynamicUi: false,
879
+ supportsVoiceInput: false,
880
+ };
881
+
758
882
  const fullOptions = {
759
- workspaceTopLevelContext: "<workspace>\nRoot: /sandbox\n</workspace>",
760
- channelCommandContext: { type: "start" } as const,
761
- activeSurface: { surfaceId: "sf_1", html: "<div>test</div>" },
762
- channelCapabilities: {
763
- channel: "telegram",
764
- dashboardCapable: false,
765
- supportsDynamicUi: false,
766
- supportsVoiceInput: false,
767
- } as ChannelCapabilities,
768
- unifiedTurnContext:
769
- "<turn_context>\ncurrent_time: 2026-03-04 (Tuesday) 12:00:00 +00:00 (UTC)\ninterface: telegram\n</turn_context>",
883
+ // Non-interactive turn so the `<non_interactive_context>` branch fires.
770
884
  isNonInteractive: true,
885
+ requestId: "injection-mode-req",
886
+ conversationId: "injection-mode-conv",
887
+ turnIndex: 0,
771
888
  // Guardian trust so the personal-memory gate admits the actor regardless
772
889
  // of the telegram channel capabilities under test, letting the reminder
773
890
  // gate hinge purely on PKB content presence.
774
- turnContext: {
775
- requestId: "injection-mode-req",
776
- conversationId: "injection-mode-conv",
777
- turnIndex: 0,
778
- trust: {
779
- sourceChannel: "vellum" as const,
780
- trustClass: "guardian" as const,
781
- },
891
+ trust: {
892
+ sourceChannel: "vellum" as const,
893
+ trustClass: "guardian" as const,
782
894
  },
783
895
  };
784
896
 
@@ -788,10 +900,22 @@ describe("applyRuntimeInjections — injection mode", () => {
788
900
  beforeEach(() => {
789
901
  seedPkbContent();
790
902
  seedNowScratchpad("Current focus: shipping PR 3");
903
+ seedActiveSurfaceConversation(
904
+ "injection-mode-conv",
905
+ "<workspace>\nRoot: /sandbox\n</workspace>",
906
+ "sf_1",
907
+ { html: "<div>test</div>" },
908
+ channelCapabilities,
909
+ { type: "start" },
910
+ {
911
+ clientTimezone: null,
912
+ },
913
+ );
791
914
  });
792
915
  afterEach(() => {
793
916
  clearPkbContent();
794
917
  clearNowScratchpad();
918
+ clearWorkspaceContext();
795
919
  });
796
920
 
797
921
  test("full mode (default) includes all injections", async () => {
@@ -898,6 +1022,34 @@ describe("applyRuntimeInjections — injection mode", () => {
898
1022
 
899
1023
  expect(texts).toContain("Hello");
900
1024
  });
1025
+
1026
+ test("post-compaction re-injection resolves the live conversation from the conversation id", async () => {
1027
+ // GIVEN the seeded live conversation `injection-mode-conv` whose
1028
+ // conversation-scoped blocks (`<active_workspace>`, `<channel_capabilities>`)
1029
+ // only resolve when `applyRuntimeInjections` finds it by conversation id
1030
+ // AND the agent loop hands the post-compaction hook the turn-identity fields
1031
+ // flat (`requestId`, `conversationId`, `trust`)
1032
+ const { messages: result } = await postCompact({
1033
+ history: baseMessages,
1034
+ requestId: "reinject-req",
1035
+ conversationId: "injection-mode-conv",
1036
+ trust: { sourceChannel: "vellum", trustClass: "guardian" },
1037
+ isNonInteractive: false,
1038
+ mode: "full",
1039
+ modelProfile: null,
1040
+ actorContext: null,
1041
+ });
1042
+ const allText = result[0].content
1043
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1044
+ .map((b) => b.text)
1045
+ .join("\n");
1046
+
1047
+ // THEN the hook forwards `conversationId` onto the flat injection options,
1048
+ // so the re-injection resolves the same live conversation as the turn's
1049
+ // initial assembly rather than the synthetic fallback.
1050
+ expect(allText).toContain("<active_workspace>");
1051
+ expect(allText).toContain("<channel_capabilities>");
1052
+ });
901
1053
  });
902
1054
 
903
1055
  // The now-md default injector emits the `<NOW.md>` block as an
@@ -1051,7 +1203,7 @@ describe("stripInjectionsForCompaction preserves persistent blocks", () => {
1051
1203
  ).toContain("<turn_context>");
1052
1204
  });
1053
1205
 
1054
- test("<workspace> blocks are NOT stripped", () => {
1206
+ test("<workspace> blocks ARE stripped so the injector re-sources them post-compaction", () => {
1055
1207
  const messages: Message[] = [
1056
1208
  {
1057
1209
  role: "user",
@@ -1067,10 +1219,10 @@ describe("stripInjectionsForCompaction preserves persistent blocks", () => {
1067
1219
 
1068
1220
  const result = stripInjectionsForCompaction(messages);
1069
1221
  expect(result.length).toBe(1);
1070
- expect(result[0].content.length).toBe(2);
1071
- expect(
1072
- (result[0].content[0] as { type: "text"; text: string }).text,
1073
- ).toContain("<workspace>");
1222
+ expect(result[0].content.length).toBe(1);
1223
+ expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
1224
+ "Hello",
1225
+ );
1074
1226
  });
1075
1227
 
1076
1228
  test("legacy <workspace_top_level> blocks ARE stripped for backward compat", () => {
@@ -1237,7 +1389,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1237
1389
 
1238
1390
  test("injects NOW.md block when the file has content", async () => {
1239
1391
  seedNowScratchpad("Current focus: fix the bug");
1240
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1392
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1393
+ conversationId: FALLBACK_CONVERSATION_ID,
1394
+ });
1241
1395
 
1242
1396
  expect(result.length).toBe(1);
1243
1397
  expect(result[0].content.length).toBe(2);
@@ -1249,7 +1403,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1249
1403
 
1250
1404
  test("scratchpad appears before user's original text content", async () => {
1251
1405
  seedNowScratchpad("scratchpad notes");
1252
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1406
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1407
+ conversationId: FALLBACK_CONVERSATION_ID,
1408
+ });
1253
1409
 
1254
1410
  // Scratchpad comes first (before user content)
1255
1411
  expect(
@@ -1262,7 +1418,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1262
1418
  });
1263
1419
 
1264
1420
  test("does not inject when the NOW.md file is absent", async () => {
1265
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1421
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1422
+ conversationId: FALLBACK_CONVERSATION_ID,
1423
+ });
1266
1424
 
1267
1425
  expect(result.length).toBe(1);
1268
1426
  expect(result[0].content.length).toBe(1);
@@ -1271,6 +1429,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1271
1429
  test("skipped in minimal mode", async () => {
1272
1430
  seedNowScratchpad("Current focus: fix the bug");
1273
1431
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1432
+ conversationId: FALLBACK_CONVERSATION_ID,
1274
1433
  mode: "minimal",
1275
1434
  });
1276
1435
 
@@ -1283,6 +1442,138 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1283
1442
  });
1284
1443
  });
1285
1444
 
1445
+ // ---------------------------------------------------------------------------
1446
+ // resolveTurnInboundActorContext
1447
+ // ---------------------------------------------------------------------------
1448
+
1449
+ describe("resolveTurnInboundActorContext", () => {
1450
+ /**
1451
+ * No trust context means there is no inbound actor to ground the turn on, so
1452
+ * the actor section is suppressed.
1453
+ */
1454
+ test("returns null when there is no trust context", () => {
1455
+ // GIVEN a turn with no trust context
1456
+ // WHEN the inbound actor context is resolved
1457
+ const actorContext = resolveTurnInboundActorContext(undefined, "asst-1");
1458
+
1459
+ // THEN there is no actor context to inject
1460
+ expect(actorContext).toBeNull();
1461
+ });
1462
+
1463
+ /**
1464
+ * Guardian (owner) turns suppress the actor section — the unified turn context
1465
+ * only renders actor fields for non-guardian actors.
1466
+ */
1467
+ test("returns null on guardian turns", () => {
1468
+ // GIVEN a guardian trust context (no requester chat id, so the direct
1469
+ // projection branch is taken)
1470
+ const trustContext: TrustContext = {
1471
+ sourceChannel: "vellum",
1472
+ trustClass: "guardian",
1473
+ requesterDisplayName: "Alice",
1474
+ };
1475
+
1476
+ // WHEN the inbound actor context is resolved
1477
+ const actorContext = resolveTurnInboundActorContext(trustContext, "asst-1");
1478
+
1479
+ // THEN the actor section is suppressed for the owner
1480
+ expect(actorContext).toBeNull();
1481
+ });
1482
+
1483
+ /**
1484
+ * A non-guardian trust context without the identity fields needed for the
1485
+ * unified actor-trust lookup is projected directly into the actor context.
1486
+ */
1487
+ test("projects a non-guardian trust context directly", () => {
1488
+ // GIVEN a trusted-contact trust context lacking requester chat/user ids
1489
+ const trustContext: TrustContext = {
1490
+ sourceChannel: "telegram",
1491
+ trustClass: "trusted_contact",
1492
+ requesterIdentifier: "@bob_handle",
1493
+ requesterDisplayName: "Bob",
1494
+ requesterSenderDisplayName: "Bobby",
1495
+ };
1496
+
1497
+ // WHEN the inbound actor context is resolved
1498
+ const actorContext = resolveTurnInboundActorContext(trustContext, "asst-1");
1499
+
1500
+ // THEN the trust context is projected into the model-facing actor context
1501
+ expect(actorContext).toEqual({
1502
+ sourceChannel: "telegram",
1503
+ canonicalActorIdentity: null,
1504
+ actorIdentifier: "@bob_handle",
1505
+ actorDisplayName: "Bob",
1506
+ actorSenderDisplayName: "Bobby",
1507
+ actorMemberDisplayName: undefined,
1508
+ trustClass: "trusted_contact",
1509
+ guardianIdentity: undefined,
1510
+ });
1511
+ });
1512
+ });
1513
+
1514
+ // ---------------------------------------------------------------------------
1515
+ // resolveTurnModelProfileLabel
1516
+ // ---------------------------------------------------------------------------
1517
+
1518
+ describe("resolveTurnModelProfileLabel", () => {
1519
+ /**
1520
+ * A null key means the active profile is unchanged since the last notified
1521
+ * one, so there is no `model_profile` line to render this turn.
1522
+ */
1523
+ test("returns null when the profile key is null", () => {
1524
+ // GIVEN a turn whose profile is unchanged since the last notification
1525
+ const llm = LLMSchema.parse({
1526
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1527
+ });
1528
+
1529
+ // WHEN the label is resolved for a null key
1530
+ const label = resolveTurnModelProfileLabel(null, "mainAgent", llm);
1531
+
1532
+ // THEN there is no profile line to inject
1533
+ expect(label).toBeNull();
1534
+ });
1535
+
1536
+ /**
1537
+ * A known profile renders its configured display label paired with the model
1538
+ * the call site resolves to.
1539
+ */
1540
+ test("renders the profile's label with the resolved model", () => {
1541
+ // GIVEN a workspace with a labelled "fast" profile
1542
+ const llm = LLMSchema.parse({
1543
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1544
+ profiles: {
1545
+ fast: { label: "Fast", provider: "anthropic", model: "claude-haiku-4" },
1546
+ },
1547
+ });
1548
+
1549
+ // WHEN the label is resolved for that profile at the main-agent call site
1550
+ const label = resolveTurnModelProfileLabel("fast", "mainAgent", llm);
1551
+
1552
+ // THEN the rendered line pairs the display label with the resolved model
1553
+ expect(label).toBe("Fast (claude-haiku-4)");
1554
+ });
1555
+
1556
+ /**
1557
+ * A profile with no configured label falls back to its raw key so the model
1558
+ * still gets a stable identifier.
1559
+ */
1560
+ test("falls back to the raw key when the profile has no label", () => {
1561
+ // GIVEN a profile that sets a model but no display label
1562
+ const llm = LLMSchema.parse({
1563
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1564
+ profiles: {
1565
+ deep: { provider: "anthropic", model: "claude-opus-4" },
1566
+ },
1567
+ });
1568
+
1569
+ // WHEN the label is resolved for that profile
1570
+ const label = resolveTurnModelProfileLabel("deep", "mainAgent", llm);
1571
+
1572
+ // THEN the raw key stands in for the missing label
1573
+ expect(label).toBe("deep (claude-opus-4)");
1574
+ });
1575
+ });
1576
+
1286
1577
  // ---------------------------------------------------------------------------
1287
1578
  // buildUnifiedTurnContextBlock
1288
1579
  // ---------------------------------------------------------------------------
@@ -1694,6 +1985,8 @@ describe("buildUnifiedTurnContextBlock", () => {
1694
1985
  // ---------------------------------------------------------------------------
1695
1986
 
1696
1987
  describe("applyRuntimeInjections with unifiedTurnContext", () => {
1988
+ afterEach(clearConversations);
1989
+
1697
1990
  const baseMessages: Message[] = [
1698
1991
  {
1699
1992
  role: "user",
@@ -1701,12 +1994,42 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1701
1994
  },
1702
1995
  ];
1703
1996
 
1704
- const sampleBlock =
1705
- "<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
1997
+ const sampleOptions = {
1998
+ interfaceName: "macos",
1999
+ };
2000
+ // The block reflects the options-bag interface and the `current_time` the
2001
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
2002
+ const sampleBlock = buildUnifiedTurnContextBlock({
2003
+ timestamp: FIXED_TURN_TIMESTAMP,
2004
+ ...sampleOptions,
2005
+ });
2006
+
2007
+ // Seed the live fallback conversation with the per-turn temporal snapshot
2008
+ // (its presence gates the `<turn_context>` block) and the turn interface
2009
+ // context (so the injector sources the interface label to match
2010
+ // `sampleOptions.interfaceName`).
2011
+ function seedTemporalSnapshot(): void {
2012
+ setConversation("runtime-assembly-fallback", {
2013
+ conversationId: "runtime-assembly-fallback",
2014
+ workingDir: "/sandbox",
2015
+ workspaceTopLevelContext: "",
2016
+ workspaceTopLevelDirty: false,
2017
+ surfaceState: new Map(),
2018
+ currentTurnTemporalSnapshot: {
2019
+ clientTimezone: null,
2020
+ },
2021
+ currentTurnInterfaceContext: {
2022
+ userMessageInterface: "macos",
2023
+ assistantMessageInterface: "macos",
2024
+ },
2025
+ } as never);
2026
+ }
2027
+
2028
+ test("injects the turn-context block when the inputs are provided", async () => {
2029
+ seedTemporalSnapshot();
1706
2030
 
1707
- test("injects unifiedTurnContext when provided", async () => {
1708
2031
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1709
- unifiedTurnContext: sampleBlock,
2032
+ conversationId: FALLBACK_CONVERSATION_ID,
1710
2033
  });
1711
2034
 
1712
2035
  expect(result).toHaveLength(1);
@@ -1720,25 +2043,21 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1720
2043
  );
1721
2044
  });
1722
2045
 
1723
- test("does not inject when unifiedTurnContext is null", async () => {
2046
+ test("does not inject when the timestamp snapshot is absent", async () => {
1724
2047
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1725
- unifiedTurnContext: null,
2048
+ conversationId: FALLBACK_CONVERSATION_ID,
1726
2049
  });
1727
2050
 
1728
2051
  expect(result).toHaveLength(1);
1729
2052
  expect(result[0].content).toHaveLength(1);
1730
2053
  });
1731
2054
 
1732
- test("does not inject when unifiedTurnContext is omitted", async () => {
1733
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1734
-
1735
- expect(result).toHaveLength(1);
1736
- expect(result[0].content).toHaveLength(1);
1737
- });
1738
-
1739
2055
  test("injected in full mode", async () => {
2056
+ seedTemporalSnapshot();
2057
+
1740
2058
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1741
- unifiedTurnContext: sampleBlock,
2059
+ conversationId: FALLBACK_CONVERSATION_ID,
2060
+ ...sampleOptions,
1742
2061
  mode: "full",
1743
2062
  });
1744
2063
 
@@ -1751,8 +2070,11 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1751
2070
  });
1752
2071
 
1753
2072
  test("injected in minimal mode (no mode guard)", async () => {
2073
+ seedTemporalSnapshot();
2074
+
1754
2075
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1755
- unifiedTurnContext: sampleBlock,
2076
+ conversationId: FALLBACK_CONVERSATION_ID,
2077
+ ...sampleOptions,
1756
2078
  mode: "minimal",
1757
2079
  });
1758
2080
 
@@ -1765,11 +2087,168 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1765
2087
  });
1766
2088
  });
1767
2089
 
2090
+ // ---------------------------------------------------------------------------
2091
+ // applyRuntimeInjections self-resolves the config timezones
2092
+ // ---------------------------------------------------------------------------
2093
+
2094
+ describe("applyRuntimeInjections timezone resolution", () => {
2095
+ afterEach(() => {
2096
+ assemblyConfiguredUserTimezone = undefined;
2097
+ assemblyDetectedTimezone = undefined;
2098
+ clearConversations();
2099
+ });
2100
+
2101
+ const baseMessages: Message[] = [
2102
+ { role: "user", content: [{ type: "text", text: "Hello there" }] },
2103
+ ];
2104
+
2105
+ // Seed the live fallback conversation with the per-turn temporal snapshot the
2106
+ // loop freezes after memory retrieval. `applyRuntimeInjections` sources the
2107
+ // client timezone and the long-absence gap from it, so the turn-context block
2108
+ // only emits when this snapshot is present.
2109
+ function seedTemporalSnapshot(
2110
+ clientTimezone: string | null,
2111
+ timeSinceLastMessage: string | null = null,
2112
+ ): void {
2113
+ setConversation("runtime-assembly-fallback", {
2114
+ conversationId: "runtime-assembly-fallback",
2115
+ workingDir: "/sandbox",
2116
+ workspaceTopLevelContext: "",
2117
+ workspaceTopLevelDirty: false,
2118
+ surfaceState: new Map(),
2119
+ currentTurnTemporalSnapshot: {
2120
+ clientTimezone,
2121
+ timeSinceLastMessage,
2122
+ },
2123
+ } as never);
2124
+ }
2125
+
2126
+ function injectedText(result: { messages: Message[] }): string {
2127
+ return result.messages[0].content
2128
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
2129
+ .map((b) => b.text)
2130
+ .join("\n");
2131
+ }
2132
+
2133
+ test("sources the configured and detected timezones from config, not the options bag", async () => {
2134
+ /**
2135
+ * Verifies the re-homed configured user timezone and detected-timezone
2136
+ * fallback are read from `config.ui` inside `applyRuntimeInjections`
2137
+ * rather than threaded through the options bag. The detected timezone is
2138
+ * the device-timezone fallback used when the client reports none.
2139
+ */
2140
+ // GIVEN the guardian has configured a user timezone in config
2141
+ assemblyConfiguredUserTimezone = "America/New_York";
2142
+
2143
+ // AND a different server-detected timezone is configured
2144
+ assemblyDetectedTimezone = "America/Los_Angeles";
2145
+
2146
+ // AND the turn's frozen snapshot carries no client timezone (so the
2147
+ // detected timezone is the device-timezone fallback)
2148
+ seedTemporalSnapshot(null);
2149
+
2150
+ // WHEN a turn is assembled
2151
+ const result = await applyRuntimeInjections(baseMessages, {
2152
+ conversationId: FALLBACK_CONVERSATION_ID,
2153
+ });
2154
+
2155
+ // THEN the injector surfaces the mismatch using the config-sourced values
2156
+ const injected = injectedText(result);
2157
+ expect(injected).toContain("configured_user_timezone: America/New_York");
2158
+ expect(injected).toContain("client_device_timezone: America/Los_Angeles");
2159
+ });
2160
+
2161
+ test("sources clientTimezone from the conversation's frozen turn snapshot", async () => {
2162
+ /**
2163
+ * Verifies the per-turn client timezone is taken from the conversation's
2164
+ * frozen `currentTurnTemporalSnapshot` and takes precedence over the
2165
+ * config-sourced detected-timezone fallback.
2166
+ */
2167
+ // GIVEN the configured user timezone and a detected fallback in config
2168
+ assemblyConfiguredUserTimezone = "America/New_York";
2169
+ assemblyDetectedTimezone = "America/Denver";
2170
+
2171
+ // AND the turn's frozen snapshot carries a client timezone
2172
+ seedTemporalSnapshot("America/Los_Angeles");
2173
+
2174
+ // WHEN a turn is assembled
2175
+ const result = await applyRuntimeInjections(baseMessages, {
2176
+ conversationId: FALLBACK_CONVERSATION_ID,
2177
+ });
2178
+
2179
+ // THEN the injector surfaces the mismatch using the snapshot's client value
2180
+ const injected = injectedText(result);
2181
+ expect(injected).toContain("configured_user_timezone: America/New_York");
2182
+ expect(injected).toContain("client_device_timezone: America/Los_Angeles");
2183
+ });
2184
+
2185
+ test("omits the mismatch lines when config and client timezone agree", async () => {
2186
+ /**
2187
+ * Verifies no mismatch affordance is emitted when the config-sourced
2188
+ * configured user timezone matches the snapshot's client timezone.
2189
+ */
2190
+ // GIVEN the configured user timezone matches the client timezone
2191
+ assemblyConfiguredUserTimezone = "America/New_York";
2192
+
2193
+ // AND the turn's frozen snapshot carries the matching client timezone
2194
+ seedTemporalSnapshot("America/New_York");
2195
+
2196
+ // WHEN a turn is assembled
2197
+ const result = await applyRuntimeInjections(baseMessages, {
2198
+ conversationId: FALLBACK_CONVERSATION_ID,
2199
+ });
2200
+
2201
+ // THEN no timezone-mismatch lines are injected
2202
+ const injected = injectedText(result);
2203
+ expect(injected).not.toContain("configured_user_timezone:");
2204
+ expect(injected).not.toContain("client_device_timezone:");
2205
+ });
2206
+
2207
+ test("sources time_since_last_message from the conversation's frozen turn snapshot", async () => {
2208
+ /**
2209
+ * Verifies the long-absence gap is taken from the conversation's frozen
2210
+ * `currentTurnTemporalSnapshot` rather than threaded through the options
2211
+ * bag, so every post-compaction re-injection reuses the turn-start value.
2212
+ */
2213
+ // GIVEN the turn's frozen snapshot carries a long-absence gap
2214
+ seedTemporalSnapshot(null, "2d ago");
2215
+
2216
+ // WHEN a turn is assembled without the gap in the options bag
2217
+ const result = await applyRuntimeInjections(baseMessages, {
2218
+ conversationId: FALLBACK_CONVERSATION_ID,
2219
+ });
2220
+
2221
+ // THEN the injector surfaces the gap from the snapshot
2222
+ const injected = injectedText(result);
2223
+ expect(injected).toContain("time_since_last_message: 2d ago");
2224
+ });
2225
+
2226
+ test("omits time_since_last_message when the snapshot carries no gap", async () => {
2227
+ /**
2228
+ * Verifies no `time_since_last_message` line is emitted when the frozen
2229
+ * snapshot's gap is null (the normal back-and-forth case).
2230
+ */
2231
+ // GIVEN the turn's frozen snapshot carries no long-absence gap
2232
+ seedTemporalSnapshot(null, null);
2233
+
2234
+ // WHEN a turn is assembled
2235
+ const result = await applyRuntimeInjections(baseMessages, {
2236
+ conversationId: FALLBACK_CONVERSATION_ID,
2237
+ });
2238
+
2239
+ // THEN no gap line is injected
2240
+ const injected = injectedText(result);
2241
+ expect(injected).not.toContain("time_since_last_message");
2242
+ });
2243
+ });
2244
+
1768
2245
  // ---------------------------------------------------------------------------
1769
2246
  // applyRuntimeInjections blocks.unifiedTurnContext
1770
2247
  // ---------------------------------------------------------------------------
1771
2248
 
1772
2249
  describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
2250
+ afterEach(clearConversations);
2251
+
1773
2252
  const userTailMessages: Message[] = [
1774
2253
  {
1775
2254
  role: "user",
@@ -1777,18 +2256,50 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
1777
2256
  },
1778
2257
  ];
1779
2258
 
1780
- const sampleBlock =
1781
- "<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
2259
+ const sampleOptions = {
2260
+ interfaceName: "macos",
2261
+ };
2262
+ // The block reflects the options-bag interface and the `current_time` the
2263
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
2264
+ const sampleBlock = buildUnifiedTurnContextBlock({
2265
+ timestamp: FIXED_TURN_TIMESTAMP,
2266
+ ...sampleOptions,
2267
+ });
2268
+
2269
+ // Seed the live fallback conversation with the per-turn temporal snapshot
2270
+ // (its presence gates the `<turn_context>` block) and the turn interface
2271
+ // context (so the injector sources the interface label to match
2272
+ // `sampleOptions.interfaceName`).
2273
+ function seedTemporalSnapshot(): void {
2274
+ setConversation("runtime-assembly-fallback", {
2275
+ conversationId: "runtime-assembly-fallback",
2276
+ workingDir: "/sandbox",
2277
+ workspaceTopLevelContext: "",
2278
+ workspaceTopLevelDirty: false,
2279
+ surfaceState: new Map(),
2280
+ currentTurnTemporalSnapshot: {
2281
+ clientTimezone: null,
2282
+ },
2283
+ currentTurnInterfaceContext: {
2284
+ userMessageInterface: "macos",
2285
+ assistantMessageInterface: "macos",
2286
+ },
2287
+ } as never);
2288
+ }
1782
2289
 
1783
2290
  test("captures unifiedTurnContext when tail is a user message", async () => {
2291
+ seedTemporalSnapshot();
2292
+
1784
2293
  const result = await applyRuntimeInjections(userTailMessages, {
1785
- unifiedTurnContext: sampleBlock,
2294
+ conversationId: FALLBACK_CONVERSATION_ID,
1786
2295
  });
1787
2296
 
1788
2297
  expect(result.blocks.unifiedTurnContext).toBe(sampleBlock);
1789
2298
  });
1790
2299
 
1791
2300
  test("does not capture when tail is not a user message", async () => {
2301
+ seedTemporalSnapshot();
2302
+
1792
2303
  const assistantTailMessages: Message[] = [
1793
2304
  {
1794
2305
  role: "user",
@@ -1801,14 +2312,16 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
1801
2312
  ];
1802
2313
 
1803
2314
  const result = await applyRuntimeInjections(assistantTailMessages, {
1804
- unifiedTurnContext: sampleBlock,
2315
+ conversationId: FALLBACK_CONVERSATION_ID,
1805
2316
  });
1806
2317
 
1807
2318
  expect(result.blocks.unifiedTurnContext).toBeUndefined();
1808
2319
  });
1809
2320
 
1810
2321
  test("does not capture when unifiedTurnContext option is absent", async () => {
1811
- const result = await applyRuntimeInjections(userTailMessages, {});
2322
+ const result = await applyRuntimeInjections(userTailMessages, {
2323
+ conversationId: FALLBACK_CONVERSATION_ID,
2324
+ });
1812
2325
 
1813
2326
  expect(result.blocks.unifiedTurnContext).toBeUndefined();
1814
2327
  });
@@ -1844,6 +2357,35 @@ function makeSubagentState(
1844
2357
  };
1845
2358
  }
1846
2359
 
2360
+ // `applyRuntimeInjections` self-resolves the `<active_subagents>` block from the
2361
+ // global subagent manager keyed by the conversation, so tests seed children
2362
+ // directly into the manager's private maps (mirroring the production source)
2363
+ // rather than threading a pre-built block through options.
2364
+ interface SubagentManagerTestInternals {
2365
+ subagents: Map<string, { state: SubagentState }>;
2366
+ parentToChildren: Map<string, Set<string>>;
2367
+ }
2368
+
2369
+ function seedSubagentChild(
2370
+ parentConversationId: string,
2371
+ state: SubagentState,
2372
+ ): void {
2373
+ const internals =
2374
+ getSubagentManager() as unknown as SubagentManagerTestInternals;
2375
+ internals.subagents.set(state.config.id, { state });
2376
+ const ids =
2377
+ internals.parentToChildren.get(parentConversationId) ?? new Set<string>();
2378
+ ids.add(state.config.id);
2379
+ internals.parentToChildren.set(parentConversationId, ids);
2380
+ }
2381
+
2382
+ function clearSeededSubagents(): void {
2383
+ const internals =
2384
+ getSubagentManager() as unknown as SubagentManagerTestInternals;
2385
+ internals.subagents.clear();
2386
+ internals.parentToChildren.clear();
2387
+ }
2388
+
1847
2389
  describe("buildSubagentStatusBlock", () => {
1848
2390
  test("returns null for empty children array", () => {
1849
2391
  expect(buildSubagentStatusBlock([])).toBeNull();
@@ -1921,25 +2463,78 @@ describe("applyRuntimeInjections — subagent status", () => {
1921
2463
  content: [{ type: "text", text: "user message" }],
1922
2464
  };
1923
2465
 
1924
- test("includes subagent status in full mode", async () => {
2466
+ afterEach(() => {
2467
+ clearSeededSubagents();
2468
+ clearConversations();
2469
+ });
2470
+
2471
+ test("includes self-resolved subagent status in full mode", async () => {
2472
+ // GIVEN the (fallback) parent conversation has one running child subagent
2473
+ seedSubagentChild(
2474
+ "runtime-assembly-fallback",
2475
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2476
+ );
2477
+
2478
+ // WHEN runtime injections run in full mode
1925
2479
  const { messages: result } = await applyRuntimeInjections([userMsg], {
1926
- subagentStatusBlock:
1927
- "<active_subagents>\n- [running] test\n</active_subagents>",
2480
+ conversationId: FALLBACK_CONVERSATION_ID,
1928
2481
  mode: "full",
1929
2482
  });
2483
+
2484
+ // THEN the tail user message carries the `<active_subagents>` block the
2485
+ // injector self-resolved from the subagent manager
1930
2486
  const tail = result[result.length - 1];
1931
2487
  const texts = tail.content
1932
2488
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
1933
2489
  .map((b) => b.text);
1934
2490
  expect(texts.some((t) => t.includes("<active_subagents>"))).toBe(true);
2491
+ expect(texts.some((t) => t.includes('"worker"'))).toBe(true);
1935
2492
  });
1936
2493
 
1937
2494
  test("skips subagent status in minimal mode", async () => {
2495
+ // GIVEN the (fallback) parent conversation has one running child subagent
2496
+ seedSubagentChild(
2497
+ "runtime-assembly-fallback",
2498
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2499
+ );
2500
+
2501
+ // WHEN runtime injections run in minimal mode
1938
2502
  const { messages: result } = await applyRuntimeInjections([userMsg], {
1939
- subagentStatusBlock:
1940
- "<active_subagents>\n- [running] test\n</active_subagents>",
2503
+ conversationId: FALLBACK_CONVERSATION_ID,
1941
2504
  mode: "minimal",
1942
2505
  });
2506
+
2507
+ // THEN the subagent status block is gated out
2508
+ const tail = result[result.length - 1];
2509
+ const texts = tail.content
2510
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
2511
+ .map((b) => b.text);
2512
+ expect(texts.some((t) => t.includes("<active_subagents>"))).toBe(false);
2513
+ });
2514
+
2515
+ test("skips subagent status when the conversation is itself a subagent", async () => {
2516
+ // GIVEN the live conversation is itself a subagent (no nesting)
2517
+ setConversation("runtime-assembly-fallback", {
2518
+ conversationId: "runtime-assembly-fallback",
2519
+ workingDir: "/sandbox",
2520
+ workspaceTopLevelContext: "",
2521
+ workspaceTopLevelDirty: false,
2522
+ surfaceState: new Map(),
2523
+ isSubagent: true,
2524
+ } as never);
2525
+ // AND a child is registered under its id
2526
+ seedSubagentChild(
2527
+ "runtime-assembly-fallback",
2528
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2529
+ );
2530
+
2531
+ // WHEN runtime injections run in full mode
2532
+ const { messages: result } = await applyRuntimeInjections([userMsg], {
2533
+ conversationId: FALLBACK_CONVERSATION_ID,
2534
+ mode: "full",
2535
+ });
2536
+
2537
+ // THEN no `<active_subagents>` block is emitted
1943
2538
  const tail = result[result.length - 1];
1944
2539
  const texts = tail.content
1945
2540
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
@@ -2012,6 +2607,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2012
2607
 
2013
2608
  function makePkbOptions(overrides: Record<string, unknown> = {}) {
2014
2609
  return {
2610
+ conversationId: FALLBACK_CONVERSATION_ID,
2015
2611
  ...overrides,
2016
2612
  };
2017
2613
  }
@@ -2298,7 +2894,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2298
2894
  // filtered out.
2299
2895
  const { messages: initialResult } = await applyRuntimeInjections(
2300
2896
  preCompactionMessages,
2301
- {},
2897
+ { conversationId: FALLBACK_CONVERSATION_ID },
2302
2898
  );
2303
2899
  // Unwrap the injected reminder from the last user message.
2304
2900
  const initialTexts = extractTexts(initialResult);
@@ -2332,7 +2928,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2332
2928
  // should be filtered, and beta (no longer "in context") should appear.
2333
2929
  const { messages: rebuiltResult } = await applyRuntimeInjections(
2334
2930
  postCompactionMessages,
2335
- {},
2931
+ { conversationId: FALLBACK_CONVERSATION_ID },
2336
2932
  );
2337
2933
  const rebuiltTexts = extractTexts(rebuiltResult);
2338
2934
  const rebuiltReminder = rebuiltTexts.find(
@@ -2352,6 +2948,19 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2352
2948
  // ---------------------------------------------------------------------------
2353
2949
 
2354
2950
  describe("Slack channel chronological rendering — multi-thread", () => {
2951
+ afterEach(() => {
2952
+ clearConversations();
2953
+ // The focus-block tests persist rows under the runtime-assembly fallback
2954
+ // conversation; clear them so later tests resolve against an empty DB.
2955
+ const db = getDb();
2956
+ db.delete(messages)
2957
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
2958
+ .run();
2959
+ db.delete(conversations)
2960
+ .where(eq(conversations.id, "runtime-assembly-fallback"))
2961
+ .run();
2962
+ });
2963
+
2355
2964
  // Slack ts values are seconds-since-epoch with microsecond precision.
2356
2965
  // Pick a few stable anchors so thread aliases (sha-derived) stay
2357
2966
  // predictable across the scenarios.
@@ -2395,6 +3004,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2395
3004
  content: JSON.stringify([{ type: "text", text: opts.text }]),
2396
3005
  createdAt: opts.createdAt,
2397
3006
  metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
3007
+ clientMessageId: null,
2398
3008
  };
2399
3009
  }
2400
3010
 
@@ -2413,6 +3023,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2413
3023
  content: JSON.stringify([{ type: "text", text: opts.text }]),
2414
3024
  createdAt: opts.createdAt,
2415
3025
  metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
3026
+ clientMessageId: null,
2416
3027
  };
2417
3028
  }
2418
3029
 
@@ -2428,18 +3039,16 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2428
3039
  supportsVoiceInput: false,
2429
3040
  chatType: "channel",
2430
3041
  };
2431
- const slackChronologicalMessages = loadSlackChronologicalMessages(
2432
- "conv-1",
2433
- slackChannelCaps,
2434
- { loader: () => rows, trustClass: "guardian" },
2435
- );
2436
3042
  const lastUserMessage: Message = {
2437
3043
  role: "user",
2438
3044
  content: [{ type: "text", text: "current turn" }],
2439
3045
  };
3046
+ // Persist the rows + live conversation so `applyRuntimeInjections`
3047
+ // self-resolves the chronological transcript from conversation state,
3048
+ // exactly as production does.
3049
+ seedSlackChannelConversationWithRows(slackChannelCaps, rows);
2440
3050
  const { messages } = await applyRuntimeInjections([lastUserMessage], {
2441
- channelCapabilities: slackChannelCaps,
2442
- slackChronologicalMessages,
3051
+ conversationId: FALLBACK_CONVERSATION_ID,
2443
3052
  });
2444
3053
  return messages;
2445
3054
  }
@@ -2726,24 +3335,15 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2726
3335
  role: "user",
2727
3336
  content: [{ type: "text", text: "vellum question" }],
2728
3337
  };
3338
+ seedChannelCapabilitiesConversation({
3339
+ channel: "vellum",
3340
+ dashboardCapable: true,
3341
+ supportsDynamicUi: true,
3342
+ supportsVoiceInput: true,
3343
+ });
2729
3344
  const { messages: result } = await applyRuntimeInjections(
2730
3345
  [lastUserMessage],
2731
- {
2732
- channelCapabilities: {
2733
- channel: "vellum",
2734
- dashboardCapable: true,
2735
- supportsDynamicUi: true,
2736
- supportsVoiceInput: true,
2737
- },
2738
- // Even if we accidentally pass a chronological transcript, the
2739
- // branch must be a no-op for non-slack channels.
2740
- slackChronologicalMessages: [
2741
- {
2742
- role: "user",
2743
- content: [{ type: "text", text: "should not appear" }],
2744
- },
2745
- ],
2746
- },
3346
+ { conversationId: FALLBACK_CONVERSATION_ID },
2747
3347
  );
2748
3348
  expect(result.length).toBe(1);
2749
3349
  const allText = result[0].content
@@ -2751,7 +3351,6 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2751
3351
  .map((b) => b.text)
2752
3352
  .join("\n");
2753
3353
  expect(allText).toContain("vellum question");
2754
- expect(allText).not.toContain("should not appear");
2755
3354
  });
2756
3355
 
2757
3356
  // ── DMs (chatType === "im") use chronological rendering ────────────────
@@ -2764,36 +3363,38 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2764
3363
  role: "user",
2765
3364
  content: [{ type: "text", text: "DM question" }],
2766
3365
  };
2767
- const { messages: result } = await applyRuntimeInjections(
2768
- [lastUserMessage],
3366
+ const rows: MessageRow[] = [
3367
+ userRow({
3368
+ id: "dm-1",
3369
+ createdAt: 1700000000_000,
3370
+ text: "earlier DM line",
3371
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3372
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3373
+ }),
3374
+ assistantRow({
3375
+ id: "dm-2",
3376
+ createdAt: 1700000005_000,
3377
+ text: "prior reply",
3378
+ slackMeta: buildSlackMeta({ channelTs: T0_REPLY1 }),
3379
+ }),
3380
+ ];
3381
+ seedSlackChannelConversationWithRows(
2769
3382
  {
2770
- channelCapabilities: {
2771
- channel: "slack",
2772
- dashboardCapable: false,
2773
- supportsDynamicUi: false,
2774
- supportsVoiceInput: false,
2775
- chatType: "im",
2776
- },
2777
- slackChronologicalMessages: [
2778
- {
2779
- role: "user",
2780
- content: [
2781
- {
2782
- type: "text",
2783
- text: "[11/14/23 14:25 @alice]: earlier DM line",
2784
- },
2785
- ],
2786
- },
2787
- {
2788
- role: "assistant",
2789
- content: [{ type: "text", text: "prior reply" }],
2790
- },
2791
- ],
3383
+ channel: "slack",
3384
+ dashboardCapable: false,
3385
+ supportsDynamicUi: false,
3386
+ supportsVoiceInput: false,
3387
+ chatType: "im",
2792
3388
  },
3389
+ rows,
2793
3390
  );
2794
- // The chronological transcript REPLACES the default runMessages, so
2795
- // the inbound `DM question` text does not appear — only the rendered
2796
- // transcript lines do (plus any non-Slack injections).
3391
+ const { messages: result } = await applyRuntimeInjections(
3392
+ [lastUserMessage],
3393
+ { conversationId: FALLBACK_CONVERSATION_ID },
3394
+ );
3395
+ // The self-resolved chronological transcript REPLACES the default
3396
+ // runMessages, so the inbound `DM question` text does not appear — only
3397
+ // the rendered transcript lines do (plus any non-Slack injections).
2797
3398
  const allText = result
2798
3399
  .flatMap((m) => m.content)
2799
3400
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
@@ -2804,6 +3405,62 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2804
3405
  expect(allText).not.toContain("DM question");
2805
3406
  });
2806
3407
 
3408
+ // ── Compaction boundary scopes the self-resolved transcript ──────────
3409
+ // The transcript self-resolves from persisted rows scoped by the
3410
+ // conversation's Slack compaction boundary. Rows at or before the
3411
+ // persisted watermark were folded into the summary, so a fresh load
3412
+ // excludes them and surfaces only the post-watermark tail — returning
3413
+ // the compacted view rather than resurrecting folded history.
3414
+ test("self-resolved transcript respects the Slack compaction watermark", async () => {
3415
+ const slackCaps: ChannelCapabilities = {
3416
+ channel: "slack",
3417
+ dashboardCapable: false,
3418
+ supportsDynamicUi: false,
3419
+ supportsVoiceInput: false,
3420
+ chatType: "channel",
3421
+ };
3422
+ const rows: MessageRow[] = [
3423
+ userRow({
3424
+ id: "folded",
3425
+ createdAt: 1700000000_000,
3426
+ text: "folded pre-compaction line",
3427
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3428
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3429
+ }),
3430
+ userRow({
3431
+ id: "fresh",
3432
+ createdAt: 1700000030_000,
3433
+ text: "fresh post-compaction line",
3434
+ slackMeta: buildSlackMeta({ channelTs: T2, displayName: "bob" }),
3435
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3436
+ }),
3437
+ ];
3438
+ // A persisted watermark at T0 folds the first row into the summary.
3439
+ seedSlackChannelConversationWithRows(slackCaps, rows, {
3440
+ slackContextCompactionWatermarkTs: T0,
3441
+ contextCompactedMessageCount: 1,
3442
+ });
3443
+ const { messages: result } = await applyRuntimeInjections(
3444
+ [
3445
+ {
3446
+ role: "user",
3447
+ content: [{ type: "text", text: "post-compaction question" }],
3448
+ },
3449
+ ],
3450
+ { conversationId: FALLBACK_CONVERSATION_ID },
3451
+ );
3452
+ const allText = result
3453
+ .flatMap((m) => m.content)
3454
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
3455
+ .map((b) => b.text)
3456
+ .join("\n");
3457
+ // The folded row stays in the summary; only the post-watermark tail is
3458
+ // surfaced, and it replaces the default runMessages.
3459
+ expect(allText).toContain("fresh post-compaction line");
3460
+ expect(allText).not.toContain("folded pre-compaction line");
3461
+ expect(allText).not.toContain("post-compaction question");
3462
+ });
3463
+
2807
3464
  // ── Memory-injection carry-through on slack replacement ──────────────
2808
3465
  // `graphMemory.prepareMemory` prepends `<memory __injected>` (and
2809
3466
  // optional memory-image groups) to the last user message BEFORE the
@@ -2828,21 +3485,23 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2828
3485
  type: "text",
2829
3486
  text: "<memory __injected>\nrecalled fact about the user\n</memory>",
2830
3487
  },
2831
- { type: "text", text: "hello there" },
3488
+ { type: "text", text: "original turn text" },
2832
3489
  ],
2833
3490
  },
2834
3491
  ];
3492
+ const rows: MessageRow[] = [
3493
+ userRow({
3494
+ id: "mem-1",
3495
+ createdAt: 1700000000_000,
3496
+ text: "recalled conversation line",
3497
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3498
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3499
+ }),
3500
+ ];
3501
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2835
3502
  const { messages: result } = await applyRuntimeInjections(
2836
3503
  runMessagesWithMemory,
2837
- {
2838
- channelCapabilities: slackCaps,
2839
- slackChronologicalMessages: [
2840
- {
2841
- role: "user",
2842
- content: [{ type: "text", text: "[19:55 alice]: hello there" }],
2843
- },
2844
- ],
2845
- },
3504
+ { conversationId: FALLBACK_CONVERSATION_ID },
2846
3505
  );
2847
3506
  const tail = result[result.length - 1];
2848
3507
  expect(tail.role).toBe("user");
@@ -2852,15 +3511,15 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2852
3511
  .join("\n");
2853
3512
  expect(allText).toContain("<memory __injected>");
2854
3513
  expect(allText).toContain("recalled fact about the user");
2855
- expect(allText).toContain("[19:55 alice]: hello there");
3514
+ expect(allText).toContain("recalled conversation line");
2856
3515
  // Memory block must appear before the Slack transcript tail so the
2857
3516
  // model sees recalled context ahead of the conversation view.
2858
3517
  const memoryIdx = allText.indexOf("<memory __injected>");
2859
- const transcriptIdx = allText.indexOf("[19:55 alice]: hello there");
3518
+ const transcriptIdx = allText.indexOf("recalled conversation line");
2860
3519
  expect(memoryIdx).toBeLessThan(transcriptIdx);
2861
- // The pre-replacement "hello there" text from the original runMessages
2862
- // must NOT leak through — only the Slack-rendered line appears.
2863
- expect(allText.match(/hello there/g)?.length).toBe(1);
3520
+ // The pre-replacement inbound turn text must NOT leak through — only the
3521
+ // carried memory prefix + the self-resolved transcript tail remain.
3522
+ expect(allText).not.toContain("original turn text");
2864
3523
  });
2865
3524
 
2866
3525
  test("slack replacement preserves memory-image groups + text block", async () => {
@@ -2896,17 +3555,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2896
3555
  ],
2897
3556
  },
2898
3557
  ];
3558
+ const rows: MessageRow[] = [
3559
+ userRow({
3560
+ id: "mem-img-1",
3561
+ createdAt: 1700000000_000,
3562
+ text: "recalled conversation line",
3563
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3564
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3565
+ }),
3566
+ ];
3567
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2899
3568
  const { messages: result } = await applyRuntimeInjections(
2900
3569
  runMessagesWithMemory,
2901
- {
2902
- channelCapabilities: slackCaps,
2903
- slackChronologicalMessages: [
2904
- {
2905
- role: "user",
2906
- content: [{ type: "text", text: "[19:55 alice]: transcript line" }],
2907
- },
2908
- ],
2909
- },
3570
+ { conversationId: FALLBACK_CONVERSATION_ID },
2910
3571
  );
2911
3572
  const tail = result[result.length - 1];
2912
3573
  expect(tail.role).toBe("user");
@@ -2921,7 +3582,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2921
3582
  expect(allText).toContain("<memory_image __injected>");
2922
3583
  expect(allText).toContain("</memory_image>");
2923
3584
  expect(allText).toContain("<memory __injected>");
2924
- expect(allText).toContain("[19:55 alice]: transcript line");
3585
+ expect(allText).toContain("recalled conversation line");
2925
3586
  // The original turn text (before the Slack replacement) must NOT
2926
3587
  // leak through — only the memory prefix + transcript tail are kept.
2927
3588
  expect(allText).not.toContain("original turn text");
@@ -2935,17 +3596,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2935
3596
  supportsVoiceInput: false,
2936
3597
  chatType: "im",
2937
3598
  };
3599
+ const rows: MessageRow[] = [
3600
+ userRow({
3601
+ id: "no-prefix-1",
3602
+ createdAt: 1700000000_000,
3603
+ text: "only transcript line",
3604
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3605
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3606
+ }),
3607
+ ];
3608
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2938
3609
  const { messages: result } = await applyRuntimeInjections(
2939
3610
  [{ role: "user", content: [{ type: "text", text: "inbound" }] }],
2940
- {
2941
- channelCapabilities: slackCaps,
2942
- slackChronologicalMessages: [
2943
- {
2944
- role: "user",
2945
- content: [{ type: "text", text: "[19:55 alice]: only transcript" }],
2946
- },
2947
- ],
2948
- },
3611
+ { conversationId: FALLBACK_CONVERSATION_ID },
2949
3612
  );
2950
3613
  const tail = result[result.length - 1];
2951
3614
  const allText = tail.content
@@ -2953,7 +3616,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2953
3616
  .map((b) => b.text)
2954
3617
  .join("\n");
2955
3618
  expect(allText).not.toContain("<memory __injected>");
2956
- expect(allText).toContain("[19:55 alice]: only transcript");
3619
+ expect(allText).toContain("only transcript line");
2957
3620
  });
2958
3621
 
2959
3622
  // ── transport_hints suppression for slack channels ────────────────────
@@ -2965,27 +3628,12 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2965
3628
  supportsVoiceInput: false,
2966
3629
  chatType: "channel",
2967
3630
  };
2968
- const rows: MessageRow[] = [
2969
- userRow({
2970
- id: "m1",
2971
- createdAt: 1700000000_000,
2972
- text: "Original message",
2973
- slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
2974
- }),
2975
- ];
2976
- const slackChronologicalMessages = loadSlackChronologicalMessages(
2977
- "conv-1",
2978
- slackChannelCaps,
2979
- { loader: () => rows, trustClass: "guardian" },
2980
- );
2981
-
3631
+ seedChannelCapabilitiesConversation(slackChannelCaps, [
3632
+ "thread context: ...",
3633
+ ]);
2982
3634
  const { messages: result } = await applyRuntimeInjections(
2983
3635
  [{ role: "user", content: [{ type: "text", text: "current turn" }] }],
2984
- {
2985
- channelCapabilities: slackChannelCaps,
2986
- slackChronologicalMessages,
2987
- transportHints: ["thread context: ..."],
2988
- },
3636
+ { conversationId: FALLBACK_CONVERSATION_ID },
2989
3637
  );
2990
3638
 
2991
3639
  const allText = result
@@ -3009,12 +3657,10 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3009
3657
  chatType: "im",
3010
3658
  };
3011
3659
 
3660
+ seedChannelCapabilitiesConversation(slackDmCaps, ["dm context: ..."]);
3012
3661
  const { messages: result } = await applyRuntimeInjections(
3013
3662
  [{ role: "user", content: [{ type: "text", text: "hi DM" }] }],
3014
- {
3015
- channelCapabilities: slackDmCaps,
3016
- transportHints: ["dm context: ..."],
3017
- },
3663
+ { conversationId: FALLBACK_CONVERSATION_ID },
3018
3664
  );
3019
3665
 
3020
3666
  const allText = result
@@ -3028,18 +3674,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3028
3674
 
3029
3675
  // ── transport_hints kept for non-slack channels ───────────────────────
3030
3676
  test("non-slack conversations still receive <transport_hints>", async () => {
3031
- const { messages: result } = await applyRuntimeInjections(
3032
- [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3677
+ seedChannelCapabilitiesConversation(
3033
3678
  {
3034
- channelCapabilities: {
3035
- channel: "telegram",
3036
- dashboardCapable: false,
3037
- supportsDynamicUi: false,
3038
- supportsVoiceInput: false,
3039
- chatType: "private",
3040
- },
3041
- transportHints: ["please answer concisely"],
3679
+ channel: "telegram",
3680
+ dashboardCapable: false,
3681
+ supportsDynamicUi: false,
3682
+ supportsVoiceInput: false,
3683
+ chatType: "private",
3042
3684
  },
3685
+ ["please answer concisely"],
3686
+ );
3687
+ const { messages: result } = await applyRuntimeInjections(
3688
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3689
+ { conversationId: FALLBACK_CONVERSATION_ID },
3043
3690
  );
3044
3691
  const allText = result
3045
3692
  .flatMap((m) => m.content)
@@ -3336,10 +3983,72 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3336
3983
  // interleaved. The block is non-persisted: replays / re-injections strip
3337
3984
  // any prior `<active_thread>` blocks via `RUNTIME_INJECTION_PREFIXES`.
3338
3985
 
3339
- // Re-run a Slack-channel turn through the public assembly path with the
3340
- // active-thread focus block plumbed in (mirrors production wiring in
3341
- // conversation-agent-loop.ts).
3342
- async function runSlackChannelAssemblyWithFocus(rows: MessageRow[]): Promise<{
3986
+ interface SeededCompactionState {
3987
+ slackContextCompactionWatermarkTs?: string | null;
3988
+ contextCompactedMessageCount?: number;
3989
+ }
3990
+
3991
+ // Persist the Slack rows under the runtime-assembly fallback conversation and
3992
+ // mirror the channel capabilities + trust class + compaction state onto its
3993
+ // live registry entry, so `applyRuntimeInjections` self-resolves the
3994
+ // `<active_thread>` focus block from the conversation's stored rows — the same
3995
+ // way it does in production, where the injector reads the live conversation
3996
+ // rather than receiving a pre-built block.
3997
+ function seedSlackChannelConversationWithRows(
3998
+ caps: ChannelCapabilities,
3999
+ rows: MessageRow[],
4000
+ compaction: SeededCompactionState = {},
4001
+ ): void {
4002
+ const db = getDb();
4003
+ const now = Date.now();
4004
+ db.delete(messages)
4005
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
4006
+ .run();
4007
+ db.insert(conversations)
4008
+ .values({
4009
+ id: "runtime-assembly-fallback",
4010
+ createdAt: now,
4011
+ updatedAt: now,
4012
+ })
4013
+ .run();
4014
+ if (rows.length > 0) {
4015
+ db.insert(messages)
4016
+ .values(
4017
+ rows.map((r) => ({
4018
+ id: r.id,
4019
+ conversationId: "runtime-assembly-fallback",
4020
+ role: r.role,
4021
+ content: r.content,
4022
+ createdAt: r.createdAt,
4023
+ metadata: r.metadata,
4024
+ })),
4025
+ )
4026
+ .run();
4027
+ }
4028
+ setConversation("runtime-assembly-fallback", {
4029
+ conversationId: "runtime-assembly-fallback",
4030
+ workingDir: "/sandbox",
4031
+ workspaceTopLevelContext: "",
4032
+ workspaceTopLevelDirty: false,
4033
+ surfaceState: new Map(),
4034
+ channelCapabilities: caps,
4035
+ trustContext: { trustClass: "guardian" },
4036
+ slackContextCompactionWatermarkTs:
4037
+ compaction.slackContextCompactionWatermarkTs ?? null,
4038
+ contextCompactedMessageCount:
4039
+ compaction.contextCompactedMessageCount ?? 0,
4040
+ } as never);
4041
+ }
4042
+
4043
+ // Re-run a Slack-channel turn through the public assembly path. The
4044
+ // active-thread focus block self-resolves inside `applyRuntimeInjections`
4045
+ // from the seeded live conversation; `focusBlock` resolves it the same way
4046
+ // (default DB loader, guardian trust + the seeded compaction state) for the
4047
+ // expected-content assertions.
4048
+ async function runSlackChannelAssemblyWithFocus(
4049
+ rows: MessageRow[],
4050
+ compaction: SeededCompactionState = {},
4051
+ ): Promise<{
3343
4052
  messages: Message[];
3344
4053
  focusBlock: string | null;
3345
4054
  }> {
@@ -3350,24 +4059,23 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3350
4059
  supportsVoiceInput: false,
3351
4060
  chatType: "channel",
3352
4061
  };
3353
- const slackChronologicalMessages = loadSlackChronologicalMessages(
3354
- "conv-1",
3355
- slackChannelCaps,
3356
- { loader: () => rows, trustClass: "guardian" },
3357
- );
4062
+ seedSlackChannelConversationWithRows(slackChannelCaps, rows, compaction);
3358
4063
  const focusBlock = loadSlackActiveThreadFocusBlock(
3359
- "conv-1",
4064
+ "runtime-assembly-fallback",
3360
4065
  slackChannelCaps,
3361
- { loader: () => rows, trustClass: "guardian" },
4066
+ {
4067
+ trustClass: "guardian",
4068
+ contextCompactedMessageCount: compaction.contextCompactedMessageCount,
4069
+ slackContextCompactionWatermarkTs:
4070
+ compaction.slackContextCompactionWatermarkTs,
4071
+ },
3362
4072
  );
3363
4073
  const lastUserMessage: Message = {
3364
4074
  role: "user",
3365
4075
  content: [{ type: "text", text: "current turn" }],
3366
4076
  };
3367
4077
  const { messages } = await applyRuntimeInjections([lastUserMessage], {
3368
- channelCapabilities: slackChannelCaps,
3369
- slackChronologicalMessages,
3370
- slackActiveThreadFocusBlock: focusBlock,
4078
+ conversationId: FALLBACK_CONVERSATION_ID,
3371
4079
  });
3372
4080
  return { messages, focusBlock };
3373
4081
  }
@@ -3584,14 +4292,143 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3584
4292
  }
3585
4293
  });
3586
4294
 
4295
+ test("focus block self-resolves the live conversation's compaction watermark", async () => {
4296
+ /**
4297
+ * The watermark + compacted count live on the live `Conversation`, not in
4298
+ * the injection options, so `applyRuntimeInjections` must read them off the
4299
+ * live conversation to bound the self-resolved `<active_thread>` block.
4300
+ */
4301
+ // GIVEN a thread whose earliest rows were folded into a prior compaction
4302
+ const rows: MessageRow[] = [
4303
+ userRow({
4304
+ id: "thread-root",
4305
+ createdAt: 1700000000_000,
4306
+ text: "compacted root",
4307
+ slackMeta: buildSlackMeta({
4308
+ channelTs: T0,
4309
+ threadTs: T0,
4310
+ displayName: "alice",
4311
+ }),
4312
+ }),
4313
+ userRow({
4314
+ id: "reply-before",
4315
+ createdAt: 1700000005_000,
4316
+ text: "compacted reply",
4317
+ slackMeta: buildSlackMeta({
4318
+ channelTs: T0_REPLY1,
4319
+ threadTs: T0,
4320
+ displayName: "bob",
4321
+ }),
4322
+ }),
4323
+ userRow({
4324
+ id: "reply-after",
4325
+ createdAt: 1700000020_000,
4326
+ text: "live reply",
4327
+ slackMeta: buildSlackMeta({
4328
+ channelTs: T0_REPLY2,
4329
+ threadTs: T0,
4330
+ displayName: "carol",
4331
+ }),
4332
+ }),
4333
+ ];
4334
+
4335
+ // WHEN the live conversation carries a Slack compaction watermark at T1
4336
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4337
+ rows,
4338
+ {
4339
+ slackContextCompactionWatermarkTs: T1,
4340
+ contextCompactedMessageCount: 2,
4341
+ },
4342
+ );
4343
+
4344
+ // THEN the self-resolved focus block keeps only the post-watermark reply
4345
+ expect(focusBlock).not.toBeNull();
4346
+ expect(focusBlock!).toContain("live reply");
4347
+ expect(focusBlock!).not.toContain("compacted root");
4348
+ expect(focusBlock!).not.toContain("compacted reply");
4349
+ // AND `applyRuntimeInjections` injected exactly that self-resolved block,
4350
+ // proving it read the watermark + count off the live conversation.
4351
+ const allText = messages
4352
+ .flatMap((m) => m.content)
4353
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4354
+ .map((b) => b.text)
4355
+ .join("\n");
4356
+ expect(allText).toContain("<active_thread>");
4357
+ expect(allText).toContain(focusBlock!);
4358
+ });
4359
+
4360
+ test("focus block bounds by the compacted count when there is no watermark", async () => {
4361
+ /**
4362
+ * Conversations compacted before the Slack watermark migration have no
4363
+ * watermark, so the raw `contextCompactedMessageCount` is the sole boundary
4364
+ * the self-resolved focus block uses to drop already-compacted rows.
4365
+ */
4366
+ // GIVEN a thread whose first two rows were folded into a prior compaction
4367
+ const rows: MessageRow[] = [
4368
+ userRow({
4369
+ id: "thread-root",
4370
+ createdAt: 1700000000_000,
4371
+ text: "compacted root",
4372
+ slackMeta: buildSlackMeta({
4373
+ channelTs: T0,
4374
+ threadTs: T0,
4375
+ displayName: "alice",
4376
+ }),
4377
+ }),
4378
+ userRow({
4379
+ id: "reply-before",
4380
+ createdAt: 1700000005_000,
4381
+ text: "compacted reply",
4382
+ slackMeta: buildSlackMeta({
4383
+ channelTs: T0_REPLY1,
4384
+ threadTs: T0,
4385
+ displayName: "bob",
4386
+ }),
4387
+ }),
4388
+ userRow({
4389
+ id: "reply-after",
4390
+ createdAt: 1700000020_000,
4391
+ text: "live reply",
4392
+ slackMeta: buildSlackMeta({
4393
+ channelTs: T0_REPLY2,
4394
+ threadTs: T0,
4395
+ displayName: "carol",
4396
+ }),
4397
+ }),
4398
+ ];
4399
+
4400
+ // WHEN the live conversation has a compacted count of 2 and no watermark
4401
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4402
+ rows,
4403
+ {
4404
+ slackContextCompactionWatermarkTs: null,
4405
+ contextCompactedMessageCount: 2,
4406
+ },
4407
+ );
4408
+
4409
+ // THEN the self-resolved focus block drops the two compacted rows and keeps
4410
+ // only the live reply
4411
+ expect(focusBlock).not.toBeNull();
4412
+ expect(focusBlock!).toContain("live reply");
4413
+ expect(focusBlock!).not.toContain("compacted root");
4414
+ expect(focusBlock!).not.toContain("compacted reply");
4415
+ const allText = messages
4416
+ .flatMap((m) => m.content)
4417
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4418
+ .map((b) => b.text)
4419
+ .join("\n");
4420
+ expect(allText).toContain("<active_thread>");
4421
+ expect(allText).toContain(focusBlock!);
4422
+ });
4423
+
3587
4424
  test("focus block is dropped when injection is replayed (rebuilds re-derive it)", async () => {
3588
4425
  // Defensive: the `<active_thread>` block is a per-turn injection. When
3589
4426
  // overflow recovery / compaction re-runs `applyRuntimeInjections` on
3590
4427
  // already-injected messages, prior `<active_thread>` blocks must be
3591
4428
  // stripped so the rebuild's freshly-derived block is the only one
3592
4429
  // present. We simulate by building a Slack channel turn, then
3593
- // running the strip pipeline + applying injections again with a
3594
- // different focus block to confirm no duplication occurs.
4430
+ // running the strip pipeline + applying injections again to confirm the
4431
+ // rebuild re-derives the block and no duplication occurs.
3595
4432
  const rows: MessageRow[] = [
3596
4433
  userRow({
3597
4434
  id: "m1",
@@ -3624,19 +4461,11 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3624
4461
  .join("\n");
3625
4462
  expect(strippedTexts).not.toContain("<active_thread>");
3626
4463
 
3627
- // Re-run injection with a fresh focus block — only ONE
4464
+ // Re-run injection on the stripped messages — the conversation is still
4465
+ // seeded, so the rebuild re-derives the focus block; only ONE
3628
4466
  // `<active_thread>` block must end up in the result.
3629
- const slackChannelCaps: ChannelCapabilities = {
3630
- channel: "slack",
3631
- dashboardCapable: false,
3632
- supportsDynamicUi: false,
3633
- supportsVoiceInput: false,
3634
- chatType: "channel",
3635
- };
3636
- const newFocus = "<active_thread>\nnewly built\n</active_thread>";
3637
4467
  const { messages: reInjected } = await applyRuntimeInjections(stripped, {
3638
- channelCapabilities: slackChannelCaps,
3639
- slackActiveThreadFocusBlock: newFocus,
4468
+ conversationId: FALLBACK_CONVERSATION_ID,
3640
4469
  });
3641
4470
  const reInjectedTexts = reInjected
3642
4471
  .flatMap((m) => m.content)
@@ -3648,24 +4477,22 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3648
4477
  expect(blockCount).toBe(1);
3649
4478
  expect(
3650
4479
  reInjectedTexts.find((t) => t.startsWith("<active_thread>")),
3651
- ).toContain("newly built");
4480
+ ).toContain("Reply in thread A");
3652
4481
  });
3653
4482
 
3654
- test("non-slack conversations ignore slackActiveThreadFocusBlock", async () => {
3655
- // Defensive: the focus injection is gated on `slackChannel` (i.e.
3656
- // `isSlackChannelConversation`). Even if a caller mistakenly forwards
3657
- // a focus block on a non-Slack channel, it must NOT be appended.
4483
+ test("non-slack conversations never get an active-thread focus block", async () => {
4484
+ // The focus block is gated on a Slack *channel* conversation
4485
+ // (`isSlackChannelConversation`). A non-Slack conversation must never
4486
+ // self-resolve an `<active_thread>` block.
4487
+ seedChannelCapabilitiesConversation({
4488
+ channel: "vellum",
4489
+ dashboardCapable: true,
4490
+ supportsDynamicUi: true,
4491
+ supportsVoiceInput: true,
4492
+ });
3658
4493
  const { messages: result } = await applyRuntimeInjections(
3659
4494
  [{ role: "user", content: [{ type: "text", text: "vellum question" }] }],
3660
- {
3661
- channelCapabilities: {
3662
- channel: "vellum",
3663
- dashboardCapable: true,
3664
- supportsDynamicUi: true,
3665
- supportsVoiceInput: true,
3666
- },
3667
- slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
3668
- },
4495
+ { conversationId: FALLBACK_CONVERSATION_ID },
3669
4496
  );
3670
4497
  const allText = result
3671
4498
  .flatMap((m) => m.content)
@@ -3676,22 +4503,20 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3676
4503
  expect(allText).toContain("vellum question");
3677
4504
  });
3678
4505
 
3679
- test("slack DMs ignore slackActiveThreadFocusBlock", async () => {
4506
+ test("slack DMs never get an active-thread focus block", async () => {
3680
4507
  // Same as above but for Slack DMs (chatType === "im"). The focus
3681
4508
  // injection is keyed on `isSlackChannelConversation` which excludes
3682
4509
  // DMs, so the block must not appear.
4510
+ seedChannelCapabilitiesConversation({
4511
+ channel: "slack",
4512
+ dashboardCapable: false,
4513
+ supportsDynamicUi: false,
4514
+ supportsVoiceInput: false,
4515
+ chatType: "im",
4516
+ });
3683
4517
  const { messages: result } = await applyRuntimeInjections(
3684
4518
  [{ role: "user", content: [{ type: "text", text: "DM question" }] }],
3685
- {
3686
- channelCapabilities: {
3687
- channel: "slack",
3688
- dashboardCapable: false,
3689
- supportsDynamicUi: false,
3690
- supportsVoiceInput: false,
3691
- chatType: "im",
3692
- },
3693
- slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
3694
- },
4519
+ { conversationId: FALLBACK_CONVERSATION_ID },
3695
4520
  );
3696
4521
  const allText = result
3697
4522
  .flatMap((m) => m.content)
@@ -5047,6 +5872,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5047
5872
  pkbSearchResults = [];
5048
5873
  pkbSearchThrows = null;
5049
5874
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5875
+ conversationId: FALLBACK_CONVERSATION_ID,
5050
5876
  mode: "full",
5051
5877
  });
5052
5878
 
@@ -5057,6 +5883,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5057
5883
  test("not captured in minimal mode", async () => {
5058
5884
  seedPkbContent();
5059
5885
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5886
+ conversationId: FALLBACK_CONVERSATION_ID,
5060
5887
  mode: "minimal",
5061
5888
  });
5062
5889
 
@@ -5065,6 +5892,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5065
5892
 
5066
5893
  test("not captured when PKB inactive", async () => {
5067
5894
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5895
+ conversationId: FALLBACK_CONVERSATION_ID,
5068
5896
  mode: "full",
5069
5897
  });
5070
5898