@vellumai/assistant 0.8.8 → 0.8.9-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (360) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/examples/plugins/echo/README.md +61 -60
  4. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  5. package/examples/plugins/echo/hooks/stop.ts +16 -0
  6. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  7. package/examples/plugins/echo/package.json +1 -2
  8. package/examples/plugins/echo/src/emit.ts +19 -0
  9. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  10. package/openapi.yaml +235 -6
  11. package/package.json +2 -2
  12. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  13. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  14. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  15. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  16. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  17. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  18. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  19. package/src/__tests__/agent-loop.test.ts +441 -96
  20. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  21. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  22. package/src/__tests__/anthropic-provider.test.ts +1 -1
  23. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  24. package/src/__tests__/app-control-flow.test.ts +1 -1
  25. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  26. package/src/__tests__/app-executors.test.ts +132 -0
  27. package/src/__tests__/approval-cascade.test.ts +5 -4
  28. package/src/__tests__/approval-routes-http.test.ts +4 -1
  29. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  30. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  31. package/src/__tests__/channel-approvals.test.ts +1 -1
  32. package/src/__tests__/compaction-circuit.test.ts +258 -0
  33. package/src/__tests__/compaction-direct.test.ts +132 -0
  34. package/src/__tests__/compaction-events.test.ts +5 -5
  35. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  36. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  37. package/src/__tests__/conversation-abort-tool-results.test.ts +6 -4
  38. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  39. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  40. package/src/__tests__/conversation-agent-loop-overflow.test.ts +142 -218
  41. package/src/__tests__/conversation-agent-loop.test.ts +297 -586
  42. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  43. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -4
  44. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  45. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  46. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -4
  47. package/src/__tests__/conversation-process-callsite.test.ts +14 -14
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +70 -65
  49. package/src/__tests__/conversation-queue.test.ts +9 -9
  50. package/src/__tests__/conversation-runtime-assembly.test.ts +923 -231
  51. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  52. package/src/__tests__/conversation-slash-queue.test.ts +6 -4
  53. package/src/__tests__/conversation-slash-unknown.test.ts +6 -4
  54. package/src/__tests__/conversation-speed-override.test.ts +10 -9
  55. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  56. package/src/__tests__/conversation-workspace-cache-state.test.ts +23 -20
  57. package/src/__tests__/conversation-workspace-injection.test.ts +68 -6
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +14 -11
  59. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +0 -1
  61. package/src/__tests__/db-acp-history.test.ts +101 -0
  62. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  63. package/src/__tests__/empty-response-hook.test.ts +1 -1
  64. package/src/__tests__/file-write-tool.test.ts +63 -0
  65. package/src/__tests__/gateway-only-guard.test.ts +12 -2
  66. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  67. package/src/__tests__/guardian-routing-invariants.test.ts +2 -4
  68. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  69. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  70. package/src/__tests__/heartbeat-service.test.ts +1 -0
  71. package/src/__tests__/history-repair-hook.test.ts +1 -1
  72. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  73. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  74. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  75. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  76. package/src/__tests__/injector-background-turn.test.ts +13 -23
  77. package/src/__tests__/injector-chain.test.ts +268 -44
  78. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  79. package/src/__tests__/injector-document-comments.test.ts +97 -114
  80. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  81. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  82. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  83. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  84. package/src/__tests__/memory-retrieval-hook.test.ts +131 -26
  85. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  86. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  87. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  88. package/src/__tests__/plugin-bootstrap.test.ts +12 -23
  89. package/src/__tests__/plugin-registry.test.ts +3 -49
  90. package/src/__tests__/plugin-types.test.ts +0 -70
  91. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  92. package/src/__tests__/reaction-persistence.test.ts +1 -1
  93. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  94. package/src/__tests__/skill-feature-flags-integration.test.ts +33 -0
  95. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  96. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  97. package/src/__tests__/subagent-detail.test.ts +25 -7
  98. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  99. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  100. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  101. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  102. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  103. package/src/__tests__/title-generate-hook.test.ts +1 -1
  104. package/src/__tests__/tool-error-hook.test.ts +1 -1
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  106. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  107. package/src/acp/__tests__/agent-process.test.ts +161 -0
  108. package/src/acp/__tests__/client-handler.test.ts +40 -0
  109. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  110. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  111. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  112. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  113. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  114. package/src/acp/agent-process.ts +61 -1
  115. package/src/acp/auto-install.test.ts +280 -0
  116. package/src/acp/auto-install.ts +232 -0
  117. package/src/acp/client-handler.ts +31 -0
  118. package/src/acp/feature-gate.test.ts +48 -0
  119. package/src/acp/feature-gate.ts +34 -0
  120. package/src/acp/prepare-agent-env.ts +80 -27
  121. package/src/acp/resolve-agent.test.ts +225 -9
  122. package/src/acp/resolve-agent.ts +122 -17
  123. package/src/acp/resume-hint.ts +23 -0
  124. package/src/acp/session-manager.ts +507 -73
  125. package/src/agent/compaction-circuit.ts +60 -102
  126. package/src/agent/loop.ts +414 -248
  127. package/src/api/responses/conversation-message.ts +14 -1
  128. package/src/approvals/guardian-request-resolvers.ts +1 -1
  129. package/src/background-wake/next-wake.ts +1 -0
  130. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  131. package/src/cli/commands/plugins.ts +43 -37
  132. package/src/cli/lib/__tests__/install-from-github.test.ts +429 -111
  133. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  134. package/src/cli/lib/__tests__/plugin-details.test.ts +372 -0
  135. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +220 -0
  136. package/src/cli/lib/__tests__/search-plugins.test.ts +226 -32
  137. package/src/cli/lib/install-from-github.ts +464 -55
  138. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  139. package/src/cli/lib/plugin-details.ts +409 -0
  140. package/src/cli/lib/plugin-marketplace.ts +197 -0
  141. package/src/cli/lib/search-plugins.ts +195 -29
  142. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  143. package/src/config/acp-defaults.test.ts +10 -0
  144. package/src/config/acp-defaults.ts +6 -0
  145. package/src/config/bundled-skills/acp/SKILL.md +85 -33
  146. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  147. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  148. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  149. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  150. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  151. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  152. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  153. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  154. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  155. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  156. package/src/config/bundled-tool-registry.ts +4 -0
  157. package/src/config/call-site-defaults.ts +0 -2
  158. package/src/config/feature-flag-registry.json +15 -6
  159. package/src/config/schemas/call-site-catalog.ts +0 -14
  160. package/src/config/schemas/heartbeat.ts +9 -0
  161. package/src/config/schemas/llm.ts +0 -2
  162. package/src/context/compactor.ts +22 -4
  163. package/src/context/strip-injections.ts +8 -2
  164. package/src/context/window-manager.ts +27 -13
  165. package/src/daemon/conversation-agent-loop-handlers.ts +10 -35
  166. package/src/daemon/conversation-agent-loop.ts +175 -1055
  167. package/src/daemon/conversation-lifecycle.ts +11 -255
  168. package/src/daemon/conversation-process.ts +8 -136
  169. package/src/daemon/conversation-registry.ts +159 -0
  170. package/src/daemon/conversation-runtime-assembly.ts +293 -392
  171. package/src/daemon/conversation-store.ts +9 -90
  172. package/src/daemon/conversation-surfaces.ts +24 -8
  173. package/src/daemon/conversation-workspace.ts +17 -0
  174. package/src/daemon/conversation.ts +404 -57
  175. package/src/daemon/disk-pressure-policy.ts +0 -1
  176. package/src/daemon/external-plugins-bootstrap.ts +14 -19
  177. package/src/daemon/handlers/conversations.ts +3 -1
  178. package/src/daemon/handlers/skills.ts +4 -1
  179. package/src/daemon/host-proxy-preactivation.ts +1 -3
  180. package/src/daemon/lifecycle.ts +21 -7
  181. package/src/daemon/server.ts +2 -0
  182. package/src/daemon/wake-conversation-ops.ts +269 -0
  183. package/src/embedded/plugin-api.ts +2 -2
  184. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  185. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  186. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  187. package/src/heartbeat/heartbeat-service.ts +26 -0
  188. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  189. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  190. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  191. package/src/ipc/skill-routes/memory.ts +4 -2
  192. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  193. package/src/memory/conversation-crud.ts +29 -19
  194. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  195. package/src/memory/db-init.ts +2 -0
  196. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  197. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  198. package/src/memory/jobs-worker.ts +25 -1
  199. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  200. package/src/memory/migrations/index.ts +1 -0
  201. package/src/memory/schema/acp.ts +4 -0
  202. package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
  203. package/src/memory/v2/consolidation-job.ts +13 -4
  204. package/src/plugin-api/constants.ts +4 -0
  205. package/src/plugin-api/index.ts +6 -5
  206. package/src/plugin-api/types.ts +75 -0
  207. package/src/plugins/defaults/compaction/compact.ts +59 -0
  208. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  209. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  210. package/src/plugins/defaults/compaction/package.json +1 -2
  211. package/src/plugins/defaults/empty-response/package.json +0 -1
  212. package/src/plugins/defaults/history-repair/package.json +0 -1
  213. package/src/plugins/defaults/index.ts +135 -26
  214. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  215. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +184 -74
  216. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  217. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  218. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  219. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/assign.test.ts +4 -4
  220. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +9 -6
  221. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/maintain-job.test.ts +5 -5
  222. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/orchestrate.test.ts +8 -5
  223. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/reconcile.test.ts +2 -2
  224. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/render-injection.test.ts +1 -1
  225. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/router.test.ts +10 -5
  226. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +8 -8
  227. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selector.test.ts +5 -5
  228. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/shadow-plugin.test.ts +16 -17
  229. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -2
  230. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/assign.ts +9 -5
  231. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/capabilities.ts +5 -2
  232. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  233. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  234. package/src/plugins/defaults/memory-v3-shadow/injector.ts +75 -0
  235. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/maintain-job.ts +15 -8
  236. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/orchestrate.ts +2 -2
  237. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  238. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/page-content.ts +2 -2
  239. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/provider-blocks.ts +1 -1
  240. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/reconcile.ts +7 -3
  241. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +1 -1
  242. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/router.ts +5 -5
  243. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selection-log-store.ts +4 -4
  244. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selector.ts +7 -7
  245. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/shadow-plugin.ts +32 -94
  246. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/tree.ts +1 -1
  247. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/types.ts +1 -1
  248. package/src/plugins/defaults/title-generate/package.json +0 -1
  249. package/src/plugins/defaults/tool-error/package.json +0 -1
  250. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  251. package/src/plugins/pipeline.ts +6 -293
  252. package/src/plugins/registry.ts +9 -37
  253. package/src/plugins/types.ts +76 -381
  254. package/src/plugins/user-loader.ts +30 -127
  255. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  256. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +35 -3
  257. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  258. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  259. package/src/runtime/agent-wake.ts +108 -209
  260. package/src/runtime/assistant-event-hub.ts +1 -1
  261. package/src/runtime/channel-approvals.ts +1 -1
  262. package/src/runtime/interactive-ui.ts +1 -1
  263. package/src/runtime/routes/__tests__/acp-routes.test.ts +315 -55
  264. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  265. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  266. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  267. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  268. package/src/runtime/routes/acp-routes.test.ts +89 -25
  269. package/src/runtime/routes/acp-routes.ts +81 -29
  270. package/src/runtime/routes/approval-routes.ts +1 -1
  271. package/src/runtime/routes/browser-routes.ts +1 -1
  272. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  273. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  274. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  275. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  276. package/src/runtime/routes/conversation-routes.ts +28 -2
  277. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  278. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  279. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  280. package/src/runtime/routes/host-cu-routes.ts +1 -1
  281. package/src/runtime/routes/identity-routes.ts +18 -3
  282. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  283. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  284. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  285. package/src/runtime/routes/memory-v3-routes.ts +16 -6
  286. package/src/runtime/routes/playground/helpers.ts +1 -1
  287. package/src/runtime/routes/plugins-routes.ts +337 -35
  288. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  289. package/src/runtime/routes/work-items-routes.ts +2 -4
  290. package/src/runtime/services/conversation-serializer.ts +1 -1
  291. package/src/signals/cancel.ts +2 -4
  292. package/src/subagent/manager.ts +21 -5
  293. package/src/tools/acp/context.ts +20 -0
  294. package/src/tools/acp/list-agents.test.ts +8 -2
  295. package/src/tools/acp/spawn.test.ts +176 -195
  296. package/src/tools/acp/spawn.ts +37 -172
  297. package/src/tools/acp/steer.test.ts +105 -8
  298. package/src/tools/acp/steer.ts +48 -17
  299. package/src/tools/apps/executors.ts +166 -50
  300. package/src/tools/filesystem/write.ts +34 -0
  301. package/src/tools/subagent/spawn.ts +2 -4
  302. package/src/tools/ui-surface/definitions.ts +25 -5
  303. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  304. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  305. package/docs/plugins.md +0 -832
  306. package/examples/plugins/echo/register.ts +0 -143
  307. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  308. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  309. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  310. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  311. package/src/__tests__/pipeline-runner.test.ts +0 -554
  312. package/src/__tests__/plugin-external-api.test.ts +0 -68
  313. package/src/daemon/wake-target-adapter.ts +0 -253
  314. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  315. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  316. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  317. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  318. package/src/plugins/defaults/compaction/register.ts +0 -35
  319. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  320. package/src/plugins/defaults/empty-response/register.ts +0 -23
  321. package/src/plugins/defaults/history-repair/register.ts +0 -24
  322. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  323. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  324. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  325. package/src/plugins/defaults/title-generate/register.ts +0 -35
  326. package/src/plugins/defaults/tool-error/register.ts +0 -23
  327. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  328. package/src/plugins/external-api.ts +0 -104
  329. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  330. package/src/proactive-artifact/decision.test.ts +0 -226
  331. package/src/proactive-artifact/decision.ts +0 -165
  332. package/src/proactive-artifact/index.ts +0 -7
  333. package/src/proactive-artifact/job.test.ts +0 -962
  334. package/src/proactive-artifact/job.ts +0 -372
  335. package/src/proactive-artifact/message-copy.ts +0 -58
  336. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  337. package/src/proactive-artifact/trigger-state.ts +0 -123
  338. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/capabilities.test.ts +0 -0
  339. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/core.test.ts +0 -0
  340. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  341. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  342. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/health.test.ts +0 -0
  343. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/needle.test.ts +0 -0
  344. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/provider-blocks.test.ts +0 -0
  345. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/snapshot.test.ts +0 -0
  346. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/tree.test.ts +0 -0
  347. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  348. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  349. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/core.ts +0 -0
  350. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  351. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  352. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  353. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  354. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  355. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  356. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/health.ts +0 -0
  357. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
  358. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/needle.ts +0 -0
  359. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/snapshot.ts +0 -0
  360. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.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,35 @@ 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 {
68
+ clearConversations,
69
+ setConversation,
70
+ } from "../daemon/conversation-registry.js";
38
71
  import type {
39
72
  ChannelCapabilities,
40
73
  SlackTranscriptInputRow,
41
- UnifiedTurnContextOptions,
42
74
  } from "../daemon/conversation-runtime-assembly.js";
