@vellumai/assistant 0.8.8 → 0.8.9-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (360) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/examples/plugins/echo/README.md +61 -60
  4. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  5. package/examples/plugins/echo/hooks/stop.ts +16 -0
  6. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  7. package/examples/plugins/echo/package.json +1 -2
  8. package/examples/plugins/echo/src/emit.ts +19 -0
  9. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  10. package/openapi.yaml +235 -6
  11. package/package.json +2 -2
  12. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  13. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  14. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  15. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  16. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  17. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  18. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  19. package/src/__tests__/agent-loop.test.ts +441 -96
  20. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  21. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  22. package/src/__tests__/anthropic-provider.test.ts +1 -1
  23. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  24. package/src/__tests__/app-control-flow.test.ts +1 -1
  25. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  26. package/src/__tests__/app-executors.test.ts +132 -0
  27. package/src/__tests__/approval-cascade.test.ts +5 -4
  28. package/src/__tests__/approval-routes-http.test.ts +4 -1
  29. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  30. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  31. package/src/__tests__/channel-approvals.test.ts +1 -1
  32. package/src/__tests__/compaction-circuit.test.ts +258 -0
  33. package/src/__tests__/compaction-direct.test.ts +132 -0
  34. package/src/__tests__/compaction-events.test.ts +5 -5
  35. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  36. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  37. package/src/__tests__/conversation-abort-tool-results.test.ts +6 -4
  38. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  39. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  40. package/src/__tests__/conversation-agent-loop-overflow.test.ts +142 -218
  41. package/src/__tests__/conversation-agent-loop.test.ts +297 -586
  42. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  43. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -4
  44. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  45. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  46. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -4
  47. package/src/__tests__/conversation-process-callsite.test.ts +14 -14
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +70 -65
  49. package/src/__tests__/conversation-queue.test.ts +9 -9
  50. package/src/__tests__/conversation-runtime-assembly.test.ts +923 -231
  51. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  52. package/src/__tests__/conversation-slash-queue.test.ts +6 -4
  53. package/src/__tests__/conversation-slash-unknown.test.ts +6 -4
  54. package/src/__tests__/conversation-speed-override.test.ts +10 -9
  55. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  56. package/src/__tests__/conversation-workspace-cache-state.test.ts +23 -20
  57. package/src/__tests__/conversation-workspace-injection.test.ts +68 -6
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +14 -11
  59. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +0 -1
  61. package/src/__tests__/db-acp-history.test.ts +101 -0
  62. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  63. package/src/__tests__/empty-response-hook.test.ts +1 -1
  64. package/src/__tests__/file-write-tool.test.ts +63 -0
  65. package/src/__tests__/gateway-only-guard.test.ts +12 -2
  66. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  67. package/src/__tests__/guardian-routing-invariants.test.ts +2 -4
  68. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  69. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  70. package/src/__tests__/heartbeat-service.test.ts +1 -0
  71. package/src/__tests__/history-repair-hook.test.ts +1 -1
  72. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  73. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  74. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  75. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  76. package/src/__tests__/injector-background-turn.test.ts +13 -23
  77. package/src/__tests__/injector-chain.test.ts +268 -44
  78. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  79. package/src/__tests__/injector-document-comments.test.ts +97 -114
  80. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  81. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  82. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  83. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  84. package/src/__tests__/memory-retrieval-hook.test.ts +131 -26
  85. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  86. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  87. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  88. package/src/__tests__/plugin-bootstrap.test.ts +12 -23
  89. package/src/__tests__/plugin-registry.test.ts +3 -49
  90. package/src/__tests__/plugin-types.test.ts +0 -70
  91. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  92. package/src/__tests__/reaction-persistence.test.ts +1 -1
  93. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  94. package/src/__tests__/skill-feature-flags-integration.test.ts +33 -0
  95. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  96. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  97. package/src/__tests__/subagent-detail.test.ts +25 -7
  98. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  99. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  100. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  101. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  102. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  103. package/src/__tests__/title-generate-hook.test.ts +1 -1
  104. package/src/__tests__/tool-error-hook.test.ts +1 -1
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  106. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  107. package/src/acp/__tests__/agent-process.test.ts +161 -0
  108. package/src/acp/__tests__/client-handler.test.ts +40 -0
  109. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  110. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  111. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  112. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  113. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  114. package/src/acp/agent-process.ts +61 -1
  115. package/src/acp/auto-install.test.ts +280 -0
  116. package/src/acp/auto-install.ts +232 -0
  117. package/src/acp/client-handler.ts +31 -0
  118. package/src/acp/feature-gate.test.ts +48 -0
  119. package/src/acp/feature-gate.ts +34 -0
  120. package/src/acp/prepare-agent-env.ts +80 -27
  121. package/src/acp/resolve-agent.test.ts +225 -9
  122. package/src/acp/resolve-agent.ts +122 -17
  123. package/src/acp/resume-hint.ts +23 -0
  124. package/src/acp/session-manager.ts +507 -73
  125. package/src/agent/compaction-circuit.ts +60 -102
  126. package/src/agent/loop.ts +414 -248
  127. package/src/api/responses/conversation-message.ts +14 -1
  128. package/src/approvals/guardian-request-resolvers.ts +1 -1
  129. package/src/background-wake/next-wake.ts +1 -0
  130. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  131. package/src/cli/commands/plugins.ts +43 -37
  132. package/src/cli/lib/__tests__/install-from-github.test.ts +429 -111
  133. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  134. package/src/cli/lib/__tests__/plugin-details.test.ts +372 -0
  135. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +220 -0
  136. package/src/cli/lib/__tests__/search-plugins.test.ts +226 -32
  137. package/src/cli/lib/install-from-github.ts +464 -55
  138. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  139. package/src/cli/lib/plugin-details.ts +409 -0
  140. package/src/cli/lib/plugin-marketplace.ts +197 -0
  141. package/src/cli/lib/search-plugins.ts +195 -29
  142. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  143. package/src/config/acp-defaults.test.ts +10 -0
  144. package/src/config/acp-defaults.ts +6 -0
  145. package/src/config/bundled-skills/acp/SKILL.md +85 -33
  146. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  147. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  148. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  149. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  150. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  151. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  152. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  153. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  154. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  155. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  156. package/src/config/bundled-tool-registry.ts +4 -0
  157. package/src/config/call-site-defaults.ts +0 -2
  158. package/src/config/feature-flag-registry.json +15 -6
  159. package/src/config/schemas/call-site-catalog.ts +0 -14
  160. package/src/config/schemas/heartbeat.ts +9 -0
  161. package/src/config/schemas/llm.ts +0 -2
  162. package/src/context/compactor.ts +22 -4
  163. package/src/context/strip-injections.ts +8 -2
  164. package/src/context/window-manager.ts +27 -13
  165. package/src/daemon/conversation-agent-loop-handlers.ts +10 -35
  166. package/src/daemon/conversation-agent-loop.ts +175 -1055
  167. package/src/daemon/conversation-lifecycle.ts +11 -255
  168. package/src/daemon/conversation-process.ts +8 -136
  169. package/src/daemon/conversation-registry.ts +159 -0
  170. package/src/daemon/conversation-runtime-assembly.ts +293 -392
  171. package/src/daemon/conversation-store.ts +9 -90
  172. package/src/daemon/conversation-surfaces.ts +24 -8
  173. package/src/daemon/conversation-workspace.ts +17 -0
  174. package/src/daemon/conversation.ts +404 -57
  175. package/src/daemon/disk-pressure-policy.ts +0 -1
  176. package/src/daemon/external-plugins-bootstrap.ts +14 -19
  177. package/src/daemon/handlers/conversations.ts +3 -1
  178. package/src/daemon/handlers/skills.ts +4 -1
  179. package/src/daemon/host-proxy-preactivation.ts +1 -3
  180. package/src/daemon/lifecycle.ts +21 -7
  181. package/src/daemon/server.ts +2 -0
  182. package/src/daemon/wake-conversation-ops.ts +269 -0
  183. package/src/embedded/plugin-api.ts +2 -2
  184. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  185. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  186. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  187. package/src/heartbeat/heartbeat-service.ts +26 -0
  188. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  189. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  190. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  191. package/src/ipc/skill-routes/memory.ts +4 -2
  192. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  193. package/src/memory/conversation-crud.ts +29 -19
  194. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  195. package/src/memory/db-init.ts +2 -0
  196. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  197. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  198. package/src/memory/jobs-worker.ts +25 -1
  199. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  200. package/src/memory/migrations/index.ts +1 -0
  201. package/src/memory/schema/acp.ts +4 -0
  202. package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
  203. package/src/memory/v2/consolidation-job.ts +13 -4
  204. package/src/plugin-api/constants.ts +4 -0
  205. package/src/plugin-api/index.ts +6 -5
  206. package/src/plugin-api/types.ts +75 -0
  207. package/src/plugins/defaults/compaction/compact.ts +59 -0
  208. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  209. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  210. package/src/plugins/defaults/compaction/package.json +1 -2
  211. package/src/plugins/defaults/empty-response/package.json +0 -1
  212. package/src/plugins/defaults/history-repair/package.json +0 -1
  213. package/src/plugins/defaults/index.ts +135 -26
  214. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  215. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +184 -74
  216. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  217. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  218. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  219. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/assign.test.ts +4 -4
  220. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +9 -6
  221. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/maintain-job.test.ts +5 -5
  222. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/orchestrate.test.ts +8 -5
  223. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/reconcile.test.ts +2 -2
  224. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/render-injection.test.ts +1 -1
  225. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/router.test.ts +10 -5
  226. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +8 -8
  227. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selector.test.ts +5 -5
  228. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/shadow-plugin.test.ts +16 -17
  229. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -2
  230. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/assign.ts +9 -5
  231. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/capabilities.ts +5 -2
  232. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  233. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  234. package/src/plugins/defaults/memory-v3-shadow/injector.ts +75 -0
  235. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/maintain-job.ts +15 -8
  236. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/orchestrate.ts +2 -2
  237. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  238. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/page-content.ts +2 -2
  239. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/provider-blocks.ts +1 -1
  240. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/reconcile.ts +7 -3
  241. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +1 -1
  242. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/router.ts +5 -5
  243. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selection-log-store.ts +4 -4
  244. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selector.ts +7 -7
  245. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/shadow-plugin.ts +32 -94
  246. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/tree.ts +1 -1
  247. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/types.ts +1 -1
  248. package/src/plugins/defaults/title-generate/package.json +0 -1
  249. package/src/plugins/defaults/tool-error/package.json +0 -1
  250. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  251. package/src/plugins/pipeline.ts +6 -293
  252. package/src/plugins/registry.ts +9 -37
  253. package/src/plugins/types.ts +76 -381
  254. package/src/plugins/user-loader.ts +30 -127
  255. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  256. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +35 -3
  257. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  258. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  259. package/src/runtime/agent-wake.ts +108 -209
  260. package/src/runtime/assistant-event-hub.ts +1 -1
  261. package/src/runtime/channel-approvals.ts +1 -1
  262. package/src/runtime/interactive-ui.ts +1 -1
  263. package/src/runtime/routes/__tests__/acp-routes.test.ts +315 -55
  264. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  265. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  266. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  267. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  268. package/src/runtime/routes/acp-routes.test.ts +89 -25
  269. package/src/runtime/routes/acp-routes.ts +81 -29
  270. package/src/runtime/routes/approval-routes.ts +1 -1
  271. package/src/runtime/routes/browser-routes.ts +1 -1
  272. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  273. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  274. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  275. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  276. package/src/runtime/routes/conversation-routes.ts +28 -2
  277. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  278. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  279. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  280. package/src/runtime/routes/host-cu-routes.ts +1 -1
  281. package/src/runtime/routes/identity-routes.ts +18 -3
  282. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  283. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  284. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  285. package/src/runtime/routes/memory-v3-routes.ts +16 -6
  286. package/src/runtime/routes/playground/helpers.ts +1 -1
  287. package/src/runtime/routes/plugins-routes.ts +337 -35
  288. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  289. package/src/runtime/routes/work-items-routes.ts +2 -4
  290. package/src/runtime/services/conversation-serializer.ts +1 -1
  291. package/src/signals/cancel.ts +2 -4
  292. package/src/subagent/manager.ts +21 -5
  293. package/src/tools/acp/context.ts +20 -0
  294. package/src/tools/acp/list-agents.test.ts +8 -2
  295. package/src/tools/acp/spawn.test.ts +176 -195
  296. package/src/tools/acp/spawn.ts +37 -172
  297. package/src/tools/acp/steer.test.ts +105 -8
  298. package/src/tools/acp/steer.ts +48 -17
  299. package/src/tools/apps/executors.ts +166 -50
  300. package/src/tools/filesystem/write.ts +34 -0
  301. package/src/tools/subagent/spawn.ts +2 -4
  302. package/src/tools/ui-surface/definitions.ts +25 -5
  303. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  304. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  305. package/docs/plugins.md +0 -832
  306. package/examples/plugins/echo/register.ts +0 -143
  307. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  308. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  309. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  310. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  311. package/src/__tests__/pipeline-runner.test.ts +0 -554
  312. package/src/__tests__/plugin-external-api.test.ts +0 -68
  313. package/src/daemon/wake-target-adapter.ts +0 -253
  314. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  315. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  316. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  317. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  318. package/src/plugins/defaults/compaction/register.ts +0 -35
  319. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  320. package/src/plugins/defaults/empty-response/register.ts +0 -23
  321. package/src/plugins/defaults/history-repair/register.ts +0 -24
  322. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  323. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  324. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  325. package/src/plugins/defaults/title-generate/register.ts +0 -35
  326. package/src/plugins/defaults/tool-error/register.ts +0 -23
  327. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  328. package/src/plugins/external-api.ts +0 -104
  329. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  330. package/src/proactive-artifact/decision.test.ts +0 -226
  331. package/src/proactive-artifact/decision.ts +0 -165
  332. package/src/proactive-artifact/index.ts +0 -7
  333. package/src/proactive-artifact/job.test.ts +0 -962
  334. package/src/proactive-artifact/job.ts +0 -372
  335. package/src/proactive-artifact/message-copy.ts +0 -58
  336. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  337. package/src/proactive-artifact/trigger-state.ts +0 -123
  338. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/capabilities.test.ts +0 -0
  339. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/core.test.ts +0 -0
  340. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  341. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  342. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/health.test.ts +0 -0
  343. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/needle.test.ts +0 -0
  344. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/provider-blocks.test.ts +0 -0
  345. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/snapshot.test.ts +0 -0
  346. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/tree.test.ts +0 -0
  347. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  348. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  349. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/core.ts +0 -0
  350. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  351. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  352. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  353. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  354. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  355. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  356. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/health.ts +0 -0
  357. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
  358. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/needle.ts +0 -0
  359. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/snapshot.ts +0 -0
  360. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +0 -0
