@animalabs/agent-framework 0.22.1 → 0.22.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 (359) hide show
  1. package/README.md +1 -1
  2. package/dist/src/agent.d.ts +192 -19
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +404 -65
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts +41 -0
  7. package/dist/src/api/mcp-server-core.d.ts.map +1 -0
  8. package/dist/src/api/mcp-server-core.js +593 -0
  9. package/dist/src/api/mcp-server-core.js.map +1 -0
  10. package/dist/src/api/mcp-server.d.ts +2 -36
  11. package/dist/src/api/mcp-server.d.ts.map +1 -1
  12. package/dist/src/api/mcp-server.js +15 -595
  13. package/dist/src/api/mcp-server.js.map +1 -1
  14. package/dist/src/api/server.d.ts +9 -1
  15. package/dist/src/api/server.d.ts.map +1 -1
  16. package/dist/src/api/server.js +50 -26
  17. package/dist/src/api/server.js.map +1 -1
  18. package/dist/src/api/types.d.ts +9 -1
  19. package/dist/src/api/types.d.ts.map +1 -1
  20. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  21. package/dist/src/code-execution/py-runner.js.map +1 -1
  22. package/dist/src/framework.d.ts +393 -113
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1748 -582
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/gate-script.d.ts.map +1 -1
  28. package/dist/src/gate/gate-script.js.map +1 -1
  29. package/dist/src/index.d.ts +2 -2
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +2 -2
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  34. package/dist/src/mcpl/channel-registry.d.ts +26 -7
  35. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  36. package/dist/src/mcpl/channel-registry.js +102 -22
  37. package/dist/src/mcpl/channel-registry.js.map +1 -1
  38. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  40. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  41. package/dist/src/mcpl/errors.js.map +1 -1
  42. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  43. package/dist/src/mcpl/feature-set-manager.js +4 -3
  44. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  46. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  47. package/dist/src/mcpl/log-value.d.ts +54 -0
  48. package/dist/src/mcpl/log-value.d.ts.map +1 -0
  49. package/dist/src/mcpl/log-value.js +109 -0
  50. package/dist/src/mcpl/log-value.js.map +1 -0
  51. package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
  52. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  53. package/dist/src/mcpl/prose-stream-router.js +4 -4
  54. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  55. package/dist/src/mcpl/push-coalescer.d.ts +38 -3
  56. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  57. package/dist/src/mcpl/push-coalescer.js +81 -16
  58. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  59. package/dist/src/mcpl/push-handler.d.ts +26 -3
  60. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-handler.js +87 -23
  62. package/dist/src/mcpl/push-handler.js.map +1 -1
  63. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  64. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  65. package/dist/src/mcpl/references.d.ts +2 -2
  66. package/dist/src/mcpl/references.d.ts.map +1 -1
  67. package/dist/src/mcpl/references.js.map +1 -1
  68. package/dist/src/mcpl/refusal-lines.d.ts +37 -0
  69. package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
  70. package/dist/src/mcpl/refusal-lines.js +61 -0
  71. package/dist/src/mcpl/refusal-lines.js.map +1 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
  73. package/dist/src/mcpl/server-connection.d.ts +18 -2
  74. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  75. package/dist/src/mcpl/server-connection.js +66 -11
  76. package/dist/src/mcpl/server-connection.js.map +1 -1
  77. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  78. package/dist/src/mcpl/tool-classes.d.ts +1 -1
  79. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  80. package/dist/src/mcpl/tool-classes.js +3 -2
  81. package/dist/src/mcpl/tool-classes.js.map +1 -1
  82. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  83. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  84. package/dist/src/mcpl/transport.d.ts +23 -5
  85. package/dist/src/mcpl/transport.d.ts.map +1 -1
  86. package/dist/src/mcpl/transport.js +103 -10
  87. package/dist/src/mcpl/transport.js.map +1 -1
  88. package/dist/src/mcpl/types.d.ts +30 -26
  89. package/dist/src/mcpl/types.d.ts.map +1 -1
  90. package/dist/src/mcpl/types.js.map +1 -1
  91. package/dist/src/module-registry.d.ts.map +1 -1
  92. package/dist/src/module-registry.js.map +1 -1
  93. package/dist/src/modules/api/index.d.ts.map +1 -1
  94. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  95. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  96. package/dist/src/modules/discord/index.d.ts.map +1 -1
  97. package/dist/src/modules/discord/index.js +4 -2
  98. package/dist/src/modules/discord/index.js.map +1 -1
  99. package/dist/src/modules/health/index.d.ts.map +1 -1
  100. package/dist/src/modules/health/index.js.map +1 -1
  101. package/dist/src/modules/history/index.d.ts.map +1 -1
  102. package/dist/src/modules/history/index.js.map +1 -1
  103. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  104. package/dist/src/modules/history/semantic.js.map +1 -1
  105. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  107. package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
  108. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/disk-agreement.js +63 -25
  110. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/index.d.ts +8 -4
  113. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  114. package/dist/src/modules/workspace/index.js +39 -12
  115. package/dist/src/modules/workspace/index.js.map +1 -1
  116. package/dist/src/modules/workspace/observe.d.ts +43 -8
  117. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  118. package/dist/src/modules/workspace/observe.js +264 -39
  119. package/dist/src/modules/workspace/observe.js.map +1 -1
  120. package/dist/src/modules/workspace/reconcile.d.ts +17 -1
  121. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  122. package/dist/src/modules/workspace/reconcile.js +57 -14
  123. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  124. package/dist/src/modules/workspace/types.d.ts +27 -2
  125. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/types.js.map +1 -1
  127. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  128. package/dist/src/modules/workspace/watcher.js.map +1 -1
  129. package/dist/src/operator-log.d.ts.map +1 -1
  130. package/dist/src/operator-log.js.map +1 -1
  131. package/dist/src/provider-waits.d.ts.map +1 -1
  132. package/dist/src/provider-waits.js.map +1 -1
  133. package/dist/src/queue.d.ts.map +1 -1
  134. package/dist/src/record-journal.d.ts.map +1 -1
  135. package/dist/src/record-journal.js.map +1 -1
  136. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  137. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  138. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  139. package/dist/src/recovery/offline-branch.js +29 -24
  140. package/dist/src/recovery/offline-branch.js.map +1 -1
  141. package/dist/src/recovery/recover-cli.js.map +1 -1
  142. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
  143. package/dist/src/runtime/liveness-watchdog.js.map +1 -1
  144. package/dist/src/shutdown.d.ts.map +1 -1
  145. package/dist/src/shutdown.js.map +1 -1
  146. package/dist/src/stored-removal.d.ts +73 -0
  147. package/dist/src/stored-removal.d.ts.map +1 -0
  148. package/dist/src/stored-removal.js +100 -0
  149. package/dist/src/stored-removal.js.map +1 -0
  150. package/dist/src/thinking-token-stamp.js.map +1 -1
  151. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  152. package/dist/src/tool-image-ledger.js.map +1 -1
  153. package/dist/src/tool-presentation.d.ts.map +1 -1
  154. package/dist/src/tool-presentation.js.map +1 -1
  155. package/dist/src/tool-result-guard.d.ts.map +1 -1
  156. package/dist/src/tool-result-guard.js.map +1 -1
  157. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  158. package/dist/src/tune-out/coordinator.js.map +1 -1
  159. package/dist/src/tune-out/tools.d.ts +1 -1
  160. package/dist/src/tune-out/tools.d.ts.map +1 -1
  161. package/dist/src/types/agent.d.ts +42 -6
  162. package/dist/src/types/agent.d.ts.map +1 -1
  163. package/dist/src/types/framework.d.ts +10 -0
  164. package/dist/src/types/framework.d.ts.map +1 -1
  165. package/dist/src/types/module.d.ts +8 -4
  166. package/dist/src/types/module.d.ts.map +1 -1
  167. package/dist/src/types/trace.d.ts +61 -10
  168. package/dist/src/types/trace.d.ts.map +1 -1
  169. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  170. package/dist/test/agent-runtime-settings.test.js +1139 -3
  171. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  172. package/dist/test/batched-wake-locus.test.js.map +1 -1
  173. package/dist/test/channel-label-history.test.js.map +1 -1
  174. package/dist/test/code-execution.test.js.map +1 -1
  175. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  176. package/dist/test/conversation-router.test.js.map +1 -1
  177. package/dist/test/conversation-routing.test.js +240 -6
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +104 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
  182. package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
  183. package/dist/test/discord-typing-keepalive.test.js +77 -0
  184. package/dist/test/discord-typing-keepalive.test.js.map +1 -0
  185. package/dist/test/ephemeral-settle.test.js.map +1 -1
  186. package/dist/test/event-gate-passthrough.test.js.map +1 -1
  187. package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
  188. package/dist/test/event-gate-rate-limit.test.js.map +1 -1
  189. package/dist/test/event-gate-script.test.js.map +1 -1
  190. package/dist/test/event-gate-selfwake.test.js.map +1 -1
  191. package/dist/test/event-gate-tags.test.js.map +1 -1
  192. package/dist/test/event-gate.test.js.map +1 -1
  193. package/dist/test/explicit-prose-routing.test.js +363 -42
  194. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  195. package/dist/test/framework.test.js.map +1 -1
  196. package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
  197. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  198. package/dist/test/helpers/coalescing-fixture.js +8 -2
  199. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  200. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  201. package/dist/test/helpers/mock-membrane.js.map +1 -1
  202. package/dist/test/hide-then-turn.test.d.ts +2 -0
  203. package/dist/test/hide-then-turn.test.d.ts.map +1 -0
  204. package/dist/test/hide-then-turn.test.js +101 -0
  205. package/dist/test/hide-then-turn.test.js.map +1 -0
  206. package/dist/test/history-module-overview.test.js.map +1 -1
  207. package/dist/test/history-module-semantic.test.js.map +1 -1
  208. package/dist/test/history-module.test.js.map +1 -1
  209. package/dist/test/history-semantic-review-findings.test.js.map +1 -1
  210. package/dist/test/host-quiesce.test.js +125 -0
  211. package/dist/test/host-quiesce.test.js.map +1 -1
  212. package/dist/test/inference-exhaustion-rewind.test.js +81 -3
  213. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  214. package/dist/test/inference-failure-bounds.test.js +1 -0
  215. package/dist/test/inference-failure-bounds.test.js.map +1 -1
  216. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
  217. package/dist/test/liveness-watchdog.test.js.map +1 -1
  218. package/dist/test/maintenance-timer.test.js +1564 -2
  219. package/dist/test/maintenance-timer.test.js.map +1 -1
  220. package/dist/test/mcp-server-bin.test.d.ts +2 -0
  221. package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
  222. package/dist/test/mcp-server-bin.test.js +142 -0
  223. package/dist/test/mcp-server-bin.test.js.map +1 -0
  224. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  225. package/dist/test/mcpl-child-env.test.js +1 -1
  226. package/dist/test/mcpl-child-env.test.js.map +1 -1
  227. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
  228. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
  229. package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
  230. package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
  231. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  232. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  233. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  234. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  235. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  236. package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
  237. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  238. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  239. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  240. package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
  241. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
  242. package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
  243. package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
  244. package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
  245. package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
  246. package/dist/test/mcpl-stderr-tail.test.js +210 -0
  247. package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
  248. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
  249. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  250. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
  251. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
  252. package/dist/test/mcpl-transport-close.test.d.ts +2 -0
  253. package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
  254. package/dist/test/mcpl-transport-close.test.js +373 -0
  255. package/dist/test/mcpl-transport-close.test.js.map +1 -0
  256. package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
  257. package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
  258. package/dist/test/mcpl-transport-spawn.test.js +58 -0
  259. package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
  260. package/dist/test/mcpl-ws-transport.test.js +1 -0
  261. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  262. package/dist/test/offline-recovery-branch.test.js +344 -0
  263. package/dist/test/offline-recovery-branch.test.js.map +1 -1
  264. package/dist/test/operator-surgery.test.js +197 -0
  265. package/dist/test/operator-surgery.test.js.map +1 -1
  266. package/dist/test/overbudget-drain-breaker.test.js +1 -0
  267. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  268. package/dist/test/pack-installs-alone.test.d.ts +2 -0
  269. package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
  270. package/dist/test/pack-installs-alone.test.js +284 -0
  271. package/dist/test/pack-installs-alone.test.js.map +1 -0
  272. package/dist/test/physical-window-restart.test.d.ts +6 -4
  273. package/dist/test/physical-window-restart.test.d.ts.map +1 -1
  274. package/dist/test/physical-window-restart.test.js +275 -10
  275. package/dist/test/physical-window-restart.test.js.map +1 -1
  276. package/dist/test/placed-reaction-baseline.test.js.map +1 -1
  277. package/dist/test/present-while-acting.test.js +949 -6
  278. package/dist/test/present-while-acting.test.js.map +1 -1
  279. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  280. package/dist/test/provider-waits.test.js +67 -4
  281. package/dist/test/provider-waits.test.js.map +1 -1
  282. package/dist/test/puppet-tool-call.test.js +2 -0
  283. package/dist/test/puppet-tool-call.test.js.map +1 -1
  284. package/dist/test/reaction-baseline-platform.test.js.map +1 -1
  285. package/dist/test/record-journal.test.js.map +1 -1
  286. package/dist/test/refusal-rewind.test.js +9 -0
  287. package/dist/test/refusal-rewind.test.js.map +1 -1
  288. package/dist/test/refusal-single-call.test.js.map +1 -1
  289. package/dist/test/release-bump.test.d.ts +2 -0
  290. package/dist/test/release-bump.test.d.ts.map +1 -0
  291. package/dist/test/release-bump.test.js +248 -0
  292. package/dist/test/release-bump.test.js.map +1 -0
  293. package/dist/test/release-changelog.test.js.map +1 -1
  294. package/dist/test/release-holds.test.d.ts +2 -0
  295. package/dist/test/release-holds.test.d.ts.map +1 -0
  296. package/dist/test/release-holds.test.js +456 -0
  297. package/dist/test/release-holds.test.js.map +1 -0
  298. package/dist/test/same-round-think-policy.test.js.map +1 -1
  299. package/dist/test/shutdown-errors.test.js.map +1 -1
  300. package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
  301. package/dist/test/stop-with-host-operations.test.d.ts +20 -0
  302. package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
  303. package/dist/test/stop-with-host-operations.test.js +1174 -0
  304. package/dist/test/stop-with-host-operations.test.js.map +1 -0
  305. package/dist/test/stop-with-work-in-flight.test.js +162 -50
  306. package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
  307. package/dist/test/surgery-marks-receipt.test.js.map +1 -1
  308. package/dist/test/tool-batch-hook.test.js.map +1 -1
  309. package/dist/test/tool-result-guard.test.js +131 -9
  310. package/dist/test/tool-result-guard.test.js.map +1 -1
  311. package/dist/test/tool-result-spill.test.js.map +1 -1
  312. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
  313. package/dist/test/trunk-channel-routing.test.js +330 -7
  314. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  315. package/dist/test/tune-out-e2e.test.js.map +1 -1
  316. package/dist/test/unconvertible-tool-result.test.js.map +1 -1
  317. package/dist/test/unsent-compiles.test.d.ts +2 -0
  318. package/dist/test/unsent-compiles.test.d.ts.map +1 -0
  319. package/dist/test/unsent-compiles.test.js +139 -0
  320. package/dist/test/unsent-compiles.test.js.map +1 -0
  321. package/dist/test/workspace-branch-continuation.test.js.map +1 -1
  322. package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
  323. package/dist/test/workspace-read-text.test.js.map +1 -1
  324. package/dist/test/workspace-three-way.test.js +907 -7
  325. package/dist/test/workspace-three-way.test.js.map +1 -1
  326. package/dist/test/workspace-watcher.test.js.map +1 -1
  327. package/dist/test/xml-round-persist.test.js.map +1 -1
  328. package/dist/tsconfig.tsbuildinfo +1 -1
  329. package/package.json +8 -6
  330. package/src/agent.ts +411 -66
  331. package/src/api/mcp-server-core.ts +681 -0
  332. package/src/api/mcp-server.ts +16 -685
  333. package/src/api/server.ts +51 -29
  334. package/src/api/types.ts +9 -1
  335. package/src/framework.ts +1899 -641
  336. package/src/index.ts +2 -2
  337. package/src/mcpl/channel-registry.ts +126 -31
  338. package/src/mcpl/feature-set-manager.ts +4 -3
  339. package/src/mcpl/log-value.ts +116 -0
  340. package/src/mcpl/prose-stream-router.ts +6 -6
  341. package/src/mcpl/push-coalescer.ts +76 -12
  342. package/src/mcpl/push-handler.ts +95 -23
  343. package/src/mcpl/refusal-lines.ts +59 -0
  344. package/src/mcpl/server-connection.ts +70 -11
  345. package/src/mcpl/tool-classes.ts +3 -2
  346. package/src/mcpl/transport.ts +105 -14
  347. package/src/mcpl/types.ts +5 -1
  348. package/src/modules/discord/index.ts +4 -2
  349. package/src/modules/workspace/disk-agreement.ts +74 -18
  350. package/src/modules/workspace/index.ts +41 -11
  351. package/src/modules/workspace/observe.ts +265 -39
  352. package/src/modules/workspace/reconcile.ts +71 -14
  353. package/src/modules/workspace/types.ts +26 -1
  354. package/src/recovery/offline-branch.ts +32 -28
  355. package/src/stored-removal.ts +126 -0
  356. package/src/types/agent.ts +42 -6
  357. package/src/types/framework.ts +10 -0
  358. package/src/types/module.ts +8 -4
  359. package/src/types/trace.ts +62 -11
