@genesislcap/ai-assistant 15.19.0 → 15.19.1

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 (288) hide show
  1. package/package.json +17 -18
  2. package/api-extractor.json +0 -11
  3. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
  4. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
  5. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
  6. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
  7. package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
  8. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
  9. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
  10. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
  11. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
  12. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
  13. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
  14. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
  15. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
  16. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
  18. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
  19. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
  20. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
  21. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
  22. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
  23. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
  24. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
  25. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
  26. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
  27. package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
  28. package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
  29. package/dist/dts/config/validate-providers.test.d.ts +0 -2
  30. package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
  31. package/dist/dts/main/blocked-state.test.d.ts +0 -2
  32. package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
  33. package/dist/dts/main/budget-meter.test.d.ts +0 -2
  34. package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
  35. package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
  36. package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
  37. package/dist/dts/main/file-attachments.test.d.ts +0 -2
  38. package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
  39. package/dist/dts/main/interaction-cost.test.d.ts +0 -2
  40. package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
  41. package/dist/dts/main/main.styles.test.d.ts +0 -2
  42. package/dist/dts/main/main.styles.test.d.ts.map +0 -1
  43. package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
  44. package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
  45. package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
  46. package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
  47. package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
  48. package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
  49. package/dist/dts/state/debug-event-log.test.d.ts +0 -2
  50. package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
  51. package/dist/dts/state/driver-registry.test.d.ts +0 -2
  52. package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
  53. package/dist/dts/state/interaction-context.test.d.ts +0 -2
  54. package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
  55. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
  56. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
  57. package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
  58. package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
  59. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
  60. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
  61. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
  62. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
  63. package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
  64. package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
  65. package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
  66. package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
  67. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
  68. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
  69. package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
  70. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
  71. package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
  72. package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
  73. package/dist/dts/utils/condense-history.test.d.ts +0 -2
  74. package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
  75. package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
  76. package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
  77. package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
  78. package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
  79. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
  80. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
  81. package/dist/dts/utils/history-transform.test.d.ts +0 -2
  82. package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
  83. package/dist/dts/utils/message-partition.test.d.ts +0 -2
  84. package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
  85. package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
  86. package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
  87. package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
  88. package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
  89. package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
  90. package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
  91. package/dist/dts/utils/sum-costs.test.d.ts +0 -2
  92. package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
  93. package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
  94. package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
  95. package/dist/dts/utils/sum-usage.test.d.ts +0 -2
  96. package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
  97. package/dist/dts/utils/usage-rows.test.d.ts +0 -2
  98. package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
  99. package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
  100. package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
  101. package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
  102. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
  103. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
  104. package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
  105. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
  106. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
  107. package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
  108. package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
  109. package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
  110. package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
  111. package/dist/esm/config/define-stateful-agent.test.js +0 -53
  112. package/dist/esm/config/validate-providers.test.js +0 -100
  113. package/dist/esm/main/blocked-state.test.js +0 -1065
  114. package/dist/esm/main/budget-meter.test.js +0 -359
  115. package/dist/esm/main/cost-session-banking.test.js +0 -538
  116. package/dist/esm/main/file-attachments.test.js +0 -164
  117. package/dist/esm/main/interaction-cost.test.js +0 -107
  118. package/dist/esm/main/main.styles.test.js +0 -161
  119. package/dist/esm/main/persistence-broken-sources.test.js +0 -180
  120. package/dist/esm/main/popout-interaction-gate.test.js +0 -54
  121. package/dist/esm/state/ai-assistant-slice.test.js +0 -229
  122. package/dist/esm/state/debug-event-log.test.js +0 -117
  123. package/dist/esm/state/driver-registry.test.js +0 -55
  124. package/dist/esm/state/interaction-context.test.js +0 -78
  125. package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
  126. package/dist/esm/state/persistence/diagnostics.test.js +0 -280
  127. package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
  128. package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
  129. package/dist/esm/state/persistence/session-persister.test.js +0 -613
  130. package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
  131. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
  132. package/dist/esm/utils/animation-exclusivity.test.js +0 -52
  133. package/dist/esm/utils/collect-session-models.test.js +0 -53
  134. package/dist/esm/utils/condense-history.test.js +0 -547
  135. package/dist/esm/utils/cost-session-history.test.js +0 -174
  136. package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
  137. package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
  138. package/dist/esm/utils/history-transform.test.js +0 -158
  139. package/dist/esm/utils/message-partition.test.js +0 -90
  140. package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
  141. package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
  142. package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
  143. package/dist/esm/utils/sum-costs.test.js +0 -114
  144. package/dist/esm/utils/sum-tokens.test.js +0 -51
  145. package/dist/esm/utils/sum-usage.test.js +0 -164
  146. package/dist/esm/utils/usage-rows.test.js +0 -189
  147. package/dist/tsconfig.tsbuildinfo +0 -1
  148. package/license.txt +0 -46
  149. package/sandbox/tsconfig.json +0 -8
  150. package/src/channel/ai-activity-bus.ts +0 -209
  151. package/src/channel/ai-activity-channel.ts +0 -146
  152. package/src/chat-driver-node.ts +0 -159
  153. package/src/components/activity-halo/activity-halo.ts +0 -106
  154. package/src/components/agent-picker/agent-picker.constants.ts +0 -2
  155. package/src/components/agent-picker/agent-picker.styles.ts +0 -24
  156. package/src/components/agent-picker/agent-picker.template.ts +0 -54
  157. package/src/components/agent-picker/agent-picker.ts +0 -99
  158. package/src/components/agent-picker/index.ts +0 -1
  159. package/src/components/ai-driver/ai-driver.ts +0 -166
  160. package/src/components/ai-driver/index.ts +0 -1
  161. package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
  162. package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
  163. package/src/components/chat-bubble/chat-bubble.ts +0 -436
  164. package/src/components/chat-bubble/index.ts +0 -1
  165. package/src/components/chat-driver/align-event-globals.ts +0 -23
  166. package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
  167. package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
  168. package/src/components/chat-driver/chat-driver.test.ts +0 -3679
  169. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
  170. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
  171. package/src/components/chat-driver/chat-driver.ts +0 -3925
  172. package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
  173. package/src/components/chat-driver/index.ts +0 -1
  174. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
  175. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
  176. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
  177. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
  178. package/src/components/chat-interaction-wrapper/index.ts +0 -3
  179. package/src/components/chat-markdown/chat-markdown.ts +0 -89
  180. package/src/components/chat-markdown/index.ts +0 -1
  181. package/src/components/flowing-waves-indicator.ts +0 -286
  182. package/src/components/halo-overlay.ts +0 -184
  183. package/src/components/orchestrating-driver/index.ts +0 -1
  184. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
  185. package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
  186. package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
  187. package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
  188. package/src/components/plasma-orb-indicator.ts +0 -281
  189. package/src/components/popout-manager/index.ts +0 -1
  190. package/src/components/popout-manager/popout-manager.ts +0 -212
  191. package/src/components/settings-modal/index.ts +0 -2
  192. package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
  193. package/src/components/settings-modal/settings-modal.styles.ts +0 -853
  194. package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
  195. package/src/components/settings-modal/settings-modal.template.ts +0 -943
  196. package/src/components/waves-indicator.ts +0 -241
  197. package/src/config/config.ts +0 -608
  198. package/src/config/define-stateful-agent.test.ts +0 -64
  199. package/src/config/define-stateful-agent.ts +0 -642
  200. package/src/config/fallback-agents.ts +0 -29
  201. package/src/config/index.ts +0 -2
  202. package/src/config/validate-providers.test.ts +0 -148
  203. package/src/config/validate-providers.ts +0 -58
  204. package/src/index.ts +0 -29
  205. package/src/main/blocked-state.test.ts +0 -1444
  206. package/src/main/budget-meter.test.ts +0 -477
  207. package/src/main/cost-session-banking.test.ts +0 -679
  208. package/src/main/file-attachments.test.ts +0 -215
  209. package/src/main/index.ts +0 -3
  210. package/src/main/interaction-cost.test.ts +0 -140
  211. package/src/main/main.styles.test.ts +0 -216
  212. package/src/main/main.styles.ts +0 -995
  213. package/src/main/main.template.ts +0 -1119
  214. package/src/main/main.ts +0 -5432
  215. package/src/main/main.types.ts +0 -225
  216. package/src/main/persistence-broken-sources.test.ts +0 -219
  217. package/src/main/popout-interaction-gate.test.ts +0 -87
  218. package/src/provider/ai-provider-switcher.ts +0 -40
  219. package/src/provider/assistant-app-settings.ts +0 -84
  220. package/src/state/ai-assistant-slice.test.ts +0 -336
  221. package/src/state/ai-assistant-slice.ts +0 -604
  222. package/src/state/debug-event-log.test.ts +0 -154
  223. package/src/state/debug-event-log.ts +0 -358
  224. package/src/state/driver-registry.test.ts +0 -70
  225. package/src/state/driver-registry.ts +0 -79
  226. package/src/state/interaction-context.test.ts +0 -90
  227. package/src/state/interaction-context.ts +0 -101
  228. package/src/state/persistence/build-timeline-entries.ts +0 -66
  229. package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
  230. package/src/state/persistence/diagnostics-cursors.ts +0 -80
  231. package/src/state/persistence/diagnostics.test.ts +0 -346
  232. package/src/state/persistence/diagnostics.ts +0 -220
  233. package/src/state/persistence/index.ts +0 -10
  234. package/src/state/persistence/persister-registry.ts +0 -55
  235. package/src/state/persistence/session-persistence-provider.test.ts +0 -136
  236. package/src/state/persistence/session-persistence-provider.ts +0 -226
  237. package/src/state/persistence/session-persistence.integration.test.ts +0 -141
  238. package/src/state/persistence/session-persister.test.ts +0 -787
  239. package/src/state/persistence/session-persister.ts +0 -917
  240. package/src/state/persistence/session-snapshot.test.ts +0 -352
  241. package/src/state/persistence/session-snapshot.ts +0 -344
  242. package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
  243. package/src/state/session-store.ts +0 -56
  244. package/src/styles/ai-colours.ts +0 -30
  245. package/src/styles/menu.ts +0 -187
  246. package/src/styles/settings-section.ts +0 -40
  247. package/src/suggestions/chat-suggestions.ts +0 -186
  248. package/src/tags/index.ts +0 -1
  249. package/src/types/ai-chat-widget.ts +0 -67
  250. package/src/types/interaction-context.ts +0 -82
  251. package/src/utils/animated-panel-toggle.ts +0 -62
  252. package/src/utils/animation-exclusivity.test.ts +0 -72
  253. package/src/utils/animation-exclusivity.ts +0 -40
  254. package/src/utils/banked-usage-baselines.ts +0 -90
  255. package/src/utils/collect-session-models.test.ts +0 -82
  256. package/src/utils/collect-session-models.ts +0 -65
  257. package/src/utils/condense-history.test.ts +0 -746
  258. package/src/utils/condense-history.ts +0 -448
  259. package/src/utils/cost-session-history.test.ts +0 -226
  260. package/src/utils/cost-session-history.ts +0 -218
  261. package/src/utils/derive-cost-session-title.test.ts +0 -63
  262. package/src/utils/derive-cost-session-title.ts +0 -76
  263. package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
  264. package/src/utils/flatten-sub-agent-messages.ts +0 -88
  265. package/src/utils/format-usd.ts +0 -26
  266. package/src/utils/history-transform.test.ts +0 -228
  267. package/src/utils/history-transform.ts +0 -163
  268. package/src/utils/index.ts +0 -3
  269. package/src/utils/logger.ts +0 -3
  270. package/src/utils/message-partition.test.ts +0 -129
  271. package/src/utils/message-partition.ts +0 -115
  272. package/src/utils/resolve-cost-history-config.test.ts +0 -38
  273. package/src/utils/resolve-cost-history-config.ts +0 -38
  274. package/src/utils/resolve-preference-baseline.test.ts +0 -84
  275. package/src/utils/resolve-preference-baseline.ts +0 -80
  276. package/src/utils/strip-agent-handlers.test.ts +0 -99
  277. package/src/utils/strip-agent-handlers.ts +0 -53
  278. package/src/utils/sum-costs.test.ts +0 -136
  279. package/src/utils/sum-costs.ts +0 -18
  280. package/src/utils/sum-tokens.test.ts +0 -93
  281. package/src/utils/sum-tokens.ts +0 -68
  282. package/src/utils/sum-usage.test.ts +0 -191
  283. package/src/utils/sum-usage.ts +0 -208
  284. package/src/utils/tool-fold.ts +0 -181
  285. package/src/utils/usage-rows.test.ts +0 -237
  286. package/src/utils/usage-rows.ts +0 -187
  287. package/src/utils/with-timeout.ts +0 -24
  288. package/tsconfig.json +0 -9