@@ -2,52 +2,165 @@
2
2
  * Tests for `wakeAgentForOpportunity()` — the generic internal agent-wake
3
3
  * mechanism.
4
4
  *
5
- * Exercise strategy: the wake helper takes a `resolveTarget` dependency so
6
- * these tests stub out the heavyweight `Conversation` class with a minimal
7
- * `WakeTarget` that just tracks agent-event forwards, buffered messages,
8
- * persisted tail messages, drain invocations, and a scripted
9
- * `agentLoop.run()` response.
5
+ * Exercise strategy: the wake helper takes a `resolveTarget` dependency that
6
+ * yields a live `Conversation`. These tests build a lightweight structural
7
+ * double typed as `Conversation` (`makeWakeConversation`) that stubs only the
8
+ * handful of members the wake touches `getMessages`, `messages.push`,
9
+ * `isProcessing`/`setProcessing`, `setTrustContext`, `setSubagentAllowedTools`,
10
+ * `drainQueue`, and a scripted `agentLoop.run()`.
10
11
  *
11
- * Persistence is now delegated to `WakeTarget.persistTailMessage` (the
12
- * daemon adapter is responsible for building channel/interface metadata
13
- * and disk-view sync out of scope for runtime tests), so we assert on
14
- * the calls received by the mock instead of stubbing
15
- * `memory/conversation-crud.js`.
12
+ * The wake's side effects flow through the daemon boundary, so the
13
+ * instrumentation is captured at that boundary: event emission and the
14
+ * ui_surface card via the `broadcastMessage` module mock, and tail
15
+ * persistence via the `addMessage` module mock. Each double registers itself
16
+ * in `wakeConvRegistry` keyed by `conversationId` so those module mocks can
17
+ * route captured calls back to the originating conversation's probe arrays.
16
18
  */
17
19
 
18
20
  import { beforeEach, describe, expect, mock, test } from "bun:test";
19
21
 
20
22
  import type { DiskPressureStatus } from "../../daemon/disk-pressure-guard.js";
21
23
 