43
75
  import {
44
76
  applyRuntimeInjections,
45
77
  assembleSlackActiveThreadFocusBlock,
46
78
  assembleSlackChronologicalMessages,
47
79
  buildSubagentStatusBlock,
48
- buildUnifiedTurnContextBlock,
49
80
  getSlackCompactionWatermarkForPrefix,
50
81
  injectChannelCapabilityContext,
51
82
  injectChannelCommandContext,
@@ -59,20 +90,35 @@ import {
59
90
  stripInjectionsForCompaction,
60
91
  stripNowScratchpad,
61
92
  } from "../daemon/conversation-runtime-assembly.js";
93
+ import type { SurfaceData, SurfaceType } from "../daemon/message-protocol.js";
62
94
  import { buildPkbReminder } from "../daemon/pkb-reminder-builder.js";
63
95
  import type { MessageRow } from "../memory/conversation-crud.js";
96
+ import { getDb } from "../memory/db-connection.js";
97
+ import { initializeDb } from "../memory/db-init.js";
64
98
  import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
65
99
  import { getPkbRoot } from "../memory/pkb/types.js";
100
+ import { conversations, messages } from "../memory/schema.js";
66
101
  import {
67
102
  type SlackMessageMetadata,
68
103
  writeSlackMetadata,
69
104
  } from "../messaging/providers/slack/message-metadata.js";
70
105
  import { parentAlias } from "../messaging/providers/slack/render-transcript.js";
106
+ import postCompact from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
107
+ import {
108
+ buildUnifiedTurnContextBlock,
109
+ type UnifiedTurnContextOptions,
110
+ } from "../plugins/defaults/memory-retrieval/unified-turn-context.js";
71
111
  import type { Message } from "../providers/types.js";
72
112
  import { wrapUntrustedContent } from "../security/untrusted-content.js";
113
+ import { getSubagentManager } from "../subagent/index.js";
73
114
  import type { SubagentState } from "../subagent/types.js";
74
115
  import { getWorkspacePromptPath } from "../util/platform.js";
75
116
 
117
+ // `applyRuntimeInjections` self-resolves the Slack active-thread focus block by
118
+ // reading the live conversation's persisted message rows, so the schema must
119
+ // exist before any Slack-channel assembly test runs.
120
+ initializeDb();
121
+
76
122
  // The pkb-reminder injector derives PKB-active state from the workspace itself
77
123
  // — `readPkbContext()` returning content behind the personal-memory trust gate
78
124
  // — rather than from a threaded flag. Tests that need the reminder to fire seed
@@ -103,6 +149,66 @@ function clearNowScratchpad(): void {
103
149
  rmSync(getWorkspacePromptPath("NOW.md"), { force: true });
104
150
  }
105
151
 
152
+ // The workspace-context injector sources its block off the live `Conversation`
153
+ // looked up by `conversationId`. Register a fake instance carrying both a
154
+ // non-dirty workspace context (non-null content, not dirty) so
155
+ // `resolveWorkspaceTopLevelContext` returns it verbatim without rescanning the
156
+ // filesystem, and an active dynamic-page surface, so `applyRuntimeInjections`
157
+ // resolves the `<workspace>` and `<active_workspace>` blocks from it;
158
+ // `clearConversations()` between tests keeps suites that assert the blocks are
159
+ // absent unaffected.
160
+ function seedActiveSurfaceConversation(
161
+ conversationId: string,
162
+ workspaceText: string,
163
+ surfaceId: string,
164
+ data: SurfaceData,
165
+ channelCapabilities?: ChannelCapabilities,
166
+ commandIntent?: { type: string; payload?: string; languageCode?: string },
167
+ currentTurnTemporalSnapshot?: {
168
+ clientTimezone: string | null;
169
+ },
170
+ ): void {
171
+ setConversation(conversationId, {
172
+ conversationId,
173
+ workingDir: "/sandbox",
174
+ workspaceTopLevelContext: workspaceText,
175
+ workspaceTopLevelDirty: false,
176
+ currentActiveSurfaceId: surfaceId,
177
+ surfaceState: new Map<
178
+ string,
179
+ { surfaceType: SurfaceType; data: SurfaceData }
180
+ >([[surfaceId, { surfaceType: "dynamic_page", data }]]),
181
+ channelCapabilities: channelCapabilities ?? undefined,
182
+ commandIntent,
183
+ currentTurnTemporalSnapshot,
184
+ } as never);
185
+ }
186
+
187
+ function clearWorkspaceContext(): void {
188
+ clearConversations();
189
+ }
190
+
191
+ // The `<channel_capabilities>` branch and the Slack gates source the channel
192
+ // capabilities off the live `Conversation` looked up by `conversationId`.
193
+ // Register a fake fallback instance carrying the given capabilities (and a
194
+ // non-dirty empty workspace + empty surface so the other live-sourced branches
195
+ // stay inert) so `applyRuntimeInjections` resolves them; `clearConversations()`
196
+ // between tests keeps suites asserting absence unaffected.
197
+ function seedChannelCapabilitiesConversation(
198
+ caps: ChannelCapabilities | null,
199
+ transportHints?: string[],
200
+ ): void {
201
+ setConversation("runtime-assembly-fallback", {
202
+ conversationId: "runtime-assembly-fallback",
203
+ workingDir: "/sandbox",
204
+ workspaceTopLevelContext: "",
205
+ workspaceTopLevelDirty: false,
206
+ surfaceState: new Map(),
207
+ channelCapabilities: caps ?? undefined,
208
+ transportHints,
209
+ } as never);
210
+ }
211
+
106
212
  // ---------------------------------------------------------------------------
107
213
  // resolveChannelCapabilities
108
214
  // ---------------------------------------------------------------------------
@@ -575,6 +681,8 @@ describe("stripChannelCapabilityContext", () => {
575
681
  // ---------------------------------------------------------------------------
576
682
 
577
683
  describe("applyRuntimeInjections with channelCapabilities", () => {
684
+ afterEach(clearConversations);
685
+
578
686
  const baseMessages: Message[] = [
579
687
  {
580
688
  role: "user",
@@ -590,8 +698,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
590
698
  supportsVoiceInput: false,
591
699
  };
592
700
 
701
+ seedChannelCapabilitiesConversation(caps);
593
702
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
594
- channelCapabilities: caps,
703
+ conversationId: FALLBACK_CONVERSATION_ID,
595
704
  });
596
705
 
597
706
  expect(result.length).toBe(1);
@@ -603,8 +712,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
603
712
  });
604
713
 
605
714
  test("does not inject when channelCapabilities is null", async () => {
715
+ seedChannelCapabilitiesConversation(null);
606
716
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
607
- channelCapabilities: null,
717
+ conversationId: FALLBACK_CONVERSATION_ID,
608
718
  });
609
719
 
610
720
  expect(result.length).toBe(1);
@@ -612,7 +722,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
612
722
  });
