@animalabs/agent-framework 0.22.2 → 0.23.0

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 (997) hide show
  1. package/README.md +108 -4
  2. package/dist/src/agent.d.ts +108 -17
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +207 -67
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +1 -1
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +5 -1
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/api/types.d.ts +1 -1
  12. package/dist/src/api/types.d.ts.map +1 -1
  13. package/dist/src/branch-names.d.ts +14 -0
  14. package/dist/src/branch-names.d.ts.map +1 -0
  15. package/dist/src/branch-names.js +22 -0
  16. package/dist/src/branch-names.js.map +1 -0
  17. package/dist/src/closest-names.d.ts +28 -0
  18. package/dist/src/closest-names.d.ts.map +1 -0
  19. package/dist/src/closest-names.js +98 -0
  20. package/dist/src/closest-names.js.map +1 -0
  21. package/dist/src/code-execution/index.d.ts +1 -1
  22. package/dist/src/code-execution/index.d.ts.map +1 -1
  23. package/dist/src/code-execution/index.js.map +1 -1
  24. package/dist/src/code-execution/py-runner.d.ts +50 -7
  25. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/py-runner.js +258 -90
  27. package/dist/src/code-execution/py-runner.js.map +1 -1
  28. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  29. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  30. package/dist/src/code-execution/runtime-py.js +94 -8
  31. package/dist/src/code-execution/runtime-py.js.map +1 -1
  32. package/dist/src/code-execution/tool-definition.d.ts +17 -1
  33. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  34. package/dist/src/code-execution/tool-definition.js +72 -6
  35. package/dist/src/code-execution/tool-definition.js.map +1 -1
  36. package/dist/src/config-checks.d.ts +28 -0
  37. package/dist/src/config-checks.d.ts.map +1 -0
  38. package/dist/src/config-checks.js +56 -0
  39. package/dist/src/config-checks.js.map +1 -0
  40. package/dist/src/deadline-timer.d.ts +21 -0
  41. package/dist/src/deadline-timer.d.ts.map +1 -0
  42. package/dist/src/deadline-timer.js +43 -0
  43. package/dist/src/deadline-timer.js.map +1 -0
  44. package/dist/src/error-message.d.ts +30 -0
  45. package/dist/src/error-message.d.ts.map +1 -0
  46. package/dist/src/error-message.js +50 -0
  47. package/dist/src/error-message.js.map +1 -0
  48. package/dist/src/framework.d.ts +1529 -165
  49. package/dist/src/framework.d.ts.map +1 -1
  50. package/dist/src/framework.js +8264 -1939
  51. package/dist/src/framework.js.map +1 -1
  52. package/dist/src/gate/event-gate.d.ts +8 -0
  53. package/dist/src/gate/event-gate.d.ts.map +1 -1
  54. package/dist/src/gate/event-gate.js +36 -5
  55. package/dist/src/gate/event-gate.js.map +1 -1
  56. package/dist/src/held-writes.d.ts +16 -0
  57. package/dist/src/held-writes.d.ts.map +1 -0
  58. package/dist/src/held-writes.js +56 -0
  59. package/dist/src/held-writes.js.map +1 -0
  60. package/dist/src/index.d.ts +10 -4
  61. package/dist/src/index.d.ts.map +1 -1
  62. package/dist/src/index.js +11 -5
  63. package/dist/src/index.js.map +1 -1
  64. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  65. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  66. package/dist/src/mcpl/capability-grant.js +1 -1
  67. package/dist/src/mcpl/capability-grant.js.map +1 -1
  68. package/dist/src/mcpl/channel-registry.d.ts +233 -18
  69. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/channel-registry.js +975 -386
  71. package/dist/src/mcpl/channel-registry.js.map +1 -1
  72. package/dist/src/mcpl/checkpoint-manager.d.ts +21 -1
  73. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  74. package/dist/src/mcpl/checkpoint-manager.js +91 -30
  75. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  76. package/dist/src/mcpl/conversation-router.d.ts +19 -3
  77. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/conversation-router.js +23 -8
  79. package/dist/src/mcpl/conversation-router.js.map +1 -1
  80. package/dist/src/mcpl/convert-block.d.ts +20 -0
  81. package/dist/src/mcpl/convert-block.d.ts.map +1 -0
  82. package/dist/src/mcpl/convert-block.js +88 -0
  83. package/dist/src/mcpl/convert-block.js.map +1 -0
  84. package/dist/src/mcpl/errors.d.ts +13 -0
  85. package/dist/src/mcpl/errors.d.ts.map +1 -1
  86. package/dist/src/mcpl/errors.js +28 -0
  87. package/dist/src/mcpl/errors.js.map +1 -1
  88. package/dist/src/mcpl/hook-orchestrator.d.ts +23 -0
  89. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.js +83 -72
  91. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  92. package/dist/src/mcpl/inbound-source.d.ts +114 -0
  93. package/dist/src/mcpl/inbound-source.d.ts.map +1 -0
  94. package/dist/src/mcpl/inbound-source.js +83 -0
  95. package/dist/src/mcpl/inbound-source.js.map +1 -0
  96. package/dist/src/mcpl/index.d.ts +7 -1
  97. package/dist/src/mcpl/index.d.ts.map +1 -1
  98. package/dist/src/mcpl/index.js +5 -1
  99. package/dist/src/mcpl/index.js.map +1 -1
  100. package/dist/src/mcpl/inference-router.d.ts +19 -13
  101. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  102. package/dist/src/mcpl/inference-router.js +155 -87
  103. package/dist/src/mcpl/inference-router.js.map +1 -1
  104. package/dist/src/mcpl/log-value.d.ts +7 -0
  105. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  106. package/dist/src/mcpl/log-value.js +9 -0
  107. package/dist/src/mcpl/log-value.js.map +1 -1
  108. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  109. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  110. package/dist/src/mcpl/modern-connection.js +1088 -0
  111. package/dist/src/mcpl/modern-connection.js.map +1 -0
  112. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  113. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  114. package/dist/src/mcpl/prefix-rule.js +25 -0
  115. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  116. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  117. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  118. package/dist/src/mcpl/protocol-family.js +697 -0
  119. package/dist/src/mcpl/protocol-family.js.map +1 -0
  120. package/dist/src/mcpl/push-coalescer.d.ts +58 -17
  121. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  122. package/dist/src/mcpl/push-coalescer.js +93 -32
  123. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  124. package/dist/src/mcpl/push-handler.d.ts +16 -9
  125. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  126. package/dist/src/mcpl/push-handler.js +18 -83
  127. package/dist/src/mcpl/push-handler.js.map +1 -1
  128. package/dist/src/mcpl/reference-fetcher.d.ts +76 -13
  129. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  130. package/dist/src/mcpl/reference-fetcher.js +275 -66
  131. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  132. package/dist/src/mcpl/references.d.ts +98 -18
  133. package/dist/src/mcpl/references.d.ts.map +1 -1
  134. package/dist/src/mcpl/references.js +170 -32
  135. package/dist/src/mcpl/references.js.map +1 -1
  136. package/dist/src/mcpl/server-connection.d.ts +406 -31
  137. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  138. package/dist/src/mcpl/server-connection.js +1331 -272
  139. package/dist/src/mcpl/server-connection.js.map +1 -1
  140. package/dist/src/mcpl/server-registry.d.ts +35 -6
  141. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  142. package/dist/src/mcpl/server-registry.js +63 -13
  143. package/dist/src/mcpl/server-registry.js.map +1 -1
  144. package/dist/src/mcpl/skip-reply-wake.d.ts +36 -0
  145. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -0
  146. package/dist/src/mcpl/skip-reply-wake.js +55 -0
  147. package/dist/src/mcpl/skip-reply-wake.js.map +1 -0
  148. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  149. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  150. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  151. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  152. package/dist/src/mcpl/transport.d.ts +104 -12
  153. package/dist/src/mcpl/transport.d.ts.map +1 -1
  154. package/dist/src/mcpl/transport.js +315 -85
  155. package/dist/src/mcpl/transport.js.map +1 -1
  156. package/dist/src/mcpl/types.d.ts +76 -19
  157. package/dist/src/mcpl/types.d.ts.map +1 -1
  158. package/dist/src/mcpl/types.js.map +1 -1
  159. package/dist/src/mcpl/visible-content.d.ts +16 -0
  160. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  161. package/dist/src/mcpl/visible-content.js +26 -0
  162. package/dist/src/mcpl/visible-content.js.map +1 -1
  163. package/dist/src/module-registry.d.ts +34 -6
  164. package/dist/src/module-registry.d.ts.map +1 -1
  165. package/dist/src/module-registry.js +119 -31
  166. package/dist/src/module-registry.js.map +1 -1
  167. package/dist/src/modules/api/index.d.ts +1 -1
  168. package/dist/src/modules/api/index.d.ts.map +1 -1
  169. package/dist/src/modules/api/index.js +3 -2
  170. package/dist/src/modules/api/index.js.map +1 -1
  171. package/dist/src/modules/discord/discord-js-client.d.ts +43 -2
  172. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  173. package/dist/src/modules/discord/discord-js-client.js +286 -67
  174. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  175. package/dist/src/modules/discord/index.d.ts +53 -5
  176. package/dist/src/modules/discord/index.d.ts.map +1 -1
  177. package/dist/src/modules/discord/index.js +712 -186
  178. package/dist/src/modules/discord/index.js.map +1 -1
  179. package/dist/src/modules/discord/refusal.d.ts +14 -0
  180. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  181. package/dist/src/modules/discord/refusal.js +14 -0
  182. package/dist/src/modules/discord/refusal.js.map +1 -0
  183. package/dist/src/modules/discord/types.d.ts +27 -4
  184. package/dist/src/modules/discord/types.d.ts.map +1 -1
  185. package/dist/src/modules/health/index.d.ts.map +1 -1
  186. package/dist/src/modules/health/index.js +8 -3
  187. package/dist/src/modules/health/index.js.map +1 -1
  188. package/dist/src/modules/history/index.d.ts +3 -1
  189. package/dist/src/modules/history/index.d.ts.map +1 -1
  190. package/dist/src/modules/history/index.js +359 -127
  191. package/dist/src/modules/history/index.js.map +1 -1
  192. package/dist/src/modules/history/semantic.d.ts +28 -1
  193. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  194. package/dist/src/modules/history/semantic.js +177 -32
  195. package/dist/src/modules/history/semantic.js.map +1 -1
  196. package/dist/src/modules/index.d.ts +1 -1
  197. package/dist/src/modules/index.d.ts.map +1 -1
  198. package/dist/src/modules/index.js +1 -1
  199. package/dist/src/modules/index.js.map +1 -1
  200. package/dist/src/modules/unknown-tool.d.ts +10 -0
  201. package/dist/src/modules/unknown-tool.d.ts.map +1 -0
  202. package/dist/src/modules/unknown-tool.js +17 -0
  203. package/dist/src/modules/unknown-tool.js.map +1 -0
  204. package/dist/src/modules/workspace/disk-agreement.d.ts +57 -4
  205. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/disk-agreement.js +75 -7
  207. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  208. package/dist/src/modules/workspace/effects.d.ts +66 -7
  209. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/effects.js +303 -46
  211. package/dist/src/modules/workspace/effects.js.map +1 -1
  212. package/dist/src/modules/workspace/index.d.ts +313 -45
  213. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/index.js +1404 -304
  215. package/dist/src/modules/workspace/index.js.map +1 -1
  216. package/dist/src/modules/workspace/observe.d.ts +315 -16
  217. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  218. package/dist/src/modules/workspace/observe.js +727 -37
  219. package/dist/src/modules/workspace/observe.js.map +1 -1
  220. package/dist/src/modules/workspace/reconcile.d.ts +58 -7
  221. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  222. package/dist/src/modules/workspace/reconcile.js +521 -56
  223. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  224. package/dist/src/modules/workspace/types.d.ts +28 -5
  225. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  226. package/dist/src/modules/workspace/types.js.map +1 -1
  227. package/dist/src/modules/workspace/watcher.d.ts +49 -2
  228. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  229. package/dist/src/modules/workspace/watcher.js +158 -29
  230. package/dist/src/modules/workspace/watcher.js.map +1 -1
  231. package/dist/src/prose-drafts.d.ts +240 -0
  232. package/dist/src/prose-drafts.d.ts.map +1 -0
  233. package/dist/src/prose-drafts.js +362 -0
  234. package/dist/src/prose-drafts.js.map +1 -0
  235. package/dist/src/prose-segments.d.ts +21 -2
  236. package/dist/src/prose-segments.d.ts.map +1 -1
  237. package/dist/src/prose-segments.js +46 -9
  238. package/dist/src/prose-segments.js.map +1 -1
  239. package/dist/src/recovery/offline-branch.d.ts +1 -0
  240. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  241. package/dist/src/recovery/offline-branch.js +3 -1
  242. package/dist/src/recovery/offline-branch.js.map +1 -1
  243. package/dist/src/safe-slice.d.ts +5 -1
  244. package/dist/src/safe-slice.d.ts.map +1 -1
  245. package/dist/src/safe-slice.js +9 -1
  246. package/dist/src/safe-slice.js.map +1 -1
  247. package/dist/src/silent-heartbeat.d.ts +112 -39
  248. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  249. package/dist/src/silent-heartbeat.js +100 -29
  250. package/dist/src/silent-heartbeat.js.map +1 -1
  251. package/dist/src/tool-args.d.ts +40 -0
  252. package/dist/src/tool-args.d.ts.map +1 -0
  253. package/dist/src/tool-args.js +207 -0
  254. package/dist/src/tool-args.js.map +1 -0
  255. package/dist/src/tool-image-ledger.d.ts +87 -19
  256. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  257. package/dist/src/tool-image-ledger.js +135 -32
  258. package/dist/src/tool-image-ledger.js.map +1 -1
  259. package/dist/src/tool-presentation.d.ts +9 -0
  260. package/dist/src/tool-presentation.d.ts.map +1 -1
  261. package/dist/src/tool-presentation.js +266 -62
  262. package/dist/src/tool-presentation.js.map +1 -1
  263. package/dist/src/tool-result-guard.d.ts.map +1 -1
  264. package/dist/src/tool-result-guard.js +7 -10
  265. package/dist/src/tool-result-guard.js.map +1 -1
  266. package/dist/src/tool-result-history.d.ts +32 -3
  267. package/dist/src/tool-result-history.d.ts.map +1 -1
  268. package/dist/src/tool-result-history.js +106 -19
  269. package/dist/src/tool-result-history.js.map +1 -1
  270. package/dist/src/tune-out/coordinator.d.ts +25 -1
  271. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  272. package/dist/src/tune-out/coordinator.js +260 -65
  273. package/dist/src/tune-out/coordinator.js.map +1 -1
  274. package/dist/src/types/agent.d.ts +44 -22
  275. package/dist/src/types/agent.d.ts.map +1 -1
  276. package/dist/src/types/events.d.ts +25 -0
  277. package/dist/src/types/events.d.ts.map +1 -1
  278. package/dist/src/types/framework.d.ts +38 -7
  279. package/dist/src/types/framework.d.ts.map +1 -1
  280. package/dist/src/types/inference-log.d.ts +1 -1
  281. package/dist/src/types/inference-log.d.ts.map +1 -1
  282. package/dist/src/types/module.d.ts +77 -14
  283. package/dist/src/types/module.d.ts.map +1 -1
  284. package/dist/src/types/trace.d.ts +97 -8
  285. package/dist/src/types/trace.d.ts.map +1 -1
  286. package/dist/src/usage/usage-tracker.d.ts +5 -0
  287. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  288. package/dist/src/usage/usage-tracker.js +35 -8
  289. package/dist/src/usage/usage-tracker.js.map +1 -1
  290. package/package.json +5 -4
  291. package/src/agent.ts +257 -72
  292. package/src/api/mcp-server-core.ts +1 -1
  293. package/src/api/server.ts +4 -1
  294. package/src/api/types.ts +1 -1
  295. package/src/branch-names.ts +19 -0
  296. package/src/closest-names.ts +94 -0
  297. package/src/code-execution/index.ts +1 -0
  298. package/src/code-execution/py-runner.ts +304 -93
  299. package/src/code-execution/runtime-py.ts +94 -8
  300. package/src/code-execution/tool-definition.ts +85 -7
  301. package/src/config-checks.ts +50 -0
  302. package/src/deadline-timer.ts +44 -0
  303. package/src/error-message.ts +48 -0
  304. package/src/framework.ts +8625 -1984
  305. package/src/gate/event-gate.ts +38 -5
  306. package/src/held-writes.ts +59 -0
  307. package/src/index.ts +29 -5
  308. package/src/mcpl/capability-grant.ts +1 -1
  309. package/src/mcpl/channel-registry.ts +1095 -411
  310. package/src/mcpl/checkpoint-manager.ts +104 -29
  311. package/src/mcpl/conversation-router.ts +28 -8
  312. package/src/mcpl/convert-block.ts +105 -0
  313. package/src/mcpl/errors.ts +28 -0
  314. package/src/mcpl/hook-orchestrator.ts +84 -77
  315. package/src/mcpl/inbound-source.ts +156 -0
  316. package/src/mcpl/index.ts +23 -1
  317. package/src/mcpl/inference-router.ts +179 -103
  318. package/src/mcpl/log-value.ts +10 -0
  319. package/src/mcpl/modern-connection.ts +1182 -0
  320. package/src/mcpl/prefix-rule.ts +32 -0
  321. package/src/mcpl/protocol-family.ts +691 -0
  322. package/src/mcpl/push-coalescer.ts +110 -36
  323. package/src/mcpl/push-handler.ts +20 -90
  324. package/src/mcpl/reference-fetcher.ts +318 -67
  325. package/src/mcpl/references.ts +183 -34
  326. package/src/mcpl/server-connection.ts +1404 -280
  327. package/src/mcpl/server-registry.ts +63 -14
  328. package/src/mcpl/skip-reply-wake.ts +73 -0
  329. package/src/mcpl/tool-result-normalize.ts +294 -0
  330. package/src/mcpl/transport.ts +320 -76
  331. package/src/mcpl/types.ts +77 -19
  332. package/src/mcpl/visible-content.ts +28 -0
  333. package/src/module-registry.ts +126 -36
  334. package/src/modules/api/index.ts +3 -2
  335. package/src/modules/discord/discord-js-client.ts +306 -74
  336. package/src/modules/discord/index.ts +737 -201
  337. package/src/modules/discord/refusal.ts +13 -0
  338. package/src/modules/discord/types.ts +27 -6
  339. package/src/modules/health/index.ts +8 -3
  340. package/src/modules/history/index.ts +410 -120
  341. package/src/modules/history/semantic.ts +189 -32
  342. package/src/modules/index.ts +1 -1
  343. package/src/modules/unknown-tool.ts +16 -0
  344. package/src/modules/workspace/disk-agreement.ts +97 -8
  345. package/src/modules/workspace/effects.ts +291 -36
  346. package/src/modules/workspace/index.ts +1469 -321
  347. package/src/modules/workspace/observe.ts +784 -41
  348. package/src/modules/workspace/reconcile.ts +619 -64
  349. package/src/modules/workspace/types.ts +28 -5
  350. package/src/modules/workspace/watcher.ts +154 -30
  351. package/src/prose-drafts.ts +481 -0
  352. package/src/prose-segments.ts +51 -8
  353. package/src/recovery/offline-branch.ts +4 -1
  354. package/src/safe-slice.ts +9 -1
  355. package/src/silent-heartbeat.ts +184 -59
  356. package/src/tool-args.ts +213 -0
  357. package/src/tool-image-ledger.ts +153 -34
  358. package/src/tool-presentation.ts +233 -42
  359. package/src/tool-result-guard.ts +7 -8
  360. package/src/tool-result-history.ts +124 -20
  361. package/src/tune-out/coordinator.ts +259 -65
  362. package/src/types/agent.ts +44 -24
  363. package/src/types/events.ts +25 -0
  364. package/src/types/framework.ts +38 -7
  365. package/src/types/inference-log.ts +1 -1
  366. package/src/types/module.ts +79 -14
  367. package/src/types/trace.ts +103 -8
  368. package/src/usage/usage-tracker.ts +32 -8
  369. package/dist/test/activation-request-sanitize.test.d.ts +0 -2
  370. package/dist/test/activation-request-sanitize.test.d.ts.map +0 -1
  371. package/dist/test/activation-request-sanitize.test.js +0 -89
  372. package/dist/test/activation-request-sanitize.test.js.map +0 -1
  373. package/dist/test/agent-cache-ttl.test.d.ts +0 -2
  374. package/dist/test/agent-cache-ttl.test.d.ts.map +0 -1
  375. package/dist/test/agent-cache-ttl.test.js +0 -41
  376. package/dist/test/agent-cache-ttl.test.js.map +0 -1
  377. package/dist/test/agent-runtime-settings.test.d.ts +0 -2
  378. package/dist/test/agent-runtime-settings.test.d.ts.map +0 -1
  379. package/dist/test/agent-runtime-settings.test.js +0 -1598
  380. package/dist/test/agent-runtime-settings.test.js.map +0 -1
  381. package/dist/test/api-admin-gate.test.d.ts +0 -2
  382. package/dist/test/api-admin-gate.test.d.ts.map +0 -1
  383. package/dist/test/api-admin-gate.test.js +0 -116
  384. package/dist/test/api-admin-gate.test.js.map +0 -1
  385. package/dist/test/assistant-persist-refusal.test.d.ts +0 -2
  386. package/dist/test/assistant-persist-refusal.test.d.ts.map +0 -1
  387. package/dist/test/assistant-persist-refusal.test.js +0 -63
  388. package/dist/test/assistant-persist-refusal.test.js.map +0 -1
  389. package/dist/test/batched-wake-locus.test.d.ts +0 -2
  390. package/dist/test/batched-wake-locus.test.d.ts.map +0 -1
  391. package/dist/test/batched-wake-locus.test.js +0 -166
  392. package/dist/test/batched-wake-locus.test.js.map +0 -1
  393. package/dist/test/busy-agent-scheduler.test.d.ts +0 -2
  394. package/dist/test/busy-agent-scheduler.test.d.ts.map +0 -1
  395. package/dist/test/busy-agent-scheduler.test.js +0 -92
  396. package/dist/test/busy-agent-scheduler.test.js.map +0 -1
  397. package/dist/test/channel-incoming-targeting.test.d.ts +0 -2
  398. package/dist/test/channel-incoming-targeting.test.d.ts.map +0 -1
  399. package/dist/test/channel-incoming-targeting.test.js +0 -81
  400. package/dist/test/channel-incoming-targeting.test.js.map +0 -1
  401. package/dist/test/channel-label-history.test.d.ts +0 -2
  402. package/dist/test/channel-label-history.test.d.ts.map +0 -1
  403. package/dist/test/channel-label-history.test.js +0 -424
  404. package/dist/test/channel-label-history.test.js.map +0 -1
  405. package/dist/test/channel-lifecycle-provenance.test.d.ts +0 -2
  406. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +0 -1
  407. package/dist/test/channel-lifecycle-provenance.test.js +0 -47
  408. package/dist/test/channel-lifecycle-provenance.test.js.map +0 -1
  409. package/dist/test/channel-registry-routing.test.d.ts +0 -2
  410. package/dist/test/channel-registry-routing.test.d.ts.map +0 -1
  411. package/dist/test/channel-registry-routing.test.js +0 -446
  412. package/dist/test/channel-registry-routing.test.js.map +0 -1
  413. package/dist/test/channel-registry-typing.test.d.ts +0 -2
  414. package/dist/test/channel-registry-typing.test.d.ts.map +0 -1
  415. package/dist/test/channel-registry-typing.test.js +0 -91
  416. package/dist/test/channel-registry-typing.test.js.map +0 -1
  417. package/dist/test/channels-changed-respond-first.test.d.ts +0 -2
  418. package/dist/test/channels-changed-respond-first.test.d.ts.map +0 -1
  419. package/dist/test/channels-changed-respond-first.test.js +0 -74
  420. package/dist/test/channels-changed-respond-first.test.js.map +0 -1
  421. package/dist/test/checkpoint-attribution.test.d.ts +0 -2
  422. package/dist/test/checkpoint-attribution.test.d.ts.map +0 -1
  423. package/dist/test/checkpoint-attribution.test.js +0 -200
  424. package/dist/test/checkpoint-attribution.test.js.map +0 -1
  425. package/dist/test/code-execution.test.d.ts +0 -11
  426. package/dist/test/code-execution.test.d.ts.map +0 -1
  427. package/dist/test/code-execution.test.js +0 -1272
  428. package/dist/test/code-execution.test.js.map +0 -1
  429. package/dist/test/component-defaults.test.d.ts +0 -2
  430. package/dist/test/component-defaults.test.d.ts.map +0 -1
  431. package/dist/test/component-defaults.test.js +0 -54
  432. package/dist/test/component-defaults.test.js.map +0 -1
  433. package/dist/test/conversation-fork-injection-scoping.test.d.ts +0 -2
  434. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +0 -1
  435. package/dist/test/conversation-fork-injection-scoping.test.js +0 -200
  436. package/dist/test/conversation-fork-injection-scoping.test.js.map +0 -1
  437. package/dist/test/conversation-router.test.d.ts +0 -2
  438. package/dist/test/conversation-router.test.d.ts.map +0 -1
  439. package/dist/test/conversation-router.test.js +0 -174
  440. package/dist/test/conversation-router.test.js.map +0 -1
  441. package/dist/test/conversation-routing.test.d.ts +0 -7
  442. package/dist/test/conversation-routing.test.d.ts.map +0 -1
  443. package/dist/test/conversation-routing.test.js +0 -621
  444. package/dist/test/conversation-routing.test.js.map +0 -1
  445. package/dist/test/deferred-wake-integrity.test.d.ts +0 -29
  446. package/dist/test/deferred-wake-integrity.test.d.ts.map +0 -1
  447. package/dist/test/deferred-wake-integrity.test.js +0 -320
  448. package/dist/test/deferred-wake-integrity.test.js.map +0 -1
  449. package/dist/test/discord-awareness-outbox.test.d.ts +0 -2
  450. package/dist/test/discord-awareness-outbox.test.d.ts.map +0 -1
  451. package/dist/test/discord-awareness-outbox.test.js +0 -371
  452. package/dist/test/discord-awareness-outbox.test.js.map +0 -1
  453. package/dist/test/discord-history-delete-window.test.d.ts +0 -2
  454. package/dist/test/discord-history-delete-window.test.d.ts.map +0 -1
  455. package/dist/test/discord-history-delete-window.test.js +0 -81
  456. package/dist/test/discord-history-delete-window.test.js.map +0 -1
  457. package/dist/test/discord-history-sync.test.d.ts +0 -2
  458. package/dist/test/discord-history-sync.test.d.ts.map +0 -1
  459. package/dist/test/discord-history-sync.test.js +0 -56
  460. package/dist/test/discord-history-sync.test.js.map +0 -1
  461. package/dist/test/discord-typing-keepalive.test.d.ts +0 -2
  462. package/dist/test/discord-typing-keepalive.test.d.ts.map +0 -1
  463. package/dist/test/discord-typing-keepalive.test.js +0 -77
  464. package/dist/test/discord-typing-keepalive.test.js.map +0 -1
  465. package/dist/test/ephemeral-settle.test.d.ts +0 -2
  466. package/dist/test/ephemeral-settle.test.d.ts.map +0 -1
  467. package/dist/test/ephemeral-settle.test.js +0 -354
  468. package/dist/test/ephemeral-settle.test.js.map +0 -1
  469. package/dist/test/event-gate-passthrough.test.d.ts +0 -15
  470. package/dist/test/event-gate-passthrough.test.d.ts.map +0 -1
  471. package/dist/test/event-gate-passthrough.test.js +0 -289
  472. package/dist/test/event-gate-passthrough.test.js.map +0 -1
  473. package/dist/test/event-gate-policy-mutation.test.d.ts +0 -2
  474. package/dist/test/event-gate-policy-mutation.test.d.ts.map +0 -1
  475. package/dist/test/event-gate-policy-mutation.test.js +0 -137
  476. package/dist/test/event-gate-policy-mutation.test.js.map +0 -1
  477. package/dist/test/event-gate-rate-limit.test.d.ts +0 -2
  478. package/dist/test/event-gate-rate-limit.test.d.ts.map +0 -1
  479. package/dist/test/event-gate-rate-limit.test.js +0 -627
  480. package/dist/test/event-gate-rate-limit.test.js.map +0 -1
  481. package/dist/test/event-gate-script.test.d.ts +0 -2
  482. package/dist/test/event-gate-script.test.d.ts.map +0 -1
  483. package/dist/test/event-gate-script.test.js +0 -95
  484. package/dist/test/event-gate-script.test.js.map +0 -1
  485. package/dist/test/event-gate-selfwake.test.d.ts +0 -15
  486. package/dist/test/event-gate-selfwake.test.d.ts.map +0 -1
  487. package/dist/test/event-gate-selfwake.test.js +0 -125
  488. package/dist/test/event-gate-selfwake.test.js.map +0 -1
  489. package/dist/test/event-gate-tags.test.d.ts +0 -2
  490. package/dist/test/event-gate-tags.test.d.ts.map +0 -1
  491. package/dist/test/event-gate-tags.test.js +0 -76
  492. package/dist/test/event-gate-tags.test.js.map +0 -1
  493. package/dist/test/event-gate.test.d.ts +0 -2
  494. package/dist/test/event-gate.test.d.ts.map +0 -1
  495. package/dist/test/event-gate.test.js +0 -919
  496. package/dist/test/event-gate.test.js.map +0 -1
  497. package/dist/test/explicit-prose-routing.test.d.ts +0 -11
  498. package/dist/test/explicit-prose-routing.test.d.ts.map +0 -1
  499. package/dist/test/explicit-prose-routing.test.js +0 -1715
  500. package/dist/test/explicit-prose-routing.test.js.map +0 -1
  501. package/dist/test/framework.test.d.ts +0 -5
  502. package/dist/test/framework.test.d.ts.map +0 -1
  503. package/dist/test/framework.test.js +0 -1048
  504. package/dist/test/framework.test.js.map +0 -1
  505. package/dist/test/gather-context-timeout.test.d.ts +0 -2
  506. package/dist/test/gather-context-timeout.test.d.ts.map +0 -1
  507. package/dist/test/gather-context-timeout.test.js +0 -120
  508. package/dist/test/gather-context-timeout.test.js.map +0 -1
  509. package/dist/test/health-snapshot.test.d.ts +0 -2
  510. package/dist/test/health-snapshot.test.d.ts.map +0 -1
  511. package/dist/test/health-snapshot.test.js +0 -74
  512. package/dist/test/health-snapshot.test.js.map +0 -1
  513. package/dist/test/helpers/coalescing-fixture.d.ts +0 -74
  514. package/dist/test/helpers/coalescing-fixture.d.ts.map +0 -1
  515. package/dist/test/helpers/coalescing-fixture.js +0 -152
  516. package/dist/test/helpers/coalescing-fixture.js.map +0 -1
  517. package/dist/test/helpers/coalescing-kill-child.d.ts +0 -2
  518. package/dist/test/helpers/coalescing-kill-child.d.ts.map +0 -1
  519. package/dist/test/helpers/coalescing-kill-child.js +0 -24
  520. package/dist/test/helpers/coalescing-kill-child.js.map +0 -1
  521. package/dist/test/helpers/mock-membrane.d.ts +0 -53
  522. package/dist/test/helpers/mock-membrane.d.ts.map +0 -1
  523. package/dist/test/helpers/mock-membrane.js +0 -168
  524. package/dist/test/helpers/mock-membrane.js.map +0 -1
  525. package/dist/test/helpers/pytc-stop-rows.d.ts +0 -11
  526. package/dist/test/helpers/pytc-stop-rows.d.ts.map +0 -1
  527. package/dist/test/helpers/pytc-stop-rows.js +0 -143
  528. package/dist/test/helpers/pytc-stop-rows.js.map +0 -1
  529. package/dist/test/helpers/quiesce-crash-child.d.ts +0 -2
  530. package/dist/test/helpers/quiesce-crash-child.d.ts.map +0 -1
  531. package/dist/test/helpers/quiesce-crash-child.js +0 -156
  532. package/dist/test/helpers/quiesce-crash-child.js.map +0 -1
  533. package/dist/test/hide-then-turn.test.d.ts +0 -2
  534. package/dist/test/hide-then-turn.test.d.ts.map +0 -1
  535. package/dist/test/hide-then-turn.test.js +0 -101
  536. package/dist/test/hide-then-turn.test.js.map +0 -1
  537. package/dist/test/history-module-overview.test.d.ts +0 -2
  538. package/dist/test/history-module-overview.test.d.ts.map +0 -1
  539. package/dist/test/history-module-overview.test.js +0 -711
  540. package/dist/test/history-module-overview.test.js.map +0 -1
  541. package/dist/test/history-module-realstore.test.d.ts +0 -2
  542. package/dist/test/history-module-realstore.test.d.ts.map +0 -1
  543. package/dist/test/history-module-realstore.test.js +0 -363
  544. package/dist/test/history-module-realstore.test.js.map +0 -1
  545. package/dist/test/history-module-semantic.test.d.ts +0 -2
  546. package/dist/test/history-module-semantic.test.d.ts.map +0 -1
  547. package/dist/test/history-module-semantic.test.js +0 -213
  548. package/dist/test/history-module-semantic.test.js.map +0 -1
  549. package/dist/test/history-module-ux.test.d.ts +0 -2
  550. package/dist/test/history-module-ux.test.d.ts.map +0 -1
  551. package/dist/test/history-module-ux.test.js +0 -304
  552. package/dist/test/history-module-ux.test.js.map +0 -1
  553. package/dist/test/history-module.test.d.ts +0 -2
  554. package/dist/test/history-module.test.d.ts.map +0 -1
  555. package/dist/test/history-module.test.js +0 -619
  556. package/dist/test/history-module.test.js.map +0 -1
  557. package/dist/test/history-semantic-review-findings.test.d.ts +0 -2
  558. package/dist/test/history-semantic-review-findings.test.d.ts.map +0 -1
  559. package/dist/test/history-semantic-review-findings.test.js +0 -338
  560. package/dist/test/history-semantic-review-findings.test.js.map +0 -1
  561. package/dist/test/history-semantic-sync-converges.test.d.ts +0 -2
  562. package/dist/test/history-semantic-sync-converges.test.d.ts.map +0 -1
  563. package/dist/test/history-semantic-sync-converges.test.js +0 -109
  564. package/dist/test/history-semantic-sync-converges.test.js.map +0 -1
  565. package/dist/test/host-quiesce.test.d.ts +0 -2
  566. package/dist/test/host-quiesce.test.d.ts.map +0 -1
  567. package/dist/test/host-quiesce.test.js +0 -988
  568. package/dist/test/host-quiesce.test.js.map +0 -1
  569. package/dist/test/inference-exhaustion-rewind.test.d.ts +0 -2
  570. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +0 -1
  571. package/dist/test/inference-exhaustion-rewind.test.js +0 -387
  572. package/dist/test/inference-exhaustion-rewind.test.js.map +0 -1
  573. package/dist/test/inference-failure-bounds.test.d.ts +0 -2
  574. package/dist/test/inference-failure-bounds.test.d.ts.map +0 -1
  575. package/dist/test/inference-failure-bounds.test.js +0 -1202
  576. package/dist/test/inference-failure-bounds.test.js.map +0 -1
  577. package/dist/test/inference-failure-observability.test.d.ts +0 -2
  578. package/dist/test/inference-failure-observability.test.d.ts.map +0 -1
  579. package/dist/test/inference-failure-observability.test.js +0 -84
  580. package/dist/test/inference-failure-observability.test.js.map +0 -1
  581. package/dist/test/journal-tool.test.d.ts +0 -2
  582. package/dist/test/journal-tool.test.d.ts.map +0 -1
  583. package/dist/test/journal-tool.test.js +0 -33
  584. package/dist/test/journal-tool.test.js.map +0 -1
  585. package/dist/test/kv-unified-open-flight-retry.test.d.ts +0 -2
  586. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +0 -1
  587. package/dist/test/kv-unified-open-flight-retry.test.js +0 -189
  588. package/dist/test/kv-unified-open-flight-retry.test.js.map +0 -1
  589. package/dist/test/kv-unified-wiring.test.d.ts +0 -2
  590. package/dist/test/kv-unified-wiring.test.d.ts.map +0 -1
  591. package/dist/test/kv-unified-wiring.test.js +0 -95
  592. package/dist/test/kv-unified-wiring.test.js.map +0 -1
  593. package/dist/test/late-events-after-stop.test.d.ts +0 -2
  594. package/dist/test/late-events-after-stop.test.d.ts.map +0 -1
  595. package/dist/test/late-events-after-stop.test.js +0 -122
  596. package/dist/test/late-events-after-stop.test.js.map +0 -1
  597. package/dist/test/liveness-watchdog.test.d.ts +0 -2
  598. package/dist/test/liveness-watchdog.test.d.ts.map +0 -1
  599. package/dist/test/liveness-watchdog.test.js +0 -94
  600. package/dist/test/liveness-watchdog.test.js.map +0 -1
  601. package/dist/test/machine-close-provenance.test.d.ts +0 -2
  602. package/dist/test/machine-close-provenance.test.d.ts.map +0 -1
  603. package/dist/test/machine-close-provenance.test.js +0 -116
  604. package/dist/test/machine-close-provenance.test.js.map +0 -1
  605. package/dist/test/maintenance-timer.test.d.ts +0 -2
  606. package/dist/test/maintenance-timer.test.d.ts.map +0 -1
  607. package/dist/test/maintenance-timer.test.js +0 -1827
  608. package/dist/test/maintenance-timer.test.js.map +0 -1
  609. package/dist/test/mcp-server-bin.test.d.ts +0 -2
  610. package/dist/test/mcp-server-bin.test.d.ts.map +0 -1
  611. package/dist/test/mcp-server-bin.test.js +0 -142
  612. package/dist/test/mcp-server-bin.test.js.map +0 -1
  613. package/dist/test/mcpl-access-provider.test.d.ts +0 -2
  614. package/dist/test/mcpl-access-provider.test.d.ts.map +0 -1
  615. package/dist/test/mcpl-access-provider.test.js +0 -20
  616. package/dist/test/mcpl-access-provider.test.js.map +0 -1
  617. package/dist/test/mcpl-awareness-barrier.test.d.ts +0 -2
  618. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +0 -1
  619. package/dist/test/mcpl-awareness-barrier.test.js +0 -769
  620. package/dist/test/mcpl-awareness-barrier.test.js.map +0 -1
  621. package/dist/test/mcpl-capability-grant.test.d.ts +0 -10
  622. package/dist/test/mcpl-capability-grant.test.d.ts.map +0 -1
  623. package/dist/test/mcpl-capability-grant.test.js +0 -111
  624. package/dist/test/mcpl-capability-grant.test.js.map +0 -1
  625. package/dist/test/mcpl-capability-scoping.test.d.ts +0 -2
  626. package/dist/test/mcpl-capability-scoping.test.d.ts.map +0 -1
  627. package/dist/test/mcpl-capability-scoping.test.js +0 -266
  628. package/dist/test/mcpl-capability-scoping.test.js.map +0 -1
  629. package/dist/test/mcpl-child-env.test.d.ts +0 -2
  630. package/dist/test/mcpl-child-env.test.d.ts.map +0 -1
  631. package/dist/test/mcpl-child-env.test.js +0 -158
  632. package/dist/test/mcpl-child-env.test.js.map +0 -1
  633. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +0 -2
  634. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +0 -1
  635. package/dist/test/mcpl-coalesced-content-parity.test.js +0 -532
  636. package/dist/test/mcpl-coalesced-content-parity.test.js.map +0 -1
  637. package/dist/test/mcpl-coalescing.test.d.ts +0 -2
  638. package/dist/test/mcpl-coalescing.test.d.ts.map +0 -1
  639. package/dist/test/mcpl-coalescing.test.js +0 -1125
  640. package/dist/test/mcpl-coalescing.test.js.map +0 -1
  641. package/dist/test/mcpl-config-diagnostics.test.d.ts +0 -2
  642. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +0 -1
  643. package/dist/test/mcpl-config-diagnostics.test.js +0 -452
  644. package/dist/test/mcpl-config-diagnostics.test.js.map +0 -1
  645. package/dist/test/mcpl-empty-content.test.d.ts +0 -2
  646. package/dist/test/mcpl-empty-content.test.d.ts.map +0 -1
  647. package/dist/test/mcpl-empty-content.test.js +0 -368
  648. package/dist/test/mcpl-empty-content.test.js.map +0 -1
  649. package/dist/test/mcpl-gate-roundtrip.test.d.ts +0 -2
  650. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +0 -1
  651. package/dist/test/mcpl-gate-roundtrip.test.js +0 -285
  652. package/dist/test/mcpl-gate-roundtrip.test.js.map +0 -1
  653. package/dist/test/mcpl-list-status.test.d.ts +0 -2
  654. package/dist/test/mcpl-list-status.test.d.ts.map +0 -1
  655. package/dist/test/mcpl-list-status.test.js +0 -82
  656. package/dist/test/mcpl-list-status.test.js.map +0 -1
  657. package/dist/test/mcpl-manifest-changed.test.d.ts +0 -2
  658. package/dist/test/mcpl-manifest-changed.test.d.ts.map +0 -1
  659. package/dist/test/mcpl-manifest-changed.test.js +0 -178
  660. package/dist/test/mcpl-manifest-changed.test.js.map +0 -1
  661. package/dist/test/mcpl-push-convert.test.d.ts +0 -2
  662. package/dist/test/mcpl-push-convert.test.d.ts.map +0 -1
  663. package/dist/test/mcpl-push-convert.test.js +0 -42
  664. package/dist/test/mcpl-push-convert.test.js.map +0 -1
  665. package/dist/test/mcpl-reconnect-events.test.d.ts +0 -2
  666. package/dist/test/mcpl-reconnect-events.test.d.ts.map +0 -1
  667. package/dist/test/mcpl-reconnect-events.test.js +0 -144
  668. package/dist/test/mcpl-reconnect-events.test.js.map +0 -1
  669. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +0 -2
  670. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +0 -1
  671. package/dist/test/mcpl-reconnect-reregistration.test.js +0 -238
  672. package/dist/test/mcpl-reconnect-reregistration.test.js.map +0 -1
  673. package/dist/test/mcpl-reference-fetcher.test.d.ts +0 -2
  674. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +0 -1
  675. package/dist/test/mcpl-reference-fetcher.test.js +0 -427
  676. package/dist/test/mcpl-reference-fetcher.test.js.map +0 -1
  677. package/dist/test/mcpl-reference-fields.test.d.ts +0 -2
  678. package/dist/test/mcpl-reference-fields.test.d.ts.map +0 -1
  679. package/dist/test/mcpl-reference-fields.test.js +0 -149
  680. package/dist/test/mcpl-reference-fields.test.js.map +0 -1
  681. package/dist/test/mcpl-rejection-log-lines.test.d.ts +0 -2
  682. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +0 -1
  683. package/dist/test/mcpl-rejection-log-lines.test.js +0 -349
  684. package/dist/test/mcpl-rejection-log-lines.test.js.map +0 -1
  685. package/dist/test/mcpl-request-timeout.test.d.ts +0 -2
  686. package/dist/test/mcpl-request-timeout.test.d.ts.map +0 -1
  687. package/dist/test/mcpl-request-timeout.test.js +0 -138
  688. package/dist/test/mcpl-request-timeout.test.js.map +0 -1
  689. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +0 -7
  690. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +0 -1
  691. package/dist/test/mcpl-runtime-lifecycle.test.js +0 -142
  692. package/dist/test/mcpl-runtime-lifecycle.test.js.map +0 -1
  693. package/dist/test/mcpl-stderr-tail.test.d.ts +0 -2
  694. package/dist/test/mcpl-stderr-tail.test.d.ts.map +0 -1
  695. package/dist/test/mcpl-stderr-tail.test.js +0 -210
  696. package/dist/test/mcpl-stderr-tail.test.js.map +0 -1
  697. package/dist/test/mcpl-tool-class-listing.test.d.ts +0 -2
  698. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +0 -1
  699. package/dist/test/mcpl-tool-class-listing.test.js +0 -147
  700. package/dist/test/mcpl-tool-class-listing.test.js.map +0 -1
  701. package/dist/test/mcpl-tool-classes.test.d.ts +0 -7
  702. package/dist/test/mcpl-tool-classes.test.d.ts.map +0 -1
  703. package/dist/test/mcpl-tool-classes.test.js +0 -80
  704. package/dist/test/mcpl-tool-classes.test.js.map +0 -1
  705. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +0 -2
  706. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +0 -1
  707. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +0 -237
  708. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +0 -1
  709. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +0 -11
  710. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +0 -1
  711. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +0 -412
  712. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +0 -1
  713. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +0 -2
  714. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +0 -1
  715. package/dist/test/mcpl-tool-lifecycle-script.test.js +0 -288
  716. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +0 -1
  717. package/dist/test/mcpl-tool-lifecycle.test.d.ts +0 -11
  718. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +0 -1
  719. package/dist/test/mcpl-tool-lifecycle.test.js +0 -270
  720. package/dist/test/mcpl-tool-lifecycle.test.js.map +0 -1
  721. package/dist/test/mcpl-transport-close.test.d.ts +0 -2
  722. package/dist/test/mcpl-transport-close.test.d.ts.map +0 -1
  723. package/dist/test/mcpl-transport-close.test.js +0 -373
  724. package/dist/test/mcpl-transport-close.test.js.map +0 -1
  725. package/dist/test/mcpl-transport-spawn.test.d.ts +0 -2
  726. package/dist/test/mcpl-transport-spawn.test.d.ts.map +0 -1
  727. package/dist/test/mcpl-transport-spawn.test.js +0 -58
  728. package/dist/test/mcpl-transport-spawn.test.js.map +0 -1
  729. package/dist/test/mcpl-ws-transport.test.d.ts +0 -2
  730. package/dist/test/mcpl-ws-transport.test.d.ts.map +0 -1
  731. package/dist/test/mcpl-ws-transport.test.js +0 -259
  732. package/dist/test/mcpl-ws-transport.test.js.map +0 -1
  733. package/dist/test/module-context-integration.test.d.ts +0 -18
  734. package/dist/test/module-context-integration.test.d.ts.map +0 -1
  735. package/dist/test/module-context-integration.test.js +0 -181
  736. package/dist/test/module-context-integration.test.js.map +0 -1
  737. package/dist/test/offline-recovery-branch.test.d.ts +0 -2
  738. package/dist/test/offline-recovery-branch.test.d.ts.map +0 -1
  739. package/dist/test/offline-recovery-branch.test.js +0 -570
  740. package/dist/test/offline-recovery-branch.test.js.map +0 -1
  741. package/dist/test/operator-surgery.test.d.ts +0 -2
  742. package/dist/test/operator-surgery.test.d.ts.map +0 -1
  743. package/dist/test/operator-surgery.test.js +0 -595
  744. package/dist/test/operator-surgery.test.js.map +0 -1
  745. package/dist/test/ops-alert.test.d.ts +0 -2
  746. package/dist/test/ops-alert.test.d.ts.map +0 -1
  747. package/dist/test/ops-alert.test.js +0 -153
  748. package/dist/test/ops-alert.test.js.map +0 -1
  749. package/dist/test/overbudget-drain-breaker.test.d.ts +0 -2
  750. package/dist/test/overbudget-drain-breaker.test.d.ts.map +0 -1
  751. package/dist/test/overbudget-drain-breaker.test.js +0 -198
  752. package/dist/test/overbudget-drain-breaker.test.js.map +0 -1
  753. package/dist/test/pack-installs-alone.test.d.ts +0 -2
  754. package/dist/test/pack-installs-alone.test.d.ts.map +0 -1
  755. package/dist/test/pack-installs-alone.test.js +0 -284
  756. package/dist/test/pack-installs-alone.test.js.map +0 -1
  757. package/dist/test/per-agent-delivery.test.d.ts +0 -2
  758. package/dist/test/per-agent-delivery.test.d.ts.map +0 -1
  759. package/dist/test/per-agent-delivery.test.js +0 -108
  760. package/dist/test/per-agent-delivery.test.js.map +0 -1
  761. package/dist/test/physical-window-restart.test.d.ts +0 -16
  762. package/dist/test/physical-window-restart.test.d.ts.map +0 -1
  763. package/dist/test/physical-window-restart.test.js +0 -487
  764. package/dist/test/physical-window-restart.test.js.map +0 -1
  765. package/dist/test/placed-reaction-baseline.test.d.ts +0 -2
  766. package/dist/test/placed-reaction-baseline.test.d.ts.map +0 -1
  767. package/dist/test/placed-reaction-baseline.test.js +0 -312
  768. package/dist/test/placed-reaction-baseline.test.js.map +0 -1
  769. package/dist/test/present-while-acting.test.d.ts +0 -19
  770. package/dist/test/present-while-acting.test.d.ts.map +0 -1
  771. package/dist/test/present-while-acting.test.js +0 -1649
  772. package/dist/test/present-while-acting.test.js.map +0 -1
  773. package/dist/test/prose-segments.test.d.ts +0 -2
  774. package/dist/test/prose-segments.test.d.ts.map +0 -1
  775. package/dist/test/prose-segments.test.js +0 -52
  776. package/dist/test/prose-segments.test.js.map +0 -1
  777. package/dist/test/prose-stream-router.test.d.ts +0 -2
  778. package/dist/test/prose-stream-router.test.d.ts.map +0 -1
  779. package/dist/test/prose-stream-router.test.js +0 -171
  780. package/dist/test/prose-stream-router.test.js.map +0 -1
  781. package/dist/test/provider-acceleration-cooldown.test.d.ts +0 -2
  782. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +0 -1
  783. package/dist/test/provider-acceleration-cooldown.test.js +0 -435
  784. package/dist/test/provider-acceleration-cooldown.test.js.map +0 -1
  785. package/dist/test/provider-waits.test.d.ts +0 -2
  786. package/dist/test/provider-waits.test.d.ts.map +0 -1
  787. package/dist/test/provider-waits.test.js +0 -2259
  788. package/dist/test/provider-waits.test.js.map +0 -1
  789. package/dist/test/puppet-tool-call.test.d.ts +0 -2
  790. package/dist/test/puppet-tool-call.test.d.ts.map +0 -1
  791. package/dist/test/puppet-tool-call.test.js +0 -226
  792. package/dist/test/puppet-tool-call.test.js.map +0 -1
  793. package/dist/test/puppet-turn-reservation.test.d.ts +0 -2
  794. package/dist/test/puppet-turn-reservation.test.d.ts.map +0 -1
  795. package/dist/test/puppet-turn-reservation.test.js +0 -139
  796. package/dist/test/puppet-turn-reservation.test.js.map +0 -1
  797. package/dist/test/reaction-baseline-platform.test.d.ts +0 -2
  798. package/dist/test/reaction-baseline-platform.test.d.ts.map +0 -1
  799. package/dist/test/reaction-baseline-platform.test.js +0 -124
  800. package/dist/test/reaction-baseline-platform.test.js.map +0 -1
  801. package/dist/test/record-journal.test.d.ts +0 -6
  802. package/dist/test/record-journal.test.d.ts.map +0 -1
  803. package/dist/test/record-journal.test.js +0 -269
  804. package/dist/test/record-journal.test.js.map +0 -1
  805. package/dist/test/refusal-reaction-baseline.test.d.ts +0 -2
  806. package/dist/test/refusal-reaction-baseline.test.d.ts.map +0 -1
  807. package/dist/test/refusal-reaction-baseline.test.js +0 -81
  808. package/dist/test/refusal-reaction-baseline.test.js.map +0 -1
  809. package/dist/test/refusal-rewind.test.d.ts +0 -2
  810. package/dist/test/refusal-rewind.test.d.ts.map +0 -1
  811. package/dist/test/refusal-rewind.test.js +0 -117
  812. package/dist/test/refusal-rewind.test.js.map +0 -1
  813. package/dist/test/refusal-single-call.test.d.ts +0 -2
  814. package/dist/test/refusal-single-call.test.d.ts.map +0 -1
  815. package/dist/test/refusal-single-call.test.js +0 -177
  816. package/dist/test/refusal-single-call.test.js.map +0 -1
  817. package/dist/test/release-bump.test.d.ts +0 -2
  818. package/dist/test/release-bump.test.d.ts.map +0 -1
  819. package/dist/test/release-bump.test.js +0 -248
  820. package/dist/test/release-bump.test.js.map +0 -1
  821. package/dist/test/release-changelog.test.d.ts +0 -2
  822. package/dist/test/release-changelog.test.d.ts.map +0 -1
  823. package/dist/test/release-changelog.test.js +0 -159
  824. package/dist/test/release-changelog.test.js.map +0 -1
  825. package/dist/test/release-holds.test.d.ts +0 -2
  826. package/dist/test/release-holds.test.d.ts.map +0 -1
  827. package/dist/test/release-holds.test.js +0 -456
  828. package/dist/test/release-holds.test.js.map +0 -1
  829. package/dist/test/same-round-think-policy.test.d.ts +0 -2
  830. package/dist/test/same-round-think-policy.test.d.ts.map +0 -1
  831. package/dist/test/same-round-think-policy.test.js +0 -853
  832. package/dist/test/same-round-think-policy.test.js.map +0 -1
  833. package/dist/test/save-recent-image.test.d.ts +0 -26
  834. package/dist/test/save-recent-image.test.d.ts.map +0 -1
  835. package/dist/test/save-recent-image.test.js +0 -974
  836. package/dist/test/save-recent-image.test.js.map +0 -1
  837. package/dist/test/shutdown-errors.test.d.ts +0 -2
  838. package/dist/test/shutdown-errors.test.d.ts.map +0 -1
  839. package/dist/test/shutdown-errors.test.js +0 -662
  840. package/dist/test/shutdown-errors.test.js.map +0 -1
  841. package/dist/test/silent-heartbeat-rows.test.d.ts +0 -2
  842. package/dist/test/silent-heartbeat-rows.test.d.ts.map +0 -1
  843. package/dist/test/silent-heartbeat-rows.test.js +0 -451
  844. package/dist/test/silent-heartbeat-rows.test.js.map +0 -1
  845. package/dist/test/silent-heartbeat.test.d.ts +0 -2
  846. package/dist/test/silent-heartbeat.test.d.ts.map +0 -1
  847. package/dist/test/silent-heartbeat.test.js +0 -165
  848. package/dist/test/silent-heartbeat.test.js.map +0 -1
  849. package/dist/test/state-scaling-e2e.test.d.ts +0 -22
  850. package/dist/test/state-scaling-e2e.test.d.ts.map +0 -1
  851. package/dist/test/state-scaling-e2e.test.js +0 -206
  852. package/dist/test/state-scaling-e2e.test.js.map +0 -1
  853. package/dist/test/state-scaling.test.d.ts +0 -12
  854. package/dist/test/state-scaling.test.d.ts.map +0 -1
  855. package/dist/test/state-scaling.test.js +0 -251
  856. package/dist/test/state-scaling.test.js.map +0 -1
  857. package/dist/test/stop-with-host-operations.test.d.ts +0 -20
  858. package/dist/test/stop-with-host-operations.test.d.ts.map +0 -1
  859. package/dist/test/stop-with-host-operations.test.js +0 -1174
  860. package/dist/test/stop-with-host-operations.test.js.map +0 -1
  861. package/dist/test/stop-with-work-in-flight.test.d.ts +0 -23
  862. package/dist/test/stop-with-work-in-flight.test.d.ts.map +0 -1
  863. package/dist/test/stop-with-work-in-flight.test.js +0 -784
  864. package/dist/test/stop-with-work-in-flight.test.js.map +0 -1
  865. package/dist/test/surgery-marks-receipt.test.d.ts +0 -2
  866. package/dist/test/surgery-marks-receipt.test.d.ts.map +0 -1
  867. package/dist/test/surgery-marks-receipt.test.js +0 -540
  868. package/dist/test/surgery-marks-receipt.test.js.map +0 -1
  869. package/dist/test/thinking-token-stamp.test.d.ts +0 -12
  870. package/dist/test/thinking-token-stamp.test.d.ts.map +0 -1
  871. package/dist/test/thinking-token-stamp.test.js +0 -129
  872. package/dist/test/thinking-token-stamp.test.js.map +0 -1
  873. package/dist/test/timezone.test.d.ts +0 -2
  874. package/dist/test/timezone.test.d.ts.map +0 -1
  875. package/dist/test/timezone.test.js +0 -18
  876. package/dist/test/timezone.test.js.map +0 -1
  877. package/dist/test/tool-batch-hook.test.d.ts +0 -8
  878. package/dist/test/tool-batch-hook.test.d.ts.map +0 -1
  879. package/dist/test/tool-batch-hook.test.js +0 -275
  880. package/dist/test/tool-batch-hook.test.js.map +0 -1
  881. package/dist/test/tool-policy.test.d.ts +0 -2
  882. package/dist/test/tool-policy.test.d.ts.map +0 -1
  883. package/dist/test/tool-policy.test.js +0 -51
  884. package/dist/test/tool-policy.test.js.map +0 -1
  885. package/dist/test/tool-presentation.test.d.ts +0 -2
  886. package/dist/test/tool-presentation.test.d.ts.map +0 -1
  887. package/dist/test/tool-presentation.test.js +0 -268
  888. package/dist/test/tool-presentation.test.js.map +0 -1
  889. package/dist/test/tool-result-content.test.d.ts +0 -2
  890. package/dist/test/tool-result-content.test.d.ts.map +0 -1
  891. package/dist/test/tool-result-content.test.js +0 -37
  892. package/dist/test/tool-result-content.test.js.map +0 -1
  893. package/dist/test/tool-result-guard.test.d.ts +0 -2
  894. package/dist/test/tool-result-guard.test.d.ts.map +0 -1
  895. package/dist/test/tool-result-guard.test.js +0 -3729
  896. package/dist/test/tool-result-guard.test.js.map +0 -1
  897. package/dist/test/tool-result-history.test.d.ts +0 -7
  898. package/dist/test/tool-result-history.test.d.ts.map +0 -1
  899. package/dist/test/tool-result-history.test.js +0 -156
  900. package/dist/test/tool-result-history.test.js.map +0 -1
  901. package/dist/test/tool-result-spill.test.d.ts +0 -13
  902. package/dist/test/tool-result-spill.test.d.ts.map +0 -1
  903. package/dist/test/tool-result-spill.test.js +0 -586
  904. package/dist/test/tool-result-spill.test.js.map +0 -1
  905. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +0 -2
  906. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +0 -1
  907. package/dist/test/tool-wrapper-prose-guard-integration.test.js +0 -485
  908. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +0 -1
  909. package/dist/test/tool-wrapper-prose-guard.test.d.ts +0 -2
  910. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +0 -1
  911. package/dist/test/tool-wrapper-prose-guard.test.js +0 -34
  912. package/dist/test/tool-wrapper-prose-guard.test.js.map +0 -1
  913. package/dist/test/tools-observe-parser-conformance.test.d.ts +0 -2
  914. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +0 -1
  915. package/dist/test/tools-observe-parser-conformance.test.js +0 -129
  916. package/dist/test/tools-observe-parser-conformance.test.js.map +0 -1
  917. package/dist/test/trunk-channel-routing.test.d.ts +0 -19
  918. package/dist/test/trunk-channel-routing.test.d.ts.map +0 -1
  919. package/dist/test/trunk-channel-routing.test.js +0 -652
  920. package/dist/test/trunk-channel-routing.test.js.map +0 -1
  921. package/dist/test/tune-out-e2e.test.d.ts +0 -2
  922. package/dist/test/tune-out-e2e.test.d.ts.map +0 -1
  923. package/dist/test/tune-out-e2e.test.js +0 -142
  924. package/dist/test/tune-out-e2e.test.js.map +0 -1
  925. package/dist/test/tune-out-gate-composition.test.d.ts +0 -2
  926. package/dist/test/tune-out-gate-composition.test.d.ts.map +0 -1
  927. package/dist/test/tune-out-gate-composition.test.js +0 -221
  928. package/dist/test/tune-out-gate-composition.test.js.map +0 -1
  929. package/dist/test/tune-out-lifecycle.test.d.ts +0 -2
  930. package/dist/test/tune-out-lifecycle.test.d.ts.map +0 -1
  931. package/dist/test/tune-out-lifecycle.test.js +0 -121
  932. package/dist/test/tune-out-lifecycle.test.js.map +0 -1
  933. package/dist/test/turn-trigger-provenance.test.d.ts +0 -2
  934. package/dist/test/turn-trigger-provenance.test.d.ts.map +0 -1
  935. package/dist/test/turn-trigger-provenance.test.js +0 -137
  936. package/dist/test/turn-trigger-provenance.test.js.map +0 -1
  937. package/dist/test/unconvertible-tool-result.test.d.ts +0 -2
  938. package/dist/test/unconvertible-tool-result.test.d.ts.map +0 -1
  939. package/dist/test/unconvertible-tool-result.test.js +0 -358
  940. package/dist/test/unconvertible-tool-result.test.js.map +0 -1
  941. package/dist/test/unsent-compiles.test.d.ts +0 -2
  942. package/dist/test/unsent-compiles.test.d.ts.map +0 -1
  943. package/dist/test/unsent-compiles.test.js +0 -139
  944. package/dist/test/unsent-compiles.test.js.map +0 -1
  945. package/dist/test/usage-accounting.test.d.ts +0 -2
  946. package/dist/test/usage-accounting.test.d.ts.map +0 -1
  947. package/dist/test/usage-accounting.test.js +0 -13
  948. package/dist/test/usage-accounting.test.js.map +0 -1
  949. package/dist/test/usage-tracker.test.d.ts +0 -2
  950. package/dist/test/usage-tracker.test.d.ts.map +0 -1
  951. package/dist/test/usage-tracker.test.js +0 -129
  952. package/dist/test/usage-tracker.test.js.map +0 -1
  953. package/dist/test/utils-surface.test.d.ts +0 -2
  954. package/dist/test/utils-surface.test.d.ts.map +0 -1
  955. package/dist/test/utils-surface.test.js +0 -145
  956. package/dist/test/utils-surface.test.js.map +0 -1
  957. package/dist/test/workspace-branch-continuation.test.d.ts +0 -15
  958. package/dist/test/workspace-branch-continuation.test.d.ts.map +0 -1
  959. package/dist/test/workspace-branch-continuation.test.js +0 -183
  960. package/dist/test/workspace-branch-continuation.test.js.map +0 -1
  961. package/dist/test/workspace-materialize-freshness.test.d.ts +0 -16
  962. package/dist/test/workspace-materialize-freshness.test.d.ts.map +0 -1
  963. package/dist/test/workspace-materialize-freshness.test.js +0 -252
  964. package/dist/test/workspace-materialize-freshness.test.js.map +0 -1
  965. package/dist/test/workspace-path-containment.test.d.ts +0 -19
  966. package/dist/test/workspace-path-containment.test.d.ts.map +0 -1
  967. package/dist/test/workspace-path-containment.test.js +0 -91
  968. package/dist/test/workspace-path-containment.test.js.map +0 -1
  969. package/dist/test/workspace-read-file-from-disk.test.d.ts +0 -17
  970. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +0 -1
  971. package/dist/test/workspace-read-file-from-disk.test.js +0 -174
  972. package/dist/test/workspace-read-file-from-disk.test.js.map +0 -1
  973. package/dist/test/workspace-read-image.test.d.ts +0 -2
  974. package/dist/test/workspace-read-image.test.d.ts.map +0 -1
  975. package/dist/test/workspace-read-image.test.js +0 -597
  976. package/dist/test/workspace-read-image.test.js.map +0 -1
  977. package/dist/test/workspace-read-text.test.d.ts +0 -2
  978. package/dist/test/workspace-read-text.test.d.ts.map +0 -1
  979. package/dist/test/workspace-read-text.test.js +0 -101
  980. package/dist/test/workspace-read-text.test.js.map +0 -1
  981. package/dist/test/workspace-restore-order.test.d.ts +0 -16
  982. package/dist/test/workspace-restore-order.test.d.ts.map +0 -1
  983. package/dist/test/workspace-restore-order.test.js +0 -140
  984. package/dist/test/workspace-restore-order.test.js.map +0 -1
  985. package/dist/test/workspace-three-way.test.d.ts +0 -12
  986. package/dist/test/workspace-three-way.test.d.ts.map +0 -1
  987. package/dist/test/workspace-three-way.test.js +0 -4094
  988. package/dist/test/workspace-three-way.test.js.map +0 -1
  989. package/dist/test/workspace-watcher.test.d.ts +0 -2
  990. package/dist/test/workspace-watcher.test.d.ts.map +0 -1
  991. package/dist/test/workspace-watcher.test.js +0 -281
  992. package/dist/test/workspace-watcher.test.js.map +0 -1
  993. package/dist/test/xml-round-persist.test.d.ts +0 -19
  994. package/dist/test/xml-round-persist.test.d.ts.map +0 -1
  995. package/dist/test/xml-round-persist.test.js +0 -235
  996. package/dist/test/xml-round-persist.test.js.map +0 -1
  997. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,3729 +0,0 @@