24
+ // ── Per-conversation capture registry ────────────────────────────────
25
+ //
26
+ // Module mocks for the daemon boundary (`broadcastMessage`, `addMessage`)
27
+ // are process-global, so each test double registers itself here keyed by
28
+ // `conversationId`. The mocks look up the originating conversation's probe
29
+ // and record the captured frame / persisted message.
30
+
31
+ /** Captured client wire frame (output of the event→wire translator). */
32
+ interface CapturedFrame {
33
+ type: string;
34
+ text?: string;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /** Instrumentation attached to a `makeWakeConversation` double. */
39
+ interface WakeConversationProbe {
40
+ /** Wire frames emitted to clients (excludes the ui_surface card). */
41
+ emittedEvents: CapturedFrame[];
42
+ /** ui_surface cards broadcast when a wake produces output. */
43
+ surfaceBroadcasts: Array<{ surfaceId?: string; source?: string }>;
44
+ /** Messages appended to live history via `conversation.messages.push`. */
45
+ pushedMessages: Message[];
46
+ /** Recorded `agentLoop.run` invocations, in call order. */
47
+ runCalls: Array<{
48
+ input: Message[];
49
+ requestId?: string;
50
+ trust?: unknown;
51
+ allowedTools?: string[];
52
+ order: number;
53
+ }>;
54
+ /** Every `setProcessing` value, in call order. */
55
+ processingToggles: boolean[];
56
+ /** Tail messages persisted via `addMessage`, in call order. */
57
+ persistedTailCalls: Message[];
58
+ /** Number of times `drainQueue` was invoked. */
59
+ drainQueueCalls: number;
60
+ /**
61
+ * Cross-hook call sequence tag. Each push/persist/drain (and the
62
+ * processing toggles that bracket them) appends an entry so tests can
63
+ * assert end-to-end ordering, not just per-hook counts.
64
+ */
65
+ callSequence: string[];
66
+ /**
67
+ * Snapshot of the processing flag at the moment `drainQueue` was
68
+ * invoked. Lets tests prove drain ran AFTER setProcessing(false),
69
+ * rather than just inferring it from the order of recorded toggles.
70
+ */
71
+ processingDuringDrain: boolean[];
72
+ /**
73
+ * Tool allowlist snapshots captured whenever the wake applies/restores a
74
+ * scope. `undefined` means unrestricted.
75
+ */
76
+ allowedToolSnapshots: Array<string[] | undefined>;
77
+ /** `setTrustContext` calls, with the value and a monotonic order tag. */
78
+ setTrustContextCalls: Array<{ ctx: unknown; order: number }>;
79
+ /** Number of persisted tail messages at the moment each frame emitted. */
80
+ persistedAtEachEmit: number[];
81
+ }
82
+
83
+ const wakeConvRegistry = new Map<string, WakeConversationProbe>();
84
+
22
85
  // Stub the DB-backed override-profile read so unit tests don't need a
23
86
  // real SQLite database. The wake helper calls this on every invocation
24
- // to honor the conversation's pinned inference profile.
25
- // `getConversation` is consumed by `defaultResolveTarget` most tests
26
- // pass explicit `deps.resolveTarget` and bypass it, but the
27
- // trust-context threading test below drives the default resolver and
28
- // needs the existence/archived check to pass.
87
+ // to honor the conversation's pinned inference profile. `getConversation`
88
+ // is consumed by `defaultResolveTarget` (existence/archived check) and by
89
+ // `persistWakeTailMessage` (createdAt for the disk-view sync). `addMessage`
90
+ // is the persistence boundary the wake's tail persistence flows through —
91
+ // it records into the originating conversation's probe.
29
92
  mock.module("../../memory/conversation-crud.js", () => ({
30
93
  getConversationOverrideProfile: () => undefined,
31
- getConversation: () => ({ archivedAt: null }),
32
- reserveMessage: mock(async () => ({ id: "msg-reserve" })),
94
+ getConversation: () => ({
95
+ archivedAt: null,
96
+ createdAt: "2026-01-01T00:00:00.000Z",
97
+ }),
98
+ provenanceFromTrustContext: () => ({}),
99
+ addMessage: async (
100
+ conversationId: string,
101
+ role: string,
102
+ contentJson: string,
103
+ ) => {
104
+ const probe = wakeConvRegistry.get(conversationId);
105
+ if (probe) {
106
+ let content: unknown = contentJson;
107
+ try {
108
+ content = JSON.parse(contentJson);
109
+ } catch {
110
+ // Leave the raw string if it is not valid JSON.
111
+ }
112
+ probe.persistedTailCalls.push({ role, content } as Message);
113
+ probe.callSequence.push("persist");
114
+ }
115
+ return { id: `msg-${probe ? probe.persistedTailCalls.length : 0}` };
116
+ },
117
+ }));
118
+
119
+ // The wake's tail persistence syncs each row to the disk view. Stub it so
120
+ // unit tests don't touch the filesystem.
121
+ mock.module("../../memory/conversation-disk-view.js", () => ({
122
+ syncMessageToDisk: () => {},
123
+ }));
124
+
125
+ // The daemon event boundary. `emitWakeAgentEvent` translates each agent
126
+ // event to a wire frame and broadcasts it; `broadcastWakeSurface` broadcasts
127
+ // the ui_surface card. Route both back to the originating conversation's
128
+ // probe by the `conversationId` stamped on the frame.
129
+ mock.module("../../runtime/assistant-event-hub.js", () => ({
130
+ broadcastMessage: (frame: CapturedFrame & { conversationId?: string }) => {
131
+ const probe = frame.conversationId
132
+ ? wakeConvRegistry.get(frame.conversationId)
133
+ : undefined;
134
+ if (!probe) return;
135
+ if (frame.type === "ui_surface_show") {
136
+ const source = (
137
+ frame.data as
138
+ | { metadata?: Array<{ label?: string; value?: string }> }
139
+ | undefined
140
+ )?.metadata?.find((m) => m.label === "Source")?.value;
141
+ probe.surfaceBroadcasts.push({
142
+ surfaceId: frame.surfaceId as string | undefined,
143
+ source,
144
+ });
145
+ return;
146
+ }
147
+ probe.emittedEvents.push(frame);
148
+ probe.persistedAtEachEmit.push(probe.persistedTailCalls.length);
149
+ },
33
150
  }));
34
151
 
35
152
  const mockGetOrCreateConversationCalls: Array<{
36
153
  conversationId: string;
37
154
  options: unknown;
38
155
  }> = [];
156
+ let mockResolverTarget: unknown = null;
39
157
  mock.module("../../daemon/conversation-store.js", () => ({
40
158
  getOrCreateConversation: (conversationId: string, options?: unknown) => {
41
159
  mockGetOrCreateConversationCalls.push({ conversationId, options });
42
- return Promise.resolve({ __mockConversation: true });
160
+ return Promise.resolve(mockResolverTarget);
43
161
  },
44
162
  }));
45
163
 
46
- let mockResolverTarget: unknown = null;
47
- mock.module("../../daemon/wake-target-adapter.js", () => ({
48
- conversationToWakeTarget: () => mockResolverTarget,
49
- }));
50
-
51
164
  mock.module("../../config/loader.js", () => ({
52
165
  getConfig: () => ({ llm: {} }),
53
166
  loadConfig: () => ({ llm: {} }),
@@ -122,11 +235,11 @@ import type {
122
235
  AgentLoopRunOptions,
123
236
  AgentLoopRunResult,
124
237
  } from "../../agent/loop.js";
238
+ import type { Conversation } from "../../daemon/conversation.js";
125
239
  import type { Message } from "../../providers/types.js";
126
240
  import {
127
241
  __resetWakeChainForTests,
128
242
  wakeAgentForOpportunity,
129
- type WakeTarget,
130
243
  } from "../agent-wake.js";
131
244
 
132
245
  // ── Test helpers ─────────────────────────────────────────────────────
@@ -142,40 +255,16 @@ const runResult = (history: Message[]): AgentLoopRunResult => ({
142
255
  newMessages: [],
143
256
  });
144
257
 
145
- interface MockTarget extends WakeTarget {
146
- emittedEvents: AgentEvent[];
147
- pushedMessages: Message[];
148
- runCalls: Array<{
149
- input: Message[];
150
- requestId?: string;
151
- turnContext?: unknown;
152
- allowedTools?: string[];
153
- }>;
154
- processingToggles: boolean[];
155
- /** Tail messages handed to `persistTailMessage`, in call order. */
156
- persistedTailCalls: Message[];
157
- /** Number of times `drainQueue` was invoked. */
158
- drainQueueCalls: number;
159
- /**
160
- * Cross-hook call sequence tag. Each push/persist/drain (and the
161
- * processing toggles that bracket them) appends an entry so tests can
162
- * assert end-to-end ordering, not just per-hook counts.
163
- */
164
- callSequence: string[];
165
- /**
166
- * Snapshot of `processing` at the moment `drainQueue` was invoked.
167
- * Lets tests prove drain ran AFTER markProcessing(false), rather than
168
- * just inferring it from the order of recorded toggles.
169
- */
170
- processingDuringDrain: boolean[];
171
- /**
172
- * Tool allowlist snapshots captured whenever the wake applies/restores a
173
- * scope. `undefined` means unrestricted.
174
- */
175
- allowedToolSnapshots: Array<string[] | undefined>;
176
- }
258
+ /** A structural `Conversation` double plus its captured instrumentation. */
259
+ type WakeConversation = Conversation & WakeConversationProbe;
177
260
 
178
- function makeTarget(options: {
261
+ type ScriptedRun = (
262
+ input: Message[],
263
+ onEvent: (event: AgentEvent) => void | Promise<void>,
264
+ runOptions?: AgentLoopRunOptions,
265
+ ) => Promise<AgentLoopRunResult>;
266
+
267
+ function makeWakeConversation(options: {
179
268
  conversationId?: string;
180
269
  baseline?: Message[];
181
270
  scriptedAssistant?: Message | null;
@@ -186,131 +275,127 @@ function makeTarget(options: {
186
275
  /** When true, omit `drainQueue` so we can verify the wake handles its absence. */
187
276
  omitDrainQueue?: boolean;
188
277
  initialAllowedTools?: Set<string>;
189
- }): MockTarget {
190
- const emittedEvents: AgentEvent[] = [];
191
- const pushedMessages: Message[] = [];
192
- const runCalls: Array<{
193
- input: Message[];
194
- requestId?: string;
195
- turnContext?: unknown;
196
- allowedTools?: string[];
197
- }> = [];
198
- const processingToggles: boolean[] = [];
199
- const persistedTailCalls: Message[] = [];
200
- const callSequence: string[] = [];
201
- const processingDuringDrain: boolean[] = [];
202
- const allowedToolSnapshots: Array<string[] | undefined> = [];
203
- const history: Message[] = [...(options.baseline ?? [])];
278
+ /** Replaces the default scripted `agentLoop.run` body entirely. */
279
+ runImpl?: ScriptedRun;
280
+ }): WakeConversation {
281
+ const conversationId = options.conversationId ?? "conv-test";
282
+ const probe: WakeConversationProbe = {
283
+ emittedEvents: [],
284
+ surfaceBroadcasts: [],
285
+ pushedMessages: [],
286
+ runCalls: [],
287
+ processingToggles: [],
288
+ persistedTailCalls: [],
289
+ drainQueueCalls: 0,
290
+ callSequence: [],
291
+ processingDuringDrain: [],
292
+ allowedToolSnapshots: [],
293
+ setTrustContextCalls: [],
294
+ persistedAtEachEmit: [],
295
+ };
296
+ wakeConvRegistry.set(conversationId, probe);
297
+
204
298
  let processing = options.isProcessing ?? false;
205
- let drainQueueCalls = 0;
299
+ let order = 0;
206
300
  let activeAllowedTools = options.initialAllowedTools;
207
301
  const snapshotAllowedTools = (): string[] | undefined =>
208
302
  activeAllowedTools ? [...activeAllowedTools].sort() : undefined;
209
303
 
210
- const target: MockTarget = {
211
- conversationId: options.conversationId ?? "conv-test",
212
- emittedEvents,
213
- pushedMessages,
214
- runCalls,
215
- processingToggles,
216
- persistedTailCalls,
217
- callSequence,
218
- processingDuringDrain,
219
- allowedToolSnapshots,
304
+ const messages: Message[] = [...(options.baseline ?? [])];
305
+ const nativePush = messages.push.bind(messages);
306
+ messages.push = (...items: Message[]): number => {
307
+ for (const item of items) {
308
+ probe.pushedMessages.push(item);
309
+ probe.callSequence.push("push");
310
+ }
311
+ return nativePush(...items);
312
+ };
313
+
314
+ const defaultRun: ScriptedRun = async (input, onEvent) => {
315
+ for (const ev of options.scriptedEvents ?? []) {
316
+ await onEvent(ev);
317
+ }
318
+ const next = [...input];
319
+ if (options.scriptedAssistant) {
320
+ next.push(options.scriptedAssistant);
321
+ await onEvent({
322
+ type: "message_complete",
323
+ message: options.scriptedAssistant,
324
+ });
325
+ }
326
+ if (options.scriptedTail) {
327
+ for (const tailMsg of options.scriptedTail) {
328
+ next.push(tailMsg);
329
+ }
330
+ }
331
+ return runResult(next);
332
+ };
333
+
334
+ const runBody = options.runImpl ?? defaultRun;
335
+
336
+ const drainQueue = options.omitDrainQueue
337
+ ? undefined
338
+ : async () => {
339
+ probe.drainQueueCalls += 1;
340
+ // Snapshot the live processing flag *inside* drain, not via the
341
+ // toggle log, so we directly observe the state visible to the
342
+ // dequeued message's enqueueMessage() gate.
343
+ probe.processingDuringDrain.push(processing);
344
+ probe.callSequence.push("drain");
345
+ };
346
+
347
+ const conversation = {
348
+ conversationId,
349
+ ...probe,
220
350
  get drainQueueCalls() {
221
- return drainQueueCalls;
351
+ return probe.drainQueueCalls;
352
+ },
353
+ get subagentAllowedTools() {
354
+ return activeAllowedTools;
355
+ },
356
+ setSubagentAllowedTools: (tools: Set<string> | undefined) => {
357
+ activeAllowedTools = tools;
358
+ probe.allowedToolSnapshots.push(snapshotAllowedTools());
359
+ probe.callSequence.push(
360
+ `tools:${snapshotAllowedTools()?.join(",") ?? "all"}`,
361
+ );
222
362
  },
223
363
  agentLoop: {
224
- run: async (
225
- input: Message[],
226
- onEvent: (event: AgentEvent) => void | Promise<void>,
227
- runOptions?: AgentLoopRunOptions,
228
- ) => {
229
- runCalls.push({
364
+ run: async (options: AgentLoopRunOptions) => {
365
+ const { messages: input, onEvent } = options;
366
+ probe.runCalls.push({
230
367
  input: [...input],
231
- requestId: runOptions?.requestId,
232
- turnContext: runOptions?.turnContext,
368
+ requestId: options.requestId,
369
+ trust: options.trust,
233
370
  allowedTools: snapshotAllowedTools(),
371
+ order: order++,
234
372
  });
235
- // Emit any scripted events the test wanted us to produce.
236
- for (const ev of options.scriptedEvents ?? []) {
237
- await onEvent(ev);
238
- }
239
- // Final history = input + optional assistant message + optional tail.
240
- const next = [...input];
241
- if (options.scriptedAssistant) {
242
- next.push(options.scriptedAssistant);
243
- await onEvent({
244
- type: "message_complete",
245
- message: options.scriptedAssistant,
246
- });
247
- }
248
- if (options.scriptedTail) {
249
- for (const tailMsg of options.scriptedTail) {
250
- next.push(tailMsg);
251
- }
252
- }
253
- return runResult(next);
373
+ return runBody(input, onEvent, options);
254
374
  },
255
375
  },
256
- getMessages: () => history,
257
- pushMessage: (msg: Message) => {
258
- pushedMessages.push(msg);
259
- history.push(msg);
260
- callSequence.push("push");
261
- },
262
- emitAgentEvent: (event) => {
263
- emittedEvents.push(event);
264
- },
376
+ messages,
377
+ getMessages: () => messages,
265
378
  isProcessing: () => processing,
266
- markProcessing: (on: boolean) => {
379
+ setProcessing: (on: boolean) => {
267
380
  processing = on;
268
- processingToggles.push(on);
269
- callSequence.push(on ? "processing:true" : "processing:false");
381
+ probe.processingToggles.push(on);
382
+ probe.callSequence.push(on ? "processing:true" : "processing:false");
270
383
  },
271
- persistTailMessage: async (msg: Message) => {
272
- persistedTailCalls.push(msg);
273
- callSequence.push("persist");
274
- },
275
- setWakeAllowedTools: (tools: ReadonlySet<string>) => {
276
- const previous = activeAllowedTools;
277
- activeAllowedTools = new Set(tools);
278
- allowedToolSnapshots.push(snapshotAllowedTools());
279
- callSequence.push(`tools:${snapshotAllowedTools()?.join(",") ?? "all"}`);
280
- return () => {
281
- activeAllowedTools = previous;
282
- allowedToolSnapshots.push(snapshotAllowedTools());
283
- callSequence.push(
284
- `tools:${snapshotAllowedTools()?.join(",") ?? "all"}`,
285
- );
286
- };
384
+ setTrustContext: (ctx: unknown) => {
385
+ probe.setTrustContextCalls.push({ ctx, order: order++ });
287
386
  },
288
- ...(options.omitDrainQueue
289
- ? {}
290
- : {
291
- drainQueue: async () => {
292
- drainQueueCalls++;
293
- // Snapshot the live processing flag *inside* drain, not via
294
- // the toggle log, so we directly observe the state visible
295
- // to the dequeued message's enqueueMessage() gate.
296
- processingDuringDrain.push(processing);
297
- callSequence.push("drain");
298
- },
299
- }),
387
+ getTurnChannelContext: () => null,
388
+ getTurnInterfaceContext: () => null,
389
+ trustContext: undefined,
390
+ ...(drainQueue ? { drainQueue } : {}),
300
391
  };
301
392
 
302
- // Expose processing setter via test-only side-channel for tests that
303
- // simulate an external (non-wake) processing state.
304
- (target as unknown as { setProcessing: (v: boolean) => void }).setProcessing =
305
- (v: boolean) => {
306
- processing = v;
307
- };
308
-
309
- return target;
393
+ return conversation as unknown as WakeConversation;
310
394
  }
311
395
 
312
396
  beforeEach(() => {
313
397
  __resetWakeChainForTests();
398
+ wakeConvRegistry.clear();
314
399
  recordRequestLogCalls.length = 0;
315
400
  mockGetOrCreateConversationCalls.length = 0;
316
401
  mockResolverTarget = null;
@@ -335,21 +420,21 @@ beforeEach(() => {
335
420
 
336
421
  describe("wakeAgentForOpportunity", () => {
337
422
  test("disabled disk pressure flag allows background wakes to pass through", async () => {
338
- const target = makeTarget({
423
+ const conversation = makeWakeConversation({
339
424
  scriptedAssistant: null,
340
425
  });
341
426
 
342
427
  const result = await wakeAgentForOpportunity(
343
428
  {
344
- conversationId: target.conversationId,
429
+ conversationId: conversation.conversationId,
345
430
  hint: "background completion",
346
431
  source: "background-tool",
347
432
  },
348
- { resolveTarget: async () => target },
433
+ { resolveTarget: async () => conversation },
349
434
  );
350
435
 
351
436
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
352
- expect(target.runCalls).toHaveLength(1);
437
+ expect(conversation.runCalls).toHaveLength(1);
353
438
  });
354
439
 
355
440
  test("blocks background wakes during disk pressure before marking processing", async () => {
@@ -368,7 +453,7 @@ describe("wakeAgentForOpportunity", () => {
368
453
  blockedCapabilities: ["agent-turns", "background-work", "remote-ingress"],
369
454
  error: null,
370
455
  };
371
- const target = makeTarget({
456
+ const conversation = makeWakeConversation({
372
457
  isProcessing: true,
373
458
  scriptedAssistant: {
374
459
  role: "assistant",
@@ -378,12 +463,12 @@ describe("wakeAgentForOpportunity", () => {
378
463
 
379
464
  const result = await wakeAgentForOpportunity(
380
465
  {
381
- conversationId: target.conversationId,
466
+ conversationId: conversation.conversationId,
382
467
  hint: "background shell completed",
383
468
  source: "background-tool",
384
469
  trustContext: { sourceChannel: "vellum", trustClass: "guardian" },
385
470
  },
386
- { resolveTarget: async () => target },
471
+ { resolveTarget: async () => conversation },
387
472
  );
388
473
 
389
474
  expect(result).toEqual({
@@ -391,10 +476,10 @@ describe("wakeAgentForOpportunity", () => {
391
476
  producedToolCalls: false,
392
477
  reason: "disk_pressure",
393
478
  });
394
- expect(target.runCalls).toHaveLength(0);
395
- expect(target.processingToggles).toEqual([]);
396
- expect(target.drainQueueCalls).toBe(0);
397
- expect(target.isProcessing()).toBe(true);
479
+ expect(conversation.runCalls).toHaveLength(0);
480
+ expect(conversation.processingToggles).toEqual([]);
481
+ expect(conversation.drainQueueCalls).toBe(0);
482
+ expect(conversation.isProcessing()).toBe(true);
398
483
  });
399
484
 
400
485
  test("blocks trusted-contact direct wakes during disk pressure", async () => {
@@ -413,11 +498,11 @@ describe("wakeAgentForOpportunity", () => {
413
498
  blockedCapabilities: ["agent-turns", "background-work", "remote-ingress"],
414
499
  error: null,
415
500
  };
416
- const target = makeTarget({ scriptedAssistant: null });
501
+ const conversation = makeWakeConversation({ scriptedAssistant: null });
417
502
 
418
503
  const result = await wakeAgentForOpportunity(
419
504
  {
420
- conversationId: target.conversationId,
505
+ conversationId: conversation.conversationId,
421
506
  hint: "notify the guardian",
422
507
  source: "notification",
423
508
  trustContext: {
@@ -425,14 +510,14 @@ describe("wakeAgentForOpportunity", () => {
425
510
  trustClass: "trusted_contact",
426
511
  },
427
512
  },
428
- { resolveTarget: async () => target },
513
+ { resolveTarget: async () => conversation },
429
514
  );
430
515
 
431
516
  expect(result.reason).toBe("disk_pressure");
432
- expect(target.runCalls).toHaveLength(0);
517
+ expect(conversation.runCalls).toHaveLength(0);
433
518
  });
434
519
 
435
- test("threads cleanup-mode injection context for explicit local-owner wakes", async () => {
520
+ test("forwards a guardian trust snapshot for explicit local-owner cleanup-mode wakes", async () => {
436
521
  mockDiskPressureStatus = {
437
522
  enabled: true,
438
523
  state: "critical",
@@ -448,31 +533,29 @@ describe("wakeAgentForOpportunity", () => {
448
533
  blockedCapabilities: ["agent-turns", "background-work", "remote-ingress"],
449
534
  error: null,
450
535
  };
451
- const target = makeTarget({ scriptedAssistant: null });
536
+ const conversation = makeWakeConversation({ scriptedAssistant: null });
452
537
 
453
538
  const result = await wakeAgentForOpportunity(
454
539
  {
455
- conversationId: target.conversationId,
540
+ conversationId: conversation.conversationId,
456
541
  hint: "clean storage",
457
542
  source: "local-cleanup",
458
543
  sourceChannel: "vellum",
459
544
  sourceInterface: "macos",
460
545
  },
461
- { resolveTarget: async () => target },
546
+ { resolveTarget: async () => conversation },
462
547
  );
463
548
 
464
549
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
465
- expect(target.runCalls).toHaveLength(1);
466
- expect(target.runCalls[0]!.turnContext).toMatchObject({
467
- conversationId: target.conversationId,
468
- injectionInputs: {
469
- diskPressureContext: { cleanupModeActive: true },
470
- },
550
+ expect(conversation.runCalls).toHaveLength(1);
551
+ expect(conversation.runCalls[0]!.trust).toEqual({
552
+ sourceChannel: "vellum",
553
+ trustClass: "guardian",
471
554
  });
472
555
  });
473
556
 
474
557
  test("silent no-op when agent produces no tool calls and no text", async () => {
475
- const target = makeTarget({
558
+ const conversation = makeWakeConversation({
476
559
  baseline: [
477
560
  { role: "user", content: [{ type: "text", text: "hi" }] },
478
561
  { role: "assistant", content: [{ type: "text", text: "hello" }] },
@@ -486,23 +569,23 @@ describe("wakeAgentForOpportunity", () => {
486
569
 
487
570
  const result = await wakeAgentForOpportunity(
488
571
  {
489
- conversationId: target.conversationId,
572
+ conversationId: conversation.conversationId,
490
573
  hint: "someone asked a question",
491
574
  source: "unit-test",
492
575
  },
493
- { resolveTarget: async () => target },
576
+ { resolveTarget: async () => conversation },
494
577
  );
495
578
 
496
579
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
497
580
  // Nothing emitted to client.
498
- expect(target.emittedEvents).toHaveLength(0);
581
+ expect(conversation.emittedEvents).toHaveLength(0);
499
582
  // Nothing persisted.
500
- expect(target.persistedTailCalls).toHaveLength(0);
583
+ expect(conversation.persistedTailCalls).toHaveLength(0);
501
584
  // Nothing pushed into live history.
502
- expect(target.pushedMessages).toHaveLength(0);
585
+ expect(conversation.pushedMessages).toHaveLength(0);
503
586
  // Hint was included in the run input, but baseline is unchanged.
504
- expect(target.runCalls).toHaveLength(1);
505
- const input = target.runCalls[0]!.input;
587
+ expect(conversation.runCalls).toHaveLength(1);
588
+ const input = conversation.runCalls[0]!.input;
506
589
  expect(input).toHaveLength(5); // 2 baseline + 3 hint (user + assistant + user)
507
590
  expect(input[2]).toEqual({
508
591
  role: "user",
@@ -534,7 +617,7 @@ describe("wakeAgentForOpportunity", () => {
534
617
  });
535
618
 
536
619
  test("scopes allowed tools during the wake and restores before queued messages drain", async () => {
537
- const target = makeTarget({
620
+ const conversation = makeWakeConversation({
538
621
  initialAllowedTools: new Set(["bash"]),
539
622
  scriptedAssistant: {
540
623
  role: "assistant",
@@ -544,29 +627,29 @@ describe("wakeAgentForOpportunity", () => {
544
627
 
545
628
  const result = await wakeAgentForOpportunity(
546
629
  {
547
- conversationId: target.conversationId,
630
+ conversationId: conversation.conversationId,
548
631
  hint: "review for memories",
549
632
  source: "memory-retrospective",
550
633
  allowedTools: ["remember"],
551
634
  },
552
- { resolveTarget: async () => target },
635
+ { resolveTarget: async () => conversation },
553
636
  );
554
637
 
555
638
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
556
- expect(target.runCalls[0]!.allowedTools).toEqual(["remember"]);
557
- expect(target.allowedToolSnapshots).toEqual([["remember"], ["bash"]]);
639
+ expect(conversation.runCalls[0]!.allowedTools).toEqual(["remember"]);
640
+ expect(conversation.allowedToolSnapshots).toEqual([["remember"], ["bash"]]);
558
641
 
559
- const restoreIndex = target.callSequence.indexOf("tools:bash");
642
+ const restoreIndex = conversation.callSequence.indexOf("tools:bash");
560
643
  const processingFalseIndex =
561
- target.callSequence.indexOf("processing:false");
562
- const drainIndex = target.callSequence.indexOf("drain");
644
+ conversation.callSequence.indexOf("processing:false");
645
+ const drainIndex = conversation.callSequence.indexOf("drain");
563
646
  expect(restoreIndex).toBeGreaterThan(-1);
564
647
  expect(restoreIndex).toBeLessThan(processingFalseIndex);
565
648
  expect(processingFalseIndex).toBeLessThan(drainIndex);
566
649
  });
567
650
 
568
651
  test("restores allowed tools before drain when the wake is a silent no-op", async () => {
569
- const target = makeTarget({
652
+ const conversation = makeWakeConversation({
570
653
  scriptedAssistant: {
571
654
  role: "assistant",
572
655
  content: [{ type: "text", text: "" }],
@@ -575,22 +658,25 @@ describe("wakeAgentForOpportunity", () => {
575
658
 
576
659
  const result = await wakeAgentForOpportunity(
577
660
  {
578
- conversationId: target.conversationId,
661
+ conversationId: conversation.conversationId,
579
662
  hint: "review for memories",
580
663
  source: "memory-retrospective",
581
664
  allowedTools: ["remember"],
582
665
  },
583
- { resolveTarget: async () => target },
666
+ { resolveTarget: async () => conversation },
584
667
  );
585
668
 
586
669
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
587
- expect(target.runCalls[0]!.allowedTools).toEqual(["remember"]);
588
- expect(target.allowedToolSnapshots).toEqual([["remember"], undefined]);
670
+ expect(conversation.runCalls[0]!.allowedTools).toEqual(["remember"]);
671
+ expect(conversation.allowedToolSnapshots).toEqual([
672
+ ["remember"],
673
+ undefined,
674
+ ]);
589
675
 
590
- const restoreIndex = target.callSequence.indexOf("tools:all");
676
+ const restoreIndex = conversation.callSequence.indexOf("tools:all");
591
677
  const processingFalseIndex =
592
- target.callSequence.indexOf("processing:false");
593
- const drainIndex = target.callSequence.indexOf("drain");
678
+ conversation.callSequence.indexOf("processing:false");
679
+ const drainIndex = conversation.callSequence.indexOf("drain");
594
680
  expect(restoreIndex).toBeGreaterThan(-1);
595
681
  expect(restoreIndex).toBeLessThan(processingFalseIndex);
596
682
  expect(processingFalseIndex).toBeLessThan(drainIndex);
@@ -608,29 +694,28 @@ describe("wakeAgentForOpportunity", () => {
608
694
  },
609
695
  ],
610
696
  };
611
- const target = makeTarget({
697
+ const conversation = makeWakeConversation({
612
698
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
613
699
  scriptedAssistant: assistantMessage,
614
700
  });
615
701
 
616
702
  const result = await wakeAgentForOpportunity(
617
703
  {
618
- conversationId: target.conversationId,
704
+ conversationId: conversation.conversationId,
619
705
  hint: "question directed at assistant",
620
706
  source: "meet-chat-opportunity",
621
707
  },
622
- { resolveTarget: async () => target },
708
+ { resolveTarget: async () => conversation },
623
709
  );
624
710
 
625
711
  expect(result).toEqual({ invoked: true, producedToolCalls: true });
626
- // Assistant message persisted via the target hook.
627
- expect(target.persistedTailCalls).toHaveLength(1);
628
- expect(target.persistedTailCalls[0]).toEqual(assistantMessage);
712
+ // Assistant message persisted via the daemon boundary (addMessage).
713
+ expect(conversation.persistedTailCalls).toHaveLength(1);
714
+ expect(conversation.persistedTailCalls[0]).toEqual(assistantMessage);
629
715
  // Assistant message pushed into live history.
630
- expect(target.pushedMessages).toContainEqual(assistantMessage);
631
- // message_complete event flushed to the client via the translator
632
- // surface (raw AgentEvent — adapter is responsible for wire shape).
633
- const flushed = target.emittedEvents.find(
716
+ expect(conversation.pushedMessages).toContainEqual(assistantMessage);
717
+ // message_complete frame flushed to the client via the translator.
718
+ const flushed = conversation.emittedEvents.find(
634
719
  (e) => e.type === "message_complete",
635
720
  );
636
721
  expect(flushed).toBeDefined();
@@ -667,7 +752,7 @@ describe("wakeAgentForOpportunity", () => {
667
752
  content: [{ type: "text", text: "Done." }],
668
753
  };
669
754
 
670
- const target = makeTarget({
755
+ const conversation = makeWakeConversation({
671
756
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
672
757
  scriptedAssistant: firstAssistant,
673
758
  scriptedTail: [toolResultUserMsg, followupAssistant],
@@ -675,30 +760,30 @@ describe("wakeAgentForOpportunity", () => {
675
760
 
676
761
  const result = await wakeAgentForOpportunity(
677
762
  {
678
- conversationId: target.conversationId,
763
+ conversationId: conversation.conversationId,
679
764
  hint: "question directed at assistant",
680
765
  source: "meet-chat-opportunity",
681
766
  },
682
- { resolveTarget: async () => target },
767
+ { resolveTarget: async () => conversation },
683
768
  );
684
769
 
685
770
  expect(result).toEqual({ invoked: true, producedToolCalls: true });
686
771
 
687
- // All three tail messages persisted in order via the target hook.
688
- expect(target.persistedTailCalls).toHaveLength(3);
689
- expect(target.persistedTailCalls[0]).toEqual(firstAssistant);
690
- expect(target.persistedTailCalls[1]).toEqual(toolResultUserMsg);
691
- expect(target.persistedTailCalls[2]).toEqual(followupAssistant);
772
+ // All three tail messages persisted in order via the daemon boundary.
773
+ expect(conversation.persistedTailCalls).toHaveLength(3);
774
+ expect(conversation.persistedTailCalls[0]).toEqual(firstAssistant);
775
+ expect(conversation.persistedTailCalls[1]).toEqual(toolResultUserMsg);
776
+ expect(conversation.persistedTailCalls[2]).toEqual(followupAssistant);
692
777
 
693
778
  // All three also pushed into live history so next turn sees them.
694
- expect(target.pushedMessages).toHaveLength(3);
695
- expect(target.pushedMessages[0]).toEqual(firstAssistant);
696
- expect(target.pushedMessages[1]).toEqual(toolResultUserMsg);
697
- expect(target.pushedMessages[2]).toEqual(followupAssistant);
779
+ expect(conversation.pushedMessages).toHaveLength(3);
780
+ expect(conversation.pushedMessages[0]).toEqual(firstAssistant);
781
+ expect(conversation.pushedMessages[1]).toEqual(toolResultUserMsg);
782
+ expect(conversation.pushedMessages[2]).toEqual(followupAssistant);
698
783
  });
699
784
 
700
785
  test("marks processing true during the run and false afterwards", async () => {
701
- const target = makeTarget({
786
+ const conversation = makeWakeConversation({
702
787
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
703
788
  scriptedAssistant: {
704
789
  role: "assistant",
@@ -709,65 +794,47 @@ describe("wakeAgentForOpportunity", () => {
709
794
  // Snapshot isProcessing() inside the run to prove we actually
710
795
  // hold the processing flag while agentLoop.run executes.
711
796
  const observedDuringRun: boolean[] = [];
712
- const originalRun = target.agentLoop.run;
713
- target.agentLoop.run = async (
714
- input: Message[],
715
- onEvent: (event: AgentEvent) => void | Promise<void>,
716
- runOptions?: AgentLoopRunOptions,
717
- ) => {
718
- observedDuringRun.push(target.isProcessing());
719
- return originalRun(input, onEvent, runOptions);
797
+ const originalRun = conversation.agentLoop.run;
798
+ conversation.agentLoop.run = async (options: AgentLoopRunOptions) => {
799
+ observedDuringRun.push(conversation.isProcessing());
800
+ return originalRun(options);
720
801
  };
721
802
 
722
803
  await wakeAgentForOpportunity(
723
804
  {
724
- conversationId: target.conversationId,
805
+ conversationId: conversation.conversationId,
725
806
  hint: "x",
726
807
  source: "unit-test",
727
808
  },
728
- { resolveTarget: async () => target },
809
+ { resolveTarget: async () => conversation },
729
810
  );
730
811
 
731
- // markProcessing toggled on then off exactly once.
732
- expect(target.processingToggles).toEqual([true, false]);
812
+ // setProcessing toggled on then off exactly once.
813
+ expect(conversation.processingToggles).toEqual([true, false]);
733
814
  // And the flag was observed as true inside the run body.
734
815
  expect(observedDuringRun).toEqual([true]);
735
816
  // Back to idle by the time the wake returns.
736
- expect(target.isProcessing()).toBe(false);
817
+ expect(conversation.isProcessing()).toBe(false);
737
818
  });
738
819
 
739
820
  test("marks processing false even when the agent loop throws", async () => {
740
- const history: Message[] = [];
741
- const toggles: boolean[] = [];
742
- let processing = false;
743
- const target: WakeTarget = {
821
+ const conversation = makeWakeConversation({
744
822
  conversationId: "conv-err-guard",
745
- agentLoop: {
746
- run: async () => {
747
- throw new Error("LLM exploded");
748
- },
749
- },
750
- getMessages: () => history,
751
- pushMessage: () => {},
752
- emitAgentEvent: () => {},
753
- isProcessing: () => processing,
754
- markProcessing: (on) => {
755
- processing = on;
756
- toggles.push(on);
823
+ runImpl: async () => {
824
+ throw new Error("LLM exploded");
757
825
  },
758
- persistTailMessage: async () => {},
759
- };
826
+ });
760
827
 
761
828
  const result = await wakeAgentForOpportunity(
762
829
  { conversationId: "conv-err-guard", hint: "boom", source: "t" },
763
- { resolveTarget: async () => target },
830
+ { resolveTarget: async () => conversation },
764
831
  );
765
832
 
766
833
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
767
834
  // Critical: the finally block must have released the flag despite
768
835
  // the thrown error, otherwise the next user turn would hang.
769
- expect(toggles).toEqual([true, false]);
770
- expect(processing).toBe(false);
836
+ expect(conversation.processingToggles).toEqual([true, false]);
837
+ expect(conversation.isProcessing()).toBe(false);
771
838
  });
772
839
 
773
840
  test("applies caller-supplied trustContext to the target before the agent loop runs", async () => {
@@ -775,32 +842,9 @@ describe("wakeAgentForOpportunity", () => {
775
842
  // guardian trust to clear the side-effect approval gate. The wake must
776
843
  // call setTrustContext BEFORE agentLoop.run so the per-turn snapshot
777
844
  // captures the elevated trust.
778
- const trustCalls: Array<{ ctx: unknown; before: number }> = [];
779
- const runCalls: number[] = [];
780
- let callOrder = 0;
781
-
782
- const history: Message[] = [];
783
- let processing = false;
784
- const target: WakeTarget = {
845
+ const conversation = makeWakeConversation({
785
846
  conversationId: "conv-trust",
786
- agentLoop: {
787
- run: async (input) => {
788
- runCalls.push(callOrder++);
789
- return runResult([...input]);
790
- },
791
- },
792
- getMessages: () => history,
793
- pushMessage: () => {},
794
- emitAgentEvent: () => {},
795
- isProcessing: () => processing,
796
- markProcessing: (on) => {
797
- processing = on;
798
- },
799
- persistTailMessage: async () => {},
800
- setTrustContext: (ctx) => {
801
- trustCalls.push({ ctx, before: callOrder++ });
802
- },
803
- };
847
+ });
804
848
 
805
849
  await wakeAgentForOpportunity(
806
850
  {
@@ -809,40 +853,25 @@ describe("wakeAgentForOpportunity", () => {
809
853
  source: "memory_v2_consolidation",
810
854
  trustContext: { sourceChannel: "vellum", trustClass: "guardian" },
811
855
  },
812
- { resolveTarget: async () => target },
856
+ { resolveTarget: async () => conversation },
813
857
  );
814
858
 
815
- expect(trustCalls).toHaveLength(1);
816
- expect(trustCalls[0]!.ctx).toEqual({
859
+ expect(conversation.setTrustContextCalls).toHaveLength(1);
860
+ expect(conversation.setTrustContextCalls[0]!.ctx).toEqual({
817
861
  sourceChannel: "vellum",
818
862
  trustClass: "guardian",
819
863
  });
820
864
  // setTrustContext fired strictly before agentLoop.run.
821
- expect(runCalls).toHaveLength(1);
822
- expect(trustCalls[0]!.before).toBeLessThan(runCalls[0]!);
865
+ expect(conversation.runCalls).toHaveLength(1);
866
+ expect(conversation.setTrustContextCalls[0]!.order).toBeLessThan(
867
+ conversation.runCalls[0]!.order,
868
+ );
823
869
  });
824
870
 
825
871
  test("does not call setTrustContext when no trustContext is supplied", async () => {
826
- const trustCalls: unknown[] = [];
827
- const history: Message[] = [];
828
- let processing = false;
829
- const target: WakeTarget = {
872
+ const conversation = makeWakeConversation({
830
873
  conversationId: "conv-no-trust",
831
- agentLoop: {
832
- run: async (input) => runResult([...input]),
833
- },
834
- getMessages: () => history,
835
- pushMessage: () => {},
836
- emitAgentEvent: () => {},
837
- isProcessing: () => processing,
838
- markProcessing: (on) => {
839
- processing = on;
840
- },
841
- persistTailMessage: async () => {},
842
- setTrustContext: (ctx) => {
843
- trustCalls.push(ctx);
844
- },
845
- };
874
+ });
846
875
 
847
876
  await wakeAgentForOpportunity(
848
877
  {
@@ -850,13 +879,13 @@ describe("wakeAgentForOpportunity", () => {
850
879
  hint: "x",
851
880
  source: "t",
852
881
  },
853
- { resolveTarget: async () => target },
882
+ { resolveTarget: async () => conversation },
854
883
  );
855
884
 
856
885
  // Inbound-message conversations populate trust via processMessage().
857
886
  // Without an explicit opt-in from the caller, the wake must not
858
887
  // overwrite whatever the conversation already holds.
859
- expect(trustCalls).toHaveLength(0);
888
+ expect(conversation.setTrustContextCalls).toHaveLength(0);
860
889
  });
861
890
 
862
891
  test("two concurrent wakes on the same conversation are serialized", async () => {
@@ -867,36 +896,22 @@ describe("wakeAgentForOpportunity", () => {
867
896
  const runCompleteOrder: number[] = [];
868
897
 
869
898
  let callIndex = 0;
870
- const history: Message[] = [];
871
- let processing = false;
872
- const target: WakeTarget = {
899
+ const conversation = makeWakeConversation({
873
900
  conversationId: "conv-serialize",
874
- agentLoop: {
875
- run: async (input) => {
876
- const myIndex = ++callIndex;
877
- runStartOrder.push(myIndex);
878
- if (myIndex === 1) {
879
- await gate1.promise;
880
- } else {
881
- await gate2.promise;
882
- }
883
- runCompleteOrder.push(myIndex);
884
- return runResult(input); // no assistant message → silent no-op
885
- },
886
- },
887
- getMessages: () => history,
888
- pushMessage: (msg) => {
889
- history.push(msg);
890
- },
891
- emitAgentEvent: () => {},
892
- isProcessing: () => processing,
893
- markProcessing: (on) => {
894
- processing = on;
901
+ runImpl: async (input) => {
902
+ const myIndex = ++callIndex;
903
+ runStartOrder.push(myIndex);
904
+ if (myIndex === 1) {
905
+ await gate1.promise;
906
+ } else {
907
+ await gate2.promise;
908
+ }
909
+ runCompleteOrder.push(myIndex);
910
+ return runResult(input); // no assistant message → silent no-op
895
911
  },
896
- persistTailMessage: async () => {},
897
- };
912
+ });
898
913
 
899
- const deps = { resolveTarget: async () => target };
914
+ const deps = { resolveTarget: async () => conversation };
900
915
 
901
916
  const p1 = wakeAgentForOpportunity(
902
917
  { conversationId: "conv-serialize", hint: "first", source: "t1" },
@@ -924,30 +939,11 @@ describe("wakeAgentForOpportunity", () => {
924
939
  });
925
940
 
926
941
  test("waits while a concurrent user turn is in flight", async () => {
927
- const history: Message[] = [];
928
- let processing = true;
929
- const target: WakeTarget & { setProcessing: (v: boolean) => void } = {
942
+ const conversation = makeWakeConversation({
930
943
  conversationId: "conv-user-turn",
931
- agentLoop: {
932
- run: async (input) => runResult(input),
933
- },
934
- getMessages: () => history,
935
- pushMessage: (msg) => {
936
- history.push(msg);
937
- },
938
- emitAgentEvent: () => {},
939
- isProcessing: () => processing,
940
- // The wake's own markProcessing updates track the flag too — the
941
- // outer "user turn" holds it at true until setProcessing(false)
942
- // is called below.
943
- markProcessing: (on) => {
944
- processing = on;
945
- },
946
- persistTailMessage: async () => {},
947
- setProcessing: (v) => {
948
- processing = v;
949
- },
950
- };
944
+ isProcessing: true,
945
+ runImpl: async (input) => runResult(input),
946
+ });
951
947
 
952
948
  const wakePromise = wakeAgentForOpportunity(
953
949
  {
@@ -955,7 +951,7 @@ describe("wakeAgentForOpportunity", () => {
955
951
  hint: "opportunity while user typing",
956
952
  source: "unit-test",
957
953
  },
958
- { resolveTarget: async () => target },
954
+ { resolveTarget: async () => conversation },
959
955
  );
960
956
 
961
957
  // Wake should be waiting (isProcessing returns true).
@@ -969,7 +965,7 @@ describe("wakeAgentForOpportunity", () => {
969
965
  expect(settled).toBe(false);
970
966
 
971
967
  // "User turn" completes — wake now proceeds.
972
- target.setProcessing(false);
968
+ conversation.setProcessing(false);
973
969
  const result = await wakePromise;
974
970
  expect(result.invoked).toBe(true);
975
971
  expect(result.producedToolCalls).toBe(false);
@@ -992,17 +988,11 @@ describe("wakeAgentForOpportunity", () => {
992
988
  // forward the injected `now` past the 30s deadline so waitUntilIdle
993
989
  // returns false. Without the distinct `timeout` reason, callers
994
990
  // cannot tell this case apart from "not_found".
995
- const history: Message[] = [];
996
- const target: WakeTarget = {
991
+ const conversation = makeWakeConversation({
997
992
  conversationId: "conv-busy",
998
- agentLoop: { run: async () => runResult(history) },
999
- getMessages: () => history,
1000
- pushMessage: () => {},
1001
- emitAgentEvent: () => {},
1002
- isProcessing: () => true,
1003
- markProcessing: () => {},
1004
- persistTailMessage: async () => {},
1005
- };
993
+ isProcessing: true,
994
+ runImpl: async (input) => runResult(input),
995
+ });
1006
996
  let t = 0;
1007
997
  const now = () => {
1008
998
  // First call establishes the deadline at +30_000. Every subsequent
@@ -1015,7 +1005,7 @@ describe("wakeAgentForOpportunity", () => {
1015
1005
 
1016
1006
  const result = await wakeAgentForOpportunity(
1017
1007
  { conversationId: "conv-busy", hint: "x", source: "y" },
1018
- { resolveTarget: async () => target, now },
1008
+ { resolveTarget: async () => conversation, now },
1019
1009
  );
1020
1010
  expect(result).toEqual({
1021
1011
  invoked: false,
@@ -1025,35 +1015,20 @@ describe("wakeAgentForOpportunity", () => {
1025
1015
  });
1026
1016
 
1027
1017
  test("agent loop error is treated as a no-op", async () => {
1028
- const history: Message[] = [];
1029
- let processing = false;
1030
- const persisted: Message[] = [];
1031
- const target: WakeTarget = {
1018
+ const conversation = makeWakeConversation({
1032
1019
  conversationId: "conv-err",
1033
- agentLoop: {
1034
- run: async () => {
1035
- throw new Error("LLM exploded");
1036
- },
1020
+ runImpl: async () => {
1021
+ throw new Error("LLM exploded");
1037
1022
  },
1038
- getMessages: () => history,
1039
- pushMessage: () => {},
1040
- emitAgentEvent: () => {},
1041
- isProcessing: () => processing,
1042
- markProcessing: (on) => {
1043
- processing = on;
1044
- },
1045
- persistTailMessage: async (m) => {
1046
- persisted.push(m);
1047
- },
1048
- };
1023
+ });
1049
1024
 
1050
1025
  const result = await wakeAgentForOpportunity(
1051
1026
  { conversationId: "conv-err", hint: "boom", source: "t" },
1052
- { resolveTarget: async () => target },
1027
+ { resolveTarget: async () => conversation },
1053
1028
  );
1054
1029
 
1055
1030
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
1056
- expect(persisted).toHaveLength(0);
1031
+ expect(conversation.persistedTailCalls).toHaveLength(0);
1057
1032
  });
1058
1033
 
1059
1034
  test("drainQueue is called in finally after a successful run", async () => {
@@ -1061,7 +1036,7 @@ describe("wakeAgentForOpportunity", () => {
1061
1036
  // wake set `processing = true`) must be picked up after the wake
1062
1037
  // completes. Mirrors the canonical user-turn `finally` path which
1063
1038
  // sets `processing = false` then calls `drainQueue`.
1064
- const target = makeTarget({
1039
+ const conversation = makeWakeConversation({
1065
1040
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1066
1041
  scriptedAssistant: {
1067
1042
  role: "assistant",
@@ -1071,76 +1046,55 @@ describe("wakeAgentForOpportunity", () => {
1071
1046
 
1072
1047
  await wakeAgentForOpportunity(
1073
1048
  {
1074
- conversationId: target.conversationId,
1049
+ conversationId: conversation.conversationId,
1075
1050
  hint: "x",
1076
1051
  source: "unit-test",
1077
1052
  },
1078
- { resolveTarget: async () => target },
1053
+ { resolveTarget: async () => conversation },
1079
1054
  );
1080
1055
 
1081
- expect(target.drainQueueCalls).toBe(1);
1056
+ expect(conversation.drainQueueCalls).toBe(1);
1082
1057
  // Critical ordering invariant: drain runs after processing=false.
1083
1058
  // If drain ran while processing was still true,
1084
1059
  // `enqueueMessage`'s `if (!ctx.isProcessing()) return ...` gate would
1085
1060
  // see processing=true and the drained item would itself just
1086
1061
  // re-enqueue — no progress. Snapshot the live flag *inside* drain
1087
1062
  // (rather than inferring from toggle order) so a future regression
1088
- // that called drain before markProcessing(false) would fail this
1063
+ // that called drain before setProcessing(false) would fail this
1089
1064
  // assertion directly.
1090
- expect(target.processingDuringDrain).toEqual([false]);
1091
- expect(target.processingToggles).toEqual([true, false]);
1092
- expect(target.isProcessing()).toBe(false);
1065
+ expect(conversation.processingDuringDrain).toEqual([false]);
1066
+ expect(conversation.processingToggles).toEqual([true, false]);
1067
+ expect(conversation.isProcessing()).toBe(false);
1093
1068
  });
1094
1069
 
1095
1070
  test("drainQueue is called in finally even when the agent loop throws", async () => {
1096
1071
  // Verifies the drain is in the finally block, not just on success.
1097
1072
  // A wake that crashes mid-run must still flush queued messages —
1098
1073
  // otherwise a transient LLM error strands every concurrent send.
1099
- const drainProcessingSnapshots: boolean[] = [];
1100
- const toggles: boolean[] = [];
1101
- let processing = false;
1102
- const target: WakeTarget = {
1074
+ const conversation = makeWakeConversation({
1103
1075
  conversationId: "conv-drain-on-throw",
1104
- agentLoop: {
1105
- run: async () => {
1106
- throw new Error("LLM exploded mid-wake");
1107
- },
1108
- },
1109
- getMessages: () => [],
1110
- pushMessage: () => {},
1111
- emitAgentEvent: () => {},
1112
- isProcessing: () => processing,
1113
- markProcessing: (on) => {
1114
- processing = on;
1115
- toggles.push(on);
1076
+ runImpl: async () => {
1077
+ throw new Error("LLM exploded mid-wake");
1116
1078
  },
1117
- persistTailMessage: async () => {},
1118
- drainQueue: async () => {
1119
- // Snapshot the live `processing` flag *inside* drain rather
1120
- // than inferring from toggle order. This directly observes the
1121
- // state visible to enqueueMessage's gate when a queued message
1122
- // is dequeued.
1123
- drainProcessingSnapshots.push(processing);
1124
- },
1125
- };
1079
+ });
1126
1080
 
1127
1081
  const result = await wakeAgentForOpportunity(
1128
1082
  { conversationId: "conv-drain-on-throw", hint: "boom", source: "t" },
1129
- { resolveTarget: async () => target },
1083
+ { resolveTarget: async () => conversation },
1130
1084
  );
1131
1085
 
1132
1086
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
1133
- // Drain ran AFTER markProcessing(false), satisfying the
1087
+ // Drain ran AFTER setProcessing(false), satisfying the
1134
1088
  // enqueueMessage gate invariant. Snapshot proves the flag was
1135
1089
  // false at the moment drain ran.
1136
- expect(drainProcessingSnapshots).toEqual([false]);
1137
- expect(toggles).toEqual([true, false]);
1090
+ expect(conversation.processingDuringDrain).toEqual([false]);
1091
+ expect(conversation.processingToggles).toEqual([true, false]);
1138
1092
  });
1139
1093
 
1140
1094
  test("missing drainQueue hook is tolerated (no-op fallback)", async () => {
1141
1095
  // The hook is intentionally optional so test stubs without a queue
1142
- // can omit it. Production daemon adapter always wires it.
1143
- const target = makeTarget({
1096
+ // can omit it. Production daemon always wires it.
1097
+ const conversation = makeWakeConversation({
1144
1098
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1145
1099
  scriptedAssistant: {
1146
1100
  role: "assistant",
@@ -1151,50 +1105,50 @@ describe("wakeAgentForOpportunity", () => {
1151
1105
 
1152
1106
  const result = await wakeAgentForOpportunity(
1153
1107
  {
1154
- conversationId: target.conversationId,
1108
+ conversationId: conversation.conversationId,
1155
1109
  hint: "x",
1156
1110
  source: "unit-test",
1157
1111
  },
1158
- { resolveTarget: async () => target },
1112
+ { resolveTarget: async () => conversation },
1159
1113
  );
1160
1114
 
1161
1115
  expect(result.invoked).toBe(true);
1162
1116
  // No throw, no drain attempt recorded.
1163
- expect(target.drainQueueCalls).toBe(0);
1117
+ expect(conversation.drainQueueCalls).toBe(0);
1164
1118
  });
1165
1119
 
1166
1120
  test("drainQueue rejection does not propagate from the wake", async () => {
1167
1121
  // Defense in depth: if the queue drain throws (e.g. a poisoned
1168
1122
  // message), the wake itself must still resolve normally — the
1169
1123
  // drain failure is logged but never surfaced.
1170
- const target = makeTarget({
1124
+ const conversation = makeWakeConversation({
1171
1125
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1172
1126
  scriptedAssistant: {
1173
1127
  role: "assistant",
1174
1128
  content: [{ type: "text", text: "reply" }],
1175
1129
  },
1176
1130
  });
1177
- target.drainQueue = async () => {
1131
+ conversation.drainQueue = async () => {
1178
1132
  throw new Error("drain blew up");
1179
1133
  };
1180
1134
 
1181
1135
  const result = await wakeAgentForOpportunity(
1182
1136
  {
1183
- conversationId: target.conversationId,
1137
+ conversationId: conversation.conversationId,
1184
1138
  hint: "x",
1185
1139
  source: "unit-test",
1186
1140
  },
1187
- { resolveTarget: async () => target },
1141
+ { resolveTarget: async () => conversation },
1188
1142
  );
1189
1143
 
1190
1144
  expect(result.invoked).toBe(true);
1191
1145
  });
1192
1146
 
1193
1147
  test("persistTailMessage called for each tail message in order", async () => {
1194
- // Verifies Gap 2 fix: the wake delegates persistence to the target
1195
- // so the daemon adapter can build channel/interface metadata. We
1196
- // only check the call ordering / arguments here — the daemon
1197
- // adapter's metadata composition is exercised separately.
1148
+ // Verifies Gap 2 fix: the wake delegates persistence to the daemon
1149
+ // boundary (addMessage) so the channel/interface metadata is built
1150
+ // there. We only check the call ordering / arguments here — the
1151
+ // metadata composition is exercised separately.
1198
1152
  const firstAssistant: Message = {
1199
1153
  role: "assistant",
1200
1154
  content: [
@@ -1214,7 +1168,7 @@ describe("wakeAgentForOpportunity", () => {
1214
1168
  role: "assistant",
1215
1169
  content: [{ type: "text", text: "All set." }],
1216
1170
  };
1217
- const target = makeTarget({
1171
+ const conversation = makeWakeConversation({
1218
1172
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1219
1173
  scriptedAssistant: firstAssistant,
1220
1174
  scriptedTail: [toolResultUserMsg, followup],
@@ -1222,14 +1176,14 @@ describe("wakeAgentForOpportunity", () => {
1222
1176
 
1223
1177
  await wakeAgentForOpportunity(
1224
1178
  {
1225
- conversationId: target.conversationId,
1179
+ conversationId: conversation.conversationId,
1226
1180
  hint: "x",
1227
1181
  source: "meet-chat-opportunity",
1228
1182
  },
1229
- { resolveTarget: async () => target },
1183
+ { resolveTarget: async () => conversation },
1230
1184
  );
1231
1185
 
1232
- expect(target.persistedTailCalls).toEqual([
1186
+ expect(conversation.persistedTailCalls).toEqual([
1233
1187
  firstAssistant,
1234
1188
  toolResultUserMsg,
1235
1189
  followup,
@@ -1249,7 +1203,7 @@ describe("wakeAgentForOpportunity", () => {
1249
1203
  // assistant outputs).
1250
1204
  //
1251
1205
  // Mirrors the canonical user-turn pattern in
1252
- // conversation-agent-loop.ts:1860,2106-2126: messages updated →
1206
+ // conversation-agent-loop.ts: messages updated →
1253
1207
  // processing=false → drainQueue.
1254
1208
  const firstAssistant: Message = {
1255
1209
  role: "assistant",
@@ -1265,7 +1219,7 @@ describe("wakeAgentForOpportunity", () => {
1265
1219
  role: "assistant",
1266
1220
  content: [{ type: "text", text: "All done." }],
1267
1221
  };
1268
- const target = makeTarget({
1222
+ const conversation = makeWakeConversation({
1269
1223
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1270
1224
  scriptedAssistant: firstAssistant,
1271
1225
  scriptedTail: [toolResultUserMsg, followup],
@@ -1273,17 +1227,17 @@ describe("wakeAgentForOpportunity", () => {
1273
1227
 
1274
1228
  await wakeAgentForOpportunity(
1275
1229
  {
1276
- conversationId: target.conversationId,
1230
+ conversationId: conversation.conversationId,
1277
1231
  hint: "x",
1278
1232
  source: "meet-chat-opportunity",
1279
1233
  },
1280
- { resolveTarget: async () => target },
1234
+ { resolveTarget: async () => conversation },
1281
1235
  );
1282
1236
 
1283
1237
  // Full call sequence: processing toggled true → 3 pushes →
1284
1238
  // 3 persists → processing toggled false → drain. Specifically,
1285
1239
  // every push and every persist must precede the single drain.
1286
- expect(target.callSequence).toEqual([
1240
+ expect(conversation.callSequence).toEqual([
1287
1241
  "processing:true",
1288
1242
  "push",
1289
1243
  "push",
@@ -1298,14 +1252,14 @@ describe("wakeAgentForOpportunity", () => {
1298
1252
  // Belt-and-braces: cross-check via index lookups so the failure
1299
1253
  // mode (drain before push/persist) shows up clearly even if the
1300
1254
  // exact sequence ever picks up additional entries.
1301
- const drainIdx = target.callSequence.indexOf("drain");
1302
- const lastPushIdx = target.callSequence.lastIndexOf("push");
1303
- const lastPersistIdx = target.callSequence.lastIndexOf("persist");
1255
+ const drainIdx = conversation.callSequence.indexOf("drain");
1256
+ const lastPushIdx = conversation.callSequence.lastIndexOf("push");
1257
+ const lastPersistIdx = conversation.callSequence.lastIndexOf("persist");
1304
1258
  expect(drainIdx).toBeGreaterThan(lastPushIdx);
1305
1259
  expect(drainIdx).toBeGreaterThan(lastPersistIdx);
1306
1260
 
1307
1261
  // And processing was false when drain ran.
1308
- expect(target.processingDuringDrain).toEqual([false]);
1262
+ expect(conversation.processingDuringDrain).toEqual([false]);
1309
1263
  },
1310
1264
  );
1311
1265
 
@@ -1318,7 +1272,7 @@ describe("wakeAgentForOpportunity", () => {
1318
1272
  // visible text and no tool calls, so no push/persist/emit should
1319
1273
  // happen. drainQueue must still run in the finally block so a
1320
1274
  // racy queued message is not stranded.
1321
- const target = makeTarget({
1275
+ const conversation = makeWakeConversation({
1322
1276
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1323
1277
  scriptedAssistant: {
1324
1278
  role: "assistant",
@@ -1328,26 +1282,26 @@ describe("wakeAgentForOpportunity", () => {
1328
1282
 
1329
1283
  await wakeAgentForOpportunity(
1330
1284
  {
1331
- conversationId: target.conversationId,
1285
+ conversationId: conversation.conversationId,
1332
1286
  hint: "x",
1333
1287
  source: "unit-test",
1334
1288
  },
1335
- { resolveTarget: async () => target },
1289
+ { resolveTarget: async () => conversation },
1336
1290
  );
1337
1291
 
1338
1292
  // No push, no persist, no emit.
1339
- expect(target.pushedMessages).toHaveLength(0);
1340
- expect(target.persistedTailCalls).toHaveLength(0);
1341
- expect(target.emittedEvents).toHaveLength(0);
1293
+ expect(conversation.pushedMessages).toHaveLength(0);
1294
+ expect(conversation.persistedTailCalls).toHaveLength(0);
1295
+ expect(conversation.emittedEvents).toHaveLength(0);
1342
1296
 
1343
1297
  // But drain still ran exactly once, after processing flipped to
1344
1298
  // false. Sequence: toggle true → toggle false → drain.
1345
- expect(target.callSequence).toEqual([
1299
+ expect(conversation.callSequence).toEqual([
1346
1300
  "processing:true",
1347
1301
  "processing:false",
1348
1302
  "drain",
1349
1303
  ]);
1350
- expect(target.processingDuringDrain).toEqual([false]);
1304
+ expect(conversation.processingDuringDrain).toEqual([false]);
1351
1305
  },
1352
1306
  );
1353
1307
 
@@ -1386,94 +1340,61 @@ describe("wakeAgentForOpportunity", () => {
1386
1340
  content: [{ type: "text", text: "All done." }],
1387
1341
  };
1388
1342
 
1389
- const emittedEvents: AgentEvent[] = [];
1390
- const pushedMessages: Message[] = [];
1391
- const persistedTailCalls: Message[] = [];
1392
- // Snapshot of how many tail messages had been persisted at each
1393
- // point a streaming event reached the target. This is the actual
1394
- // observability invariant: when a turn-2 streaming event arrives,
1395
- // turn-1's messages must already be persisted so a fetchHistory
1396
- // call from a client opening the conversation mid-stream returns
1397
- // turn-1's content.
1398
- const persistedAtEachEmit: number[] = [];
1399
- const baseline: Message[] = [
1400
- { role: "user", content: [{ type: "text", text: "hi" }] },
1401
- ];
1402
- const history: Message[] = [...baseline];
1403
- let processing = false;
1404
-
1405
- const target: WakeTarget = {
1343
+ const conversation = makeWakeConversation({
1406
1344
  conversationId: "conv-stream",
1407
- agentLoop: {
1408
- run: async (_input, onEvent, runOptions) => {
1409
- // Preamble + assistant hint + postamble (mirrors what the
1410
- // wake injects). The agent-wake helper expects these three
1411
- // hint messages in the input it hands to run().
1412
- const runHistory: Message[] = [..._input];
1413
-
1414
- // Turn 1: stream a text_delta + message_complete, then
1415
- // fire the checkpoint after the tool_result lands.
1416
- await onEvent({ type: "text_delta", text: "Working" });
1417
- runHistory.push(turn1Assistant);
1418
- await onEvent({
1419
- type: "message_complete",
1420
- message: turn1Assistant,
1421
- });
1422
- runHistory.push(turn1ToolResult);
1423
- const dec1 = await runOptions!.onCheckpoint!({
1424
- turnIndex: 0,
1425
- toolCount: 1,
1426
- hasToolUse: true,
1427
- history: runHistory,
1428
- });
1429
- expect(dec1).toBe("continue");
1430
-
1431
- // Turn 2: another tool turn — must already see the live
1432
- // streaming because mode flipped after turn 1.
1433
- await onEvent({ type: "text_delta", text: "Still going" });
1434
- runHistory.push(turn2Assistant);
1435
- await onEvent({
1436
- type: "message_complete",
1437
- message: turn2Assistant,
1438
- });
1439
- runHistory.push(turn2ToolResult);
1440
- const dec2 = await runOptions!.onCheckpoint!({
1441
- turnIndex: 1,
1442
- toolCount: 1,
1443
- hasToolUse: true,
1444
- history: runHistory,
1445
- });
1446
- expect(dec2).toBe("continue");
1447
-
1448
- // Final assistant message with no tool calls — loop would
1449
- // exit. onCheckpoint does NOT fire for the terminal turn,
1450
- // so the post-run flushPendingTail must catch this one.
1451
- await onEvent({ type: "text_delta", text: "All done." });
1452
- runHistory.push(finalAssistant);
1453
- await onEvent({
1454
- type: "message_complete",
1455
- message: finalAssistant,
1456
- });
1457
- return runResult(runHistory);
1458
- },
1459
- },
1460
- getMessages: () => history,
1461
- pushMessage: (msg) => {
1462
- pushedMessages.push(msg);
1463
- history.push(msg);
1464
- },
1465
- emitAgentEvent: (event) => {
1466
- emittedEvents.push(event);
1467
- persistedAtEachEmit.push(persistedTailCalls.length);
1468
- },
1469
- isProcessing: () => processing,
1470
- markProcessing: (on) => {
1471
- processing = on;
1472
- },
1473
- persistTailMessage: async (msg) => {
1474
- persistedTailCalls.push(msg);
1345
+ baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1346
+ runImpl: async (_input, onEvent, runOptions) => {
1347
+ // Preamble + assistant hint + postamble (mirrors what the
1348
+ // wake injects). The agent-wake helper expects these three
1349
+ // hint messages in the input it hands to run().
1350
+ const runHistory: Message[] = [..._input];
1351
+
1352
+ // Turn 1: stream a text_delta + message_complete, then
1353
+ // fire the checkpoint after the tool_result lands.
1354
+ await onEvent({ type: "text_delta", text: "Working" });
1355
+ runHistory.push(turn1Assistant);
1356
+ await onEvent({
1357
+ type: "message_complete",
1358
+ message: turn1Assistant,
1359
+ });
1360
+ runHistory.push(turn1ToolResult);
1361
+ const dec1 = await runOptions!.onCheckpoint!({
1362
+ turnIndex: 0,
1363
+ toolCount: 1,
1364
+ hasToolUse: true,
1365
+ history: runHistory,
1366
+ });
1367
+ expect(dec1).toBe("continue");
1368
+
1369
+ // Turn 2: another tool turn — must already see the live
1370
+ // streaming because mode flipped after turn 1.
1371
+ await onEvent({ type: "text_delta", text: "Still going" });
1372
+ runHistory.push(turn2Assistant);
1373
+ await onEvent({
1374
+ type: "message_complete",
1375
+ message: turn2Assistant,
1376
+ });
1377
+ runHistory.push(turn2ToolResult);
1378
+ const dec2 = await runOptions!.onCheckpoint!({
1379
+ turnIndex: 1,
1380
+ toolCount: 1,
1381
+ hasToolUse: true,
1382
+ history: runHistory,
1383
+ });
1384
+ expect(dec2).toBe("continue");
1385
+
1386
+ // Final assistant message with no tool calls — loop would
1387
+ // exit. onCheckpoint does NOT fire for the terminal turn,
1388
+ // so the post-run flushPendingTail must catch this one.
1389
+ await onEvent({ type: "text_delta", text: "All done." });
1390
+ runHistory.push(finalAssistant);
1391
+ await onEvent({
1392
+ type: "message_complete",
1393
+ message: finalAssistant,
1394
+ });
1395
+ return runResult(runHistory);
1475
1396
  },
1476
- };
1397
+ });
1477
1398
 
1478
1399
  const result = await wakeAgentForOpportunity(
1479
1400
  {
@@ -1481,31 +1402,35 @@ describe("wakeAgentForOpportunity", () => {
1481
1402
  hint: "consolidate",
1482
1403
  source: "memory_v2_consolidation",
1483
1404
  },
1484
- { resolveTarget: async () => target },
1405
+ { resolveTarget: async () => conversation },
1485
1406
  );
1486
1407
 
1487
1408
  expect(result).toEqual({ invoked: true, producedToolCalls: true });
1488
1409
 
1489
1410
  // All 5 tail messages persisted in order. The first two via
1490
1411
  // turn-1 checkpoint, the next two via turn-2 checkpoint, and
1491
- // `finalAssistant` via the post-run flush.
1492
- expect(persistedTailCalls).toHaveLength(5);
1493
- expect(persistedTailCalls[0]).toBe(turn1Assistant);
1494
- expect(persistedTailCalls[1]).toBe(turn1ToolResult);
1495
- expect(persistedTailCalls[2]).toBe(turn2Assistant);
1496
- expect(persistedTailCalls[3]).toBe(turn2ToolResult);
1497
- expect(persistedTailCalls[4]).toBe(finalAssistant);
1412
+ // `finalAssistant` via the post-run flush. Persistence flows
1413
+ // through addMessage (serialize → store), so identity is not
1414
+ // preserved — assert structural equality.
1415
+ expect(conversation.persistedTailCalls).toHaveLength(5);
1416
+ expect(conversation.persistedTailCalls[0]).toEqual(turn1Assistant);
1417
+ expect(conversation.persistedTailCalls[1]).toEqual(turn1ToolResult);
1418
+ expect(conversation.persistedTailCalls[2]).toEqual(turn2Assistant);
1419
+ expect(conversation.persistedTailCalls[3]).toEqual(turn2ToolResult);
1420
+ expect(conversation.persistedTailCalls[4]).toEqual(finalAssistant);
1498
1421
 
1499
1422
  // Critical observability invariant: by the time turn-2's
1500
1423
  // streaming text_delta reached the client, turn-1's messages
1501
1424
  // were already persisted. A client opening the conversation at
1502
1425
  // that moment would fetchHistory and see turn-1, plus stream
1503
1426
  // turn-2 live — instead of seeing an empty welcome view.
1504
- const turn2DeltaIdx = emittedEvents.findIndex(
1505
- (e) => e.type === "text_delta" && e.text === "Still going",
1427
+ const turn2DeltaIdx = conversation.emittedEvents.findIndex(
1428
+ (e) => e.type === "assistant_text_delta" && e.text === "Still going",
1506
1429
  );
1507
1430
  expect(turn2DeltaIdx).toBeGreaterThan(-1);
1508
- expect(persistedAtEachEmit[turn2DeltaIdx]).toBeGreaterThanOrEqual(2);
1431
+ expect(
1432
+ conversation.persistedAtEachEmit[turn2DeltaIdx],
1433
+ ).toBeGreaterThanOrEqual(2);
1509
1434
  },
1510
1435
  );
1511
1436
 
@@ -1530,46 +1455,22 @@ describe("wakeAgentForOpportunity", () => {
1530
1455
  content: [{ type: "tool_result", tool_use_id: "tu-1", content: "ok" }],
1531
1456
  };
1532
1457
 
1533
- const persistedTailCalls: Message[] = [];
1534
- const baseline: Message[] = [
1535
- { role: "user", content: [{ type: "text", text: "hi" }] },
1536
- ];
1537
- const history: Message[] = [...baseline];
1538
- let processing = false;
1539
- const wakeProducedOutputCalls: string[] = [];
1540
-
1541
- const target: WakeTarget = {
1458
+ const conversation = makeWakeConversation({
1542
1459
  conversationId: "conv-card",
1543
- agentLoop: {
1544
- run: async (_input, _onEvent, runOptions) => {
1545
- const runHistory: Message[] = [..._input];
1546
- runHistory.push(firstAssistant);
1547
- runHistory.push(toolResult);
1548
- await runOptions!.onCheckpoint!({
1549
- turnIndex: 0,
1550
- toolCount: 1,
1551
- hasToolUse: true,
1552
- history: runHistory,
1553
- });
1554
- return runResult(runHistory);
1555
- },
1556
- },
1557
- getMessages: () => history,
1558
- pushMessage: (msg) => {
1559
- history.push(msg);
1560
- },
1561
- emitAgentEvent: () => {},
1562
- isProcessing: () => processing,
1563
- markProcessing: (on) => {
1564
- processing = on;
1565
- },
1566
- persistTailMessage: async (msg) => {
1567
- persistedTailCalls.push(msg);
1568
- },
1569
- onWakeProducedOutput: (_source, _hint, surfaceId) => {
1570
- wakeProducedOutputCalls.push(surfaceId);
1460
+ baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1461
+ runImpl: async (_input, _onEvent, runOptions) => {
1462
+ const runHistory: Message[] = [..._input];
1463
+ runHistory.push(firstAssistant);
1464
+ runHistory.push(toolResult);
1465
+ await runOptions!.onCheckpoint!({
1466
+ turnIndex: 0,
1467
+ toolCount: 1,
1468
+ hasToolUse: true,
1469
+ history: runHistory,
1470
+ });
1471
+ return runResult(runHistory);
1571
1472
  },
1572
- };
1473
+ });
1573
1474
 
1574
1475
  await wakeAgentForOpportunity(
1575
1476
  {
@@ -1577,14 +1478,14 @@ describe("wakeAgentForOpportunity", () => {
1577
1478
  hint: "do the thing",
1578
1479
  source: "memory_v2_consolidation",
1579
1480
  },
1580
- { resolveTarget: async () => target },
1481
+ { resolveTarget: async () => conversation },
1581
1482
  );
1582
1483
 
1583
1484
  // ui_surface fired exactly once (idempotent goLive), and the
1584
1485
  // surfaceId matches the block prepended into the first
1585
1486
  // assistant message.
1586
- expect(wakeProducedOutputCalls).toHaveLength(1);
1587
- const persistedFirst = persistedTailCalls[0];
1487
+ expect(conversation.surfaceBroadcasts).toHaveLength(1);
1488
+ const persistedFirst = conversation.persistedTailCalls[0];
1588
1489
  expect(persistedFirst).toBeDefined();
1589
1490
  const blocks = Array.isArray(persistedFirst!.content)
1590
1491
  ? persistedFirst!.content
@@ -1593,7 +1494,9 @@ describe("wakeAgentForOpportunity", () => {
1593
1494
  (b: { type?: string }) => b.type === "ui_surface",
1594
1495
  ) as { surfaceId?: string } | undefined;
1595
1496
  expect(uiBlock).toBeDefined();
1596
- expect(uiBlock!.surfaceId).toBe(wakeProducedOutputCalls[0]);
1497
+ expect(uiBlock!.surfaceId).toBe(
1498
+ conversation.surfaceBroadcasts[0]!.surfaceId,
1499
+ );
1597
1500
  },
1598
1501
  );
1599
1502
 
@@ -1611,7 +1514,7 @@ describe("wakeAgentForOpportunity", () => {
1611
1514
  rawRequest: { request: "no-op wake" },
1612
1515
  rawResponse: { response: "no output" },
1613
1516
  };
1614
- const target = makeTarget({
1517
+ const conversation = makeWakeConversation({
1615
1518
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1616
1519
  scriptedEvents: [usageEvent],
1617
1520
  // Empty assistant text → silent no-op.
@@ -1623,17 +1526,17 @@ describe("wakeAgentForOpportunity", () => {
1623
1526
 
1624
1527
  const result = await wakeAgentForOpportunity(
1625
1528
  {
1626
- conversationId: target.conversationId,
1529
+ conversationId: conversation.conversationId,
1627
1530
  hint: "consider doing nothing",
1628
1531
  source: "unit-test",
1629
1532
  },
1630
- { resolveTarget: async () => target },
1533
+ { resolveTarget: async () => conversation },
1631
1534
  );
1632
1535
 
1633
1536
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
1634
1537
  // Nothing emitted, nothing persisted to the conversation.
1635
- expect(target.emittedEvents).toHaveLength(0);
1636
- expect(target.persistedTailCalls).toHaveLength(0);
1538
+ expect(conversation.emittedEvents).toHaveLength(0);
1539
+ expect(conversation.persistedTailCalls).toHaveLength(0);
1637
1540
  // Critical: the LLM request log must NOT be inserted with messageId=NULL,
1638
1541
  // otherwise the next user turn's backfillMessageIdOnLogs sweep would
1639
1542
  // misattach this row to an unrelated future assistant reply.
@@ -1652,7 +1555,7 @@ describe("wakeAgentForOpportunity", () => {
1652
1555
  rawRequest: { request: "produced wake" },
1653
1556
  rawResponse: { response: "real reply" },
1654
1557
  };
1655
- const target = makeTarget({
1558
+ const conversation = makeWakeConversation({
1656
1559
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1657
1560
  scriptedEvents: [usageEvent],
1658
1561
  scriptedAssistant: {
@@ -1663,17 +1566,17 @@ describe("wakeAgentForOpportunity", () => {
1663
1566
 
1664
1567
  const result = await wakeAgentForOpportunity(
1665
1568
  {
1666
- conversationId: target.conversationId,
1569
+ conversationId: conversation.conversationId,
1667
1570
  hint: "do reply",
1668
1571
  source: "unit-test",
1669
1572
  },
1670
- { resolveTarget: async () => target },
1573
+ { resolveTarget: async () => conversation },
1671
1574
  );
1672
1575
 
1673
1576
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
1674
1577
  expect(recordRequestLogCalls).toHaveLength(1);
1675
1578
  expect(recordRequestLogCalls[0]).toMatchObject({
1676
- conversationId: target.conversationId,
1579
+ conversationId: conversation.conversationId,
1677
1580
  provider: "test-provider",
1678
1581
  messageId: undefined,
1679
1582
  });
@@ -1696,7 +1599,7 @@ describe("wakeAgentForOpportunity", () => {
1696
1599
  rawRequest: circular,
1697
1600
  rawResponse: { response: "real reply" },
1698
1601
  };
1699
- const target = makeTarget({
1602
+ const conversation = makeWakeConversation({
1700
1603
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1701
1604
  scriptedEvents: [usageEvent],
1702
1605
  scriptedAssistant: {
@@ -1707,16 +1610,16 @@ describe("wakeAgentForOpportunity", () => {
1707
1610
 
1708
1611
  const result = await wakeAgentForOpportunity(
1709
1612
  {
1710
- conversationId: target.conversationId,
1613
+ conversationId: conversation.conversationId,
1711
1614
  hint: "do reply",
1712
1615
  source: "unit-test",
1713
1616
  },
1714
- { resolveTarget: async () => target },
1617
+ { resolveTarget: async () => conversation },
1715
1618
  );
1716
1619
 
1717
1620
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
1718
1621
  // Wake still produced output even though logging failed.
1719
- expect(target.persistedTailCalls).toHaveLength(1);
1622
+ expect(conversation.persistedTailCalls).toHaveLength(1);
1720
1623
  // No log row was inserted because JSON.stringify threw.
1721
1624
  expect(recordRequestLogCalls).toHaveLength(0);
1722
1625
  });
@@ -1731,22 +1634,13 @@ describe("wakeAgentForOpportunity", () => {
1731
1634
  // every fork sent `messages: []`. Threading trustContext through
1732
1635
  // ensures `setTrustContext` + `ensureActorScopedHistory` run during
1733
1636
  // hydration.
1734
- const makeDefaultResolverTarget = (conversationId: string): WakeTarget => {
1735
- const history: Message[] = [];
1736
- let processing = false;
1737
- return {
1637
+ const makeDefaultResolverTarget = (
1638
+ conversationId: string,
1639
+ ): WakeConversation =>
1640
+ makeWakeConversation({
1738
1641
  conversationId,
1739
- agentLoop: { run: async (input) => runResult(input) },
1740
- getMessages: () => history,
1741
- pushMessage: () => {},
1742
- emitAgentEvent: () => {},
1743
- isProcessing: () => processing,
1744
- markProcessing: (on) => {
1745
- processing = on;
1746
- },
1747
- persistTailMessage: async () => {},
1748
- };
1749
- };
1642
+ runImpl: async (input) => runResult(input),
1643
+ });
1750
1644
 
1751
1645
  test("default resolver threads WakeOptions.trustContext into getOrCreateConversation", async () => {
1752
1646
  mockResolverTarget = makeDefaultResolverTarget("conv-thread-trust");
@@ -1787,11 +1681,7 @@ describe("wakeAgentForOpportunity", () => {
1787
1681
  });
1788
1682
 
1789
1683
  describe("suppressWakeSurface option", () => {
1790
- function makeCheckpointTarget(): {
1791
- target: WakeTarget;
1792
- persistedTailCalls: Message[];
1793
- wakeProducedOutputCalls: string[];
1794
- } {
1684
+ function makeCheckpointConversation(): WakeConversation {
1795
1685
  const firstAssistant: Message = {
1796
1686
  role: "assistant",
1797
1687
  content: [
@@ -1802,55 +1692,30 @@ describe("wakeAgentForOpportunity", () => {
1802
1692
  role: "user",
1803
1693
  content: [{ type: "tool_result", tool_use_id: "tu-1", content: "ok" }],
1804
1694
  };
1805
- const persistedTailCalls: Message[] = [];
1806
- const baseline: Message[] = [
1807
- { role: "user", content: [{ type: "text", text: "hi" }] },
1808
- ];
1809
- const history: Message[] = [...baseline];
1810
- let processing = false;
1811
- const wakeProducedOutputCalls: string[] = [];
1812
1695
 
1813
- const target: WakeTarget = {
1696
+ return makeWakeConversation({
1814
1697
  conversationId: "conv-suppress-surface",
1815
- agentLoop: {
1816
- run: async (_input, _onEvent, runOptions) => {
1817
- const runHistory: Message[] = [..._input];
1818
- runHistory.push(firstAssistant);
1819
- runHistory.push(toolResult);
1820
- await runOptions!.onCheckpoint!({
1821
- turnIndex: 0,
1822
- toolCount: 1,
1823
- hasToolUse: true,
1824
- history: runHistory,
1825
- });
1826
- return runResult(runHistory);
1827
- },
1828
- },
1829
- getMessages: () => history,
1830
- pushMessage: (msg) => {
1831
- history.push(msg);
1832
- },
1833
- emitAgentEvent: () => {},
1834
- isProcessing: () => processing,
1835
- markProcessing: (on) => {
1836
- processing = on;
1837
- },
1838
- persistTailMessage: async (msg) => {
1839
- persistedTailCalls.push(msg);
1840
- },
1841
- onWakeProducedOutput: (_source, _hint, surfaceId) => {
1842
- wakeProducedOutputCalls.push(surfaceId);
1698
+ baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
1699
+ runImpl: async (_input, _onEvent, runOptions) => {
1700
+ const runHistory: Message[] = [..._input];
1701
+ runHistory.push(firstAssistant);
1702
+ runHistory.push(toolResult);
1703
+ await runOptions!.onCheckpoint!({
1704
+ turnIndex: 0,
1705
+ toolCount: 1,
1706
+ hasToolUse: true,
1707
+ history: runHistory,
1708
+ });
1709
+ return runResult(runHistory);
1843
1710
  },
1844
- };
1845
- return { target, persistedTailCalls, wakeProducedOutputCalls };
1711
+ });
1846
1712
  }
1847
1713
 
1848
1714
  test(
1849
1715
  "default (suppressWakeSurface omitted) still injects the ui_surface " +
1850
1716
  "card and calls onWakeProducedOutput",
1851
1717
  async () => {
1852
- const { target, persistedTailCalls, wakeProducedOutputCalls } =
1853
- makeCheckpointTarget();
1718
+ const conversation = makeCheckpointConversation();
1854
1719
 
1855
1720
  await wakeAgentForOpportunity(
1856
1721
  {
@@ -1858,12 +1723,12 @@ describe("wakeAgentForOpportunity", () => {
1858
1723
  hint: "do the thing",
1859
1724
  source: "memory_v2_consolidation",
1860
1725
  },
1861
- { resolveTarget: async () => target },
1726
+ { resolveTarget: async () => conversation },
1862
1727
  );
1863
1728
 
1864
1729
  // Existing behavior: card injected, broadcast fired exactly once.
1865
- expect(wakeProducedOutputCalls).toHaveLength(1);
1866
- const persistedFirst = persistedTailCalls[0];
1730
+ expect(conversation.surfaceBroadcasts).toHaveLength(1);
1731
+ const persistedFirst = conversation.persistedTailCalls[0];
1867
1732
  expect(persistedFirst).toBeDefined();
1868
1733
  const blocks = Array.isArray(persistedFirst!.content)
1869
1734
  ? persistedFirst!.content
@@ -1879,8 +1744,7 @@ describe("wakeAgentForOpportunity", () => {
1879
1744
  "suppressWakeSurface: true produces output but skips the ui_surface " +
1880
1745
  "card injection and the onWakeProducedOutput broadcast",
1881
1746
  async () => {
1882
- const { target, persistedTailCalls, wakeProducedOutputCalls } =
1883
- makeCheckpointTarget();
1747
+ const conversation = makeCheckpointConversation();
1884
1748
 
1885
1749
  await wakeAgentForOpportunity(
1886
1750
  {
@@ -1889,11 +1753,11 @@ describe("wakeAgentForOpportunity", () => {
1889
1753
  source: "memory_v2_consolidation",
1890
1754
  suppressWakeSurface: true,
1891
1755
  },
1892
- { resolveTarget: async () => target },
1756
+ { resolveTarget: async () => conversation },
1893
1757
  );
1894
1758
 
1895
1759
  // Tail still persisted (wake produced real output).
1896
- const persistedFirst = persistedTailCalls[0];
1760
+ const persistedFirst = conversation.persistedTailCalls[0];
1897
1761
  expect(persistedFirst).toBeDefined();
1898
1762
  // First assistant tail message should NOT have a ui_surface block
1899
1763
  // prepended at the front.
@@ -1907,7 +1771,7 @@ describe("wakeAgentForOpportunity", () => {
1907
1771
  );
1908
1772
  expect(uiBlock).toBeUndefined();
1909
1773
  // Live broadcast was suppressed.
1910
- expect(wakeProducedOutputCalls).toHaveLength(0);
1774
+ expect(conversation.surfaceBroadcasts).toHaveLength(0);
1911
1775
  },
1912
1776
  );
1913
1777
  });