613
723
 
614
724
  test("does not inject when channelCapabilities is omitted", async () => {
615
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
725
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
726
+ conversationId: FALLBACK_CONVERSATION_ID,
727
+ });
616
728
 
617
729
  expect(result.length).toBe(1);
618
730
  expect(result[0].content.length).toBe(1);
@@ -626,8 +738,9 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
626
738
  supportsVoiceInput: false,
627
739
  };
628
740
 
741
+ seedChannelCapabilitiesConversation(caps);
629
742
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
630
- channelCapabilities: caps,
743
+ conversationId: FALLBACK_CONVERSATION_ID,
631
744
  });
632
745
 
633
746
  expect(result.length).toBe(1);
@@ -755,30 +868,25 @@ describe("applyRuntimeInjections — injection mode", () => {
755
868
  },
756
869
  ];
757
870
 
871
+ const channelCapabilities: ChannelCapabilities = {
872
+ channel: "telegram",
873
+ dashboardCapable: false,
874
+ supportsDynamicUi: false,
875
+ supportsVoiceInput: false,
876
+ };
877
+
758
878
  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>",
879
+ // Non-interactive turn so the `<non_interactive_context>` branch fires.
770
880
  isNonInteractive: true,
881
+ requestId: "injection-mode-req",
882
+ conversationId: "injection-mode-conv",
883
+ turnIndex: 0,
771
884
  // Guardian trust so the personal-memory gate admits the actor regardless
772
885
  // of the telegram channel capabilities under test, letting the reminder
773
886
  // 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
- },
887
+ trust: {
888
+ sourceChannel: "vellum" as const,
889
+ trustClass: "guardian" as const,
782
890
  },
783
891
  };
784
892
 