@@ -22,6 +22,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
23
  import { AgentFramework } from '../src/index.js';
24
24
  import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js';
25
+ import { MembraneError } from '@animalabs/membrane';
26
+ import { JsStore } from '@animalabs/chronicle';
25
27
  // ---------------------------------------------------------------------------
26
28
  // Test module: tools that optionally emit a mid-turn external message
27
29
  // ---------------------------------------------------------------------------
@@ -39,8 +41,18 @@ class RobotModule {
39
41
  interjectionMetadata = null;
40
42
  /** Delay tool completion so live routing deterministically precedes it. */
41
43
  toolDelayMs = 0;
44
+ /** Run once, during the next turn start's context gathering (the turn is
45
+ * alive and its stream doesn't exist yet). */
46
+ startHook = null;
42
47
  async start(_ctx) { }
43
48
  async stop() { }
49
+ async gatherContext() {
50
+ const hook = this.startHook;
51
+ this.startHook = null;
52
+ if (hook)
53
+ await hook();
54
+ return [];
55
+ }
44
56
  getTools() {
45
57
  return [
46
58
  {
@@ -150,12 +162,12 @@ describe('present while acting', () => {
150
162
  afterEach(() => {
151
163
  rmSync(tempDir, { recursive: true, force: true });
152
164
  });
153
- async function createFramework() {
165
+ async function createFramework(agentOptions = {}) {
154
166
  const framework = await AgentFramework.create({
155
167
  storePath: join(tempDir, 'test.chronicle'),
156
168
  membrane: membrane.asMembrane(),
157
169
  agents: [
158
- { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.' },
170
+ { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', ...agentOptions },
159
171
  ],
160
172
  modules: [module],
161
173
  });
@@ -335,11 +347,11 @@ describe('present while acting', () => {
335
347
  };
336
348
  // Simulate an explicit send into #portables earlier in THIS turn (the
337
349
  // production record happens on the MCPL send path; the harness module's
338
- // tools are not MCPL, so seed the turn-scoped set mid-turn — after the
339
- // turn-start clear, before the injection boundary).
350
+ // tools are not MCPL, so seed the turn's engaged set mid-turn — after
351
+ // the turn opens, before the injection boundary).
340
352
  const origHandle = module.handleToolCall.bind(module);
341
353
  module.handleToolCall = async (call) => {
342
- framework.turnEngagedChannels.set('assistant', new Set(['discord:guild:portables']));
354
+ framework.turnRouting.get('assistant').engaged.add('discord:guild:portables');
343
355
  return origHandle(call);
344
356
  };
345
357
  trigger(framework);
@@ -371,7 +383,7 @@ describe('present while acting', () => {
371
383
  };
372
384
  const origHandle = module.handleToolCall.bind(module);
373
385
  module.handleToolCall = async (call) => {
374
- framework.turnEngagedChannels.set('assistant', new Set(['discord:guild:hospital']));
386
+ framework.turnRouting.get('assistant').engaged.add('discord:guild:hospital');
375
387
  return origHandle(call);
376
388
  };
377
389
  trigger(framework);
@@ -432,6 +444,937 @@ describe('present while acting', () => {
432
444
  assert.equal(notices.length, 1, 'only the boot-baseline announcement — no re-pin chatter');
433
445
  await framework.stop();
434
446
  });
447
+ // -------------------------------------------------------------------------
448
+ // Every later start of the turn keeps its locus
449
+ // -------------------------------------------------------------------------
450
+ // A framework retry, the compile a capacity hold defers a turn to, the
451
+ // re-inference on unstreamed tool results and a budget restart all keep
452
+ // the turn's locus, re-pins included. A message that no tool boundary
453
+ // weighed, because none of the turn's streams was running or its boundary
454
+ // couldn't re-pin, still moves it at the turn's next start, by the mid-turn
455
+ // rule: a conversational message that addressed the agent, or that follows
456
+ // up in a channel the turn sent into.
457
+ /** Make each stream in `indices` (1-based) end with `error` instead of completing. */
458
+ function failStream(indices, error) {
459
+ const failing = new Set(Array.isArray(indices) ? indices : [indices]);
460
+ const makeStream = membrane.streamYielding.bind(membrane);
461
+ let streams = 0;
462
+ membrane.streamYielding = (request, options) => {
463
+ const stream = makeStream(request, options);
464
+ const n = ++streams;
465
+ const iterate = stream[Symbol.asyncIterator].bind(stream);
466
+ stream[Symbol.asyncIterator] = async function* () {
467
+ for await (const event of { [Symbol.asyncIterator]: iterate }) {
468
+ if (event.type === 'complete' && failing.has(n)) {
469
+ yield { type: 'error', error: error(request) };
470
+ return;
471
+ }
472
+ yield event;
473
+ }
474
+ };
475
+ return stream;
476
+ };
477
+ }
478
+ /** Feed a response to the Nth turn start (1-based) the moment it begins. */
479
+ function feedStart(framework, start, ...responses) {
480
+ let starts = 0;
481
+ framework.onTrace((event) => {
482
+ if (event.type !== 'inference:started' || ++starts !== start)
483
+ return;
484
+ responses.forEach((blocks, i) => membrane.pushResponse(createMockResponse(blocks, i < responses.length - 1 ? 'tool_use' : undefined)));
485
+ });
486
+ }
487
+ /** An organization acceleration 429, which holds the turn for a later compile. */
488
+ const accelerationLimit = (request) => new MembraneError({
489
+ type: 'rate_limit', retryable: true, httpStatus: 429,
490
+ message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute",
491
+ rawError: { status: 429 }, rawRequest: request,
492
+ });
493
+ /** Bring the agent's capacity hold to its deadline and release it, as its
494
+ * timer does then, and run the deferred compile. */
495
+ async function release(framework) {
496
+ const internals = framework;
497
+ const hold = internals.providerAccelerationCooldowns.get('assistant');
498
+ assert.ok(hold, 'the turn is held');
499
+ hold.until = Date.now();
500
+ internals.releaseProviderAccelerationCooldown('assistant');
501
+ await framework.runUntilIdle();
502
+ }
503
+ /** A message someone sends, stored as the test module stores it. */
504
+ async function say(framework, text, metadata) {
505
+ framework.pushEvent({ type: 'external-message', source: 'test', content: text, metadata });
506
+ await framework.runUntilIdle();
507
+ }
508
+ /** Remove a stored message, as the retraction of an unread coalesced message does (removePlacement). */
509
+ function withdraw(framework, text) {
510
+ const cm = framework.getAgent('assistant').getContextManager();
511
+ const message = cm.getAllMessages().find((m) => m.content.some((b) => b.type === 'text' && b.text === text));
512
+ assert.ok(message, `"${text}" is stored`);
513
+ cm.removeMessage(message.id);
514
+ }
515
+ const addressed = (channelId) => ({ channelId, tags: ['chat:addressed'] });
516
+ const ambient = (channelId) => ({ channelId, tags: ['chat:ambient', 'chat:from-human'] });
517
+ /** The `[routing] The conversation moved to …` notices, by channel. */
518
+ const movedTo = (framework) => framework.getAgent('assistant').getContextManager().getAllMessages()
519
+ .filter((m) => m.metadata?.kind === 'routing-notice')
520
+ .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => b.text))
521
+ .map((text) => /^\[routing\] The conversation moved to (\S+) /.exec(text)?.[1])
522
+ .filter((channel) => channel !== undefined);
523
+ it("a framework retry keeps the turn's locus, a mid-turn re-pin included", async () => {
524
+ membrane.pushResponse(createMockResponse([
525
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
526
+ ], 'tool_use'));
527
+ membrane.pushResponse(createMockResponse([]));
528
+ const framework = await createFramework();
529
+ const routed = stubChannelRegistry(framework);
530
+ module.toolDelayMs = 25;
531
+ module.interjection = 'quick question for you';
532
+ module.interjectionMetadata = addressed('discord:dm:antra');
533
+ failStream(1, () => new Error('synthetic transient stream error'));
534
+ feedStart(framework, 2, [{ type: 'text', text: 'Answering after the retry.' }]);
535
+ trigger(framework);
536
+ await framework.runUntilIdle();
537
+ assert.equal(membrane.calls.length, 2, 'the error was retried');
538
+ assert.deepEqual(routed, [
539
+ { text: 'Answering after the retry.', locus: 'discord:dm:antra' },
540
+ ], 'the retry speaks where the re-pin moved the turn, not at a re-resolved locus');
541
+ await framework.stop();
542
+ });
543
+ for (const [label, metadata, expected] of [
544
+ ['an addressed message from another channel moves the locus there, with the notice',
545
+ addressed('discord:dm:antra'), 'discord:dm:antra'],
546
+ ['ambient traffic in another channel leaves the locus where it was',
547
+ ambient('discord:guild:lounge'), 'chan-live-1'],
548
+ // Tune-out stamps a message it diverts, and a resident's compiled view
549
+ // leaves it out, so the agent never sees it.
550
+ ['an addressed message that tune-out diverts leaves the locus where it was',
551
+ { ...addressed('discord:dm:antra'), tuneOut: { epochId: 'epoch-1' } }, 'chan-live-1'],
552
+ ['an addressed system marker from another channel leaves the locus where it was',
553
+ { ...addressed('discord:dm:antra'), system: true }, 'chan-live-1'],
554
+ ]) {
555
+ it(`during a capacity hold, ${label}`, async () => {
556
+ membrane.pushResponse(createMockResponse([
557
+ { type: 'text', text: 'On it.' },
558
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
559
+ ], 'tool_use'));
560
+ membrane.pushResponse(createMockResponse([]));
561
+ const framework = await createFramework();
562
+ const routed = stubChannelRegistry(framework);
563
+ module.toolDelayMs = 25;
564
+ failStream(1, accelerationLimit);
565
+ feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }]);
566
+ trigger(framework);
567
+ await framework.runUntilIdle();
568
+ // No turn is alive during the hold, so the message is stored at once.
569
+ await say(framework, 'a message during the hold', metadata);
570
+ assert.equal(membrane.calls.length, 1, 'the turn is still held');
571
+ await release(framework);
572
+ assert.equal(membrane.calls.length, 2, 'one deferred compile');
573
+ assert.deepEqual(routed, [
574
+ { text: 'On it.', locus: 'chan-live-1' },
575
+ { text: 'Here is the rest.', locus: expected },
576
+ ]);
577
+ assert.deepEqual(movedTo(framework), expected === 'chan-live-1' ? [] : [expected], 'a re-pin tells the agent, and nothing else does');
578
+ await framework.stop();
579
+ });
580
+ }
581
+ for (const [label, messages, expected] of [
582
+ ['an addressed message withdrawn before the deferred compile moves nothing',
583
+ [['soon withdrawn', 'discord:dm:antra', true]], 'chan-live-1'],
584
+ ['the newest addressed message that remains moves the locus, when a newer one is withdrawn',
585
+ [['still there', 'discord:dm:antra', false], ['soon withdrawn', 'discord:dm:q', true]], 'discord:dm:antra'],
586
+ ]) {
587
+ it(`during a capacity hold, ${label}`, async () => {
588
+ membrane.pushResponse(createMockResponse([
589
+ { type: 'text', text: 'On it.' },
590
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
591
+ ], 'tool_use'));
592
+ membrane.pushResponse(createMockResponse([]));
593
+ const framework = await createFramework();
594
+ const routed = stubChannelRegistry(framework);
595
+ module.toolDelayMs = 25;
596
+ failStream(1, accelerationLimit);
597
+ feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }]);
598
+ trigger(framework);
599
+ await framework.runUntilIdle();
600
+ for (const [text, channelId] of messages)
601
+ await say(framework, text, addressed(channelId));
602
+ // Each withdrawn message leaves the agent's store before the turn's
603
+ // next start, so the agent never sees it there.
604
+ for (const [text, , withdrawn] of messages)
605
+ if (withdrawn)
606
+ withdraw(framework, text);
607
+ await release(framework);
608
+ assert.equal(membrane.calls.length, 2, 'one deferred compile');
609
+ assert.deepEqual(routed, [
610
+ { text: 'On it.', locus: 'chan-live-1' },
611
+ { text: 'Here is the rest.', locus: expected },
612
+ ]);
613
+ assert.deepEqual(movedTo(framework), expected === 'chan-live-1' ? [] : [expected], 'a re-pin tells the agent, and nothing else does');
614
+ await framework.stop();
615
+ });
616
+ }
617
+ it("during a capacity hold, a store that can't be read at the deferred compile fails the start rather than drop the arrival", async () => {
618
+ // Adapted from a reviewer's probe: a read that fails says nothing about
619
+ // whether the message is there, so it can't count as a withdrawal.
620
+ membrane.pushResponse(createMockResponse([
621
+ { type: 'text', text: 'On it.' },
622
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
623
+ ], 'tool_use'));
624
+ membrane.pushResponse(createMockResponse([]));
625
+ const framework = await createFramework();
626
+ const routed = stubChannelRegistry(framework);
627
+ module.toolDelayMs = 25;
628
+ failStream(1, accelerationLimit);
629
+ // Were the failed read taken as a withdrawal, the compile would run and
630
+ // answer here, at the turn's old channel.
631
+ feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }]);
632
+ trigger(framework);
633
+ await framework.runUntilIdle();
634
+ await say(framework, 'a question for you', addressed('discord:dm:antra'));
635
+ const cm = framework.getAgent('assistant').getContextManager();
636
+ const arrival = cm.getAllMessages().find((m) => m.content.some((b) => b.type === 'text' && b.text === 'a question for you'));
637
+ const getMessage = cm.getMessage.bind(cm);
638
+ cm.getMessage = (id) => {
639
+ if (id === arrival.id)
640
+ throw new Error('store unavailable');
641
+ return getMessage(id);
642
+ };
643
+ try {
644
+ await assert.rejects(release(framework), /store unavailable/);
645
+ }
646
+ finally {
647
+ cm.getMessage = getMessage;
648
+ }
649
+ assert.equal(membrane.calls.length, 1, 'the deferred compile never ran');
650
+ assert.deepEqual(routed, [{ text: 'On it.', locus: 'chan-live-1' }], 'and the turn spoke nowhere else');
651
+ assert.deepEqual(movedTo(framework), [], 'nor told the agent of a move');
652
+ await framework.stop();
653
+ });
654
+ it("a message a tool boundary already weighed doesn't take the locus back at a later start", async () => {
655
+ membrane.pushResponse(createMockResponse([
656
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
657
+ ], 'tool_use'));
658
+ membrane.pushResponse(createMockResponse([
659
+ { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'east' } },
660
+ ], 'tool_use'));
661
+ membrane.pushResponse(createMockResponse([]));
662
+ const framework = await createFramework();
663
+ const routed = stubChannelRegistry(framework);
664
+ module.toolDelayMs = 25;
665
+ const internals = framework;
666
+ const origHandle = module.handleToolCall.bind(module);
667
+ module.handleToolCall = async (call) => {
668
+ if (call.id === 'c1') {
669
+ // Someone addresses the agent from a DM: the first boundary re-pins
670
+ // there. The message's wake waits behind the turn, as a real one
671
+ // would, and is released with the hold.
672
+ module.interjection = 'quick question for you';
673
+ module.interjectionMetadata = addressed('discord:dm:antra');
674
+ internals.pendingRequests.push({
675
+ agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: Date.now(),
676
+ channelId: 'discord:dm:antra', addressed: true,
677
+ });
678
+ }
679
+ else {
680
+ // The turn sent into #portables, and a follow-up there re-pins the
681
+ // second boundary (seeded: this module's tools aren't MCPL sends).
682
+ internals.turnRouting.get('assistant').engaged.add('discord:guild:portables');
683
+ module.interjection = 'replying on portables';
684
+ module.interjectionMetadata = ambient('discord:guild:portables');
685
+ }
686
+ return origHandle(call);
687
+ };
688
+ failStream(1, accelerationLimit);
689
+ feedStart(framework, 2, [{ type: 'text', text: 'Back now.' }]);
690
+ trigger(framework);
691
+ await framework.runUntilIdle();
692
+ await release(framework);
693
+ assert.equal(membrane.calls.length, 2, 'one deferred compile');
694
+ assert.deepEqual(routed, [
695
+ { text: 'Back now.', locus: 'discord:guild:portables' },
696
+ ], "the DM, weighed at the first boundary, doesn't move the locus back from #portables");
697
+ assert.deepEqual(movedTo(framework), ['discord:dm:antra', 'discord:guild:portables']);
698
+ await framework.stop();
699
+ });
700
+ it("an arrival a start has used doesn't move the locus again after a later re-pin", async () => {
701
+ membrane.pushResponse(createMockResponse([
702
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
703
+ ], 'tool_use'));
704
+ membrane.pushResponse(createMockResponse([]));
705
+ const framework = await createFramework();
706
+ const routed = stubChannelRegistry(framework);
707
+ module.toolDelayMs = 25;
708
+ const origHandle = module.handleToolCall.bind(module);
709
+ module.handleToolCall = async (call) => {
710
+ if (call.id === 'c2') {
711
+ // In the second stream, someone else addresses the agent: its boundary re-pins.
712
+ module.interjection = 'over here now';
713
+ module.interjectionMetadata = addressed('discord:dm:q');
714
+ }
715
+ return origHandle(call);
716
+ };
717
+ failStream([1, 2], accelerationLimit);
718
+ feedStart(framework, 2, [{ type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'east' } }], []);
719
+ feedStart(framework, 3, [{ type: 'text', text: 'Back now.' }]);
720
+ trigger(framework);
721
+ await framework.runUntilIdle();
722
+ await say(framework, 'quick question for you', addressed('discord:dm:antra'));
723
+ // The first release uses the DM; its stream re-pins to q, then is held again.
724
+ await release(framework);
725
+ assert.equal(membrane.calls.length, 2);
726
+ await release(framework);
727
+ assert.equal(membrane.calls.length, 3, 'two deferred compiles');
728
+ assert.deepEqual(movedTo(framework), ['discord:dm:antra', 'discord:dm:q']);
729
+ assert.deepEqual(routed, [
730
+ { text: 'Back now.', locus: 'discord:dm:q' },
731
+ ], 'the second release keeps the re-pin its stream made');
732
+ await framework.stop();
733
+ });
734
+ it('a message deferred in a start that fails before its stream lands then, so a newer one during the hold wins', async () => {
735
+ membrane.pushResponse(createMockResponse([
736
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
737
+ ], 'tool_use'));
738
+ membrane.pushResponse(createMockResponse([]));
739
+ const framework = await createFramework();
740
+ const routed = stubChannelRegistry(framework);
741
+ module.toolDelayMs = 25;
742
+ // The host holds the agent once when its stream can't be set up. (Asked
743
+ // again at the release, it lets the hold go.)
744
+ let hostHolds = 0;
745
+ framework
746
+ .providerHoldHook = (error) => error.message === 'stream setup failed' && hostHolds++ === 0
747
+ ? { holdMs: 60_000, reason: 'host hold' } : undefined;
748
+ // Stream one is held at its end; the start after the first release
749
+ // fails before its stream exists, and the host holds it; the third start
750
+ // answers.
751
+ const makeStream = membrane.streamYielding.bind(membrane);
752
+ let calls = 0;
753
+ membrane.streamYielding = (request, options) => {
754
+ const n = ++calls;
755
+ if (n === 2)
756
+ throw new Error('stream setup failed');
757
+ const stream = makeStream(request, options);
758
+ const iterate = stream[Symbol.asyncIterator].bind(stream);
759
+ stream[Symbol.asyncIterator] = async function* () {
760
+ for await (const event of { [Symbol.asyncIterator]: iterate }) {
761
+ if (event.type === 'complete' && n === 1) {
762
+ yield { type: 'error', error: accelerationLimit(request) };
763
+ return;
764
+ }
765
+ yield event;
766
+ }
767
+ };
768
+ return stream;
769
+ };
770
+ feedStart(framework, 2, [{ type: 'text', text: 'Back now.' }]);
771
+ trigger(framework);
772
+ await framework.runUntilIdle();
773
+ // During the second start, before its stream exists, a DM arrives and is
774
+ // deferred: the start holds the turn. (Written directly: the run loop
775
+ // that would store an event's message is busy with this start.)
776
+ const store = framework;
777
+ let deferredInStart = -1;
778
+ module.startHook = async () => {
779
+ store.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }], addressed('discord:dm:antra'));
780
+ deferredInStart = store.deferredMessages.length;
781
+ };
782
+ await release(framework);
783
+ assert.equal(deferredInStart, 1, 'the DM was deferred while the start held the turn');
784
+ assert.equal(calls, 2, 'the second start failed before its stream');
785
+ // During the second hold, someone else addresses the agent.
786
+ await say(framework, 'over here', addressed('discord:dm:q'));
787
+ await release(framework);
788
+ assert.equal(calls, 3, 'the third start compiled');
789
+ assert.deepEqual(routed, [
790
+ { text: 'Back now.', locus: 'discord:dm:q' },
791
+ ], 'the newer message wins: the deferred one landed when its start failed');
792
+ await framework.stop();
793
+ });
794
+ for (const [label, withdrawn, expected] of [
795
+ ['re-pins to an addressed message its over-budget boundary left unweighed', false, 'discord:dm:antra'],
796
+ ['moves nothing for such a message withdrawn before it starts', true, 'chan-live-1'],
797
+ ]) {
798
+ it(`a budget restart ${label}`, async () => {
799
+ const overBudget = createMockResponse([
800
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
801
+ ], 'tool_use');
802
+ overBudget.usage = { inputTokens: 100, outputTokens: 5 };
803
+ membrane.pushResponse(overBudget);
804
+ const framework = await createFramework({ maxStreamTokens: 20 });
805
+ const routed = stubChannelRegistry(framework);
806
+ module.toolDelayMs = 25;
807
+ module.interjection = 'quick question for you';
808
+ module.interjectionMetadata = addressed('discord:dm:antra');
809
+ feedStart(framework, 2, [{ type: 'text', text: 'After the restart.' }]);
810
+ if (withdrawn) {
811
+ // The boundary has stored the message and left it to the restart as
812
+ // an arrival, by the id it was stored under; it leaves the store
813
+ // before the restart starts.
814
+ framework.onTrace((event) => {
815
+ if (event.type === 'inference:stream_restarted')
816
+ withdraw(framework, 'quick question for you');
817
+ });
818
+ }
819
+ trigger(framework);
820
+ await framework.runUntilIdle();
821
+ assert.equal(membrane.calls.length, 2, 'the turn restarted for its budget');
822
+ assert.deepEqual(routed, [
823
+ { text: 'After the restart.', locus: expected },
824
+ ]);
825
+ assert.deepEqual(movedTo(framework), withdrawn ? [] : [expected]);
826
+ await framework.stop();
827
+ });
828
+ }
829
+ for (const [label, messages, expected] of [
830
+ ["keeps the turn's locus", [], 'chan-turn'],
831
+ ['moves it to the newest addressed arrival',
832
+ [addressed('discord:dm:antra'), addressed('discord:dm:q')], 'discord:dm:q'],
833
+ ['moves it to a follow-up in a channel the turn engaged',
834
+ [ambient('discord:guild:portables')], 'discord:guild:portables'],
835
+ ['leaves it for a reaction in a channel the turn engaged',
836
+ [{ channelId: 'discord:guild:portables', tags: ['chat:reaction'] }], 'chan-turn'],
837
+ ]) {
838
+ it(`a re-inference on unstreamed tool results ${label}`, async () => {
839
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }]));
840
+ const framework = await createFramework();
841
+ const routed = stubChannelRegistry(framework);
842
+ const internals = framework;
843
+ // The turn's earlier stream froze `chan-turn` as its locus, sent into
844
+ // #portables, and left its tool results for the non-streaming
845
+ // fallback's re-inference. Messages arrive before that starts.
846
+ const open = {
847
+ locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']),
848
+ destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [],
849
+ };
850
+ internals.turnRouting.set('assistant', open);
851
+ internals.openLogical.set('assistant', open);
852
+ for (const metadata of messages)
853
+ await say(framework, 'a message', metadata);
854
+ internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() });
855
+ await framework.runUntilIdle();
856
+ assert.deepEqual(routed, [{ text: 'Carrying on.', locus: expected }]);
857
+ assert.deepEqual(movedTo(framework), expected === 'chan-turn' ? [] : [expected], 'a re-pin leaves its notice');
858
+ await framework.stop();
859
+ });
860
+ }
861
+ it('the start that continues the turn weighs a message its own flush lands, and a delivery assembled for it', async () => {
862
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }]));
863
+ const framework = await createFramework();
864
+ const routed = stubChannelRegistry(framework);
865
+ const internals = framework;
866
+ const open = {
867
+ locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(),
868
+ destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [],
869
+ };
870
+ internals.turnRouting.set('assistant', open);
871
+ internals.openLogical.set('assistant', open);
872
+ try {
873
+ // A delivery assembled while a start of the turn holds it (the
874
+ // coalescing assembly writes past the deferral) is an arrival too.
875
+ internals.activeTurnTokens.set('assistant', -1);
876
+ internals.addMessage('Antra', [{ type: 'text', text: 'batched' }], addressed('discord:dm:q'), { bypassDeferralFor: 'assistant' });
877
+ internals.activeTurnTokens.delete('assistant');
878
+ assert.deepEqual(open.arrivals.map((a) => a.channelId), ['discord:dm:q']);
879
+ // A message still deferred when the turn's next start begins lands in
880
+ // that start's own flush, and is the newest arrival.
881
+ internals.deferredMessages.push({
882
+ id: 'deferred-1', seq: 1, participant: 'Antra', forAgent: 'assistant',
883
+ content: [{ type: 'text', text: 'quick question for you' }], metadata: addressed('discord:dm:antra'),
884
+ });
885
+ internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() });
886
+ await framework.runUntilIdle();
887
+ assert.deepEqual(routed, [{ text: 'Carrying on.', locus: 'discord:dm:antra' }]);
888
+ assert.deepEqual(movedTo(framework), ['discord:dm:antra']);
889
+ }
890
+ finally {
891
+ await framework.stop();
892
+ }
893
+ });
894
+ it('a start that throws before its stream ends its logical turn, so the next start opens its own', async () => {
895
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello there.' }]));
896
+ const framework = await createFramework();
897
+ const routed = stubChannelRegistry(framework);
898
+ // The registry is unavailable for the first start, which throws after it
899
+ // has opened its turn, outside the stream's own failure handling.
900
+ const registry = framework.channelRegistry;
901
+ const resolve = registry.resolveLocus;
902
+ let failed = false;
903
+ registry.resolveLocus = (agent) => {
904
+ if (!failed) {
905
+ failed = true;
906
+ throw new Error('registry unavailable');
907
+ }
908
+ return resolve(agent);
909
+ };
910
+ const internals = framework;
911
+ trigger(framework);
912
+ await assert.rejects(framework.runUntilIdle(), /registry unavailable/);
913
+ assert.equal(internals.openLogical.has('assistant'), false, 'the failed start left no turn open');
914
+ trigger(framework);
915
+ await framework.runUntilIdle();
916
+ assert.deepEqual(routed, [{ text: 'Hello there.', locus: 'chan-live-1' }], 'the next start resolved a locus of its own');
917
+ await framework.stop();
918
+ });
919
+ it("a silent turn's tool boundary doesn't re-pin its locus or tell it the conversation moved", async () => {
920
+ membrane.pushResponse(createMockResponse([
921
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
922
+ ], 'tool_use'));
923
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'quiet' }]));
924
+ const framework = await createFramework();
925
+ stubChannelRegistry(framework);
926
+ module.toolDelayMs = 25;
927
+ module.interjection = 'quick question for you';
928
+ module.interjectionMetadata = addressed('discord:dm:antra');
929
+ const internals = framework;
930
+ internals.pendingRequests.push({
931
+ agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true,
932
+ });
933
+ await framework.runUntilIdle();
934
+ assert.equal(module.interjection, null, 'the silent turn ran its tool round, and the DM arrived during it');
935
+ assert.deepEqual(movedTo(framework), [], 'no routing notice in a turn that routes no prose');
936
+ await framework.stop();
937
+ });
938
+ it("a start that continues the turn and throws before it takes the turn's routing still ends the turn", async () => {
939
+ membrane.pushResponse(createMockResponse([
940
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
941
+ ], 'tool_use'));
942
+ membrane.pushResponse(createMockResponse([]));
943
+ const framework = await createFramework();
944
+ const routed = stubChannelRegistry(framework);
945
+ module.toolDelayMs = 25;
946
+ failStream(1, accelerationLimit);
947
+ feedStart(framework, 2, [{ type: 'text', text: 'A new turn.' }]);
948
+ const internals = framework;
949
+ trigger(framework);
950
+ await framework.runUntilIdle();
951
+ assert.equal(internals.openLogical.has('assistant'), true, 'the held turn is open');
952
+ // The deferred compile's checkpoint fails before the start takes the turn.
953
+ const checkpoint = internals.recordTurnCheckpoint.bind(framework);
954
+ internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); };
955
+ await assert.rejects(release(framework), /checkpoint failed/);
956
+ internals.recordTurnCheckpoint = checkpoint;
957
+ assert.equal(internals.openLogical.has('assistant'), false, 'the held turn ended with its failed compile');
958
+ trigger(framework);
959
+ await framework.runUntilIdle();
960
+ assert.deepEqual(routed, [{ text: 'A new turn.', locus: 'chan-live-2' }], 'the next start opened its own turn and resolved its own locus');
961
+ await framework.stop();
962
+ });
963
+ it("an ephemeral run whose start throws outside its stream fails with that error, not by its watchdog", async () => {
964
+ // Adapted from a reviewer's probe. On main the run waited for its startup
965
+ // watchdog (30 s by default), which fails it as a zombie.
966
+ const framework = await AgentFramework.create({
967
+ storePath: join(tempDir, 'ephemeral.chronicle'),
968
+ membrane: membrane.asMembrane(),
969
+ agents: [],
970
+ modules: [],
971
+ syncIntervalMs: 0,
972
+ });
973
+ const internals = framework;
974
+ const checkpoint = internals.recordTurnCheckpoint.bind(framework);
975
+ internals.recordTurnCheckpoint = (agentName) => {
976
+ if (agentName === 'worker')
977
+ throw new Error('checkpoint store unavailable');
978
+ checkpoint(agentName);
979
+ };
980
+ const error = console.error;
981
+ console.error = () => { };
982
+ try {
983
+ const { agent, contextManager } = await framework.createEphemeralAgent({
984
+ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all',
985
+ });
986
+ contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]);
987
+ const run = framework.runEphemeralToCompletion(agent, contextManager, { startupTimeoutMs: 3_000 });
988
+ framework.start();
989
+ await assert.rejects(run, /checkpoint store unavailable/, "the run fails with its start's error");
990
+ }
991
+ finally {
992
+ console.error = error;
993
+ await framework.stop();
994
+ }
995
+ });
996
+ it("a silent start that throws leaves an open prose turn alone", async () => {
997
+ const framework = await createFramework();
998
+ stubChannelRegistry(framework);
999
+ const internals = framework;
1000
+ const open = {
1001
+ locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false,
1002
+ continuation: false, deliveries: [], suppressed: 0, arrivals: [],
1003
+ };
1004
+ internals.openLogical.set('assistant', open);
1005
+ internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); };
1006
+ internals.pendingRequests.push({
1007
+ agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true,
1008
+ });
1009
+ await assert.rejects(framework.runUntilIdle(), /checkpoint failed/);
1010
+ assert.equal(internals.openLogical.get('assistant'), open);
1011
+ await framework.stop();
1012
+ });
1013
+ it("a resume after stop() continues its turn, and opens it again for the turn's later starts", async () => {
1014
+ membrane.pushResponse(createMockResponse([]));
1015
+ const framework = await createFramework();
1016
+ const routed = stubChannelRegistry(framework);
1017
+ failStream(1, () => new Error('synthetic transient stream error'));
1018
+ feedStart(framework, 2, [{ type: 'text', text: 'After the retry.' }]);
1019
+ const internals = framework;
1020
+ // A turn that crossed its budget before a stop(): its routing state is
1021
+ // live, and stop() ended every open turn. Its budget restart now runs,
1022
+ // and its stream fails and is retried.
1023
+ const turn = {
1024
+ locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false,
1025
+ continuation: false, deliveries: [], suppressed: 0, arrivals: [],
1026
+ };
1027
+ internals.turnRouting.set('assistant', turn);
1028
+ internals.pendingRequests.push({
1029
+ agentName: 'assistant', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(),
1030
+ });
1031
+ // The retry of a resume is a resume too, and takes the turn's routing
1032
+ // state whatever is open. The turn must be open while its streams run, so
1033
+ // that its other later starts, such as a re-inference on unstreamed tool
1034
+ // results, continue it rather than open a new one.
1035
+ const openAtStart = [];
1036
+ framework.onTrace((event) => {
1037
+ if (event.type === 'inference:started')
1038
+ openAtStart.push(internals.openLogical.get('assistant'));
1039
+ });
1040
+ await framework.runUntilIdle();
1041
+ assert.equal(membrane.calls.length, 2, 'the restart was retried');
1042
+ assert.deepEqual(openAtStart.map((open) => open === turn), [true, true], 'the resumed turn was open again while each of its streams ran');
1043
+ assert.equal(internals.openLogical.get('assistant'), undefined, "the turn completed");
1044
+ assert.deepEqual(routed, [{ text: 'After the retry.', locus: 'chan-turn' }], "the retry continued the resumed turn's routing");
1045
+ await framework.stop();
1046
+ });
1047
+ it("a silent turn's over-budget boundary leaves no arrival on an open prose turn", async () => {
1048
+ const overBudget = createMockResponse([
1049
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
1050
+ ], 'tool_use');
1051
+ overBudget.usage = { inputTokens: 100, outputTokens: 5 };
1052
+ membrane.pushResponse(overBudget);
1053
+ const framework = await createFramework({ maxStreamTokens: 20 });
1054
+ const routed = stubChannelRegistry(framework);
1055
+ module.toolDelayMs = 25;
1056
+ module.interjection = 'quick question for you';
1057
+ module.interjectionMetadata = addressed('discord:dm:antra');
1058
+ feedStart(framework, 2, [{ type: 'text', text: 'quiet' }]);
1059
+ const internals = framework;
1060
+ // Built by hand, as #312's silent-start test does: production runs no
1061
+ // silent turn while a prose turn is open.
1062
+ const open = {
1063
+ locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false,
1064
+ continuation: false, deliveries: [], suppressed: 0, arrivals: [],
1065
+ };
1066
+ internals.openLogical.set('assistant', open);
1067
+ internals.pendingRequests.push({
1068
+ agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true,
1069
+ });
1070
+ await framework.runUntilIdle();
1071
+ assert.equal(membrane.calls.length, 2, 'the silent turn crossed its budget and restarted');
1072
+ assert.deepEqual(routed, [], 'and its restart stayed silent');
1073
+ assert.equal(module.interjection, null, 'the DM arrived during its tool round');
1074
+ assert.deepEqual(open.arrivals, [], "the silent turn's injections are no arrival of the prose turn");
1075
+ await framework.stop();
1076
+ });
1077
+ it("a failed start's flush stores each deferred message on its own, so one failure loses no other", async () => {
1078
+ membrane.pushResponse(createMockResponse([
1079
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
1080
+ ], 'tool_use'));
1081
+ membrane.pushResponse(createMockResponse([]));
1082
+ const framework = await createFramework();
1083
+ stubChannelRegistry(framework);
1084
+ module.toolDelayMs = 25;
1085
+ let hostHolds = 0;
1086
+ framework
1087
+ .providerHoldHook = (error) => error.message === 'stream setup failed' && hostHolds++ === 0
1088
+ ? { holdMs: 60_000, reason: 'host hold' } : undefined;
1089
+ const makeStream = membrane.streamYielding.bind(membrane);
1090
+ let calls = 0;
1091
+ membrane.streamYielding = (request, options) => {
1092
+ const n = ++calls;
1093
+ if (n === 2)
1094
+ throw new Error('stream setup failed');
1095
+ const stream = makeStream(request, options);
1096
+ const iterate = stream[Symbol.asyncIterator].bind(stream);
1097
+ stream[Symbol.asyncIterator] = async function* () {
1098
+ for await (const event of { [Symbol.asyncIterator]: iterate }) {
1099
+ if (event.type === 'complete' && n === 1) {
1100
+ yield { type: 'error', error: accelerationLimit(request) };
1101
+ return;
1102
+ }
1103
+ yield event;
1104
+ }
1105
+ };
1106
+ return stream;
1107
+ };
1108
+ const store = framework;
1109
+ const add = store.addMessage.bind(framework);
1110
+ // The store refuses the first message when the failed start's flush lands it.
1111
+ store.addMessage = (participant, content, metadata, opts) => {
1112
+ if (opts?.deferredWriteId && content[0].text === 'poison')
1113
+ throw new Error('store refused');
1114
+ return add(participant, content, metadata, opts);
1115
+ };
1116
+ trigger(framework);
1117
+ await framework.runUntilIdle();
1118
+ module.startHook = async () => {
1119
+ store.addMessage('Antra', [{ type: 'text', text: 'poison' }], {});
1120
+ store.addMessage('Antra', [{ type: 'text', text: 'second' }], {});
1121
+ };
1122
+ await release(framework);
1123
+ assert.equal(calls, 2, 'the second start failed before its stream');
1124
+ const texts = framework.getAgent('assistant').getContextManager().getAllMessages()
1125
+ .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => b.text));
1126
+ assert.ok(texts.includes('second'), 'the message behind the refused one was stored');
1127
+ assert.ok(!texts.includes('poison'));
1128
+ await framework.stop();
1129
+ });
1130
+ for (const [label, notice] of [
1131
+ ['a re-pin', 'The conversation moved to'],
1132
+ ["a turn's announcement", 'Your plain speech now lands in'],
1133
+ ]) {
1134
+ it(`the announce baseline moves only once ${label}'s notice is stored`, async () => {
1135
+ membrane.pushResponse(createMockResponse([
1136
+ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
1137
+ ], 'tool_use'));
1138
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }]));
1139
+ const framework = await createFramework();
1140
+ stubChannelRegistry(framework);
1141
+ module.toolDelayMs = 25;
1142
+ if (notice === 'The conversation moved to') {
1143
+ // Someone addresses the agent mid-turn from a DM: the turn re-pins.
1144
+ module.interjection = 'quick question for you';
1145
+ module.interjectionMetadata = addressed('discord:dm:antra');
1146
+ }
1147
+ const cm = framework.getAgent('assistant').getContextManager();
1148
+ const add = cm.addMessage.bind(cm);
1149
+ cm.addMessage = (participant, content, metadata) => {
1150
+ if (content[0]?.text?.includes(notice))
1151
+ throw new Error('store refused');
1152
+ return add(participant, content, metadata);
1153
+ };
1154
+ const baseline = framework.lastAnnouncedLocus;
1155
+ trigger(framework);
1156
+ await framework.runUntilIdle();
1157
+ if (notice === 'The conversation moved to') {
1158
+ assert.equal(baseline.get('assistant'), 'chan-live-1', 'the agent was last told of the locus the turn opened at');
1159
+ }
1160
+ else {
1161
+ assert.equal(baseline.has('assistant'), false, 'the agent was never told a locus');
1162
+ }
1163
+ await framework.stop();
1164
+ });
1165
+ }
1166
+ // A tool's late write lands in the turn that called it: a send's engaged
1167
+ // channel, and a channel_open's move of the locus. (The dispatch functions
1168
+ // run on a bare framework, as checkpoint-attribution.test.ts's harness does.)
1169
+ function bareFramework() {
1170
+ const framework = Object.create(AgentFramework.prototype);
1171
+ framework.emitTrace = () => { };
1172
+ framework.pushEvent = () => { };
1173
+ framework.conversationAgentHomes = new Map();
1174
+ framework.lastAnnouncedLocus = new Map();
1175
+ framework.activeTurnTriggers = new Map();
1176
+ framework.openLogical = new Map();
1177
+ return framework;
1178
+ }
1179
+ const until = async (done, label) => {
1180
+ for (const end = Date.now() + 2000; !done();) {
1181
+ if (Date.now() > end)
1182
+ throw new Error(`timed out waiting for ${label}`);
1183
+ await new Promise((resolve) => setTimeout(resolve, 5));
1184
+ }
1185
+ };
1186
+ // stop() ends every open turn, but a fresh start that awaits a deferred
1187
+ // batch's assembly while the stop runs reaches its routing after it, opens
1188
+ // its turn, and is then dropped before its stream sends anything: before
1189
+ // its compile, once stop() has closed admission, or after it, once a
1190
+ // restart has begun a lifecycle of its own.
1191
+ for (const restartFirst of [false, true]) {
1192
+ const name = restartFirst
1193
+ ? "a start whose compile spans a stop() and restart is dropped and leaves no turn open, so the next start opens its own"
1194
+ : "a start that stop() drops before its compile leaves no turn open, so the first start after a restart opens its own";
1195
+ it(name, async () => {
1196
+ // A store the framework doesn't own, so the same instance can start again.
1197
+ const store = JsStore.openOrCreate({ path: join(tempDir, 'restart.chronicle') });
1198
+ const framework = await AgentFramework.create({
1199
+ store,
1200
+ membrane: membrane.asMembrane(),
1201
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.' }],
1202
+ modules: [module],
1203
+ syncIntervalMs: 0,
1204
+ maintenanceIntervalMs: 0,
1205
+ });
1206
+ module.framework = framework;
1207
+ const routed = stubChannelRegistry(framework);
1208
+ const internals = framework;
1209
+ const agent = framework.getAgent('assistant');
1210
+ const wake = () => ({
1211
+ agentName: 'assistant', reason: 'external-message', source: 'test', timestamp: Date.now(),
1212
+ });
1213
+ let entered;
1214
+ const assembling = new Promise((resolve) => { entered = resolve; });
1215
+ let release;
1216
+ const released = new Promise((resolve) => { release = resolve; });
1217
+ internals.pushCoalescer = {
1218
+ pendingBatches: () => true,
1219
+ assemble: async () => {
1220
+ entered();
1221
+ await released;
1222
+ return { settled: new Set(), materialized: new Set() };
1223
+ },
1224
+ suspend: () => { },
1225
+ isRendering: () => false,
1226
+ };
1227
+ const lines = [];
1228
+ const error = console.error;
1229
+ try {
1230
+ console.error = (...args) => { lines.push(args.map(String).join(' ')); };
1231
+ try {
1232
+ const dropped = internals.startAgentStream(agent, wake());
1233
+ await assembling;
1234
+ await framework.stop();
1235
+ if (restartFirst)
1236
+ framework.start();
1237
+ release();
1238
+ await dropped;
1239
+ }
1240
+ finally {
1241
+ console.error = error;
1242
+ }
1243
+ if (restartFirst) {
1244
+ assert.ok(lines.some((line) => /stopped and started again/.test(line) && /dropped before it sends anything/.test(line)), lines.join('\n'));
1245
+ }
1246
+ else {
1247
+ assert.equal(membrane.calls.length, 0, 'the start was dropped before its compile');
1248
+ }
1249
+ assert.deepEqual(routed, [], 'the dropped start routed nothing');
1250
+ assert.equal(internals.openLogical.has('assistant'), false, 'the dropped start left no turn open');
1251
+ if (!restartFirst)
1252
+ framework.start();
1253
+ internals.pushCoalescer = undefined;
1254
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'After the restart.' }]));
1255
+ await internals.startAgentStream(agent, wake());
1256
+ await until(() => routed.length > 0, "the next turn's speech");
1257
+ // The dropped start resolved chan-live-1. Continuing its turn would
1258
+ // have kept that locus, without resolving one.
1259
+ assert.deepEqual(routed, [{ text: 'After the restart.', locus: 'chan-live-2' }], 'the next start resolved a locus of its own');
1260
+ }
1261
+ finally {
1262
+ release();
1263
+ await framework.stop();
1264
+ store.close();
1265
+ }
1266
+ });
1267
+ }
1268
+ it("a send's engaged channel is recorded on the turn that sent, however late its open resolves", async () => {
1269
+ const sending = { engaged: new Set() };
1270
+ const next = { engaged: new Set() };
1271
+ let resolveOpen;
1272
+ const framework = bareFramework();
1273
+ framework.mcplServerRegistry = {
1274
+ getServer: (id) => id === 'chat' ? {
1275
+ id: 'chat',
1276
+ sendToolsCall: async () => ({ content: [{ type: 'text', text: 'sent' }] }),
1277
+ } : null,
1278
+ };
1279
+ framework.mcplServerConfigs = new Map([['chat', { id: 'chat', toolPrefix: 'chat' }]]);
1280
+ framework.mcplToolFeatureSets = new Map();
1281
+ framework.checkpointManager = null;
1282
+ framework.channelRegistry = {
1283
+ openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }),
1284
+ };
1285
+ framework.turnRouting = new Map([['assistant', sending]]);
1286
+ const results = [];
1287
+ framework.pushEvent = (event) => { if (event.type === 'tool-result')
1288
+ results.push(event); };
1289
+ framework.dispatchMcplToolCall('assistant', {
1290
+ id: 'send-1', name: 'chat--send_message', input: { channelId: 'discord:guild:portables', text: 'hi' },
1291
+ }, 'chat', 'chat');
1292
+ await until(() => results.length > 0 && resolveOpen !== undefined, 'the send to finish');
1293
+ // The turn that sent has ended, and the next turn is the agent's live routing.
1294
+ framework.turnRouting.set('assistant', next);
1295
+ resolveOpen({ status: 'already-open', channelId: 'discord:guild:portables' });
1296
+ await until(() => sending.engaged.size > 0, 'the open to resolve');
1297
+ assert.deepEqual([...sending.engaged], ['discord:guild:portables']);
1298
+ assert.deepEqual([...next.engaged], [], "the next turn didn't send there");
1299
+ });
1300
+ // A channel_open's result can land after a later start has made another
1301
+ // object the agent's live routing. While the turn that called is open, the
1302
+ // result moves that turn's locus and tells the agent. Once the turn has
1303
+ // ended, the tool-result handler drops the result, so the agent never reads
1304
+ // it, and nothing moves, whether or not a later start has come yet.
1305
+ for (const [label, after, moves] of [
1306
+ ['moves the locus of its turn, still open, when a silent start\'s routing is the live one', 'silent start', true],
1307
+ ['moves no locus once its turn has ended and the next turn has opened', 'next turn', false],
1308
+ ['moves no locus once its turn has settled, before any later start', 'settled', false],
1309
+ ]) {
1310
+ it(`a channel_open whose result lands late ${label}`, async () => {
1311
+ const calling = { engaged: new Set(), locus: 'chan-before' };
1312
+ const other = { engaged: new Set(), locus: 'chan-next' };
1313
+ let resolveOpen;
1314
+ const framework = bareFramework();
1315
+ framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]);
1316
+ framework.channelRegistry = {
1317
+ handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }),
1318
+ };
1319
+ framework.turnRouting = new Map([['assistant', calling]]);
1320
+ framework.openLogical = new Map([['assistant', calling]]);
1321
+ const results = [];
1322
+ framework.pushEvent = (event) => {
1323
+ if (event.type === 'tool-result')
1324
+ results.push(event);
1325
+ };
1326
+ framework.dispatchChannelToolCall('assistant', {
1327
+ id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' },
1328
+ });
1329
+ await until(() => resolveOpen !== undefined, 'the open to start');
1330
+ const turnRouting = framework.turnRouting;
1331
+ const openLogical = framework.openLogical;
1332
+ if (after === 'silent start')
1333
+ turnRouting.set('assistant', other);
1334
+ if (after === 'next turn') {
1335
+ turnRouting.set('assistant', other);
1336
+ openLogical.set('assistant', other);
1337
+ }
1338
+ if (after === 'settled')
1339
+ openLogical.delete('assistant');
1340
+ resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } });
1341
+ await until(() => results.length > 0, 'the tool result');
1342
+ assert.equal(calling.triggerChannel, 'discord:guild:observatory', 'the turn that called records the channel it opened');
1343
+ assert.equal(calling.locus, moves ? 'discord:guild:observatory' : 'chan-before');
1344
+ assert.deepEqual(other, { engaged: new Set(), locus: 'chan-next' }, 'the other routing state is untouched');
1345
+ assert.equal(framework.lastAnnouncedLocus.get('assistant'), moves ? 'discord:guild:observatory' : undefined, "the announce baseline moves with the locus, for a move the agent is told");
1346
+ assert.equal(results[0].result.data?.routing !== undefined, moves, 'and the result says so only then');
1347
+ });
1348
+ }
1349
+ for (const [label, silent, expected] of [
1350
+ ['while its turn runs moves the locus and the announce baseline, and says so', false, 'discord:guild:observatory'],
1351
+ ['in a silent turn moves no locus, and claims none', true, 'chan-before'],
1352
+ ]) {
1353
+ it(`a channel_open ${label}`, async () => {
1354
+ const calling = { engaged: new Set(), locus: 'chan-before' };
1355
+ const framework = bareFramework();
1356
+ framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]);
1357
+ framework.channelRegistry = {
1358
+ handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory' } }),
1359
+ };
1360
+ framework.turnRouting = new Map([['assistant', calling]]);
1361
+ if (!silent)
1362
+ framework.openLogical = new Map([['assistant', calling]]);
1363
+ framework.activeTurnTriggers.set('assistant', silent ? { suppressProse: true } : {});
1364
+ const results = [];
1365
+ framework.pushEvent = (event) => {
1366
+ if (event.type === 'tool-result')
1367
+ results.push(event);
1368
+ };
1369
+ framework.dispatchChannelToolCall('assistant', {
1370
+ id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' },
1371
+ });
1372
+ await until(() => results.length > 0, 'the tool result');
1373
+ assert.equal(calling.locus, expected);
1374
+ assert.equal(framework.lastAnnouncedLocus.get('assistant'), silent ? undefined : 'discord:guild:observatory');
1375
+ assert.equal(results[0].result.data?.routing !== undefined, !silent, 'the result tells the agent only of a move it made');
1376
+ });
1377
+ }
435
1378
  it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => {
436
1379
  // Explicit sends receipt themselves via tool_result; auto-routed prose
437
1380
  // previously vanished with no in-window trace — the agent could never