@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
@@ -28,6 +28,20 @@ let mockUiConfig: { userTimezone?: string; detectedTimezone?: string } = {};
28
28
 
29
29
  // ── Module mocks (must precede imports of the module under test) ─────
30
30
 
31
+ // The real AgentLoop resolves the per-conversation ContextWindowManager from
32
+ // the compaction store keyed by conversationId. These orchestrator tests build
33
+ // fake conversations whose manager is a canned stub, so register each stub in a
34
+ // map the mocked store reads from.
35
+ const fakeContextWindowManagers = new Map<string, unknown>();
36
+ mock.module("../plugins/defaults/compaction/manager-store.js", () => ({
37
+ createContextWindowManager: () => undefined,
38
+ getContextWindowManager: (conversationId: string) =>
39
+ fakeContextWindowManagers.get(conversationId),
40
+ disposeContextWindowManager: (conversationId: string) => {
41
+ fakeContextWindowManagers.delete(conversationId);
42
+ },
43
+ }));
44
+
31
45
  mock.module("../util/logger.js", () => ({
32
46
  getLogger: () =>
33
47
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
@@ -100,35 +114,38 @@ mock.module("../context/token-estimator.js", () => ({
100
114
  let mockReducerStepFn:
101
115
  | ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
102
116
  | null = null;
103
- mock.module("../daemon/context-overflow-reducer.js", () => ({
104
- createInitialReducerState: () => ({
105
- appliedTiers: [],
106
- injectionMode: "full" as const,
107
- exhausted: false,
117
+ mock.module(
118
+ "../plugins/defaults/compaction/context-overflow-reducer.js",
119
+ () => ({
120
+ createInitialReducerState: () => ({
121
+ appliedTiers: [],
122
+ injectionMode: "full" as const,
123
+ exhausted: false,
124
+ }),
125
+ reduceContextOverflow: async (
126
+ msgs: Message[],
127
+ cfg: unknown,
128
+ state: unknown,
129
+ ) => {
130
+ if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
131
+ return {
132
+ messages: msgs,
133
+ tier: "forced_compaction",
134
+ state: {
135
+ appliedTiers: [
136
+ "forced_compaction",
137
+ "tool_result_truncation",
138
+ "media_stubbing",
139
+ "injection_downgrade",
140
+ ],
141
+ injectionMode: "full",
142
+ exhausted: true,
143
+ },
144
+ estimatedTokens: 1000,
145
+ };
146
+ },
108
147
  }),
109
- reduceContextOverflow: async (
110
- msgs: Message[],
111
- cfg: unknown,
112
- state: unknown,
113
- ) => {
114
- if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
115
- return {
116
- messages: msgs,
117
- tier: "forced_compaction",
118
- state: {
119
- appliedTiers: [
120
- "forced_compaction",
121
- "tool_result_truncation",
122
- "media_stubbing",
123
- "injection_downgrade",
124
- ],
125
- injectionMode: "full",
126
- exhausted: true,
127
- },
128
- estimatedTokens: 1000,
129
- };
130
- },
131
- }));
148
+ );
132
149
 
133
150
  // Policy: default to fail_gracefully
134
151
  let mockOverflowAction: string = "fail_gracefully";
@@ -312,16 +329,14 @@ let mockInjectionBlocks: {
312
329
  pkbSystemReminder?: string;
313
330
  unifiedTurnContext?: string;
314
331
  } = {};
315
- const buildUnifiedTurnContextBlockMock = mock(
316
- (options: Record<string, unknown>) =>
317
- `<turn_context>\ncurrent_time: ${String(options.timestamp)}\n</turn_context>`,
318
- );
319
- const applyRuntimeInjectionsMock = mock(
320
- async (msgs: Message[], _options?: unknown) => ({
321
- messages: msgs,
322
- blocks: { ...mockInjectionBlocks },
323
- }),
324
- );
332
+ const defaultApplyRuntimeInjectionsImpl = async (
333
+ msgs: Message[],
334
+ _options?: unknown,
335
+ ) => ({
336
+ messages: msgs,
337
+ blocks: { ...mockInjectionBlocks },
338
+ });
339
+ const applyRuntimeInjectionsMock = mock(defaultApplyRuntimeInjectionsImpl);
325
340
  let mockSlackChronologicalContext: {
326
341
  renderedMessages: Array<{
327
342
  message: Message;
@@ -358,7 +373,6 @@ const getSlackCompactionWatermarkForPrefixMock = mock(
358
373
  );
359
374
  mock.module("../daemon/conversation-runtime-assembly.js", () => ({
360
375
  applyRuntimeInjections: applyRuntimeInjectionsMock,
361
- buildUnifiedTurnContextBlock: buildUnifiedTurnContextBlockMock,
362
376
  stripInjectionsForCompaction: (msgs: Message[]) => msgs,
363
377
  isSlackChannelConversation: () => false,
364
378
  getSlackCompactionWatermarkForPrefix:
@@ -523,22 +537,11 @@ mock.module("../memory/llm-request-log-store.js", () => ({
523
537
  setAgentLoopExitReasonOnLatestLog: setAgentLoopExitReasonOnLatestLogMock,
524
538
  }));
525
539
 
526
- let mockHasProactiveArtifactCompleted = true;
527
- let mockTryClaimProactiveArtifactTrigger = false;
528
- const runProactiveArtifactJobMock = mock(
529
- async (_params: Record<string, unknown>) => {},
530
- );
531
- mock.module("../proactive-artifact/index.js", () => ({
532
- hasProactiveArtifactCompleted: () => mockHasProactiveArtifactCompleted,
533
- tryClaimProactiveArtifactTrigger: () => mockTryClaimProactiveArtifactTrigger,
534
- runProactiveArtifactJob: runProactiveArtifactJobMock,
535
- }));
536
-
537
540
  // ── Imports (after mocks) ────────────────────────────────────────────
538
541
 
539
542
  import { AgentLoop } from "../agent/loop.js";
543
+ import type { Conversation } from "../daemon/conversation.js";
540
544
  import {
541
- type AgentLoopConversationContext,
542
545
  applyCompactionResult,
543
546
  runAgentLoopImpl,
544
547
  } from "../daemon/conversation-agent-loop.js";
@@ -552,13 +555,13 @@ import {
552
555
  // ── Test helpers ─────────────────────────────────────────────────────
553
556
 
554
557
  function makeCtx(
555
- overrides?: Partial<AgentLoopConversationContext> & {
558
+ overrides?: Partial<Conversation> & {
556
559
  providerResponses?: ScriptedResponse[];
557
560
  loopProvider?: Provider;
558
561
  loopTools?: ToolDefinition[];
559
562
  toolExecutor?: LoopToolExecutor;
560
563
  },
561
- ): AgentLoopConversationContext {
564
+ ): Conversation {
562
565
  const {
563
566
  providerResponses,
564
567
  loopProvider,
@@ -589,13 +592,15 @@ function makeCtx(
589
592
  providerResponses ?? [textResponse("response")],
590
593
  loopProviderName,
591
594
  ).provider;
592
- const agentLoop = new AgentLoop(provider, "system prompt", {
595
+ const agentLoop = new AgentLoop({
596
+ provider: provider,
597
+ systemPrompt: "system prompt",
593
598
  conversationId,
594
599
  tools: loopTools ?? [],
595
600
  toolExecutor,
596
601
  });
597
602
 
598
- return {
603
+ const ctx = {
599
604
  conversationId: "test-conv",
600
605
  messages: [
601
606
  { role: "user", content: [{ type: "text", text: "Hello" }] },
@@ -616,15 +621,24 @@ function makeCtx(
616
621
  usage: { inputTokens: 0, outputTokens: 0 },
617
622
  stopReason: "end_turn",
618
623
  }),
619
- } as unknown as AgentLoopConversationContext["provider"],
624
+ } as unknown as Conversation["provider"],
620
625
  systemPrompt: "system prompt",
621
626
 
622
627
  contextWindowManager: {
628
+ updateConfig: () => {},
623
629
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
624
630
  maybeCompact: async () => ({ compacted: false }),
625
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
626
- contextCompactedMessageCount: 0,
627
- contextCompactedAt: null,
631
+ } as unknown as Conversation["contextWindowManager"],
632
+ conversationType: mockConversationRow?.conversationType ?? undefined,
633
+ source: mockConversationRow?.source ?? undefined,
634
+ contextSummary: mockConversationRow?.contextSummary ?? null,
635
+ contextCompactedMessageCount:
636
+ mockConversationRow?.contextCompactedMessageCount ?? 0,
637
+ contextCompactedAt: mockConversationRow?.contextCompactedAt ?? null,
638
+ slackContextCompactionWatermarkTs:
639
+ mockConversationRow?.slackContextCompactionWatermarkTs ?? null,
640
+ lastNotifiedInferenceProfile:
641
+ mockConversationRow?.lastNotifiedInferenceProfile ?? null,
628
642
 
629
643
  memoryPolicy: { scopeId: "default", includeDefaultFallback: true },
630
644
 
@@ -636,8 +650,6 @@ function makeCtx(
636
650
  currentTurnSurfaces: [],
637
651
 
638
652
  workingDir: "/tmp",
639
- workspaceTopLevelContext: null,
640
- workspaceTopLevelDirty: false,
641
653
  channelCapabilities: undefined,
642
654
  commandIntent: undefined,
643
655
  trustContext: undefined,
@@ -647,15 +659,15 @@ function makeCtx(
647
659
  preactivatedSkillIds: undefined,
648
660
  skillProjectionState: new Map(),
649
661
  skillProjectionCache:
650
- new Map() as unknown as AgentLoopConversationContext["skillProjectionCache"],
662
+ new Map() as unknown as Conversation["skillProjectionCache"],
651
663
 
652
664
  traceEmitter: {
653
665
  emit: () => {},
654
- } as unknown as AgentLoopConversationContext["traceEmitter"],
666
+ } as unknown as Conversation["traceEmitter"],
655
667
  profiler: {
656
668
  startRequest: () => {},
657
669
  emitSummary: () => {},
658
- } as unknown as AgentLoopConversationContext["profiler"],
670
+ } as unknown as Conversation["profiler"],
659
671
  usageStats: {
660
672
  totalInputTokens: 0,
661
673
  totalOutputTokens: 0,
@@ -668,13 +680,12 @@ function makeCtx(
668
680
  lastAttachmentWarnings: [],
669
681
 
670
682
  hasNoClient: false,
671
- prompter: {} as unknown as AgentLoopConversationContext["prompter"],
672
- queue: {} as unknown as AgentLoopConversationContext["queue"],
683
+ prompter: {} as unknown as Conversation["prompter"],
684
+ queue: {} as unknown as Conversation["queue"],
673
685
 
674
686
  getWorkspaceGitService: () => ({ ensureInitialized: async () => {} }),
675
687
  commitTurnChanges: async () => {},
676
688
 
677
- refreshWorkspaceTopLevelContextIfNeeded: () => {},
678
689
  markWorkspaceTopLevelDirty: () => {},
679
690
  emitActivityState: () => {},
680
691
  getQueueDepth: () => 0,
@@ -701,10 +712,34 @@ function makeCtx(
701
712
  }),
702
713
  retrackCachedNodes: () => {},
703
714
  recordPkbQueryVectors: () => {},
704
- } as unknown as AgentLoopConversationContext["graphMemory"],
715
+ } as unknown as Conversation["graphMemory"],
705
716
 
706
717
  ...ctxOverrides,
707
- } as AgentLoopConversationContext;
718
+ } as unknown as Conversation;
719
+ fakeContextWindowManagers.set(conversationId, ctx.contextWindowManager);
720
+ return ctx;
721
+ }
722
+
723
+ type CompactionResult = Parameters<typeof applyCompactionResult>[1];
724
+
725
+ function makeCompactionResult(
726
+ overrides?: Partial<CompactionResult>,
727
+ ): CompactionResult {
728
+ return {
729
+ messages: [{ role: "user", content: [{ type: "text", text: "summary" }] }],
730
+ compactedPersistedMessages: 4,
731
+ previousEstimatedInputTokens: 12000,
732
+ estimatedInputTokens: 3000,
733
+ maxInputTokens: 100000,
734
+ thresholdTokens: 80000,
735
+ compactedMessages: 4,
736
+ summaryCalls: 1,
737
+ summaryInputTokens: 100,
738
+ summaryOutputTokens: 20,
739
+ summaryModel: "mock-model",
740
+ summaryText: "summary",
741
+ ...overrides,
742
+ };
708
743
  }
709
744
 
710
745
  // ── Tests ────────────────────────────────────────────────────────────
@@ -738,13 +773,12 @@ beforeEach(() => {
738
773
  title: null,
739
774
  };
740
775
  mockMessageById = null;
741
- mockHasProactiveArtifactCompleted = true;
742
- mockTryClaimProactiveArtifactTrigger = false;
743
- runProactiveArtifactJobMock.mockClear();
744
776
  setConversationHistoryStrippedAtMock.mockClear();
745
777
  setConversationHistoryStrippedAtMock.mockImplementation(() => {});
746
778
  applyRuntimeInjectionsMock.mockClear();
747
- buildUnifiedTurnContextBlockMock.mockClear();
779
+ applyRuntimeInjectionsMock.mockImplementation(
780
+ defaultApplyRuntimeInjectionsImpl,
781
+ );
748
782
  resolveTurnTimezoneContextMock.mockClear();
749
783
  formatTurnTimestampMock.mockClear();
750
784
  mockSlackChronologicalContext = null;
@@ -757,11 +791,10 @@ beforeEach(() => {
757
791
  projectAssistantMessageMock.mockClear();
758
792
  publishSyncInvalidationMock.mockClear();
759
793
  mockMessageById = null;
760
- // Orchestrator pipelines (overflowReduce, persistence, …) run through the
761
- // plugin registry; reset and re-register every default so the pipelines
762
- // dispatch to middleware backed by the mocked collaborators these tests
763
- // install (`reduceContextOverflow`, `syncMessageToDisk`, etc.) instead of
764
- // hitting the bare terminals.
794
+ // The compaction pipeline runs through the plugin registry; reset and
795
+ // re-register every default so it dispatches to middleware backed by the
796
+ // mocked collaborators these tests install (`syncMessageToDisk`, etc.)
797
+ // instead of hitting the bare terminal.
765
798
  resetPluginRegistryAndRegisterDefaults();
766
799
  });
767
800
 
@@ -785,7 +818,7 @@ describe("session-agent-loop", () => {
785
818
  });
786
819
  });
787
820
 
788
- test("passes resolved canonical timezones into unified turn context", async () => {
821
+ test("freezes the client timezone snapshot on the conversation, not the options bag", async () => {
789
822
  mockUiConfig = {
790
823
  userTimezone: "US/Eastern",
791
824
  detectedTimezone: "US/Central",
@@ -802,17 +835,25 @@ describe("session-agent-loop", () => {
802
835
 
803
836
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
804
837
 
805
- expect(formatTurnTimestampMock).toHaveBeenCalledWith({
806
- timeZone: "America/New_York",
807
- });
808
- expect(buildUnifiedTurnContextBlockMock).toHaveBeenCalled();
809
- const turnContextOptions =
810
- buildUnifiedTurnContextBlockMock.mock.calls[0]?.[0];
811
- expect(turnContextOptions).toMatchObject({
812
- configuredUserTimezone: "America/New_York",
838
+ // The turn-start client timezone and the long-absence gap are frozen on
839
+ // the conversation as `currentTurnTemporalSnapshot`; the live
840
+ // `clientTimezone` would otherwise be clobbered mid-turn. `current_time`
841
+ // is not frozen — it is computed fresh at each injection inside
842
+ // `applyRuntimeInjections`.
843
+ expect(ctx.currentTurnTemporalSnapshot).toEqual({
813
844
  clientTimezone: "America/Los_Angeles",
814
- detectedTimezone: "America/Chicago",
815
- });
845
+ timeSinceLastMessage: null,
846
+ });
847
+ // Neither the timezones nor the long-absence gap are threaded through the
848
+ // options bag — `applyRuntimeInjections` sources the client timezone and
849
+ // gap from the snapshot and the config timezones from config
850
+ // (`ui.userTimezone`, `ui.detectedTimezone`).
851
+ const injectionOptions = applyRuntimeInjectionsMock.mock.calls[0]?.[1];
852
+ expect(injectionOptions).not.toHaveProperty("timestamp");
853
+ expect(injectionOptions).not.toHaveProperty("clientTimezone");
854
+ expect(injectionOptions).not.toHaveProperty("configuredUserTimezone");
855
+ expect(injectionOptions).not.toHaveProperty("detectedTimezone");
856
+ expect(injectionOptions).not.toHaveProperty("timeSinceLastMessage");
816
857
  });
817
858
  });
818
859
 
@@ -826,61 +867,24 @@ describe("session-agent-loop", () => {
826
867
  });
827
868
  });
828
869
 
829
- describe("proactive artifact trigger", () => {
830
- test("does not start proactive artifact jobs after foreground user turns", async () => {
831
- mockConversationRow = {
832
- ...mockConversationRow,
833
- id: "test-conv",
834
- conversationType: "standard",
835
- };
836
- mockMessageById = {
837
- id: "user-msg-1",
838
- conversationId: "test-conv",
839
- createdAt: 1000,
840
- };
841
- mockHasProactiveArtifactCompleted = false;
842
- mockTryClaimProactiveArtifactTrigger = true;
843
-
844
- // A two-call agent turn: the model invokes `app_create`, then wraps up
845
- // with a final text reply.
846
- const ctx = makeCtx({
847
- conversationId: "test-conv",
848
- providerResponses: [
849
- {
850
- content: [
851
- { type: "text", text: "I'll build that app." },
852
- {
853
- type: "tool_use",
854
- id: "tool-1",
855
- name: "app_create",
856
- input: { name: "Flow" },
857
- },
858
- ],
859
- model: "mock-model",
860
- usage: { inputTokens: 10, outputTokens: 5 },
861
- stopReason: "tool_use",
862
- },
863
- textResponse("Done."),
864
- ],
865
- toolExecutor: async () => ({ content: "{}", isError: false }),
866
- });
867
- await runAgentLoopImpl(
868
- ctx,
869
- "build a kanban app",
870
- "user-msg-1",
871
- () => {},
872
- {
873
- isUserMessage: true,
874
- },
875
- );
876
- await new Promise((resolve) => setTimeout(resolve, 0));
877
-
878
- expect(runProactiveArtifactJobMock).toHaveBeenCalledTimes(0);
879
- });
880
- });
881
-
882
870
  describe("disk pressure injection context", () => {
883
- test("passes cleanup context into runtime injections for cleanup-mode turns", async () => {
871
+ // The loop sets `ctx.diskPressureCleanupModeActive` for the duration of the
872
+ // turn (the disk-pressure-warning injector reads it via the per-conversation
873
+ // registry) and resets it in the turn-end cleanup path. Snapshot the flag at
874
+ // each `applyRuntimeInjections` call so assertions observe its value while
875
+ // injection runs, not the post-turn reset.
876
+ function captureCleanupFlagDuringInjection(ctx: {
877
+ diskPressureCleanupModeActive?: boolean;
878
+ }): () => Array<boolean | undefined> {
879
+ const observed: Array<boolean | undefined> = [];
880
+ applyRuntimeInjectionsMock.mockImplementation(async (msgs: Message[]) => {
881
+ observed.push(ctx.diskPressureCleanupModeActive);
882
+ return { messages: msgs, blocks: { ...mockInjectionBlocks } };
883
+ });
884
+ return () => observed;
885
+ }
886
+
887
+ test("sets the cleanup-mode flag on the conversation for cleanup-mode turns", async () => {
884
888
  mockDiskPressureDecision = {
885
889
  action: "allow-cleanup-mode",
886
890
  reason: "guardian",
@@ -901,8 +905,9 @@ describe("session-agent-loop", () => {
901
905
  trustContext: {
902
906
  sourceChannel: "telegram",
903
907
  trustClass: "guardian",
904
- } as AgentLoopConversationContext["trustContext"],
908
+ } as Conversation["trustContext"],
905
909
  });
910
+ const cleanupFlagDuringInjection = captureCleanupFlagDuringInjection(ctx);
906
911
 
907
912
  await runAgentLoopImpl(ctx, "free up space", "msg-1", () => {});
908
913
 
@@ -921,21 +926,16 @@ describe("session-agent-loop", () => {
921
926
  },
922
927
  }),
923
928
  );
924
- const firstInjectionOptions = applyRuntimeInjectionsMock.mock
925
- .calls[0]![1] as {
926
- diskPressureContext?: { cleanupModeActive: boolean } | null;
927
- };
928
- expect(firstInjectionOptions.diskPressureContext).toEqual({
929
- cleanupModeActive: true,
930
- });
929
+ expect(cleanupFlagDuringInjection()).toEqual([true]);
931
930
  });
932
931
 
933
- test("passes cleanup context into runtime injections for local-owner turns", async () => {
932
+ test("sets the cleanup-mode flag on the conversation for local-owner turns", async () => {
934
933
  mockDiskPressureDecision = {
935
934
  action: "allow-cleanup-mode",
936
935
  reason: "local-owner",
937
936
  };
938
937
  const ctx = makeCtx();
938
+ const cleanupFlagDuringInjection = captureCleanupFlagDuringInjection(ctx);
939
939
 
940
940
  await runAgentLoopImpl(ctx, "free up space", "msg-1", () => {});
941
941
 
@@ -947,16 +947,10 @@ describe("session-agent-loop", () => {
947
947
  trustContext: null,
948
948
  }),
949
949
  );
950
- const firstInjectionOptions = applyRuntimeInjectionsMock.mock
951
- .calls[0]![1] as {
952
- diskPressureContext?: { cleanupModeActive: boolean } | null;
953
- };
954
- expect(firstInjectionOptions.diskPressureContext).toEqual({
955
- cleanupModeActive: true,
956
- });
950
+ expect(cleanupFlagDuringInjection()).toEqual([true]);
957
951
  });
958
952
 
959
- test("keeps cleanup context on overflow recovery reinjection", async () => {
953
+ test("keeps the cleanup-mode flag set across overflow recovery reinjection", async () => {
960
954
  mockDiskPressureDecision = {
961
955
  action: "allow-cleanup-mode",
962
956
  reason: "guardian",
@@ -976,20 +970,16 @@ describe("session-agent-loop", () => {
976
970
  trustContext: {
977
971
  sourceChannel: "telegram",
978
972
  trustClass: "guardian",
979
- } as AgentLoopConversationContext["trustContext"],
973
+ } as Conversation["trustContext"],
980
974
  });
975
+ const cleanupFlagDuringInjection = captureCleanupFlagDuringInjection(ctx);
981
976
 
982
977
  await runAgentLoopImpl(ctx, "free up space", "msg-1", () => {});
983
978
 
984
979
  expect(applyRuntimeInjectionsMock.mock.calls.length).toBeGreaterThan(1);
985
- for (const call of applyRuntimeInjectionsMock.mock.calls) {
986
- const options = call[1] as {
987
- diskPressureContext?: { cleanupModeActive: boolean } | null;
988
- };
989
- expect(options.diskPressureContext).toEqual({
990
- cleanupModeActive: true,
991
- });
992
- }
980
+ const flags = cleanupFlagDuringInjection();
981
+ expect(flags.length).toBeGreaterThan(1);
982
+ expect(flags.every((flag) => flag === true)).toBe(true);
993
983
  });
994
984
 
995
985
  test("blocks policy-denied turns before runtime injection or model execution", async () => {
@@ -1008,7 +998,7 @@ describe("session-agent-loop", () => {
1008
998
  emit: (...args: unknown[]) => {
1009
999
  traceEvents.push(args);
1010
1000
  },
1011
- } as unknown as AgentLoopConversationContext["traceEmitter"],
1001
+ } as unknown as Conversation["traceEmitter"],
1012
1002
  });
1013
1003
  const runSpy = spyOn(ctx.agentLoop, "run");
1014
1004
 
@@ -1169,7 +1159,7 @@ describe("session-agent-loop", () => {
1169
1159
  usage: { inputTokens: 0, outputTokens: 0 },
1170
1160
  stopReason: "end_turn",
1171
1161
  }),
1172
- } as unknown as AgentLoopConversationContext["provider"],
1162
+ } as unknown as Conversation["provider"],
1173
1163
  });
1174
1164
 
1175
1165
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1218,7 +1208,7 @@ describe("session-agent-loop", () => {
1218
1208
  usage: { inputTokens: 0, outputTokens: 0 },
1219
1209
  stopReason: "end_turn",
1220
1210
  }),
1221
- } as unknown as AgentLoopConversationContext["provider"],
1211
+ } as unknown as Conversation["provider"],
1222
1212
  });
1223
1213
 
1224
1214
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
@@ -1284,7 +1274,7 @@ describe("session-agent-loop", () => {
1284
1274
  usage: { inputTokens: 0, outputTokens: 0 },
1285
1275
  stopReason: "end_turn",
1286
1276
  }),
1287
- } as unknown as AgentLoopConversationContext["provider"],
1277
+ } as unknown as Conversation["provider"],
1288
1278
  });
1289
1279
 
1290
1280
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1335,7 +1325,7 @@ describe("session-agent-loop", () => {
1335
1325
  usage: { inputTokens: 0, outputTokens: 0 },
1336
1326
  stopReason: "end_turn",
1337
1327
  }),
1338
- } as unknown as AgentLoopConversationContext["provider"],
1328
+ } as unknown as Conversation["provider"],
1339
1329
  traceEmitter: {
1340
1330
  emit: (
1341
1331
  event: string,
@@ -1346,7 +1336,7 @@ describe("session-agent-loop", () => {
1346
1336
  traceEvents.push({ label, attrs: payload.attributes ?? {} });
1347
1337
  }
1348
1338
  },
1349
- } as unknown as AgentLoopConversationContext["traceEmitter"],
1339
+ } as unknown as Conversation["traceEmitter"],
1350
1340
  });
1351
1341
 
1352
1342
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
@@ -1393,7 +1383,7 @@ describe("session-agent-loop", () => {
1393
1383
  usage: { inputTokens: 0, outputTokens: 0 },
1394
1384
  stopReason: "end_turn",
1395
1385
  }),
1396
- } as unknown as AgentLoopConversationContext["provider"],
1386
+ } as unknown as Conversation["provider"],
1397
1387
  traceEmitter: {
1398
1388
  emit: (
1399
1389
  event: string,
@@ -1404,7 +1394,7 @@ describe("session-agent-loop", () => {
1404
1394
  traceEvents.push({ label, attrs: payload.attributes ?? {} });
1405
1395
  }
1406
1396
  },
1407
- } as unknown as AgentLoopConversationContext["traceEmitter"],
1397
+ } as unknown as Conversation["traceEmitter"],
1408
1398
  });