@@ -788,10 +896,22 @@ describe("applyRuntimeInjections — injection mode", () => {
788
896
  beforeEach(() => {
789
897
  seedPkbContent();
790
898
  seedNowScratchpad("Current focus: shipping PR 3");
899
+ seedActiveSurfaceConversation(
900
+ "injection-mode-conv",
901
+ "<workspace>\nRoot: /sandbox\n</workspace>",
902
+ "sf_1",
903
+ { html: "<div>test</div>" },
904
+ channelCapabilities,
905
+ { type: "start" },
906
+ {
907
+ clientTimezone: null,
908
+ },
909
+ );
791
910
  });
792
911
  afterEach(() => {
793
912
  clearPkbContent();
794
913
  clearNowScratchpad();
914
+ clearWorkspaceContext();
795
915
  });
796
916
 
797
917
  test("full mode (default) includes all injections", async () => {
@@ -898,6 +1018,34 @@ describe("applyRuntimeInjections — injection mode", () => {
898
1018
 
899
1019
  expect(texts).toContain("Hello");
900
1020
  });
1021
+
1022
+ test("post-compaction re-injection resolves the live conversation from the conversation id", async () => {
1023
+ // GIVEN the seeded live conversation `injection-mode-conv` whose
1024
+ // conversation-scoped blocks (`<active_workspace>`, `<channel_capabilities>`)
1025
+ // only resolve when `applyRuntimeInjections` finds it by conversation id
1026
+ // AND the agent loop hands the post-compaction hook the turn-identity fields
1027
+ // flat (`requestId`, `conversationId`, `trust`)
1028
+ const { messages: result } = await postCompact({
1029
+ history: baseMessages,
1030
+ requestId: "reinject-req",
1031
+ conversationId: "injection-mode-conv",
1032
+ trust: { sourceChannel: "vellum", trustClass: "guardian" },
1033
+ isNonInteractive: false,
1034
+ mode: "full",
1035
+ modelProfile: null,
1036
+ actorContext: null,
1037
+ });
1038
+ const allText = result[0].content
1039
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1040
+ .map((b) => b.text)
1041
+ .join("\n");
1042
+
1043
+ // THEN the hook forwards `conversationId` onto the flat injection options,
1044
+ // so the re-injection resolves the same live conversation as the turn's
1045
+ // initial assembly rather than the synthetic fallback.
1046
+ expect(allText).toContain("<active_workspace>");
1047
+ expect(allText).toContain("<channel_capabilities>");
1048
+ });
901
1049
  });
902
1050
 
903
1051
  // The now-md default injector emits the `<NOW.md>` block as an
@@ -1051,7 +1199,7 @@ describe("stripInjectionsForCompaction preserves persistent blocks", () => {
1051
1199
  ).toContain("<turn_context>");
1052
1200
  });
1053
1201
 
1054
- test("<workspace> blocks are NOT stripped", () => {
1202
+ test("<workspace> blocks ARE stripped so the injector re-sources them post-compaction", () => {
1055
1203
  const messages: Message[] = [
1056
1204
  {
1057
1205
  role: "user",
@@ -1067,10 +1215,10 @@ describe("stripInjectionsForCompaction preserves persistent blocks", () => {
1067
1215
 
1068
1216
  const result = stripInjectionsForCompaction(messages);
1069
1217
  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>");
1218
+ expect(result[0].content.length).toBe(1);
1219
+ expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
1220
+ "Hello",
1221
+ );
1074
1222
  });
1075
1223
 
1076
1224
  test("legacy <workspace_top_level> blocks ARE stripped for backward compat", () => {
@@ -1237,7 +1385,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1237
1385
 
1238
1386
  test("injects NOW.md block when the file has content", async () => {
1239
1387
  seedNowScratchpad("Current focus: fix the bug");
1240
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1388
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1389
+ conversationId: FALLBACK_CONVERSATION_ID,
1390
+ });
1241
1391
 
1242
1392
  expect(result.length).toBe(1);
1243
1393
  expect(result[0].content.length).toBe(2);
@@ -1249,7 +1399,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1249
1399
 
1250
1400
  test("scratchpad appears before user's original text content", async () => {
1251
1401
  seedNowScratchpad("scratchpad notes");
1252
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1402
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1403
+ conversationId: FALLBACK_CONVERSATION_ID,
1404
+ });
1253
1405
 
1254
1406
  // Scratchpad comes first (before user content)
1255
1407
  expect(
@@ -1262,7 +1414,9 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1262
1414
  });
1263
1415
 
1264
1416
  test("does not inject when the NOW.md file is absent", async () => {
1265
- const { messages: result } = await applyRuntimeInjections(baseMessages, {});
1417
+ const { messages: result } = await applyRuntimeInjections(baseMessages, {
1418
+ conversationId: FALLBACK_CONVERSATION_ID,
1419
+ });
1266
1420
 
1267
1421
  expect(result.length).toBe(1);
1268
1422
  expect(result[0].content.length).toBe(1);
@@ -1271,6 +1425,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1271
1425
  test("skipped in minimal mode", async () => {
1272
1426
  seedNowScratchpad("Current focus: fix the bug");
1273
1427
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1428
+ conversationId: FALLBACK_CONVERSATION_ID,
1274
1429
  mode: "minimal",
1275
1430
  });
1276
1431
 
@@ -1694,6 +1849,8 @@ describe("buildUnifiedTurnContextBlock", () => {
1694
1849
  // ---------------------------------------------------------------------------
1695
1850
 
1696
1851
  describe("applyRuntimeInjections with unifiedTurnContext", () => {
1852
+ afterEach(clearConversations);
1853
+
1697
1854
  const baseMessages: Message[] = [
1698
1855
  {
1699
1856
  role: "user",
@@ -1701,12 +1858,42 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1701
1858
  },
1702
1859
  ];
1703
1860
 
1704
- const sampleBlock =
1705
- "<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
1861
+ const sampleOptions = {
1862
+ interfaceName: "macos",
1863
+ };
1864
+ // The block reflects the options-bag interface and the `current_time` the
1865
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
1866
+ const sampleBlock = buildUnifiedTurnContextBlock({
1867
+ timestamp: FIXED_TURN_TIMESTAMP,
1868
+ ...sampleOptions,
1869
+ });
1870
+
1871
+ // Seed the live fallback conversation with the per-turn temporal snapshot
1872
+ // (its presence gates the `<turn_context>` block) and the turn interface
1873
+ // context (so the injector sources the interface label to match
1874
+ // `sampleOptions.interfaceName`).
1875
+ function seedTemporalSnapshot(): void {
1876
+ setConversation("runtime-assembly-fallback", {
1877
+ conversationId: "runtime-assembly-fallback",
1878
+ workingDir: "/sandbox",
1879
+ workspaceTopLevelContext: "",
1880
+ workspaceTopLevelDirty: false,
1881
+ surfaceState: new Map(),
1882
+ currentTurnTemporalSnapshot: {
1883
+ clientTimezone: null,
1884
+ },
1885
+ currentTurnInterfaceContext: {
1886
+ userMessageInterface: "macos",
1887
+ assistantMessageInterface: "macos",
1888
+ },
1889
+ } as never);
1890
+ }
1891
+
1892
+ test("injects the turn-context block when the inputs are provided", async () => {
1893
+ seedTemporalSnapshot();
1706
1894
 
1707
- test("injects unifiedTurnContext when provided", async () => {
1708
1895
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1709
- unifiedTurnContext: sampleBlock,
1896
+ conversationId: FALLBACK_CONVERSATION_ID,
1710
1897
  });
1711
1898
 
1712
1899
  expect(result).toHaveLength(1);
@@ -1720,25 +1907,21 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1720
1907
  );
1721
1908
  });
1722
1909
 
1723
- test("does not inject when unifiedTurnContext is null", async () => {
1910
+ test("does not inject when the timestamp snapshot is absent", async () => {
1724
1911
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1725
- unifiedTurnContext: null,
1912
+ conversationId: FALLBACK_CONVERSATION_ID,
1726
1913
  });
1727
1914
 
1728
1915
  expect(result).toHaveLength(1);
1729
1916
  expect(result[0].content).toHaveLength(1);
1730
1917
  });
1731
1918
 
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
1919
  test("injected in full mode", async () => {
1920
+ seedTemporalSnapshot();
1921
+
1740
1922
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1741
- unifiedTurnContext: sampleBlock,
1923
+ conversationId: FALLBACK_CONVERSATION_ID,
1924
+ ...sampleOptions,
1742
1925
  mode: "full",
1743
1926
  });
1744
1927
 
@@ -1751,8 +1934,11 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1751
1934
  });
1752
1935
 
1753
1936
  test("injected in minimal mode (no mode guard)", async () => {
1937
+ seedTemporalSnapshot();
1938
+
1754
1939
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1755
- unifiedTurnContext: sampleBlock,
1940
+ conversationId: FALLBACK_CONVERSATION_ID,
1941
+ ...sampleOptions,
1756
1942
  mode: "minimal",
1757
1943
  });
1758
1944
 
@@ -1765,11 +1951,168 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1765
1951
  });
1766
1952
  });
1767
1953
 
1954
+ // ---------------------------------------------------------------------------
1955
+ // applyRuntimeInjections self-resolves the config timezones
1956
+ // ---------------------------------------------------------------------------
1957
+
1958
+ describe("applyRuntimeInjections timezone resolution", () => {
1959
+ afterEach(() => {
1960
+ assemblyConfiguredUserTimezone = undefined;
1961
+ assemblyDetectedTimezone = undefined;
1962
+ clearConversations();
1963
+ });
1964
+
1965
+ const baseMessages: Message[] = [
1966
+ { role: "user", content: [{ type: "text", text: "Hello there" }] },
1967
+ ];
1968
+
1969
+ // Seed the live fallback conversation with the per-turn temporal snapshot the
1970
+ // loop freezes after memory retrieval. `applyRuntimeInjections` sources the
1971
+ // client timezone and the long-absence gap from it, so the turn-context block
1972
+ // only emits when this snapshot is present.
1973
+ function seedTemporalSnapshot(
1974
+ clientTimezone: string | null,
1975
+ timeSinceLastMessage: string | null = null,
1976
+ ): void {
1977
+ setConversation("runtime-assembly-fallback", {
1978
+ conversationId: "runtime-assembly-fallback",
1979
+ workingDir: "/sandbox",
1980
+ workspaceTopLevelContext: "",
1981
+ workspaceTopLevelDirty: false,
1982
+ surfaceState: new Map(),
1983
+ currentTurnTemporalSnapshot: {
1984
+ clientTimezone,
1985
+ timeSinceLastMessage,
1986
+ },
1987
+ } as never);
1988
+ }
1989
+
1990
+ function injectedText(result: { messages: Message[] }): string {
1991
+ return result.messages[0].content
1992
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1993
+ .map((b) => b.text)
1994
+ .join("\n");
1995
+ }
1996
+
1997
+ test("sources the configured and detected timezones from config, not the options bag", async () => {
1998
+ /**
1999
+ * Verifies the re-homed configured user timezone and detected-timezone
2000
+ * fallback are read from `config.ui` inside `applyRuntimeInjections`
2001
+ * rather than threaded through the options bag. The detected timezone is
2002
+ * the device-timezone fallback used when the client reports none.
2003
+ */
2004
+ // GIVEN the guardian has configured a user timezone in config
2005
+ assemblyConfiguredUserTimezone = "America/New_York";
2006
+
2007
+ // AND a different server-detected timezone is configured
2008
+ assemblyDetectedTimezone = "America/Los_Angeles";
2009
+
2010
+ // AND the turn's frozen snapshot carries no client timezone (so the
2011
+ // detected timezone is the device-timezone fallback)
2012
+ seedTemporalSnapshot(null);
2013
+
2014
+ // WHEN a turn is assembled
2015
+ const result = await applyRuntimeInjections(baseMessages, {
2016
+ conversationId: FALLBACK_CONVERSATION_ID,
2017
+ });
2018
+
2019
+ // THEN the injector surfaces the mismatch using the config-sourced values
2020
+ const injected = injectedText(result);
2021
+ expect(injected).toContain("configured_user_timezone: America/New_York");
2022
+ expect(injected).toContain("client_device_timezone: America/Los_Angeles");
2023
+ });
2024
+
2025
+ test("sources clientTimezone from the conversation's frozen turn snapshot", async () => {
2026
+ /**
2027
+ * Verifies the per-turn client timezone is taken from the conversation's
2028
+ * frozen `currentTurnTemporalSnapshot` and takes precedence over the
2029
+ * config-sourced detected-timezone fallback.
2030
+ */
2031
+ // GIVEN the configured user timezone and a detected fallback in config
2032
+ assemblyConfiguredUserTimezone = "America/New_York";
2033
+ assemblyDetectedTimezone = "America/Denver";
2034
+
2035
+ // AND the turn's frozen snapshot carries a client timezone
2036
+ seedTemporalSnapshot("America/Los_Angeles");
2037
+
2038
+ // WHEN a turn is assembled
2039
+ const result = await applyRuntimeInjections(baseMessages, {
2040
+ conversationId: FALLBACK_CONVERSATION_ID,
2041
+ });
2042
+
2043
+ // THEN the injector surfaces the mismatch using the snapshot's client value
2044
+ const injected = injectedText(result);
2045
+ expect(injected).toContain("configured_user_timezone: America/New_York");
2046
+ expect(injected).toContain("client_device_timezone: America/Los_Angeles");
2047
+ });
2048
+
2049
+ test("omits the mismatch lines when config and client timezone agree", async () => {
2050
+ /**
2051
+ * Verifies no mismatch affordance is emitted when the config-sourced
2052
+ * configured user timezone matches the snapshot's client timezone.
2053
+ */
2054
+ // GIVEN the configured user timezone matches the client timezone
2055
+ assemblyConfiguredUserTimezone = "America/New_York";
2056
+
2057
+ // AND the turn's frozen snapshot carries the matching client timezone
2058
+ seedTemporalSnapshot("America/New_York");
2059
+
2060
+ // WHEN a turn is assembled
2061
+ const result = await applyRuntimeInjections(baseMessages, {
2062
+ conversationId: FALLBACK_CONVERSATION_ID,
2063
+ });
2064
+
2065
+ // THEN no timezone-mismatch lines are injected
2066
+ const injected = injectedText(result);
2067
+ expect(injected).not.toContain("configured_user_timezone:");
2068
+ expect(injected).not.toContain("client_device_timezone:");
2069
+ });
2070
+
2071
+ test("sources time_since_last_message from the conversation's frozen turn snapshot", async () => {
2072
+ /**
2073
+ * Verifies the long-absence gap is taken from the conversation's frozen
2074
+ * `currentTurnTemporalSnapshot` rather than threaded through the options
2075
+ * bag, so every post-compaction re-injection reuses the turn-start value.
2076
+ */
2077
+ // GIVEN the turn's frozen snapshot carries a long-absence gap
2078
+ seedTemporalSnapshot(null, "2d ago");
2079
+
2080
+ // WHEN a turn is assembled without the gap in the options bag
2081
+ const result = await applyRuntimeInjections(baseMessages, {
2082
+ conversationId: FALLBACK_CONVERSATION_ID,
2083
+ });
2084
+
2085
+ // THEN the injector surfaces the gap from the snapshot
2086
+ const injected = injectedText(result);
2087
+ expect(injected).toContain("time_since_last_message: 2d ago");
2088
+ });
2089
+
2090
+ test("omits time_since_last_message when the snapshot carries no gap", async () => {
2091
+ /**
2092
+ * Verifies no `time_since_last_message` line is emitted when the frozen
2093
+ * snapshot's gap is null (the normal back-and-forth case).
2094
+ */
2095
+ // GIVEN the turn's frozen snapshot carries no long-absence gap
2096
+ seedTemporalSnapshot(null, null);
2097
+
2098
+ // WHEN a turn is assembled
2099
+ const result = await applyRuntimeInjections(baseMessages, {
2100
+ conversationId: FALLBACK_CONVERSATION_ID,
2101
+ });
2102
+
2103
+ // THEN no gap line is injected
2104
+ const injected = injectedText(result);
2105
+ expect(injected).not.toContain("time_since_last_message");
2106
+ });
2107
+ });
2108
+
1768
2109
  // ---------------------------------------------------------------------------
1769
2110
  // applyRuntimeInjections blocks.unifiedTurnContext
1770
2111
  // ---------------------------------------------------------------------------
1771
2112
 
1772
2113
  describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
2114
+ afterEach(clearConversations);
2115
+
1773
2116
  const userTailMessages: Message[] = [
1774
2117
  {
1775
2118
  role: "user",
@@ -1777,18 +2120,50 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
1777
2120
  },
1778
2121
  ];
1779
2122
 
1780
- const sampleBlock =
1781
- "<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
2123
+ const sampleOptions = {
2124
+ interfaceName: "macos",
2125
+ };
2126
+ // The block reflects the options-bag interface and the `current_time` the
2127
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
2128
+ const sampleBlock = buildUnifiedTurnContextBlock({
2129
+ timestamp: FIXED_TURN_TIMESTAMP,
2130
+ ...sampleOptions,
2131
+ });
2132
+
2133
+ // Seed the live fallback conversation with the per-turn temporal snapshot
2134
+ // (its presence gates the `<turn_context>` block) and the turn interface
2135
+ // context (so the injector sources the interface label to match
2136
+ // `sampleOptions.interfaceName`).
2137
+ function seedTemporalSnapshot(): void {
2138
+ setConversation("runtime-assembly-fallback", {
2139
+ conversationId: "runtime-assembly-fallback",
2140
+ workingDir: "/sandbox",
2141
+ workspaceTopLevelContext: "",
2142
+ workspaceTopLevelDirty: false,
2143
+ surfaceState: new Map(),
2144
+ currentTurnTemporalSnapshot: {
2145
+ clientTimezone: null,
2146
+ },
2147
+ currentTurnInterfaceContext: {
2148
+ userMessageInterface: "macos",
2149
+ assistantMessageInterface: "macos",
2150
+ },
2151
+ } as never);
2152
+ }
1782
2153
 
1783
2154
  test("captures unifiedTurnContext when tail is a user message", async () => {
2155
+ seedTemporalSnapshot();
2156
+
1784
2157
  const result = await applyRuntimeInjections(userTailMessages, {
1785
- unifiedTurnContext: sampleBlock,
2158
+ conversationId: FALLBACK_CONVERSATION_ID,
1786
2159
  });
1787
2160
 
1788
2161
  expect(result.blocks.unifiedTurnContext).toBe(sampleBlock);
1789
2162
  });
1790
2163
 
1791
2164
  test("does not capture when tail is not a user message", async () => {
2165
+ seedTemporalSnapshot();
2166
+
1792
2167
  const assistantTailMessages: Message[] = [
1793
2168
  {
1794
2169
  role: "user",
@@ -1801,14 +2176,16 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
1801
2176
  ];
1802
2177
 
1803
2178
  const result = await applyRuntimeInjections(assistantTailMessages, {
1804
- unifiedTurnContext: sampleBlock,
2179
+ conversationId: FALLBACK_CONVERSATION_ID,
1805
2180
  });
1806
2181
 
1807
2182
  expect(result.blocks.unifiedTurnContext).toBeUndefined();
1808
2183
  });
1809
2184
 
1810
2185
  test("does not capture when unifiedTurnContext option is absent", async () => {
1811
- const result = await applyRuntimeInjections(userTailMessages, {});
2186
+ const result = await applyRuntimeInjections(userTailMessages, {
2187
+ conversationId: FALLBACK_CONVERSATION_ID,
2188
+ });
1812
2189
 
1813
2190
  expect(result.blocks.unifiedTurnContext).toBeUndefined();
1814
2191
  });
@@ -1844,6 +2221,35 @@ function makeSubagentState(
1844
2221
  };
1845
2222
  }
1846
2223
 
2224
+ // `applyRuntimeInjections` self-resolves the `<active_subagents>` block from the
2225
+ // global subagent manager keyed by the conversation, so tests seed children
2226
+ // directly into the manager's private maps (mirroring the production source)
2227
+ // rather than threading a pre-built block through options.
2228
+ interface SubagentManagerTestInternals {
2229
+ subagents: Map<string, { state: SubagentState }>;
2230
+ parentToChildren: Map<string, Set<string>>;
2231
+ }
2232
+
2233
+ function seedSubagentChild(
2234
+ parentConversationId: string,
2235
+ state: SubagentState,
2236
+ ): void {
2237
+ const internals =
2238
+ getSubagentManager() as unknown as SubagentManagerTestInternals;
2239
+ internals.subagents.set(state.config.id, { state });
2240
+ const ids =
2241
+ internals.parentToChildren.get(parentConversationId) ?? new Set<string>();
2242
+ ids.add(state.config.id);
2243
+ internals.parentToChildren.set(parentConversationId, ids);
2244
+ }
2245
+
2246
+ function clearSeededSubagents(): void {
2247
+ const internals =
2248
+ getSubagentManager() as unknown as SubagentManagerTestInternals;
2249
+ internals.subagents.clear();
2250
+ internals.parentToChildren.clear();
2251
+ }
2252
+
1847
2253
  describe("buildSubagentStatusBlock", () => {
1848
2254
  test("returns null for empty children array", () => {
1849
2255
  expect(buildSubagentStatusBlock([])).toBeNull();
@@ -1921,25 +2327,78 @@ describe("applyRuntimeInjections — subagent status", () => {
1921
2327
  content: [{ type: "text", text: "user message" }],
1922
2328
  };
1923
2329
 
1924
- test("includes subagent status in full mode", async () => {
2330
+ afterEach(() => {
2331
+ clearSeededSubagents();
2332
+ clearConversations();
2333
+ });
2334
+
2335
+ test("includes self-resolved subagent status in full mode", async () => {
2336
+ // GIVEN the (fallback) parent conversation has one running child subagent
2337
+ seedSubagentChild(
2338
+ "runtime-assembly-fallback",
2339
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2340
+ );
2341
+
2342
+ // WHEN runtime injections run in full mode
1925
2343
  const { messages: result } = await applyRuntimeInjections([userMsg], {
1926
- subagentStatusBlock:
1927
- "<active_subagents>\n- [running] test\n</active_subagents>",
2344
+ conversationId: FALLBACK_CONVERSATION_ID,
1928
2345
  mode: "full",
1929
2346
  });
2347
+
2348
+ // THEN the tail user message carries the `<active_subagents>` block the
2349
+ // injector self-resolved from the subagent manager
1930
2350
  const tail = result[result.length - 1];
1931
2351
  const texts = tail.content
1932
2352
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
1933
2353
  .map((b) => b.text);
1934
2354
  expect(texts.some((t) => t.includes("<active_subagents>"))).toBe(true);
2355
+ expect(texts.some((t) => t.includes('"worker"'))).toBe(true);
1935
2356
  });
1936
2357
 
1937
2358
  test("skips subagent status in minimal mode", async () => {
2359
+ // GIVEN the (fallback) parent conversation has one running child subagent
2360
+ seedSubagentChild(
2361
+ "runtime-assembly-fallback",
2362
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2363
+ );
2364
+
2365
+ // WHEN runtime injections run in minimal mode
1938
2366
  const { messages: result } = await applyRuntimeInjections([userMsg], {
1939
- subagentStatusBlock:
1940
- "<active_subagents>\n- [running] test\n</active_subagents>",
2367
+ conversationId: FALLBACK_CONVERSATION_ID,
1941
2368
  mode: "minimal",
1942
2369
  });
2370
+
2371
+ // THEN the subagent status block is gated out
2372
+ const tail = result[result.length - 1];
2373
+ const texts = tail.content
2374
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
2375
+ .map((b) => b.text);
2376
+ expect(texts.some((t) => t.includes("<active_subagents>"))).toBe(false);
2377
+ });
2378
+
2379
+ test("skips subagent status when the conversation is itself a subagent", async () => {
2380
+ // GIVEN the live conversation is itself a subagent (no nesting)
2381
+ setConversation("runtime-assembly-fallback", {
2382
+ conversationId: "runtime-assembly-fallback",
2383
+ workingDir: "/sandbox",
2384
+ workspaceTopLevelContext: "",
2385
+ workspaceTopLevelDirty: false,
2386
+ surfaceState: new Map(),
2387
+ isSubagent: true,
2388
+ } as never);
2389
+ // AND a child is registered under its id
2390
+ seedSubagentChild(
2391
+ "runtime-assembly-fallback",
2392
+ makeSubagentState({ id: "child-1", label: "worker", status: "running" }),
2393
+ );
2394
+
2395
+ // WHEN runtime injections run in full mode
2396
+ const { messages: result } = await applyRuntimeInjections([userMsg], {
2397
+ conversationId: FALLBACK_CONVERSATION_ID,
2398
+ mode: "full",
2399
+ });
2400
+
2401
+ // THEN no `<active_subagents>` block is emitted
1943
2402
  const tail = result[result.length - 1];
1944
2403
  const texts = tail.content
1945
2404
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
@@ -2012,6 +2471,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2012
2471
 
2013
2472
  function makePkbOptions(overrides: Record<string, unknown> = {}) {
2014
2473
  return {
2474
+ conversationId: FALLBACK_CONVERSATION_ID,
2015
2475
  ...overrides,
2016
2476
  };
2017
2477
  }
@@ -2298,7 +2758,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2298
2758
  // filtered out.
2299
2759
  const { messages: initialResult } = await applyRuntimeInjections(
2300
2760
  preCompactionMessages,
2301
- {},
2761
+ { conversationId: FALLBACK_CONVERSATION_ID },
2302
2762
  );
2303
2763
  // Unwrap the injected reminder from the last user message.
2304
2764
  const initialTexts = extractTexts(initialResult);
@@ -2332,7 +2792,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2332
2792
  // should be filtered, and beta (no longer "in context") should appear.
2333
2793
  const { messages: rebuiltResult } = await applyRuntimeInjections(
2334
2794
  postCompactionMessages,
2335
- {},
2795
+ { conversationId: FALLBACK_CONVERSATION_ID },
2336
2796
  );
2337
2797
  const rebuiltTexts = extractTexts(rebuiltResult);
2338
2798
  const rebuiltReminder = rebuiltTexts.find(
@@ -2352,6 +2812,19 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2352
2812
  // ---------------------------------------------------------------------------
2353
2813
 
2354
2814
  describe("Slack channel chronological rendering — multi-thread", () => {
2815
+ afterEach(() => {
2816
+ clearConversations();
2817
+ // The focus-block tests persist rows under the runtime-assembly fallback
2818
+ // conversation; clear them so later tests resolve against an empty DB.
2819
+ const db = getDb();
2820
+ db.delete(messages)
2821
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
2822
+ .run();
2823
+ db.delete(conversations)
2824
+ .where(eq(conversations.id, "runtime-assembly-fallback"))
2825
+ .run();
2826
+ });
2827
+
2355
2828
  // Slack ts values are seconds-since-epoch with microsecond precision.
2356
2829
  // Pick a few stable anchors so thread aliases (sha-derived) stay
2357
2830
  // predictable across the scenarios.
@@ -2395,6 +2868,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2395
2868
  content: JSON.stringify([{ type: "text", text: opts.text }]),
2396
2869
  createdAt: opts.createdAt,
2397
2870
  metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
2871
+ clientMessageId: null,
2398
2872
  };
2399
2873
  }
2400
2874
 
@@ -2413,6 +2887,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2413
2887
  content: JSON.stringify([{ type: "text", text: opts.text }]),
2414
2888
  createdAt: opts.createdAt,
2415
2889
  metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
2890
+ clientMessageId: null,
2416
2891
  };
2417
2892
  }
2418
2893
 
@@ -2428,18 +2903,16 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2428
2903
  supportsVoiceInput: false,
2429
2904
  chatType: "channel",
2430
2905
  };
2431
- const slackChronologicalMessages = loadSlackChronologicalMessages(
2432
- "conv-1",
2433
- slackChannelCaps,
2434
- { loader: () => rows, trustClass: "guardian" },
2435
- );
2436
2906
  const lastUserMessage: Message = {
2437
2907
  role: "user",
2438
2908
  content: [{ type: "text", text: "current turn" }],
2439
2909
  };
2910
+ // Persist the rows + live conversation so `applyRuntimeInjections`
2911
+ // self-resolves the chronological transcript from conversation state,
2912
+ // exactly as production does.
2913
+ seedSlackChannelConversationWithRows(slackChannelCaps, rows);
2440
2914
  const { messages } = await applyRuntimeInjections([lastUserMessage], {
2441
- channelCapabilities: slackChannelCaps,
2442
- slackChronologicalMessages,
2915
+ conversationId: FALLBACK_CONVERSATION_ID,
2443
2916
  });
2444
2917
  return messages;
2445
2918
  }
@@ -2726,24 +3199,15 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2726
3199
  role: "user",
2727
3200
  content: [{ type: "text", text: "vellum question" }],
2728
3201
  };
3202
+ seedChannelCapabilitiesConversation({
3203
+ channel: "vellum",
3204
+ dashboardCapable: true,
3205
+ supportsDynamicUi: true,
3206
+ supportsVoiceInput: true,
3207
+ });
2729
3208
  const { messages: result } = await applyRuntimeInjections(
2730
3209
  [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
- },
3210
+ { conversationId: FALLBACK_CONVERSATION_ID },
2747
3211
  );
2748
3212
  expect(result.length).toBe(1);
2749
3213
  const allText = result[0].content
@@ -2751,7 +3215,6 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2751
3215
  .map((b) => b.text)
2752
3216
  .join("\n");
2753
3217
  expect(allText).toContain("vellum question");
2754
- expect(allText).not.toContain("should not appear");
2755
3218
  });
2756
3219
 
2757
3220
  // ── DMs (chatType === "im") use chronological rendering ────────────────
@@ -2764,36 +3227,38 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2764
3227
  role: "user",
2765
3228
  content: [{ type: "text", text: "DM question" }],
2766
3229
  };
2767
- const { messages: result } = await applyRuntimeInjections(
2768
- [lastUserMessage],
3230
+ const rows: MessageRow[] = [
3231
+ userRow({
3232
+ id: "dm-1",
3233
+ createdAt: 1700000000_000,
3234
+ text: "earlier DM line",
3235
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3236
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3237
+ }),
3238
+ assistantRow({
3239
+ id: "dm-2",
3240
+ createdAt: 1700000005_000,
3241
+ text: "prior reply",
3242
+ slackMeta: buildSlackMeta({ channelTs: T0_REPLY1 }),
3243
+ }),
3244
+ ];
3245
+ seedSlackChannelConversationWithRows(
2769
3246
  {
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
- ],
3247
+ channel: "slack",
3248
+ dashboardCapable: false,
3249
+ supportsDynamicUi: false,
3250
+ supportsVoiceInput: false,
3251
+ chatType: "im",
2792
3252
  },
3253
+ rows,
2793
3254
  );
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).
3255
+ const { messages: result } = await applyRuntimeInjections(
3256
+ [lastUserMessage],
3257
+ { conversationId: FALLBACK_CONVERSATION_ID },
3258
+ );
3259
+ // The self-resolved chronological transcript REPLACES the default
3260
+ // runMessages, so the inbound `DM question` text does not appear — only
3261
+ // the rendered transcript lines do (plus any non-Slack injections).
2797
3262
  const allText = result
2798
3263
  .flatMap((m) => m.content)
2799
3264
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
@@ -2804,6 +3269,62 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2804
3269
  expect(allText).not.toContain("DM question");
2805
3270
  });
2806
3271
 
3272
+ // ── Compaction boundary scopes the self-resolved transcript ──────────
3273
+ // The transcript self-resolves from persisted rows scoped by the
3274
+ // conversation's Slack compaction boundary. Rows at or before the
3275
+ // persisted watermark were folded into the summary, so a fresh load
3276
+ // excludes them and surfaces only the post-watermark tail — returning
3277
+ // the compacted view rather than resurrecting folded history.
3278
+ test("self-resolved transcript respects the Slack compaction watermark", async () => {
3279
+ const slackCaps: ChannelCapabilities = {
3280
+ channel: "slack",
3281
+ dashboardCapable: false,
3282
+ supportsDynamicUi: false,
3283
+ supportsVoiceInput: false,
3284
+ chatType: "channel",
3285
+ };
3286
+ const rows: MessageRow[] = [
3287
+ userRow({
3288
+ id: "folded",
3289
+ createdAt: 1700000000_000,
3290
+ text: "folded pre-compaction line",
3291
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3292
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3293
+ }),
3294
+ userRow({
3295
+ id: "fresh",
3296
+ createdAt: 1700000030_000,
3297
+ text: "fresh post-compaction line",
3298
+ slackMeta: buildSlackMeta({ channelTs: T2, displayName: "bob" }),
3299
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3300
+ }),
3301
+ ];
3302
+ // A persisted watermark at T0 folds the first row into the summary.
3303
+ seedSlackChannelConversationWithRows(slackCaps, rows, {
3304
+ slackContextCompactionWatermarkTs: T0,
3305
+ contextCompactedMessageCount: 1,
3306
+ });
3307
+ const { messages: result } = await applyRuntimeInjections(
3308
+ [
3309
+ {
3310
+ role: "user",
3311
+ content: [{ type: "text", text: "post-compaction question" }],
3312
+ },
3313
+ ],
3314
+ { conversationId: FALLBACK_CONVERSATION_ID },
3315
+ );
3316
+ const allText = result
3317
+ .flatMap((m) => m.content)
3318
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
3319
+ .map((b) => b.text)
3320
+ .join("\n");
3321
+ // The folded row stays in the summary; only the post-watermark tail is
3322
+ // surfaced, and it replaces the default runMessages.
3323
+ expect(allText).toContain("fresh post-compaction line");
3324
+ expect(allText).not.toContain("folded pre-compaction line");
3325
+ expect(allText).not.toContain("post-compaction question");
3326
+ });
3327
+
2807
3328
  // ── Memory-injection carry-through on slack replacement ──────────────
2808
3329
  // `graphMemory.prepareMemory` prepends `<memory __injected>` (and
2809
3330
  // optional memory-image groups) to the last user message BEFORE the
@@ -2828,21 +3349,23 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2828
3349
  type: "text",
2829
3350
  text: "<memory __injected>\nrecalled fact about the user\n</memory>",
2830
3351
  },
2831
- { type: "text", text: "hello there" },
3352
+ { type: "text", text: "original turn text" },
2832
3353
  ],
2833
3354
  },
2834
3355
  ];
3356
+ const rows: MessageRow[] = [
3357
+ userRow({
3358
+ id: "mem-1",
3359
+ createdAt: 1700000000_000,
3360
+ text: "recalled conversation line",
3361
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3362
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3363
+ }),
3364
+ ];
3365
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2835
3366
  const { messages: result } = await applyRuntimeInjections(
2836
3367
  runMessagesWithMemory,
2837
- {
2838
- channelCapabilities: slackCaps,
2839
- slackChronologicalMessages: [
2840
- {
2841
- role: "user",
2842
- content: [{ type: "text", text: "[19:55 alice]: hello there" }],
2843
- },
2844
- ],
2845
- },
3368
+ { conversationId: FALLBACK_CONVERSATION_ID },
2846
3369
  );
2847
3370
  const tail = result[result.length - 1];
2848
3371
  expect(tail.role).toBe("user");
@@ -2852,15 +3375,15 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2852
3375
  .join("\n");
2853
3376
  expect(allText).toContain("<memory __injected>");
2854
3377
  expect(allText).toContain("recalled fact about the user");
2855
- expect(allText).toContain("[19:55 alice]: hello there");
3378
+ expect(allText).toContain("recalled conversation line");
2856
3379
  // Memory block must appear before the Slack transcript tail so the
2857
3380
  // model sees recalled context ahead of the conversation view.
2858
3381
  const memoryIdx = allText.indexOf("<memory __injected>");
2859
- const transcriptIdx = allText.indexOf("[19:55 alice]: hello there");
3382
+ const transcriptIdx = allText.indexOf("recalled conversation line");
2860
3383
  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);
