@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
@@ -22,7 +22,6 @@ import type { Message } from "../providers/types.js";
22
22
 
23
23
  mock.module("../memory/conversation-crud.js", () => ({
24
24
  getConversationOverrideProfile: () => undefined,
25
- reserveMessage: mock(async () => ({ id: "msg-reserve" })),
26
25
  }));
27
26
 
28
27
  mock.module("../config/loader.js", () => ({
@@ -62,36 +61,37 @@ mock.module("../daemon/disk-pressure-guard.js", () => ({
62
61
  }),
63
62
  }));
64
63
 
64
+ import type { Conversation } from "../daemon/conversation.js";
65
65
  import {
66
66
  __resetWakeChainForTests,
67
67
  wakeAgentForOpportunity,
68
- type WakeTarget,
69
68
  } from "../runtime/agent-wake.js";
70
69
 
71
- function makeTarget(): WakeTarget {
72
- const history: Message[] = [];
70
+ function makeTarget(): Conversation {
71
+ const messages: Message[] = [];
73
72
  let processing = false;
74
- return {
73
+ const target = {
75
74
  conversationId: "conv-wake-callsite",
76
75
  agentLoop: {
77
- run: (async (messages: Message[]) => ({
76
+ run: async ({ messages }: { messages: Message[] }) => ({
78
77
  history: messages,
79
78
  exitReason: null,
80
79
  appendedNewMessages: false,
81
80
  newMessages: [],
82
- })) as WakeTarget["agentLoop"]["run"],
81
+ }),
83
82
  },
84
- getMessages: () => history,
85
- pushMessage: (msg) => {
86
- history.push(msg);
87
- },
88
- emitAgentEvent: () => {},
83
+ messages,
84
+ getMessages: () => messages,
89
85
  isProcessing: () => processing,
90
- markProcessing: (on) => {
86
+ setProcessing: (on: boolean) => {
91
87
  processing = on;
92
88
  },
93
- persistTailMessage: async () => {},
89
+ setTrustContext: () => {},
90
+ getTurnChannelContext: () => null,
91
+ getTurnInterfaceContext: () => null,
92
+ drainQueue: async () => {},
94
93
  };
94
+ return target as unknown as Conversation;
95
95
  }
96
96
 
97
97
  beforeEach(() => {
@@ -48,8 +48,9 @@ mock.module("../config/loader.js", () => ({
48
48
  }),
49
49
  }));
50
50
 
51
- import type { AgentEvent, AgentLoopRunOptions } from "../agent/loop.js";
51
+ import type { AgentLoopRunOptions } from "../agent/loop.js";
52
52
  import { LLMSchema } from "../config/schemas/llm.js";
53
+ import type { Conversation } from "../daemon/conversation.js";
53
54
  import { RetryProvider } from "../providers/retry.js";
54
55
  import type {
55
56
  Message,
@@ -60,7 +61,6 @@ import type {
60
61
  import {
61
62
  __resetWakeChainForTests,
62
63
  wakeAgentForOpportunity,
63
- type WakeTarget,
64
64
  } from "../runtime/agent-wake.js";
65
65
 
66
66
  interface RunArgs {
@@ -69,44 +69,40 @@ interface RunArgs {
69
69
  }
70
70
 
71
71
  function makeTarget(): {
72
- target: WakeTarget;
72
+ target: Conversation;
73
73
  runArgs: RunArgs[];
74
74
  } {
75
75
  const runArgs: RunArgs[] = [];
76
- const history: Message[] = [
76
+ const messages: Message[] = [
77
77
  { role: "user", content: [{ type: "text", text: "hi" }] },
78
78
  ];
79
79
  let processing = false;
80
80
 
81
- const target: WakeTarget = {
81
+ const target = {
82
82
  conversationId: "conv-wake-override",
83
83
  agentLoop: {
84
- run: (async (
85
- messages: Message[],
86
- _onEvent: (event: AgentEvent) => void | Promise<void>,
87
- options?: AgentLoopRunOptions,
88
- ) => {
84
+ run: async (options: AgentLoopRunOptions) => {
89
85
  runArgs.push({
90
- messages: [...messages],
86
+ messages: [...options.messages],
91
87
  options,
92
88
  });
93
89
  // Return the input verbatim → silent no-op (no assistant tail).
94
90
  // Wake never yields at a checkpoint, so the pause-reason is null.
95
- return { history: messages, exitReason: null };
96
- }) as WakeTarget["agentLoop"]["run"],
97
- },
98
- getMessages: () => history,
99
- pushMessage: (msg) => {
100
- history.push(msg);
91
+ return { history: options.messages, exitReason: null };
92
+ },
101
93
  },
102
- emitAgentEvent: () => {},
94
+ messages,
95
+ getMessages: () => messages,
103
96
  isProcessing: () => processing,
104
- markProcessing: (on) => {
97
+ setProcessing: (on: boolean) => {
105
98
  processing = on;
106
99
  },
107
- persistTailMessage: async () => {},
100
+ setTrustContext: () => {},
101
+ getTurnChannelContext: () => null,
102
+ getTurnInterfaceContext: () => null,
103
+ drainQueue: async () => {},
108
104
  };
109
- return { target, runArgs };
105
+ return { target: target as unknown as Conversation, runArgs };
110
106
  }
111
107
 
112
108
  beforeEach(() => {
@@ -102,7 +102,7 @@ mock.module("@anthropic-ai/sdk", () => ({
102
102
  }));
103
103
 
104
104
  // Import after mocking
105
- import { cachedTextBlock } from "../memory/v3/provider-blocks.js";
105
+ import { cachedTextBlock } from "../plugins/defaults/memory-v3-shadow/provider-blocks.js";
106
106
  import { AnthropicProvider } from "../providers/anthropic/client.js";
107
107
  import {
108
108
  isPlaceholderSentinelText,
@@ -81,6 +81,7 @@ mock.module("../bundler/app-compiler.js", () => ({
81
81
  import * as appCreateScript from "../config/bundled-skills/app-builder/tools/app-create.js";
82
82
  import * as appDeleteScript from "../config/bundled-skills/app-builder/tools/app-delete.js";
83
83
  import * as appRefreshScript from "../config/bundled-skills/app-builder/tools/app-refresh.js";
84
+ import * as appUpdateScript from "../config/bundled-skills/app-builder/tools/app-update.js";
84
85
 
85
86
  // ---------------------------------------------------------------------------
86
87
  // Tests
@@ -171,4 +172,24 @@ describe("app-builder skill tool scripts", () => {
171
172
  expect(parsed.appId).toBe("app-1");
172
173
  });
173
174
  });
175
+
176
+ // ---- app-update --------------------------------------------------------
177
+
178
+ describe("app-update", () => {
179
+ test("exports a run function", () => {
180
+ expect(typeof appUpdateScript.run).toBe("function");
181
+ });
182
+
183
+ test("delegates to executeAppUpdate and returns result", async () => {
184
+ const result = await appUpdateScript.run(
185
+ { app_id: "app-1", name: "Renamed" },
186
+ makeContext(),
187
+ );
188
+ expect(result.isError).toBe(false);
189
+ const parsed = JSON.parse(result.content);
190
+ expect(parsed.updated).toBe(true);
191
+ expect(parsed.appId).toBe("app-1");
192
+ expect(parsed.name).toBe("Renamed");
193
+ });
194
+ });
174
195
  });
@@ -103,7 +103,7 @@ const { ROUTES } = await import("../runtime/routes/host-app-control-routes.js");
103
103
  const { surfaceProxyResolver } =
104
104
  await import("../daemon/conversation-surfaces.js");
105
105
  const { setConversation, clearConversations } =
106
- await import("../daemon/conversation-store.js");
106
+ await import("../daemon/conversation-registry.js");
107
107
  type SurfaceConversationContext =
108
108
  import("../daemon/conversation-surfaces.js").SurfaceConversationContext;
109
109
 
@@ -19,6 +19,7 @@ const ALLOWLIST = new Set([
19
19
  "assistant/src/memory/app-store.ts", // defines getAppsDir
20
20
  "assistant/src/memory/app-git-service.ts", // uses getAppsDir for git repo root, not per-app paths
21
21
  "assistant/src/daemon/app-source-watcher.ts", // uses getAppsDir for recursive fs.watch root, not per-app paths
22
+ "assistant/src/tools/filesystem/write.ts", // uses getAppsDir as an exemption root for the artifact-HTML guard, not per-app paths
22
23
  ]);
23
24
 
24
25
  function isTestFile(filePath: string): boolean {
@@ -32,6 +32,7 @@ import type { AppStore } from "../tools/apps/executors.js";
32
32
  import {
33
33
  executeAppCreate,
34
34
  executeAppRefresh,
35
+ executeAppUpdate,
35
36
  } from "../tools/apps/executors.js";
36
37
 
37
38
  // ---------------------------------------------------------------------------
@@ -89,6 +90,46 @@ function mockStore(
89
90
  // ---------------------------------------------------------------------------
90
91
 
91
92
  describe("executeAppCreate", () => {
93
+ test("falls back to the preview title when the name is omitted or blank", async () => {
94
+ let createdParams: Record<string, unknown> | undefined;
95
+ const app = makeMultifileApp({ name: "Coffee Tracker" });
96
+ const store: AppStore = {
97
+ ...mockStore(app, {}),
98
+ createApp: (params) => {
99
+ createdParams = params as unknown as Record<string, unknown>;
100
+ return app;
101
+ },
102
+ };
103
+
104
+ const result = await executeAppCreate(
105
+ { name: " ", preview: { title: "Coffee Tracker" } },
106
+ store,
107
+ );
108
+
109
+ expect(result.isError).toBe(false);
110
+ expect(createdParams?.name).toBe("Coffee Tracker");
111
+ });
112
+
113
+ test("defaults the name to 'New App' when neither name nor preview title is given", async () => {
114
+ let createdParams: Record<string, unknown> | undefined;
115
+ const app = makeMultifileApp({ name: "New App" });
116
+ const store: AppStore = {
117
+ ...mockStore(app, {}),
118
+ createApp: (params) => {
119
+ createdParams = params as unknown as Record<string, unknown>;
120
+ return app;
121
+ },
122
+ };
123
+
124
+ const result = await executeAppCreate(
125
+ {} as unknown as Parameters<typeof executeAppCreate>[0],
126
+ store,
127
+ );
128
+
129
+ expect(result.isError).toBe(false);
130
+ expect(createdParams?.name).toBe("New App");
131
+ });
132
+
92
133
  test("creates multifile app with src/ scaffold", async () => {
93
134
  const files: Record<string, string> = {};
94
135
  let createdParams: Record<string, unknown> | undefined;
@@ -408,3 +449,94 @@ describe("executeAppRefresh", () => {
408
449
  expect(parsed.error).toContain("not found");
409
450
  });
410
451
  });
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // executeAppUpdate
455
+ // ---------------------------------------------------------------------------
456
+
457
+ describe("executeAppUpdate", () => {
458
+ test("updates metadata and recompiles a multifile app", async () => {
459
+ const app = makeMultifileApp({ name: "Old", description: "old desc" });
460
+ let updateArgs: unknown;
461
+ const store: AppStore = {
462
+ ...mockStore(app),
463
+ updateApp: (_id, updates) => {
464
+ updateArgs = updates;
465
+ return { ...app, ...updates };
466
+ },
467
+ };
468
+
469
+ const result = await executeAppUpdate(
470
+ { app_id: app.id, name: "New Name", description: "new desc" },
471
+ store,
472
+ );
473
+
474
+ expect(result.isError).toBe(false);
475
+ expect(updateArgs).toEqual({ name: "New Name", description: "new desc" });
476
+ const parsed = JSON.parse(result.content);
477
+ expect(parsed.updated).toBe(true);
478
+ expect(parsed.name).toBe("New Name");
479
+ expect(parsed.description).toBe("new desc");
480
+ expect(parsed.compiled).toBe(true);
481
+ });
482
+
483
+ test("writes source_files before recompiling", async () => {
484
+ const files: Record<string, string> = {};
485
+ const app = makeMultifileApp();
486
+ const store: AppStore = {
487
+ ...mockStore(app, files),
488
+ updateApp: (_id, updates) => ({ ...app, ...updates }),
489
+ };
490
+
491
+ const result = await executeAppUpdate(
492
+ { app_id: app.id, source_files: { "src/App.tsx": "// new code" } },
493
+ store,
494
+ );
495
+
496
+ expect(result.isError).toBe(false);
497
+ expect(files["src/App.tsx"]).toBe("// new code");
498
+ expect(JSON.parse(result.content).compiled).toBe(true);
499
+ });
500
+
501
+ test("legacy app: updates without compiling", async () => {
502
+ const app = makeLegacyApp({ name: "Legacy" });
503
+ const store: AppStore = {
504
+ ...mockStore(app),
505
+ updateApp: (_id, updates) => ({ ...app, ...updates }),
506
+ };
507
+
508
+ const result = await executeAppUpdate(
509
+ { app_id: app.id, name: "Renamed" },
510
+ store,
511
+ );
512
+
513
+ expect(result.isError).toBe(false);
514
+ const parsed = JSON.parse(result.content);
515
+ expect(parsed.updated).toBe(true);
516
+ expect(parsed.name).toBe("Renamed");
517
+ expect(parsed.compiled).toBeUndefined();
518
+ });
519
+
520
+ test("returns error for unknown app", async () => {
521
+ const store = mockStore(makeMultifileApp());
522
+ const result = await executeAppUpdate({ app_id: "nope" }, store);
523
+
524
+ expect(result.isError).toBe(true);
525
+ expect(JSON.parse(result.content).error).toContain("not found");
526
+ });
527
+
528
+ test("rejects invalid source_files", async () => {
529
+ const app = makeMultifileApp();
530
+ const store = mockStore(app);
531
+ const result = await executeAppUpdate(
532
+ {
533
+ app_id: app.id,
534
+ source_files: { "src/App.tsx": 123 as unknown as string },
535
+ },
536
+ store,
537
+ );
538
+
539
+ expect(result.isError).toBe(true);
540
+ expect(JSON.parse(result.content).error).toContain("must be a string");
541
+ });
542
+ });
@@ -176,6 +176,7 @@ mock.module("../memory/retriever.js", () => ({
176
176
  mock.module("../context/window-manager.js", () => ({
177
177
  ContextWindowManager: class {
178
178
  constructor() {}
179
+ updateConfig() {}
179
180
  shouldCompact() {
180
181
  return { needed: false, estimatedTokens: 0 };
181
182
  }
@@ -208,10 +209,10 @@ mock.module("../agent/loop.js", () => ({
208
209
  getActiveModel() {
209
210
  return undefined;
210
211
  }
211
- async run(
212
- _messages: Message[],
213
- _onEvent: (event: AgentEvent) => void,
214
- ): Promise<Message[]> {
212
+ async run(_options: {
213
+ messages: Message[];
214
+ onEvent: (event: AgentEvent) => void;
215
+ }): Promise<Message[]> {
215
216
  return [];
216
217
  }
217
218
  },
@@ -119,12 +119,15 @@ mock.module("../permissions/trust-store.js", () => ({
119
119
  // ---------------------------------------------------------------------------
120
120
  let _conversationFactory: (() => Conversation) | undefined;
121
121
 
122
- mock.module("../daemon/conversation-store.js", () => ({
122
+ mock.module("../daemon/conversation-registry.js", () => ({
123
123
  findConversation: () => {
124
124
  // Return the current test session for any conversation ID lookup.
125
125
  if (!_conversationFactory) return undefined;
126
126
  return _conversationFactory();
127
127
  },
128
+ }));
129
+
130
+ mock.module("../daemon/conversation-store.js", () => ({
128
131
  getOrCreateConversation: async () => {
129
132
  if (!_conversationFactory)
130
133
  throw new Error("_conversationFactory not set in test");
@@ -117,7 +117,7 @@ mock.module("../memory/conversation-crud.js", () => ({
117
117
  findMostRecentRetrospectiveFor: mock(() => null),
118
118
  forkConversation: mock(() => ({ id: "conv-fork" })),
119
119
  getConversationOverrideProfile: () => undefined,
120
- getConversationOverrideProfileFromRow: () => undefined,
120
+ resolveOverrideProfile: () => undefined,
121
121
  getConversationMemoryScopeId: () => "default",
122
122
  getConversationOriginChannel: () => null,
123
123
  getConversationOriginInterface: () => null,
@@ -16,7 +16,7 @@ mock.module("../util/logger.js", () => ({
16
16
  }));
17
17
 
18
18
  const _conversationMocks = new Map<string, unknown>();
19
- mock.module("../daemon/conversation-store.js", () => ({
19
+ mock.module("../daemon/conversation-registry.js", () => ({
20
20
  findConversation: (id: string) => _conversationMocks.get(id),
21
21
  }));
22
22
 
@@ -11,7 +11,7 @@ mock.module("../util/logger.js", () => ({
11
11
 
12
12
  // Map conversationId → mock session so findConversation returns the right mock.
13
13
  const conversationMocks = new Map<string, unknown>();
14
- mock.module("../daemon/conversation-store.js", () => ({
14
+ mock.module("../daemon/conversation-registry.js", () => ({
15
15
  findConversation: (id: string) => conversationMocks.get(id),
16
16
  }));
17
17
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Tests for the `CompactionCircuit` class.
3
+ *
4
+ * The breaker logic lives as direct methods on the per-conversation
5
+ * `CompactionCircuit` state container (`agent/compaction-circuit.ts`). These
6
+ * tests assert the threshold (3 consecutive failures) and cooldown (1 hour)
7
+ * exactly match the user-visible behavior:
8
+ * (a) counter increments on each failure outcome
9
+ * (b) circuit opens after exactly 3 consecutive failures
10
+ * (c) successful compaction resets counter and clears the circuit
11
+ * (d) isOpen() reflects state and cooldown expiry
12
+ * (e) circuit re-opens after cooldown expiry when 3 more failures
13
+ * accumulate (guards the stale-timestamp regression)
14
+ * (f) isOpen() is query-only and never mutates the counter
15
+ * (g) open→closed transition emits `compaction_circuit_closed` exactly once
16
+ * (h) closed→closed transition emits nothing
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
20
+
21
+ import {
22
+ COMPACTION_CIRCUIT_COOLDOWN_MS,
23
+ COMPACTION_CIRCUIT_FAILURE_THRESHOLD,
24
+ CompactionCircuit,
25
+ } from "../agent/compaction-circuit.js";
26
+ import type { CompactionCircuitEvent } from "../plugins/types.js";
27
+
28
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
29
+
30
+ const CONVERSATION_ID = "conv-breaker-test";
31
+
32
+ function collectEvents(): {
33
+ events: CompactionCircuitEvent[];
34
+ onEvent: (msg: CompactionCircuitEvent) => void;
35
+ } {
36
+ const events: CompactionCircuitEvent[] = [];
37
+ return { events, onEvent: (msg) => events.push(msg) };
38
+ }
39
+
40
+ describe("CompactionCircuit", () => {
41
+ let originalDateNow: () => number;
42
+
43
+ beforeEach(() => {
44
+ originalDateNow = Date.now;
45
+ });
46
+
47
+ afterEach(() => {
48
+ Date.now = originalDateNow;
49
+ });
50
+
51
+ test("threshold and cooldown constants match the user-visible contract", () => {
52
+ expect(COMPACTION_CIRCUIT_FAILURE_THRESHOLD).toBe(3);
53
+ expect(COMPACTION_CIRCUIT_COOLDOWN_MS).toBe(60 * 60 * 1000);
54
+ });
55
+
56
+ test("(a) counter increments on each failure outcome", async () => {
57
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
58
+ const { onEvent, events } = collectEvents();
59
+
60
+ await circuit.recordOutcome(true, onEvent);
61
+ expect(circuit.consecutiveCompactionFailures).toBe(1);
62
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
63
+ expect(events).toHaveLength(0);
64
+
65
+ await circuit.recordOutcome(true, onEvent);
66
+ expect(circuit.consecutiveCompactionFailures).toBe(2);
67
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
68
+ expect(events).toHaveLength(0);
69
+ });
70
+
71
+ test("(b) circuit opens after exactly 3 consecutive failures", async () => {
72
+ const fixedNow = 1_700_000_000_000;
73
+ Date.now = () => fixedNow;
74
+
75
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
76
+ const { onEvent, events } = collectEvents();
77
+
78
+ await circuit.recordOutcome(true, onEvent);
79
+ await circuit.recordOutcome(true, onEvent);
80
+ // Two failures — circuit still closed.
81
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
82
+ expect(events).toHaveLength(0);
83
+
84
+ await circuit.recordOutcome(true, onEvent);
85
+ // Third failure — circuit trips and fires the event exactly once.
86
+ expect(circuit.consecutiveCompactionFailures).toBe(3);
87
+ expect(circuit.compactionCircuitOpenUntil).toBe(fixedNow + 60 * 60 * 1000);
88
+ expect(await circuit.isOpen()).toBe(true);
89
+ expect(events).toHaveLength(1);
90
+ expect(events[0]).toEqual({
91
+ type: "compaction_circuit_open",
92
+ conversationId: CONVERSATION_ID,
93
+ reason: "3_consecutive_failures",
94
+ openUntil: fixedNow + 60 * 60 * 1000,
95
+ });
96
+
97
+ // Further failures do not re-fire the event while the circuit is open.
98
+ await circuit.recordOutcome(true, onEvent);
99
+ expect(circuit.consecutiveCompactionFailures).toBe(4);
100
+ expect(events).toHaveLength(1);
101
+ });
102
+
103
+ test("(c) successful outcome resets counter and clears circuit", async () => {
104
+ const fixedNow = 1_700_000_000_000;
105
+ Date.now = () => fixedNow;
106
+
107
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
108
+ const { onEvent } = collectEvents();
109
+
110
+ // Trip the breaker.
111
+ await circuit.recordOutcome(true, onEvent);
112
+ await circuit.recordOutcome(true, onEvent);
113
+ await circuit.recordOutcome(true, onEvent);
114
+ expect(circuit.compactionCircuitOpenUntil).not.toBeNull();
115
+
116
+ // Success resets state.
117
+ await circuit.recordOutcome(false, onEvent);
118
+ expect(circuit.consecutiveCompactionFailures).toBe(0);
119
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
120
+ });
121
+
122
+ test("(d) isOpen() reflects state and expiry", async () => {
123
+ const fixedNow = 1_700_000_000_000;
124
+ Date.now = () => fixedNow;
125
+
126
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
127
+ const { onEvent } = collectEvents();
128
+
129
+ // Fresh state: closed.
130
+ expect(await circuit.isOpen()).toBe(false);
131
+
132
+ // Trip the breaker.
133
+ await circuit.recordOutcome(true, onEvent);
134
+ await circuit.recordOutcome(true, onEvent);
135
+ await circuit.recordOutcome(true, onEvent);
136
+
137
+ // While open.
138
+ expect(await circuit.isOpen()).toBe(true);
139
+
140
+ // After cooldown expires the breaker reports closed again, even without
141
+ // an explicit reset — the open-until timestamp is the only source of
142
+ // truth for the gate.
143
+ Date.now = () => fixedNow + 60 * 60 * 1000 + 1;
144
+ expect(await circuit.isOpen()).toBe(false);
145
+ });
146
+
147
+ test("(e) circuit re-opens after cooldown expiry when 3 more failures accumulate", async () => {
148
+ // Regression: opening the breaker a second time must not require
149
+ // `compactionCircuitOpenUntil === null`. Once a cooldown expires, the
150
+ // gate correctly reports "closed" but a stale past-timestamp stays on the
151
+ // state, so the next 3-strike window must still trip a new cooldown. Any
152
+ // expired timestamp is treated the same as null.
153
+ const t0 = 1_700_000_000_000;
154
+ Date.now = () => t0;
155
+
156
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
157
+ const { onEvent, events } = collectEvents();
158
+
159
+ // Trip the breaker the first time.
160
+ await circuit.recordOutcome(true, onEvent);
161
+ await circuit.recordOutcome(true, onEvent);
162
+ await circuit.recordOutcome(true, onEvent);
163
+ expect(circuit.compactionCircuitOpenUntil).toBe(t0 + 60 * 60 * 1000);
164
+ expect(events).toHaveLength(1);
165
+
166
+ // Advance past the cooldown window. Reset the counter — in production this
167
+ // happens when a subsequent `maybeCompact` call succeeds after the
168
+ // cooldown elapses, but the bug manifests even when the counter is reset:
169
+ // the stale `compactionCircuitOpenUntil` is what breaks re-opening.
170
+ const t1 = t0 + 60 * 60 * 1000 + 1;
171
+ Date.now = () => t1;
172
+ expect(await circuit.isOpen()).toBe(false);
173
+ circuit.consecutiveCompactionFailures = 0;
174
+ // `compactionCircuitOpenUntil` is deliberately left as the old timestamp
175
+ // to reproduce the bug condition.
176
+ expect(circuit.compactionCircuitOpenUntil).toBe(t0 + 60 * 60 * 1000);
177
+
178
+ // Three more failures must trip a fresh cooldown even though the old
179
+ // timestamp is still set.
180
+ await circuit.recordOutcome(true, onEvent);
181
+ await circuit.recordOutcome(true, onEvent);
182
+ await circuit.recordOutcome(true, onEvent);
183
+ expect(circuit.consecutiveCompactionFailures).toBe(3);
184
+ expect(circuit.compactionCircuitOpenUntil).toBe(t1 + 60 * 60 * 1000);
185
+ expect(events).toHaveLength(2);
186
+ expect(events[1]).toEqual({
187
+ type: "compaction_circuit_open",
188
+ conversationId: CONVERSATION_ID,
189
+ reason: "3_consecutive_failures",
190
+ openUntil: t1 + 60 * 60 * 1000,
191
+ });
192
+ });
193
+
194
+ test("(f) isOpen() is query-only and never mutates the counter", async () => {
195
+ // `maybeCompact()` early-return paths skip `recordOutcome` entirely and
196
+ // only gate on `isOpen()`. The query must never touch the 3-strike
197
+ // counter so those early returns can't silently reset it.
198
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
199
+ const { onEvent } = collectEvents();
200
+
201
+ await circuit.recordOutcome(true, onEvent);
202
+ await circuit.recordOutcome(true, onEvent);
203
+ expect(circuit.consecutiveCompactionFailures).toBe(2);
204
+
205
+ // Query-only — must NOT touch the counter.
206
+ expect(await circuit.isOpen()).toBe(false);
207
+ expect(circuit.consecutiveCompactionFailures).toBe(2);
208
+
209
+ // A third real failure then trips the breaker as expected.
210
+ await circuit.recordOutcome(true, onEvent);
211
+ expect(circuit.consecutiveCompactionFailures).toBe(3);
212
+ expect(circuit.compactionCircuitOpenUntil).not.toBeNull();
213
+ });
214
+
215
+ test("(g) open→closed transition emits compaction_circuit_closed exactly once", async () => {
216
+ // Regression: the reset branch must notify the client on open→closed.
217
+ // Otherwise the Swift banner set from `compaction_circuit_open` stays
218
+ // visible until the original `openUntil` deadline (up to 1h),
219
+ // misrepresenting the live state.
220
+ const fixedNow = 1_700_000_000_000;
221
+ Date.now = () => fixedNow;
222
+
223
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
224
+ const { onEvent, events } = collectEvents();
225
+
226
+ // Force the circuit into the open state directly — the emitted-event
227
+ // transition logic is what we're testing, not the tripping path.
228
+ circuit.compactionCircuitOpenUntil = fixedNow + 60 * 60 * 1000;
229
+ circuit.consecutiveCompactionFailures = 3;
230
+
231
+ await circuit.recordOutcome(false, onEvent);
232
+
233
+ expect(circuit.consecutiveCompactionFailures).toBe(0);
234
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
235
+ expect(events).toHaveLength(1);
236
+ expect(events[0]).toEqual({
237
+ type: "compaction_circuit_closed",
238
+ conversationId: CONVERSATION_ID,
239
+ });
240
+ });
241
+
242
+ test("(h) successful outcome against an already-closed circuit emits no event", async () => {
243
+ // Emitting `compaction_circuit_closed` on every successful compaction
244
+ // would spam the client (the breaker is closed in the common case).
245
+ // Only the open→closed transition is meaningful.
246
+ const circuit = new CompactionCircuit(CONVERSATION_ID);
247
+ const { onEvent, events } = collectEvents();
248
+
249
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
250
+ await circuit.recordOutcome(false, onEvent);
251
+ expect(circuit.compactionCircuitOpenUntil).toBeNull();
252
+ expect(events).toHaveLength(0);
253
+
254
+ // A second successful outcome while still closed — still no event.
255
+ await circuit.recordOutcome(false, onEvent);
256
+ expect(events).toHaveLength(0);
257
+ });
258
+ });