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