1409
1399
 
1410
1400
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
@@ -1460,7 +1450,7 @@ describe("session-agent-loop", () => {
1460
1450
  usage: { inputTokens: 0, outputTokens: 0 },
1461
1451
  stopReason: "end_turn",
1462
1452
  }),
1463
- } as unknown as AgentLoopConversationContext["provider"],
1453
+ } as unknown as Conversation["provider"],
1464
1454
  });
1465
1455
 
1466
1456
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1521,9 +1511,21 @@ describe("session-agent-loop", () => {
1521
1511
  },
1522
1512
  });
1523
1513
 
1524
- // After the orchestrator's preflight compaction runs, the loop completes
1525
- // the turn normally.
1526
- const ctx = makeCtx({ providerResponses: [textResponse("recovered")] });
1514
+ // The provider rejects the first call as too large; the convergence
1515
+ // reducer compacts and the rerun completes the turn, forwarding the
1516
+ // compaction's cache-aware usage to recordUsage.
1517
+ const { provider } = createMockProvider([
1518
+ new Error("context_length_exceeded"),
1519
+ textResponse("recovered"),
1520
+ ]);
1521
+ const ctx = makeCtx({
1522
+ loopProvider: provider,
1523
+ contextWindowManager: {
1524
+ updateConfig: () => {},
1525
+ shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1526
+ maybeCompact: async () => ({ compacted: false }),
1527
+ } as unknown as Conversation["contextWindowManager"],
1528
+ });
1527
1529
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1528
1530
 