3384
+ // The pre-replacement inbound turn text must NOT leak through — only the
3385
+ // carried memory prefix + the self-resolved transcript tail remain.
3386
+ expect(allText).not.toContain("original turn text");
2864
3387
  });
2865
3388
 
2866
3389
  test("slack replacement preserves memory-image groups + text block", async () => {
@@ -2896,17 +3419,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2896
3419
  ],
2897
3420
  },
2898
3421
  ];
3422
+ const rows: MessageRow[] = [
3423
+ userRow({
3424
+ id: "mem-img-1",
3425
+ createdAt: 1700000000_000,
3426
+ text: "recalled conversation line",
3427
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3428
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3429
+ }),
3430
+ ];
3431
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2899
3432
  const { messages: result } = await applyRuntimeInjections(
2900
3433
  runMessagesWithMemory,
2901
- {
2902
- channelCapabilities: slackCaps,
2903
- slackChronologicalMessages: [
2904
- {
2905
- role: "user",
2906
- content: [{ type: "text", text: "[19:55 alice]: transcript line" }],
2907
- },
2908
- ],
2909
- },
3434
+ { conversationId: FALLBACK_CONVERSATION_ID },
2910
3435
  );
2911
3436
  const tail = result[result.length - 1];
2912
3437
  expect(tail.role).toBe("user");