1
- import { afterEach, test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { PassthroughStrategy } from '@animalabs/context-manager';
7
- import { Membrane as RealMembrane, NativeFormatter } from '@animalabs/membrane';
8
- import { AgentFramework } from '../src/index.js';
9
- import { TOOL_RESULT_GUARD_AUDIT_STATE, TOOL_RESULT_GUARD_NOTICE, keptWhenWithheld, withheldResultNotice } from '../src/tool-result-guard.js';
10
- import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js';
11
- import { WorkspaceModule } from '../src/modules/workspace/index.js';
12
- import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../src/tool-result-history.js';
13
- import { JsStore } from '@animalabs/chronicle';
14
- // What a withheld result settles to with no writable workspace (#277): the
15
- // neutral notice, then where the original is kept. Written out, not computed
16
- // by withheldResultNotice, so a change of its wording fails here.
17
- const AUDIT_STUB = 'Tool result withheld by the guard. The tool has already executed. Its result is kept in ' +
18
- "the guard's audit record (framework/tool-result-guard), for an operator.";
19
- const UNSAVED_STUB = 'Tool result withheld by the guard. The tool has already executed. Its result went to ' +
20
- "the guard's audit record (framework/tool-result-guard), for an operator, though saving that record had failed " +
21
- 'when the result arrived.';
22
- const dirs = [];
23
- afterEach(() => { while (dirs.length)
24
- rmSync(dirs.pop(), { recursive: true, force: true }); });
25
- const answer = () => createMockResponse([{ type: 'text', text: 'continued' }]);
26
- const refused = () => ({
27
- ...createMockResponse([{ type: 'text', text: 'discard-this-partial-output' }], 'refusal'),
28
- raw: { request: {}, response: { stop_details: { category: 'test-category' } } },
29
- });
30
- const calls = (...ids) => createMockResponse(ids.map((id) => ({
31
- type: 'tool_use', id, name: 'test--read', input: {},
32
- })), 'tool_use');
33
- class ScriptMembrane {
34
- scripts;
35
- requests = [];
36
- streams = [];
37
- retriesAtSubmission = [];
38
- onSubmit;
39
- constructor(scripts) {
40
- this.scripts = scripts;
41
- }
42
- streamYielding(request, options = {}) {
43
- this.requests.push(structuredClone({ ...request, onCacheWireReceipt: undefined }));
44
- const script = this.scripts.shift();
45
- assert.ok(script, 'unexpected extra inference');
46
- const stream = new MockYieldingStream(script);
47
- const provide = stream.provideToolResults.bind(stream);
48
- stream.provideToolResults = (...args) => {
49
- this.retriesAtSubmission.push(options.refusalRetries ?? 0);
50
- this.onSubmit?.();
51
- provide(...args);
52
- };
53
- this.streams.push(stream);
54
- return stream;
55
- }
56
- asMembrane() { return this; }
57
- }
58
- class ReadModule {
59
- results;
60
- name = 'test';
61
- calls = [];
62
- speeches = [];
63
- constructor(results = {}) {
64
- this.results = results;
65
- }
66
- async start(ctx) { ctx.registerSpeechHandler('*'); }
67
- async stop() { }
68
- getTools() {
69
- return [
70
- { name: 'read', description: 'Read a result', inputSchema: { type: 'object', properties: {} } },
71
- { name: 'send_message', description: 'Explicit send', inputSchema: { type: 'object', properties: {} } },
72
- ];
73
- }
74
- async handleToolCall(call) {
75
- this.calls.push(call.id);
76
- return this.results[call.id] ?? { success: true, data: `payload-${call.id}` };
77
- }
78
- async onProcess(event, _state) {
79
- return event.type === 'external-message'
80
- ? { addMessages: [{ participant: 'user', content: [{ type: 'text', text: String(event.content) }] }], requestInference: true }
81
- : {};
82
- }
83
- async onAgentSpeech(_name, content) {
84
- this.speeches.push(...content.flatMap((block) => block.type === 'text' ? [block.text] : []));
85
- }
86
- }
87
- class IngressObserver extends PassthroughStrategy {
88
- snapshots = [];
89
- async onNewMessage(_message, ctx) {
90
- this.snapshots.push(JSON.stringify(ctx.messageStore.getAll()));
91
- }
92
- }
93
- async function harness(scripts, config = {}, results) {
94
- const dir = mkdtempSync(join(tmpdir(), 'af-tool-result-guard-'));
95
- dirs.push(dir);
96
- const membrane = new ScriptMembrane(scripts);
97
- const module = new ReadModule(results);
98
- const base = { storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
99
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', ...config }], modules: [module], syncIntervalMs: 0 };
100
- const framework = await AgentFramework.create(base);
101
- const run = async () => {
102
- framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
103
- await framework.runUntilIdle();
104
- };
105
- return { framework, membrane, module, base, run };
106
- }
107
- function extension(framework) {
108
- const extensions = framework.collectAgentSettingsExtensions();
109
- return [...extensions.values()].find((ext) => ext.keys.includes('tool_result_guard'));
110
- }
111
- function toolResults(framework) {
112
- return framework.getAgent('assistant').getContextManager().getAllMessages()
113
- .flatMap((message) => message.content.filter((block) => block.type === 'tool_result'));
114
- }
115
- test('default off retains existing refusal behavior and original tool output', async () => {
116
- const h = await harness([[calls('one'), refused()]]);
117
- try {
118
- await h.run();
119
- assert.equal(h.membrane.requests.length, 1);
120
- assert.match(JSON.stringify(toolResults(h.framework)), /payload-one/);
121
- assert.equal(extension(h.framework).get('assistant').tool_result_guard, false);
122
- }
123
- finally {
124
- await h.framework.stop();
125
- }
126
- });
127
- test('withholds the entire latest batch, retries inference once, and keeps originals in Chronicle', async () => {
128
- const strategy = new IngressObserver();
129
- const image = Buffer.from('original-image-bytes').toString('base64');
130
- const h = await harness([[calls('text', 'image', 'error'), refused()], [answer()]], { toolResultGuard: true, strategy, refusalHandling: { retries: 3 } }, {
131
- text: { success: true, data: 'original-text-payload' },
132
- image: { success: true, data: [{ type: 'image', mimeType: 'image/png', data: image }] },
133
- error: { success: false, isError: true, error: 'original-error-payload' },
134
- });
135
- let stagedSequence = 0;
136
- let originalStopped = false;
137
- h.membrane.onSubmit = () => { stagedSequence = h.framework.getStore().currentSequence(); };
138
- try {
139
- await h.run();
140
- assert.deepEqual(h.module.calls.sort(), ['error', 'image', 'text']);
141
- assert.equal(h.membrane.requests.length, 2);
142
- assert.deepEqual(h.membrane.retriesAtSubmission, [0], 'first refusal must reach guard before plain retries');
143
- const retry = h.membrane.requests[1];
144
- assert.equal(retry.config.model, 'test');
145
- assert.doesNotMatch(JSON.stringify(retry), /original-text-payload|original-error-payload|discard-this-partial-output|test-category/);
146
- assert.ok(!JSON.stringify(retry).includes(image));
147
- assert.equal(retry.messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_use').length, 3);
148
- const retried = retry.messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
149
- assert.ok(retried.length === 3 && retried.every((b) => b.content === AUDIT_STUB), 'the retry already says where each original is');
150
- const guarded = toolResults(h.framework);
151
- assert.equal(guarded.length, 3);
152
- assert.ok(guarded.every((block) => block.content === AUDIT_STUB));
153
- assert.equal(guarded.find((b) => b.toolUseId === 'error')?.isError, true);
154
- assert.deepEqual(h.module.speeches, ['continued']);
155
- assert.ok(strategy.snapshots.every((s) => !s.includes('original-text-payload') && !s.includes(image)), 'background strategy ingress must never see withheld payloads');
156
- const store = h.framework.getStore();
157
- const audit = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
158
- assert.match(JSON.stringify(audit[0]), /original-text-payload|original-error-payload/);
159
- assert.ok(JSON.stringify(audit[0]).includes(image));
160
- assert.deepEqual(audit.slice(-3).map((r) => r.type), ['withheld', 'annotated', 'stub_updated']);
161
- const historical = store.getStateJsonAt(TOOL_RESULT_GUARD_AUDIT_STATE, stagedSequence);
162
- assert.deepEqual(audit[0], historical[0], 'redaction only appends; original Chronicle record is unchanged');
163
- const original = structuredClone(audit[0]);
164
- extension(h.framework).update('assistant', { tool_result_guard: false });
165
- assert.ok(toolResults(h.framework).every((block) => block.content === AUDIT_STUB));
166
- await h.framework.stop();
167
- originalStopped = true;
168
- const restarted = await AgentFramework.create(h.base);
169
- try {
170
- assert.equal(extension(restarted).get('assistant').tool_result_guard, false, 'explicit disable persists');
171
- assert.ok(toolResults(restarted).every((block) => block.content === AUDIT_STUB));
172
- assert.deepEqual(restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE)[0], original);
173
- const preview = await restarted.previewActivation('assistant');
174
- assert.doesNotMatch(JSON.stringify(preview), /original-text-payload|original-error-payload/);
175
- }
176
- finally {
177
- await restarted.stop();
178
- }
179
- }
180
- finally {
181
- if (!originalStopped)
182
- await h.framework.stop();
183
- }
184
- });
185
- test('successful physical rounds admit results; refusal affects only the newest batch', async () => {
186
- const h = await harness([[calls('accepted'), calls('withheld'), refused()], [answer()]], { toolResultGuard: true });
187
- try {
188
- await h.run();
189
- const results = toolResults(h.framework);
190
- assert.match(String(results.find((b) => b.toolUseId === 'accepted')?.content), /payload-accepted/);
191
- assert.equal(results.find((b) => b.toolUseId === 'withheld')?.content, AUDIT_STUB);
192
- assert.deepEqual(h.module.calls, ['accepted', 'withheld']);
193
- assert.equal(h.framework.getAgent('assistant').toolResultGuard.enabled, true);
194
- }
195
- finally {
196
- await h.framework.stop();
197
- }
198
- });
199
- test('a second refusal stops recovery without auto-rewinding older or human messages', async () => {
200
- const h = await harness([[calls('one'), refused()], [refused()]], { toolResultGuard: true, refusalHandling: { autoRewind: true, retries: 3 } });
201
- try {
202
- await h.run();
203
- assert.equal(h.membrane.requests.length, 2);
204
- assert.deepEqual(h.module.calls, ['one']);
205
- assert.deepEqual(h.module.speeches, []);
206
- const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
207
- assert.ok(messages.some((m) => m.content.some((b) => b.type === 'text' && b.text === 'read')));
208
- assert.doesNotMatch(JSON.stringify(messages), /discard-this-partial-output|refusal-rewind/);
209
- assert.equal(toolResults(h.framework)[0].content, AUDIT_STUB);
210
- }
211
- finally {
212
- await h.framework.stop();
213
- }
214
- });
215
- test('durable typed agent setting can be enabled, disabled, and explicitly reset', async () => {
216
- const h = await harness([]);
217
- let originalStopped = false;
218
- try {
219
- const ext = extension(h.framework);
220
- for (const value of ['true', 1, null, {}]) {
221
- assert.throws(() => ext.update('assistant', { tool_result_guard: value }), /must be a boolean/);
222
- }
223
- ext.update('assistant', { tool_result_guard: true });
224
- assert.equal(ext.get('assistant').tool_result_guard, true);
225
- await h.framework.stop();
226
- originalStopped = true;
227
- const restarted = await AgentFramework.create(h.base);
228
- try {
229
- const restored = extension(restarted);
230
- assert.equal(restored.get('assistant').tool_result_guard, true);
231
- assert.equal(restored.get('assistant').tool_result_guard_source, 'runtime_override');
232
- restored.reset('assistant');
233
- assert.equal(restored.get('assistant').tool_result_guard, false);
234
- const tool = restarted.getAllTools().find((t) => t.name === 'agent_settings');
235
- assert.equal(tool.inputSchema.properties.tool_result_guard.type, 'boolean');
236
- assert.doesNotMatch(JSON.stringify(tool), /classifier/i);
237
- }
238
- finally {
239
- await restarted.stop();
240
- }
241
- }
242
- finally {
243
- if (!originalStopped)
244
- await h.framework.stop();
245
- }
246
- });
247
- test('a normal successful response releases the pending output into versioned history', async () => {
248
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
249
- try {
250
- await h.run();
251
- assert.equal(h.membrane.requests.length, 1);
252
- assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
253
- const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
254
- assert.equal(records.at(-1)?.type, 'accepted');
255
- assert.equal(h.framework.getAgent('assistant').toolResultGuard.enabled, true);
256
- }
257
- finally {
258
- await h.framework.stop();
259
- }
260
- });
261
- test('refusal without a new tool result does not invoke tool guard recovery', async () => {
262
- const h = await harness([[refused()]], { toolResultGuard: true });
263
- try {
264
- await h.run();
265
- assert.equal(h.membrane.requests.length, 1);
266
- }
267
- finally {
268
- await h.framework.stop();
269
- }
270
- });
271
- test('budget restart submits staged originals, then recovers without re-executing tools', async () => {
272
- const h = await harness([[calls('one')], [refused()], [answer()]], { toolResultGuard: true, maxStreamTokens: 1 });
273
- try {
274
- await h.run();
275
- assert.equal(h.membrane.requests.length, 3);
276
- assert.match(JSON.stringify(h.membrane.requests[1]), /payload-one/);
277
- assert.doesNotMatch(JSON.stringify(h.membrane.requests[2]), /payload-one/);
278
- assert.deepEqual(h.module.calls, ['one']);
279
- assert.equal(toolResults(h.framework)[0].content, AUDIT_STUB);
280
- }
281
- finally {
282
- await h.framework.stop();
283
- }
284
- });
285
- test('ephemeral run settles only after recovery and counts each tool once', async () => {
286
- const h = await harness([[calls('one'), refused()], [answer()]]);
287
- try {
288
- const created = await h.framework.createEphemeralAgent({
289
- name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
290
- });
291
- created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
292
- const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
293
- h.framework.start();
294
- const result = await completion;
295
- assert.deepEqual(result, { speech: 'continued', toolCallsCount: 1 });
296
- assert.deepEqual(h.module.calls, ['one']);
297
- assert.equal(h.membrane.requests.length, 2);
298
- }
299
- finally {
300
- await h.framework.stop();
301
- }
302
- });
303
- test('quiesced scheduler admits a queued guard recovery as a continuation', async () => {
304
- const h = await harness([[answer()]], { toolResultGuard: true });
305
- try {
306
- await h.framework.quiesce();
307
- h.framework.getAgent('assistant').getContextManager().addMessage('user', [{
308
- type: 'text', text: TOOL_RESULT_GUARD_NOTICE,
309
- }]);
310
- // A recovery can be requeued while waiting for provider admission. It
311
- // must finish the held turn even after the host stops admitting new work.
312
- h.framework.pendingRequests.push({
313
- agentName: 'assistant', reason: 'tool_result_guard_retry', source: 'framework', timestamp: Date.now(),
314
- });
315
- await h.framework.runUntilIdle();
316
- assert.equal(h.membrane.requests.length, 1);
317
- assert.equal(h.framework.getHostModeStatus().quiesced, true);
318
- }
319
- finally {
320
- await h.framework.stop();
321
- }
322
- });
323
- test('native Membrane observes the first refusal even when guard is enabled by a tool mid-stream', async () => {
324
- const h = await harness([]);
325
- await h.framework.stop();
326
- const requests = [];
327
- const adapter = {
328
- name: 'test', usageCacheConvention: 'cache-excluded', supportsModel: () => true,
329
- complete: async () => { throw new Error('unexpected complete'); },
330
- stream: async (request, callbacks) => {
331
- requests.push(structuredClone(request));
332
- const index = requests.length;
333
- assert.ok(index <= 3, 'must not retry unchanged refused input');
334
- const content = index === 1 ? [
335
- { type: 'tool_use', id: 'enable', name: 'agent_settings', input: { action: 'update', tool_result_guard: true } },
336
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
337
- ] : [{ type: 'text', text: index === 2 ? 'discard-native-partial' : 'continued' }];
338
- if (index > 1)
339
- callbacks.onChunk?.(index === 2 ? 'discard-native-partial' : 'continued');
340
- return {
341
- content, stopReason: index === 1 ? 'tool_use' : index === 2 ? 'refusal' : 'end_turn',
342
- usage: { inputTokens: 20, outputTokens: 5 }, model: 'test',
343
- raw: { response: { stop_details: { category: 'test-category' } } },
344
- };
345
- },
346
- };
347
- const framework = await AgentFramework.create({ ...h.base,
348
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', refusalHandling: { retries: 4 } }],
349
- membrane: new RealMembrane(adapter, { formatter: new NativeFormatter() }),
350
- });
351
- const routed = [];
352
- const outgoing = [];
353
- framework.channelRegistry = new Proxy({
354
- resolveLocus: () => 'world:test',
355
- routeSpeech: async (_agent, speech) => {
356
- routed.push(speech);
357
- return { delivered: true, channelId: 'world:test' };
358
- },
359
- sendOutgoingChunk: (_channel, _agent, _id, _index, delta) => { outgoing.push(delta); },
360
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
361
- getDescriptor: () => undefined, getChannelTools: () => [],
362
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
363
- const tokenTraces = [];
364
- framework.onTrace((event) => {
365
- if (event.type === 'inference:tokens')
366
- tokenTraces.push(String(event.content));
367
- });
368
- try {
369
- framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
370
- await framework.runUntilIdle();
371
- assert.equal(requests.length, 3);
372
- assert.match(JSON.stringify(requests[1]), /payload-one/);
373
- assert.doesNotMatch(JSON.stringify(requests[2]), /payload-one|discard-native-partial|test-category/);
374
- assert.match(JSON.stringify(requests[2]), /Tool result withheld by the guard/);
375
- assert.deepEqual(h.module.calls, ['one']);
376
- assert.equal(extension(framework).get('assistant').tool_result_guard, true);
377
- assert.deepEqual(h.module.speeches, ['continued']);
378
- assert.deepEqual(routed, ['continued']);
379
- assert.doesNotMatch(outgoing.join(''), /discard-native-partial/);
380
- assert.match(outgoing.join(''), /continued/, 'accepted answer must reach outgoing-stream consumers');
381
- assert.doesNotMatch(tokenTraces.join(''), /discard-native-partial/, 'inference:tokens must not leak refused text');
382
- assert.match(tokenTraces.join(''), /continued/);
383
- }
384
- finally {
385
- await framework.stop();
386
- }
387
- });
388
- test('explicit mode: a guard retry streams on to the sticky destination delivery uses', async () => {
389
- const h = await harness([
390
- [createMockResponse([
391
- { type: 'text', text: '>>#alpha first' },
392
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
393
- ], 'tool_use'), refused()],
394
- [answer()],
395
- ], { toolResultGuard: true, proseRouting: 'explicit', refusalHandling: { retries: 3 } });
396
- const routed = [];
397
- const streamed = [];
398
- h.framework.channelRegistry = new Proxy({
399
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` },
400
- routeSpeech: async (_agent, speech, channelId) => {
401
- routed.push({ text: speech, channelId });
402
- return { delivered: true, channelId };
403
- },
404
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
405
- streamed.push({ channelId, delta });
406
- },
407
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
408
- getDescriptor: () => undefined, getChannelTools: () => [],
409
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
410
- try {
411
- await h.run();
412
- assert.equal(h.membrane.requests.length, 2, 'the guard retried once');
413
- assert.deepEqual(routed, [
414
- { text: 'first', channelId: 'chan-alpha' },
415
- { text: 'continued', channelId: 'chan-alpha' },
416
- ], 'delivery keeps the sticky destination across the retry');
417
- const toAlpha = streamed.filter((d) => d.channelId === 'chan-alpha').map((d) => d.delta).join('');
418
- assert.match(toAlpha, /continued/, 'the resumed stream follows delivery there');
419
- assert.doesNotMatch(streamed.map((d) => d.delta).join(''), /discard-this-partial-output/);
420
- }
421
- finally {
422
- await h.framework.stop();
423
- }
424
- });
425
- test('explicit mode: a budget restart streams on to the destination the turn last addressed, before delivery catches up', { timeout: 15000 }, async () => {
426
- const h = await harness([
427
- [
428
- createMockResponse([
429
- { type: 'text', text: '>>#alpha first' },
430
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
431
- ], 'tool_use'),
432
- createMockResponse([
433
- { type: 'text', text: '>>#cafe second' },
434
- { type: 'tool_use', id: 'two', name: 'test--read', input: {} },
435
- ], 'tool_use'),
436
- ],
437
- [answer()],
438
- ], { proseRouting: 'explicit', maxStreamTokens: 1000 });
439
- const routed = [];
440
- const streamed = [];
441
- // Delivery of 'first' is held until the resumed stream has written
442
- // 'continued', so '>>#cafe second' is still queued, and delivery's sticky
443
- // destination still #alpha, when the restart begins.
444
- let releaseFirst;
445
- const firstHeld = new Promise((resolve) => { releaseFirst = resolve; });
446
- let secondDelivered;
447
- const secondDone = new Promise((resolve) => { secondDelivered = resolve; });
448
- // The second tool call drops the stream budget, so the turn restarts.
449
- const handle = h.module.handleToolCall.bind(h.module);
450
- h.module.handleToolCall = async (call) => {
451
- if (call.id === 'two')
452
- h.framework.getAgent('assistant').maxStreamTokens = 1;
453
- return handle(call);
454
- };
455
- const open = h.membrane.streamYielding.bind(h.membrane);
456
- h.membrane.streamYielding = (request, options) => {
457
- const stream = open(request, options);
458
- if (h.membrane.requests.length === 2) {
459
- const iterate = stream[Symbol.asyncIterator].bind(stream);
460
- stream[Symbol.asyncIterator] = async function* () {
461
- const events = { [Symbol.asyncIterator]: iterate };
462
- for await (const event of events) {
463
- yield event;
464
- if (event.type === 'tokens' && event.content === 'continued') {
465
- releaseFirst();
466
- await secondDone;
467
- }
468
- }
469
- };
470
- }
471
- return stream;
472
- };
473
- const restarts = [];
474
- h.framework.onTrace((event) => {
475
- if (event.type === 'inference:stream_restarted')
476
- restarts.push(String(event.reason));
477
- });
478
- h.framework.channelRegistry = new Proxy({
479
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' }
480
- : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` },
481
- routeSpeech: async (_agent, text, channelId) => {
482
- routed.push({ text, channelId });
483
- if (text === 'first')
484
- await firstHeld;
485
- if (text === 'second')
486
- secondDelivered();
487
- return { delivered: true, channelId };
488
- },
489
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
490
- streamed.push({ channelId, delta });
491
- },
492
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
493
- getDescriptor: () => undefined, getChannelTools: () => [],
494
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
495
- try {
496
- await h.run();
497
- assert.equal(h.membrane.requests.length, 2);
498
- assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget');
499
- assert.deepEqual(routed, [
500
- { text: 'first', channelId: 'chan-alpha' },
501
- { text: 'second', channelId: 'chan-cafe' },
502
- { text: 'continued', channelId: 'chan-cafe' },
503
- ]);
504
- assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe'], "the second round's prefix starts a new line in the stream, as delivery reads it");
505
- assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text');
506
- assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe'], 'the resumed stream follows the destination the turn last addressed');
507
- }
508
- finally {
509
- releaseFirst();
510
- secondDelivered();
511
- await h.framework.stop();
512
- }
513
- });
514
- test("explicit mode: a private round's prefix doesn't move where a budget restart resumes", { timeout: 15000 }, async () => {
515
- // A same-round private think keeps its round's text out of delivery, so
516
- // its `>>#cafe` sets no destination: the resumed reply goes to #alpha.
517
- const privateRound = createMockResponse([
518
- { type: 'text', text: '>>#cafe private plan' },
519
- { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } },
520
- ], 'tool_use');
521
- privateRound.usage = { inputTokens: 100, outputTokens: 5 };
522
- const h = await harness([
523
- [
524
- createMockResponse([
525
- { type: 'text', text: '>>#alpha first\n' },
526
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
527
- ], 'tool_use'),
528
- privateRound,
529
- ],
530
- [answer()],
531
- ], { proseRouting: 'explicit', maxStreamTokens: 20, sameRoundThinkTextPolicy: 'private' });
532
- const routed = [];
533
- const streamed = [];
534
- const restarts = [];
535
- h.framework.onTrace((event) => {
536
- if (event.type === 'inference:stream_restarted')
537
- restarts.push(String(event.reason));
538
- });
539
- h.framework.channelRegistry = new Proxy({
540
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' }
541
- : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` },
542
- routeSpeech: async (_agent, text, channelId) => {
543
- routed.push({ text, channelId });
544
- return { delivered: true, channelId };
545
- },
546
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
547
- streamed.push({ channelId, delta });
548
- },
549
- handleChannelToolCall: async () => ({ success: true, data: { noted: true } }),
550
- getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined,
551
- getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }],
552
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
553
- try {
554
- await h.run();
555
- assert.equal(h.membrane.requests.length, 2);
556
- assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget');
557
- assert.deepEqual(routed, [
558
- { text: 'first', channelId: 'chan-alpha' },
559
- { text: 'continued', channelId: 'chan-alpha' },
560
- ], 'the private round moves no destination');
561
- assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha'], 'the resumed reply streams where it is delivered');
562
- }
563
- finally {
564
- await h.framework.stop();
565
- }
566
- });
567
- /**
568
- * A budget-restart turn whose first round's delivery is held: round one
569
- * writes `first`, round two `second` and crosses the budget, and the resumed
570
- * stream writes `continued`. `held` resolves once the resumed stream has
571
- * handed over its 'complete' and either its reply has gone out or the event
572
- * loop has turned 200 times, enough for a delivery that doesn't wait on the
573
- * held one to run; `release` lets the held delivery finish.
574
- */
575
- async function heldRestart(mode, texts) {
576
- const secondRound = createMockResponse([
577
- { type: 'text', text: texts.second },
578
- { type: 'tool_use', id: 'two', name: 'test--read', input: {} },
579
- ], 'tool_use');
580
- secondRound.usage = { inputTokens: 100, outputTokens: 5 };
581
- const h = await harness([
582
- [
583
- createMockResponse([
584
- { type: 'text', text: texts.first },
585
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
586
- ], 'tool_use'),
587
- secondRound,
588
- ],
589
- [createMockResponse([{ type: 'text', text: texts.continued }])],
590
- ], { proseRouting: mode, maxStreamTokens: 20 });
591
- const routed = [];
592
- const streamed = [];
593
- // What the registry's #cafe names; a test may rebind it while 'first' is held.
594
- const directory = { cafe: 'chan-cafe' };
595
- let release;
596
- const firstHeld = new Promise((resolve) => { release = resolve; });
597
- let resumedEnded;
598
- const ended = new Promise((resolve) => { resumedEnded = resolve; });
599
- const open = h.membrane.streamYielding.bind(h.membrane);
600
- h.membrane.streamYielding = (request, options) => {
601
- const stream = open(request, options);
602
- if (h.membrane.requests.length === 2) {
603
- const iterate = stream[Symbol.asyncIterator].bind(stream);
604
- stream[Symbol.asyncIterator] = async function* () {
605
- // The framework stops pulling once it has 'complete', so the signal
606
- // goes with that event rather than after the loop.
607
- const events = { [Symbol.asyncIterator]: iterate };
608
- for await (const event of events) {
609
- if (event.type === 'complete')
610
- resumedEnded();
611
- yield event;
612
- }
613
- };
614
- }
615
- return stream;
616
- };
617
- h.framework.channelRegistry = new Proxy({
618
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' }
619
- : spec === '#cafe' ? { channelId: directory.cafe } : { error: `no channel ${spec}` },
620
- routeSpeech: async (_agent, text, channelId) => {
621
- routed.push({ text: text.trim(), channelId });
622
- if (text.trim() === 'first')
623
- await firstHeld;
624
- return { delivered: true, channelId };
625
- },
626
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
627
- streamed.push({ channelId, delta });
628
- },
629
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
630
- getDescriptor: () => undefined, getChannelTools: () => [],
631
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
632
- const restarts = [];
633
- h.framework.onTrace((event) => {
634
- if (event.type === 'inference:stream_restarted')
635
- restarts.push(String(event.reason));
636
- });
637
- const run = h.run();
638
- const held = ended.then(async () => {
639
- for (let turn = 0; turn < 200 && !routed.some((r) => r.text === texts.continued.replace(/^>>\S+\s*/, '').trim()); turn++) {
640
- await new Promise((resolve) => setImmediate(resolve));
641
- }
642
- });
643
- return { h, run, held, release, routed, streamed, restarts, directory };
644
- }
645
- test("explicit mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => {
646
- // From a review's probe. While 'first' is still posting, '>>#cafe second'
647
- // is queued behind it, and the resumed reply waits behind both: it then
648
- // goes to #cafe, where the turn last addressed, as the stream sends it.
649
- const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: 'continued' });
650
- try {
651
- await t.held;
652
- assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held');
653
- t.release();
654
- await t.run;
655
- assert.equal(t.h.membrane.requests.length, 2);
656
- assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget');
657
- assert.deepEqual(t.routed, [
658
- { text: 'first', channelId: 'chan-alpha' },
659
- { text: 'second', channelId: 'chan-cafe' },
660
- { text: 'continued', channelId: 'chan-cafe' },
661
- ], 'the turn delivers in the order it wrote');
662
- assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']);
663
- }
664
- finally {
665
- t.release();
666
- await t.h.framework.stop();
667
- }
668
- });
669
- test("locus mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => {
670
- const t = await heldRestart('locus', { first: 'first', second: 'second', continued: 'continued' });
671
- try {
672
- await t.held;
673
- assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held');
674
- t.release();
675
- await t.run;
676
- assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget');
677
- assert.deepEqual(t.routed.map((r) => r.text), ['first', 'second', 'continued'], 'the turn delivers in the order it wrote');
678
- }
679
- finally {
680
- t.release();
681
- await t.h.framework.stop();
682
- }
683
- });
684
- /**
685
- * The registry stub for one-stream explicit turns: #alpha and #cafe resolve,
686
- * deliveries and stream chunks are recorded, and a `think` tool is offered.
687
- */
688
- function explicitRegistry(h) {
689
- const routed = [];
690
- const streamed = [];
691
- h.framework.channelRegistry = new Proxy({
692
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' }
693
- : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` },
694
- routeSpeech: async (_agent, text, channelId) => {
695
- routed.push({ text: text.trim(), channelId });
696
- return { delivered: true, channelId };
697
- },
698
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
699
- streamed.push({ channelId, delta });
700
- },
701
- handleChannelToolCall: async () => ({ success: true, data: { noted: true } }),
702
- getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined,
703
- getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }],
704
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
705
- return { routed, streamed };
706
- }
707
- test("explicit mode: within one stream, each round after a private round or >>skip_reply streams where delivery sends it", { timeout: 15000 }, async () => {
708
- // The stream follows each prefix as it's written, a private round's too.
709
- // At each round's end it returns to the turn's destination, which a private
710
- // think's >>#cafe and a >>skip_reply envelope leave at #alpha.
711
- const h = await harness([[
712
- createMockResponse([
713
- { type: 'text', text: '>>#alpha first\n' },
714
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
715
- ], 'tool_use'),
716
- createMockResponse([
717
- { type: 'text', text: '>>#cafe private plan' },
718
- { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } },
719
- ], 'tool_use'),
720
- createMockResponse([
721
- { type: 'text', text: 'middle' },
722
- { type: 'tool_use', id: 'three', name: 'test--read', input: {} },
723
- ], 'tool_use'),
724
- createMockResponse([
725
- { type: 'text', text: '>>skip_reply aside' },
726
- { type: 'tool_use', id: 'four', name: 'test--read', input: {} },
727
- ], 'tool_use'),
728
- createMockResponse([{ type: 'text', text: 'later' }]),
729
- ]], { proseRouting: 'explicit', sameRoundThinkTextPolicy: 'private' });
730
- const { routed, streamed } = explicitRegistry(h);
731
- try {
732
- await h.run();
733
- assert.deepEqual(routed, [
734
- { text: 'first', channelId: 'chan-alpha' },
735
- { text: 'middle', channelId: 'chan-alpha' },
736
- { text: 'later', channelId: 'chan-alpha' },
737
- ]);
738
- for (const text of ['middle', 'later']) {
739
- assert.deepEqual(streamed.filter((d) => d.delta.includes(text)).map((d) => d.channelId), ['chan-alpha'], `${text} streams to #alpha`);
740
- }
741
- }
742
- finally {
743
- await h.framework.stop();
744
- }
745
- });
746
- test("explicit mode: a membrane retry within a stream starts again at the turn's destination", { timeout: 15000 }, async () => {
747
- // Round one set #alpha. The membrane then refuses the next attempt and
748
- // retries it; the retried attempt's unprefixed reply is delivered to
749
- // #alpha, and streams there too.
750
- const h = await harness([[
751
- createMockResponse([
752
- { type: 'text', text: '>>#alpha first\n' },
753
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
754
- ], 'tool_use'),
755
- createMockResponse([{ type: 'text', text: 'continued' }]),
756
- ]], { proseRouting: 'explicit' });
757
- const open = h.membrane.streamYielding.bind(h.membrane);
758
- h.membrane.streamYielding = (request, options) => {
759
- const stream = open(request, options);
760
- const iterate = stream[Symbol.asyncIterator].bind(stream);
761
- stream[Symbol.asyncIterator] = async function* () {
762
- const events = { [Symbol.asyncIterator]: iterate };
763
- for await (const event of events) {
764
- if (event.type === 'tokens' && event.content === 'continued') {
765
- yield { type: 'tokens', content: 'refused partial', meta: { type: 'text', visible: true, blockIndex: 0 } };
766
- yield { type: 'retrying', attempt: 1, maxAttempts: 3 };
767
- }
768
- yield event;
769
- }
770
- };
771
- return stream;
772
- };
773
- const { routed, streamed } = explicitRegistry(h);
774
- try {
775
- await h.run();
776
- assert.deepEqual(routed, [
777
- { text: 'first', channelId: 'chan-alpha' },
778
- { text: 'continued', channelId: 'chan-alpha' },
779
- ]);
780
- assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha']);
781
- assert.equal(streamed.some((d) => d.delta.includes('refused partial')), true, 'the refused attempt had streamed, as text streams before a refusal is known');
782
- }
783
- finally {
784
- await h.framework.stop();
785
- }
786
- });
787
- test("explicit mode: a round's later text block starts a new line for the stream, as delivery joins blocks", { timeout: 15000 }, async () => {
788
- // Two text blocks in one round, the second opening with a prefix, the first
789
- // not ending with a newline. Delivery reads them joined by a newline.
790
- const h = await harness([[
791
- createMockResponse([
792
- { type: 'text', text: '>>#alpha first part' },
793
- { type: 'text', text: '>>#cafe second' },
794
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
795
- ], 'tool_use'),
796
- createMockResponse([{ type: 'text', text: 'continued' }]),
797
- ]], { proseRouting: 'explicit' });
798
- const open = h.membrane.streamYielding.bind(h.membrane);
799
- h.membrane.streamYielding = (request, options) => {
800
- const stream = open(request, options);
801
- const iterate = stream[Symbol.asyncIterator].bind(stream);
802
- stream[Symbol.asyncIterator] = async function* () {
803
- const events = { [Symbol.asyncIterator]: iterate };
804
- let split = false;
805
- for await (const event of events) {
806
- if (!split && event.type === 'tokens' && typeof event.content === 'string' && event.content.includes('>>#cafe')) {
807
- // The mock streams a round's text as one token: stream it as the
808
- // two blocks the round holds.
809
- split = true;
810
- yield { type: 'tokens', content: '>>#alpha first part', meta: { type: 'text', visible: true, blockIndex: 0 } };
811
- yield { type: 'tokens', content: '>>#cafe second', meta: { type: 'text', visible: true, blockIndex: 1 } };
812
- continue;
813
- }
814
- yield event;
815
- }
816
- };
817
- return stream;
818
- };
819
- const { routed, streamed } = explicitRegistry(h);
820
- try {
821
- await h.run();
822
- assert.deepEqual(routed.slice(0, 2), [
823
- { text: 'first part', channelId: 'chan-alpha' },
824
- { text: 'second', channelId: 'chan-cafe' },
825
- ]);
826
- assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe']);
827
- assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text');
828
- }
829
- finally {
830
- await h.framework.stop();
831
- }
832
- });
833
- test("explicit mode: a round's targets are resolved once, when it ends, for its delivery and the resumed stream alike", { timeout: 15000 }, async () => {
834
- // From a review's probe. Both rounds that name #cafe have ended, and their
835
- // deliveries wait behind 'first', when the registry's #cafe starts naming
836
- // another channel. Each goes to the channel #cafe named when its round
837
- // ended, where the stream sent it: the queued '>>#cafe second', and the
838
- // resumed '>>#cafe continued', whose text-only round ended before the wait.
839
- const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: '>>#cafe continued' });
840
- try {
841
- await t.held;
842
- t.directory.cafe = 'chan-rebound';
843
- t.release();
844
- await t.run;
845
- assert.deepEqual(t.routed, [
846
- { text: 'first', channelId: 'chan-alpha' },
847
- { text: 'second', channelId: 'chan-cafe' },
848
- { text: 'continued', channelId: 'chan-cafe' },
849
- ]);
850
- assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']);
851
- }
852
- finally {
853
- t.release();
854
- await t.h.framework.stop();
855
- }
856
- });
857
- test("a fresh turn and its restart wait behind the earlier turn's speech chain", { timeout: 15000 }, async () => {
858
- const fresh = createMockResponse([
859
- { type: 'text', text: '>>#alpha fresh' },
860
- { type: 'tool_use', id: 'fresh', name: 'test--read', input: {} },
861
- ], 'tool_use');
862
- fresh.usage = { inputTokens: 1, outputTokens: 1 };
863
- const quiet = createMockResponse([
864
- { type: 'tool_use', id: 'restart', name: 'test--read', input: {} },
865
- ], 'tool_use');
866
- quiet.usage = { inputTokens: 100, outputTokens: 5 };
867
- const reply = createMockResponse([{ type: 'text', text: 'continued' }], 'end_turn');
868
- const h = await harness([[fresh, quiet], [reply]], { proseRouting: 'explicit', maxStreamTokens: 20 });
869
- const routed = [];
870
- let release;
871
- const held = new Promise((resolve) => { release = resolve; });
872
- const routeSpeech = async (_agent, text, channelId) => {
873
- routed.push(text.trim());
874
- if (text.trim() === 'earlier')
875
- await held;
876
- return { delivered: true, channelId };
877
- };
878
- h.framework.channelRegistry = new Proxy({
879
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: 'unknown channel' },
880
- routeSpeech,
881
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
882
- getDescriptor: () => undefined, getChannelTools: () => [],
883
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
884
- const prior = routeSpeech('assistant', 'earlier', 'chan-alpha').then(() => { });
885
- h.framework
886
- .proseSpeechChains.set('assistant', prior);
887
- let resolveCompleted;
888
- const completed = new Promise((resolve) => { resolveCompleted = resolve; });
889
- const off = h.framework.onTrace((event) => {
890
- if (event.type === 'inference:completed')
891
- resolveCompleted();
892
- });
893
- const bounded = async (promise, label) => {
894
- let timer;
895
- try {
896
- await Promise.race([promise, new Promise((_, reject) => {
897
- timer = setTimeout(() => reject(new Error('Timed out waiting for ' + label)), 5000);
898
- })]);
899
- }
900
- finally {
901
- clearTimeout(timer);
902
- }
903
- };
904
- let run;
905
- try {
906
- run = h.run();
907
- void run.catch(() => { });
908
- await bounded(completed, 'the resumed inference');
909
- assert.equal(h.membrane.requests.length, 2, 'a budget restart followed the fresh stream');
910
- assert.deepEqual(routed, ['earlier'], 'both fresh-turn and resumed posts await the prior delivery');
911
- release();
912
- await bounded(run, 'the resumed turn to drain');
913
- assert.deepEqual(routed, ['earlier', 'fresh', 'continued'], 'all three posts keep their order');
914
- }
915
- finally {
916
- off();
917
- release();
918
- try {
919
- if (run)
920
- await bounded(run, 'cleanup');
921
- }
922
- finally {
923
- await h.framework.stop();
924
- }
925
- }
926
- });
927
- test("explicit: a restarted turn whose last response is empty checks for its ' !' after the earlier stream's queued delivery", { timeout: 15000 }, async () => {
928
- // Stream one's round holds its first envelope's post, with a ' !' envelope
929
- // queued behind it. The round crosses the budget, the turn restarts, and
930
- // the restart's response is empty, so nothing but the completion's own
931
- // wait orders the continuation check after that envelope.
932
- const quiet = createMockResponse([
933
- { type: 'text', text: '>>#alpha first\n>>#alpha ! second' },
934
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
935
- ], 'tool_use');
936
- quiet.usage = { inputTokens: 100, outputTokens: 5 };
937
- const empty = createMockResponse([], 'end_turn');
938
- const after = createMockResponse([{ type: 'text', text: '>>skip_reply' }], 'end_turn');
939
- const h = await harness([[quiet], [empty], [after]], { proseRouting: 'explicit', maxStreamTokens: 20 });
940
- let release;
941
- const held = new Promise((resolve) => { release = resolve; });
942
- const routed = [];
943
- h.framework.channelRegistry = new Proxy({
944
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` },
945
- resolveLocus: () => 'chan-locus',
946
- routeSpeech: async (_agent, text, channelId) => {
947
- routed.push(text);
948
- if (text.trim() === 'first')
949
- await held;
950
- return { delivered: true, channelId };
951
- },
952
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
953
- getDescriptor: () => undefined, getChannelTools: () => [],
954
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
955
- const internals = h.framework;
956
- const check = internals.requestProseContinuation.bind(h.framework);
957
- const seen = [];
958
- internals.requestProseContinuation = (agent, routing) => {
959
- seen.push(routing.continuation);
960
- check(agent, routing);
961
- };
962
- try {
963
- const run = h.run();
964
- for (const end = Date.now() + 5000; h.membrane.requests.length < 2 && Date.now() < end;) {
965
- await new Promise((resolve) => setTimeout(resolve, 5));
966
- }
967
- assert.equal(h.membrane.requests.length, 2, 'the turn restarted for its budget');
968
- await new Promise((resolve) => setTimeout(resolve, 100));
969
- assert.deepEqual(seen, [], 'no continuation check while the earlier post is held');
970
- release();
971
- await run;
972
- for (const end = Date.now() + 5000; h.membrane.requests.length < 3 && Date.now() < end;) {
973
- await new Promise((resolve) => setTimeout(resolve, 5));
974
- await h.framework.runUntilIdle();
975
- }
976
- assert.deepEqual(routed.map((text) => text.trim()), ['first', 'second']);
977
- assert.equal(seen[0], true, "the completion's check saw the ' !' the queued envelope set");
978
- assert.equal(h.membrane.requests.length, 3, 'and the continuation ran');
979
- }
980
- finally {
981
- release();
982
- await h.framework.stop();
983
- }
984
- });
985
- test("hybrid: a restarted turn whose last response is empty writes its receipt after the earlier stream's queued delivery", { timeout: 15000 }, async () => {
986
- // Stream one names #alpha, and its post is held. Its round crosses the
987
- // budget, so the turn restarts, and the restart's response is empty.
988
- const quiet = createMockResponse([
989
- { type: 'text', text: '>>>#alpha first' },
990
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
991
- ], 'tool_use');
992
- quiet.usage = { inputTokens: 100, outputTokens: 5 };
993
- const empty = createMockResponse([], 'end_turn');
994
- const h = await harness([[quiet], [empty]], { proseRouting: 'hybrid', maxStreamTokens: 20 });
995
- let release;
996
- const held = new Promise((resolve) => { release = resolve; });
997
- const routed = [];
998
- h.framework.channelRegistry = new Proxy({
999
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` },
1000
- resolveLocus: () => 'chan-locus',
1001
- routeSpeech: async (_agent, text, channelId) => {
1002
- routed.push(text);
1003
- if (text.trim() === 'first')
1004
- await held;
1005
- return { delivered: true, channelId };
1006
- },
1007
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
1008
- getDescriptor: () => undefined, getChannelTools: () => [],
1009
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
1010
- const receipts = () => h.framework.getAgent('assistant').getContextManager().getAllMessages()
1011
- .flatMap((message) => message.content)
1012
- .filter((block) => block.type === 'text' && block.text.startsWith('[delivered]'))
1013
- .map((block) => block.text);
1014
- try {
1015
- const run = h.run();
1016
- for (const end = Date.now() + 5000; h.membrane.requests.length < 2 && Date.now() < end;) {
1017
- await new Promise((resolve) => setTimeout(resolve, 5));
1018
- }
1019
- assert.equal(h.membrane.requests.length, 2, 'the turn restarted for its budget');
1020
- await new Promise((resolve) => setTimeout(resolve, 100));
1021
- assert.deepEqual(receipts(), [], 'no receipt while the earlier post is still held');
1022
- release();
1023
- await run;
1024
- assert.deepEqual(routed.map((text) => text.trim()), ['first']);
1025
- const written = receipts();
1026
- assert.equal(written.length, 1, 'one receipt for the turn');
1027
- assert.match(written[0], /chan-alpha/, 'and it names the post that was still queued');
1028
- }
1029
- finally {
1030
- release();
1031
- await h.framework.stop();
1032
- }
1033
- });
1034
- test("a fresh turn starts new routing state in every mode, and leaves the earlier turn's as it was", async () => {
1035
- for (const mode of ['explicit', 'hybrid', 'locus']) {
1036
- const h = await harness([[answer()]], { proseRouting: mode });
1037
- const internals = h.framework;
1038
- const earlierFields = () => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true,
1039
- continuation: true, deliveries: ['chan-earlier'], suppressed: 2,
1040
- triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrivals: [{ messageId: 'earlier-message', channelId: 'chan-earlier' }] });
1041
- const earlier = earlierFields();
1042
- internals.turnRouting.set('assistant', earlier);
1043
- try {
1044
- await h.run();
1045
- const now = internals.turnRouting.get('assistant');
1046
- assert.notEqual(now, earlier, `${mode}: the new turn has its own routing state`);
1047
- assert.equal(now?.pin, undefined, `${mode}: no sticky destination`);
1048
- assert.equal(now?.destination, null, `${mode}: no turn destination`);
1049
- assert.equal(now?.hybridHold, false, `${mode}: no hybrid hold`);
1050
- assert.equal(now?.continuation, false, `${mode}: no continuation`);
1051
- assert.deepEqual(now?.deliveries, [], `${mode}: no deliveries carried into the new turn`);
1052
- assert.equal(now?.suppressed, 0, `${mode}: no suppression carried into the new turn`);
1053
- assert.equal(now?.triggerChannel, undefined, `${mode}: no trigger channel carried into the new turn`);
1054
- assert.notEqual(now?.locus, 'chan-earlier', `${mode}: no locus carried into the new turn`);
1055
- assert.deepEqual([...(now?.engaged ?? [])], [], `${mode}: no engaged channels carried into the new turn`);
1056
- assert.deepEqual(now?.arrivals, [], `${mode}: no arrival carried into the new turn`);
1057
- assert.deepEqual(earlier, earlierFields(), `${mode}: all earlier-turn routing and receipt state stays with its queued prose`);
1058
- }
1059
- finally {
1060
- await h.framework.stop();
1061
- }
1062
- }
1063
- });
1064
- for (const shape of ['text-only', 'trailing']) {
1065
- test(`explicit mode: a turn's ${shape} text is admitted before a module's speech callback runs`, { timeout: 15000 }, async () => {
1066
- // From a review's probe: a speech callback that rebinds #cafe can't move
1067
- // where the turn's final text goes, since that text was admitted first.
1068
- const scripts = shape === 'text-only'
1069
- ? [[createMockResponse([{ type: 'text', text: '>>#cafe final' }])]]
1070
- : [[
1071
- createMockResponse([
1072
- { type: 'text', text: '>>#alpha first\n' },
1073
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
1074
- ], 'tool_use'),
1075
- createMockResponse([{ type: 'text', text: '>>#cafe final' }]),
1076
- ]];
1077
- const h = await harness(scripts, { proseRouting: 'explicit' });
1078
- let cafe = 'chan-cafe';
1079
- const routed = [];
1080
- const streamed = [];
1081
- h.framework.channelRegistry = new Proxy({
1082
- resolveProseTarget: (spec) => spec === '#alpha' ? { channelId: 'chan-alpha' }
1083
- : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` },
1084
- routeSpeech: async (_agent, text, channelId) => {
1085
- routed.push({ text: text.trim(), channelId });
1086
- return { delivered: true, channelId };
1087
- },
1088
- sendOutgoingChunk: (channelId, _agent, _id, _index, delta) => {
1089
- streamed.push({ channelId, delta });
1090
- },
1091
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
1092
- getDescriptor: () => undefined, getChannelTools: () => [],
1093
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
1094
- const modules = h.framework.moduleRegistry;
1095
- const dispatch = modules.dispatchSpeech.bind(modules);
1096
- modules.dispatchSpeech = async (...args) => {
1097
- cafe = 'chan-rebound';
1098
- await dispatch(...args);
1099
- };
1100
- try {
1101
- await h.run();
1102
- assert.deepEqual(routed.at(-1), { text: 'final', channelId: 'chan-cafe' });
1103
- assert.ok(streamed.some((d) => d.channelId === 'chan-cafe' && d.delta.includes('final')));
1104
- }
1105
- finally {
1106
- await h.framework.stop();
1107
- }
1108
- });
1109
- }
1110
- test('backward-compatible direct Agent inference also guards tool results', async () => {
1111
- const h = await harness([], { toolResultGuard: true });
1112
- const responses = [calls('one'), refused(), answer()];
1113
- const requests = [];
1114
- h.membrane.stream = async (request) => {
1115
- requests.push(structuredClone(request));
1116
- const response = responses.shift();
1117
- assert.ok(response);
1118
- return response;
1119
- };
1120
- try {
1121
- const agent = h.framework.getAgent('assistant');
1122
- agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
1123
- const first = await agent.runInference(h.framework.getAllTools());
1124
- assert.equal(first.toolCalls.length, 1);
1125
- agent.provideToolResult('one', { success: true, data: 'direct-original' });
1126
- const final = await agent.runInference(h.framework.getAllTools());
1127
- assert.deepEqual(final.speechContent, [{ type: 'text', text: 'continued' }]);
1128
- assert.equal(requests.length, 3);
1129
- assert.equal(final.usage?.inputTokens, 20, 'abandoned refused attempt usage is included');
1130
- assert.match(JSON.stringify(requests[1]), /direct-original/);
1131
- assert.doesNotMatch(JSON.stringify(requests[2]), /direct-original|discard-this/);
1132
- const retried = requests[2].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
1133
- assert.deepEqual(retried.map((b) => b.content), [AUDIT_STUB], 'the direct retry says where the original is too');
1134
- assert.equal(toolResults(h.framework)[0].content, AUDIT_STUB);
1135
- }
1136
- finally {
1137
- await h.framework.stop();
1138
- }
1139
- });
1140
- test('full oversized output survives withholding and reopening as a Chronicle blob', async () => {
1141
- const original = 'original-large-'.repeat(8_000) + 'end-of-original';
1142
- const h = await harness([[calls('large'), refused()], [answer()]], { toolResultGuard: true }, {
1143
- large: { success: true, data: original },
1144
- });
1145
- let originalStopped = false;
1146
- try {
1147
- await h.run();
1148
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1149
- const blobId = audit[0].originals.blobId;
1150
- assert.equal(typeof blobId, 'string');
1151
- const blob = h.framework.getStore().getBlob(blobId);
1152
- assert.equal(JSON.parse(blob.toString())[0].result.data, original, 'keep pre-truncation bytes');
1153
- await h.framework.stop();
1154
- originalStopped = true;
1155
- const restarted = await AgentFramework.create(h.base);
1156
- try {
1157
- assert.deepEqual(restarted.getStore().getBlob(blobId), blob);
1158
- assert.equal(toolResults(restarted)[0].content, AUDIT_STUB);
1159
- }
1160
- finally {
1161
- await restarted.stop();
1162
- }
1163
- }
1164
- finally {
1165
- if (!originalStopped)
1166
- await h.framework.stop();
1167
- }
1168
- });
1169
- // ---------------------------------------------------------------------------
1170
- // Review regressions (PR #159): guard effects apply only to a batch that was
1171
- // actually SUBMITTED to the provider in the current turn.
1172
- // ---------------------------------------------------------------------------
1173
- class OmitToolExchange extends PassthroughStrategy {
1174
- select(...args) {
1175
- return super.select(...args).filter((entry) => !entry.content.some((block) => block.type === 'tool_use' || block.type === 'tool_result'));
1176
- }
1177
- }
1178
- test('a staged batch omitted by compilation does not claim the refusal; autoRewind proceeds', async () => {
1179
- const h = await harness([[calls('one')], [refused()], [answer()]], {
1180
- toolResultGuard: true, maxStreamTokens: 1, strategy: new OmitToolExchange(),
1181
- refusalHandling: { autoRewind: true },
1182
- });
1183
- try {
1184
- await h.run();
1185
- const guard = h.framework.getAgent('assistant').toolResultGuard;
1186
- assert.equal(guard.hasPending, false, 'unsubmitted batch must be settled, not stranded');
1187
- assert.equal(guard.recovering, false);
1188
- assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
1189
- assert.equal(h.membrane.requests.length, 3, 'ordinary refusal rewind retry must run');
1190
- const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
1191
- assert.match(JSON.stringify(messages), /\[refusal-rewind\]/);
1192
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1193
- assert.ok(audit.some((r) => r.type === 'withheld' && r.reason === 'unsubmitted'));
1194
- }
1195
- finally {
1196
- await h.framework.stop();
1197
- }
1198
- });
1199
- test('a turn ended by an endTurn tool settles (accepts) its batch', async () => {
1200
- const h = await harness([[calls('one')]], { toolResultGuard: true }, { one: { success: true, data: 'payload-one', endTurn: true } });
1201
- try {
1202
- await h.run();
1203
- const guard = h.framework.getAgent('assistant').toolResultGuard;
1204
- assert.equal(guard.hasPending, false);
1205
- assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
1206
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1207
- assert.equal(audit.at(-1)?.type, 'accepted');
1208
- assert.equal(audit.at(-1)?.reason, 'turn_ended');
1209
- }
1210
- finally {
1211
- await h.framework.stop();
1212
- }
1213
- });
1214
- test('a refusal on the next turn after endTurn uses ordinary refusal handling', async () => {
1215
- const h = await harness([[calls('one')], [refused()], [answer()]], {
1216
- toolResultGuard: true, refusalHandling: { autoRewind: true, retries: 2 },
1217
- }, { one: { success: true, data: 'payload-one', endTurn: true } });
1218
- try {
1219
- await h.run();
1220
- await h.run();
1221
- const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
1222
- assert.match(JSON.stringify(messages), /\[refusal-rewind\]/, 'autoRewind must not be suppressed');
1223
- assert.equal(h.membrane.requests.length, 3);
1224
- }
1225
- finally {
1226
- await h.framework.stop();
1227
- }
1228
- });
1229
- test('budget restart reserves the real wire cost of the pending batch', async () => {
1230
- const big = 'x'.repeat(40_000); // ~10k tokens on the wire, notice is ~20
1231
- const budget = 14_000;
1232
- const h = await harness([[calls('one')], [answer()]], {
1233
- toolResultGuard: true, maxStreamTokens: 1, contextBudgetTokens: budget, maxTokens: 1_000,
1234
- }, { one: { success: true, data: big } });
1235
- try {
1236
- const cm = h.framework.getAgent('assistant').getContextManager();
1237
- for (let i = 0; i < 20; i++) {
1238
- cm.addMessage('user', [{ type: 'text', text: `filler-${i} ` + 'y'.repeat(2_000) }]);
1239
- }
1240
- await h.run();
1241
- const rebuilt = h.membrane.requests[1];
1242
- assert.match(JSON.stringify(rebuilt), /xxxxxxxx/, 'pending original still submitted');
1243
- const chars = rebuilt.messages.flatMap((m) => m.content).reduce((n, b) => n + JSON.stringify(b).length, 0);
1244
- assert.ok(Math.ceil(chars / 4) <= budget - 1_000, `rebuilt request ~${Math.ceil(chars / 4)} tokens exceeds budget ${budget - 1_000}`);
1245
- }
1246
- finally {
1247
- await h.framework.stop();
1248
- }
1249
- });
1250
- test('audit is synced before the pending batch is submitted', async () => {
1251
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1252
- const store = h.framework.getStore();
1253
- const sync = store.sync.bind(store);
1254
- let syncedWhilePending = false;
1255
- store.sync = () => {
1256
- if (h.framework.getAgent('assistant').toolResultGuard.hasPending)
1257
- syncedWhilePending = true;
1258
- sync();
1259
- };
1260
- let durableAtSubmit = false;
1261
- h.membrane.onSubmit = () => { durableAtSubmit = syncedWhilePending; };
1262
- try {
1263
- await h.run();
1264
- assert.equal(durableAtSubmit, true);
1265
- }
1266
- finally {
1267
- store.sync = sync;
1268
- await h.framework.stop();
1269
- }
1270
- });
1271
- test('direct API: a transient compile failure does not wedge the guard', async () => {
1272
- const h = await harness([], { toolResultGuard: true });
1273
- const responses = [calls('one'), answer()];
1274
- h.membrane.stream =
1275
- async () => responses.shift();
1276
- try {
1277
- const agent = h.framework.getAgent('assistant');
1278
- agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
1279
- await agent.runInference(h.framework.getAllTools());
1280
- agent.provideToolResult('one', { success: true, data: 'direct-original' });
1281
- const compile = agent.compileWithInjections.bind(agent);
1282
- let failed = false;
1283
- agent.compileWithInjections = async (...args) => {
1284
- if (!failed) {
1285
- failed = true;
1286
- throw new Error('transient compile failure');
1287
- }
1288
- return compile(...args);
1289
- };
1290
- await assert.rejects(agent.runInference(h.framework.getAllTools()), /transient compile failure/);
1291
- const final = await agent.runInference(h.framework.getAllTools());
1292
- assert.deepEqual(final.speechContent, [{ type: 'text', text: 'continued' }]);
1293
- assert.equal(toolResults(h.framework).length, 1, 'batch staged exactly once');
1294
- assert.match(String(toolResults(h.framework)[0].content), /direct-original/);
1295
- }
1296
- finally {
1297
- await h.framework.stop();
1298
- }
1299
- });
1300
- test('usage of an abandoned guarded round is counted in session totals', async () => {
1301
- const withUsage = (r, input) => ({ ...r, details: { ...(r.details ?? {}), usage: { inputTokens: input, outputTokens: 1 } } });
1302
- const h = await harness([[calls('one'), withUsage(refused(), 1_000)], [withUsage(answer(), 7)]], { toolResultGuard: true });
1303
- try {
1304
- await h.run();
1305
- const totals = h.framework.getSessionUsage().totals;
1306
- assert.ok(totals.inputTokens >= 1_007, `refused round usage missing: ${JSON.stringify(totals)}`);
1307
- }
1308
- finally {
1309
- await h.framework.stop();
1310
- }
1311
- });
1312
- // ---------------------------------------------------------------------------
1313
- // Greptile review regressions (PR #159, head 647f081).
1314
- // ---------------------------------------------------------------------------
1315
- function fakeRegistry(framework) {
1316
- const routed = [];
1317
- framework.channelRegistry = new Proxy({
1318
- resolveLocus: () => 'world:test',
1319
- routeSpeech: async (_agent, speech) => {
1320
- routed.push(speech);
1321
- return { delivered: true, channelId: 'world:test' };
1322
- },
1323
- sendOutgoingChunk: () => { },
1324
- getDefaultPublishChannel: () => null, isChannelOpen: () => true,
1325
- getDescriptor: () => undefined, getChannelTools: () => [],
1326
- }, { get: (target, key) => key in target ? target[key] : () => undefined });
1327
- return routed;
1328
- }
1329
- test('failed audit sync fails closed: originals are never submitted', async () => {
1330
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1331
- const store = h.framework.getStore();
1332
- const sync = store.sync.bind(store);
1333
- store.sync = () => {
1334
- if (h.framework.getAgent('assistant').toolResultGuard.hasPending)
1335
- throw new Error('disk full');
1336
- sync();
1337
- };
1338
- try {
1339
- await h.run();
1340
- const provided = JSON.stringify(h.membrane.streams[0].receivedToolResults);
1341
- assert.doesNotMatch(provided, /payload-one/, 'non-durable audit must not release originals');
1342
- assert.match(provided, /Tool result withheld by the guard/);
1343
- assert.equal(toolResults(h.framework)[0].content, UNSAVED_STUB);
1344
- const guard = h.framework.getAgent('assistant').toolResultGuard;
1345
- assert.equal(guard.hasPending, false);
1346
- }
1347
- finally {
1348
- store.sync = sync;
1349
- await h.framework.stop();
1350
- }
1351
- });
1352
- test('an aborted stream settles its submitted batch; the next turn neither resubmits nor claims it', async () => {
1353
- const h = await harness([[calls('one')], [refused()], [answer()]], {
1354
- toolResultGuard: true, refusalHandling: { autoRewind: true },
1355
- });
1356
- h.membrane.onSubmit = () => {
1357
- const stream = h.membrane.streams.at(-1);
1358
- queueMicrotask(() => stream.cancel());
1359
- };
1360
- try {
1361
- await h.run();
1362
- const guard = h.framework.getAgent('assistant').toolResultGuard;
1363
- assert.equal(guard.hasPending, false, 'aborted batch must not stay pending');
1364
- h.membrane.onSubmit = undefined;
1365
- await h.run();
1366
- assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/, 'old originals must not be resubmitted');
1367
- const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
1368
- assert.match(JSON.stringify(messages), /\[refusal-rewind\]/, 'ordinary autoRewind must run');
1369
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1370
- assert.ok(audit.some((r) => r.type === 'withheld' && r.reason === 'aborted'));
1371
- }
1372
- finally {
1373
- await h.framework.stop();
1374
- }
1375
- });
1376
- test('guard recovery keeps same-turn explicit-send suppression', async () => {
1377
- const h = await harness([[createMockResponse([
1378
- { type: 'tool_use', id: 'send', name: 'test--send_message', input: {} },
1379
- { type: 'tool_use', id: 'one', name: 'test--read', input: {} },
1380
- ], 'tool_use'), refused()], [createMockResponse([{ type: 'text', text: 'postscript' }])]], { toolResultGuard: true });
1381
- const routed = fakeRegistry(h.framework);
1382
- try {
1383
- await h.run();
1384
- assert.equal(h.membrane.requests.length, 2);
1385
- assert.ok(!routed.some((text) => text.includes('postscript')), `postscript routed: ${JSON.stringify(routed)}`);
1386
- }
1387
- finally {
1388
- await h.framework.stop();
1389
- }
1390
- });
1391
- test('a failed audit append while settling an aborted batch does not block teardown', async () => {
1392
- const h = await harness([[calls('one')], [answer()]], { toolResultGuard: true });
1393
- const store = h.framework.getStore();
1394
- const append = store.appendToStateJson.bind(store);
1395
- let failAborted = true;
1396
- store.appendToStateJson = (id, value) => {
1397
- if (failAborted && value?.reason === 'aborted')
1398
- throw new Error('storage down');
1399
- return append(id, value);
1400
- };
1401
- h.membrane.onSubmit = () => {
1402
- const stream = h.membrane.streams.at(-1);
1403
- queueMicrotask(() => stream.cancel());
1404
- };
1405
- try {
1406
- await h.run().catch(() => { });
1407
- assert.equal(h.framework.getAgent('assistant').toolResultGuard.hasPending, false);
1408
- failAborted = false;
1409
- h.membrane.onSubmit = undefined;
1410
- await h.run();
1411
- assert.equal(h.membrane.requests.length, 2, 'agent must accept a later turn after teardown');
1412
- assert.deepEqual(h.module.speeches.at(-1), 'continued');
1413
- }
1414
- finally {
1415
- store.appendToStateJson = append;
1416
- await h.framework.stop();
1417
- }
1418
- });
1419
- test('an aborted outcome that failed to append is recorded on the next guard write or stop', async () => {
1420
- const h = await harness([[calls('one')], [calls('two'), answer()]], { toolResultGuard: true });
1421
- const store = h.framework.getStore();
1422
- const append = store.appendToStateJson.bind(store);
1423
- let failAborted = true;
1424
- store.appendToStateJson = (id, value) => {
1425
- if (failAborted && value?.reason === 'aborted')
1426
- throw new Error('storage down');
1427
- return append(id, value);
1428
- };
1429
- h.membrane.onSubmit = () => {
1430
- const stream = h.membrane.streams.at(-1);
1431
- queueMicrotask(() => stream.cancel());
1432
- };
1433
- try {
1434
- await h.run().catch(() => { });
1435
- failAborted = false;
1436
- h.membrane.onSubmit = undefined;
1437
- await h.run();
1438
- const audit = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1439
- const first = audit[0].batchId;
1440
- const outcome = audit.findIndex((r) => r.batchId === first && r.type === 'withheld' && r.reason === 'aborted');
1441
- assert.ok(outcome > 0, 'lost aborted outcome must be retried');
1442
- const nextStaged = audit.findIndex((r, i) => i > 0 && r.type === 'staged');
1443
- assert.ok(outcome < nextStaged, 'retried outcome lands before the next batch');
1444
- }
1445
- finally {
1446
- store.appendToStateJson = append;
1447
- await h.framework.stop();
1448
- }
1449
- });
1450
- test('a lost aborted outcome is flushed at stop', async () => {
1451
- const h = await harness([[calls('one')]], { toolResultGuard: true });
1452
- const store = h.framework.getStore();
1453
- const append = store.appendToStateJson.bind(store);
1454
- let failAborted = true;
1455
- store.appendToStateJson = (id, value) => {
1456
- if (failAborted && value?.reason === 'aborted')
1457
- throw new Error('storage down');
1458
- return append(id, value);
1459
- };
1460
- h.membrane.onSubmit = () => {
1461
- const stream = h.membrane.streams.at(-1);
1462
- queueMicrotask(() => stream.cancel());
1463
- };
1464
- let stopped = false;
1465
- try {
1466
- await h.run().catch(() => { });
1467
- failAborted = false;
1468
- const written = [];
1469
- store.appendToStateJson = (id, value) => {
1470
- if (id === TOOL_RESULT_GUARD_AUDIT_STATE)
1471
- written.push(value);
1472
- return append(id, value);
1473
- };
1474
- await h.framework.stop();
1475
- stopped = true;
1476
- assert.ok(written.some((r) => r.type === 'withheld' && r.reason === 'aborted'), 'outcome flushed at stop');
1477
- }
1478
- finally {
1479
- if (!stopped)
1480
- await h.framework.stop();
1481
- }
1482
- });
1483
- // ---------------------------------------------------------------------------
1484
- // Compression holds (CM 0.12): real Autobiographical compression with the
1485
- // guard on (Codex 09-26 P1 repro / review finding #3).
1486
- // ---------------------------------------------------------------------------
1487
- async function compressionRig() {
1488
- const { ContextManager, AutobiographicalStrategy } = await import('@animalabs/context-manager');
1489
- const { ToolResultGuard } = await import('../src/tool-result-guard.js');
1490
- const dir = mkdtempSync(join(tmpdir(), 'af-guard-compression-'));
1491
- dirs.push(dir);
1492
- const prompts = [];
1493
- const strategy = new AutobiographicalStrategy({
1494
- compressionModel: 'test-compression-model', targetChunkTokens: 50, headWindowTokens: 0,
1495
- recentWindowTokens: 0, autoTickOnNewMessage: false, minChunkCharsForLLM: 0, l1HoldbackChunks: 0,
1496
- });
1497
- const cm = await ContextManager.open({ path: join(dir, 'store'), strategy, membrane: {
1498
- complete: async (req) => {
1499
- prompts.push(JSON.stringify(req));
1500
- return { stopReason: 'end_turn', content: [{ type: 'text', text: `Summary #${prompts.length}` }] };
1501
- },
1502
- } });
1503
- cm.setToolDefinitions([{ name: 'search', description: 'search', inputSchema: { type: 'object', properties: {} } }]);
1504
- const filler = (n) => 'word '.repeat(n);
1505
- for (let i = 0; i < 6; i++)
1506
- cm.addMessage(i % 2 === 0 ? 'User' : 'Claude', [{ type: 'text', text: filler(30) }]);
1507
- cm.addMessage('Claude', [{ type: 'text', text: filler(20) }, { type: 'tool_use', id: 'tu-1', name: 'search', input: {} }]);
1508
- const guard = new ToolResultGuard('Claude', cm, true);
1509
- const REAL = 'REAL-ACCEPTED-TOOL-OUTPUT';
1510
- const content = [{ type: 'tool_result', toolUseId: 'tu-1', content: REAL }];
1511
- const messageId = guard.storeResults(content, [{ toolUseId: 'tu-1', content: REAL }], [], undefined, undefined);
1512
- // Deferred messages flushed right behind the staged batch push it out of
1513
- // the protected tail.
1514
- for (let i = 0; i < 6; i++)
1515
- cm.addMessage(i % 2 === 0 ? 'Claude' : 'User', [{ type: 'text', text: filler(30) }]);
1516
- const drain = async () => {
1517
- const s = strategy;
1518
- await cm.compile();
1519
- let n = 0;
1520
- while (s.compressionQueue.length > 0 && n++ < 50)
1521
- await cm.tick();
1522
- };
1523
- return { cm, guard, prompts, drain, messageId, REAL };
1524
- }
1525
- test('real compression: a pending batch is held; accepted output reaches memory, placeholder never does', async () => {
1526
- const r = await compressionRig();
1527
- try {
1528
- assert.deepEqual([...r.cm.getCompressionHolds()], [r.messageId], 'staged with a compression hold');
1529
- await r.drain();
1530
- assert.ok(r.prompts.length > 0, 'history before the hold still compresses');
1531
- assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder summarized while pending');
1532
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
1533
- r.guard.accept();
1534
- assert.equal(r.cm.getCompressionHolds().size, 0, 'accept releases the hold');
1535
- await r.drain();
1536
- assert.ok(r.prompts.some((p) => p.includes(r.REAL)), 'accepted output must reach the compression request');
1537
- assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder must never be summarized');
1538
- }
1539
- finally {
1540
- r.cm.close();
1541
- }
1542
- });
1543
- test('real compression: a withheld batch releases its hold and the placeholder compresses normally', async () => {
1544
- const r = await compressionRig();
1545
- try {
1546
- await r.drain();
1547
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
1548
- assert.ok(r.guard.withhold('test-category'));
1549
- assert.deepEqual([...r.cm.getCompressionHolds()], [r.messageId], 'held until the stub says where the original is');
1550
- await r.guard.whenAnnotated();
1551
- assert.equal(r.cm.getCompressionHolds().size, 0, 'withhold releases the hold once annotated');
1552
- await r.drain();
1553
- assert.ok(r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder compresses after release');
1554
- assert.ok(!r.prompts.some((p) => p.includes(r.REAL)), 'withheld output never reaches memory');
1555
- }
1556
- finally {
1557
- r.cm.close();
1558
- }
1559
- });
1560
- test('every settlement path releases the hold, even when the audit write fails', async () => {
1561
- const paths = [
1562
- ['accept-submitted', (g) => { g.submissionResults([{ toolUseId: 'tu-1', content: 'x' }]); g.accept(); }],
1563
- ['accept-unsubmitted', (g) => g.accept()],
1564
- ['withhold', (g) => { g.submissionResults([{ toolUseId: 'tu-1', content: 'x' }]); g.withhold('c'); }],
1565
- ['turn-ended', (g) => g.settleTurnEnded()],
1566
- ['unsubmitted', (g) => g.settleUnsubmitted('c')],
1567
- ['abandon', (g) => g.abandon('aborted')],
1568
- ];
1569
- for (const [name, settle] of paths) {
1570
- const r = await compressionRig();
1571
- try {
1572
- const store = r.cm.getStore();
1573
- store.appendToStateJson = () => { throw new Error('storage down'); };
1574
- try {
1575
- settle(r.guard);
1576
- }
1577
- catch { /* audit failure surfaces; hold must still go */ }
1578
- await r.guard.whenAnnotated();
1579
- assert.equal(r.guard.hasPending, false, `${name}: batch cleared`);
1580
- assert.equal(r.cm.getCompressionHolds().size, 0, `${name}: hold released`);
1581
- }
1582
- finally {
1583
- r.cm.close();
1584
- }
1585
- }
1586
- });
1587
- for (const failedType of ['staged', 'linked']) {
1588
- test(`storage recovery: failed ${failedType} append continues with placeholders and releases the hold`, async () => {
1589
- const h = await harness([[calls('one'), answer()], [answer()]], { toolResultGuard: true });
1590
- const store = h.framework.getStore();
1591
- const append = store.appendToStateJson.bind(store);
1592
- let failing = true;
1593
- store.appendToStateJson = (id, value) => {
1594
- if (failing && id === TOOL_RESULT_GUARD_AUDIT_STATE && value?.type === failedType) {
1595
- throw new Error(`injected ${failedType} append failure`);
1596
- }
1597
- return append(id, value);
1598
- };
1599
- try {
1600
- await assert.doesNotReject(h.run(), 'storage failure must not strand the live tool loop');
1601
- const agent = h.framework.getAgent('assistant');
1602
- assert.equal(String(agent.state.status), 'idle');
1603
- assert.equal(agent.toolResultGuard.hasPending, false);
1604
- assert.equal(agent.getContextManager().getCompressionHolds().size, 0);
1605
- assert.match(JSON.stringify(h.membrane.streams[0].receivedToolResults), /Tool result withheld/);
1606
- assert.doesNotMatch(JSON.stringify(h.membrane.streams[0].receivedToolResults), /payload-one/);
1607
- failing = false;
1608
- await h.run(); // A normal activation, with no new tool call, retries the audit.
1609
- const records = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1610
- assert.deepEqual(records.map((r) => r.type), ['staged', 'linked', 'withheld', 'annotated', 'stub_updated']);
1611
- assert.match(JSON.stringify(records[0]), /payload-one/, 'the original is recoverable after storage returns');
1612
- assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
1613
- assert.deepEqual(h.module.calls, ['one']);
1614
- }
1615
- finally {
1616
- store.appendToStateJson = append;
1617
- for (const stream of h.membrane.streams)
1618
- stream.cancel();
1619
- await h.framework.stop();
1620
- }
1621
- });
1622
- }
1623
- test('storage recovery: failed accepted edit is retried before the next activation without rerunning tools', async () => {
1624
- const h = await harness([[calls('one'), answer()], [answer()]], { toolResultGuard: true });
1625
- const agent = h.framework.getAgent('assistant');
1626
- const cm = agent.getContextManager();
1627
- const edit = cm.editMessage.bind(cm);
1628
- let failing = true;
1629
- cm.editMessage = (id, content) => {
1630
- if (failing && JSON.stringify(content).includes('payload-one'))
1631
- throw new Error('injected edit failure');
1632
- edit(id, content);
1633
- };
1634
- try {
1635
- await h.run();
1636
- assert.equal(String(agent.state.status), 'idle');
1637
- assert.equal(agent.toolResultGuard.hasPending, false, 'an accepted batch cannot claim another refusal');
1638
- assert.equal(cm.getCompressionHolds().size, 1, 'keep provisional history out of compression until repaired');
1639
- assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
1640
- assert.throws(() => agent.toolResultGuard.flushUnrecorded(), /injected edit failure/);
1641
- const busy = h.framework.busyGuards;
1642
- assert.equal(busy.has(agent.toolResultGuard), true, 'a queued acceptance edit keeps the guard within reach');
1643
- failing = false;
1644
- await h.run();
1645
- assert.match(JSON.stringify(h.membrane.requests[1]), /payload-one/, 'next activation must see the accepted result');
1646
- assert.equal(cm.getCompressionHolds().size, 0);
1647
- assert.equal(busy.has(agent.toolResultGuard), false, 'and the guard is let go once it lands');
1648
- const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1649
- assert.equal(records.filter((r) => r.type === 'accepted').length, 1);
1650
- assert.deepEqual(h.module.calls, ['one']);
1651
- assert.equal(h.module.speeches.at(-1), 'continued');
1652
- assert.equal(cm.getAllMessages().filter((message) => message.participant === 'assistant'
1653
- && message.content.some((block) => block.type === 'text' && block.text === 'continued')).length, 2);
1654
- }
1655
- finally {
1656
- cm.editMessage = edit;
1657
- await h.framework.stop();
1658
- }
1659
- });
1660
- test('storage recovery: failed accepted edit stays held until the real compressor can see the original', async () => {
1661
- const r = await compressionRig();
1662
- const edit = r.cm.editMessage.bind(r.cm);
1663
- r.cm.editMessage = () => { throw new Error('injected edit failure'); };
1664
- try {
1665
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
1666
- assert.doesNotThrow(() => r.guard.accept());
1667
- assert.equal(r.cm.getCompressionHolds().size, 1);
1668
- await r.drain();
1669
- assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)));
1670
- r.cm.editMessage = edit;
1671
- r.guard.flushUnrecorded();
1672
- assert.equal(r.cm.getCompressionHolds().size, 0);
1673
- await r.drain();
1674
- assert.ok(r.prompts.some((p) => p.includes(r.REAL)));
1675
- assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)));
1676
- }
1677
- finally {
1678
- r.cm.editMessage = edit;
1679
- r.cm.close();
1680
- }
1681
- });
1682
- test('storage recovery: failed accepted edit and its audit outcome are repaired at stop', async () => {
1683
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1684
- const cm = h.framework.getAgent('assistant').getContextManager();
1685
- const edit = cm.editMessage.bind(cm);
1686
- cm.editMessage = () => { throw new Error('injected edit failure'); };
1687
- let stopped = false;
1688
- try {
1689
- await h.run();
1690
- cm.editMessage = edit;
1691
- await h.framework.stop();
1692
- stopped = true;
1693
- const restarted = await AgentFramework.create(h.base);
1694
- try {
1695
- assert.match(String(toolResults(restarted)[0].content), /payload-one/);
1696
- const records = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1697
- assert.equal(records.at(-1)?.type, 'accepted');
1698
- }
1699
- finally {
1700
- await restarted.stop();
1701
- }
1702
- }
1703
- finally {
1704
- cm.editMessage = edit;
1705
- if (!stopped)
1706
- await h.framework.stop();
1707
- }
1708
- });
1709
- test('storage recovery: endTurn never admits a batch whose audit sync failed', async () => {
1710
- const h = await harness([[calls('one')], [answer()]], { toolResultGuard: true }, {
1711
- one: { success: true, data: 'payload-one', endTurn: true },
1712
- });
1713
- const agent = h.framework.getAgent('assistant');
1714
- const store = h.framework.getStore();
1715
- const sync = store.sync.bind(store);
1716
- store.sync = () => {
1717
- if (agent.toolResultGuard.hasPending)
1718
- throw new Error('injected sync failure');
1719
- sync();
1720
- };
1721
- try {
1722
- await h.run();
1723
- assert.equal(toolResults(h.framework)[0].content, UNSAVED_STUB);
1724
- assert.equal(agent.getContextManager().getCompressionHolds().size, 0);
1725
- assert.equal(agent.toolResultGuard.hasPending, false);
1726
- store.sync = sync;
1727
- await h.run();
1728
- assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
1729
- const records = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1730
- assert.deepEqual(records.slice(-3).map((r) => r.type), ['withheld', 'annotated', 'stub_updated']);
1731
- assert.match(JSON.stringify(records[0]), /payload-one/, 'the original is still archived');
1732
- }
1733
- finally {
1734
- store.sync = sync;
1735
- await h.framework.stop();
1736
- }
1737
- });
1738
- for (const supersedingAction of ['edit', 'branch']) {
1739
- test(`storage recovery: queued acceptance respects an operator ${supersedingAction}`, async () => {
1740
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1741
- const agent = h.framework.getAgent('assistant');
1742
- const cm = agent.getContextManager();
1743
- const edit = cm.editMessage.bind(cm);
1744
- cm.editMessage = () => { throw new Error('injected edit failure'); };
1745
- try {
1746
- await h.run();
1747
- cm.editMessage = edit;
1748
- const result = cm.getAllMessages().find((message) => message.content.some((block) => block.type === 'tool_result'));
1749
- if (supersedingAction === 'edit') {
1750
- cm.editMessage(result.id, [{ type: 'tool_result', toolUseId: 'one', content: 'operator replacement' }]);
1751
- }
1752
- else {
1753
- await cm.fork('operator-branch');
1754
- }
1755
- agent.toolResultGuard.flushUnrecorded();
1756
- assert.equal(toolResults(h.framework)[0].content, supersedingAction === 'edit' ? 'operator replacement' : TOOL_RESULT_GUARD_NOTICE);
1757
- assert.equal(cm.getCompressionHolds().size, 0);
1758
- const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1759
- assert.equal(records.at(-1)?.historyUpdate, 'superseded');
1760
- assert.match(JSON.stringify(records[0]), /payload-one/, 'original audit remains intact');
1761
- }
1762
- finally {
1763
- cm.editMessage = edit;
1764
- await h.framework.stop();
1765
- }
1766
- });
1767
- }
1768
- test('storage recovery: idle maintenance retries acceptance without a new activation', async () => {
1769
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1770
- const cm = h.framework.getAgent('assistant').getContextManager();
1771
- const edit = cm.editMessage.bind(cm);
1772
- cm.editMessage = () => { throw new Error('injected edit failure'); };
1773
- try {
1774
- await h.run();
1775
- assert.equal(cm.getCompressionHolds().size, 1);
1776
- cm.editMessage = edit;
1777
- h.framework.start();
1778
- await h.framework.maintenanceTick();
1779
- assert.equal(cm.getCompressionHolds().size, 0);
1780
- assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
1781
- assert.equal(h.membrane.requests.length, 1);
1782
- }
1783
- finally {
1784
- cm.editMessage = edit;
1785
- await h.framework.stop();
1786
- }
1787
- });
1788
- test('storage recovery: shutdown retries edits queued by its terminating stream', async () => {
1789
- const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
1790
- const cm = h.framework.getAgent('assistant').getContextManager();
1791
- const edit = cm.editMessage.bind(cm);
1792
- let failOnce = true;
1793
- cm.editMessage = (id, content) => {
1794
- if (failOnce) {
1795
- failOnce = false;
1796
- throw new Error('injected edit failure');
1797
- }
1798
- edit(id, content);
1799
- };
1800
- let stopping;
1801
- h.membrane.onSubmit = () => { stopping = h.framework.stop(); };
1802
- try {
1803
- await h.run();
1804
- assert.ok(stopping);
1805
- await stopping;
1806
- const restarted = await AgentFramework.create(h.base);
1807
- try {
1808
- assert.match(String(toolResults(restarted)[0].content), /payload-one/);
1809
- const records = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1810
- assert.equal(records.at(-1)?.type, 'accepted');
1811
- }
1812
- finally {
1813
- await restarted.stop();
1814
- }
1815
- }
1816
- finally {
1817
- cm.editMessage = edit;
1818
- if (stopping)
1819
- await stopping;
1820
- else
1821
- await h.framework.stop();
1822
- }
1823
- });
1824
- // ---------------------------------------------------------------------------
1825
- // agent-framework #277: a withheld result says where its original is. The
1826
- // guard's neutral notice stays (#159), followed by the place: a workspace
1827
- // file when a writable mount takes it, else the guard's audit record.
1828
- // ---------------------------------------------------------------------------
1829
- async function workspaceHarness(scripts, mount = {}) {
1830
- const dir = mkdtempSync(join(tmpdir(), 'af-tool-result-guard-ws-'));
1831
- dirs.push(dir);
1832
- const mountDir = join(dir, 'mount');
1833
- mkdirSync(mountDir, { recursive: true });
1834
- const membrane = new ScriptMembrane(scripts);
1835
- const module = new ReadModule();
1836
- const workspace = new WorkspaceModule({ mounts: [{
1837
- name: 'work', path: mountDir, mode: 'read-write', watch: 'never',
1838
- ...(mount.maxFileSize !== undefined ? { maxFileSize: mount.maxFileSize } : {}),
1839
- ...(mount.autoMaterialize !== undefined ? { autoMaterialize: mount.autoMaterialize } : {}),
1840
- }] });
1841
- const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
1842
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', toolResultGuard: true }],
1843
- modules: [module, workspace], syncIntervalMs: 0 });
1844
- workspace.initStore(framework.getStore());
1845
- const run = async () => {
1846
- framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
1847
- await framework.runUntilIdle();
1848
- };
1849
- return { framework, membrane, module, workspace, run, mountDir, storePath: join(dir, 'store') };
1850
- }
1851
- /** Delay the framework's spill of withheld originals (it then reports no
1852
- * workspace), keeping the framework's own wiring and tracking in place. */
1853
- function slowSpill(framework, wait) {
1854
- framework.spillWithheld = async () => {
1855
- await wait();
1856
- return null;
1857
- };
1858
- }
1859
- /** A gate a test opens itself, and a wait for a condition the code under test
1860
- * makes true (no fixed timers standing in for events). */
1861
- function gate() {
1862
- let open;
1863
- const opened = new Promise((resolve) => { open = resolve; });
1864
- return { opened, open };
1865
- }
1866
- async function until(check, what) {
1867
- for (let i = 0; i < 400 && !check(); i++)
1868
- await new Promise((resolve) => setTimeout(resolve, 5));
1869
- assert.ok(check(), `timed out waiting until ${what}`);
1870
- }
1871
- /** `promise`, or a failure once it has taken `ms`: a wait the code under test
1872
- * should bound fails the test rather than hanging it. */
1873
- async function bounded(promise, what, ms = 5000) {
1874
- let timer;
1875
- const late = new Promise((_, reject) => {
1876
- timer = setTimeout(() => reject(new Error(`${what} took longer than ${ms} ms`)), ms);
1877
- });
1878
- try {
1879
- return await Promise.race([promise, late]);
1880
- }
1881
- finally {
1882
- clearTimeout(timer);
1883
- }
1884
- }
1885
- /** Calls of a guard's afterAnnotations, the compile's wait for its stubs. */
1886
- function watchCompiles(framework, agentName = 'assistant') {
1887
- const guard = framework.getAgent(agentName).toolResultGuard;
1888
- const watched = { calls: 0 };
1889
- const original = guard.afterAnnotations.bind(guard);
1890
- guard.afterAnnotations = (read) => { watched.calls++; return original(read); };
1891
- return watched;
1892
- }
1893
- /** Whether stop() has reached its wait for the guards' annotations. */
1894
- function watchStopDrain(framework) {
1895
- const annotations = framework.guardAnnotations;
1896
- const watched = { reached: false };
1897
- const iterate = annotations[Symbol.iterator].bind(annotations);
1898
- annotations[Symbol.iterator] = () => { watched.reached = true; return iterate(); };
1899
- return watched;
1900
- }
1901
- const SPILLED_STUB = /^Tool result withheld by the guard\. The tool has already executed\. Its result is in workspace file (work\/tool-results\/\d{4}-\d{2}-\d{2}-withheld-[0-9a-f]{12}-0-one\.txt)\.$/;
1902
- test('#277: a withheld original is written to the workspace, and its stub names the file', async () => {
1903
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]]);
1904
- try {
1905
- await h.run();
1906
- const stub = String(toolResults(h.framework)[0].content);
1907
- const path = SPILLED_STUB.exec(stub)?.[1];
1908
- assert.ok(path, `stub names the file: ${stub}`);
1909
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1910
- const staged = audit[0].content;
1911
- const admitted = staged.find((b) => b.toolUseId === 'one')?.content;
1912
- assert.match(String(admitted), /payload-one/);
1913
- const file = await h.workspace.readBinary(path);
1914
- assert.ok('data' in file, `the file is readable: ${JSON.stringify(file)}`);
1915
- assert.equal(file.data.toString('utf8'), admitted, 'the file holds exactly what acceptance would have admitted');
1916
- const retried = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
1917
- assert.deepEqual(retried.map((b) => b.content), [stub], 'the retry already says where the original is');
1918
- assert.doesNotMatch(stub, /refus|test-category/, "#159's line: no refusal or category in the context");
1919
- assert.deepEqual(audit.at(-2), { ...audit.at(-2), type: 'annotated', results: [{ toolUseId: 'one', path }] });
1920
- assert.equal(audit.at(-1)?.type, 'stub_updated');
1921
- assert.equal(audit.at(-1)?.historyUpdate, undefined, 'the stub was edited to name the file');
1922
- }
1923
- finally {
1924
- await h.framework.stop();
1925
- }
1926
- });
1927
- test('#281: a withheld result over the inline cap is written as stored, and its own note names the whole output', async () => {
1928
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]]);
1929
- h.module.results.one = { success: true, data: `long-${'x'.repeat(400)}` };
1930
- h.framework.toolResultInlineMaxCharsConfig = 100;
1931
- try {
1932
- await h.run();
1933
- const stub = String(toolResults(h.framework)[0].content);
1934
- const path = SPILLED_STUB.exec(stub)?.[1];
1935
- assert.ok(path, `the stub names the withheld file: ${stub}`);
1936
- const withheld = await h.workspace.readBinary(path);
1937
- assert.ok('data' in withheld);
1938
- const text = withheld.data.toString('utf8');
1939
- // The data is a string, stored as its JSON: 407 characters with the quotes.
1940
- assert.match(text, /^"long-x{94}\n\n\[truncated — showing 100 of 407 chars; full content: workspace file /, 'the withheld file holds the capped text, as stored');
1941
- const whole = /full content: workspace file (work\/tool-results\/[^\s\]"]+\.txt)/.exec(text)?.[1];
1942
- assert.ok(whole && whole !== path, `its note names the file with the whole output: ${text}`);
1943
- const full = await h.workspace.readBinary(whole);
1944
- assert.ok('data' in full);
1945
- assert.match(full.data.toString('utf8'), /^"long-x{400}"$/);
1946
- assert.doesNotMatch(stub, /full result/, 'the stub says "its result", which the file holds');
1947
- }
1948
- finally {
1949
- await h.framework.stop();
1950
- }
1951
- });
1952
- test('#281: a spill that throws is recorded with its error, and the stub names the audit record', async () => {
1953
- const h = await harness([[calls('one'), refused()], [answer()]], { toolResultGuard: true });
1954
- h.framework.spillWithheld = async () => {
1955
- throw new Error('the host broke its promise');
1956
- };
1957
- const originalError = console.error;
1958
- console.error = () => { };
1959
- try {
1960
- await h.run();
1961
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1962
- const annotated = audit.find((record) => record.type === 'annotated');
1963
- assert.deepEqual(annotated?.results, [{ toolUseId: 'one', error: 'the host broke its promise' }]);
1964
- assert.equal(String(toolResults(h.framework)[0].content), AUDIT_STUB);
1965
- }
1966
- finally {
1967
- console.error = originalError;
1968
- await h.framework.stop();
1969
- }
1970
- });
1971
- test('#277: a failed workspace write says so, and the audit record still keeps the original', async () => {
1972
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]], { maxFileSize: 4 });
1973
- try {
1974
- await h.run();
1975
- const stub = String(toolResults(h.framework)[0].content);
1976
- // The write's own error stays in the audit record and the logs.
1977
- assert.match(stub, /^Tool result withheld by the guard\. The tool has already executed\. Writing its result to workspace file work\/tool-results\/\d{4}-\d{2}-\d{2}-withheld-[0-9a-f]{12}-0-one\.txt failed, so it is kept only in the guard's audit record \(framework\/tool-result-guard\), for an operator\.$/);
1978
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
1979
- const annotated = audit.at(-2);
1980
- assert.equal(annotated.type, 'annotated');
1981
- assert.equal(annotated.results[0].toolUseId, 'one');
1982
- assert.equal(typeof annotated.results[0].error, 'string');
1983
- assert.match(JSON.stringify(audit[0]), /payload-one/, 'the original is in the staged record');
1984
- }
1985
- finally {
1986
- await h.framework.stop();
1987
- }
1988
- });
1989
- test('#277: the refusal retry is compiled only once the stub says where the original is', async () => {
1990
- // The framework's retry is a new activation (startStreamWithInjections), so
1991
- // this also pins that path's wait.
1992
- const h = await harness([[calls('one'), refused()], [answer()]], { toolResultGuard: true });
1993
- const write = gate();
1994
- let spills = 0;
1995
- slowSpill(h.framework, () => { spills++; return write.opened; });
1996
- const compiles = watchCompiles(h.framework);
1997
- try {
1998
- const running = h.run();
1999
- await until(() => spills === 1 && compiles.calls >= 2, 'the retry is being compiled while the stub is written');
2000
- assert.equal(h.membrane.requests.length, 1, 'the retry waits for the stub');
2001
- write.open();
2002
- await running;
2003
- const retried = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2004
- assert.deepEqual(retried.map((b) => b.content), [AUDIT_STUB]);
2005
- }
2006
- finally {
2007
- write.open();
2008
- await h.framework.stop();
2009
- }
2010
- });
2011
- test("#277: an operator's edit made before the stub is annotated stands, and the hold waits for it", async () => {
2012
- const r = await compressionRig();
2013
- try {
2014
- let release;
2015
- const gate = new Promise((resolve) => { release = resolve; });
2016
- let entered = false;
2017
- r.guard.setHost({ deadline: () => Infinity, spill: async () => { entered = true; await gate; return null; }, track: () => { }, idle: () => { } });
2018
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
2019
- assert.ok(r.guard.withhold('test-category'));
2020
- for (let i = 0; i < 100 && !entered; i++)
2021
- await new Promise((resolve) => setTimeout(resolve, 10));
2022
- assert.ok(entered, 'the original is being written');
2023
- assert.deepEqual([...r.cm.getCompressionHolds()], [r.messageId], 'held while the original is being written');
2024
- r.cm.editMessage(r.messageId, [{ type: 'tool_result', toolUseId: 'tu-1', content: 'operator replacement' }]);
2025
- release();
2026
- await r.guard.whenAnnotated();
2027
- assert.equal(r.cm.getMessage(r.messageId).content[0].content, 'operator replacement');
2028
- assert.equal(r.cm.getCompressionHolds().size, 0);
2029
- const audit = r.cm.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2030
- assert.equal(audit.at(-2)?.type, 'annotated');
2031
- assert.equal(audit.at(-1)?.type, 'stub_updated');
2032
- assert.equal(audit.at(-1)?.historyUpdate, 'superseded');
2033
- }
2034
- finally {
2035
- r.cm.close();
2036
- }
2037
- });
2038
- test('#277: stop lets an aborted batch\'s stub be annotated before the store closes', async () => {
2039
- const h = await harness([[calls('one')]], { toolResultGuard: true });
2040
- const write = gate();
2041
- let spills = 0;
2042
- slowSpill(h.framework, () => { spills++; return write.opened; });
2043
- const drain = watchStopDrain(h.framework);
2044
- let modulesStopped = false;
2045
- const stopModule = h.module.stop.bind(h.module);
2046
- h.module.stop = async () => { modulesStopped = true; await stopModule(); };
2047
- h.membrane.onSubmit = () => {
2048
- const stream = h.membrane.streams.at(-1);
2049
- queueMicrotask(() => stream.cancel());
2050
- };
2051
- let stopped = false;
2052
- try {
2053
- await h.run().catch(() => { });
2054
- await until(() => spills === 1, 'the aborted batch is being annotated');
2055
- const stopping = h.framework.stop().then(() => { stopped = true; });
2056
- await until(() => drain.reached, 'stop waits for the annotation');
2057
- assert.equal(modulesStopped, false, 'modules and the store wait for the annotation');
2058
- assert.equal(stopped, false);
2059
- write.open();
2060
- await stopping;
2061
- assert.equal(modulesStopped, true);
2062
- const restarted = await AgentFramework.create(h.base);
2063
- try {
2064
- assert.equal(toolResults(restarted)[0].content, AUDIT_STUB, 'the annotated stub was saved before close');
2065
- }
2066
- finally {
2067
- await restarted.stop();
2068
- }
2069
- }
2070
- finally {
2071
- write.open();
2072
- if (!stopped)
2073
- await h.framework.stop();
2074
- }
2075
- });
2076
- test('#277: the stub has the same text for each place the original can be', () => {
2077
- const notice = 'Tool result withheld by the guard. The tool has already executed.';
2078
- const audit = "the guard's audit record (framework/tool-result-guard), for an operator";
2079
- const unsaved = `${audit}, though saving that record had failed when the result arrived`;
2080
- assert.equal(withheldResultNotice({ path: 'work/tool-results/2026-10-10-withheld-a.txt' }, true), `${notice} Its result is in workspace file work/tool-results/2026-10-10-withheld-a.txt.`);
2081
- assert.equal(withheldResultNotice({ path: 'work/tool-results/2026-10-10-withheld-a.txt' }, false), `${notice} Its result is in workspace file work/tool-results/2026-10-10-withheld-a.txt.`);
2082
- // A failed write's own error text isn't the model's to read.
2083
- assert.equal(withheldResultNotice({ path: 'work/tool-results/x.txt', error: 'EACCES: /srv/host/secret' }, true), `${notice} Writing its result to workspace file work/tool-results/x.txt failed, so it is kept only in ${audit}.`);
2084
- // The audit record is said to keep it only when that record was saved.
2085
- assert.equal(withheldResultNotice({ path: 'work/tool-results/x.txt', error: 'disk full' }, false), `${notice} Writing its result to workspace file work/tool-results/x.txt failed, so it went only to ${unsaved}.`);
2086
- // A write still under way when the host stopped waiting.
2087
- assert.equal(withheldResultNotice({ path: 'work/tool-results/y.txt', unfinished: true }, true), `${notice} Its result was still being written to workspace file work/tool-results/y.txt when this note ` +
2088
- `was made; if that file is missing, it is kept in ${audit}.`);
2089
- assert.equal(withheldResultNotice({ path: 'work/tool-results/y.txt', unfinished: true }, false), `${notice} Its result was still being written to workspace file work/tool-results/y.txt when this note ` +
2090
- `was made; if that file is missing, it went to ${unsaved}.`);
2091
- assert.equal(withheldResultNotice(null, true), `${notice} Its result is kept in ${audit}.`);
2092
- assert.equal(withheldResultNotice(null, false), `${notice} Its result went to ${unsaved}.`);
2093
- assert.equal(withheldResultNotice(null, true), AUDIT_STUB);
2094
- assert.equal(withheldResultNotice(null, false), UNSAVED_STUB);
2095
- // A skipped write reads as no write at all.
2096
- for (const skipped of ['workspace_unanswered', 'branch_changed']) {
2097
- assert.equal(withheldResultNotice({ skipped }, true), AUDIT_STUB);
2098
- assert.equal(withheldResultNotice({ skipped }, false), UNSAVED_STUB);
2099
- }
2100
- });
2101
- test('#277: direct API: a request after an aborted batch is compiled once its stub is annotated', async () => {
2102
- const h = await harness([], { toolResultGuard: true });
2103
- const responses = [calls('one'), { aborted: true, reason: 'test-abort' }, answer()];
2104
- const requests = [];
2105
- h.membrane.stream = async (request) => {
2106
- requests.push(structuredClone(request));
2107
- const response = responses.shift();
2108
- assert.ok(response);
2109
- return response;
2110
- };
2111
- const write = gate();
2112
- let spills = 0;
2113
- try {
2114
- const agent = h.framework.getAgent('assistant');
2115
- slowSpill(h.framework, () => { spills++; return write.opened; });
2116
- const compiles = watchCompiles(h.framework);
2117
- agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
2118
- await agent.runInference(h.framework.getAllTools());
2119
- agent.provideToolResult('one', { success: true, data: 'direct-original' });
2120
- const aborted = await agent.runInference(h.framework.getAllTools());
2121
- assert.equal(aborted.aborted, true);
2122
- await until(() => spills === 1, 'the aborted batch is being annotated');
2123
- const before = compiles.calls;
2124
- const next = agent.runInference(h.framework.getAllTools());
2125
- await until(() => compiles.calls > before, 'the next request is being compiled while the stub is written');
2126
- assert.equal(requests.length, 2, 'the next request waits for the stub');
2127
- write.open();
2128
- await next;
2129
- const results = requests[2].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2130
- assert.deepEqual(results.map((b) => b.content), [AUDIT_STUB]);
2131
- }
2132
- finally {
2133
- write.open();
2134
- await h.framework.stop();
2135
- }
2136
- });
2137
- test('#277: stop waits for an annotation started by an ephemeral run it has already disposed', async () => {
2138
- const h = await harness([[calls('one')]]);
2139
- const write = gate();
2140
- let spills = 0;
2141
- slowSpill(h.framework, () => { spills++; return write.opened; });
2142
- const drain = watchStopDrain(h.framework);
2143
- let modulesStopped = false;
2144
- const stopModule = h.module.stop.bind(h.module);
2145
- h.module.stop = async () => { modulesStopped = true; await stopModule(); };
2146
- h.membrane.onSubmit = () => {
2147
- const stream = h.membrane.streams.at(-1);
2148
- queueMicrotask(() => stream.cancel());
2149
- };
2150
- let stopped = false;
2151
- try {
2152
- const created = await h.framework.createEphemeralAgent({
2153
- name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
2154
- });
2155
- created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
2156
- const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
2157
- h.framework.start();
2158
- await completion.catch(() => { });
2159
- await until(() => spills === 1, "the ephemeral's aborted batch is being annotated");
2160
- assert.equal(h.framework.getAgent('ephemeral') ?? null, null, 'the run was disposed');
2161
- const stopping = h.framework.stop().then(() => { stopped = true; });
2162
- await until(() => drain.reached, 'stop waits for the annotation');
2163
- assert.equal(modulesStopped, false, 'modules and the store wait for the annotation');
2164
- write.open();
2165
- await stopping;
2166
- const restarted = await AgentFramework.create(h.base);
2167
- try {
2168
- const audit = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2169
- assert.ok(audit.some((r) => r.type === 'annotated' && r.agentName === 'ephemeral'), 'the annotation finished before the store closed');
2170
- }
2171
- finally {
2172
- await restarted.stop();
2173
- }
2174
- }
2175
- finally {
2176
- write.open();
2177
- if (!stopped)
2178
- await h.framework.stop();
2179
- }
2180
- });
2181
- // ---------------------------------------------------------------------------
2182
- // #281's review: each withheld result's file is its own, a branch change stops
2183
- // the annotation's writes, and the direct API's refusal retry fits the budget.
2184
- // ---------------------------------------------------------------------------
2185
- /** The file a written stub names, whatever the naming. */
2186
- const stubPath = (stub) => /workspace file (\S+\.txt)\.$/.exec(stub)?.[1];
2187
- test('#277: a tool use id seen again gets a file of its own, and each stub reads back its own original', async () => {
2188
- const h = await workspaceHarness([[calls('one'), refused()], [answer()], [calls('one'), refused()], [answer()]]);
2189
- try {
2190
- h.module.results.one = { success: true, data: 'first-original' };
2191
- await h.run();
2192
- h.module.results.one = { success: true, data: 'second-original' };
2193
- await h.run();
2194
- const stubs = toolResults(h.framework).map((block) => String(block.content));
2195
- const paths = stubs.map(stubPath);
2196
- assert.equal(paths.length, 2);
2197
- assert.ok(paths[0] && paths[1] && paths[0] !== paths[1], `each stub names a file of its own: ${JSON.stringify(stubs)}`);
2198
- for (const [index, expected] of ['first-original', 'second-original'].entries()) {
2199
- const file = await h.workspace.readBinary(paths[index]);
2200
- assert.ok('data' in file, `the file is readable: ${JSON.stringify(file)}`);
2201
- assert.match(file.data.toString('utf8'), new RegExp(expected), `stub ${index} reads back its own original`);
2202
- }
2203
- }
2204
- finally {
2205
- await h.framework.stop();
2206
- }
2207
- });
2208
- test("#277: results whose ids the writer's cut makes alike still get a file each", async () => {
2209
- // The writer keeps 80 characters of a label; these ids agree in their first 75.
2210
- const shared = 'call_' + 'a'.repeat(70);
2211
- const ids = [`${shared}-1`, `${shared}-2`];
2212
- const h = await workspaceHarness([[calls(...ids), refused()], [answer()]]);
2213
- try {
2214
- await h.run();
2215
- const stubs = toolResults(h.framework).map((block) => String(block.content));
2216
- const paths = stubs.map(stubPath);
2217
- assert.ok(paths[0] && paths[1] && paths[0] !== paths[1], `each stub names a file of its own: ${JSON.stringify(stubs)}`);
2218
- for (const [index, id] of ids.entries()) {
2219
- const file = await h.workspace.readBinary(paths[index]);
2220
- assert.ok('data' in file, `the file is readable: ${JSON.stringify(file)}`);
2221
- assert.match(file.data.toString('utf8'), new RegExp(`payload-${id}`), `stub ${index} reads back its own original`);
2222
- }
2223
- }
2224
- finally {
2225
- await h.framework.stop();
2226
- }
2227
- });
2228
- test('#277: a rollback while an aborted batch is being annotated gets none of its files, and its stub stays as it was', async () => {
2229
- const h = await harness([[calls('one', 'two')]], { toolResultGuard: true });
2230
- let release;
2231
- const gate = new Promise((resolve) => { release = resolve; });
2232
- const labels = [];
2233
- // The annotation is held after its first write, where a rollback can land
2234
- // between two writes. (A rollback during a write: the next test.)
2235
- h.framework.spillWithheld = async (label) => {
2236
- labels.push(label);
2237
- if (labels.length === 1)
2238
- await gate;
2239
- return null;
2240
- };
2241
- h.membrane.onSubmit = () => {
2242
- const stream = h.membrane.streams.at(-1);
2243
- queueMicrotask(() => stream.cancel());
2244
- };
2245
- try {
2246
- await h.run().catch(() => { });
2247
- for (let i = 0; i < 100 && labels.length === 0; i++)
2248
- await new Promise((resolve) => setTimeout(resolve, 10));
2249
- assert.equal(labels.length, 1, "the aborted batch's first original is being written");
2250
- const agent = h.framework.getAgent('assistant');
2251
- const cm = agent.getContextManager();
2252
- const source = cm.currentBranch().name;
2253
- const batch = cm.getAllMessages().find((m) => m.content.some((b) => b.type === 'tool_result'));
2254
- const read = cm.getAllMessages().find((m) => m.content.some((b) => b.type === 'text' && b.text === 'read'));
2255
- const rolled = await h.framework.rollbackToMessage('assistant', { messageId: read.id });
2256
- release();
2257
- await agent.toolResultGuard.whenAnnotated();
2258
- assert.equal(labels.length, 1, 'nothing more is written once the branch has changed');
2259
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2260
- const annotated = audit.find((record) => record.type === 'annotated');
2261
- assert.ok(annotated, `the annotation is recorded on ${rolled.targetBranch}: ${JSON.stringify(audit)}`);
2262
- // The first write went to the slowed spill, which wrote nothing.
2263
- assert.deepEqual(annotated.results, [{ toolUseId: 'one', skipped: 'no_workspace_file' }, { toolUseId: 'two', skipped: 'branch_changed' }]);
2264
- assert.equal(audit.find((record) => record.type === 'stub_updated')?.historyUpdate, 'superseded');
2265
- await cm.switchBranch(source);
2266
- const kept = cm.getMessage(batch.id).content.filter((b) => b.type === 'tool_result');
2267
- assert.deepEqual(kept.map((b) => b.content), [TOOL_RESULT_GUARD_NOTICE, TOOL_RESULT_GUARD_NOTICE], 'the source branch keeps the plain notice');
2268
- }
2269
- finally {
2270
- release();
2271
- await h.framework.stop();
2272
- }
2273
- });
2274
- test('#277: a write under way when a rollback lands is refused where it commits, recorded as skipped for the branch change, and neither branch gets its file', async () => {
2275
- const h = await workspaceHarness([[calls('one', 'two')]]);
2276
- const framework = h.framework;
2277
- const write = framework.writeToolResultFile.bind(h.framework);
2278
- let release;
2279
- const gate = new Promise((resolve) => { release = resolve; });
2280
- const branches = [];
2281
- const labels = [];
2282
- // The guard's branch check and the workspace's commit run with nothing
2283
- // yielding between them. This wrapper adds a wait there, so a rollback
2284
- // lands in between, and the test pins the commit's own check, the one that
2285
- // holds should an await ever come between them. writeBinary's check alone
2286
- // is pinned by the test after this one.
2287
- framework.writeToolResultFile = async (label, text, branch) => {
2288
- branches.push(branch);
2289
- labels.push(label);
2290
- if (branches.length === 1)
2291
- await gate;
2292
- return write(label, text, branch);
2293
- };
2294
- h.membrane.onSubmit = () => {
2295
- const stream = h.membrane.streams.at(-1);
2296
- queueMicrotask(() => stream.cancel());
2297
- };
2298
- const traces = [];
2299
- h.framework.onTrace((event) => traces.push(event));
2300
- try {
2301
- await h.run().catch(() => { });
2302
- for (let i = 0; i < 100 && branches.length === 0; i++)
2303
- await new Promise((resolve) => setTimeout(resolve, 10));
2304
- assert.equal(branches.length, 1, "the aborted batch's first original is being written");
2305
- const agent = h.framework.getAgent('assistant');
2306
- const cm = agent.getContextManager();
2307
- const source = cm.currentBranch().name;
2308
- assert.equal(branches[0], source, "the write names the batch's branch");
2309
- const read = cm.getAllMessages().find((m) => m.content.some((b) => b.type === 'text' && b.text === 'read'));
2310
- await h.framework.rollbackToMessage('assistant', { messageId: read.id });
2311
- release();
2312
- await agent.toolResultGuard.whenAnnotated();
2313
- assert.equal(branches.length, 1, 'nothing more is written once the branch has changed');
2314
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2315
- const annotated = audit.find((record) => record.type === 'annotated');
2316
- assert.ok(annotated, `the annotation is recorded: ${JSON.stringify(audit)}`);
2317
- assert.deepEqual(annotated.results, [{ toolUseId: 'one', skipped: 'branch_changed' }, { toolUseId: 'two', skipped: 'branch_changed' }], 'the declined write names no path, since no file was written, and the next is not started');
2318
- assert.ok(!traces.some((event) => event.type === 'tool:spill_failed'), 'a declined write is not traced as a failed spill');
2319
- const relative = `tool-results/${labels[0].replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)}.txt`;
2320
- const store = h.framework.getStore();
2321
- assert.equal(store.treeGet('workspace/work/tree', relative), null, 'the branch rolled back to has no file');
2322
- await cm.switchBranch(source);
2323
- assert.equal(store.treeGet('workspace/work/tree', relative), null, "nor does the batch's own branch");
2324
- }
2325
- finally {
2326
- release();
2327
- await h.framework.stop();
2328
- }
2329
- });
2330
- class BudgetProbe extends PassthroughStrategy {
2331
- store;
2332
- select(...args) {
2333
- this.store = args[0];
2334
- return super.select(...args);
2335
- }
2336
- }
2337
- test('#277: direct API: the refusal retry fits the budget, though its stubs are longer than the originals', async () => {
2338
- const probe = new BudgetProbe();
2339
- const budget = 3_000;
2340
- const h = await harness([], { toolResultGuard: true, strategy: probe, contextBudgetTokens: budget, maxTokens: 1_000 });
2341
- const ids = Array.from({ length: 20 }, (_, i) => `t${i}`);
2342
- const responses = [calls(...ids), refused(), answer()];
2343
- const requests = [];
2344
- h.membrane.stream = async (request) => {
2345
- requests.push(structuredClone(request));
2346
- const response = responses.shift();
2347
- assert.ok(response);
2348
- return response;
2349
- };
2350
- try {
2351
- const agent = h.framework.getAgent('assistant');
2352
- const cm = agent.getContextManager();
2353
- for (let i = 0; i < 200; i++)
2354
- cm.addMessage('user', [{ type: 'text', text: `filler-${i} ` + 'y'.repeat(200) }]);
2355
- cm.addMessage('user', [{ type: 'text', text: 'read' }]);
2356
- await agent.runInference(h.framework.getAllTools());
2357
- for (const id of ids)
2358
- agent.provideToolResult(id, { success: true, data: 'x' });
2359
- await agent.runInference(h.framework.getAllTools());
2360
- assert.equal(requests.length, 3);
2361
- const tokens = (request) => request.messages.reduce((n, message) => n + probe.store.estimateTokens({ content: message.content }), 0);
2362
- const limit = budget - 1_000;
2363
- assert.ok(tokens(requests[1]) <= limit, `the refused attempt fits: ${tokens(requests[1])} <= ${limit}`);
2364
- const retried = requests[2].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2365
- assert.deepEqual(retried.map((b) => b.content), ids.map(() => AUDIT_STUB), 'the retry carries every stub');
2366
- assert.ok(tokens(requests[2]) <= limit, `the retry fits: ${tokens(requests[2])} <= ${limit}`);
2367
- }
2368
- finally {
2369
- await h.framework.stop();
2370
- }
2371
- });
2372
- test('#277: writeBinary for a branch writes there, and is refused once the workspace has left it', async () => {
2373
- const dir = mkdtempSync(join(tmpdir(), 'af-guard-write-binary-'));
2374
- dirs.push(dir);
2375
- mkdirSync(join(dir, 'mount'));
2376
- const store = JsStore.openOrCreate({ path: join(dir, 'store') });
2377
- const workspace = new WorkspaceModule({ mounts: [{ name: 'work', path: join(dir, 'mount'), mode: 'read-write', watch: 'never' }] });
2378
- workspace.initStore(store);
2379
- try {
2380
- const tree = 'workspace/work/tree';
2381
- const source = store.currentBranch().name;
2382
- const before = store.currentSequence();
2383
- const here = await workspace.writeBinary('work/tool-results/here.txt', Buffer.from('here'), 'text/plain', { branch: source });
2384
- assert.equal(here.success, true);
2385
- assert.ok(store.treeGet(tree, 'tool-results/here.txt'), 'a write for the current branch lands');
2386
- store.createBranchAt('elsewhere', source, before);
2387
- store.switchBranch('elsewhere');
2388
- const there = await workspace.writeBinary('work/tool-results/there.txt', Buffer.from('there'), 'text/plain', { branch: source });
2389
- assert.deepEqual(there, { success: false, error: 'the workspace had left the branch this file is for', isError: true,
2390
- data: { branchLeft: true } }, 'declined, and marked so, not a failed write');
2391
- assert.equal(store.treeGet(tree, 'tool-results/there.txt'), null, 'nothing lands on the branch the workspace is on');
2392
- store.switchBranch(source);
2393
- assert.equal(store.treeGet(tree, 'tool-results/there.txt'), null, 'nor on the branch the write named');
2394
- }
2395
- finally {
2396
- store.close();
2397
- }
2398
- });
2399
- // ---------------------------------------------------------------------------
2400
- // sw-af281's review: the annotation's waits are bounded and cover every
2401
- // compile, its record survives a failed stub edit, and the stub says only
2402
- // what is so.
2403
- // ---------------------------------------------------------------------------
2404
- test('#281: a write still under way at the deadline is named as unfinished, and nothing waits on it longer', async () => {
2405
- const h = await workspaceHarness([[calls('one', 'two'), refused()], [answer()]]);
2406
- h.framework.withheldSpillDeadlineMs = 50;
2407
- const stalled = gate();
2408
- let writes = 0;
2409
- h.framework.writeToolResultFile = async () => {
2410
- writes++;
2411
- await stalled.opened;
2412
- return null;
2413
- };
2414
- let stopped = false;
2415
- try {
2416
- await bounded(h.run(), 'the run, with its write stalled');
2417
- assert.equal(writes, 1, "once the workspace stopped answering, the batch's next result isn't written");
2418
- assert.equal(h.membrane.requests.length, 2, 'the retry went out without the write');
2419
- const [one, two] = toolResults(h.framework).map((block) => String(block.content));
2420
- assert.match(one, /^Tool result withheld by the guard\. The tool has already executed\. Its result was still being written to workspace file work\/tool-results\/\d{4}-\d{2}-\d{2}-withheld-[0-9a-f]{12}-0-one\.txt when this note was made; if that file is missing, it is kept in the guard's audit record \(framework\/tool-result-guard\), for an operator\.$/);
2421
- assert.equal(two, AUDIT_STUB);
2422
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2423
- const annotated = audit.find((record) => record.type === 'annotated');
2424
- assert.equal(annotated.results[0].unfinished, true);
2425
- assert.deepEqual(annotated.results[1], { toolUseId: 'two', skipped: 'workspace_unanswered' });
2426
- await bounded(h.framework.stop(), 'stop, with the write still stalled');
2427
- stopped = true;
2428
- assert.equal(writes, 1, 'stop finished with the write still stalled');
2429
- }
2430
- finally {
2431
- stalled.open();
2432
- if (!stopped)
2433
- await h.framework.stop();
2434
- }
2435
- });
2436
- test("#281: a stub edit that keeps failing leaves the record of the written file and keeps the batch's hold until it lands", async () => {
2437
- const h = await workspaceHarness([[calls('one'), refused()], [answer()], [answer()]]);
2438
- const cm = h.framework.getAgent('assistant').getContextManager();
2439
- const edit = cm.editMessage.bind(cm);
2440
- let failing = true;
2441
- let attempts = 0;
2442
- cm.editMessage = (id, content) => {
2443
- if (JSON.stringify(content).includes('Its result is in workspace file')) {
2444
- attempts++;
2445
- if (failing)
2446
- throw new Error('injected stub edit failure');
2447
- }
2448
- return edit(id, content);
2449
- };
2450
- // The write waits until the refusal retry's compile is waiting for it, so
2451
- // the stub's edit fails only where the guard handles it.
2452
- const framework = h.framework;
2453
- const writeFile = framework.writeToolResultFile.bind(h.framework);
2454
- const write = gate();
2455
- framework.writeToolResultFile = async (...args) => { await write.opened; return writeFile(...args); };
2456
- const compiles = watchCompiles(h.framework);
2457
- const audit = () => h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2458
- try {
2459
- const running = h.run();
2460
- await until(() => compiles.calls === 2, 'the refusal retry waits for the stub');
2461
- write.open();
2462
- await running;
2463
- assert.ok(attempts >= 3, `made at once, retried with the queue, and again before the retry was compiled: ${attempts}`);
2464
- const annotated = audit().find((record) => record.type === 'annotated');
2465
- const path = annotated.results[0].path;
2466
- assert.ok(path && SPILLED_STUB.test(`${TOOL_RESULT_GUARD_NOTICE} Its result is in workspace file ${path}.`), `the record names the written file though the edit failed: ${JSON.stringify(annotated)}`);
2467
- assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE, 'the stub is still the bare notice');
2468
- const retried = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2469
- assert.deepEqual(retried.map((b) => b.content), [TOOL_RESULT_GUARD_NOTICE], 'the retry went out with the store still failing');
2470
- assert.equal(audit().some((record) => record.type === 'stub_updated'), false, 'the edit is still queued');
2471
- assert.equal(cm.getCompressionHolds().size, 1, 'and still holds the batch out of summaries');
2472
- const guard = h.framework.getAgent('assistant').toolResultGuard;
2473
- const busy = h.framework.busyGuards;
2474
- assert.equal(busy.has(guard), true, 'the framework keeps the guard within reach while its edit waits');
2475
- failing = false;
2476
- await h.run();
2477
- assert.equal(toolResults(h.framework)[0].content, `${TOOL_RESULT_GUARD_NOTICE} Its result is in workspace file ${path}.`);
2478
- assert.equal(audit().at(-1)?.type, 'stub_updated');
2479
- assert.equal(audit().at(-1)?.historyUpdate, undefined);
2480
- assert.equal(cm.getCompressionHolds().size, 0, 'released once it landed');
2481
- assert.equal(busy.has(guard), false, 'and lets it go once the edit lands, with no maintenance pass');
2482
- }
2483
- finally {
2484
- write.open();
2485
- await h.framework.stop();
2486
- }
2487
- });
2488
- test('#281: a stub edit that failed is retried before the refusal retry is compiled, so the retry says where the original is', async () => {
2489
- const h = await harness([[calls('one'), refused()], [answer()]], { toolResultGuard: true });
2490
- const write = gate();
2491
- slowSpill(h.framework, () => write.opened);
2492
- const cm = h.framework.getAgent('assistant').getContextManager();
2493
- const edit = cm.editMessage.bind(cm);
2494
- let attempts = 0;
2495
- cm.editMessage = (id, content) => {
2496
- if (JSON.stringify(content).includes('Its result is kept in')) {
2497
- attempts++;
2498
- // Both tries the annotation makes fail; the next succeeds.
2499
- if (attempts <= 2)
2500
- throw new Error('injected stub edit failure');
2501
- }
2502
- return edit(id, content);
2503
- };
2504
- const compiles = watchCompiles(h.framework);
2505
- try {
2506
- const running = h.run();
2507
- await until(() => compiles.calls === 2, 'the refusal retry waits for the stub');
2508
- write.open();
2509
- await running;
2510
- assert.equal(attempts, 3);
2511
- const retried = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2512
- assert.deepEqual(retried.map((b) => b.content), [AUDIT_STUB]);
2513
- }
2514
- finally {
2515
- write.open();
2516
- await h.framework.stop();
2517
- }
2518
- });
2519
- test("#281: when the record of where each original went can't be saved at first, the stub is still edited, and the records keep their order", async () => {
2520
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]]);
2521
- const store = h.framework.getStore();
2522
- const append = store.appendToStateJson.bind(store);
2523
- let failures = 0;
2524
- store.appendToStateJson = (id, value) => {
2525
- if (id === TOOL_RESULT_GUARD_AUDIT_STATE && value?.type === 'annotated' && failures === 0) {
2526
- failures++;
2527
- throw new Error('injected annotated append failure');
2528
- }
2529
- return append(id, value);
2530
- };
2531
- try {
2532
- await h.run();
2533
- assert.equal(failures, 1);
2534
- const stub = String(toolResults(h.framework)[0].content);
2535
- const path = SPILLED_STUB.exec(stub)?.[1];
2536
- assert.ok(path, `the stub names the file: ${stub}`);
2537
- const audit = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2538
- assert.deepEqual(audit.slice(-3).map((record) => record.type), ['withheld', 'annotated', 'stub_updated']);
2539
- assert.deepEqual(audit.at(-2)?.results, [{ toolUseId: 'one', path }]);
2540
- const branch = h.framework.getAgent('assistant').getContextManager().currentBranch().name;
2541
- for (const record of audit.slice(-3))
2542
- assert.equal(record.sourceBranch, branch, `${String(record.type)} names its branch`);
2543
- }
2544
- finally {
2545
- store.appendToStateJson = append;
2546
- await h.framework.stop();
2547
- }
2548
- });
2549
- test('#281: a compile waits for an annotation that began while it waited for another', async () => {
2550
- const r = await compressionRig();
2551
- try {
2552
- const first = gate();
2553
- const second = gate();
2554
- let entered = 0;
2555
- r.guard.setHost({ deadline: () => Infinity, spill: async () => {
2556
- entered++;
2557
- await (entered === 1 ? first.opened : second.opened);
2558
- return null;
2559
- }, track: () => { }, idle: () => { } });
2560
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
2561
- assert.ok(r.guard.withhold('test-category'));
2562
- await until(() => entered === 1, 'the first annotation is writing');
2563
- const stub = (id) => r.cm.getMessage(id).content[0].content;
2564
- let secondId;
2565
- let reads = 0;
2566
- const compiled = r.guard.afterAnnotations(async () => { reads++; return [stub(r.messageId), stub(secondId)]; });
2567
- // A second batch settles while the compile waits for the first annotation.
2568
- r.cm.addMessage('Claude', [{ type: 'tool_use', id: 'tu-2', name: 'search', input: {} }]);
2569
- secondId = r.guard.storeResults([{ type: 'tool_result', toolUseId: 'tu-2', content: 'SECOND' }], [{ toolUseId: 'tu-2', content: 'SECOND' }], [], undefined, undefined);
2570
- r.guard.submissionResults([{ toolUseId: 'tu-2', content: 'SECOND' }]);
2571
- assert.ok(r.guard.withhold('test-category'));
2572
- first.open();
2573
- await until(() => entered === 2, 'the second annotation is writing');
2574
- assert.equal(reads, 0, 'the compile waits for it too');
2575
- second.open();
2576
- assert.deepEqual(await bounded(compiled, 'the compile'), [AUDIT_STUB, AUDIT_STUB]);
2577
- assert.equal(reads, 1);
2578
- }
2579
- finally {
2580
- r.cm.close();
2581
- }
2582
- });
2583
- test("#281: a disposed run's failed stub edit is retried by maintenance and at stop", async () => {
2584
- const h = await harness([[calls('one')]]);
2585
- slowSpill(h.framework, async () => { });
2586
- h.membrane.onSubmit = () => {
2587
- const stream = h.membrane.streams.at(-1);
2588
- queueMicrotask(() => stream.cancel());
2589
- };
2590
- let stopped = false;
2591
- try {
2592
- const created = await h.framework.createEphemeralAgent({
2593
- name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
2594
- });
2595
- const cm = created.contextManager;
2596
- const edit = cm.editMessage.bind(cm);
2597
- let failing = true;
2598
- let attempts = 0;
2599
- cm.editMessage = (id, content) => {
2600
- if (JSON.stringify(content).includes('Its result is kept in')) {
2601
- attempts++;
2602
- if (failing)
2603
- throw new Error('injected stub edit failure');
2604
- }
2605
- return edit(id, content);
2606
- };
2607
- created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
2608
- const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
2609
- h.framework.start();
2610
- await completion.catch(() => { });
2611
- await created.agent.toolResultGuard.whenAnnotated();
2612
- assert.equal(h.framework.getAgent('ephemeral') ?? null, null, 'the run was disposed');
2613
- assert.ok(attempts >= 2, `made at once and retried with the queue: ${attempts}`);
2614
- assert.equal(created.agent.toolResultGuard.idle, false, 'and still queued');
2615
- const before = attempts;
2616
- await h.framework.runQueuedMaintenance();
2617
- assert.ok(attempts > before, "maintenance retries the disposed run's queue");
2618
- failing = false;
2619
- await h.framework.stop();
2620
- stopped = true;
2621
- assert.equal(created.agent.toolResultGuard.idle, true, 'stop retried it, and it landed');
2622
- const restarted = await AgentFramework.create(h.base);
2623
- try {
2624
- const audit = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2625
- assert.ok(audit.some((record) => record.type === 'stub_updated' && record.agentName === 'ephemeral'), "the disposed run's stub edit landed before the store closed");
2626
- }
2627
- finally {
2628
- await restarted.stop();
2629
- }
2630
- }
2631
- finally {
2632
- if (!stopped)
2633
- await h.framework.stop();
2634
- }
2635
- });
2636
- test("#281: a disposed run's failed acceptance edit is retried by maintenance, and its guard is let go once it lands", async () => {
2637
- const h = await harness([[calls('one'), answer()]]);
2638
- const maintain = () => h.framework.runQueuedMaintenance();
2639
- const busy = h.framework.busyGuards;
2640
- try {
2641
- const created = await h.framework.createEphemeralAgent({
2642
- name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
2643
- });
2644
- const cm = created.contextManager;
2645
- const edit = cm.editMessage.bind(cm);
2646
- let failing = true;
2647
- let attempts = 0;
2648
- cm.editMessage = (id, content) => {
2649
- if (JSON.stringify(content).includes('payload-one')) {
2650
- attempts++;
2651
- if (failing)
2652
- throw new Error('injected acceptance edit failure');
2653
- }
2654
- return edit(id, content);
2655
- };
2656
- created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
2657
- const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
2658
- h.framework.start();
2659
- await bounded(completion, 'the ephemeral run');
2660
- const guard = created.agent.toolResultGuard;
2661
- assert.equal(h.framework.getAgent('ephemeral') ?? null, null, 'the run was disposed');
2662
- assert.equal(guard.idle, false, 'with its acceptance edit queued');
2663
- assert.equal(busy.has(guard), true, 'which keeps its guard within reach');
2664
- const before = attempts;
2665
- await maintain();
2666
- assert.ok(attempts > before, "maintenance retries the disposed run's acceptance edit");
2667
- assert.equal(busy.has(guard), true, 'and keeps the guard while the edit fails');
2668
- failing = false;
2669
- await maintain();
2670
- assert.equal(guard.idle, true, 'the edit landed');
2671
- assert.equal(busy.has(guard), false, 'and the guard is let go');
2672
- }
2673
- finally {
2674
- await h.framework.stop();
2675
- }
2676
- });
2677
- test('#281: batches behind one stalled write cost one deadline: no write starts until it settles', async () => {
2678
- const h = await workspaceHarness([
2679
- [calls('one'), refused()], [answer()],
2680
- [calls('two'), refused()], [answer()],
2681
- [calls('three'), refused()], [answer()],
2682
- ]);
2683
- h.framework.withheldSpillDeadlineMs = 50;
2684
- const framework = h.framework;
2685
- const writeFile = framework.writeToolResultFile.bind(h.framework);
2686
- const stalled = gate();
2687
- let writes = 0;
2688
- framework.writeToolResultFile = async (...args) => {
2689
- writes++;
2690
- if (writes === 1)
2691
- await stalled.opened;
2692
- return writeFile(...args);
2693
- };
2694
- const annotated = () => h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE)
2695
- .filter((record) => record.type === 'annotated');
2696
- try {
2697
- await bounded(h.run(), 'the first batch');
2698
- await bounded(h.run(), 'the second batch');
2699
- assert.equal(writes, 1, "no write starts while the first is still under way, for this agent's next batch either");
2700
- assert.equal(annotated()[0].results[0].unfinished, true);
2701
- assert.deepEqual(annotated()[1].results, [{ toolUseId: 'two', skipped: 'workspace_unanswered' }]);
2702
- assert.equal(toolResults(h.framework)[1].content, AUDIT_STUB);
2703
- stalled.open();
2704
- await until(() => framework.stalledSpills.size === 0, 'the stalled write settles');
2705
- await bounded(h.run(), 'the third batch');
2706
- assert.equal(writes, 2, 'once it has settled, the next original is written');
2707
- assert.ok(typeof annotated()[2].results[0].path === 'string' && annotated()[2].results[0].unfinished === undefined);
2708
- }
2709
- finally {
2710
- stalled.open();
2711
- await h.framework.stop();
2712
- }
2713
- });
2714
- test('#281: a write declined for a branch the workspace left stops the writes, and a stub edited after the branch came back names the audit record', async () => {
2715
- const h = await workspaceHarness([[calls('one', 'two'), refused()], [answer()]]);
2716
- let writes = 0;
2717
- // Declined where it commits, as when the branch changed while the write was
2718
- // under way; the store is back on the batch's branch by the time it ends,
2719
- // as after an undo and a redo.
2720
- h.framework.writeToolResultFile = async () => {
2721
- writes++;
2722
- return { path: 'work/tool-results/declined.txt', branchLeft: true };
2723
- };
2724
- try {
2725
- await bounded(h.run(), 'the run');
2726
- assert.equal(writes, 1, 'no write starts after the decline');
2727
- assert.deepEqual(toolResults(h.framework).map((block) => block.content), [AUDIT_STUB, AUDIT_STUB]);
2728
- const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2729
- const annotated = audit.find((record) => record.type === 'annotated');
2730
- assert.deepEqual(annotated.results, [
2731
- { toolUseId: 'one', skipped: 'branch_changed' },
2732
- { toolUseId: 'two', skipped: 'branch_changed' },
2733
- ]);
2734
- }
2735
- finally {
2736
- await h.framework.stop();
2737
- }
2738
- });
2739
- test('#281: stop during a compile that waits for an annotation lets no provider stream be driven after it', async () => {
2740
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]]);
2741
- const framework = h.framework;
2742
- const writeFile = framework.writeToolResultFile.bind(h.framework);
2743
- const write = gate();
2744
- framework.writeToolResultFile = async (...args) => { await write.opened; return writeFile(...args); };
2745
- const compiles = watchCompiles(h.framework);
2746
- const traces = [];
2747
- h.framework.onTrace((event) => traces.push(event.type));
2748
- let driven = 0;
2749
- const stream = h.membrane.streamYielding.bind(h.membrane);
2750
- h.membrane.streamYielding = (...args) => {
2751
- const result = stream(...args);
2752
- const iterate = result[Symbol.asyncIterator].bind(result);
2753
- result[Symbol.asyncIterator] = () => { driven++; return iterate(); };
2754
- return result;
2755
- };
2756
- let stopped = false;
2757
- try {
2758
- const running = h.run();
2759
- await until(() => compiles.calls === 2, 'the refusal retry waits for the stub');
2760
- const stopping = h.framework.stop();
2761
- write.open();
2762
- await bounded(stopping, 'stop');
2763
- stopped = true;
2764
- // Nothing is left to drive, so the run ends too: the refused stream's
2765
- // entry went with the retry it started.
2766
- await bounded(running, 'the run');
2767
- assert.equal(h.membrane.requests.length, 2, 'the retry was compiled once its stub was written');
2768
- assert.equal(driven, 1, 'only the original stream was driven; the retry sends nothing');
2769
- assert.equal(traces.includes('inference:completed'), false, 'but, compiled after stop began, it was never driven');
2770
- }
2771
- finally {
2772
- write.open();
2773
- if (!stopped)
2774
- await h.framework.stop();
2775
- }
2776
- });
2777
- test('#281: writes that stalled together each hold the workspace as unanswered until it settles', async () => {
2778
- const h = await workspaceHarness([]);
2779
- const framework = h.framework;
2780
- framework.withheldSpillDeadlineMs = 30;
2781
- const gates = new Map([['a', gate()], ['b', gate()]]);
2782
- const started = [];
2783
- framework.writeToolResultFile = async (label) => {
2784
- started.push(label);
2785
- await gates.get(label)?.opened;
2786
- return { path: `work/tool-results/${label}.txt` };
2787
- };
2788
- const branch = h.framework.getAgent('assistant').getContextManager().currentBranch().name;
2789
- try {
2790
- // Two agents' writes start together, and both outlive their deadline. The
2791
- // deadline's timer doesn't hold the process open, so the wait goes through
2792
- // bounded, whose timer does.
2793
- const [a, b] = await bounded(Promise.all([framework.spillWithheld('a', 'A', branch), framework.spillWithheld('b', 'B', branch)]), 'both writes reach their deadline');
2794
- assert.deepEqual([a, b], [{ path: 'work/tool-results/a.txt', unfinished: true }, { path: 'work/tool-results/b.txt', unfinished: true }]);
2795
- gates.get('b').open();
2796
- await until(() => h.framework.stalledSpills.size === 1, 'b settles');
2797
- assert.deepEqual(await framework.spillWithheld('c', 'C', branch), { skipped: 'workspace_unanswered' }, 'one stalled write settling leaves the other holding the workspace');
2798
- gates.get('a').open();
2799
- await until(() => h.framework.stalledSpills.size === 0, 'both settle');
2800
- assert.deepEqual(await framework.spillWithheld('d', 'D', branch), { path: 'work/tool-results/d.txt' });
2801
- assert.deepEqual(started, ['a', 'b', 'd']);
2802
- }
2803
- finally {
2804
- for (const g of gates.values())
2805
- g.open();
2806
- await h.framework.stop();
2807
- }
2808
- });
2809
- test("#281: a disposed run's guard is forgotten once its annotation ends with nothing queued, without a maintenance pass", async () => {
2810
- const h = await harness([[calls('one')]]);
2811
- slowSpill(h.framework, async () => { });
2812
- h.membrane.onSubmit = () => {
2813
- const stream = h.membrane.streams.at(-1);
2814
- queueMicrotask(() => stream.cancel());
2815
- };
2816
- try {
2817
- const created = await h.framework.createEphemeralAgent({
2818
- name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
2819
- });
2820
- created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
2821
- const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
2822
- h.framework.start();
2823
- await completion.catch(() => { });
2824
- await created.agent.toolResultGuard.whenAnnotated();
2825
- await new Promise((resolve) => setImmediate(resolve));
2826
- assert.equal(h.framework.getAgent('ephemeral') ?? null, null, 'the run was disposed');
2827
- assert.equal(created.agent.toolResultGuard.idle, true);
2828
- const remembered = h.framework.busyGuards;
2829
- assert.equal(remembered.has(created.agent.toolResultGuard), false, 'nothing keeps the disposed guard');
2830
- }
2831
- finally {
2832
- await h.framework.stop();
2833
- }
2834
- });
2835
- test("#281: a file whose copy on disk fails is still in the workspace's tree, and the stub names it", async () => {
2836
- const h = await workspaceHarness([[calls('one'), refused()], [answer()]], { autoMaterialize: true });
2837
- // A file where the directory should be: the copy on disk fails for anyone, root included.
2838
- writeFileSync(join(h.mountDir, 'tool-results'), 'not a directory');
2839
- try {
2840
- await h.run();
2841
- const stub = String(toolResults(h.framework)[0].content);
2842
- const path = SPILLED_STUB.exec(stub)?.[1];
2843
- assert.ok(path, `the stub names the file: ${stub}`);
2844
- const file = await h.workspace.readBinary(path);
2845
- assert.ok('data' in file, `the workspace reads it: ${JSON.stringify(file)}`);
2846
- assert.match(file.data.toString('utf8'), /payload-one/);
2847
- assert.equal(existsSync(join(h.mountDir, path.replace(/^work\//, ''))), false, 'its copy on disk failed');
2848
- const annotated = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE)
2849
- .find((record) => record.type === 'annotated');
2850
- assert.deepEqual(annotated.results, [{ toolUseId: 'one', path }]);
2851
- }
2852
- finally {
2853
- await h.framework.stop();
2854
- }
2855
- });
2856
- test("#281: a conversation fork seeded while its template's stub is written copies the stub that names the original", async () => {
2857
- const dir = mkdtempSync(join(tmpdir(), 'af-guard-fork-'));
2858
- dirs.push(dir);
2859
- const membrane = new ScriptMembrane([[calls('one')]]);
2860
- const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
2861
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', toolResultGuard: true }],
2862
- modules: [new ReadModule()], syncIntervalMs: 0,
2863
- conversations: { templateAgent: 'assistant', bind: { channel: 'mention' } } });
2864
- const write = gate();
2865
- let spills = 0;
2866
- slowSpill(framework, () => { spills++; return write.opened; });
2867
- membrane.onSubmit = () => {
2868
- const stream = membrane.streams.at(-1);
2869
- queueMicrotask(() => stream.cancel());
2870
- };
2871
- try {
2872
- framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
2873
- await framework.runUntilIdle().catch(() => { });
2874
- await until(() => spills === 1, "the template's aborted batch is being annotated");
2875
- const compiles = watchCompiles(framework);
2876
- const internals = framework;
2877
- const forking = internals.createConversationAgent('assistant-chan-g1', 'world:chan');
2878
- await until(() => compiles.calls > 0, "the fork's seed waits for the template's stub");
2879
- write.open();
2880
- const fork = await forking;
2881
- const seeded = fork.getContextManager().getAllMessages()
2882
- .flatMap((message) => message.content.filter((block) => block.type === 'tool_result'));
2883
- assert.deepEqual(seeded.map((block) => block.content), [AUDIT_STUB]);
2884
- }
2885
- finally {
2886
- write.open();
2887
- await framework.stop();
2888
- }
2889
- });
2890
- test('#281: a compile during which an annotation began is compiled again once it is done', async () => {
2891
- const r = await compressionRig();
2892
- try {
2893
- const write = gate();
2894
- let entered = false;
2895
- r.guard.setHost({ deadline: () => Infinity, spill: async () => { entered = true; await write.opened; return null; }, track: () => { }, idle: () => { } });
2896
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
2897
- let reads = 0;
2898
- const stub = () => r.cm.getMessage(r.messageId).content[0].content;
2899
- const compiled = r.guard.afterAnnotations(async () => {
2900
- reads++;
2901
- // A settlement lands during the compile, as a previous frame's teardown can.
2902
- if (reads === 1)
2903
- assert.ok(r.guard.withhold('test-category'));
2904
- return stub();
2905
- });
2906
- await until(() => entered, 'the annotation is writing');
2907
- write.open();
2908
- assert.equal(await compiled, AUDIT_STUB);
2909
- assert.equal(reads, 2, 'compiled again once the annotation was done');
2910
- }
2911
- finally {
2912
- r.cm.close();
2913
- }
2914
- });
2915
- test('#281: a request preview never waits on a workspace write, though the next request does', async () => {
2916
- const h = await harness([[calls('one')], [answer()]], { toolResultGuard: true });
2917
- const write = gate();
2918
- let spills = 0;
2919
- slowSpill(h.framework, () => { spills++; return write.opened; });
2920
- h.membrane.onSubmit = () => {
2921
- const stream = h.membrane.streams.at(-1);
2922
- queueMicrotask(() => stream.cancel());
2923
- };
2924
- try {
2925
- await h.run().catch(() => { });
2926
- await until(() => spills === 1, 'the aborted batch is being annotated');
2927
- // Rollback, undo and redo report a preview: it must not wait on the write.
2928
- const preview = await h.framework.previewActivation('assistant');
2929
- const previewed = preview.messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2930
- assert.deepEqual(previewed.map((b) => b.content), [TOOL_RESULT_GUARD_NOTICE], 'the preview shows the stub as it stands');
2931
- const compiles = watchCompiles(h.framework);
2932
- const running = h.run();
2933
- await until(() => compiles.calls > 0, 'the next request is being compiled');
2934
- assert.equal(h.membrane.requests.length, 1, 'the next request waits for the stub');
2935
- write.open();
2936
- await running;
2937
- const sent = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
2938
- assert.deepEqual(sent.map((b) => b.content), [AUDIT_STUB]);
2939
- }
2940
- finally {
2941
- write.open();
2942
- await h.framework.stop();
2943
- }
2944
- });
2945
- test('#281: an annotation that starts while modules stop writes no file, and is saved before the store closes', async () => {
2946
- const h = await workspaceHarness([]);
2947
- const agent = h.framework.getAgent('assistant');
2948
- const framework = h.framework;
2949
- const writeFile = framework.writeToolResultFile.bind(h.framework);
2950
- let writes = 0;
2951
- framework.writeToolResultFile = async (...args) => { writes++; return writeFile(...args); };
2952
- const stopModule = h.module.stop.bind(h.module);
2953
- h.module.stop = async () => {
2954
- // A settlement during shutdown, as a direct-API call that stop doesn't cancel can make.
2955
- agent.toolResultGuard.storeResults([{ type: 'tool_result', toolUseId: 'late', content: 'late-original' }], [{ toolUseId: 'late', content: 'late-original' }], [], undefined, undefined);
2956
- agent.toolResultGuard.abandon('stopping');
2957
- await stopModule();
2958
- };
2959
- await h.framework.stop();
2960
- assert.equal(writes, 0, 'nothing is written to the stopping workspace');
2961
- const restarted = await AgentFramework.create({ storePath: h.storePath, membrane: new ScriptMembrane([]).asMembrane(),
2962
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', toolResultGuard: true }], modules: [], syncIntervalMs: 0 });
2963
- try {
2964
- assert.deepEqual(toolResults(restarted).map((block) => block.content), [AUDIT_STUB]);
2965
- const audit = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
2966
- assert.deepEqual(audit.slice(-3).map((record) => record.type), ['withheld', 'annotated', 'stub_updated']);
2967
- assert.deepEqual(audit.at(-2)?.results, [{ toolUseId: 'late', skipped: 'no_workspace_file' }]);
2968
- }
2969
- finally {
2970
- await restarted.stop();
2971
- }
2972
- });
2973
- test('#277: a write for a branch that waits for its mount while the branch changes is refused, and neither branch gets it', async () => {
2974
- const dir = mkdtempSync(join(tmpdir(), 'af-guard-write-binary-'));
2975
- dirs.push(dir);
2976
- mkdirSync(join(dir, 'mount'));
2977
- const store = JsStore.openOrCreate({ path: join(dir, 'store') });
2978
- const workspace = new WorkspaceModule({ mounts: [{ name: 'work', path: join(dir, 'mount'), mode: 'read-write', watch: 'never' }] });
2979
- workspace.initStore(store);
2980
- try {
2981
- const tree = 'workspace/work/tree';
2982
- const source = store.currentBranch().name;
2983
- const before = store.currentSequence();
2984
- // Hold the mount's turn, as a pass on it would: the write queues behind it.
2985
- const internals = workspace;
2986
- let release;
2987
- const gate = new Promise((resolve) => { release = resolve; });
2988
- const held = internals.withMount(internals.mounts.get('work'), () => gate);
2989
- const writing = workspace.writeBinary('work/tool-results/late.txt', Buffer.from('late'), 'text/plain', { branch: source });
2990
- store.createBranchAt('elsewhere', source, before);
2991
- store.switchBranch('elsewhere');
2992
- release();
2993
- await held;
2994
- assert.deepEqual(await writing, { success: false, error: 'the workspace had left the branch this file is for', isError: true,
2995
- data: { branchLeft: true } });
2996
- assert.equal(store.treeGet(tree, 'tool-results/late.txt'), null, 'nothing lands on the branch switched to');
2997
- store.switchBranch(source);
2998
- assert.equal(store.treeGet(tree, 'tool-results/late.txt'), null, 'nor on the branch the write named');
2999
- }
3000
- finally {
3001
- store.close();
3002
- }
3003
- });
3004
- // ---------------------------------------------------------------------------
3005
- // #277's second half: a withheld result keeps the file lists its tool marked
3006
- // (ToolResult.keepWhenWithheld) inline in its stub. materialize, sync and
3007
- // delete mark theirs; nothing else does.
3008
- // ---------------------------------------------------------------------------
3009
- const LISTS = ' Kept from its result: ';
3010
- /** The file lists a stub kept, parsed; undefined when it kept none. */
3011
- const keptLists = (stub) => stub.includes(LISTS) ? JSON.parse(stub.slice(stub.indexOf(LISTS) + LISTS.length)) : undefined;
3012
- const toolUse = (id, name, input = {}) => createMockResponse([{ type: 'tool_use', id, name, input }], 'tool_use');
3013
- test('#277: a stub keeps the file lists after where the original is, for each place it can be', () => {
3014
- const notice = 'Tool result withheld by the guard. The tool has already executed.';
3015
- const lists = '{"materialized":[{"mount":"work","path":"LOG.md"}]}';
3016
- assert.equal(withheldResultNotice({ path: 'work/tool-results/x.txt' }, true, lists), `${notice} Its result is in workspace file work/tool-results/x.txt. Kept from its result: ${lists}`);
3017
- assert.equal(withheldResultNotice({ path: 'work/tool-results/x.txt', error: 'disk full' }, true, lists), `${notice} Writing its result to workspace file work/tool-results/x.txt failed, ` +
3018
- `so it is kept only in the guard's audit record (framework/tool-result-guard), for an operator. Kept from its result: ${lists}`);
3019
- assert.equal(withheldResultNotice({ path: 'work/tool-results/y.txt', unfinished: true }, true, lists), `${notice} Its result was still being written to workspace file work/tool-results/y.txt when this note was made; ` +
3020
- `if that file is missing, it is kept in the guard's audit record (framework/tool-result-guard), for an operator. ` +
3021
- `Kept from its result: ${lists}`);
3022
- assert.equal(withheldResultNotice(null, false, lists), `${notice} Its result went to the guard's audit record (framework/tool-result-guard), for an operator, ` +
3023
- `though saving that record had failed when the result arrived. Kept from its result: ${lists}`);
3024
- assert.equal(withheldResultNotice(null, true, undefined), AUDIT_STUB, 'nothing kept: the stub is as before');
3025
- });
3026
- test('#277: a result keeps the fields its tool marked, in the order marked, cut at the inline cap', () => {
3027
- const data = { materialized: [{ mount: 'work', path: 'LOG.md' }], count: 1, deleted: [{ mount: 'work', path: 'old.txt' }],
3028
- skipped: [{ mount: 'work', reason: 'a.txt: divergent' }] };
3029
- assert.equal(keptWhenWithheld({ success: true, data, keepWhenWithheld: ['deleted', 'materialized'] }, undefined), '{"deleted":[{"mount":"work","path":"old.txt"}],"materialized":[{"mount":"work","path":"LOG.md"}]}');
3030
- assert.equal(keptWhenWithheld({ success: true, data: { materialized: [], count: 0 }, keepWhenWithheld: ['deleted', 'materialized'] }, undefined), '{"materialized":[]}', 'a marked field the result lacks is left out; an empty list is kept');
3031
- for (const result of [
3032
- { success: true, data },
3033
- { success: true, data, keepWhenWithheld: [] },
3034
- { success: true, data, keepWhenWithheld: ['absent'] },
3035
- { success: true, data: 'text', keepWhenWithheld: ['length'] },
3036
- { success: true, data: [{ path: 'a' }], keepWhenWithheld: ['0'] },
3037
- { success: false, isError: true, error: 'failed', data: { path: 'work/a.txt' }, keepWhenWithheld: ['path'] },
3038
- // A failure is success false, whatever isError says.
3039
- { success: false, isError: false, error: 'failed', data: { path: 'work/a.txt' }, keepWhenWithheld: ['path'] },
3040
- // No data at all, with marks.
3041
- { success: true, data: null, keepWhenWithheld: ['path'] },
3042
- { success: true, keepWhenWithheld: ['path'] },
3043
- ])
3044
- assert.equal(keptWhenWithheld(result, undefined), undefined, `keeps nothing: ${JSON.stringify(result)}`);
3045
- const long = { written: Array.from({ length: 200 }, (_, i) => `file-${i}.txt`) };
3046
- const cut = keptWhenWithheld({ success: true, data: long, keepWhenWithheld: ['written'] }, 1000);
3047
- const whole = JSON.stringify(long);
3048
- assert.ok(whole.length > 1000);
3049
- // The note says what was cut and where the rest is, not "original was N",
3050
- // which would read as the whole result's size.
3051
- assert.equal(cut, `${whole.slice(0, 1000)} [cut at the inline cap: 1000 of these fields' ${whole.length} characters; ` +
3052
- 'the whole result is where this note says]');
3053
- // A cut never leaves half a surrogate pair.
3054
- // '{"name":"' is 9 characters and each emoji 2, so a cap of 14 falls inside the third.
3055
- const emoji = keptWhenWithheld({ success: true, data: { name: '\u{1F600}'.repeat(10) }, keepWhenWithheld: ['name'] }, 14);
3056
- const loneSurrogate = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
3057
- assert.doesNotMatch(emoji, loneSurrogate, `well-formed: ${JSON.stringify(emoji)}`);
3058
- assert.match(emoji, /^\{"name":"\u{1F600}\u{1F600} \[cut at the inline cap: 13 of these fields' 31 characters;/u);
3059
- });
3060
- test('#277: a withheld batch keeps what each marked result listed; an unmarked one keeps nothing, and its file has everything', async () => {
3061
- const h = await workspaceHarness([[calls('marked', 'plain'), refused()], [answer()]]);
3062
- h.module.results.marked = { success: true, data: { written: ['LOG.md'], detail: 'payload-marked' }, keepWhenWithheld: ['written'] };
3063
- try {
3064
- await h.run();
3065
- const [marked, plain] = toolResults(h.framework).map((block) => String(block.content));
3066
- assert.deepEqual(keptLists(marked), { written: ['LOG.md'] });
3067
- assert.doesNotMatch(marked, /payload-marked/, 'only the marked field is kept');
3068
- assert.match(marked, /^Tool result withheld by the guard\. The tool has already executed\. Its result is in workspace file work\/tool-results\/\S+-0-marked\.txt\. Kept from its result: /);
3069
- assert.equal(keptLists(plain), undefined);
3070
- assert.match(plain, /Its result is in workspace file work\/tool-results\/\S+-1-plain\.txt\.$/);
3071
- const file = await h.workspace.readBinary(/workspace file (\S+\.txt)\./.exec(marked)[1]);
3072
- assert.match(file.data.toString('utf8'), /payload-marked/, 'the file holds the whole result');
3073
- const retried = h.membrane.requests[1].messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_result');
3074
- assert.deepEqual(retried.map((b) => b.content), [marked, plain], 'the retry already carries the lists');
3075
- assert.doesNotMatch(marked, /refus|test-category/, "#159's line: no refusal or category in the context");
3076
- }
3077
- finally {
3078
- await h.framework.stop();
3079
- }
3080
- });
3081
- test('#277: a marked result skipped for a branch change keeps its fields in the stub that names the audit record', async () => {
3082
- const h = await workspaceHarness([[calls('marked', 'plain'), refused()], [answer()]]);
3083
- h.module.results.marked = { success: true, data: { written: ['LOG.md'], detail: 'payload-marked' }, keepWhenWithheld: ['written'] };
3084
- // Declined where it commits, and the store is back on the batch's branch by
3085
- // the edit, as after an undo and a redo.
3086
- h.framework.writeToolResultFile = async () => ({ path: 'work/tool-results/declined.txt', branchLeft: true });
3087
- try {
3088
- await bounded(h.run(), 'the run');
3089
- const [marked, plain] = toolResults(h.framework).map((block) => String(block.content));
3090
- assert.equal(plain, AUDIT_STUB);
3091
- assert.ok(marked.startsWith(`${AUDIT_STUB} Kept from its result: `), marked);
3092
- assert.deepEqual(keptLists(marked), { written: ['LOG.md'] });
3093
- }
3094
- finally {
3095
- await h.framework.stop();
3096
- }
3097
- });
3098
- test('#277: with no writable workspace the stub still keeps the file lists, cut at the agent\'s inline cap', async () => {
3099
- const written = Array.from({ length: 200 }, (_, i) => `file-${i}.txt`);
3100
- const h = await harness([[calls('marked'), refused()], [answer()]], { toolResultGuard: true }, { marked: { success: true, data: { written }, keepWhenWithheld: ['written'] } });
3101
- try {
3102
- const spill = [...h.framework.collectAgentSettingsExtensions().values()].find((ext) => ext.keys.includes('tool_result_inline_max_chars'));
3103
- spill.update('assistant', { tool_result_inline_max_chars: 1000 });
3104
- await h.run();
3105
- const stub = String(toolResults(h.framework)[0].content);
3106
- const whole = JSON.stringify({ written });
3107
- assert.equal(stub, `${AUDIT_STUB}${LISTS}${whole.slice(0, 1000)} [cut at the inline cap: 1000 of these fields' ` +
3108
- `${whole.length} characters; the whole result is where this note says]`);
3109
- }
3110
- finally {
3111
- await h.framework.stop();
3112
- }
3113
- });
3114
- test('#277: direct API: a withheld result keeps its file lists, cut at the house default cap it was stored under', async () => {
3115
- const h = await harness([], { toolResultGuard: true });
3116
- const responses = [calls('marked'), refused(), answer()];
3117
- h.membrane.stream = async () => {
3118
- const response = responses.shift();
3119
- assert.ok(response);
3120
- return response;
3121
- };
3122
- try {
3123
- const agent = h.framework.getAgent('assistant');
3124
- agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
3125
- await agent.runInference(h.framework.getAllTools());
3126
- const written = Array.from({ length: 3000 }, (_, i) => `file-${i}.txt`);
3127
- agent.provideToolResult('marked', { success: true, data: { written }, keepWhenWithheld: ['written'] });
3128
- await agent.runInference(h.framework.getAllTools());
3129
- const stub = String(toolResults(h.framework)[0].content);
3130
- const whole = JSON.stringify({ written });
3131
- assert.ok(whole.length > DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS);
3132
- assert.equal(stub, `${AUDIT_STUB}${LISTS}${whole.slice(0, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS)}` +
3133
- ` [cut at the inline cap: ${DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS} of these fields' ${whole.length} characters; ` +
3134
- 'the whole result is where this note says]');
3135
- }
3136
- finally {
3137
- await h.framework.stop();
3138
- }
3139
- });
3140
- test('#283: a withheld materialize keeps the deletions it made; sync keeps a root it accepted ahead of its results', async () => {
3141
- const h = await workspaceHarness([[toolUse('m', 'workspace--materialize', { applyDeletions: true }), refused()], [answer()]]);
3142
- const call = (name, input = {}) => h.workspace.handleToolCall({ id: name, name, input });
3143
- try {
3144
- // A file on disk that the workspace then deletes: the agent's materialize removes it.
3145
- assert.equal((await h.workspace.writeBinary('work/old.txt', Buffer.from('old'), 'text/plain')).success, true);
3146
- assert.equal((await call('materialize')).success, true);
3147
- assert.equal((await call('delete', { path: 'work/old.txt' })).success, true);
3148
- await h.run();
3149
- const [materialize] = toolResults(h.framework).map((block) => String(block.content));
3150
- assert.deepEqual(keptLists(materialize), { deleted: [{ mount: 'work', path: 'old.txt' }], materialized: [] }, materialize);
3151
- // sync marks a root it accepted first, so a long results list can't cut it away.
3152
- const synced = await call('sync');
3153
- assert.deepEqual(synced.keepWhenWithheld, ['rootsAccepted', 'results']);
3154
- }
3155
- finally {
3156
- await h.framework.stop();
3157
- }
3158
- });
3159
- test('#277: withheld materialize, sync and delete keep the files they acted on in their stubs', async () => {
3160
- const h = await workspaceHarness([
3161
- [toolUse('m', 'workspace--materialize'), refused()], [answer()],
3162
- [toolUse('s', 'workspace--sync'), refused()], [answer()],
3163
- [toolUse('d', 'workspace--delete', { path: 'work/LOG.md' }), refused()], [answer()],
3164
- ]);
3165
- try {
3166
- // A workspace draft disk doesn't have yet: the materialize writes it.
3167
- assert.equal((await h.workspace.writeBinary('work/LOG.md', Buffer.from('log'), 'text/plain')).success, true);
3168
- await h.run();
3169
- // A file disk has and the workspace doesn't: the sync takes it in.
3170
- const mountDir = /^(.*)\/LOG\.md$/.exec(h.workspace.resolveAbsolutePath('work/LOG.md'))[1];
3171
- writeFileSync(join(mountDir, 'disk.txt'), 'from disk');
3172
- await h.run();
3173
- await h.run();
3174
- const [materialize, sync, del] = toolResults(h.framework).map((block) => String(block.content));
3175
- assert.deepEqual(keptLists(materialize), { materialized: [{ mount: 'work', path: 'LOG.md' }] }, materialize);
3176
- const synced = keptLists(sync);
3177
- assert.deepEqual(synced.results.map((r) => [r.mount, r.synced]), [['work', ['disk.txt']]], sync);
3178
- assert.deepEqual(keptLists(del), { path: 'work/LOG.md', deleted: true }, del);
3179
- for (const stub of [materialize, sync, del])
3180
- assert.match(stub, /^Tool result withheld by the guard\. The tool has already executed\. Its result is in workspace file /);
3181
- }
3182
- finally {
3183
- await h.framework.stop();
3184
- }
3185
- });
3186
- test('an unexpected annotation exception releases its compression hold', async () => {
3187
- const r = await compressionRig();
3188
- const branch = r.cm.currentBranch.bind(r.cm);
3189
- try {
3190
- r.guard.abandon('test');
3191
- r.cm.currentBranch = () => { throw new Error('injected annotation branch read'); };
3192
- await r.guard.whenAnnotated();
3193
- r.cm.currentBranch = branch;
3194
- assert.equal(r.cm.getCompressionHolds().size, 0);
3195
- assert.equal(r.cm.getMessage(r.messageId).content[0].type, 'tool_result');
3196
- assert.equal(r.cm.getMessage(r.messageId).content[0].content, TOOL_RESULT_GUARD_NOTICE);
3197
- }
3198
- finally {
3199
- r.cm.currentBranch = branch;
3200
- await r.cm.close();
3201
- }
3202
- });
3203
- test('a throw after treeSet reports the original as readable', async () => {
3204
- const h = await workspaceHarness([], { autoMaterialize: true });
3205
- const ws = h.workspace;
3206
- const push = ws.pushUnlocked;
3207
- try {
3208
- ws.pushUnlocked = async () => { throw new Error('injected push exception'); };
3209
- const result = await h.workspace.writeBinary('work/throw.txt', Buffer.from('original'), 'text/plain');
3210
- assert.equal(result.success, false);
3211
- assert.equal(result.data?.inChronicle, true);
3212
- assert.match(result.error, /injected push exception/);
3213
- const file = await h.workspace.readBinary('work/throw.txt');
3214
- assert.ok('data' in file);
3215
- assert.equal(file.data.toString(), 'original');
3216
- }
3217
- finally {
3218
- ws.pushUnlocked = push;
3219
- await h.framework.stop();
3220
- }
3221
- });
3222
- test('a throw from recording the intent after treeSet still reports the original as readable', async () => {
3223
- const h = await workspaceHarness([]);
3224
- const ws = h.workspace;
3225
- const intents = ws.intents.get('work');
3226
- const update = intents.update;
3227
- try {
3228
- intents.update = () => { throw new Error('injected intent exception'); };
3229
- const result = await h.workspace.writeBinary('work/intent.txt', Buffer.from('original'), 'text/plain');
3230
- assert.equal(result.success, false);
3231
- assert.equal(result.data?.inChronicle, true);
3232
- assert.match(result.error, /Stored "work\/intent\.txt" in Chronicle, but the rest of the write failed: injected intent exception/);
3233
- const file = await h.workspace.readBinary('work/intent.txt');
3234
- assert.ok('data' in file);
3235
- }
3236
- finally {
3237
- intents.update = update;
3238
- await h.framework.stop();
3239
- }
3240
- });
3241
- test("the reference fetcher's writer takes a file the tree holds as saved", async () => {
3242
- const h = await workspaceHarness([], { autoMaterialize: true });
3243
- const ws = h.workspace;
3244
- const push = ws.pushUnlocked;
3245
- const f = h.framework;
3246
- try {
3247
- ws.pushUnlocked = async () => { throw new Error('injected push exception'); };
3248
- const saved = await f.getReferenceFetcher().saveFile('ref.txt', Buffer.from('fetched'), 'text/plain');
3249
- assert.equal(saved, 'work/refs/ref.txt', 'stored in the tree, so the reference names it');
3250
- }
3251
- finally {
3252
- ws.pushUnlocked = push;
3253
- await h.framework.stop();
3254
- }
3255
- });
3256
- test("an inline spill's failed disk copy is traced with its error, cut at 200 characters, or a fallback", async () => {
3257
- const h = await workspaceHarness([]);
3258
- const traces = [];
3259
- h.framework.onTrace((e) => traces.push(e));
3260
- const f = h.framework;
3261
- const original = h.workspace.writeBinary.bind(h.workspace);
3262
- try {
3263
- for (const error of ['x'.repeat(300), undefined]) {
3264
- h.workspace.writeBinary = async () => ({ success: false, isError: true, ...(error ? { error } : {}), data: { inChronicle: true } });
3265
- await f.writeToolResultFile(`label-${error ? 'long' : 'none'}`, 'content', h.framework.getStore().currentBranch().name);
3266
- const traced = traces.filter((t) => t.type === 'tool:spill_failed').at(-1);
3267
- assert.ok(traced, 'traced');
3268
- assert.equal(traced.error, error ? 'x'.repeat(200) : 'the rest of the write failed');
3269
- }
3270
- }
3271
- finally {
3272
- h.workspace.writeBinary = original;
3273
- await h.framework.stop();
3274
- }
3275
- });
3276
- test('a batch that used its deadline is logged once for the batch, by the guard', async (t) => {
3277
- const h = await workspaceHarness([[calls('one', 'two', 'three'), refused()], [answer()]]);
3278
- h.framework.spillWithheld = async () => ({ skipped: 'batch_deadline' });
3279
- const lines = [];
3280
- t.mock.method(console, 'error', (...args) => { lines.push(args.map(String).join(' ')); });
3281
- try {
3282
- await bounded(h.run(), 'the run');
3283
- await h.framework.getAgent('assistant').toolResultGuard.whenAnnotated();
3284
- assert.equal(lines.filter((line) => /used its deadline/.test(line)).length, 1, lines.join('\n'));
3285
- }
3286
- finally {
3287
- await h.framework.stop();
3288
- }
3289
- });
3290
- test('a withheld spill with no finite deadline takes its own, not a 1 ms timer', async () => {
3291
- const h = await workspaceHarness([]);
3292
- const f = h.framework;
3293
- f.writeToolResultFile = async () => { await new Promise((resolve) => setTimeout(resolve, 20)); return { path: 'work/tool-results/slow.txt' }; };
3294
- try {
3295
- const spilled = await f.spillWithheld('slow', 'original', h.framework.getStore().currentBranch().name, Infinity);
3296
- assert.deepEqual(spilled, { path: 'work/tool-results/slow.txt' }, 'a 20 ms write finishes within the default deadline');
3297
- assert.equal(f.stalledSpills.size, 0);
3298
- }
3299
- finally {
3300
- await h.framework.stop();
3301
- }
3302
- });
3303
- test("a batch's deadline is taken when it is queued, not when the batches ahead of it finish", async () => {
3304
- const r = await compressionRig();
3305
- try {
3306
- const first = gate();
3307
- let entered = 0;
3308
- let taken = 0;
3309
- r.guard.setHost({
3310
- deadline: () => { taken++; return Date.now() + 60_000; },
3311
- spill: async () => { entered++; if (entered === 1)
3312
- await first.opened; return null; },
3313
- track: () => { }, idle: () => { },
3314
- });
3315
- r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
3316
- assert.ok(r.guard.withhold('test-category'));
3317
- await until(() => entered === 1, 'the first annotation is writing');
3318
- // A second batch queued behind the first, still writing.
3319
- r.cm.addMessage('Claude', [{ type: 'tool_use', id: 'tu-2', name: 'search', input: {} }]);
3320
- r.guard.storeResults([{ type: 'tool_result', toolUseId: 'tu-2', content: 'SECOND' }], [{ toolUseId: 'tu-2', content: 'SECOND' }], [], undefined, undefined);
3321
- r.guard.submissionResults([{ toolUseId: 'tu-2', content: 'SECOND' }]);
3322
- assert.ok(r.guard.withhold('test-category'));
3323
- assert.equal(taken, 2, "the second batch's deadline was taken when it was queued");
3324
- first.open();
3325
- await bounded(r.guard.whenAnnotated(), 'both annotations');
3326
- assert.equal(taken, 2, 'and not taken again when its annotation ran');
3327
- }
3328
- finally {
3329
- r.cm.close();
3330
- }
3331
- });
3332
- test('a throw before treeSet does not claim a readable original', async () => {
3333
- const h = await workspaceHarness([]);
3334
- const store = h.framework.getStore();
3335
- const set = store.treeSet.bind(store);
3336
- try {
3337
- store.treeSet = () => { throw new Error('injected tree exception'); };
3338
- await assert.rejects(h.workspace.writeBinary('work/throw.txt', Buffer.from('original'), 'text/plain'), /injected tree exception/);
3339
- assert.equal(store.treeGet('workspace/work/tree', 'throw.txt'), null);
3340
- }
3341
- finally {
3342
- store.treeSet = set;
3343
- await h.framework.stop();
3344
- }
3345
- });
3346
- test('abort during the direct refusal retry annotation sends no retry', async () => {
3347
- const h = await harness([], { toolResultGuard: true });
3348
- const requests = [];
3349
- const responses = [calls('one'), refused(), answer()];
3350
- h.membrane.stream = async (r) => {
3351
- requests.push(r);
3352
- return responses.shift();
3353
- };
3354
- const write = gate();
3355
- let entered = false;
3356
- slowSpill(h.framework, () => { entered = true; return write.opened; });
3357
- try {
3358
- const agent = h.framework.getAgent('assistant');
3359
- await agent.runInference([]);
3360
- agent.provideToolResult('one', { success: true, data: 'original' });
3361
- const controller = new AbortController();
3362
- const inference = agent.runInference([], undefined, { signal: controller.signal });
3363
- await until(() => entered, 'the direct retry waits for annotation');
3364
- controller.abort();
3365
- write.open();
3366
- const result = await inference;
3367
- assert.equal(requests.length, 2);
3368
- assert.equal(result.aborted, true);
3369
- assert.equal(String(agent.state.status), 'idle');
3370
- }
3371
- finally {
3372
- write.open();
3373
- await h.framework.stop();
3374
- }
3375
- });
3376
- test('unguarded results are read after the direct compile', async () => {
3377
- const h = await harness([]);
3378
- const agent = h.framework.getAgent('assistant');
3379
- const compile = agent.compileWithInjections.bind(agent);
3380
- const requests = [];
3381
- h.membrane.stream = async (r) => {
3382
- requests.push(r);
3383
- return answer();
3384
- };
3385
- agent.compileWithInjections = async (...args) => {
3386
- const result = await compile(...args);
3387
- // A tool result becoming ready in the compile's await must be observed,
3388
- // as it was before annotations added another await around compilation.
3389
- agent.enterWaitingForTools([{ id: 'late', name: 'test--read', input: {} }], new MockYieldingStream([]));
3390
- agent.provideToolResult('late', { success: true, data: 'arrived-during-compile' });
3391
- return result;
3392
- };
3393
- try {
3394
- await agent.runInference([]);
3395
- assert.match(JSON.stringify(requests[0]), /arrived-during-compile/);
3396
- }
3397
- finally {
3398
- await h.framework.stop();
3399
- }
3400
- });
3401
- test('stop then start reopens withheld spills', async () => {
3402
- const dir = mkdtempSync(join(tmpdir(), 'guard-restart-'));
3403
- dirs.push(dir);
3404
- const store = JsStore.openOrCreate({ path: join(dir, 'store') });
3405
- const framework = await AgentFramework.create({ store, membrane: new ScriptMembrane([]).asMembrane(),
3406
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', toolResultGuard: true }],
3407
- modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0 });
3408
- const f = framework;
3409
- f.toolResultFilePlace = () => ({ path: 'work/original.txt', safeLabel: 'original' });
3410
- let writes = 0;
3411
- f.writeToolResultFile = async () => { writes++; return { path: 'work/original.txt' }; };
3412
- try {
3413
- framework.start();
3414
- await framework.stop();
3415
- framework.start();
3416
- const guard = framework.getAgent('assistant').toolResultGuard;
3417
- guard.storeResults([{ type: 'tool_result', toolUseId: 'one', content: 'original' }], [{ toolUseId: 'one', content: 'original' }], [], undefined, undefined);
3418
- guard.abandon('test');
3419
- await guard.whenAnnotated();
3420
- assert.equal(writes, 1);
3421
- assert.match(String(toolResults(framework)[0].content), /is in workspace file work\/original.txt/);
3422
- }
3423
- finally {
3424
- await framework.stop();
3425
- store.close();
3426
- }
3427
- });
3428
- test('a framework compile from before stop cannot drive after restart', async () => {
3429
- const dir = mkdtempSync(join(tmpdir(), 'guard-generation-'));
3430
- dirs.push(dir);
3431
- const store = JsStore.openOrCreate({ path: join(dir, 'store') });
3432
- // One scripted answer for the turn compiled before the stop, one for the turn begun after the restart.
3433
- const membrane = new ScriptMembrane([[answer()], [answer()]]);
3434
- const framework = await AgentFramework.create({ store, membrane: membrane.asMembrane(),
3435
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system' }],
3436
- modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0 });
3437
- const agent = framework.getAgent('assistant');
3438
- const compile = gate();
3439
- const entered = gate();
3440
- const build = agent.buildActivationRequest.bind(agent);
3441
- agent.buildActivationRequest = async (...args) => { entered.open(); await compile.opened; return build(...args); };
3442
- let driven = 0;
3443
- const stream = membrane.streamYielding.bind(membrane);
3444
- membrane.streamYielding = (...args) => {
3445
- const result = stream(...args);
3446
- const iterate = result[Symbol.asyncIterator].bind(result);
3447
- result[Symbol.asyncIterator] = () => { driven++; return iterate(); };
3448
- return result;
3449
- };
3450
- let starting;
3451
- // The dropped turn's inference:* events, as a module's trace listener sees them.
3452
- const turnEvents = [];
3453
- const unsubscribe = framework.onTrace((event) => {
3454
- if (event.type.startsWith('inference:') && event.agentName === 'assistant') {
3455
- turnEvents.push({ type: event.type, ...('reason' in event ? { reason: String(event.reason) } : {}) });
3456
- }
3457
- });
3458
- try {
3459
- const lines = [];
3460
- const error = console.error;
3461
- console.error = (...args) => { lines.push(args.map(String).join(' ')); };
3462
- try {
3463
- starting = framework.startAgentStream(agent);
3464
- await entered.opened;
3465
- await framework.stop();
3466
- framework.start();
3467
- compile.open();
3468
- await starting;
3469
- }
3470
- finally {
3471
- console.error = error;
3472
- }
3473
- assert.equal(driven, 0);
3474
- assert.ok(lines.some((line) => /stopped and started again/.test(line) && /dropped before it sends anything/.test(line)), lines.join('\n'));
3475
- // The turn emitted inference:started before its compile, so it gets one
3476
- // terminal event: a listener's bracket (a typing loop) closes.
3477
- assert.deepEqual(turnEvents.map((event) => event.type), ['inference:started', 'inference:aborted']);
3478
- assert.match(turnEvents[1]?.reason ?? '', /stopped and started again.*dropped before it sends anything/);
3479
- unsubscribe();
3480
- // A turn begun after the restart is admitted and drives its stream.
3481
- await framework.startAgentStream(agent);
3482
- await until(() => driven === 1, 'the turn begun after the restart drives');
3483
- }
3484
- finally {
3485
- compile.open();
3486
- await starting;
3487
- await framework.stop();
3488
- store.close();
3489
- }
3490
- });
3491
- // A turn dropped before it sends anything, at either check: before its
3492
- // compile (it waits in a module's gatherContext) or after it (it waits in its
3493
- // compile). Each way it can lose its place: the framework stops, the agent is
3494
- // removed, the agent object is replaced under its name, or the replacement's
3495
- // own turn already holds the name's turn token.
3496
- for (const phase of ['before', 'after']) {
3497
- for (const how of ['stopping', 'removed', 'disposed', 'replaced', 'superseded']) {
3498
- const title = { stopping: 'the framework stopping', removed: 'its agent removed',
3499
- disposed: 'its run disposed with its record', replaced: 'its agent replaced',
3500
- superseded: "a newer turn of its agent's name running" }[how];
3501
- test(`a turn dropped ${phase} its compile, with ${title}, ${how === 'superseded' ? "leaves the newer turn's bracket alone" : 'ends with inference:aborted'}`, async () => {
3502
- const dir = mkdtempSync(join(tmpdir(), `guard-drop-${phase}-compile-`));
3503
- dirs.push(dir);
3504
- const store = JsStore.openOrCreate({ path: join(dir, 'store') });
3505
- const membrane = new ScriptMembrane([[answer()]]);
3506
- const framework = await AgentFramework.create({ store, membrane: membrane.asMembrane(),
3507
- agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system' }],
3508
- modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0 });
3509
- const agent = framework.getAgent('assistant');
3510
- const internal = framework;
3511
- const held = gate();
3512
- const entered = gate();
3513
- if (phase === 'before') {
3514
- internal.moduleRegistry.gatherContext = async () => { entered.open(); await held.opened; return []; };
3515
- }
3516
- else {
3517
- const build = agent.buildActivationRequest.bind(agent);
3518
- agent.buildActivationRequest = async (...args) => { entered.open(); await held.opened; return build(...args); };
3519
- }
3520
- const turnEvents = [];
3521
- framework.onTrace((event) => {
3522
- if (event.type.startsWith('inference:') && event.agentName === 'assistant') {
3523
- turnEvents.push({ type: event.type,
3524
- ...('reason' in event ? { reason: String(event.reason) } : {}),
3525
- ...('durationMs' in event ? { durationMs: Number(event.durationMs) } : {}) });
3526
- }
3527
- });
3528
- const lines = [];
3529
- const error = console.error;
3530
- console.error = (...args) => { lines.push(args.map(String).join(' ')); };
3531
- const heldMs = 25;
3532
- let starting;
3533
- try {
3534
- starting = framework.startAgentStream(agent);
3535
- await entered.opened;
3536
- if (how === 'stopping')
3537
- await framework.stop();
3538
- else if (how === 'removed')
3539
- internal.agents.delete('assistant');
3540
- else if (how === 'disposed') {
3541
- // As runEphemeralToCompletion's disposal does: the agent, its record and its token go.
3542
- internal.agents.delete('assistant');
3543
- internal.lastInferenceAt.delete('assistant');
3544
- internal.activeTurnTokens.delete('assistant');
3545
- }
3546
- else {
3547
- internal.agents.set('assistant', Object.create(agent));
3548
- if (how === 'superseded')
3549
- internal.activeTurnTokens.set('assistant', -1);
3550
- }
3551
- await new Promise((resolve) => setTimeout(resolve, heldMs));
3552
- held.open();
3553
- await starting;
3554
- }
3555
- finally {
3556
- console.error = error;
3557
- held.open();
3558
- await starting;
3559
- }
3560
- try {
3561
- const why = how === 'stopping' ? 'the framework was stopping' : 'the agent was replaced or removed';
3562
- const reason = `a turn ${phase === 'before' ? 'assembled' : 'compiled'} while ${why} is dropped before it sends anything`;
3563
- assert.ok(lines.some((line) => line.includes(reason)), lines.join('\n'));
3564
- const last = internal.lastInferenceAt.get('assistant');
3565
- if (how === 'superseded') {
3566
- // The terminal event and lastInference carry only the name, so they
3567
- // would end the newer turn: the dropped frame emits and records nothing.
3568
- assert.deepEqual(turnEvents.map((event) => event.type), ['inference:started']);
3569
- assert.equal(last?.endedAt, undefined);
3570
- }
3571
- else {
3572
- assert.deepEqual(turnEvents.map((event) => event.type), ['inference:started', 'inference:aborted']);
3573
- assert.equal(turnEvents[1]?.reason, reason);
3574
- const durationMs = turnEvents[1]?.durationMs ?? NaN;
3575
- assert.ok(Number.isFinite(durationMs) && durationMs >= heldMs - 5 && durationMs < 60_000, `durationMs ${durationMs}`);
3576
- if (how === 'disposed') {
3577
- // The terminal event still closes a listener's bracket, but the
3578
- // disposed name gets no record back.
3579
- assert.equal(internal.lastInferenceAt.has('assistant'), false, JSON.stringify(last));
3580
- }
3581
- else {
3582
- assert.ok(last?.endedAt !== undefined && last.endedAt >= (last.startedAt ?? Infinity), JSON.stringify(last));
3583
- }
3584
- }
3585
- }
3586
- finally {
3587
- if (how === 'superseded')
3588
- internal.activeTurnTokens.delete('assistant');
3589
- if (how !== 'stopping')
3590
- internal.agents.set('assistant', agent);
3591
- await framework.stop();
3592
- store.close();
3593
- }
3594
- });
3595
- }
3596
- }
3597
- // Drain async storage/annotation continuations without advancing deadline time.
3598
- async function flushAsync() {
3599
- for (let i = 0; i < 8; i++)
3600
- await new Promise((resolve) => setImmediate(resolve));
3601
- }
3602
- test('slow successful writes share one batch deadline', async (t) => {
3603
- const h = await workspaceHarness([]);
3604
- const f = h.framework;
3605
- f.withheldSpillDeadlineMs = 50;
3606
- const writes = [];
3607
- const first = gate();
3608
- const second = gate();
3609
- f.writeToolResultFile = async (label) => {
3610
- writes.push(label);
3611
- await (writes.length === 1 ? first.opened : second.opened);
3612
- return { path: `work/tool-results/${label}.txt` };
3613
- };
3614
- const guard = h.framework.getAgent('assistant').toolResultGuard;
3615
- try {
3616
- t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: Date.now() });
3617
- guard.storeResults(['one', 'two', 'three'].map((id) => ({ type: 'tool_result', toolUseId: id, content: id })), ['one', 'two', 'three'].map((id) => ({ toolUseId: id, content: id })), [], undefined, undefined);
3618
- guard.abandon('test');
3619
- let annotated = false;
3620
- void guard.whenAnnotated().then(() => { annotated = true; });
3621
- await flushAsync();
3622
- assert.equal(writes.length, 1);
3623
- t.mock.timers.tick(30);
3624
- first.open();
3625
- await flushAsync();
3626
- assert.equal(writes.length, 2);
3627
- t.mock.timers.tick(19);
3628
- await flushAsync();
3629
- assert.equal(annotated, false);
3630
- t.mock.timers.tick(1);
3631
- await flushAsync();
3632
- assert.equal(annotated, true, 'the second write has only the remaining 20 ms');
3633
- assert.equal(writes.length, 2, 'the third original stays in audit');
3634
- const notices = toolResults(h.framework).map((b) => String(b.content));
3635
- assert.match(notices[0], /is in workspace file/);
3636
- assert.match(notices[1], /was still being written/);
3637
- assert.equal(notices[2], AUDIT_STUB);
3638
- }
3639
- finally {
3640
- first.open();
3641
- second.open();
3642
- t.mock.timers.reset();
3643
- await h.framework.stop();
3644
- }
3645
- });
3646
- test('a withheld deadline and an unanswered skip are observable', async (t) => {
3647
- const h = await workspaceHarness([]);
3648
- const f = h.framework;
3649
- f.withheldSpillDeadlineMs = 50;
3650
- const write = gate();
3651
- f.writeToolResultFile = async () => { await write.opened; return null; };
3652
- const logs = [];
3653
- t.mock.method(console, 'error', (...args) => { logs.push(args.map(String).join(' ')); });
3654
- try {
3655
- t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: Date.now() });
3656
- const spilling = f.spillWithheld('stalled', 'original', h.framework.getStore().currentBranch().name);
3657
- let settled = false;
3658
- void spilling.then(() => { settled = true; });
3659
- t.mock.timers.tick(49);
3660
- await flushAsync();
3661
- assert.equal(settled, false);
3662
- t.mock.timers.tick(1);
3663
- await flushAsync();
3664
- assert.equal(settled, true, 'the configured 50 ms, not a multi-second ceiling');
3665
- assert.deepEqual(await spilling, { path: 'work/tool-results/stalled.txt', unfinished: true });
3666
- for (const label of ['skipped-1', 'skipped-2', 'skipped-3']) {
3667
- assert.deepEqual(await f.spillWithheld(label, 'original', h.framework.getStore().currentBranch().name), { skipped: 'workspace_unanswered' });
3668
- }
3669
- assert.ok(logs.some((line) => /still being written/.test(line) && /deadline/.test(line)), logs.join('\n'));
3670
- // One line for the stall's skips, not one per skipped result.
3671
- assert.equal(logs.filter((line) => /workspace_unanswered/.test(line)).length, 1, logs.join('\n'));
3672
- write.open();
3673
- t.mock.timers.reset();
3674
- await until(() => logs.some((line) => /answered again; 3 withheld original\(s\) were skipped/.test(line)), 'the count when the stall clears');
3675
- }
3676
- finally {
3677
- write.open();
3678
- t.mock.timers.reset();
3679
- await h.framework.stop();
3680
- }
3681
- });
3682
- test('an exhausted batch starts no new write without claiming a stalled workspace', async () => {
3683
- const h = await workspaceHarness([]);
3684
- const f = h.framework;
3685
- let writes = 0;
3686
- f.writeToolResultFile = async () => { writes++; return null; };
3687
- try {
3688
- assert.deepEqual(await f.spillWithheld('expired', 'original', h.framework.getStore().currentBranch().name, Date.now() - 1), { skipped: 'batch_deadline' });
3689
- assert.equal(writes, 0);
3690
- }
3691
- finally {
3692
- await h.framework.stop();
3693
- }
3694
- });
3695
- test('an inline spill names the readable tree file and traces its failed disk copy', async () => {
3696
- const h = await workspaceHarness([], { autoMaterialize: true });
3697
- writeFileSync(join(h.mountDir, 'tool-results'), 'not a directory');
3698
- const traces = [];
3699
- h.framework.onTrace((event) => traces.push(event));
3700
- try {
3701
- const f = h.framework;
3702
- const result = await f.spillOrTruncate('original'.repeat(30), 20, 'inline');
3703
- assert.equal(result.filePath, 'work/tool-results/inline.txt');
3704
- assert.match(result.text, /work\/tool-results\/inline.txt/);
3705
- const file = await h.workspace.readBinary(result.filePath);
3706
- assert.ok('data' in file);
3707
- assert.equal(file.data.toString(), 'original'.repeat(30));
3708
- assert.ok(traces.some((t) => t.type === 'tool:spill_failed' && t.path === result.filePath));
3709
- }
3710
- finally {
3711
- await h.framework.stop();
3712
- }
3713
- });
3714
- test('an already-aborted direct call never reaches membrane', async () => {
3715
- const h = await harness([]);
3716
- let calls = 0;
3717
- h.membrane.stream = async () => { calls++; return answer(); };
3718
- try {
3719
- const controller = new AbortController();
3720
- controller.abort();
3721
- const result = await h.framework.getAgent('assistant').runInference([], undefined, { signal: controller.signal });
3722
- assert.equal(result.aborted, true);
3723
- assert.equal(calls, 0);
3724
- }
3725
- finally {
3726
- await h.framework.stop();
3727
- }
3728
- });
3729
- //# sourceMappingURL=tool-result-guard.test.js.map