1529
1531
  const compactorCall = recordUsageMock.mock.calls.find(
@@ -1605,9 +1607,10 @@ describe("session-agent-loop", () => {
1605
1607
  const ctx = makeCtx({
1606
1608
  loopProvider: provider,
1607
1609
  contextWindowManager: {
1610
+ updateConfig: () => {},
1608
1611
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1609
1612
  maybeCompact: async () => ({ compacted: false }),
1610
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1613
+ } as unknown as Conversation["contextWindowManager"],
1611
1614
  });
1612
1615
 
1613
1616
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1626,6 +1629,7 @@ describe("session-agent-loop", () => {
1626
1629
  const ctx = makeCtx({
1627
1630
  providerResponses: [new Error("context_length_exceeded")],
1628
1631
  contextWindowManager: {
1632
+ updateConfig: () => {},
1629
1633
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1630
1634
  // Compaction succeeds but context is still too large
1631
1635
  maybeCompact: async () => ({
@@ -1645,7 +1649,7 @@ describe("session-agent-loop", () => {
1645
1649
  summaryOutputTokens: 200,
1646
1650
  summaryModel: "mock-model",
1647
1651
  }),
1648
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1652
+ } as unknown as Conversation["contextWindowManager"],
1649
1653
  });
1650
1654
 
1651
1655
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1685,9 +1689,10 @@ describe("session-agent-loop", () => {
1685
1689
  const ctx = makeCtx({
1686
1690
  loopProvider: provider,
1687
1691
  contextWindowManager: {
1692
+ updateConfig: () => {},
1688
1693
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1689
1694
  maybeCompact: async () => ({ compacted: false }),
1690
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1695
+ } as unknown as Conversation["contextWindowManager"],
1691
1696
  });
1692
1697
 
1693
1698
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1734,6 +1739,7 @@ describe("session-agent-loop", () => {
1734
1739
  ],
1735
1740
  hasNoClient: true,
1736
1741
  contextWindowManager: {
1742
+ updateConfig: () => {},
1737
1743
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1738
1744
  maybeCompact: async () => ({
1739
1745
  compacted: true,
@@ -1752,7 +1758,7 @@ describe("session-agent-loop", () => {
1752
1758
  summaryOutputTokens: 100,
1753
1759
  summaryModel: "mock-model",
1754
1760
  }),
1755
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1761
+ } as unknown as Conversation["contextWindowManager"],
1756
1762
  });
1757
1763
 
1758
1764
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1834,6 +1840,7 @@ describe("session-agent-loop", () => {
1834
1840
  toolExecutor: async () => ({ content: "data", isError: false }),
1835
1841
  hasNoClient: true,
1836
1842
  contextWindowManager: {
1843
+ updateConfig: () => {},
1837
1844
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1838
1845
  maybeCompact: async () => ({
1839
1846
  compacted: true,
@@ -1852,7 +1859,7 @@ describe("session-agent-loop", () => {
1852
1859
  summaryOutputTokens: 100,
1853
1860
  summaryModel: "mock-model",
1854
1861
  }),
1855
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1862
+ } as unknown as Conversation["contextWindowManager"],
1856
1863
  });
1857
1864
 
1858
1865
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1919,9 +1926,10 @@ describe("session-agent-loop", () => {
1919
1926
  const ctx = makeCtx({
1920
1927
  providerResponses: [new Error("context_length_exceeded")],
1921
1928
  contextWindowManager: {
1929
+ updateConfig: () => {},
1922
1930
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1923
1931
  maybeCompact: async () => ({ compacted: false }),
1924
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1932
+ } as unknown as Conversation["contextWindowManager"],
1925
1933
  });
1926
1934
 
1927
1935
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
@@ -1929,48 +1937,6 @@ describe("session-agent-loop", () => {
1929
1937
  // maxAttempts is 3 — reducer should be called at most 3 times
1930
1938
  expect(reducerCalls).toBeLessThanOrEqual(3);
1931
1939
  });
1932
-
1933
- test("preflight budget evaluation invokes reducer before provider call", async () => {
1934
- const events: ServerMessage[] = [];
1935
- let reducerCalls = 0;
1936
-
1937
- // Set token estimate above budget (100000 * 0.95 = 95000)
1938
- mockEstimateTokens = 96000;
1939
-
1940
- mockReducerStepFn = (msgs: Message[]) => {
1941
- reducerCalls++;
1942
- return {
1943
- messages: msgs,
1944
- tier: "forced_compaction",
1945
- state: {
1946
- appliedTiers: ["forced_compaction"],
1947
- injectionMode: "full",
1948
- exhausted: true,
1949
- },
1950
- estimatedTokens: 50000,
1951
- };
1952
- };
1953
-
1954
- // After the preflight reducer brings the estimate under budget, the loop
1955
- // completes the turn in a single provider call.
1956
- const { provider, calls } = createMockProvider([textResponse("ok")]);
1957
- const ctx = makeCtx({
1958
- loopProvider: provider,
1959
- contextWindowManager: {
1960
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
1961
- maybeCompact: async () => ({ compacted: false }),
1962
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
1963
- });
1964
-
1965
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1966
-
1967
- // Reducer should have been called during preflight
1968
- expect(reducerCalls).toBeGreaterThanOrEqual(1);
1969
- // Agent loop should still succeed in a single provider call
1970
- expect(calls.length).toBe(1);
1971
- const complete = events.find((e) => e.type === "message_complete");
1972
- expect(complete).toBeDefined();
1973
- });
1974
1940
  });
1975
1941
 
1976
1942
  describe("provider ordering error retry", () => {
@@ -2024,7 +1990,7 @@ describe("session-agent-loop", () => {
2024
1990
  ],
2025
1991
  toolExecutor: async () => ({ content: "file content", isError: false }),
2026
1992
  canHandoffAtCheckpoint: () => true,
2027
- } as unknown as Partial<AgentLoopConversationContext>);
1993
+ } as unknown as Partial<Conversation>);
2028
1994
 
2029
1995
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2030
1996
 
@@ -2055,7 +2021,7 @@ describe("session-agent-loop", () => {
2055
2021
  ],
2056
2022
  toolExecutor: async () => ({ content: "content", isError: false }),
2057
2023
  canHandoffAtCheckpoint: () => false,
2058
- } as unknown as Partial<AgentLoopConversationContext>);
2024
+ } as unknown as Partial<Conversation>);
2059
2025
 
2060
2026
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2061
2027
 
@@ -2206,7 +2172,7 @@ describe("session-agent-loop", () => {
2206
2172
  drainQueue: (reason: string) => {
2207
2173
  drainReason = reason;
2208
2174
  },
2209
- } as unknown as Partial<AgentLoopConversationContext>);
2175
+ } as unknown as Partial<Conversation>);
2210
2176
 
2211
2177
  // WHEN the orchestrator runs the turn to completion
2212
2178
  await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
@@ -2648,9 +2614,10 @@ describe("session-agent-loop", () => {
2648
2614
  textResponse("retry succeeded"),
2649
2615
  ],
2650
2616
  contextWindowManager: {
2617
+ updateConfig: () => {},
2651
2618
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
2652
2619
  maybeCompact: async () => ({ compacted: false }),
2653
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
2620
+ } as unknown as Conversation["contextWindowManager"],
2654
2621
  });
2655
2622
  await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
2656
2623
 
@@ -3112,7 +3079,7 @@ describe("session-agent-loop", () => {
3112
3079
  });
3113
3080
 
3114
3081
  describe("Slack compaction watermarks", () => {
3115
- test("start-of-turn Slack compaction derives and persists watermark from rendered context", async () => {
3082
+ test("first-call compaction runs in the loop and drops the Slack watermark", async () => {
3116
3083
  const renderedSlackMessages: Message[] = [
3117
3084
  {
3118
3085
  role: "user",
@@ -3140,9 +3107,12 @@ describe("session-agent-loop", () => {
3140
3107
  })),
3141
3108
  compactableStartIndex: 0,
3142
3109
  };
3143
- const shouldCompactInputs: Message[][] = [];
3144
3110
  const maybeCompactInputs: Message[][] = [];
3145
3111
 
3112
+ // Sits above the loop's first-call gate threshold (~80.75k), so the
3113
+ // loop's first-call gate owns the turn-start compaction.
3114
+ mockEstimateTokens = 90_000;
3115
+
3146
3116
  const ctx = makeCtx({
3147
3117
  channelCapabilities: {
3148
3118
  channel: "slack",
@@ -3154,18 +3124,19 @@ describe("session-agent-loop", () => {
3154
3124
  trustContext: {
3155
3125
  sourceChannel: "slack",
3156
3126
  trustClass: "guardian",
3157
- } as AgentLoopConversationContext["trustContext"],
3127
+ } as Conversation["trustContext"],
3158
3128
  getTurnChannelContext: () => ({
3159
3129
  userMessageChannel: "slack" as const,
3160
3130
  assistantMessageChannel: "slack" as const,
3161
3131
  }),
3162
3132
  contextWindowManager: {
3163
- shouldCompact: (messages: Message[]) => {
3164
- shouldCompactInputs.push(messages);
3165
- return { needed: true, estimatedTokens: 95_000 };
3166
- },
3133
+ updateConfig: () => {},
3134
+ shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3167
3135
  maybeCompact: async (messages: Message[]) => {
3168
3136
  maybeCompactInputs.push(messages);
3137
+ // Drop back under budget so the post-compaction provider call
3138
+ // proceeds.
3139
+ mockEstimateTokens = 1000;
3169
3140
  return {
3170
3141
  compacted: true,
3171
3142
  messages: [
@@ -3173,10 +3144,10 @@ describe("session-agent-loop", () => {
3173
3144
  role: "user",
3174
3145
  content: [{ type: "text", text: "summary" }],
3175
3146
  },
3176
- messages[2]!,
3147
+ messages[messages.length - 1]!,
3177
3148
  ],
3178
3149
  compactedPersistedMessages: 2,
3179
- previousEstimatedInputTokens: 95_000,
3150
+ previousEstimatedInputTokens: 90_000,
3180
3151
  estimatedInputTokens: 5_000,
3181
3152
  maxInputTokens: 100_000,
3182
3153
  thresholdTokens: 80_000,
@@ -3189,292 +3160,24 @@ describe("session-agent-loop", () => {
3189
3160
  summaryFailed: false,
3190
3161
  };
3191
3162
  },
3192
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3163
+ } as unknown as Conversation["contextWindowManager"],
3193
3164
  });
3194
3165
 
3195
- await runAgentLoopImpl(ctx, "next reply", "user-msg-start", () => {});
3196
-
3197
- expect(shouldCompactInputs[0]).toBe(renderedSlackMessages);
3198
- expect(maybeCompactInputs[0]).toBe(renderedSlackMessages);
3199
- expect(getSlackCompactionWatermarkForPrefixMock).toHaveBeenCalledWith(
3200
- mockSlackChronologicalContext,
3201
- 2,
3202
- );
3203
- expect(updateConversationSlackContextWatermarkMock).toHaveBeenCalledWith(
3204
- "test-conv",
3205
- "1700000020.000000",
3206
- expect.any(Number),
3207
- );
3208
- const firstInjectionOptions = applyRuntimeInjectionsMock.mock
3209
- .calls[0]![1] as {
3210
- slackChronologicalMessages?: Message[] | null;
3211
- };
3212
- expect(firstInjectionOptions.slackChronologicalMessages).toBeNull();
3213
- });
3214
-
3215
- test("overflow reducer Slack compaction persists watermark from rendered context", async () => {
3216
- const renderedSlackMessages: Message[] = [
3217
- {
3218
- role: "user",
3219
- content: [{ type: "text", text: "first rendered Slack row" }],
3220
- },
3221
- {
3222
- role: "user",
3223
- content: [{ type: "text", text: "second rendered Slack row" }],
3224
- },
3225
- {
3226
- role: "user",
3227
- content: [{ type: "text", text: "retained Slack row" }],
3228
- },
3229
- ];
3230
- mockSlackChronologicalContext = {
3231
- messages: renderedSlackMessages,
3232
- renderedMessages: renderedSlackMessages.map((message, index) => ({
3233
- message,
3234
- sourceChannelTs: [
3235
- "1700000010.000000",
3236
- "1700000020.000000",
3237
- "1700000030.000000",
3238
- ][index]!,
3239
- tagLineProvenance: "none",
3240
- })),
3241
- compactableStartIndex: 0,
3242
- };
3243
- mockEstimateTokens = 120_000;
3244
- mockReducerStepFn = (_msgs: Message[]) => ({
3245
- messages: [
3246
- {
3247
- role: "user",
3248
- content: [{ type: "text", text: "summary" }],
3249
- },
3250
- renderedSlackMessages[2]!,
3251
- ],
3252
- tier: "forced_compaction",
3253
- state: {
3254
- appliedTiers: ["forced_compaction"],
3255
- injectionMode: "full",
3256
- exhausted: false,
3257
- },
3258
- estimatedTokens: 5_000,
3259
- compactionResult: {
3260
- compacted: true,
3261
- messages: [
3262
- {
3263
- role: "user",
3264
- content: [{ type: "text", text: "summary" }],
3265
- },
3266
- renderedSlackMessages[2]!,
3267
- ],
3268
- compactedPersistedMessages: 2,
3269
- previousEstimatedInputTokens: 120_000,
3270
- estimatedInputTokens: 5_000,
3271
- maxInputTokens: 100_000,
3272
- thresholdTokens: 80_000,
3273
- compactedMessages: 2,
3274
- summaryCalls: 1,
3275
- summaryInputTokens: 100,
3276
- summaryOutputTokens: 20,
3277
- summaryModel: "mock-model",
3278
- summaryText: "summary",
3279
- summaryFailed: false,
3280
- },
3281
- });
3282
-
3283
- const ctx = makeCtx({
3284
- channelCapabilities: {
3285
- channel: "slack",
3286
- dashboardCapable: false,
3287
- supportsDynamicUi: false,
3288
- supportsVoiceInput: false,
3289
- chatType: "channel",
3290
- },
3291
- trustContext: {
3292
- sourceChannel: "slack",
3293
- trustClass: "guardian",
3294
- } as AgentLoopConversationContext["trustContext"],
3295
- getTurnChannelContext: () => ({
3296
- userMessageChannel: "slack" as const,
3297
- assistantMessageChannel: "slack" as const,
3298
- }),
3299
- contextWindowManager: {
3300
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3301
- maybeCompact: async () => ({ compacted: false }),
3302
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3303
- });
3304
-
3305
- await runAgentLoopImpl(ctx, "next reply", "user-msg-overflow", () => {});
3306
-
3307
- expect(getSlackCompactionWatermarkForPrefixMock).toHaveBeenCalledWith(
3308
- mockSlackChronologicalContext,
3309
- 2,
3310
- );
3311
- expect(updateConversationSlackContextWatermarkMock).toHaveBeenCalledWith(
3312
- "test-conv",
3313
- "1700000020.000000",
3314
- expect.any(Number),
3166
+ await runAgentLoopImpl(
3167
+ ctx,
3168
+ "next reply",
3169
+ "user-msg-first-call",
3170
+ () => {},
3315
3171
  );
3316
- const reinjectionOptions = applyRuntimeInjectionsMock.mock.calls.find(
3317
- (call) => {
3318
- const options = call[1] as {
3319
- slackChronologicalMessages?: Message[] | null;
3320
- };
3321
- return options.slackChronologicalMessages === null;
3322
- },
3323
- )?.[1] as { slackChronologicalMessages?: Message[] | null } | undefined;
3324
- expect(reinjectionOptions?.slackChronologicalMessages).toBeNull();
3325
- });
3326
3172
 
3327
- test("same-turn Slack compaction updates watermark from projected provenance", async () => {
3328
- const renderedSlackMessages: Message[] = [
3329
- {
3330
- role: "user",
3331
- content: [{ type: "text", text: "first rendered Slack row" }],
3332
- },
3333
- {
3334
- role: "user",
3335
- content: [{ type: "text", text: "second rendered Slack row" }],
3336
- },
3337
- {
3338
- role: "user",
3339
- content: [{ type: "text", text: "third rendered Slack row" }],
3340
- },
3341
- {
3342
- role: "user",
3343
- content: [{ type: "text", text: "retained Slack row" }],
3344
- },
3345
- ];
3346
- mockSlackChronologicalContext = {
3347
- messages: renderedSlackMessages,
3348
- renderedMessages: renderedSlackMessages.map((message, index) => ({
3349
- message,
3350
- sourceChannelTs: [
3351
- "1700000010.000000",
3352
- "1700000020.000000",
3353
- "1700000030.000000",
3354
- "1700000040.000000",
3355
- ][index]!,
3356
- tagLineProvenance: "none",
3357
- })),
3358
- compactableStartIndex: 0,
3359
- };
3360
-
3361
- const firstSummaryMessage: Message = {
3362
- role: "user",
3363
- content: [{ type: "text", text: "first summary" }],
3364
- };
3365
- const firstCompactedMessages: Message[] = [
3366
- firstSummaryMessage,
3367
- renderedSlackMessages[2]!,
3368
- renderedSlackMessages[3]!,
3369
- ];
3370
- const secondSummaryMessage: Message = {
3371
- role: "user",
3372
- content: [{ type: "text", text: "second summary" }],
3373
- };
3374
- const secondCompactedMessages: Message[] = [
3375
- secondSummaryMessage,
3376
- renderedSlackMessages[3]!,
3377
- ];
3378
- const reducerInputs: Message[][] = [];
3379
- mockEstimateTokens = 120_000;
3380
- mockReducerStepFn = (msgs: Message[]) => {
3381
- reducerInputs.push(msgs);
3382
- mockEstimateTokens = 1000;
3383
- return {
3384
- messages: secondCompactedMessages,
3385
- tier: "forced_compaction",
3386
- state: {
3387
- appliedTiers: ["forced_compaction"],
3388
- injectionMode: "full",
3389
- exhausted: false,
3390
- },
3391
- estimatedTokens: 5_000,
3392
- compactionResult: {
3393
- compacted: true,
3394
- messages: secondCompactedMessages,
3395
- compactedPersistedMessages: 1,
3396
- previousEstimatedInputTokens: 120_000,
3397
- estimatedInputTokens: 5_000,
3398
- maxInputTokens: 100_000,
3399
- thresholdTokens: 80_000,
3400
- compactedMessages: 1,
3401
- summaryCalls: 1,
3402
- summaryInputTokens: 100,
3403
- summaryOutputTokens: 20,
3404
- summaryModel: "mock-model",
3405
- summaryText: "second summary",
3406
- summaryFailed: false,
3407
- },
3408
- };
3409
- };
3410
-
3411
- const ctx = makeCtx({
3412
- channelCapabilities: {
3413
- channel: "slack",
3414
- dashboardCapable: false,
3415
- supportsDynamicUi: false,
3416
- supportsVoiceInput: false,
3417
- chatType: "channel",
3418
- },
3419
- trustContext: {
3420
- sourceChannel: "slack",
3421
- trustClass: "guardian",
3422
- } as AgentLoopConversationContext["trustContext"],
3423
- getTurnChannelContext: () => ({
3424
- userMessageChannel: "slack" as const,
3425
- assistantMessageChannel: "slack" as const,
3426
- }),
3427
- contextWindowManager: {
3428
- shouldCompact: () => ({ needed: true, estimatedTokens: 120_000 }),
3429
- maybeCompact: async (messages: Message[]) => {
3430
- expect(messages).toBe(renderedSlackMessages);
3431
- return {
3432
- compacted: true,
3433
- messages: firstCompactedMessages,
3434
- compactedPersistedMessages: 2,
3435
- previousEstimatedInputTokens: 120_000,
3436
- estimatedInputTokens: 60_000,
3437
- maxInputTokens: 100_000,
3438
- thresholdTokens: 80_000,
3439
- compactedMessages: 2,
3440
- summaryCalls: 1,
3441
- summaryInputTokens: 100,
3442
- summaryOutputTokens: 20,
3443
- summaryModel: "mock-model",
3444
- summaryText: "first summary",
3445
- summaryFailed: false,
3446
- };
3447
- },
3448
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3449
- });
3450
-
3451
- await runAgentLoopImpl(ctx, "next reply", "user-msg-repeat", () => {});
3452
-
3453
- expect(reducerInputs[0]).toBe(firstCompactedMessages);
3454
- expect(getSlackCompactionWatermarkForPrefixMock.mock.calls).toEqual([
3455
- [mockSlackChronologicalContext, 2],
3456
- [
3457
- {
3458
- renderedMessages: [
3459
- {
3460
- message: firstSummaryMessage,
3461
- sourceChannelTs: null,
3462
- tagLineProvenance: "none",
3463
- },
3464
- mockSlackChronologicalContext!.renderedMessages[2],
3465
- mockSlackChronologicalContext!.renderedMessages[3],
3466
- ],
3467
- messages: firstCompactedMessages,
3468
- compactableStartIndex: 1,
3469
- },
3470
- 1,
3471
- ],
3472
- ]);
3473
- expect(updateConversationSlackContextWatermarkMock.mock.calls).toEqual([
3474
- ["test-conv", "1700000020.000000", expect.any(Number)],
3475
- ["test-conv", "1700000030.000000", expect.any(Number)],
3476
- ]);
3477
- expect(loadSlackChronologicalContextMock).toHaveBeenCalledTimes(1);
3173
+ // The loop compacts its own stripped working history, never the loaded
3174
+ // Slack transcript array, so provenance cannot be projected and the
3175
+ // watermark is left untouched — matching mid-loop compaction.
3176
+ expect(maybeCompactInputs.length).toBeGreaterThan(0);
3177
+ expect(maybeCompactInputs[0]).not.toBe(renderedSlackMessages);
3178
+ expect(
3179
+ updateConversationSlackContextWatermarkMock,
3180
+ ).not.toHaveBeenCalled();
3478
3181
  });
3479
3182
 
3480
3183
  test("mid-loop Slack compaction does not persist watermark from mismatched loaded context", async () => {
@@ -3542,32 +3245,16 @@ describe("session-agent-loop", () => {
3542
3245
  trustContext: {
3543
3246
  sourceChannel: "slack",
3544
3247
  trustClass: "guardian",
3545
- } as AgentLoopConversationContext["trustContext"],
3248
+ } as Conversation["trustContext"],
3546
3249
  getTurnChannelContext: () => ({
3547
3250
  userMessageChannel: "slack" as const,
3548
3251
  assistantMessageChannel: "slack" as const,
3549
3252
  }),
3550
3253
  contextWindowManager: {
3254
+ updateConfig: () => {},
3551
3255
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3552
3256
  maybeCompact: async (messages: Message[]) => {
3553
3257
  maybeCompactInputs.push(messages);
3554
- if (messages === renderedSlackMessages) {
3555
- return {
3556
- compacted: false,
3557
- messages,
3558
- compactedPersistedMessages: 0,
3559
- previousEstimatedInputTokens: 1000,
3560
- estimatedInputTokens: 1000,
3561
- maxInputTokens: 100_000,
3562
- thresholdTokens: 80_000,
3563
- compactedMessages: 0,
3564
- summaryCalls: 0,
3565
- summaryInputTokens: 0,
3566
- summaryOutputTokens: 0,
3567
- summaryModel: "",
3568
- summaryText: "",
3569
- };
3570
- }
3571
3258
  // The mid-loop gate compacted its in-loop basis; drop the estimate
3572
3259
  // back under budget so the post-compaction provider call proceeds.
3573
3260
  mockEstimateTokens = 1000;
@@ -3593,15 +3280,16 @@ describe("session-agent-loop", () => {
3593
3280
  summaryFailed: false,
3594
3281
  };
3595
3282
  },
3596
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3283
+ } as unknown as Conversation["contextWindowManager"],
3597
3284
  });
3598
3285
 
3599
3286
  await runAgentLoopImpl(ctx, "next reply", "user-msg-mid-loop", () => {});
3600
3287
 
3601
- expect(maybeCompactInputs[0]).toBe(renderedSlackMessages);
3602
3288
  // The mid-loop gate compacts the loop's own in-loop history, never the
3603
- // loaded Slack rows — the mismatch this test guards against.
3604
- expect(maybeCompactInputs[1]).not.toBe(renderedSlackMessages);
3289
+ // loaded Slack rows — the mismatch this test guards against. No
3290
+ // start-of-turn compaction runs, so this is the turn's only compaction.
3291
+ expect(maybeCompactInputs).toHaveLength(1);
3292
+ expect(maybeCompactInputs[0]).not.toBe(renderedSlackMessages);
3605
3293
  expect(getSlackCompactionWatermarkForPrefixMock).toHaveBeenCalledWith(
3606
3294
  null,
3607
3295
  2,
@@ -3611,7 +3299,7 @@ describe("session-agent-loop", () => {
3611
3299
  ).not.toHaveBeenCalled();
3612
3300
  });
3613
3301
 
3614
- test("next inbound Slack turn injects the watermark-filtered chronological context", async () => {
3302
+ test("next inbound Slack turn loads chronological context using the persisted watermark", async () => {
3615
3303
  mockConversationRow = {
3616
3304
  ...mockConversationRow,
3617
3305
  contextSummary: "## Summary\n- compacted Slack context",
@@ -3671,7 +3359,7 @@ describe("session-agent-loop", () => {
3671
3359
  trustContext: {
3672
3360
  sourceChannel: "slack",
3673
3361
  trustClass: "guardian",
3674
- } as AgentLoopConversationContext["trustContext"],
3362
+ } as Conversation["trustContext"],
3675
3363
  getTurnChannelContext: () => ({
3676
3364
  userMessageChannel: "slack" as const,
3677
3365
  assistantMessageChannel: "slack" as const,
@@ -3690,26 +3378,9 @@ describe("session-agent-loop", () => {
3690
3378
  trustClass: "guardian",
3691
3379
  }),
3692
3380
  );
3693
- const firstInjectionOptions = applyRuntimeInjectionsMock.mock
3694
- .calls[0]![1] as {
3695
- slackChronologicalMessages?: Message[] | null;
3696
- };
3697
- expect(firstInjectionOptions.slackChronologicalMessages).toBe(
3698
- mockSlackChronologicalContext.messages,
3699
- );
3700
- const rendered = firstInjectionOptions
3701
- .slackChronologicalMessages!.flatMap((message) => message.content)
3702
- .filter((block): block is { type: "text"; text: string } => {
3703
- return block.type === "text";
3704
- })
3705
- .map((block) => block.text)
3706
- .join("\n");
3707
- expect(rendered).toContain("compacted Slack context");
3708
- expect(rendered).toContain("after watermark reply");
3709
- expect(rendered).not.toContain("before watermark");
3710
- });
3711
-
3712
- test("subsequent Slack turn keeps long-thread compaction summary and filtered tail", async () => {
3381
+ });
3382
+
3383
+ test("subsequent Slack turn loads chronological context using the persisted long-thread watermark", async () => {
3713
3384
  mockConversationRow = {
3714
3385
  ...mockConversationRow,
3715
3386
  contextSummary: "## Summary\n- compacted long Slack thread",
@@ -3779,7 +3450,7 @@ describe("session-agent-loop", () => {
3779
3450
  trustContext: {
3780
3451
  sourceChannel: "slack",
3781
3452
  trustClass: "guardian",
3782
- } as AgentLoopConversationContext["trustContext"],
3453
+ } as Conversation["trustContext"],
3783
3454
  getTurnChannelContext: () => ({
3784
3455
  userMessageChannel: "slack" as const,
3785
3456
  assistantMessageChannel: "slack" as const,
@@ -3802,21 +3473,6 @@ describe("session-agent-loop", () => {
3802
3473
  slackContextCompactionWatermarkTs: "1700000080.000000",
3803
3474
  }),
3804
3475
  );
3805
- const firstInjectionOptions = applyRuntimeInjectionsMock.mock
3806
- .calls[0]![1] as {
3807
- slackChronologicalMessages?: Message[] | null;
3808
- };
3809
- const rendered = firstInjectionOptions
3810
- .slackChronologicalMessages!.flatMap((message) => message.content)
3811
- .filter((block): block is { type: "text"; text: string } => {
3812
- return block.type === "text";
3813
- })
3814
- .map((block) => block.text)
3815
- .join("\n");
3816
- expect(rendered).toContain("compacted long Slack thread");
3817
- expect(rendered).toContain("reply after compaction");
3818
- expect(rendered).not.toContain("pre-compaction");
3819
- expect(rendered).not.toContain("original root");
3820
3476
  });
3821
3477
 
3822
3478
  test("applyCompactionResult records Slack timestamp watermark when provided", async () => {
@@ -3858,6 +3514,59 @@ describe("session-agent-loop", () => {
3858
3514
  true,
3859
3515
  );
3860
3516
  });
3517
+
3518
+ test("applyCompactionResult advances the persisted count from the trusted in-context boundary", async () => {
3519
+ // Trusted views slice past the already-compacted prefix, so a further
3520
+ // compaction advances the persisted count from the mirrored DB boundary.
3521
+
3522
+ // GIVEN a trusted (guardian) conversation that has already compacted 5
3523
+ // persisted messages, so its in-context history starts past that prefix
3524
+ const ctx = makeCtx({
3525
+ contextCompactedMessageCount: 5,
3526
+ trustContext: {
3527
+ trustClass: "guardian",
3528
+ } as Conversation["trustContext"],
3529
+ });
3530
+
3531
+ // WHEN a turn compacts 4 more in-context messages
3532
+ await applyCompactionResult(
3533
+ ctx,
3534
+ makeCompactionResult({ compactedPersistedMessages: 4 }),
3535
+ () => {},
3536
+ "req-1",
3537
+ );
3538
+
3539
+ // THEN the persisted count advances from the prior boundary (5 + 4)
3540
+ expect(ctx.contextCompactedMessageCount).toBe(9);
3541
+ });
3542
+
3543
+ test("applyCompactionResult resets the persisted count to the unsliced boundary for untrusted views", async () => {
3544
+ // Untrusted views render history unsliced (boundary 0), so a compaction
3545
+ // must record only the new summary's prefix instead of adding to the raw
3546
+ // mirror — otherwise future loads slice past unsummarized rows.
3547
+
3548
+ // GIVEN an untrusted view of a conversation whose raw DB count mirrors a
3549
+ // 5-message compacted prefix — but untrusted views render that history
3550
+ // unsliced (boundary 0), so the compactor operates on the full list
3551
+ const ctx = makeCtx({
3552
+ contextCompactedMessageCount: 5,
3553
+ trustContext: {
3554
+ trustClass: "unknown",
3555
+ } as Conversation["trustContext"],
3556
+ });
3557
+
3558
+ // WHEN that turn compacts 4 in-context messages
3559
+ await applyCompactionResult(
3560
+ ctx,
3561
+ makeCompactionResult({ compactedPersistedMessages: 4 }),
3562
+ () => {},
3563
+ "req-1",
3564
+ );
3565
+
3566
+ // THEN the persisted count reflects only the new summary's prefix (0 + 4)
3567
+ // rather than double-counting the raw mirror (which would yield 9)
3568
+ expect(ctx.contextCompactedMessageCount).toBe(4);
3569
+ });
3861
3570
  });
3862
3571
 
3863
3572
  describe("compaction-strip marker persistence", () => {
@@ -3894,9 +3603,10 @@ describe("session-agent-loop", () => {
3894
3603
  ],
3895
3604
  toolExecutor: async () => ({ content: "ok", isError: false }),
3896
3605
  contextWindowManager: {
3606
+ updateConfig: () => {},
3897
3607
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3898
3608
  maybeCompact: async () => ({ compacted: false }),
3899
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3609
+ } as unknown as Conversation["contextWindowManager"],
3900
3610
  });
3901
3611
 
3902
3612
  // WHEN the orchestrator runs the turn to completion
@@ -3943,9 +3653,10 @@ describe("session-agent-loop", () => {
3943
3653
  ],
3944
3654
  toolExecutor: async () => ({ content: "ok", isError: false }),
3945
3655
  contextWindowManager: {
3656
+ updateConfig: () => {},
3946
3657
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
3947
3658
  maybeCompact: async () => ({ compacted: false }),
3948
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
3659
+ } as unknown as Conversation["contextWindowManager"],
3949
3660
  });
3950
3661
 
3951
3662
  // Must not throw — the strip-site marker write is wrapped in try/catch.