@@ -2921,7 +3446,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2921
3446
  expect(allText).toContain("<memory_image __injected>");
2922
3447
  expect(allText).toContain("</memory_image>");
2923
3448
  expect(allText).toContain("<memory __injected>");
2924
- expect(allText).toContain("[19:55 alice]: transcript line");
3449
+ expect(allText).toContain("recalled conversation line");
2925
3450
  // The original turn text (before the Slack replacement) must NOT
2926
3451
  // leak through — only the memory prefix + transcript tail are kept.
2927
3452
  expect(allText).not.toContain("original turn text");
@@ -2935,17 +3460,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2935
3460
  supportsVoiceInput: false,
2936
3461
  chatType: "im",
2937
3462
  };
3463
+ const rows: MessageRow[] = [
3464
+ userRow({
3465
+ id: "no-prefix-1",
3466
+ createdAt: 1700000000_000,
3467
+ text: "only transcript line",
3468
+ slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
3469
+ extraOuterMetadata: { provenanceTrustClass: "guardian" },
3470
+ }),
3471
+ ];
3472
+ seedSlackChannelConversationWithRows(slackCaps, rows);
2938
3473
  const { messages: result } = await applyRuntimeInjections(
2939
3474
  [{ 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
- },
3475
+ { conversationId: FALLBACK_CONVERSATION_ID },
2949
3476
  );
2950
3477
  const tail = result[result.length - 1];
2951
3478
  const allText = tail.content
@@ -2953,7 +3480,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2953
3480
  .map((b) => b.text)
2954
3481
  .join("\n");
2955
3482
  expect(allText).not.toContain("<memory __injected>");
2956
- expect(allText).toContain("[19:55 alice]: only transcript");
3483
+ expect(allText).toContain("only transcript line");
2957
3484
  });