@@ -1,3679 +0,0 @@
1
- import type {
2
- AIProvider,
3
- AIProviderRegistry,
4
- CachePolicy,
5
- ChatDriverResult,
6
- ChatMessage,
7
- ChatRequestOptions,
8
- ChatToolCall,
9
- ChatToolChoice,
10
- ChatToolDefinition,
11
- InteractionResult,
12
- TurnFailureReason,
13
- } from '@genesislcap/foundation-ai';
14
- import {
15
- BudgetExhaustedError,
16
- DEFAULT_PROVIDER_REFUSED_MESSAGE,
17
- ProviderRefusedError,
18
- DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
19
- isChatToolCallUnknown,
20
- MalformedFunctionCallError,
21
- ResponseTruncatedError,
22
- } from '@genesislcap/foundation-ai';
23
- import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
24
- import { type ActivityBus, AgenticActivityBus } from '../../channel/ai-activity-bus';
25
- import type { AgentConfig } from '../../config/config';
26
- import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
27
- import { sumCosts } from '../../utils/sum-costs';
28
- import { sumTokens } from '../../utils/sum-tokens';
29
- import { createToolFold } from '../../utils/tool-fold';
30
- // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
31
- // jsdom's EventTarget rather than Node's native one (see the file). None of the
32
- // imports above pull in the driver, so its realm is still set before evaluation.
33
- import './align-event-globals';
34
- import { ChatDriver } from './chat-driver';
35
-
36
- // ---------------------------------------------------------------------------
37
- // Test harness
38
- //
39
- // The driver calls `provider.chat(history, userMessage, options)` once per
40
- // tool-loop iteration and inspects the returned ChatMessage: a message with
41
- // `toolCalls` keeps the loop running; one without ends the turn. So a fake
42
- // provider that replays a scripted sequence of ChatMessages is enough to drive
43
- // any tool-loop path. We capture the advertised tool names per call so tests
44
- // can assert per-state narrowing actually happened.
45
- // ---------------------------------------------------------------------------
46
-
47
- interface ScriptedProvider extends AIProvider {
48
- /** Tool names advertised to the model on each `chat()` call, in order. */
49
- advertisedPerCall: string[][];
50
- /** `toolChoice` seen on each `chat()` call, in order (sub-agents force it). */
51
- toolChoicePerCall: Array<ChatToolChoice | undefined>;
52
- /** `temperature` seen on each `chat()` call, in order. */
53
- temperaturePerCall: Array<number | undefined>;
54
- /** `cachePolicy` seen on each `chat()` call, in order. */
55
- cachePolicyPerCall: Array<CachePolicy | undefined>;
56
- /** `tailContext` seen on each `chat()` call, in order (already framed by the driver). */
57
- tailContextPerCall: Array<string | undefined>;
58
- /** `systemPrompt` seen on each `chat()` call, in order. */
59
- systemPromptPerCall: Array<string | undefined>;
60
- /** The model-bound history slice seen on each `chat()` call (post-transform). */
61
- historyPerCall: ChatMessage[][];
62
- }
63
-
64
- const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
65
- const queue = [...responses];
66
- const advertisedPerCall: string[][] = [];
67
- const toolChoicePerCall: Array<ChatToolChoice | undefined> = [];
68
- const temperaturePerCall: Array<number | undefined> = [];
69
- const cachePolicyPerCall: Array<CachePolicy | undefined> = [];
70
- const tailContextPerCall: Array<string | undefined> = [];
71
- const systemPromptPerCall: Array<string | undefined> = [];
72
- const historyPerCall: ChatMessage[][] = [];
73
- return {
74
- advertisedPerCall,
75
- toolChoicePerCall,
76
- temperaturePerCall,
77
- cachePolicyPerCall,
78
- tailContextPerCall,
79
- systemPromptPerCall,
80
- historyPerCall,
81
- chat: async (
82
- history: ChatMessage[],
83
- _userMessage: string,
84
- options?: ChatRequestOptions,
85
- ): Promise<ChatMessage> => {
86
- advertisedPerCall.push((options?.tools ?? []).map((t) => t.name));
87
- toolChoicePerCall.push(options?.toolChoice);
88
- temperaturePerCall.push(options?.temperature);
89
- cachePolicyPerCall.push(options?.cachePolicy);
90
- tailContextPerCall.push(options?.tailContext);
91
- systemPromptPerCall.push(options?.systemPrompt);
92
- // Snapshot the slice (condensation produces fresh objects per call and never
93
- // mutates stored history, so a shallow array copy is a stable per-call view).
94
- historyPerCall.push([...history]);
95
- // Once the script is exhausted, end the turn with a plain text reply.
96
- return queue.shift() ?? { role: 'assistant', content: 'done' };
97
- },
98
- };
99
- };
100
-
101
- const makeRegistry = (provider: AIProvider): AIProviderRegistry => ({
102
- get: () => provider,
103
- default: () => provider,
104
- defaultName: () => 'test',
105
- names: () => ['test'],
106
- getStatus: async () => null,
107
- listStatuses: async () => [],
108
- });
109
-
110
- const def = (name: string): ChatToolDefinition => ({
111
- name,
112
- description: `${name} tool`,
113
- parameters: { type: 'object', properties: {} },
114
- });
115
-
116
- /** An assistant turn that calls a single tool. `content` is empty so the driver
117
- * does not treat it as a thinking step (which would split it into two messages). */
118
- const callsTool = (name: string, id: string): ChatMessage => ({
119
- role: 'assistant',
120
- content: '',
121
- toolCalls: [{ id, name, args: {} }],
122
- });
123
-
124
- const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
125
- ({ description: 'test agent', ...overrides }) as AgentConfig;
126
-
127
- const makeDriver = (
128
- config: AgentConfig,
129
- provider: AIProvider,
130
- sessionKey = '',
131
- activityBus?: ActivityBus,
132
- ): ChatDriver => {
133
- const driver = new ChatDriver(makeRegistry(provider), {
134
- maxToolIterations: 50,
135
- maxFoldOperations: 5,
136
- sessionKey,
137
- activityBus,
138
- });
139
- driver.applyAgent(config);
140
- return driver;
141
- };
142
-
143
- /** All tool calls across the whole conversation, flattened. */
144
- const allToolCalls = (driver: ChatDriver): ChatToolCall[] =>
145
- driver.getHistory().flatMap((m) => m.toolCalls ?? []);
146
-
147
- /** Tool-result message contents, in order. */
148
- const toolResultContents = (driver: ChatDriver): string[] =>
149
- driver
150
- .getHistory()
151
- .filter((m) => m.role === 'tool' && m.toolResult)
152
- .map((m) => m.toolResult!.content);
153
-
154
- /** `tool.unresolved` meta-event details recorded for a session (download-log surface). */
155
- const unresolvedEvents = (sessionKey: string): Array<Record<string, unknown>> =>
156
- getMetaEvents(sessionKey)
157
- .filter((e) => e.type === 'tool.unresolved')
158
- .map((e) => e.detail ?? {});
159
-
160
- // ---------------------------------------------------------------------------
161
- // stale tool detection — stateful agent advances past a tool's state
162
- // ---------------------------------------------------------------------------
163
-
164
- const stale = createLogicSuite('ChatDriver stale-tool detection');
165
-
166
- stale('guides the model when it calls a tool that an earlier state exposed', async () => {
167
- // State A exposes tool_a; calling it advances to state B, which exposes only
168
- // tool_b. A factory-form agent narrows the tool set per turn, mirroring how
169
- // `defineStatefulAgent` works.
170
- let state: 'A' | 'B' = 'A';
171
- const config = agent({
172
- name: 'Stateful',
173
- toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
174
- toolHandlers: () =>
175
- state === 'A'
176
- ? {
177
- tool_a: async () => {
178
- state = 'B';
179
- return 'advanced to B';
180
- },
181
- }
182
- : { tool_b: async () => 'b done' },
183
- });
184
-
185
- const provider = scriptedProvider([
186
- callsTool('tool_a', 't1'), // real — advances A -> B
187
- callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
188
- callsTool('tool_b', 't3'), // real — valid in state B
189
- ]);
190
- const sessionKey = 'stale-meta-test';
191
- const driver = makeDriver(config, provider, sessionKey);
192
-
193
- const result = await driver.sendMessage('go');
194
- assert.is(result.reason, 'done');
195
-
196
- // The per-state narrowing actually happened: tool_a advertised first, tool_b later.
197
- assert.equal(provider.advertisedPerCall[0], ['tool_a']);
198
- assert.ok(
199
- provider.advertisedPerCall.some(
200
- (tools) => tools.includes('tool_b') && !tools.includes('tool_a'),
201
- ),
202
- 'a later turn should advertise tool_b without tool_a',
203
- );
204
-
205
- // The retried tool_a got stale guidance — not "Unknown tool".
206
- const staleGuidance = toolResultContents(driver).find((c) =>
207
- c.includes('was available earlier but is not part of the current step'),
208
- );
209
- assert.ok(staleGuidance, 'a previously-available tool should receive stale guidance');
210
- assert.not.ok(
211
- toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')),
212
- 'a previously-available tool must not be reported as a hallucination',
213
- );
214
-
215
- // The retried call is flagged unknown + stale for the UI.
216
- const retried = allToolCalls(driver).filter(
217
- (tc) => tc.name === 'tool_a' && isChatToolCallUnknown(tc),
218
- );
219
- assert.is(retried.length, 1, 'exactly one tool_a call should be flagged unknown');
220
- assert.ok(isChatToolCallUnknown(retried[0]) && retried[0].stale === true, 'and marked stale');
221
-
222
- // The occurrence is recorded to the meta-event log for the download log.
223
- assert.ok(
224
- unresolvedEvents(sessionKey).some((d) => d.kind === 'stale' && d.tool === 'tool_a'),
225
- 'a stale tool.unresolved meta event should be recorded',
226
- );
227
- });
228
-
229
- stale('reports a never-seen tool as a hallucinated unknown tool', async () => {
230
- const config = agent({
231
- name: 'Static',
232
- toolDefinitions: [def('real_tool')],
233
- toolHandlers: { real_tool: async () => 'ok' },
234
- });
235
- const provider = scriptedProvider([callsTool('made_up', 'm1')]);
236
- const sessionKey = 'hallucination-meta-test';
237
- const driver = makeDriver(config, provider, sessionKey);
238
-
239
- await driver.sendMessage('go');
240
-
241
- assert.ok(
242
- toolResultContents(driver).includes('Unknown tool: made_up'),
243
- 'a tool never advertised should be reported as unknown',
244
- );
245
- const call = allToolCalls(driver).find((tc) => tc.name === 'made_up');
246
- assert.ok(call && isChatToolCallUnknown(call), 'the call should be flagged unknown');
247
- assert.not.ok(
248
- (call as { stale?: boolean }).stale,
249
- 'a hallucinated tool must NOT be flagged stale',
250
- );
251
-
252
- assert.ok(
253
- unresolvedEvents(sessionKey).some((d) => d.kind === 'unknown' && d.tool === 'made_up'),
254
- 'an unknown tool.unresolved meta event should be recorded',
255
- );
256
- });
257
-
258
- stale('points the model at the close tool when an exclusive fold hides a base tool', async () => {
259
- const fold = createToolFold({
260
- name: 'my_fold',
261
- tools: [def('inner_tool')],
262
- handlers: { inner_tool: async () => 'inner done' },
263
- // exclusive defaults to true — opening it removes base_tool from the set.
264
- });
265
- const config = agent({
266
- name: 'Folded',
267
- toolDefinitions: [def('base_tool'), fold.definition],
268
- toolHandlers: { base_tool: async () => 'base done', ...fold.handler },
269
- });
270
-
271
- const provider = scriptedProvider([
272
- callsTool('my_fold', 'f1'), // open the exclusive fold — base_tool now hidden
273
- callsTool('base_tool', 'b1'), // hidden behind the open fold
274
- ]);
275
- const sessionKey = 'fold-meta-test';
276
- const driver = makeDriver(config, provider, sessionKey);
277
-
278
- await driver.sendMessage('go');
279
-
280
- // Target the base_tool result specifically — the fold-open result also
281
- // mentions my_fold, so match on the tool call id rather than substring.
282
- const guidance = driver
283
- .getHistory()
284
- .find((m) => m.role === 'tool' && m.toolResult?.toolCallId === 'b1')?.toolResult?.content;
285
- assert.ok(guidance, 'calling a fold-hidden tool should produce guidance');
286
- assert.match(guidance!, /not available while the "my_fold" fold is open/);
287
- assert.match(guidance!, /close_my_fold/);
288
-
289
- const hidden = allToolCalls(driver).find(
290
- (tc) => tc.name === 'base_tool' && isChatToolCallUnknown(tc),
291
- );
292
- assert.ok(
293
- hidden && isChatToolCallUnknown(hidden) && hidden.stale === true,
294
- 'the hidden call is stale',
295
- );
296
-
297
- assert.ok(
298
- unresolvedEvents(sessionKey).some(
299
- (d) => d.kind === 'fold-hidden' && d.tool === 'base_tool' && d.fold === 'my_fold',
300
- ),
301
- 'a fold-hidden tool.unresolved meta event should be recorded',
302
- );
303
- });
304
-
305
- stale('splits stale vs hallucinated tools on the unknown-tool-limit error', async () => {
306
- const sessionKey = 'stale-limit-test';
307
- clearMetaEventRegistry();
308
-
309
- let state: 'A' | 'B' = 'A';
310
- const config = agent({
311
- name: 'Stateful',
312
- toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
313
- toolHandlers: () =>
314
- state === 'A'
315
- ? {
316
- tool_a: async () => {
317
- state = 'B';
318
- return 'advanced to B';
319
- },
320
- }
321
- : { tool_b: async () => 'b done' },
322
- });
323
-
324
- // One real call to advance to B, then 10 consecutive stale calls — the 10th
325
- // trips the stale ceiling (MAX_STALE_TOOL_CALLS, 2x the hallucination limit) and ends the turn.
326
- const provider = scriptedProvider([
327
- callsTool('tool_a', 'real'),
328
- ...Array.from({ length: 10 }, (_unused, i) => callsTool('tool_a', `stale-${i}`)),
329
- ]);
330
- const driver = makeDriver(config, provider, sessionKey);
331
-
332
- const result = await driver.sendMessage('go');
333
- assert.is(result.reason, 'done');
334
-
335
- const limitError = getMetaEvents(sessionKey).find(
336
- (e) => e.type === 'turn.error' && e.detail?.reason === 'unknown-tool-limit',
337
- );
338
- assert.ok(limitError, 'hitting the limit should record an unknown-tool-limit turn.error');
339
- const detail = limitError!.detail!;
340
- assert.equal(detail.staleTools, ['tool_a'], 'tool_a should be classified as stale');
341
- assert.equal(detail.hallucinatedTools, [], 'nothing was hallucinated');
342
-
343
- // Every stale attempt — not just the final limit error — is in the download log.
344
- assert.is(
345
- unresolvedEvents(sessionKey).filter((d) => d.kind === 'stale').length,
346
- 10,
347
- 'each stale attempt should be recorded as its own tool.unresolved event',
348
- );
349
-
350
- // The user-facing turn ends with the apology, not a crash.
351
- const last = driver.getHistory().at(-1);
352
- assert.ok(last?.role === 'assistant' && last.content.startsWith("I'm sorry"));
353
- });
354
-
355
- stale.run();
356
-
357
- // ---------------------------------------------------------------------------
358
- // onUnresolvedTool hook — an agent can redirect an unresolved tool call
359
- // ---------------------------------------------------------------------------
360
-
361
- const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');
362
-
363
- hook('replaces the default for a hallucinated tool when the hook returns a string', async () => {
364
- const config = agent({
365
- name: 'Hooked',
366
- toolDefinitions: [def('real_tool')],
367
- toolHandlers: { real_tool: async () => 'ok' },
368
- onUnresolvedTool: ({ toolName, kind, availableTools }) =>
369
- `redirect: ${toolName} is ${kind}; use ${availableTools.join(', ')}`,
370
- });
371
- const provider = scriptedProvider([callsTool('made_up', 'm1')]);
372
- const driver = makeDriver(config, provider);
373
-
374
- await driver.sendMessage('go');
375
-
376
- assert.ok(
377
- toolResultContents(driver).includes('redirect: made_up is unknown; use real_tool'),
378
- 'the hook string should replace the default Unknown tool message',
379
- );
380
- assert.not.ok(
381
- toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')),
382
- 'the default unknown-tool message must not appear',
383
- );
384
- });
385
-
386
- hook('replaces the default for a stale tool when the hook returns a string', async () => {
387
- let state: 'A' | 'B' = 'A';
388
- const config = agent({
389
- name: 'HookedStateful',
390
- toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
391
- toolHandlers: () =>
392
- state === 'A'
393
- ? {
394
- tool_a: async () => {
395
- state = 'B';
396
- return 'advanced to B';
397
- },
398
- }
399
- : { tool_b: async () => 'b done' },
400
- onUnresolvedTool: ({ toolName, kind }) => `redirect: ${toolName}/${kind}`,
401
- });
402
- const provider = scriptedProvider([
403
- callsTool('tool_a', 't1'), // real — advances A -> B
404
- callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
405
- callsTool('tool_b', 't3'), // real — valid in state B
406
- ]);
407
- const driver = makeDriver(config, provider);
408
-
409
- await driver.sendMessage('go');
410
-
411
- assert.ok(
412
- toolResultContents(driver).includes('redirect: tool_a/stale'),
413
- 'the hook string should replace the default stale message',
414
- );
415
- assert.not.ok(
416
- toolResultContents(driver).some((c) =>
417
- c.includes('was available earlier but is not part of the current step'),
418
- ),
419
- 'the default stale message must not appear',
420
- );
421
- });
422
-
423
- hook('falls back to the default when the hook returns undefined', async () => {
424
- const config = agent({
425
- name: 'HookUndefined',
426
- toolDefinitions: [def('real_tool')],
427
- toolHandlers: { real_tool: async () => 'ok' },
428
- onUnresolvedTool: () => undefined,
429
- });
430
- const provider = scriptedProvider([callsTool('made_up', 'm1')]);
431
- const driver = makeDriver(config, provider);
432
-
433
- await driver.sendMessage('go');
434
-
435
- assert.ok(
436
- toolResultContents(driver).includes('Unknown tool: made_up'),
437
- 'an undefined hook result should fall back to the default message',
438
- );
439
- });
440
-
441
- hook('falls back to the default when the hook returns a whitespace-only string', async () => {
442
- const config = agent({
443
- name: 'HookEmpty',
444
- toolDefinitions: [def('real_tool')],
445
- toolHandlers: { real_tool: async () => 'ok' },
446
- onUnresolvedTool: () => ' ',
447
- });
448
- const provider = scriptedProvider([callsTool('made_up', 'm1')]);
449
- const driver = makeDriver(config, provider);
450
-
451
- await driver.sendMessage('go');
452
-
453
- assert.ok(
454
- toolResultContents(driver).includes('Unknown tool: made_up'),
455
- 'a whitespace-only hook result should fall back to the default message',
456
- );
457
- });
458
-
459
- hook('falls back to the default when the hook throws', async () => {
460
- const config = agent({
461
- name: 'HookThrows',
462
- toolDefinitions: [def('real_tool')],
463
- toolHandlers: { real_tool: async () => 'ok' },
464
- onUnresolvedTool: () => {
465
- throw new Error('boom');
466
- },
467
- });
468
- const provider = scriptedProvider([callsTool('made_up', 'm1')]);
469
- const driver = makeDriver(config, provider);
470
-
471
- await driver.sendMessage('go');
472
-
473
- assert.ok(
474
- toolResultContents(driver).includes('Unknown tool: made_up'),
475
- 'a throwing hook should fall back to the default message',
476
- );
477
- });
478
-
479
- hook.run();
480
-
481
- // ---------------------------------------------------------------------------
482
- // timeout handling — a transport `TimeoutError` surfaces a distinct message
483
- // ---------------------------------------------------------------------------
484
-
485
- const timeout = createLogicSuite('ChatDriver timeout handling');
486
-
487
- /** A provider whose `chat` rejects with the `TimeoutError` the transports tag
488
- * a request timeout with (see gemini/anthropic `post`). */
489
- const timesOutProvider = (): AIProvider => ({
490
- chat: async (): Promise<ChatMessage> => {
491
- throw new DOMException('Gemini request timed out', 'TimeoutError');
492
- },
493
- });
494
-
495
- timeout('surfaces a timeout-specific message instead of the generic failure', async () => {
496
- clearMetaEventRegistry();
497
- const config = agent({
498
- name: 'Static',
499
- toolDefinitions: [def('noop')],
500
- toolHandlers: { noop: async () => 'ok' },
501
- });
502
- const sessionKey = 'timeout-test';
503
- const driver = makeDriver(config, timesOutProvider(), sessionKey);
504
-
505
- const result = await driver.sendMessage('go');
506
- assert.is(result.reason, 'done');
507
-
508
- // The turn ends with the timeout message, NOT the generic "something went wrong".
509
- const last = driver.getHistory().at(-1);
510
- assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
511
- assert.ok(last!.content.includes('timed out'), 'message names the timeout');
512
- assert.not.ok(
513
- last!.content.includes('something went wrong'),
514
- 'must not fall through to the generic catch',
515
- );
516
-
517
- // Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
518
- // not swallowed silently.
519
- const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
520
- assert.ok(err, 'a turn.error should be recorded');
521
- assert.is(err!.detail?.reason, 'exception');
522
- assert.is(err!.detail?.name, 'TimeoutError');
523
- });
524
-
525
- timeout.run();
526
-
527
- // ---------------------------------------------------------------------------
528
- // cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
529
- // ---------------------------------------------------------------------------
530
-
531
- const cancel = createLogicSuite('ChatDriver cancellation');
532
-
533
- cancel('threads a non-aborted signal into the provider call by default', async () => {
534
- const config = agent({
535
- name: 'Static',
536
- toolDefinitions: [def('noop')],
537
- toolHandlers: { noop: async () => 'ok' },
538
- });
539
- const capture: { signal?: AbortSignal } = {};
540
- const provider: AIProvider = {
541
- chat: async (_h, _m, options): Promise<ChatMessage> => {
542
- capture.signal = options?.signal;
543
- return { role: 'assistant', content: 'hi' };
544
- },
545
- };
546
- const driver = makeDriver(config, provider, 'signal-plumb');
547
-
548
- await driver.sendMessage('go');
549
-
550
- assert.ok(capture.signal, 'provider received an options.signal');
551
- assert.not.ok(capture.signal!.aborted, 'and it is not aborted on a normal turn');
552
- });
553
-
554
- cancel('dispose() aborts the in-flight request and stops quietly', async () => {
555
- clearMetaEventRegistry();
556
- const config = agent({
557
- name: 'Static',
558
- toolDefinitions: [def('noop')],
559
- toolHandlers: { noop: async () => 'ok' },
560
- });
561
- // A provider that hangs until its signal aborts, then rejects with the
562
- // signal's reason — mirroring how the transport surfaces an aborted fetch.
563
- const capture: { signal?: AbortSignal } = {};
564
- const provider: AIProvider = {
565
- chat: (_h, _m, options): Promise<ChatMessage> => {
566
- const signal = options?.signal;
567
- capture.signal = signal;
568
- return new Promise<ChatMessage>((_resolve, reject) => {
569
- if (!signal) return;
570
- if (signal.aborted) {
571
- reject(signal.reason);
572
- return;
573
- }
574
- signal.addEventListener('abort', () => reject(signal.reason), { once: true });
575
- });
576
- },
577
- };
578
- const driver = makeDriver(config, provider, 'cancel-test');
579
-
580
- const pending = driver.sendMessage('go');
581
- driver.dispose();
582
- const result = await pending;
583
-
584
- assert.is(result.reason, 'done');
585
- assert.ok(capture.signal?.aborted, 'the provider signal aborted on dispose');
586
- assert.is(capture.signal!.reason.name, 'AbortError');
587
-
588
- // Quiet: no assistant message appended (would otherwise pollute cached history).
589
- const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
590
- assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
591
- assert.not.ok(
592
- driver.getHistory().some((m) => m.content?.includes('something went wrong')),
593
- 'must not surface the generic failure',
594
- );
595
- });
596
-
597
- cancel(
598
- 'cancel() stops the turn with a "Stopped." marker and leaves the driver usable',
599
- async () => {
600
- clearMetaEventRegistry();
601
- const config = agent({
602
- name: 'Static',
603
- toolDefinitions: [def('noop')],
604
- toolHandlers: { noop: async () => 'ok' },
605
- });
606
- let call = 0;
607
- const provider: AIProvider = {
608
- chat: (_h, _m, options): Promise<ChatMessage> => {
609
- call += 1;
610
- if (call === 1) {
611
- // First turn hangs until its signal aborts (the user cancel). Must also
612
- // handle the already-aborted case: cancel can land before chat runs
613
- // (during the provider-resolution await), so the signal may arrive
614
- // aborted — mirror how real fetch rejects immediately in that case.
615
- const signal = options!.signal!;
616
- return new Promise<ChatMessage>((_resolve, reject) => {
617
- if (signal.aborted) {
618
- reject(signal.reason);
619
- return;
620
- }
621
- signal.addEventListener('abort', () => reject(signal.reason), { once: true });
622
- });
623
- }
624
- // Second turn resolves normally — proves the driver wasn't bricked.
625
- return Promise.resolve({ role: 'assistant', content: 'second response' });
626
- },
627
- };
628
- const driver = makeDriver(config, provider, 'cancel-stop');
629
-
630
- const pending = driver.sendMessage('go');
631
- driver.cancel();
632
- const result = await pending;
633
-
634
- assert.is(result.reason, 'done');
635
- // Subtle 'Stopped.' marker (system-event, not a generic failure).
636
- const last = driver.getHistory().at(-1);
637
- assert.is(last?.role, 'system-event');
638
- assert.is(last?.content, 'Stopped.');
639
-
640
- // The driver is still usable for the next message.
641
- const second = await driver.sendMessage('again');
642
- assert.is(second.reason, 'done');
643
- assert.ok(
644
- driver.getHistory().some((m) => m.content === 'second response'),
645
- 'a fresh turn runs after a cancel',
646
- );
647
- },
648
- );
649
-
650
- cancel('a tool already running finishes, then the loop stops at the boundary', async () => {
651
- let toolFinished = false;
652
- let secondChatCalled = false;
653
- const config = agent({
654
- name: 'Static',
655
- toolDefinitions: [def('slow')],
656
- toolHandlers: {
657
- slow: async () => {
658
- // Cancel mid-tool: the tool must still run to completion.
659
- driver.cancel();
660
- await Promise.resolve();
661
- toolFinished = true;
662
- return 'tool done';
663
- },
664
- },
665
- });
666
- let call = 0;
667
- const provider: AIProvider = {
668
- chat: async (): Promise<ChatMessage> => {
669
- call += 1;
670
- if (call === 1) {
671
- return {
672
- role: 'assistant',
673
- content: '',
674
- toolCalls: [{ id: 't1', name: 'slow', args: {} }],
675
- };
676
- }
677
- secondChatCalled = true;
678
- return { role: 'assistant', content: 'should not be reached' };
679
- },
680
- };
681
- const driver = makeDriver(config, provider, 'cancel-tool');
682
-
683
- const result = await driver.sendMessage('go');
684
-
685
- assert.is(result.reason, 'done');
686
- assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
687
- assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
688
- const last = driver.getHistory().at(-1);
689
- assert.is(last?.role, 'system-event');
690
- assert.is(last?.content, 'Stopped.');
691
- });
692
-
693
- cancel('cancel() is a no-op when no turn is running', () => {
694
- const config = agent({
695
- name: 'Static',
696
- toolDefinitions: [def('noop')],
697
- toolHandlers: { noop: async () => 'ok' },
698
- });
699
- const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');
700
-
701
- driver.cancel(); // must not throw or append anything while idle
702
-
703
- assert.is(driver.getHistory().length, 0);
704
- });
705
-
706
- cancel.run();
707
-
708
- // ---------------------------------------------------------------------------
709
- // sub-agents — forced tool use + typed completion/failure union (GENC-1312)
710
- //
711
- // A child sub-agent driver shares the parent's provider registry, so one
712
- // scripted queue drives both: script the parent's delegating turn, then the
713
- // worker's turn(s), in order.
714
- // ---------------------------------------------------------------------------
715
-
716
- const subagent = createLogicSuite('ChatDriver sub-agents');
717
-
718
- /** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
719
- const completingWorker = (result: unknown): AgentConfig =>
720
- agent({
721
- name: 'worker',
722
- toolDefinitions: [def('finish')],
723
- toolHandlers: {
724
- finish: async (_args, ctx) => {
725
- ctx.completeSubAgent?.(result);
726
- return 'finished';
727
- },
728
- },
729
- });
730
-
731
- /** A parent that delegates to `worker` and reports the outcome via `capture`. */
732
- const delegatingParent = (sub: AgentConfig, capture: (outcome: unknown) => void): AgentConfig =>
733
- agent({
734
- name: 'boss',
735
- subAgents: [sub],
736
- toolDefinitions: [def('delegate')],
737
- toolHandlers: {
738
- delegate: async (_args, ctx) => {
739
- const outcome = await ctx.requestSubAgent!('worker', { task: 'do it' });
740
- capture(outcome);
741
- return outcome.ok ? 'sub-agent completed' : `sub-agent failed: ${outcome.reason}`;
742
- },
743
- },
744
- });
745
-
746
- subagent('resolves { ok: true, result } when the sub-agent calls completeSubAgent', async () => {
747
- const sessionKey = 'subagent-success-events';
748
- clearMetaEventRegistry();
749
-
750
- let outcome: unknown;
751
- const parent = delegatingParent(completingWorker({ value: 42 }), (o) => {
752
- outcome = o;
753
- });
754
- const provider = scriptedProvider([
755
- callsTool('delegate', 'd1'), // parent delegates to the worker
756
- callsTool('finish', 'f1'), // worker completes
757
- ]);
758
-
759
- const driver = makeDriver(parent, provider, sessionKey);
760
- await driver.sendMessage('go');
761
-
762
- assert.equal(outcome, { ok: true, result: { value: 42 } });
763
- // The delegation is bracketed on the parent timeline by started + completed
764
- // (no subagent.failed), mirroring turn.start/turn.end.
765
- const subEvents = getMetaEvents(sessionKey).filter((e) => e.type.startsWith('subagent.'));
766
- assert.equal(
767
- subEvents.map((e) => e.type),
768
- ['subagent.started', 'subagent.completed'],
769
- );
770
- assert.is(subEvents[0].detail?.agent, 'worker');
771
- assert.is(subEvents[1].detail?.agent, 'worker');
772
-
773
- // The sub-agent trace lives on the tool CALL (read by the UI trace panel and
774
- // sumCosts) and is NOT duplicated onto the tool RESULT.
775
- const callMsg = driver.getHistory().find((m) => m.toolCalls?.some((c) => c.name === 'delegate'));
776
- const delegateCall = callMsg?.toolCalls?.find((c) => c.name === 'delegate');
777
- assert.ok(delegateCall?.subAgentTrace?.length, 'the tool call carries the sub-agent trace');
778
- const resultMsg = driver.getHistory().find((m) => m.role === 'tool' && !!m.toolResult);
779
- assert.not.ok(
780
- (resultMsg?.toolResult as { subAgentTrace?: unknown } | undefined)?.subAgentTrace,
781
- 'the tool result must not duplicate the trace',
782
- );
783
- });
784
-
785
- subagent('forces tool use on the sub-agent turn but not the parent turn', async () => {
786
- const parent = delegatingParent(completingWorker({ done: true }), () => {});
787
- const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
788
-
789
- await makeDriver(parent, provider).sendMessage('go');
790
-
791
- // Call 0 is the parent's turn (may-call); call 1 is the worker's turn (must-call).
792
- assert.is(provider.toolChoicePerCall[0], undefined, 'parent turn is not forced');
793
- assert.is(provider.toolChoicePerCall[1], 'required', 'sub-agent turn forces a tool call');
794
- assert.ok(
795
- provider.advertisedPerCall[1].includes('finish'),
796
- 'the worker advertised its completion tool',
797
- );
798
- });
799
-
800
- subagent(
801
- 'resolves { ok: false, reason } and records telemetry when the sub-agent never completes',
802
- async () => {
803
- const sessionKey = 'subagent-unknown-tool-test';
804
- clearMetaEventRegistry();
805
-
806
- let outcome: unknown;
807
- const worker = agent({
808
- name: 'worker',
809
- toolDefinitions: [def('real')],
810
- toolHandlers: { real: async () => 'ok' },
811
- });
812
- const parent = delegatingParent(worker, (o) => {
813
- outcome = o;
814
- });
815
-
816
- // The worker repeatedly calls a tool it was never given, tripping the
817
- // unknown-tool limit (DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5) without completing.
818
- const provider = scriptedProvider([
819
- callsTool('delegate', 'd1'),
820
- ...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
821
- ]);
822
-
823
- await makeDriver(parent, provider, sessionKey).sendMessage('go');
824
-
825
- assert.equal(outcome, { ok: false, reason: 'unknown_tool_limit' });
826
- // The failure surfaces as a high-importance `subagent.failed` meta event,
827
- // recorded under the PARENT driver's session so it lands on the user-visible
828
- // debug-log timeline — not orphaned in the child's own session bucket.
829
- assert.ok(
830
- getMetaEvents(sessionKey).some(
831
- (e) =>
832
- e.type === 'subagent.failed' &&
833
- e.detail?.agent === 'worker' &&
834
- e.detail?.reason === 'unknown_tool_limit',
835
- ),
836
- 'a subagent.failed meta event should be recorded under the parent session',
837
- );
838
- assert.not.ok(
839
- getMetaEvents('').some((e) => e.type === 'subagent.failed'),
840
- 'the failure must not be orphaned in the child default session bucket',
841
- );
842
- },
843
- );
844
-
845
- subagent(
846
- "folds the sub-agent's high-value meta events into the parent session, breadcrumbed",
847
- async () => {
848
- const sessionKey = 'subagent-meta-harvest-test';
849
- clearMetaEventRegistry();
850
-
851
- const worker = agent({
852
- name: 'worker',
853
- toolDefinitions: [def('real')],
854
- toolHandlers: { real: async () => 'ok' },
855
- });
856
- const parent = delegatingParent(worker, () => {});
857
- // The worker trips the unknown-tool limit — emitting tool.unresolved + a
858
- // turn.error under its own transient session, which the parent harvests.
859
- const provider = scriptedProvider([
860
- callsTool('delegate', 'd1'),
861
- ...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
862
- ]);
863
-
864
- await makeDriver(parent, provider, sessionKey).sendMessage('go');
865
-
866
- const events = getMetaEvents(sessionKey);
867
- // The sub-agent's turn.error is folded onto the parent timeline, breadcrumbed.
868
- const harvestedError = events.find(
869
- (e) => e.type === 'turn.error' && e.detail?.subAgent === 'boss › worker',
870
- );
871
- assert.ok(harvestedError, "the sub-agent's turn.error is folded in, breadcrumbed");
872
- assert.is(harvestedError!.detail?.reason, 'unknown-tool-limit');
873
- // ... as are its per-attempt tool.unresolved signals.
874
- assert.ok(
875
- events.some((e) => e.type === 'tool.unresolved' && e.detail?.subAgent === 'boss › worker'),
876
- "the sub-agent's tool.unresolved events are folded in too",
877
- );
878
- // High-volume, message-derivable events are NOT merged.
879
- assert.not.ok(
880
- events.some((e) => e.type === 'turn.start' && e.detail?.subAgent === 'boss › worker'),
881
- 'turn.start is intentionally excluded from the harvest',
882
- );
883
- // The child's transient bucket is cleared, never polluting the empty-key sink.
884
- assert.is(getMetaEvents('').length, 0);
885
- },
886
- );
887
-
888
- subagent(
889
- 'defaults to { ok: false, reason: "max_iterations" } when the sub-agent ends without completing',
890
- async () => {
891
- const sessionKey = 'subagent-default-fail-test';
892
- clearMetaEventRegistry();
893
-
894
- let outcome: unknown;
895
- const worker = agent({
896
- name: 'worker',
897
- toolDefinitions: [def('noop')],
898
- toolHandlers: { noop: async () => 'ok' },
899
- });
900
- const parent = delegatingParent(worker, (o) => {
901
- outcome = o;
902
- });
903
- // No script for the worker turn → it returns a plain-text reply and ends
904
- // without ever calling a completion tool (the child records no explicit
905
- // failure reason).
906
- const provider = scriptedProvider([callsTool('delegate', 'd1')]);
907
-
908
- await makeDriver(parent, provider, sessionKey).sendMessage('go');
909
-
910
- assert.equal(outcome, { ok: false, reason: 'max_iterations' });
911
- // Even the defensive default is reported to the parent session — this is the
912
- // only telemetry path when the child recorded no explicit failure.
913
- assert.ok(
914
- getMetaEvents(sessionKey).some(
915
- (e) => e.type === 'subagent.failed' && e.detail?.reason === 'max_iterations',
916
- ),
917
- 'the default failure should still record a subagent.failed meta event',
918
- );
919
- },
920
- );
921
-
922
- subagent(
923
- "forwards the sub-agent's turns onto the parent timeline, numbered under the activating turn",
924
- async () => {
925
- const parent = delegatingParent(completingWorker({ done: true }), () => {});
926
- const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
927
- const driver = makeDriver(parent, provider);
928
-
929
- await driver.sendMessage('go');
930
-
931
- const snaps = driver.getTurnSnapshots();
932
- // Parent turn 0 activated the sub-agent, so the worker's single turn is "0-1".
933
- const childSnap = snaps.find((s) => s.turnIndex === '0-1');
934
- assert.ok(childSnap, 'the sub-agent\'s turn should be forwarded as "0-1"');
935
- assert.is(childSnap!.agentName, 'worker', 'the forwarded snapshot keeps the sub-agent name');
936
- assert.ok(childSnap!.toolNames.includes('finish'), 'and records the tools the sub-agent saw');
937
- // The parent's own turns stay numeric.
938
- assert.ok(
939
- snaps.some((s) => s.turnIndex === '0'),
940
- 'the activating parent turn is present as a bare string counter',
941
- );
942
- },
943
- );
944
-
945
- subagent(
946
- 'resolves { ok: false, reason: "timeout" } and aborts the run when the sub-agent exceeds timeoutMs',
947
- async () => {
948
- const sessionKey = 'subagent-timeout-test';
949
- clearMetaEventRegistry();
950
-
951
- let outcome: unknown;
952
- // The worker would complete if allowed to run — but its turn hangs (below),
953
- // so the parent's timeout fires first.
954
- const worker = agent({
955
- name: 'worker',
956
- toolDefinitions: [def('finish')],
957
- toolHandlers: {
958
- finish: async (_args, ctx) => {
959
- ctx.completeSubAgent?.({ ok: true });
960
- return 'finished';
961
- },
962
- },
963
- });
964
- const parent = agent({
965
- name: 'boss',
966
- subAgents: [worker],
967
- toolDefinitions: [def('delegate')],
968
- toolHandlers: {
969
- delegate: async (_args, ctx) => {
970
- const o = await ctx.requestSubAgent!('worker', { task: 'do it', timeoutMs: 20 });
971
- outcome = o;
972
- return o.ok ? 'sub-agent completed' : `sub-agent failed: ${o.reason}`;
973
- },
974
- },
975
- });
976
-
977
- // Call 0: parent delegates. Call 1: the worker's turn hangs until its signal
978
- // aborts (the timeout-driven dispose). Call 2: the parent wraps up.
979
- let call = 0;
980
- const provider: AIProvider = {
981
- chat: (_h, _m, options): Promise<ChatMessage> => {
982
- const n = call;
983
- call += 1;
984
- if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
985
- if (n === 1) {
986
- return new Promise<ChatMessage>((_resolve, reject) => {
987
- const signal = options?.signal;
988
- if (!signal) return;
989
- if (signal.aborted) {
990
- reject(signal.reason);
991
- return;
992
- }
993
- signal.addEventListener('abort', () => reject(signal.reason), { once: true });
994
- });
995
- }
996
- return Promise.resolve({ role: 'assistant', content: 'done' });
997
- },
998
- };
999
-
1000
- await makeDriver(parent, provider, sessionKey).sendMessage('go');
1001
-
1002
- assert.equal(outcome, { ok: false, reason: 'timeout' });
1003
- // Recorded under the parent session, like every other sub-agent failure.
1004
- assert.ok(
1005
- getMetaEvents(sessionKey).some(
1006
- (e) => e.type === 'subagent.failed' && e.detail?.reason === 'timeout',
1007
- ),
1008
- 'a subagent.failed meta event with reason "timeout" should be recorded',
1009
- );
1010
- },
1011
- );
1012
-
1013
- subagent(
1014
- 'times out (without hanging) when the sub-agent is parked on a user interaction',
1015
- async () => {
1016
- const sessionKey = 'subagent-interaction-timeout';
1017
- clearMetaEventRegistry();
1018
-
1019
- let outcome: unknown;
1020
- // The worker asks a question and waits; the user never answers. The parent's
1021
- // timeout must cancel that interaction and unblock the worker — if it didn't,
1022
- // `await runPromise` inside invokeSubAgent would hang here forever (the worker
1023
- // is parked on a promise only the parent can settle), and this test would
1024
- // never resolve.
1025
- const worker = agent({
1026
- name: 'worker',
1027
- toolDefinitions: [def('ask')],
1028
- toolHandlers: {
1029
- ask: async (_args, ctx) => {
1030
- const r = await ctx.requestInteraction('q', {}); // parks on the parent
1031
- return { answered: r };
1032
- },
1033
- },
1034
- });
1035
- const parent = agent({
1036
- name: 'boss',
1037
- subAgents: [worker],
1038
- toolDefinitions: [def('delegate')],
1039
- toolHandlers: {
1040
- delegate: async (_args, ctx) => {
1041
- const o = await ctx.requestSubAgent!('worker', { task: 'ask', timeoutMs: 20 });
1042
- outcome = o;
1043
- return o.ok ? 'ok' : `failed: ${o.reason}`;
1044
- },
1045
- },
1046
- });
1047
-
1048
- // call 0: parent delegates. call 1: worker asks (parks on the interaction).
1049
- // Later calls: the disposed worker's turn sees an aborted signal and rejects;
1050
- // the parent's resuming turn (not aborted) ends with text.
1051
- let call = 0;
1052
- const provider: AIProvider = {
1053
- chat: (_h, _m, options): Promise<ChatMessage> => {
1054
- const n = call;
1055
- call += 1;
1056
- if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
1057
- if (n === 1) return Promise.resolve(callsTool('ask', 'a1'));
1058
- const signal = options?.signal;
1059
- if (signal?.aborted) return Promise.reject(signal.reason);
1060
- return Promise.resolve({ role: 'assistant', content: 'done' });
1061
- },
1062
- };
1063
-
1064
- await makeDriver(parent, provider, sessionKey).sendMessage('go');
1065
-
1066
- assert.equal(outcome, { ok: false, reason: 'timeout' });
1067
- assert.ok(
1068
- getMetaEvents(sessionKey).some(
1069
- (e) => e.type === 'subagent.failed' && e.detail?.reason === 'timeout',
1070
- ),
1071
- 'subagent.failed (timeout) should be recorded — proving it neither hung nor skipped logging',
1072
- );
1073
- },
1074
- );
1075
-
1076
- subagent(
1077
- 'fails the sub-agent with reason "timeout" (no apology text) on a transport TimeoutError',
1078
- async () => {
1079
- const sessionKey = 'subagent-transport-timeout-test';
1080
- clearMetaEventRegistry();
1081
-
1082
- let outcome: unknown;
1083
- const worker = agent({
1084
- name: 'worker',
1085
- toolDefinitions: [def('finish')],
1086
- toolHandlers: {
1087
- finish: async (_args, ctx) => {
1088
- ctx.completeSubAgent?.({ ok: true });
1089
- return 'finished';
1090
- },
1091
- },
1092
- });
1093
- const parent = delegatingParent(worker, (o) => {
1094
- outcome = o;
1095
- });
1096
-
1097
- // Call 0: parent delegates. Call 1: the worker's request times out at the
1098
- // transport (tagged TimeoutError). Call 2: the parent wraps up.
1099
- let call = 0;
1100
- const provider: AIProvider = {
1101
- chat: (): Promise<ChatMessage> => {
1102
- const n = call;
1103
- call += 1;
1104
- if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
1105
- if (n === 1) {
1106
- return Promise.reject(new DOMException('Anthropic request timed out', 'TimeoutError'));
1107
- }
1108
- return Promise.resolve({ role: 'assistant', content: 'done' });
1109
- },
1110
- };
1111
-
1112
- const driver = makeDriver(parent, provider, sessionKey);
1113
- await driver.sendMessage('go');
1114
-
1115
- // The parent gets a typed timeout — not the wrong max_iterations default it
1116
- // used to see when the child answered with the user-facing apology instead.
1117
- assert.equal(outcome, { ok: false, reason: 'timeout' });
1118
- assert.ok(
1119
- getMetaEvents(sessionKey).some(
1120
- (e) =>
1121
- e.type === 'subagent.failed' &&
1122
- e.detail?.agent === 'worker' &&
1123
- e.detail?.reason === 'timeout',
1124
- ),
1125
- 'a subagent.failed meta event with reason "timeout" should be recorded',
1126
- );
1127
- // The child must not speak to the user.
1128
- assert.not.ok(
1129
- driver.getHistory().some((m) => m.content?.includes('The request timed out')),
1130
- 'the sub-agent must not append the user-facing timeout apology',
1131
- );
1132
- },
1133
- );
1134
-
1135
- subagent.run();
1136
-
1137
- // ---------------------------------------------------------------------------
1138
- // cost / token attribution on the thinking-step split (GENC-1410)
1139
- // ---------------------------------------------------------------------------
1140
-
1141
- const costAttribution = createLogicSuite('ChatDriver cost attribution (thinking-step split)');
1142
-
1143
- costAttribution(
1144
- 'a text-plus-tool-call response contributes its cost and tokens exactly once',
1145
- async () => {
1146
- const config = agent({
1147
- name: 'Worker',
1148
- toolDefinitions: () => [def('do_thing')],
1149
- toolHandlers: () => ({ do_thing: async () => 'ok' }),
1150
- });
1151
- // A single response that emits reasoning + interstitial narration + a tool call, carrying that
1152
- // response's usage. The driver splits it into three messages — a `reasoning` bubble, a
1153
- // `narration` bubble, and the tool-call message — but only ONE may carry the cost/tokens or the
1154
- // session totals double-count this one response (GENC-1410).
1155
- const provider = scriptedProvider([
1156
- {
1157
- role: 'assistant',
1158
- content: 'Let me act.',
1159
- reasoning: 'Let me think.',
1160
- toolCalls: [{ id: 't1', name: 'do_thing', args: {} }],
1161
- cost: 0.05,
1162
- inputTokens: 1000,
1163
- outputTokens: 50,
1164
- },
1165
- // Turn-ending reply (no tool calls, no usage) so the loop terminates cleanly.
1166
- { role: 'assistant', content: 'Done.' },
1167
- ]);
1168
- const driver = makeDriver(config, provider);
1169
-
1170
- await driver.sendMessage('go');
1171
-
1172
- const history = driver.getHistory();
1173
- const reasoning = history.find((m) => m.category === 'reasoning');
1174
- const narration = history.find((m) => m.category === 'narration');
1175
- const toolCallMsg = history.find((m) => m.toolCalls?.some((c) => c.name === 'do_thing'));
1176
-
1177
- // The one response was split into reasoning + narration bubbles + the tool-call message.
1178
- assert.ok(reasoning, 'a reasoning bubble is emitted for the chain-of-thought summary');
1179
- assert.is(reasoning!.content, 'Let me think.');
1180
- assert.ok(narration, 'a narration bubble is emitted for the interstitial prose');
1181
- assert.is(narration!.content, 'Let me act.');
1182
- assert.ok(toolCallMsg, 'the tool call is on its own message');
1183
- assert.is(toolCallMsg!.content, '');
1184
-
1185
- // Only the tool-call message carries the turn's usage; the display bubbles carry none.
1186
- for (const m of [reasoning!, narration!]) {
1187
- assert.is(m.cost, undefined, 'a display bubble must not carry cost');
1188
- assert.is(m.inputTokens, undefined, 'nor inputTokens');
1189
- assert.is(m.outputTokens, undefined, 'nor outputTokens');
1190
- }
1191
- assert.is(toolCallMsg!.cost, 0.05, 'the tool-call message carries the cost');
1192
- assert.is(toolCallMsg!.inputTokens, 1000);
1193
- assert.is(toolCallMsg!.outputTokens, 50);
1194
-
1195
- // The reasoning/narration bubbles are UI-only; they must not carry the tool call either.
1196
- assert.is(reasoning!.toolCalls, undefined, 'the reasoning bubble carries no tool call');
1197
- assert.is(narration!.toolCalls, undefined, 'the narration bubble carries no tool call');
1198
-
1199
- // The session totals count the one response once, not twice.
1200
- assert.is(sumCosts(history), 0.05, 'cost is summed once');
1201
- assert.is(sumTokens(history), 1050, 'tokens are summed once');
1202
- },
1203
- );
1204
-
1205
- costAttribution.run();
1206
-
1207
- // ---------------------------------------------------------------------------
1208
- // per-agent / per-state temperature & tool-call mode (GENC-1321)
1209
- //
1210
- // The driver resolves `temperature` and `toolChoice` the same way it resolves
1211
- // `provider`: a static value, or a function of the turn context (which carries
1212
- // the live state for stateful agents). These tests assert both forms reach the
1213
- // provider call, and that the per-turn (function) form re-resolves each call.
1214
- // ---------------------------------------------------------------------------
1215
-
1216
- const settings = createLogicSuite('ChatDriver temperature & toolChoice');
1217
-
1218
- settings('passes a static agent temperature and tool-call mode to the provider', async () => {
1219
- const provider = scriptedProvider([]);
1220
- const driver = makeDriver(
1221
- agent({
1222
- name: 'a',
1223
- temperature: 0.3,
1224
- toolChoice: 'none',
1225
- cachePolicy: { scope: 'prompt' },
1226
- tailContext: 'STATE',
1227
- }),
1228
- provider,
1229
- );
1230
-
1231
- await driver.sendMessage('hi');
1232
-
1233
- assert.is(provider.temperaturePerCall[0], 0.3);
1234
- assert.equal(provider.toolChoicePerCall[0], 'none');
1235
- assert.equal(provider.cachePolicyPerCall[0], { scope: 'prompt' });
1236
- // The driver frames the raw tail content in a <system-reminder> marker.
1237
- assert.is(provider.tailContextPerCall[0], '<system-reminder>\nSTATE\n</system-reminder>');
1238
- // ...and surfaced in the per-turn debug snapshot (the effective values sent).
1239
- const snap = driver.getTurnSnapshots()[0];
1240
- assert.is(snap.temperature, 0.3);
1241
- assert.equal(snap.toolChoice, 'none');
1242
- // The framed tail is captured on the snapshot too — it is never stored in
1243
- // history, so the snapshot is the only record of what the model actually saw.
1244
- assert.is(snap.tailContext, '<system-reminder>\nSTATE\n</system-reminder>');
1245
- });
1246
-
1247
- settings('re-resolves the function form per turn (carrying turn context)', async () => {
1248
- const provider = scriptedProvider([callsTool('tool_a', 't1')]);
1249
- const config = agent({
1250
- name: 'b',
1251
- toolDefinitions: [def('tool_a')],
1252
- toolHandlers: { tool_a: async () => 'ok' },
1253
- // turnIndex: 0 on the first LLM call, > 0 on subsequent tool-loop iterations.
1254
- temperature: (ctx) => (ctx.turnIndex === 0 ? 0.1 : 0.9),
1255
- toolChoice: (ctx) => (ctx.turnIndex === 0 ? { tool: 'tool_a' } : 'auto'),
1256
- cachePolicy: (ctx) => (ctx.turnIndex === 0 ? { scope: 'history' } : { scope: 'tools' }),
1257
- });
1258
-
1259
- await makeDriver(config, provider).sendMessage('go');
1260
-
1261
- // First call forces the named tool at a low temperature...
1262
- assert.is(provider.temperaturePerCall[0], 0.1);
1263
- assert.equal(provider.toolChoicePerCall[0], { tool: 'tool_a' });
1264
- assert.equal(provider.cachePolicyPerCall[0], { scope: 'history' });
1265
- // ...the follow-up call (after the tool result) sees the other branch.
1266
- assert.is(provider.temperaturePerCall[1], 0.9);
1267
- assert.equal(provider.toolChoicePerCall[1], 'auto');
1268
- assert.equal(provider.cachePolicyPerCall[1], { scope: 'tools' });
1269
- });
1270
-
1271
- settings(
1272
- 'leaves temperature and toolChoice unset when the agent does not configure them',
1273
- async () => {
1274
- const provider = scriptedProvider([]);
1275
-
1276
- await makeDriver(agent({ name: 'c' }), provider).sendMessage('hi');
1277
-
1278
- assert.is(provider.temperaturePerCall[0], undefined);
1279
- assert.is(provider.toolChoicePerCall[0], undefined);
1280
- assert.is(provider.cachePolicyPerCall[0], undefined);
1281
- assert.is(provider.tailContextPerCall[0], undefined);
1282
- },
1283
- );
1284
-
1285
- // GENC-1380 A3b: the framework's malformed/empty retry nudge is routed to the framed tail (so the
1286
- // system prompt stays byte-stable for caching), not appended to the system string.
1287
- settings(
1288
- 'routes the empty-response retry nudge into the framed tail, not the system prompt',
1289
- async () => {
1290
- // First call returns empty → the driver retries; the second exhausts the queue and ends the turn.
1291
- const provider = scriptedProvider([{ role: 'assistant', content: '' }]);
1292
-
1293
- const driver = makeDriver(agent({ name: 'r', systemPrompt: 'BASE' }), provider);
1294
- await driver.sendMessage('go');
1295
-
1296
- // The retry call (index 1) carries the nudge in the framed tail...
1297
- assert.ok(provider.tailContextPerCall[1]?.startsWith('<system-reminder>'), 'tail is framed');
1298
- assert.ok(
1299
- provider.tailContextPerCall[1]?.includes('You must respond'),
1300
- 'retry nudge is in the tail',
1301
- );
1302
- // ...and the system prompt stays the bare agent prompt on both calls (nudge moved, not copied).
1303
- assert.is(provider.systemPromptPerCall[0], 'BASE');
1304
- assert.is(provider.systemPromptPerCall[1], 'BASE');
1305
-
1306
- // Regression guard (debug-log reconstruction): the tail is never stored in
1307
- // history, so the per-turn snapshot must capture it — otherwise the retry
1308
- // nudge the model actually received would be invisible in the export. The
1309
- // snapshot's (systemPrompt + tailContext) must equal what the provider saw.
1310
- const retrySnap = driver.getTurnSnapshots()[1];
1311
- assert.is(retrySnap.systemPrompt, provider.systemPromptPerCall[1]);
1312
- assert.is(retrySnap.tailContext, provider.tailContextPerCall[1]);
1313
- assert.ok(retrySnap.tailContext?.includes('You must respond'), 'nudge reconstructable');
1314
- // The first (non-retry) call had no framework suffix and no agent tail → empty.
1315
- assert.is(driver.getTurnSnapshots()[0].tailContext, undefined);
1316
- },
1317
- );
1318
-
1319
- settings.run();
1320
-
1321
- // ---------------------------------------------------------------------------
1322
- // empty-response diagnostics (GENC-1321)
1323
- //
1324
- // The transport surfaces a provider diagnostic (Gemini: finishReason,
1325
- // thoughtsTokens, parts, blockReason) on the message; the driver must fold it
1326
- // into the empty-response meta events so the debug-log timeline shows *why* a
1327
- // turn came back blank — not just that it did.
1328
- // ---------------------------------------------------------------------------
1329
-
1330
- const emptyDiag = createLogicSuite('ChatDriver empty-response diagnostics');
1331
-
1332
- emptyDiag('folds the provider responseMeta into the empty-response meta events', async () => {
1333
- clearMetaEventRegistry();
1334
- const provider: AIProvider = {
1335
- chat: async (): Promise<ChatMessage> => ({
1336
- role: 'assistant',
1337
- content: '',
1338
- responseMeta: {
1339
- finishReason: 'STOP',
1340
- thoughtsTokens: 999,
1341
- parts: { functionCall: 0, thought: 0, text: 0 },
1342
- },
1343
- }),
1344
- };
1345
- const sessionKey = 'empty-diag';
1346
-
1347
- await makeDriver(agent({ name: 'Static' }), provider, sessionKey).sendMessage('go');
1348
-
1349
- // Both the in-turn retries and the final bail carry the diagnostic.
1350
- const retry = getMetaEvents(sessionKey).find(
1351
- (e) => e.type === 'turn.retry' && e.detail?.reason === 'empty-response',
1352
- );
1353
- assert.ok(retry, 'an empty-response turn.retry should be recorded');
1354
- assert.is(retry!.detail?.finishReason, 'STOP');
1355
- assert.is(retry!.detail?.thoughtsTokens, 999);
1356
-
1357
- const err = getMetaEvents(sessionKey).find(
1358
- (e) => e.type === 'turn.error' && e.detail?.reason === 'empty-response',
1359
- );
1360
- assert.ok(err, 'an empty-response turn.error should be recorded after retries');
1361
- assert.is(err!.detail?.finishReason, 'STOP');
1362
- assert.is(err!.detail?.thoughtsTokens, 999);
1363
- });
1364
-
1365
- emptyDiag.run();
1366
-
1367
- // ---------------------------------------------------------------------------
1368
- // synthetic-user echo — a resolved interaction can optionally surface its
1369
- // outcome on the user's side of the chat (GENC-1330). The widget opts in by
1370
- // returning `userMessage`; the driver appends it as a display-only
1371
- // `'synthetic-user'` message that transports never serialize.
1372
- // ---------------------------------------------------------------------------
1373
-
1374
- const syntheticUser = createLogicSuite('ChatDriver synthetic-user interaction echo');
1375
-
1376
- syntheticUser('resolveInteraction with userMessage appends a synthetic-user message', async () => {
1377
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1378
-
1379
- const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
1380
- const interactionMsg = driver.getHistory().at(-1)!;
1381
- const id = interactionMsg.interaction!.interactionId;
1382
-
1383
- driver.resolveInteraction(id, {
1384
- status: 'approved',
1385
- payload: { answer: 'Option A' },
1386
- userMessage: 'Option A',
1387
- });
1388
- await pending;
1389
-
1390
- const history = driver.getHistory();
1391
- const synthetic = history.filter((m) => m.role === 'synthetic-user');
1392
- assert.is(synthetic.length, 1);
1393
- assert.is(synthetic[0].content, 'Option A');
1394
-
1395
- // The original interaction message is still present and now marked resolved
1396
- // (the existing resolved-widget mechanism is untouched).
1397
- const resolved = history.find((m) => m.interaction?.interactionId === id);
1398
- assert.ok(resolved?.interaction?.resolved);
1399
-
1400
- // Chronology: the user-side echo follows the (resolved) interaction.
1401
- const idxInteraction = history.findIndex((m) => m.interaction?.interactionId === id);
1402
- const idxSynthetic = history.findIndex((m) => m.role === 'synthetic-user');
1403
- assert.ok(idxSynthetic > idxInteraction);
1404
-
1405
- // It is attributed to the agent that created it — same stamping as any other
1406
- // appended message — so the debug log can say which agent produced it.
1407
- assert.is(synthetic[0].agentName, resolved?.agentName);
1408
- });
1409
-
1410
- syntheticUser(
1411
- 'omitted or whitespace-only userMessage appends no synthetic-user message',
1412
- async () => {
1413
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1414
-
1415
- // Omitted entirely — the previous behaviour.
1416
- const first = driver.requestInteraction('w', {});
1417
- const firstId = driver.getHistory().at(-1)!.interaction!.interactionId;
1418
- driver.resolveInteraction(firstId, { status: 'approved' });
1419
- await first;
1420
-
1421
- // Whitespace-only — treated as absent, never appended.
1422
- const second = driver.requestInteraction('w', {});
1423
- const secondId = driver.getHistory().at(-1)!.interaction!.interactionId;
1424
- driver.resolveInteraction(secondId, { status: 'approved', userMessage: ' ' });
1425
- await second;
1426
-
1427
- assert.is(driver.getHistory().filter((m) => m.role === 'synthetic-user').length, 0);
1428
- },
1429
- );
1430
-
1431
- syntheticUser.run();
1432
-
1433
- // ---------------------------------------------------------------------------
1434
- // interaction presentation — the per-call `presentation` option is stamped
1435
- // onto the appended interaction so the renderer can frame the widget, and is
1436
- // absent (the historical default) when the option is omitted.
1437
- // ---------------------------------------------------------------------------
1438
-
1439
- const interactionPresentation = createLogicSuite('ChatDriver interaction presentation');
1440
-
1441
- interactionPresentation(
1442
- 'presentation option is stamped onto the appended interaction',
1443
- async () => {
1444
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1445
-
1446
- const pending = driver.requestInteraction('w', {}, { presentation: 'bubble' });
1447
- const interaction = driver.getHistory().at(-1)!.interaction!;
1448
- assert.is(interaction.presentation, 'bubble');
1449
-
1450
- // Clean up the pending interaction so it does not dangle.
1451
- driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1452
- await pending;
1453
- },
1454
- );
1455
-
1456
- interactionPresentation('presentation is absent when the option is omitted', async () => {
1457
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1458
-
1459
- const pending = driver.requestInteraction('w', {});
1460
- const interaction = driver.getHistory().at(-1)!.interaction!;
1461
- assert.ok(!('presentation' in interaction));
1462
-
1463
- driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1464
- await pending;
1465
- });
1466
-
1467
- interactionPresentation.run();
1468
-
1469
- // ---------------------------------------------------------------------------
1470
- // external diagnostics harvest (GENC-1461) — a widget may return an out-of-band
1471
- // engine's collated debug log on `InteractionResult.diagnostics` (e.g. a
1472
- // server-side ChatDriver's). Those entries carry the SOURCE engine's own 0-based
1473
- // index space, which would collide with this host's forward-capture cursors and
1474
- // be dropped: the persister dedups `turn` entries by `turnIndex` and `event`
1475
- // entries by an `index` high-water mark. So the driver decomposes the harvest by
1476
- // kind — events fold into the session's meta-event registry (re-indexed onto the
1477
- // host's monotonic counter), turns are re-keyed into a per-batch namespace, and
1478
- // messages pass through unchanged (the persister already keys those by content).
1479
- // ---------------------------------------------------------------------------
1480
-
1481
- const externalDiagnostics = createLogicSuite('ChatDriver external diagnostics harvest');
1482
-
1483
- /** A `diagnostics` payload as a headless engine would collate it — one of each kind,
1484
- * all carrying that engine's own 0-based indices (`turnIndex: '0'`, event `index: 0`). */
1485
- const harvestPayload = (marker: string) => [
1486
- { kind: 'message', timestamp: '2026-07-23T10:00:00.000Z', role: 'assistant', content: marker },
1487
- { kind: 'turn', timestamp: '2026-07-23T10:00:01.000Z', turnIndex: '0', systemPrompt: 'sys' },
1488
- {
1489
- kind: 'event',
1490
- index: 0,
1491
- timestamp: '2026-07-23T10:00:02.000Z',
1492
- type: 'turn.error',
1493
- importance: 'high',
1494
- detail: { marker },
1495
- },
1496
- ];
1497
-
1498
- externalDiagnostics(
1499
- 'routes harvested events to the meta-event registry (re-indexed) and keeps turns + messages',
1500
- async () => {
1501
- clearMetaEventRegistry();
1502
- const sessionKey = 'external-diag-harvest';
1503
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
1504
-
1505
- // Requesting the interaction records a host `interaction.requested` meta-event, so the
1506
- // registry's index counter is already advanced when the harvested event is merged — exactly
1507
- // the condition that made the foreign 0-based `index` lose the persister's high-water race.
1508
- const pending = driver.requestInteraction('w', {});
1509
- const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1510
- const requestedIndex = getMetaEvents(sessionKey).find(
1511
- (e) => e.type === 'interaction.requested',
1512
- )!.index;
1513
-
1514
- driver.resolveInteraction(interactionId, {
1515
- status: 'approved',
1516
- diagnostics: harvestPayload('m1'),
1517
- });
1518
- await pending;
1519
-
1520
- // The event left the external buffer and landed in the registry, re-indexed ABOVE the host's
1521
- // prior event (so it beats the persister's `index <= lastEventIndex` high-water), timestamp kept.
1522
- const external = driver.getExternalDiagnostics();
1523
- assert.is(
1524
- external.filter((e) => e.kind === 'event').length,
1525
- 0,
1526
- 'events do not stay in the buffer',
1527
- );
1528
- const merged = getMetaEvents(sessionKey).find((e) => e.detail?.marker === 'm1');
1529
- assert.ok(merged, 'the harvested event is folded into the session registry');
1530
- assert.ok(
1531
- merged!.index > requestedIndex,
1532
- 're-indexed onto the host counter, above prior events',
1533
- );
1534
- assert.is(
1535
- merged!.timestamp,
1536
- '2026-07-23T10:00:02.000Z',
1537
- 'original timestamp preserved for ordering',
1538
- );
1539
-
1540
- // The turn is kept in the buffer but re-keyed out of the host's bare-integer turn-key space,
1541
- // and the message passes through untouched.
1542
- const turn = external.find((e) => e.kind === 'turn');
1543
- assert.ok(turn, 'the harvested turn is kept in the external buffer');
1544
- assert.is(turn!.turnIndex, 'server-generation.1:0', 'turnIndex namespaced per batch');
1545
- const message = external.find((e) => e.kind === 'message');
1546
- assert.ok(message, 'the harvested message is kept in the external buffer');
1547
- assert.is(message!.content, 'm1', 'message passes through unchanged');
1548
-
1549
- // A single manifest event records the fold's provenance — the fragile boundary this feature
1550
- // guards is now observable: a dropped generation shows up as a zero/absent manifest.
1551
- const manifest = getMetaEvents(sessionKey).find(
1552
- (e) => e.type === 'external-diagnostics.folded',
1553
- );
1554
- assert.ok(manifest, 'a manifest meta-event is recorded for the fold');
1555
- assert.is(manifest!.detail?.interactionId, interactionId);
1556
- assert.equal(manifest!.detail?.counts, { turn: 1, event: 1, message: 1 });
1557
- },
1558
- );
1559
-
1560
- externalDiagnostics(
1561
- 'namespaces each batch so distinct server generations cannot collide on turnIndex',
1562
- async () => {
1563
- clearMetaEventRegistry();
1564
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-batches');
1565
-
1566
- const first = driver.requestInteraction('w', {});
1567
- const firstId = driver.getHistory().at(-1)!.interaction!.interactionId;
1568
- driver.resolveInteraction(firstId, { status: 'approved', diagnostics: harvestPayload('a') });
1569
- await first;
1570
-
1571
- const second = driver.requestInteraction('w', {});
1572
- const secondId = driver.getHistory().at(-1)!.interaction!.interactionId;
1573
- driver.resolveInteraction(secondId, { status: 'approved', diagnostics: harvestPayload('b') });
1574
- await second;
1575
-
1576
- // Both harvests carried `turnIndex: '0'`; the per-batch namespace keeps them distinct, so the
1577
- // persister's `emittedTurnKeys` can't fold the second generation's turn into the first.
1578
- const turnKeys = driver
1579
- .getExternalDiagnostics()
1580
- .filter((e) => e.kind === 'turn')
1581
- .map((e) => e.turnIndex);
1582
- assert.equal(turnKeys, ['server-generation.1:0', 'server-generation.2:0']);
1583
- },
1584
- );
1585
-
1586
- externalDiagnostics(
1587
- 'keys turns by local position so several server runs in one harvest do not collide',
1588
- async () => {
1589
- clearMetaEventRegistry();
1590
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-multirun');
1591
-
1592
- // One interaction carrying two independent server runs (e.g. a consolidator's consolidator-code
1593
- // run and its table-code run) — each numbers its own turns from 0, so the harvest has two
1594
- // `turnIndex: '0'` entries. Keying by source index would collapse them; local position keeps
1595
- // both, so the persister's per-turnIndex dedup drops neither.
1596
- const pending = driver.requestInteraction('w', {});
1597
- const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1598
- driver.resolveInteraction(interactionId, {
1599
- status: 'approved',
1600
- diagnostics: [
1601
- {
1602
- kind: 'turn',
1603
- timestamp: '2026-07-23T10:00:00.000Z',
1604
- turnIndex: '0',
1605
- systemPrompt: 'run1',
1606
- },
1607
- {
1608
- kind: 'turn',
1609
- timestamp: '2026-07-23T10:00:01.000Z',
1610
- turnIndex: '1',
1611
- systemPrompt: 'run1',
1612
- },
1613
- {
1614
- kind: 'turn',
1615
- timestamp: '2026-07-23T10:00:02.000Z',
1616
- turnIndex: '0',
1617
- systemPrompt: 'run2',
1618
- },
1619
- ],
1620
- });
1621
- await pending;
1622
-
1623
- const turnKeys = driver
1624
- .getExternalDiagnostics()
1625
- .filter((e) => e.kind === 'turn')
1626
- .map((e) => e.turnIndex);
1627
- assert.equal(turnKeys, [
1628
- 'server-generation.1:0',
1629
- 'server-generation.1:1',
1630
- 'server-generation.1:2',
1631
- ]);
1632
- // All distinct — no key repeats despite the repeated source `turnIndex: '0'`.
1633
- assert.is(new Set(turnKeys).size, 3, 'every harvested turn gets a distinct key');
1634
- },
1635
- );
1636
-
1637
- externalDiagnostics(
1638
- 'tolerates malformed entries and does not fold or miscount non-standard kinds',
1639
- async () => {
1640
- clearMetaEventRegistry();
1641
- const sessionKey = 'external-diag-malformed';
1642
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
1643
-
1644
- const pending = driver.requestInteraction('w', {});
1645
- const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1646
- // The harvest is untrusted `unknown[]`: primitives/null and an object without a string `kind`
1647
- // must be skipped (not throw), and a `meta-snapshot` (host-owned, never emitted by a sibling)
1648
- // must be dropped rather than folded or miscounted as a message.
1649
- driver.resolveInteraction(interactionId, {
1650
- status: 'approved',
1651
- diagnostics: [
1652
- null,
1653
- 'oops',
1654
- 42,
1655
- { timestamp: '2026-07-23T10:00:00.000Z' }, // no kind
1656
- {
1657
- kind: 'message',
1658
- timestamp: '2026-07-23T10:00:01.000Z',
1659
- role: 'assistant',
1660
- content: 'real',
1661
- },
1662
- { kind: 'meta-snapshot', timestamp: '2026-07-23T10:00:02.000Z', meta: { host: 'foreign' } },
1663
- ],
1664
- });
1665
- await pending;
1666
-
1667
- // Only the one real message survives the buffer; the meta-snapshot is not folded.
1668
- const external = driver.getExternalDiagnostics();
1669
- assert.equal(
1670
- external.map((e) => e.kind),
1671
- ['message'],
1672
- );
1673
- assert.is(external[0].content, 'real');
1674
- // Manifest counts only the three real kinds — the meta-snapshot is not miscounted as a message.
1675
- const manifest = getMetaEvents(sessionKey).find(
1676
- (e) => e.type === 'external-diagnostics.folded',
1677
- );
1678
- assert.equal(manifest!.detail?.counts, { turn: 0, event: 0, message: 1 });
1679
- },
1680
- );
1681
-
1682
- externalDiagnostics.run();
1683
-
1684
- // ---------------------------------------------------------------------------
1685
- // interaction context lifecycle (GENC-1390) — the driver creates a live
1686
- // InteractionContext on requestInteraction and disposes+drops it on resolve, so
1687
- // widget state survives element remounts but never outlives the interaction.
1688
- // ---------------------------------------------------------------------------
1689
-
1690
- const interactionContextLifecycle = createLogicSuite('ChatDriver interaction context');
1691
-
1692
- interactionContextLifecycle('creates a context on request and returns it by id', async () => {
1693
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1694
-
1695
- const pending = driver.requestInteraction('w', {});
1696
- const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1697
-
1698
- const ctx = driver.getInteractionContext(interactionId);
1699
- assert.ok(ctx, 'a context exists for the pending interaction');
1700
- assert.is(ctx!.interactionId, interactionId);
1701
- // Same instance on a second lookup — this stable identity is what survives remounts.
1702
- assert.is(driver.getInteractionContext(interactionId), ctx, 'the context is a stable instance');
1703
-
1704
- driver.resolveInteraction(interactionId, { status: 'approved' });
1705
- await pending;
1706
- });
1707
-
1708
- interactionContextLifecycle('disposes and drops the context on resolve', async () => {
1709
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1710
-
1711
- const pending = driver.requestInteraction('w', {});
1712
- const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1713
-
1714
- let disposed = false;
1715
- driver.getInteractionContext(interactionId)!.onDispose(() => {
1716
- disposed = true;
1717
- });
1718
-
1719
- driver.resolveInteraction(interactionId, { status: 'approved' });
1720
- await pending;
1721
-
1722
- assert.ok(disposed, 'onDispose fired on resolve');
1723
- assert.is(
1724
- driver.getInteractionContext(interactionId),
1725
- undefined,
1726
- 'the context is dropped after resolve',
1727
- );
1728
- });
1729
-
1730
- interactionContextLifecycle.run();
1731
-
1732
- // ---------------------------------------------------------------------------
1733
- // interaction activity-bus signals (GENC-1346) — the driver brackets a parked
1734
- // widget interaction with `interaction-requested` / `interaction-resolved`, so
1735
- // turn-aware UI can distinguish "actively computing" from "parked awaiting the
1736
- // user". No tool-loop event fires at a park boundary, so these are the signal.
1737
- // ---------------------------------------------------------------------------
1738
-
1739
- const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
1740
-
1741
- // These two tests need to observe what the driver publishes, so they inject a local
1742
- // in-memory bus (no `crossTabEvents` → no BroadcastChannel is ever opened, nothing to close)
1743
- // rather than the shared singleton. Every other suite lets the driver default to the no-op bus.
1744
-
1745
- interactionBus('brackets a park with interaction-requested then -resolved', async () => {
1746
- const events: string[] = [];
1747
- const bus = new AgenticActivityBus();
1748
- const unsubs = [
1749
- bus.subscribe('interaction-requested', () => events.push('requested')),
1750
- bus.subscribe('interaction-resolved', () => events.push('resolved')),
1751
- ];
1752
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1753
-
1754
- const pending = driver.requestInteraction('w', {});
1755
- assert.equal(events, ['requested'], 'parking fires interaction-requested');
1756
-
1757
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
1758
- driver.resolveInteraction(id, { status: 'approved' });
1759
- await pending;
1760
- assert.equal(events, ['requested', 'resolved'], 'resolving fires interaction-resolved');
1761
-
1762
- unsubs.forEach((u) => u());
1763
- driver.dispose();
1764
- });
1765
-
1766
- interactionBus('a timed-out interaction still fires interaction-resolved', async () => {
1767
- const events: string[] = [];
1768
- const bus = new AgenticActivityBus();
1769
- const unsub = bus.subscribe('interaction-resolved', () => events.push('resolved'));
1770
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1771
-
1772
- // Never resolved by a user — the timeout path runs the same teardown, so it
1773
- // must signal the bus too (else the button would stay enabled after a timeout).
1774
- const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
1775
- await pending;
1776
- assert.equal(events, ['resolved'], 'the timeout resolution path also signals the bus');
1777
-
1778
- unsub();
1779
- driver.dispose();
1780
- });
1781
-
1782
- interactionBus.run();
1783
-
1784
- // ---------------------------------------------------------------------------
1785
- // interaction timeout — requestInteraction({ timeoutMs }) resolves with a
1786
- // status:'timeout' result (never rejects) and closes the widget read-only.
1787
- // ---------------------------------------------------------------------------
1788
-
1789
- const interactionTimeout = createLogicSuite('ChatDriver interaction timeout');
1790
-
1791
- interactionTimeout(
1792
- 'resolves with status "timeout" when the user does not respond in time',
1793
- async () => {
1794
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1795
-
1796
- const result = (await driver.requestInteraction(
1797
- 'planning-question',
1798
- { question: 'Pick one' },
1799
- { timeoutMs: 10 },
1800
- )) as InteractionResult;
1801
-
1802
- assert.is(result.status, 'timeout');
1803
- // The widget message is stamped resolved (renders read-only) with the timeout.
1804
- const msg = driver.getHistory().find((m) => m.interaction);
1805
- assert.ok(msg?.interaction?.resolved);
1806
- assert.is((msg!.interaction!.resolved as InteractionResult).status, 'timeout');
1807
- },
1808
- );
1809
-
1810
- interactionTimeout('a user response before the deadline wins and cancels the timeout', async () => {
1811
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1812
-
1813
- // A long timeout that must NOT fire because we resolve immediately.
1814
- const pending = driver.requestInteraction<InteractionResult<{ answer: string }>>(
1815
- 'planning-question',
1816
- { question: 'Pick one' },
1817
- { timeoutMs: 10_000 },
1818
- );
1819
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
1820
- driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' } });
1821
-
1822
- const result = await pending;
1823
- assert.is(result.status, 'approved');
1824
- assert.is(result.payload?.answer, 'A');
1825
- });
1826
-
1827
- interactionTimeout.run();
1828
-
1829
- // ---------------------------------------------------------------------------
1830
- // interaction external cost — a widget may report its own (non-LLM) cost via
1831
- // InteractionResult.costUsd; the driver folds it onto the message as
1832
- // externalCostUsd so the host's sumCosts recompute includes it.
1833
- // ---------------------------------------------------------------------------
1834
-
1835
- const interactionCost = createLogicSuite('ChatDriver interaction external cost');
1836
-
1837
- interactionCost('folds a widget-reported costUsd onto the message as externalCostUsd', async () => {
1838
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1839
-
1840
- const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
1841
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
1842
-
1843
- driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' }, costUsd: 0.05 });
1844
- await pending;
1845
-
1846
- const msg = driver.getHistory().find((m) => m.interaction?.interactionId === id);
1847
- assert.is(msg?.externalCostUsd, 0.05);
1848
- });
1849
-
1850
- interactionCost(
1851
- 'leaves externalCostUsd unset for a missing, zero, or negative costUsd',
1852
- async () => {
1853
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1854
-
1855
- // Omitted entirely.
1856
- const p1 = driver.requestInteraction('w', {});
1857
- const id1 = driver.getHistory().at(-1)!.interaction!.interactionId;
1858
- driver.resolveInteraction(id1, { status: 'approved' });
1859
- await p1;
1860
-
1861
- // Zero and negative are both ignored (only a positive, finite amount counts).
1862
- const p2 = driver.requestInteraction('w', {});
1863
- const id2 = driver.getHistory().at(-1)!.interaction!.interactionId;
1864
- driver.resolveInteraction(id2, { status: 'approved', costUsd: 0 });
1865
- await p2;
1866
-
1867
- const p3 = driver.requestInteraction('w', {});
1868
- const id3 = driver.getHistory().at(-1)!.interaction!.interactionId;
1869
- driver.resolveInteraction(id3, { status: 'approved', costUsd: -1 });
1870
- await p3;
1871
-
1872
- assert.not.ok(driver.getHistory().some((m) => m.externalCostUsd != null));
1873
- },
1874
- );
1875
-
1876
- interactionCost.run();
1877
-
1878
- // ---------------------------------------------------------------------------
1879
- // observable provider registry — runtime provider switching (GENC-1346)
1880
- //
1881
- // When the host registers an observable registry and swaps providers at
1882
- // runtime, the driver must drop its memoised lookups so the next turn resolves
1883
- // the new provider, re-emit `provider-changed` even when the resolved *name* is
1884
- // unchanged, and (critically) never leak its subscription — including for child
1885
- // sub-agent drivers that complete normally.
1886
- // ---------------------------------------------------------------------------
1887
-
1888
- interface ObservableTestRegistry extends AIProviderRegistry {
1889
- subscribe(listener: () => void): () => void;
1890
- /** Swap the provider returned by `get`/`default` and notify subscribers. */
1891
- swap(provider: AIProvider): void;
1892
- /** Live subscriber count — lets tests assert there's no listener leak. */
1893
- listenerCount(): number;
1894
- }
1895
-
1896
- /**
1897
- * A single-name (`'high'`) observable registry. `get`/`default` always return
1898
- * the current provider, so swapping it mid-session models a same-name vendor
1899
- * switch (the tier name stays `'high'`, the provider underneath changes).
1900
- */
1901
- const makeObservableRegistry = (initial: AIProvider): ObservableTestRegistry => {
1902
- let current = initial;
1903
- const listeners = new Set<() => void>();
1904
- return {
1905
- get: () => current,
1906
- default: () => current,
1907
- defaultName: () => 'high',
1908
- names: () => ['high'],
1909
- getStatus: async () => null,
1910
- listStatuses: async () => [],
1911
- subscribe(listener: () => void) {
1912
- listeners.add(listener);
1913
- return () => {
1914
- listeners.delete(listener);
1915
- };
1916
- },
1917
- swap(provider: AIProvider) {
1918
- current = provider;
1919
- for (const l of Array.from(listeners)) l();
1920
- },
1921
- listenerCount: () => listeners.size,
1922
- };
1923
- };
1924
-
1925
- const makeDriverWithRegistry = (config: AgentConfig, registry: AIProviderRegistry): ChatDriver => {
1926
- const driver = new ChatDriver(registry, { maxToolIterations: 50, maxFoldOperations: 5 });
1927
- driver.applyAgent(config);
1928
- return driver;
1929
- };
1930
-
1931
- const observable = createLogicSuite('ChatDriver observable provider registry');
1932
-
1933
- observable(
1934
- 'a registry change clears the resolved-provider cache so the next turn uses the new provider',
1935
- async () => {
1936
- const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
1937
- const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
1938
- const registry = makeObservableRegistry(providerA);
1939
- // A static provider name means lookups go through `resolvedProviderCache` —
1940
- // the cache that must self-invalidate on a registry change.
1941
- const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
1942
-
1943
- await driver.sendMessage('first');
1944
- assert.is(providerA.advertisedPerCall.length, 1, 'turn 1 resolves the original provider');
1945
- assert.is(providerB.advertisedPerCall.length, 0);
1946
-
1947
- registry.swap(providerB); // notify → cache cleared
1948
-
1949
- await driver.sendMessage('second');
1950
- assert.is(providerB.advertisedPerCall.length, 1, 'turn 2 resolves the swapped-in provider');
1951
- assert.is(providerA.advertisedPerCall.length, 1, 'the stale provider is not reused');
1952
-
1953
- driver.dispose();
1954
- },
1955
- );
1956
-
1957
- observable('re-emits provider-changed on a same-name swap', async () => {
1958
- const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
1959
- const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
1960
- const registry = makeObservableRegistry(providerA);
1961
- const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
1962
-
1963
- const names: string[] = [];
1964
- driver.addEventListener('provider-changed', (e) => {
1965
- names.push((e as CustomEvent<{ name: string }>).detail.name);
1966
- });
1967
-
1968
- await driver.sendMessage('first');
1969
- registry.swap(providerB);
1970
- await driver.sendMessage('second');
1971
-
1972
- // The resolved name ('high') never changes, but the swap resets the
1973
- // last-dispatched name so the cog can refresh — two events, not one.
1974
- assert.equal(names, ['high', 'high']);
1975
-
1976
- driver.dispose();
1977
- });
1978
-
1979
- observable('a non-observable registry is a no-op — turn runs, dispose does not throw', async () => {
1980
- const provider = scriptedProvider([{ role: 'assistant', content: 'hi' }]);
1981
- const driver = makeDriverWithRegistry(agent({ name: 'plain' }), makeRegistry(provider));
1982
- await driver.sendMessage('go');
1983
- assert.is(provider.advertisedPerCall.length, 1);
1984
- driver.dispose(); // no subscription was wired — must still be safe
1985
- });
1986
-
1987
- observable('a child sub-agent driver unsubscribes on completion — no listener leak', async () => {
1988
- const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
1989
- const registry = makeObservableRegistry(provider);
1990
- const parent = delegatingParent(completingWorker({ ok: true }), () => {});
1991
- const driver = makeDriverWithRegistry(parent, registry);
1992
-
1993
- assert.is(registry.listenerCount(), 1, 'the parent driver subscribed on construction');
1994
-
1995
- await driver.sendMessage('go');
1996
- // The child subscribed during the run; if it didn't clean up on its (normal)
1997
- // completion the registry would now hold two listeners.
1998
- assert.is(registry.listenerCount(), 1, 'the completed child unsubscribed');
1999
-
2000
- driver.dispose();
2001
- assert.is(registry.listenerCount(), 0, 'the parent unsubscribed on dispose');
2002
- });
2003
-
2004
- observable.run();
2005
-
2006
- // ---------------------------------------------------------------------------
2007
- // per-message model attribution (GENC-1346)
2008
- //
2009
- // Each model-produced assistant message carries `model` (the concrete model id
2010
- // the active provider's getStatus reports) and `providerName` (the registry slot
2011
- // it resolved under), so the exported debug log shows which model produced each
2012
- // message — and, since tool calls ride on the assistant message, each tool call.
2013
- // ---------------------------------------------------------------------------
2014
-
2015
- /** A provider that replays scripted replies and reports `model` via getStatus
2016
- * (omitted entirely when `model` is undefined, to model a provider with no
2017
- * status). */
2018
- const modelProvider = (model: string | undefined, responses: ChatMessage[]): AIProvider => {
2019
- const queue = [...responses];
2020
- const provider: AIProvider = {
2021
- chat: async (): Promise<ChatMessage> => queue.shift() ?? { role: 'assistant', content: 'done' },
2022
- };
2023
- if (model !== undefined) {
2024
- provider.getStatus = async () => ({ provider: 'gemini', model });
2025
- }
2026
- return provider;
2027
- };
2028
-
2029
- const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
2030
-
2031
- modelAttr('stamps the resolved model and registry name onto an assistant reply', async () => {
2032
- const provider = modelProvider('gemini-2.5-flash-lite', [
2033
- { role: 'assistant', content: 'hi there' },
2034
- ]);
2035
- const driver = makeDriver(agent({ name: 'plain' }), provider);
2036
-
2037
- await driver.sendMessage('hello');
2038
-
2039
- const reply = driver.getHistory().find((m) => m.role === 'assistant');
2040
- assert.ok(reply, 'assistant reply present');
2041
- assert.is(reply!.model, 'gemini-2.5-flash-lite', 'model id read from provider getStatus');
2042
- assert.is(reply!.provider, 'gemini', 'vendor id read from provider getStatus');
2043
- // makeRegistry registers a single provider under the name 'test'.
2044
- assert.is(reply!.providerName, 'test', 'registry slot the turn resolved under');
2045
-
2046
- driver.dispose();
2047
- });
2048
-
2049
- modelAttr(
2050
- 'attributes a tool-calling assistant message (and so its tool calls) to the model',
2051
- async () => {
2052
- const provider = modelProvider('claude-haiku-4-5-20251001', [
2053
- callsTool('noop', 't1'),
2054
- { role: 'assistant', content: 'finished' },
2055
- ]);
2056
- const config = agent({
2057
- name: 'withTool',
2058
- toolDefinitions: [def('noop')],
2059
- toolHandlers: { noop: async () => 'ok' },
2060
- });
2061
- const driver = makeDriver(config, provider);
2062
-
2063
- await driver.sendMessage('go');
2064
-
2065
- const toolCallMsg = driver.getHistory().find((m) => (m.toolCalls?.length ?? 0) > 0);
2066
- assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
2067
- assert.is(toolCallMsg!.model, 'claude-haiku-4-5-20251001');
2068
- assert.is(toolCallMsg!.provider, 'gemini');
2069
- assert.is(toolCallMsg!.providerName, 'test');
2070
-
2071
- driver.dispose();
2072
- },
2073
- );
2074
-
2075
- modelAttr('picks up a model swapped behind a stable name', async () => {
2076
- const before = modelProvider('gemini-2.5-flash-lite', [{ role: 'assistant', content: 'A' }]);
2077
- const after = modelProvider('gemini-2.5-pro', [{ role: 'assistant', content: 'B' }]);
2078
- const registry = makeObservableRegistry(before);
2079
- const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
2080
-
2081
- await driver.sendMessage('first');
2082
- registry.swap(after); // notify → model cache cleared, next turn re-resolves
2083
- await driver.sendMessage('second');
2084
-
2085
- const replies = driver.getHistory().filter((m) => m.role === 'assistant');
2086
- assert.is(replies.at(-2)?.model, 'gemini-2.5-flash-lite', 'first turn keeps the original model');
2087
- assert.is(replies.at(-1)?.model, 'gemini-2.5-pro', 'after the swap the new model is stamped');
2088
- // The tier name ('high') never changed across the swap — only the model behind it.
2089
- assert.is(replies.at(-1)?.providerName, 'high');
2090
-
2091
- driver.dispose();
2092
- });
2093
-
2094
- modelAttr('omits the model key entirely when the provider reports no status', async () => {
2095
- const provider = modelProvider(undefined, [{ role: 'assistant', content: 'no status here' }]);
2096
- const driver = makeDriver(agent({ name: 'plain' }), provider);
2097
-
2098
- await driver.sendMessage('hi');
2099
-
2100
- const reply = driver.getHistory().find((m) => m.role === 'assistant');
2101
- assert.ok(reply, 'assistant reply present');
2102
- // No getStatus → no model. The key is left off, not set to undefined, so the
2103
- // exported log carries no dead `model` line for this message.
2104
- assert.not.ok('model' in reply!, 'model key is absent, not present-as-undefined');
2105
- assert.is(reply!.providerName, 'test', 'provider name is still recorded');
2106
-
2107
- driver.dispose();
2108
- });
2109
-
2110
- modelAttr.run();
2111
-
2112
- // ---------------------------------------------------------------------------
2113
- // condenseWhen — tool-declared context condensation (wiring through the loop)
2114
- // ---------------------------------------------------------------------------
2115
-
2116
- const condense = createLogicSuite('ChatDriver condenseWhen');
2117
-
2118
- const bigBody = 'A'.repeat(2000);
2119
-
2120
- /** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
2121
- const readsPath = (id: string, path: string): ChatMessage => ({
2122
- role: 'assistant',
2123
- content: '',
2124
- toolCalls: [{ id, name: 'read', args: { path } }],
2125
- });
2126
-
2127
- condense(
2128
- 'collapses a superseded result in the model slice but keeps stored history full',
2129
- async () => {
2130
- const sessionKey = 'condense-supersede';
2131
- clearMetaEventRegistry();
2132
- const provider = scriptedProvider([
2133
- readsPath('r1', 'A'),
2134
- readsPath('r2', 'A'), // re-read of the same path supersedes r1
2135
- ]);
2136
- const config = agent({
2137
- name: 'reader',
2138
- systemPrompt: 'read files',
2139
- toolDefinitions: [def('read')],
2140
- toolHandlers: {
2141
- read: async (args, ctx) => {
2142
- ctx.condenseWhen({
2143
- on: { kind: 'superseded', by: String(args.path) },
2144
- response: 'pointer',
2145
- });
2146
- return bigBody;
2147
- },
2148
- },
2149
- });
2150
- const driver = makeDriver(config, provider, sessionKey);
2151
-
2152
- const result = await driver.sendMessage('go');
2153
- assert.is(result.reason, 'done');
2154
-
2155
- // The 3rd provider call (index 2) ran after both reads: r1 is superseded, r2 is latest.
2156
- const slice = provider.historyPerCall[2];
2157
- const r1 = slice.find((m) => m.toolResult?.toolCallId === 'r1');
2158
- const r2 = slice.find((m) => m.toolResult?.toolCallId === 'r2');
2159
- assert.match(
2160
- r1!.toolResult!.content,
2161
- /re-call to restore/,
2162
- 'earlier read collapsed for the model',
2163
- );
2164
- assert.is(r2!.toolResult!.content, bigBody, 'latest read still full');
2165
-
2166
- // Stored history is untouched — both reads remain full for the UI / debug export.
2167
- assert.is(
2168
- toolResultContents(driver).filter((c) => c === bigBody).length,
2169
- 2,
2170
- 'stored history retains both full reads',
2171
- );
2172
-
2173
- // One context.condensed meta-event, for r1's response, recorded once.
2174
- const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
2175
- assert.is(events.length, 1, 'the full→stub transition is reported exactly once');
2176
- assert.is(events[0].detail?.toolCallId, 'r1');
2177
- assert.is(events[0].detail?.target, 'response');
2178
- },
2179
- );
2180
-
2181
- condense('first-wins: a second condenseWhen for the same tool call is ignored', async () => {
2182
- const sessionKey = 'condense-first-wins';
2183
- clearMetaEventRegistry();
2184
- const provider = scriptedProvider([readsPath('r1', 'A'), readsPath('r2', 'A')]);
2185
- const config = agent({
2186
- name: 'reader',
2187
- systemPrompt: 'read files',
2188
- toolDefinitions: [def('read')],
2189
- toolHandlers: {
2190
- read: async (args, ctx) => {
2191
- // First declaration wins; the second (a different mode) is dropped.
2192
- ctx.condenseWhen({
2193
- on: { kind: 'superseded', by: String(args.path) },
2194
- response: 'pointer',
2195
- });
2196
- ctx.condenseWhen({
2197
- on: { kind: 'superseded', by: String(args.path) },
2198
- response: { replaceWith: 'SECOND' },
2199
- });
2200
- return bigBody;
2201
- },
2202
- },
2203
- });
2204
- const driver = makeDriver(config, provider, sessionKey);
2205
- await driver.sendMessage('go');
2206
-
2207
- const collapsed = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'r1');
2208
- assert.match(
2209
- collapsed!.toolResult!.content,
2210
- /re-call to restore/,
2211
- 'the first (pointer) policy applied',
2212
- );
2213
- assert.is.not(collapsed!.toolResult!.content, 'SECOND', 'the later policy was ignored');
2214
- });
2215
-
2216
- condense('age clock is monotonic across turns — collapses on a later, separate turn', async () => {
2217
- // Regression guard for the per-turn-reset bug: the age clock is the local loop
2218
- // counter that resets every `sendMessage`, so an `age` payload from turn 1
2219
- // would never collapse across short turns. The driver now uses a monotonic
2220
- // model-call counter, so it does.
2221
- const sessionKey = 'condense-age-cross-turn';
2222
- clearMetaEventRegistry();
2223
- const provider = scriptedProvider([
2224
- { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
2225
- { role: 'assistant', content: 'noted' }, // turn 1, call 2 — model sees n1 once (full), turn ends
2226
- { role: 'assistant', content: 'ok' }, // turn 2, call 1 — a short follow-up, no tools
2227
- ]);
2228
- const config = agent({
2229
- name: 'note-taker',
2230
- systemPrompt: 'take notes',
2231
- toolDefinitions: [def('notes')],
2232
- toolHandlers: {
2233
- notes: async (_args, ctx) => {
2234
- ctx.condenseWhen({ on: { kind: 'age', turns: 1 }, response: 'pointer' });
2235
- return bigBody;
2236
- },
2237
- },
2238
- });
2239
- const driver = makeDriver(config, provider, sessionKey);
2240
-
2241
- await driver.sendMessage('first'); // turn 1
2242
- await driver.sendMessage('second'); // turn 2 — a separate, short sendMessage
2243
-
2244
- // Turn 1's consume call (provider call #1) saw n1 in full — its one allowed look.
2245
- const turn1Consume = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
2246
- assert.is(turn1Consume!.toolResult!.content, bigBody, 'full on its one allowed turn');
2247
-
2248
- // Turn 2's first call (#2) is a SEPARATE sendMessage — the local loop counter has
2249
- // reset to 0, but the monotonic clock kept counting, so n1 is now collapsed.
2250
- // Under the old per-turn counter it would still be full here.
2251
- const turn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
2252
- assert.match(turn2!.toolResult!.content, /re-call to restore/, 'collapsed on the next turn');
2253
-
2254
- assert.ok(toolResultContents(driver).includes(bigBody), 'stored history retains the full note');
2255
- const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
2256
- assert.is(events.length, 1);
2257
- assert.is(events[0].detail?.trigger, 'age:1');
2258
- });
2259
-
2260
- condense('turnEnd: full during its request, collapsed on the next user turn', async () => {
2261
- const sessionKey = 'condense-turn-end';
2262
- clearMetaEventRegistry();
2263
- const provider = scriptedProvider([
2264
- { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
2265
- { role: 'assistant', content: 'noted' }, // turn 1, call 2 — n1 still full this turn
2266
- { role: 'assistant', content: 'ok' }, // turn 2 — a separate request
2267
- ]);
2268
- const config = agent({
2269
- name: 'note-taker',
2270
- systemPrompt: 'take notes',
2271
- toolDefinitions: [def('notes')],
2272
- toolHandlers: {
2273
- notes: async (_args, ctx) => {
2274
- ctx.condenseWhen({ on: { kind: 'turnEnd' }, response: 'pointer' });
2275
- return bigBody;
2276
- },
2277
- },
2278
- });
2279
- const driver = makeDriver(config, provider, sessionKey);
2280
-
2281
- await driver.sendMessage('first'); // turn 1
2282
- await driver.sendMessage('second'); // turn 2
2283
-
2284
- const inTurn1 = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
2285
- assert.is(inTurn1!.toolResult!.content, bigBody, 'full for the rest of the request it served');
2286
- const inTurn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
2287
- assert.match(inTurn2!.toolResult!.content, /re-call to restore/, 'collapsed once the turn ended');
2288
-
2289
- const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
2290
- assert.is(events.length, 1);
2291
- assert.is(events[0].detail?.trigger, 'turnEnd');
2292
- });
2293
-
2294
- condense('agentEnd: collapses once the agent releases (flow complete)', async () => {
2295
- const sessionKey = 'condense-agent-end';
2296
- clearMetaEventRegistry();
2297
- const provider = scriptedProvider([
2298
- { role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
2299
- { role: 'assistant', content: '', toolCalls: [{ id: 'f1', name: 'finish', args: {} }] }, // call 2 — releases
2300
- { role: 'assistant', content: 'wrapped up' }, // call 3 — flow done
2301
- ]);
2302
- const config = agent({
2303
- name: 'wizard',
2304
- systemPrompt: 'a flow',
2305
- toolDefinitions: [def('load'), def('finish')],
2306
- toolHandlers: {
2307
- load: async (_args, ctx) => {
2308
- ctx.condenseWhen({ on: { kind: 'agentEnd' }, response: 'pointer' });
2309
- return bigBody;
2310
- },
2311
- finish: async (_args, ctx) => {
2312
- ctx.releaseAgent();
2313
- return 'flow complete';
2314
- },
2315
- },
2316
- });
2317
- const driver = makeDriver(config, provider, sessionKey);
2318
- await driver.sendMessage('run the flow');
2319
-
2320
- // call #2 (index 1) ran before `finish` released — schema still full.
2321
- const beforeRelease = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
2322
- assert.is(beforeRelease!.toolResult!.content, bigBody, 'full while the flow is active');
2323
- // call #3 (index 2) ran after release — schema collapsed.
2324
- const afterRelease = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
2325
- assert.match(afterRelease!.toolResult!.content, /re-call to restore/, 'collapsed once released');
2326
-
2327
- const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
2328
- assert.is(events.length, 1);
2329
- assert.is(events[0].detail?.trigger, 'agentEnd');
2330
- });
2331
-
2332
- condense('phaseEnd: collapses at endPhase() while the agent keeps running', async () => {
2333
- const sessionKey = 'condense-phase-end';
2334
- clearMetaEventRegistry();
2335
- const provider = scriptedProvider([
2336
- { role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
2337
- { role: 'assistant', content: '', toolCalls: [{ id: 'a1', name: 'advance', args: {} }] }, // call 2 — ends the phase
2338
- { role: 'assistant', content: 'continuing' }, // call 3 — agent still active, just answers
2339
- ]);
2340
- const config = agent({
2341
- name: 'journey',
2342
- systemPrompt: 'a multi-phase flow',
2343
- toolDefinitions: [def('load'), def('advance')],
2344
- toolHandlers: {
2345
- load: async (_args, ctx) => {
2346
- ctx.condenseWhen({ on: { kind: 'phaseEnd' }, response: 'pointer' });
2347
- return bigBody;
2348
- },
2349
- advance: async (_args, ctx) => {
2350
- // Declares a phase boundary WITHOUT ending the agent (no releaseAgent).
2351
- ctx.endPhase?.();
2352
- return 'next phase';
2353
- },
2354
- },
2355
- });
2356
- const driver = makeDriver(config, provider, sessionKey);
2357
- const result = await driver.sendMessage('run the flow');
2358
- // The agent never released — the turn ends normally on the text response.
2359
- assert.is(result.reason, 'done');
2360
-
2361
- // call #2 (index 1) ran before `advance` ended the phase — result still full.
2362
- const beforePhaseEnd = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
2363
- assert.is(beforePhaseEnd!.toolResult!.content, bigBody, 'full while the phase is current');
2364
- // call #3 (index 2) ran after endPhase() — collapsed. The activation never ended
2365
- // (no release/swap), so this collapse can ONLY be the phase boundary, not the
2366
- // agentEnd backstop.
2367
- const afterPhaseEnd = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
2368
- assert.match(afterPhaseEnd!.toolResult!.content, /re-call to restore/, 'collapsed at phase end');
2369
- assert.match(afterPhaseEnd!.toolResult!.content, /phase ended/);
2370
-
2371
- const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
2372
- assert.is(events.length, 1);
2373
- assert.is(events[0].detail?.trigger, 'phaseEnd');
2374
- });
2375
-
2376
- condense.run();
2377
-
2378
- // ---------------------------------------------------------------------------
2379
- // turn-outcome surfacing (PTC-0) — the typed failure reason must reach BOTH
2380
- // the loop-boundary result (`ChatDriverResult.failureReason`) and the
2381
- // `tool-loop-end` activity-bus event detail. Historically every exit flattened
2382
- // to `{ reason: 'done' }` and the bus detail was `undefined`; these lock the
2383
- // two seams together, one per `TurnFailureReason`, plus a happy-path compat
2384
- // check that the legacy shape is byte-unchanged.
2385
- // ---------------------------------------------------------------------------
2386
-
2387
- const outcome = createLogicSuite('ChatDriver turn-outcome surfacing');
2388
-
2389
- // This branch routes activity events to an INJECTED bus (default NOOP), not the module
2390
- // singleton, so these tests inject a local bus and observe the same seam the driver publishes to.
2391
- const outcomeBus = new AgenticActivityBus();
2392
-
2393
- outcome.after(() => {
2394
- outcomeBus.close();
2395
- });
2396
-
2397
- /**
2398
- * Subscribe to `tool-loop-end` and expose the most recent detail seen. `fired()` reports
2399
- * whether the event was seen at all — distinct from `detail()`, which is `undefined` both
2400
- * when no event fired and when a clean turn fired with the historical `undefined` detail.
2401
- */
2402
- const captureLoopEnd = (): {
2403
- detail: () => { failureReason?: TurnFailureReason } | undefined;
2404
- fired: () => boolean;
2405
- stop: () => void;
2406
- } => {
2407
- let last: { failureReason?: TurnFailureReason } | undefined;
2408
- let seen = false;
2409
- const stop = outcomeBus.subscribe('tool-loop-end', (d) => {
2410
- last = d;
2411
- seen = true;
2412
- });
2413
- return { detail: () => (seen ? last : undefined), fired: () => seen, stop };
2414
- };
2415
-
2416
- /** A ChatDriver with an explicit (small) tool-iteration cap. */
2417
- const makeCappedDriver = (
2418
- config: AgentConfig,
2419
- provider: AIProvider,
2420
- maxIterations: number,
2421
- sessionKey = '',
2422
- ): ChatDriver => {
2423
- const driver = new ChatDriver(makeRegistry(provider), {
2424
- maxToolIterations: maxIterations,
2425
- maxFoldOperations: 5,
2426
- sessionKey,
2427
- activityBus: outcomeBus,
2428
- });
2429
- driver.applyAgent(config);
2430
- return driver;
2431
- };
2432
-
2433
- /**
2434
- * Assert that a turn driven by `provider` surfaces `expected` at BOTH seams.
2435
- * `driverFor` lets the max-iterations case swap in a low cap.
2436
- */
2437
- const assertSurfacesReason = async (
2438
- label: string,
2439
- provider: AIProvider,
2440
- expected: TurnFailureReason,
2441
- driverFor: (config: AgentConfig, provider: AIProvider, sessionKey: string) => ChatDriver = (
2442
- c,
2443
- p,
2444
- k,
2445
- ) => makeDriver(c, p, k, outcomeBus),
2446
- ): Promise<void> => {
2447
- clearMetaEventRegistry();
2448
- const sessionKey = `outcome-${label}`;
2449
- const config = agent({
2450
- name: 'Static',
2451
- toolDefinitions: [def('noop')],
2452
- toolHandlers: { noop: async () => 'ok' },
2453
- });
2454
- const driver = driverFor(config, provider, sessionKey);
2455
- const cap = captureLoopEnd();
2456
-
2457
- const result: ChatDriverResult = await driver.sendMessage('go');
2458
-
2459
- // Seam 1 — the loop-boundary result. Discriminant stays 'done' (compat).
2460
- assert.is(result.reason, 'done', `[${label}] discriminant stays 'done'`);
2461
- assert.is(
2462
- result.reason === 'done' ? result.failureReason : undefined,
2463
- expected,
2464
- `[${label}] result.failureReason surfaces the typed reason`,
2465
- );
2466
-
2467
- // Seam 2 — the activity-bus tool-loop-end detail.
2468
- assert.ok(cap.detail(), `[${label}] a tool-loop-end event fired`);
2469
- assert.is(
2470
- cap.detail()!.failureReason,
2471
- expected,
2472
- `[${label}] the bus detail carries the same reason`,
2473
- );
2474
-
2475
- // Consistency: the debug-log turn.error records the same taxonomy.
2476
- const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
2477
- assert.ok(err, `[${label}] a turn.error is recorded`);
2478
- assert.is(err!.detail?.reason, expected, `[${label}] the debug-log reason matches`);
2479
-
2480
- cap.stop();
2481
- };
2482
-
2483
- /** A provider that returns a MALFORMED_FUNCTION_CALL on every call. */
2484
- const malformedProvider = (): AIProvider => ({
2485
- chat: async (): Promise<ChatMessage> => {
2486
- throw new MalformedFunctionCallError('bad call');
2487
- },
2488
- });
2489
-
2490
- /** A provider that returns an empty response on every call. */
2491
- const emptyProvider = (): AIProvider => ({
2492
- chat: async (): Promise<ChatMessage> => ({ role: 'assistant', content: '' }),
2493
- });
2494
-
2495
- /** A provider that throws a ResponseTruncatedError (deterministic, no retry). */
2496
- const truncatedProvider = (): AIProvider => ({
2497
- chat: async (): Promise<ChatMessage> => {
2498
- throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
2499
- },
2500
- });
2501
-
2502
- /** A provider refused for budget (deterministic, terminal — must not retry). */
2503
- const budgetExhaustedProvider = (): AIProvider => ({
2504
- chat: async (): Promise<ChatMessage> => {
2505
- throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
2506
- },
2507
- });
2508
-
2509
- /** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
2510
- const providerRefusedProvider = (kind: 'spend' | 'auth' = 'spend'): AIProvider => ({
2511
- chat: async (): Promise<ChatMessage> => {
2512
- // Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
2513
- // fixture carrying a credit-balance message would read as a classification bug to the next person.
2514
- throw kind === 'auth'
2515
- ? new ProviderRefusedError(
2516
- 'Anthropic',
2517
- 'auth',
2518
- 401,
2519
- 'authentication_error',
2520
- 'API key is invalid.',
2521
- )
2522
- : new ProviderRefusedError(
2523
- 'Anthropic',
2524
- 'spend',
2525
- 400,
2526
- 'invalid_request_error',
2527
- 'Your credit balance is too low to access the Anthropic API.',
2528
- );
2529
- },
2530
- });
2531
-
2532
- /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
2533
- const throwingProvider = (): AIProvider => ({
2534
- chat: async (): Promise<ChatMessage> => {
2535
- throw new Error('boom');
2536
- },
2537
- });
2538
-
2539
- /** A provider that never stops calling a valid tool (drives the iteration cap). */
2540
- const neverStopsProvider = (): AIProvider => {
2541
- let n = 0;
2542
- return {
2543
- chat: async (): Promise<ChatMessage> => {
2544
- const id = `noop-${n}`;
2545
- n += 1;
2546
- return { role: 'assistant', content: '', toolCalls: [{ id, name: 'noop', args: {} }] };
2547
- },
2548
- };
2549
- };
2550
-
2551
- /** A provider that keeps calling a tool with no handler (hallucinated → limit). */
2552
- const hallucinatedProvider = (): AIProvider =>
2553
- scriptedProvider(Array.from({ length: 6 }, (_u, i) => callsTool('ghost', `ghost-${i}`)));
2554
-
2555
- /** A provider that refuses: empty content + finishReason 'refusal' (deterministic, must not retry). */
2556
- const refusalProvider = (): AIProvider => ({
2557
- chat: async (): Promise<ChatMessage> => ({
2558
- role: 'assistant',
2559
- content: '',
2560
- responseMeta: { finishReason: 'refusal' },
2561
- }),
2562
- });
2563
-
2564
- outcome('malformed-function-call surfaces at both seams', async () => {
2565
- await assertSurfacesReason('malformed', malformedProvider(), 'malformed-function-call');
2566
- });
2567
-
2568
- outcome('empty-response surfaces at both seams', async () => {
2569
- await assertSurfacesReason('empty', emptyProvider(), 'empty-response');
2570
- });
2571
-
2572
- outcome('response-truncated surfaces at both seams', async () => {
2573
- await assertSurfacesReason('truncated', truncatedProvider(), 'response-truncated');
2574
- });
2575
-
2576
- outcome('exception surfaces at both seams', async () => {
2577
- await assertSurfacesReason('exception', throwingProvider(), 'exception');
2578
- });
2579
-
2580
- outcome('unknown-tool-limit surfaces at both seams', async () => {
2581
- await assertSurfacesReason('unknown-tool', hallucinatedProvider(), 'unknown-tool-limit');
2582
- });
2583
-
2584
- outcome('max-iterations surfaces at both seams', async () => {
2585
- await assertSurfacesReason('max-iter', neverStopsProvider(), 'max-iterations', (c, p, k) =>
2586
- makeCappedDriver(c, p, 2, k),
2587
- );
2588
- });
2589
-
2590
- // A refusal reaching 'refusal' (not 'empty-response') at both seams proves the short-circuit fired —
2591
- // otherwise the empty content would retry to exhaustion and surface as 'empty-response'.
2592
- outcome('refusal surfaces at both seams', async () => {
2593
- await assertSurfacesReason('refusal', refusalProvider(), 'refusal');
2594
- });
2595
-
2596
- outcome('a refusal is deterministic — surfaced without any empty-response retry', async () => {
2597
- clearMetaEventRegistry();
2598
- let calls = 0;
2599
- const provider: AIProvider = {
2600
- chat: async () => {
2601
- calls += 1;
2602
- return { role: 'assistant', content: '', responseMeta: { finishReason: 'refusal' } };
2603
- },
2604
- };
2605
- const config = agent({
2606
- name: 'Static',
2607
- toolDefinitions: [def('noop')],
2608
- toolHandlers: { noop: async () => 'ok' },
2609
- });
2610
- const driver = makeDriver(config, provider, 'outcome-refusal-once', outcomeBus);
2611
-
2612
- const result: ChatDriverResult = await driver.sendMessage('go');
2613
-
2614
- assert.is(
2615
- result.reason === 'done' ? result.failureReason : undefined,
2616
- 'refusal',
2617
- 'refusal surfaces as the typed failure reason',
2618
- );
2619
- assert.is(calls, 1, 'a refusal is not retried — exactly one model call');
2620
- });
2621
-
2622
- outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)', async () => {
2623
- clearMetaEventRegistry();
2624
- const config = agent({ name: 'Static' });
2625
- // Plain-text reply ends the turn cleanly on the first call.
2626
- const driver = makeDriver(
2627
- config,
2628
- scriptedProvider([{ role: 'assistant', content: 'hi' }]),
2629
- 'outcome-ok',
2630
- outcomeBus,
2631
- );
2632
- const cap = captureLoopEnd();
2633
-
2634
- const result: ChatDriverResult = await driver.sendMessage('go');
2635
-
2636
- // The result is exactly `{ reason: 'done' }` — no `failureReason` key added.
2637
- assert.equal(result, { reason: 'done' }, 'happy-path result is the historical shape');
2638
- assert.not.ok('failureReason' in result, 'no failureReason key is present on a clean turn');
2639
-
2640
- // The bus event still fires, and its detail is the historical `undefined` — a clean
2641
- // turn emits no detail object at all (byte-shape compat), not `{ failureReason: undefined }`.
2642
- assert.ok(cap.fired(), 'a tool-loop-end event fired');
2643
- assert.is(cap.detail(), undefined, 'a clean turn emits the historical undefined detail');
2644
-
2645
- // And no turn.error was recorded.
2646
- assert.not.ok(
2647
- getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'),
2648
- 'a clean turn records no turn.error',
2649
- );
2650
-
2651
- cap.stop();
2652
- });
2653
-
2654
- // ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
2655
-
2656
- outcome('provider-refused surfaces at both seams', async () => {
2657
- await assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
2658
- });
2659
-
2660
- outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', async () => {
2661
- // GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
2662
- // get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
2663
- // as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
2664
- // trying again cannot help.
2665
- clearMetaEventRegistry();
2666
- let calls = 0;
2667
- const provider: AIProvider = {
2668
- chat: async (): Promise<ChatMessage> => {
2669
- calls += 1;
2670
- throw new ProviderRefusedError(
2671
- 'Anthropic',
2672
- 'spend',
2673
- 400,
2674
- 'invalid_request_error',
2675
- 'credit too low',
2676
- );
2677
- },
2678
- getStatus: async () => ({ provider: 'anthropic', model: 'test-model' }),
2679
- };
2680
- const config = agent({
2681
- name: 'Static',
2682
- toolDefinitions: [def('noop')],
2683
- toolHandlers: { noop: async () => 'ok' },
2684
- });
2685
- const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
2686
-
2687
- const result: ChatDriverResult = await driver.sendMessage('go');
2688
-
2689
- assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
2690
- assert.is(
2691
- result.reason === 'done' ? result.failureReason : undefined,
2692
- 'provider-refused',
2693
- 'the turn ends with the provider-refused failure reason',
2694
- );
2695
-
2696
- // The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
2697
- // must top up an account or rotate a key. It is the only place that distinction survives, because
2698
- // the user-facing sentence is deliberately cause-free.
2699
- const refused = result.reason === 'done' ? result.providerRefused : undefined;
2700
- assert.is(refused?.kind, 'spend');
2701
- assert.is(refused?.vendorLabel, 'Anthropic');
2702
- assert.is(refused?.upstreamStatus, 400);
2703
- assert.is(refused?.upstreamType, 'invalid_request_error');
2704
-
2705
- const last = driver.getHistory().at(-1);
2706
- assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
2707
- // Identity, not a substring: the sentence is provisional copy shared with ai-service via one
2708
- // exported const, and this is what stops a second copy drifting in.
2709
- assert.is(last!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2710
- assert.not.ok(
2711
- last!.content.includes('something went wrong'),
2712
- 'must not fall through to the generic apology',
2713
- );
2714
- // The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
2715
- // be told about a limit, and we must not imply the bill went unpaid.
2716
- for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
2717
- assert.not.ok(
2718
- last!.content.toLowerCase().includes(banned),
2719
- `copy must not mention "${banned}"`,
2720
- );
2721
- }
2722
-
2723
- const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
2724
- assert.is((err?.detail as { reason?: string })?.reason, 'provider-refused');
2725
- assert.is((err?.detail as { kind?: string })?.kind, 'spend');
2726
- });
2727
-
2728
- outcome('an auth refusal shows the SAME sentence but logs a different kind', async () => {
2729
- // The whole justification for one type with two kinds: identical user experience, distinct
2730
- // diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
2731
- clearMetaEventRegistry();
2732
- const config = agent({
2733
- name: 'Static',
2734
- toolDefinitions: [def('noop')],
2735
- toolHandlers: { noop: async () => 'ok' },
2736
- });
2737
- const driver = makeDriver(
2738
- config,
2739
- providerRefusedProvider('auth'),
2740
- 'outcome-refused-auth',
2741
- outcomeBus,
2742
- );
2743
-
2744
- const result: ChatDriverResult = await driver.sendMessage('go');
2745
-
2746
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
2747
- assert.is(result.reason === 'done' ? result.providerRefused?.kind : undefined, 'auth');
2748
- assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
2749
- });
2750
-
2751
- outcome('a host override replaces the provider-refusal sentence verbatim', async () => {
2752
- const config = agent({
2753
- name: 'Static',
2754
- toolDefinitions: [def('noop')],
2755
- toolHandlers: { noop: async () => 'ok' },
2756
- });
2757
- const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
2758
- maxToolIterations: 50,
2759
- maxFoldOperations: 5,
2760
- sessionKey: 'outcome-refused-override',
2761
- providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
2762
- });
2763
- driver.applyAgent(config);
2764
-
2765
- await driver.sendMessage('go');
2766
-
2767
- assert.is(driver.getHistory().at(-1)!.content, 'Bespoke wording for a white-labelled host.');
2768
- driver.dispose();
2769
- });
2770
-
2771
- outcome('budget-exhausted surfaces at both seams', async () => {
2772
- await assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
2773
- });
2774
-
2775
- outcome('a budget wall is terminal — no transient retry, and actionable copy', async () => {
2776
- clearMetaEventRegistry();
2777
- let calls = 0;
2778
- const provider: AIProvider = {
2779
- chat: async (): Promise<ChatMessage> => {
2780
- calls += 1;
2781
- throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2782
- },
2783
- // Resolvable vendor, so the per-vendor fields below are exercised.
2784
- getStatus: async () => ({ provider: 'anthropic', model: 'test-model' }),
2785
- };
2786
- const config = agent({
2787
- name: 'Static',
2788
- toolDefinitions: [def('noop')],
2789
- toolHandlers: { noop: async () => 'ok' },
2790
- });
2791
- const driver = makeDriver(config, provider, 'outcome-budget-once', outcomeBus);
2792
- const cap = captureLoopEnd();
2793
-
2794
- const result: ChatDriverResult = await driver.sendMessage('go');
2795
-
2796
- // The whole point of the typed branch: it sits BEFORE the transient-retry
2797
- // catch, so the driver must not burn MAX_SETUP_TRANSPORT_RETRIES attempts
2798
- // against a wall that no retry can clear.
2799
- assert.is(calls, 1, 'a budget wall is not retried — exactly one model call');
2800
- assert.is(
2801
- result.reason === 'done' ? result.failureReason : undefined,
2802
- 'budget-exhausted',
2803
- 'the turn ends with the budget-exhausted failure reason',
2804
- );
2805
-
2806
- // The figures now reach the CALLER, not just the debug log — this is what lets
2807
- // the element render "$25.40 of $25" in its banner without catching the
2808
- // transport error itself.
2809
- const budget = result.reason === 'done' ? result.budget : undefined;
2810
- assert.is(budget?.budgetUsd, 25);
2811
- assert.is(budget?.spentUsd, 25.4);
2812
- assert.is(budget?.vendorLabel, 'Anthropic');
2813
-
2814
- // The bus detail carries the resolved vendor beside the reason.
2815
- assert.is(cap.detail()!.failureReason, 'budget-exhausted');
2816
- assert.is((cap.detail() as { vendor?: string }).vendor, 'anthropic');
2817
- cap.stop();
2818
-
2819
- const last = driver.getHistory().at(-1);
2820
- assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
2821
- // Identity, not substrings: the transcript bubble and the element's blocked
2822
- // banner now read from one shared const, and this is what pins them together.
2823
- assert.is(last!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2824
- assert.not.ok(
2825
- last!.content.includes('something went wrong'),
2826
- 'must not fall through to the generic apology',
2827
- );
2828
-
2829
- // The budget figures ride the debug-log entry so a support ticket can show them.
2830
- const err = getMetaEvents('outcome-budget-once').find((e) => e.type === 'turn.error');
2831
- assert.is(err!.detail?.budgetUsd, 25);
2832
- assert.is(err!.detail?.spentUsd, 25.4);
2833
- // The registry ALIAS and the VENDOR are different things; record both.
2834
- assert.is(err!.detail?.provider, 'test');
2835
- assert.is(err!.detail?.vendor, 'anthropic');
2836
- });
2837
-
2838
- outcome('a non-budget failure emits the historical detail shape, with no vendor', async () => {
2839
- // Back-compat for subscribers that structurally compare the `tool-loop-end`
2840
- // detail: only a budget failure grew a field.
2841
- clearMetaEventRegistry();
2842
- const config = agent({
2843
- name: 'Static',
2844
- toolDefinitions: [def('noop')],
2845
- toolHandlers: { noop: async () => 'ok' },
2846
- });
2847
- const driver = makeDriver(config, throwingProvider(), 'outcome-vendor-compat', outcomeBus);
2848
- const cap = captureLoopEnd();
2849
-
2850
- await driver.sendMessage('go');
2851
-
2852
- assert.equal(cap.detail(), { failureReason: 'exception' }, 'no vendor key was added');
2853
- cap.stop();
2854
- });
2855
-
2856
- outcome('a 402 with no figures still carries the vendor that refused it', async () => {
2857
- // The regression this pins: the driver used to drop the ENTIRE budget object
2858
- // when both figures were absent, and `vendorLabel` — the authoritative
2859
- // attribution source — went with it. The element then fell back to the
2860
- // driver's last-resolved provider, i.e. the PREVIOUS turn's vendor, and walled
2861
- // the wrong one. Three live shapes arrive here with no figures: a bare gateway
2862
- // 402, a code-only body, and the framed err frame whose figures are `null`.
2863
- clearMetaEventRegistry();
2864
- const provider: AIProvider = {
2865
- chat: async (): Promise<ChatMessage> => {
2866
- throw new BudgetExhaustedError('Anthropic');
2867
- },
2868
- };
2869
- const config = agent({
2870
- name: 'Static',
2871
- toolDefinitions: [def('noop')],
2872
- toolHandlers: { noop: async () => 'ok' },
2873
- });
2874
- const driver = makeDriver(config, provider, 'outcome-budget-nofigures', outcomeBus);
2875
- const cap = captureLoopEnd();
2876
-
2877
- const result: ChatDriverResult = await driver.sendMessage('go');
2878
-
2879
- const budget = result.reason === 'done' ? result.budget : undefined;
2880
- assert.ok(budget, 'a figure-less wall still reports who refused it');
2881
- assert.is(budget!.vendorLabel, 'Anthropic');
2882
- assert.is(budget!.vendor, 'anthropic');
2883
- // Still no figures — the element reads their ABSENCE as "leave any host-set
2884
- // banner copy alone", so they must not be invented.
2885
- assert.is(budget!.budgetUsd, undefined);
2886
- assert.is(budget!.spentUsd, undefined);
2887
- // Both seams, since the bus one lands first and wins the latch.
2888
- assert.is((cap.detail() as { budget?: { vendor?: string } }).budget?.vendor, 'anthropic');
2889
- cap.stop();
2890
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2891
- });
2892
-
2893
- outcome('a wall with nothing to report at all still omits the budget key', async () => {
2894
- // The other direction: `budget` is present when there is something to say and
2895
- // absent when there is not, so presence keeps meaning something. No figures AND
2896
- // an unclaimed label leaves nothing to carry — and the element's fail-safe
2897
- // (block everything) is the correct outcome for an unattributable wall.
2898
- clearMetaEventRegistry();
2899
- const provider: AIProvider = {
2900
- chat: async (): Promise<ChatMessage> => {
2901
- throw new BudgetExhaustedError('Acme AI');
2902
- },
2903
- };
2904
- const config = agent({
2905
- name: 'Static',
2906
- toolDefinitions: [def('noop')],
2907
- toolHandlers: { noop: async () => 'ok' },
2908
- });
2909
- const driver = makeDriver(config, provider, 'outcome-budget-nothing', outcomeBus);
2910
-
2911
- const result: ChatDriverResult = await driver.sendMessage('go');
2912
-
2913
- assert.not.ok('budget' in result, 'nothing reportable → no budget key');
2914
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2915
- });
2916
-
2917
- outcome('a figure-less wall attributes to the refusing transport, not the last turn', async () => {
2918
- // The exact failure: turn 1 runs on Gemini, turn 2's request is refused by
2919
- // Anthropic with a figure-less 402. `lastResolvedProvider` still says `gemini`,
2920
- // so the pre-fix driver reported Gemini and the user was told to switch to the
2921
- // vendor that had actually run out.
2922
- clearMetaEventRegistry();
2923
- let walled = false;
2924
- const provider: AIProvider = {
2925
- chat: async (): Promise<ChatMessage> => {
2926
- if (!walled) {
2927
- walled = true;
2928
- return { role: 'assistant', content: 'first turn, on Gemini' };
2929
- }
2930
- throw new BudgetExhaustedError('Anthropic');
2931
- },
2932
- };
2933
- // The registry resolves to Gemini, so `lastResolvedProvider` is `gemini` by the
2934
- // time the Anthropic wall lands — the disagreement has to be seeded to prove
2935
- // which source is believed.
2936
- const registry: AIProviderRegistry = {
2937
- get: () => provider,
2938
- default: () => provider,
2939
- defaultName: () => 'test',
2940
- names: () => ['test'],
2941
- getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
2942
- listStatuses: async () => [],
2943
- };
2944
- const driver = new ChatDriver(registry, {
2945
- sessionKey: 'outcome-budget-stale-vendor',
2946
- activityBus: outcomeBus,
2947
- });
2948
- driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2949
-
2950
- await driver.sendMessage('one');
2951
- const result: ChatDriverResult = await driver.sendMessage('two');
2952
-
2953
- const budget = result.reason === 'done' ? result.budget : undefined;
2954
- assert.is(budget?.vendor, 'anthropic', 'the transport that said no, not the one before it');
2955
- const err = getMetaEvents('outcome-budget-stale-vendor').find((e) => e.type === 'turn.error');
2956
- assert.is(err!.detail?.vendor, 'anthropic', 'and the debug log agrees');
2957
- });
2958
-
2959
- outcome("the proxy's own `vendor` rescues an unclaimed transport label", async () => {
2960
- // A multiplexing or white-labelled gateway fronts several upstreams behind one
2961
- // transport, so its static label names none of them. The 402's `vendor` is then
2962
- // the only thing that knows which pot was actually booked against — second
2963
- // opinion, never first: the transport's own label still wins where it resolves.
2964
- clearMetaEventRegistry();
2965
- const provider: AIProvider = {
2966
- chat: async (): Promise<ChatMessage> => {
2967
- throw new BudgetExhaustedError('Acme AI', undefined, undefined, undefined, {
2968
- serverVendor: 'Gemini',
2969
- });
2970
- },
2971
- };
2972
- const config = agent({
2973
- name: 'Static',
2974
- toolDefinitions: [def('noop')],
2975
- toolHandlers: { noop: async () => 'ok' },
2976
- });
2977
- const driver = makeDriver(config, provider, 'outcome-budget-server-vendor', outcomeBus);
2978
-
2979
- const result: ChatDriverResult = await driver.sendMessage('go');
2980
-
2981
- const budget = result.reason === 'done' ? result.budget : undefined;
2982
- assert.is(budget?.vendorLabel, 'Acme AI', 'the transport label is reported verbatim');
2983
- assert.is(budget?.vendor, 'gemini', 'and attribution still lands');
2984
- });
2985
-
2986
- outcome("the proxy's `otherVendorAvailable` verdict reaches both seams", async () => {
2987
- // The one fact the element cannot re-derive: registry membership says which
2988
- // vendors EXIST, never which still have budget. It has to survive the trip.
2989
- clearMetaEventRegistry();
2990
- const provider: AIProvider = {
2991
- chat: async (): Promise<ChatMessage> => {
2992
- throw new BudgetExhaustedError('Anthropic', 25, 25, undefined, {
2993
- otherVendorAvailable: false,
2994
- });
2995
- },
2996
- };
2997
- const config = agent({
2998
- name: 'Static',
2999
- toolDefinitions: [def('noop')],
3000
- toolHandlers: { noop: async () => 'ok' },
3001
- });
3002
- const driver = makeDriver(config, provider, 'outcome-budget-other-vendor', outcomeBus);
3003
- const cap = captureLoopEnd();
3004
-
3005
- const result: ChatDriverResult = await driver.sendMessage('go');
3006
-
3007
- assert.is(
3008
- result.reason === 'done' ? result.budget?.otherVendorAvailable : undefined,
3009
- false,
3010
- 'the return-value seam',
3011
- );
3012
- assert.is(
3013
- (cap.detail() as { budget?: { otherVendorAvailable?: boolean } }).budget?.otherVendorAvailable,
3014
- false,
3015
- 'and the bus seam, which lands first and wins the latch',
3016
- );
3017
- cap.stop();
3018
- });
3019
-
3020
- outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', async () => {
3021
- clearMetaEventRegistry();
3022
- const provider: AIProvider = {
3023
- chat: async (): Promise<ChatMessage> => {
3024
- throw new BudgetExhaustedError('Anthropic', 25, 25.4);
3025
- },
3026
- };
3027
- const driver = new ChatDriver(makeRegistry(provider), {
3028
- sessionKey: 'outcome-budget-custom',
3029
- activityBus: outcomeBus,
3030
- budgetExhaustedMessage: 'Contact ops@acme.com',
3031
- });
3032
- driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
3033
-
3034
- await driver.sendMessage('go');
3035
-
3036
- // The banner was already overridable; without this seam a white-labelled host
3037
- // got its own copy in the banner and 'contact Genesis' directly below it.
3038
- assert.is(driver.getHistory().at(-1)!.content, 'Contact ops@acme.com');
3039
- });
3040
-
3041
- // ── The composed default bubble (GENC-1464 tester finding) ─────────────────────
3042
- // The blocked banner advises switching when another vendor has headroom; the
3043
- // transcript bubble directly under it used to say only "contact your
3044
- // administrator" regardless. The two surfaces must agree, and the bubble may
3045
- // only advise a switch the proxy positively vouched for.
3046
-
3047
- /** Drive one walled turn and return the transcript's final (bubble) content. */
3048
- const bubbleAfterWall = async (
3049
- sessionKey: string,
3050
- e: BudgetExhaustedError,
3051
- budgetExhaustedMessage?: string,
3052
- ): Promise<string> => {
3053
- clearMetaEventRegistry();
3054
- const provider: AIProvider = {
3055
- chat: async (): Promise<ChatMessage> => {
3056
- throw e;
3057
- },
3058
- };
3059
- const driver = new ChatDriver(makeRegistry(provider), {
3060
- sessionKey,
3061
- activityBus: outcomeBus,
3062
- ...(budgetExhaustedMessage == null ? {} : { budgetExhaustedMessage }),
3063
- });
3064
- driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
3065
- await driver.sendMessage('go');
3066
- return String(driver.getHistory().at(-1)!.content);
3067
- };
3068
-
3069
- outcome(
3070
- 'default bubble advises switching when the 402 vouches for headroom elsewhere',
3071
- async () => {
3072
- const content = await bubbleAfterWall(
3073
- 'outcome-bubble-switch',
3074
- new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
3075
- );
3076
- assert.is(
3077
- content,
3078
- "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
3079
- );
3080
- },
3081
- );
3082
-
3083
- outcome('default bubble stays neutral when every other vendor is exhausted too', async () => {
3084
- const content = await bubbleAfterWall(
3085
- 'outcome-bubble-all-walled',
3086
- new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: false }),
3087
- );
3088
- // Advising a switch to a vendor the proxy just said is empty would be worse
3089
- // than the neutral copy.
3090
- assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
3091
- });
3092
-
3093
- outcome('default bubble stays neutral when an older proxy did not say', async () => {
3094
- const content = await bubbleAfterWall(
3095
- 'outcome-bubble-no-verdict',
3096
- new BudgetExhaustedError('Anthropic', 25, 25.4),
3097
- );
3098
- // `undefined` is "the proxy did not say", not "another vendor is free".
3099
- assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
3100
- });
3101
-
3102
- outcome('a host override wins verbatim even when switching would be honest advice', async () => {
3103
- const content = await bubbleAfterWall(
3104
- 'outcome-bubble-override-wins',
3105
- new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
3106
- 'Contact ops@acme.com',
3107
- );
3108
- // The white-label contract: host copy is never composed onto.
3109
- assert.is(content, 'Contact ops@acme.com');
3110
- });
3111
-
3112
- // ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
3113
- // `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
3114
- // parent too". Nothing parent-side read it, so the parent's tool loop appended
3115
- // the failed outcome as a tool result and called the model again — into the same
3116
- // wall. With N batched delegations that was N doomed children AND a doomed parent
3117
- // call, and the element's `failureReason === 'budget-exhausted'` latch never fired.
3118
-
3119
- /** A sub-agent whose own turn is refused for budget; the parent's turns are not. */
3120
- const parentOkChildWalled = (): { parentCalls: () => number; provider: AIProvider } => {
3121
- let parentCalls = 0;
3122
- return {
3123
- parentCalls: () => parentCalls,
3124
- provider: {
3125
- chat: async (
3126
- _h: ChatMessage[],
3127
- _u: string,
3128
- options?: ChatRequestOptions,
3129
- ): Promise<ChatMessage> => {
3130
- const names = (options?.tools ?? []).map((t) => t.name);
3131
- if (names.includes('delegate')) {
3132
- parentCalls += 1;
3133
- return callsTool('delegate', `d${parentCalls}`);
3134
- }
3135
- throw new BudgetExhaustedError('Anthropic', 25, 25.4);
3136
- },
3137
- },
3138
- };
3139
- };
3140
-
3141
- const walledWorker = (): AgentConfig =>
3142
- agent({
3143
- name: 'worker',
3144
- toolDefinitions: [def('finish')],
3145
- toolHandlers: { finish: async () => 'x' },
3146
- });
3147
-
3148
- /**
3149
- * The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
3150
- * succeeds and delegates, and only the child's provider refuses.
3151
- */
3152
- const parentOkChildRefused = (
3153
- kind: 'spend' | 'auth' = 'spend',
3154
- ): { parentCalls: () => number; provider: AIProvider } => {
3155
- let parentCalls = 0;
3156
- return {
3157
- parentCalls: () => parentCalls,
3158
- provider: {
3159
- chat: async (
3160
- _h: ChatMessage[],
3161
- _u: string,
3162
- options?: ChatRequestOptions,
3163
- ): Promise<ChatMessage> => {
3164
- const names = (options?.tools ?? []).map((t) => t.name);
3165
- if (names.includes('delegate')) {
3166
- parentCalls += 1;
3167
- return callsTool('delegate', `d${parentCalls}`);
3168
- }
3169
- throw kind === 'auth'
3170
- ? new ProviderRefusedError(
3171
- 'Anthropic',
3172
- 'auth',
3173
- 401,
3174
- 'authentication_error',
3175
- 'API key is invalid.',
3176
- )
3177
- : new ProviderRefusedError(
3178
- 'Anthropic',
3179
- 'spend',
3180
- 400,
3181
- 'invalid_request_error',
3182
- 'credit too low',
3183
- );
3184
- },
3185
- },
3186
- };
3187
- };
3188
-
3189
- outcome(
3190
- 'a sub-agent provider refusal ends the parent turn without another model call',
3191
- async () => {
3192
- /*
3193
- * The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
3194
- * instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
3195
- * only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
3196
- * parent issued another model call straight into the same wall, and if that call had happened to
3197
- * succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
3198
- * dead account. `parentCalls() === 1` is the assertion that pins it.
3199
- */
3200
- clearMetaEventRegistry();
3201
- const { parentCalls, provider } = parentOkChildRefused();
3202
- const parent = delegatingParent(walledWorker(), () => undefined);
3203
- const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
3204
-
3205
- const result: ChatDriverResult = await driver.sendMessage('go');
3206
-
3207
- assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
3208
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
3209
- assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
3210
- },
3211
- );
3212
-
3213
- outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", async () => {
3214
- // The other half of the same finding. The parent can only report what the child bubbled, so without a
3215
- // payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
3216
- // absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
3217
- // top up an account or rotate a credential.
3218
- clearMetaEventRegistry();
3219
- const { provider } = parentOkChildRefused('auth');
3220
- const parent = delegatingParent(walledWorker(), () => undefined);
3221
- const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
3222
-
3223
- const result: ChatDriverResult = await driver.sendMessage('go');
3224
-
3225
- const refused = result.reason === 'done' ? result.providerRefused : undefined;
3226
- assert.is(refused?.kind, 'auth', "the child's kind survives the hop");
3227
- assert.is(refused?.vendorLabel, 'Anthropic');
3228
- assert.is(refused?.upstreamStatus, 401);
3229
- });
3230
-
3231
- outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", async () => {
3232
- /*
3233
- * The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
3234
- * grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
3235
- * `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
3236
- * travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
3237
- * gone, which is the failure mode this payload exists to prevent.
3238
- *
3239
- * Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
3240
- * succeeds, so nothing but the forwarding can carry the detail to the top.
3241
- */
3242
- clearMetaEventRegistry();
3243
- const worker = agent({
3244
- name: 'worker',
3245
- toolDefinitions: [def('finish')],
3246
- toolHandlers: { finish: async () => 'x' },
3247
- });
3248
- const middle = agent({
3249
- name: 'middle',
3250
- subAgents: [worker],
3251
- toolDefinitions: [def('sub_delegate')],
3252
- toolHandlers: {
3253
- sub_delegate: async (_args, ctx) => {
3254
- const o = await ctx.requestSubAgent!('worker', { task: 'deeper' });
3255
- return o.ok ? 'ok' : `failed: ${o.reason}`;
3256
- },
3257
- },
3258
- });
3259
- const boss = agent({
3260
- name: 'boss',
3261
- subAgents: [middle],
3262
- toolDefinitions: [def('delegate')],
3263
- toolHandlers: {
3264
- delegate: async (_args, ctx) => {
3265
- const o = await ctx.requestSubAgent!('middle', { task: 'do it' });
3266
- return o.ok ? 'ok' : `failed: ${o.reason}`;
3267
- },
3268
- },
3269
- });
3270
-
3271
- // Routed by the tool surface each tier is given, so only the deepest turn throws.
3272
- const provider: AIProvider = {
3273
- chat: async (
3274
- _h: ChatMessage[],
3275
- _u: string,
3276
- options?: ChatRequestOptions,
3277
- ): Promise<ChatMessage> => {
3278
- const names = (options?.tools ?? []).map((t) => t.name);
3279
- if (names.includes('delegate')) return callsTool('delegate', 'd1');
3280
- if (names.includes('sub_delegate')) return callsTool('sub_delegate', 's1');
3281
- throw new ProviderRefusedError(
3282
- 'Anthropic',
3283
- 'auth',
3284
- 401,
3285
- 'authentication_error',
3286
- 'API key is invalid.',
3287
- );
3288
- },
3289
- };
3290
- const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
3291
-
3292
- const result: ChatDriverResult = await driver.sendMessage('go');
3293
-
3294
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
3295
- const refused = result.reason === 'done' ? result.providerRefused : undefined;
3296
- assert.is(refused?.kind, 'auth', "the grandchild's kind survives BOTH hops");
3297
- assert.is(refused?.vendorLabel, 'Anthropic');
3298
- assert.is(refused?.upstreamStatus, 401);
3299
- assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
3300
- });
3301
-
3302
- outcome('a sub-agent budget wall ends the parent turn without another model call', async () => {
3303
- clearMetaEventRegistry();
3304
- const { parentCalls, provider } = parentOkChildWalled();
3305
- const parent = delegatingParent(walledWorker(), () => undefined);
3306
- const driver = makeDriver(parent, provider, 'outcome-subagent-budget', outcomeBus);
3307
-
3308
- const result: ChatDriverResult = await driver.sendMessage('go');
3309
-
3310
- assert.is(parentCalls(), 1, 'the parent never calls the model again after the wall');
3311
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
3312
- assert.is(driver.getHistory().at(-1)!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
3313
- });
3314
-
3315
- outcome(
3316
- "a sub-agent wall with headroom elsewhere composes the parent's switch advice",
3317
- async () => {
3318
- // The parent's bubble is composed from the latched `budgetWallDetail` — on the
3319
- // sub-agent path there is NO error object in scope, only the failure payload
3320
- // the child attributed (this pins the exact seam a scope bug once lived on).
3321
- clearMetaEventRegistry();
3322
- let parentCalls = 0;
3323
- const provider: AIProvider = {
3324
- chat: async (
3325
- _h: ChatMessage[],
3326
- _u: string,
3327
- options?: ChatRequestOptions,
3328
- ): Promise<ChatMessage> => {
3329
- if ((options?.tools ?? []).some((t) => t.name === 'delegate')) {
3330
- parentCalls += 1;
3331
- return callsTool('delegate', `d${parentCalls}`);
3332
- }
3333
- throw new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, {
3334
- otherVendorAvailable: true,
3335
- });
3336
- },
3337
- };
3338
- const parent = delegatingParent(walledWorker(), () => undefined);
3339
- const driver = makeDriver(parent, provider, 'outcome-subagent-budget-switch', outcomeBus);
3340
-
3341
- const result: ChatDriverResult = await driver.sendMessage('go');
3342
-
3343
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
3344
- assert.is(
3345
- driver.getHistory().at(-1)!.content,
3346
- "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
3347
- );
3348
- },
3349
- );
3350
-
3351
- outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", async () => {
3352
- // A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
3353
- // `config.provider`). The wall reaches the parent only as a tool result, so
3354
- // while the parent carried nothing but a boolean it reported
3355
- // `lastResolvedProvider` — its own vendor, i.e. precisely the one that did NOT
3356
- // refuse. Under a two-vendor registry that walls Gemini because an Anthropic
3357
- // child 402'd, and the host then blocks a composer that still had headroom.
3358
- clearMetaEventRegistry();
3359
- const { provider } = parentOkChildWalled();
3360
- // Seeded disagreement: the registry resolves Gemini, so the parent's
3361
- // `lastResolvedProvider` is `gemini` while the child is refused by Anthropic.
3362
- const registry: AIProviderRegistry = {
3363
- get: () => provider,
3364
- default: () => provider,
3365
- defaultName: () => 'test',
3366
- names: () => ['test'],
3367
- getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3368
- listStatuses: async () => [],
3369
- };
3370
- const driver = new ChatDriver(registry, {
3371
- sessionKey: 'outcome-subagent-budget-vendor',
3372
- activityBus: outcomeBus,
3373
- });
3374
- driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
3375
-
3376
- const result: ChatDriverResult = await driver.sendMessage('go');
3377
-
3378
- const budget = result.reason === 'done' ? result.budget : undefined;
3379
- assert.is(budget?.vendor, 'anthropic', "the child's refusing vendor survives the hop");
3380
- assert.is(budget?.budgetUsd, 25, "and so do the child's figures");
3381
- assert.is(budget?.spentUsd, 25.4);
3382
- const err = getMetaEvents('outcome-subagent-budget-vendor').find((e) => e.type === 'turn.error');
3383
- assert.is(err!.detail?.vendor, 'anthropic', 'and the debug log agrees');
3384
- });
3385
-
3386
- outcome('N batched sub-agent calls still produce at most one doomed parent call', async () => {
3387
- clearMetaEventRegistry();
3388
- let parentCalls = 0;
3389
- const provider: AIProvider = {
3390
- chat: async (
3391
- _h: ChatMessage[],
3392
- _u: string,
3393
- options?: ChatRequestOptions,
3394
- ): Promise<ChatMessage> => {
3395
- const names = (options?.tools ?? []).map((t) => t.name);
3396
- if (names.includes('delegate')) {
3397
- parentCalls += 1;
3398
- return {
3399
- role: 'assistant',
3400
- content: '',
3401
- toolCalls: [
3402
- { id: 'd1', name: 'delegate', args: {} },
3403
- { id: 'd2', name: 'delegate', args: {} },
3404
- ],
3405
- };
3406
- }
3407
- throw new BudgetExhaustedError('Anthropic', 25, 25.4);
3408
- },
3409
- };
3410
- const parent = delegatingParent(walledWorker(), () => undefined);
3411
- const driver = makeDriver(parent, provider, 'outcome-subagent-budget-batched', outcomeBus);
3412
-
3413
- const result: ChatDriverResult = await driver.sendMessage('go');
3414
-
3415
- assert.is(parentCalls, 1);
3416
- assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
3417
- });
3418
-
3419
- outcome('a non-terminal sub-agent failure still lets the parent continue', async () => {
3420
- // The short-circuit is scoped strictly to the budget reason — every other
3421
- // SubAgentFailureReason is something the parent can legitimately recover from.
3422
- clearMetaEventRegistry();
3423
- let parentCalls = 0;
3424
- const provider: AIProvider = {
3425
- chat: async (
3426
- _h: ChatMessage[],
3427
- _u: string,
3428
- options?: ChatRequestOptions,
3429
- ): Promise<ChatMessage> => {
3430
- const names = (options?.tools ?? []).map((t) => t.name);
3431
- if (names.includes('delegate')) {
3432
- parentCalls += 1;
3433
- return parentCalls === 1
3434
- ? callsTool('delegate', 'd1')
3435
- : { role: 'assistant', content: 'carried on regardless' };
3436
- }
3437
- // The child answers in free text without calling its completion tool →
3438
- // `max_iterations`, a recoverable failure.
3439
- return { role: 'assistant', content: 'no structured result' };
3440
- },
3441
- };
3442
- let captured: unknown;
3443
- const parent = delegatingParent(walledWorker(), (o) => {
3444
- captured = o;
3445
- });
3446
- const driver = makeDriver(parent, provider, 'outcome-subagent-nonterminal', outcomeBus);
3447
-
3448
- const result: ChatDriverResult = await driver.sendMessage('go');
3449
-
3450
- assert.equal(captured, { ok: false, reason: 'max_iterations' });
3451
- assert.is(parentCalls, 2, 'the parent loop kept running');
3452
- assert.is(result.reason === 'done' ? result.failureReason : undefined, undefined);
3453
- });
3454
-
3455
- outcome(
3456
- "a later unattributable child wall does not erase the first child's attribution",
3457
- async () => {
3458
- // Batched delegations: child A is refused by Anthropic WITH attribution,
3459
- // child B by a transport whose label no vendor claims (figure-less 402 → no
3460
- // BudgetDetail at all). A plain assignment let B's `undefined` clobber A's
3461
- // attribution, and the fallback then reported the PARENT's vendor — under a
3462
- // Gemini-resolving registry, precisely the vendor that did not refuse.
3463
- clearMetaEventRegistry();
3464
- let childCalls = 0;
3465
- const provider: AIProvider = {
3466
- chat: async (
3467
- _h: ChatMessage[],
3468
- _u: string,
3469
- options?: ChatRequestOptions,
3470
- ): Promise<ChatMessage> => {
3471
- const names = (options?.tools ?? []).map((t) => t.name);
3472
- if (names.includes('delegate')) {
3473
- return {
3474
- role: 'assistant',
3475
- content: '',
3476
- toolCalls: [
3477
- { id: 'd1', name: 'delegate', args: {} },
3478
- { id: 'd2', name: 'delegate', args: {} },
3479
- ],
3480
- };
3481
- }
3482
- childCalls += 1;
3483
- throw childCalls === 1
3484
- ? new BudgetExhaustedError('Anthropic', 25, 25.4)
3485
- : new BudgetExhaustedError('Acme AI'); // unclaimed label, no figures
3486
- },
3487
- };
3488
- const registry: AIProviderRegistry = {
3489
- get: () => provider,
3490
- default: () => provider,
3491
- defaultName: () => 'test',
3492
- names: () => ['test'],
3493
- getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3494
- listStatuses: async () => [],
3495
- };
3496
- const driver = new ChatDriver(registry, {
3497
- sessionKey: 'outcome-subagent-budget-clobber',
3498
- activityBus: outcomeBus,
3499
- });
3500
- driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
3501
-
3502
- const result: ChatDriverResult = await driver.sendMessage('one');
3503
-
3504
- const budget = result.reason === 'done' ? result.budget : undefined;
3505
- assert.is(budget?.vendor, 'anthropic', "child A's attribution survives child B's blank wall");
3506
- },
3507
- );
3508
-
3509
- outcome(
3510
- 'a fully unattributable child wall degrades vendor-agnostic, never to the parent',
3511
- async () => {
3512
- // When NO child wall carries attribution, the honest event is vendor-less.
3513
- // Falling back to `lastResolvedProvider` named the parent's vendor — walling
3514
- // BOTH vendors (the child's via its own tool-loop-end, the parent's via this
3515
- // one) and deriving `blocked` over headroom that still existed.
3516
- clearMetaEventRegistry();
3517
- const events: Array<{ vendor?: string } | undefined> = [];
3518
- const bus = new AgenticActivityBus();
3519
- bus.subscribe('tool-loop-end', (d) => events.push(d as { vendor?: string } | undefined));
3520
- // The provider must carry its own `getStatus` — `lastResolvedProvider` is
3521
- // stamped from the PROVIDER's status, not the registry's, and a status-less
3522
- // mock leaves it unset, which lets the buggy fallback pass vacuously (there
3523
- // is no wrong vendor to fall back to).
3524
- const provider: AIProvider = {
3525
- getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3526
- chat: async (
3527
- _h: ChatMessage[],
3528
- _u: string,
3529
- options?: ChatRequestOptions,
3530
- ): Promise<ChatMessage> => {
3531
- const names = (options?.tools ?? []).map((t) => t.name);
3532
- if (names.includes('delegate')) return callsTool('delegate', 'd1');
3533
- // The CHILD's turn: refused by a transport whose label no vendor claims.
3534
- throw new BudgetExhaustedError('Acme AI'); // no figures either
3535
- },
3536
- } as AIProvider;
3537
- const registry: AIProviderRegistry = {
3538
- get: () => provider,
3539
- default: () => provider,
3540
- defaultName: () => 'test',
3541
- names: () => ['test'],
3542
- getStatus: async () => ({ provider: 'gemini', model: 'gemini-test' }),
3543
- listStatuses: async () => [],
3544
- };
3545
- const driver = new ChatDriver(registry, {
3546
- sessionKey: 'outcome-subagent-budget-agnostic',
3547
- activityBus: bus,
3548
- });
3549
- driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
3550
-
3551
- await driver.sendMessage('go');
3552
-
3553
- // The PARENT's loop-end is the last one on the bus. It must carry the
3554
- // failure without naming the parent's (gemini) vendor.
3555
- const parentEnd = events.at(-1) as { failureReason?: string; vendor?: string } | undefined;
3556
- assert.is(parentEnd?.failureReason, 'budget-exhausted');
3557
- assert.is(parentEnd?.vendor, undefined, 'no vendor is better than the wrong vendor');
3558
- // The CHILD's folded-in turn.error legitimately carries its own best-effort
3559
- // self-attribution (its resolved provider really did make the refused call);
3560
- // the record this fix governs is the PARENT's short-circuit, tagged
3561
- // `via: 'sub-agent'`.
3562
- const err = getMetaEvents('outcome-subagent-budget-agnostic').find(
3563
- (e) => e.type === 'turn.error' && e.detail?.via === 'sub-agent',
3564
- );
3565
- assert.ok(err, "the parent's short-circuit recorded the wall");
3566
- assert.is(err!.detail?.vendor, undefined, 'and named no vendor rather than the wrong one');
3567
- },
3568
- );
3569
-
3570
- outcome.run();
3571
-
3572
- // ---------------------------------------------------------------------------
3573
- // recordExternalCost — spend a widget incurs AFTER it has resolved.
3574
- //
3575
- // Some widgets resolve EARLY on purpose, so the assistant stops waiting while the
3576
- // user deliberates, and then keep doing paid work for them. `resolveInteraction`
3577
- // cannot serve that: its whole body is inside `if (interaction)` and it deletes the
3578
- // entry, so a second resolution is a silent no-op and the spend never reached the
3579
- // figure shown to the user.
3580
- // ---------------------------------------------------------------------------
3581
-
3582
- const postResolveCost = createLogicSuite('ChatDriver post-resolve external cost');
3583
-
3584
- postResolveCost('adds spend to an interaction that has already resolved', async () => {
3585
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
3586
- const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
3587
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
3588
-
3589
- driver.resolveInteraction(id, { status: 'approved', costUsd: 0.5 });
3590
- await pending;
3591
-
3592
- // The interaction is gone from `pendingInteractions` — this is exactly the state in
3593
- // which the old path silently did nothing.
3594
- assert.is(driver.recordExternalCost(id, 0.25), true);
3595
- assert.is(driver.recordExternalCost(id, 0.25), true);
3596
-
3597
- const msg = driver.getHistory().find((m) => m.interaction?.interactionId === id);
3598
- // ADDITIVE: the resolution's 0.5 plus two 0.25 deltas.
3599
- assert.is(msg?.externalCostUsd, 1);
3600
- // …and the recorded resolution is untouched — this is not a second resolution.
3601
- assert.equal(msg?.interaction?.resolved, { status: 'approved', costUsd: 0.5 });
3602
- });
3603
-
3604
- postResolveCost('works on an interaction that resolved with no cost at all', async () => {
3605
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
3606
- const pending = driver.requestInteraction('planning-question', { question: 'q' });
3607
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
3608
- driver.resolveInteraction(id, { status: 'approved' });
3609
- await pending;
3610
-
3611
- assert.is(driver.recordExternalCost(id, 0.02), true);
3612
- const msg = driver.getHistory().find((m) => m.interaction?.interactionId === id);
3613
- assert.is(msg?.externalCostUsd, 0.02);
3614
- });
3615
-
3616
- postResolveCost('refuses values that are not real spend, and an unknown interaction', async () => {
3617
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
3618
- const pending = driver.requestInteraction('planning-question', { question: 'q' });
3619
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
3620
- driver.resolveInteraction(id, { status: 'approved' });
3621
- await pending;
3622
-
3623
- // Includes the shapes only an untyped JS caller can produce — which is exactly who the
3624
- // `typeof` guard is written for, and what the previous `as number` cast asserted nothing about.
3625
- for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '0.5', null, undefined]) {
3626
- assert.is(driver.recordExternalCost(id, bad as number), false);
3627
- }
3628
- // …and each refusal leaves a trace: the wrapper no longer pre-validates (it made this
3629
- // unreachable through the event path, a widget's only way in), so this is the ONE validator
3630
- // and it has to record what it dropped.
3631
- const dropped = getMetaEvents('').filter((m) => m.type === 'interaction.external-cost-dropped');
3632
- assert.ok(dropped.length >= 1, 'a refused amount must record why');
3633
- // An empty id can never match a message, so it is refused before the search.
3634
- assert.is(driver.recordExternalCost('', 0.1), false);
3635
- // A message trimmed or condensed out of history is a real outcome, not a throw.
3636
- assert.is(driver.recordExternalCost('no-such-interaction', 0.1), false);
3637
-
3638
- const msg = driver.getHistory().find((m) => m.interaction?.interactionId === id);
3639
- assert.is(msg?.externalCostUsd, undefined);
3640
- });
3641
-
3642
- postResolveCost(
3643
- 'announces the change NARROWLY — never on history-updated, which rebuilds the widget',
3644
- async () => {
3645
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
3646
- const pending = driver.requestInteraction('planning-question', { question: 'q' });
3647
- const id = driver.getHistory().at(-1)!.interaction!.interactionId;
3648
- driver.resolveInteraction(id, { status: 'approved' });
3649
- await pending;
3650
-
3651
- let narrow = 0;
3652
- let costUsd: number | undefined;
3653
- let interactionId: string | undefined;
3654
- let historyUpdated = 0;
3655
- driver.addEventListener('external-cost-recorded', (e) => {
3656
- narrow += 1;
3657
- ({ costUsd, interactionId } = (
3658
- e as CustomEvent<{ interactionId: string; costUsd: number }>
3659
- ).detail);
3660
- });
3661
- driver.addEventListener('history-updated', () => {
3662
- historyUpdated += 1;
3663
- });
3664
- driver.recordExternalCost(id, 0.07);
3665
-
3666
- // Announced, or the total is right in history and stale on screen.
3667
- assert.is(narrow, 1);
3668
- assert.is(costUsd, 0.07);
3669
- assert.is(interactionId, id);
3670
- // REGRESSION (reviewer catch on #2474): `history-updated` makes the host reassign `messages`,
3671
- // which rebuilds the trailing interaction row — a `recycle: false` repeat that disposes its
3672
- // views outright. For a RESOLVED interaction there is no `interactionContext` to remount
3673
- // from, so announcing this way tore down and blanked the very widget that had just reported
3674
- // its spend. Bookkeeping about a finished turn must not touch what is rendered.
3675
- assert.is(historyUpdated, 0);
3676
- },
3677
- );
3678
-
3679
- postResolveCost.run();