2958
3485
 
2959
3486
  // ── transport_hints suppression for slack channels ────────────────────
@@ -2965,27 +3492,12 @@ describe("Slack channel chronological rendering — multi-thread", () => {
2965
3492
  supportsVoiceInput: false,
2966
3493
  chatType: "channel",
2967
3494
  };
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
-
3495
+ seedChannelCapabilitiesConversation(slackChannelCaps, [
3496
+ "thread context: ...",
3497
+ ]);
2982
3498
  const { messages: result } = await applyRuntimeInjections(
2983
3499
  [{ role: "user", content: [{ type: "text", text: "current turn" }] }],
2984
- {
2985
- channelCapabilities: slackChannelCaps,
2986
- slackChronologicalMessages,
2987
- transportHints: ["thread context: ..."],
2988
- },
3500
+ { conversationId: FALLBACK_CONVERSATION_ID },
2989
3501
  );
2990
3502
 
2991
3503
  const allText = result
@@ -3009,12 +3521,10 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3009
3521
  chatType: "im",
3010
3522
  };
3011
3523
 
3524
+ seedChannelCapabilitiesConversation(slackDmCaps, ["dm context: ..."]);
3012
3525
  const { messages: result } = await applyRuntimeInjections(
3013
3526
  [{ role: "user", content: [{ type: "text", text: "hi DM" }] }],
3014
- {
3015
- channelCapabilities: slackDmCaps,
3016
- transportHints: ["dm context: ..."],
3017
- },
3527
+ { conversationId: FALLBACK_CONVERSATION_ID },
3018
3528
  );
3019
3529
 
3020
3530
  const allText = result
@@ -3028,18 +3538,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3028
3538
 
3029
3539
  // ── transport_hints kept for non-slack channels ───────────────────────
3030
3540
  test("non-slack conversations still receive <transport_hints>", async () => {
3031
- const { messages: result } = await applyRuntimeInjections(
3032
- [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3541
+ seedChannelCapabilitiesConversation(
3033
3542
  {
3034
- channelCapabilities: {
3035
- channel: "telegram",
3036
- dashboardCapable: false,
3037
- supportsDynamicUi: false,
3038
- supportsVoiceInput: false,
3039
- chatType: "private",
3040
- },
3041
- transportHints: ["please answer concisely"],
3543
+ channel: "telegram",
3544
+ dashboardCapable: false,
3545
+ supportsDynamicUi: false,
3546
+ supportsVoiceInput: false,
3547
+ chatType: "private",
3042
3548
  },
3549
+ ["please answer concisely"],
3550
+ );
3551
+ const { messages: result } = await applyRuntimeInjections(
3552
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3553
+ { conversationId: FALLBACK_CONVERSATION_ID },
3043
3554
  );
3044
3555
  const allText = result
3045
3556
  .flatMap((m) => m.content)
@@ -3336,10 +3847,72 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3336
3847
  // interleaved. The block is non-persisted: replays / re-injections strip
3337
3848
  // any prior `<active_thread>` blocks via `RUNTIME_INJECTION_PREFIXES`.
3338
3849
 
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<{
3850
+ interface SeededCompactionState {
3851
+ slackContextCompactionWatermarkTs?: string | null;
3852
+ contextCompactedMessageCount?: number;
3853
+ }
3854
+
3855
+ // Persist the Slack rows under the runtime-assembly fallback conversation and
3856
+ // mirror the channel capabilities + trust class + compaction state onto its
3857
+ // live registry entry, so `applyRuntimeInjections` self-resolves the
3858
+ // `<active_thread>` focus block from the conversation's stored rows — the same
3859
+ // way it does in production, where the injector reads the live conversation
3860
+ // rather than receiving a pre-built block.
3861
+ function seedSlackChannelConversationWithRows(
3862
+ caps: ChannelCapabilities,
3863
+ rows: MessageRow[],
3864
+ compaction: SeededCompactionState = {},
3865
+ ): void {
3866
+ const db = getDb();
3867
+ const now = Date.now();
3868
+ db.delete(messages)
3869
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
3870
+ .run();
3871
+ db.insert(conversations)
3872
+ .values({
3873
+ id: "runtime-assembly-fallback",
3874
+ createdAt: now,
3875
+ updatedAt: now,
3876
+ })
3877
+ .run();
3878
+ if (rows.length > 0) {
3879
+ db.insert(messages)
3880
+ .values(
3881
+ rows.map((r) => ({
3882
+ id: r.id,
3883
+ conversationId: "runtime-assembly-fallback",
3884
+ role: r.role,
3885
+ content: r.content,
3886
+ createdAt: r.createdAt,
3887
+ metadata: r.metadata,
3888
+ })),
3889
+ )
3890
+ .run();
3891
+ }
3892
+ setConversation("runtime-assembly-fallback", {
3893
+ conversationId: "runtime-assembly-fallback",
3894
+ workingDir: "/sandbox",
3895
+ workspaceTopLevelContext: "",
3896
+ workspaceTopLevelDirty: false,
3897
+ surfaceState: new Map(),
3898
+ channelCapabilities: caps,
3899
+ trustContext: { trustClass: "guardian" },
3900
+ slackContextCompactionWatermarkTs:
3901
+ compaction.slackContextCompactionWatermarkTs ?? null,
3902
+ contextCompactedMessageCount:
3903
+ compaction.contextCompactedMessageCount ?? 0,
3904
+ } as never);
3905
+ }
3906
+
3907
+ // Re-run a Slack-channel turn through the public assembly path. The
3908
+ // active-thread focus block self-resolves inside `applyRuntimeInjections`
3909
+ // from the seeded live conversation; `focusBlock` resolves it the same way
3910
+ // (default DB loader, guardian trust + the seeded compaction state) for the
3911
+ // expected-content assertions.
3912
+ async function runSlackChannelAssemblyWithFocus(
3913
+ rows: MessageRow[],
3914
+ compaction: SeededCompactionState = {},
3915
+ ): Promise<{
3343
3916
  messages: Message[];
3344
3917
  focusBlock: string | null;
3345
3918
  }> {
@@ -3350,24 +3923,23 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3350
3923
  supportsVoiceInput: false,
3351
3924
  chatType: "channel",
3352
3925
  };
3353
- const slackChronologicalMessages = loadSlackChronologicalMessages(
3354
- "conv-1",
3355
- slackChannelCaps,
3356
- { loader: () => rows, trustClass: "guardian" },
3357
- );
3926
+ seedSlackChannelConversationWithRows(slackChannelCaps, rows, compaction);
3358
3927
  const focusBlock = loadSlackActiveThreadFocusBlock(
3359
- "conv-1",
3928
+ "runtime-assembly-fallback",
3360
3929
  slackChannelCaps,
3361
- { loader: () => rows, trustClass: "guardian" },
3930
+ {
3931
+ trustClass: "guardian",
3932
+ contextCompactedMessageCount: compaction.contextCompactedMessageCount,
3933
+ slackContextCompactionWatermarkTs:
3934
+ compaction.slackContextCompactionWatermarkTs,
3935
+ },
3362
3936
  );
3363
3937
  const lastUserMessage: Message = {
3364
3938
  role: "user",
3365
3939
  content: [{ type: "text", text: "current turn" }],
3366
3940
  };
3367
3941
  const { messages } = await applyRuntimeInjections([lastUserMessage], {
3368
- channelCapabilities: slackChannelCaps,
3369
- slackChronologicalMessages,
3370
- slackActiveThreadFocusBlock: focusBlock,
3942
+ conversationId: FALLBACK_CONVERSATION_ID,
3371
3943
  });
3372
3944
  return { messages, focusBlock };
3373
3945
  }
@@ -3584,14 +4156,143 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3584
4156
  }
3585
4157
  });
3586
4158
 
4159
+ test("focus block self-resolves the live conversation's compaction watermark", async () => {
4160
+ /**
4161
+ * The watermark + compacted count live on the live `Conversation`, not in
4162
+ * the injection options, so `applyRuntimeInjections` must read them off the
4163
+ * live conversation to bound the self-resolved `<active_thread>` block.
4164
+ */
4165
+ // GIVEN a thread whose earliest rows were folded into a prior compaction
4166
+ const rows: MessageRow[] = [
4167
+ userRow({
4168
+ id: "thread-root",
4169
+ createdAt: 1700000000_000,
4170
+ text: "compacted root",
4171
+ slackMeta: buildSlackMeta({
4172
+ channelTs: T0,
4173
+ threadTs: T0,
4174
+ displayName: "alice",
4175
+ }),
4176
+ }),
4177
+ userRow({
4178
+ id: "reply-before",
4179
+ createdAt: 1700000005_000,
4180
+ text: "compacted reply",
4181
+ slackMeta: buildSlackMeta({
4182
+ channelTs: T0_REPLY1,
4183
+ threadTs: T0,
4184
+ displayName: "bob",
4185
+ }),
4186
+ }),
4187
+ userRow({
4188
+ id: "reply-after",
4189
+ createdAt: 1700000020_000,
4190
+ text: "live reply",
4191
+ slackMeta: buildSlackMeta({
4192
+ channelTs: T0_REPLY2,
4193
+ threadTs: T0,
4194
+ displayName: "carol",
4195
+ }),
4196
+ }),
4197
+ ];
4198
+
4199
+ // WHEN the live conversation carries a Slack compaction watermark at T1
4200
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4201
+ rows,
4202
+ {
4203
+ slackContextCompactionWatermarkTs: T1,
4204
+ contextCompactedMessageCount: 2,
4205
+ },
4206
+ );
4207
+
4208
+ // THEN the self-resolved focus block keeps only the post-watermark reply
4209
+ expect(focusBlock).not.toBeNull();
4210
+ expect(focusBlock!).toContain("live reply");
4211
+ expect(focusBlock!).not.toContain("compacted root");
4212
+ expect(focusBlock!).not.toContain("compacted reply");
4213
+ // AND `applyRuntimeInjections` injected exactly that self-resolved block,
4214
+ // proving it read the watermark + count off the live conversation.
4215
+ const allText = messages
4216
+ .flatMap((m) => m.content)
4217
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4218
+ .map((b) => b.text)
4219
+ .join("\n");
4220
+ expect(allText).toContain("<active_thread>");
4221
+ expect(allText).toContain(focusBlock!);
4222
+ });
4223
+
4224
+ test("focus block bounds by the compacted count when there is no watermark", async () => {
4225
+ /**
4226
+ * Conversations compacted before the Slack watermark migration have no
4227
+ * watermark, so the raw `contextCompactedMessageCount` is the sole boundary
4228
+ * the self-resolved focus block uses to drop already-compacted rows.
4229
+ */
4230
+ // GIVEN a thread whose first two rows were folded into a prior compaction
4231
+ const rows: MessageRow[] = [
4232
+ userRow({
4233
+ id: "thread-root",
4234
+ createdAt: 1700000000_000,
4235
+ text: "compacted root",
4236
+ slackMeta: buildSlackMeta({
4237
+ channelTs: T0,
4238
+ threadTs: T0,
4239
+ displayName: "alice",
4240
+ }),
4241
+ }),
4242
+ userRow({
4243
+ id: "reply-before",
4244
+ createdAt: 1700000005_000,
4245
+ text: "compacted reply",
4246
+ slackMeta: buildSlackMeta({
4247
+ channelTs: T0_REPLY1,
4248
+ threadTs: T0,
4249
+ displayName: "bob",
4250
+ }),
4251
+ }),
4252
+ userRow({
4253
+ id: "reply-after",
4254
+ createdAt: 1700000020_000,
4255
+ text: "live reply",
4256
+ slackMeta: buildSlackMeta({
4257
+ channelTs: T0_REPLY2,
4258
+ threadTs: T0,
4259
+ displayName: "carol",
4260
+ }),
4261
+ }),
4262
+ ];
4263
+
4264
+ // WHEN the live conversation has a compacted count of 2 and no watermark
4265
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4266
+ rows,
4267
+ {
4268
+ slackContextCompactionWatermarkTs: null,
4269
+ contextCompactedMessageCount: 2,
4270
+ },
4271
+ );
4272
+
4273
+ // THEN the self-resolved focus block drops the two compacted rows and keeps
4274
+ // only the live reply
4275
+ expect(focusBlock).not.toBeNull();
4276
+ expect(focusBlock!).toContain("live reply");
4277
+ expect(focusBlock!).not.toContain("compacted root");
4278
+ expect(focusBlock!).not.toContain("compacted reply");
4279
+ const allText = messages
4280
+ .flatMap((m) => m.content)
4281
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4282
+ .map((b) => b.text)
4283
+ .join("\n");
4284
+ expect(allText).toContain("<active_thread>");
4285
+ expect(allText).toContain(focusBlock!);
4286
+ });
4287
+
3587
4288
  test("focus block is dropped when injection is replayed (rebuilds re-derive it)", async () => {
3588
4289
  // Defensive: the `<active_thread>` block is a per-turn injection. When
3589
4290
  // overflow recovery / compaction re-runs `applyRuntimeInjections` on
3590
4291
  // already-injected messages, prior `<active_thread>` blocks must be
3591
4292
  // stripped so the rebuild's freshly-derived block is the only one
3592
4293
  // 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.
4294
+ // running the strip pipeline + applying injections again to confirm the
4295
+ // rebuild re-derives the block and no duplication occurs.
3595
4296
  const rows: MessageRow[] = [
3596
4297
  userRow({
3597
4298
  id: "m1",
@@ -3624,19 +4325,11 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3624
4325
  .join("\n");
3625
4326
  expect(strippedTexts).not.toContain("<active_thread>");
3626
4327
 
3627
- // Re-run injection with a fresh focus block only ONE
4328
+ // Re-run injection on the stripped messagesthe conversation is still
4329
+ // seeded, so the rebuild re-derives the focus block; only ONE
3628
4330
  // `<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
4331
  const { messages: reInjected } = await applyRuntimeInjections(stripped, {
3638
- channelCapabilities: slackChannelCaps,
3639
- slackActiveThreadFocusBlock: newFocus,
4332
+ conversationId: FALLBACK_CONVERSATION_ID,
3640
4333
  });
3641
4334
  const reInjectedTexts = reInjected
3642
4335
  .flatMap((m) => m.content)
@@ -3648,24 +4341,22 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3648
4341
  expect(blockCount).toBe(1);
3649
4342
  expect(
3650
4343
  reInjectedTexts.find((t) => t.startsWith("<active_thread>")),
3651
- ).toContain("newly built");
4344
+ ).toContain("Reply in thread A");
3652
4345
  });
3653
4346
 
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.
4347
+ test("non-slack conversations never get an active-thread focus block", async () => {
4348
+ // The focus block is gated on a Slack *channel* conversation
4349
+ // (`isSlackChannelConversation`). A non-Slack conversation must never
4350
+ // self-resolve an `<active_thread>` block.
4351
+ seedChannelCapabilitiesConversation({
4352
+ channel: "vellum",
4353
+ dashboardCapable: true,
4354
+ supportsDynamicUi: true,
4355
+ supportsVoiceInput: true,
4356
+ });
3658
4357
  const { messages: result } = await applyRuntimeInjections(
3659
4358
  [{ 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
- },
4359
+ { conversationId: FALLBACK_CONVERSATION_ID },
3669
4360
  );
3670
4361
  const allText = result
3671
4362
  .flatMap((m) => m.content)
@@ -3676,22 +4367,20 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3676
4367
  expect(allText).toContain("vellum question");
3677
4368
  });
3678
4369
 
3679
- test("slack DMs ignore slackActiveThreadFocusBlock", async () => {
4370
+ test("slack DMs never get an active-thread focus block", async () => {
3680
4371
  // Same as above but for Slack DMs (chatType === "im"). The focus
3681
4372
  // injection is keyed on `isSlackChannelConversation` which excludes
3682
4373
  // DMs, so the block must not appear.
4374
+ seedChannelCapabilitiesConversation({
4375
+ channel: "slack",
4376
+ dashboardCapable: false,
4377
+ supportsDynamicUi: false,
4378
+ supportsVoiceInput: false,
4379
+ chatType: "im",
4380
+ });
3683
4381
  const { messages: result } = await applyRuntimeInjections(
3684
4382
  [{ 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
- },
4383
+ { conversationId: FALLBACK_CONVERSATION_ID },
3695
4384
  );
3696
4385
  const allText = result
3697
4386
  .flatMap((m) => m.content)
@@ -5047,6 +5736,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5047
5736
  pkbSearchResults = [];
5048
5737
  pkbSearchThrows = null;
5049
5738
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5739
+ conversationId: FALLBACK_CONVERSATION_ID,
5050
5740
  mode: "full",
5051
5741
  });
5052
5742
 
@@ -5057,6 +5747,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5057
5747
  test("not captured in minimal mode", async () => {
5058
5748
  seedPkbContent();
5059
5749
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5750
+ conversationId: FALLBACK_CONVERSATION_ID,
5060
5751
  mode: "minimal",
5061
5752
  });
5062
5753
 
@@ -5065,6 +5756,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
5065
5756
 
5066
5757
  test("not captured when PKB inactive", async () => {
5067
5758
  const { blocks } = await applyRuntimeInjections(baseMessages, {
5759
+ conversationId: FALLBACK_CONVERSATION_ID,
5068
5760
  mode: "full",
5069
5761
  });
5070
5762