@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,1065 +0,0 @@
1
- import { __awaiter } from "tslib";
2
- import { DEFAULT_BUDGET_EXHAUSTED_MESSAGE } from '@genesislcap/foundation-ai';
3
- import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
- import { clearAllSessionStores, getSessionStore } from '../state/session-store';
5
- import { formatBlockedReason, FoundationAiAssistant } from './main';
6
- import { BLOCKED_BANNER_ID, blockedBannerClasses, composerDescribedBy } from './main.template';
7
- // Hold a reference so the custom-element registration isn't tree-shaken.
8
- FoundationAiAssistant;
9
- // GENC-1464 — the first-class "AI budget exhausted" blocked state.
10
- //
11
- // Like `popout-interaction-gate.test.ts`, this pins the GETTER LOGIC the template
12
- // bindings depend on rather than the rendered DOM, and for the same reason: mounting
13
- // the assistant runs `connectedCallback`, which subscribes to `agenticActivityBus` and
14
- // leaves the runner's event loop open. `document.createElement` only upgrades the
15
- // element (constructor) — it does not connect — so nothing subscribes here.
16
- //
17
- // The three bindings under test in `main.template.ts` are:
18
- // ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked} (textarea)
19
- // ?disabled=${(x) => ... || x.blocked || ...} (send button)
20
- // ${when((x) => x.blocked, blockedBannerTemplate)} (banner)
21
- // All three are driven by `blocked`; the banner's only dynamic content is
22
- // `effectiveBlockedReason`. Both are asserted below.
23
- const Suite = createLogicSuite('FoundationAiAssistant blocked state');
24
- let storeSeq = 0;
25
- /** A fresh (unconnected) element wired to its own real session store. */
26
- function element() {
27
- const el = document.createElement('foundation-ai-assistant');
28
- storeSeq += 1;
29
- el._sessionRef = getSessionStore(`blocked-test-${storeSeq}`, false);
30
- return el;
31
- }
32
- /** `sendBlocked` is private; this reads it the way the composer bindings do. */
33
- const sendBlocked = (el) => el.sendBlocked;
34
- /**
35
- * `latchBlockedFrom` is the single decision point both latch sites go through
36
- * (the `tool-loop-end` bus subscription and `send()`'s return-value check). It is
37
- * private, so drive it the way those sites do rather than reproducing its rule.
38
- */
39
- const latch = (el, reason, budget, vendorHint) => el.latchBlockedFrom(reason, el._sessionRef, budget, vendorHint);
40
- /**
41
- * Point the element's provider statuses at a set of vendors — the input
42
- * `reachableVendors` (and therefore the derived `blocked`) reads. The first
43
- * entry is the registry default, which is what `suggestionsBlocked` resolves
44
- * against.
45
- */
46
- const reachable = (el, ...vendors) => {
47
- el.providerStatuses = vendors.map((provider, i) => ({
48
- name: provider,
49
- isDefault: i === 0,
50
- status: { provider, model: `${provider}-model` },
51
- }));
52
- };
53
- /**
54
- * Like `reachable`, but with a registry behind the statuses so the capability
55
- * gate in `reachableVendors` has something to consult. `chatless` names the
56
- * vendors whose provider — like the shipped ChromeProvider/OpenAIProvider —
57
- * implements `getStatus()` only. In production every status entry's `name`
58
- * resolves in the registry (statuses come FROM `listStatuses()`), so this is
59
- * the faithful harness; `reachable` above models the direct-assignment case
60
- * where an unresolvable name must degrade to counting the vendor.
61
- */
62
- const reachableWithCapabilities = (el, vendors, chatless = []) => {
63
- reachable(el, ...vendors);
64
- const providers = new Map(vendors.map((v) => [
65
- v,
66
- chatless.includes(v)
67
- ? { getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: v, model: `${v}-model` }); }) }
68
- : {
69
- getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: v, model: `${v}-model` }); }),
70
- chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
71
- },
72
- ]));
73
- // `providerRegistry` is a DI-decorated prototype getter; a plain assignment
74
- // is swallowed by it, so shadow it with an instance own-property.
75
- Object.defineProperty(el, 'providerRegistry', {
76
- configurable: true,
77
- value: {
78
- get: (name) => providers.get(name),
79
- default: () => providers.get(vendors[0]),
80
- defaultName: () => vendors[0],
81
- names: () => [...vendors],
82
- getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
83
- listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
84
- },
85
- });
86
- };
87
- /**
88
- * The store half of "Clear" / "New chat". `clearSession()` itself also tears the
89
- * driver down and awaits the persister, neither of which exists on an
90
- * unconnected element — `resetSession` is the one action it dispatches, and the
91
- * only part of it that touches the blocked state.
92
- */
93
- const resetSession = (el) => el._sessionRef.actions.aiAssistant.resetSession();
94
- Suite.after(() => {
95
- clearAllSessionStores();
96
- });
97
- Suite('defaults to unblocked with no reason', () => {
98
- const el = element();
99
- assert.is(el.blocked, false);
100
- assert.is(el.blockedReason, null);
101
- assert.is(sendBlocked(el), false);
102
- });
103
- Suite('the blocked setter round-trips through the session store', () => {
104
- const el = element();
105
- el.blocked = true;
106
- assert.is(el.blocked, true, 'the getter reads back what the setter wrote');
107
- el.blocked = false;
108
- assert.is(el.blocked, false);
109
- });
110
- Suite('setBlocked writes the flag and the reason together', () => {
111
- const el = element();
112
- el.setBlocked(true, 'Budget exhausted — contact billing');
113
- assert.is(el.blocked, true);
114
- assert.is(el.blockedReason, 'Budget exhausted — contact billing');
115
- assert.is(el.effectiveBlockedReason, 'Budget exhausted — contact billing');
116
- // Unblocking clears the reason so no stale copy can be rendered later.
117
- el.setBlocked(false);
118
- assert.is(el.blockedReason, null);
119
- });
120
- Suite('effectiveBlockedReason falls back to the default copy', () => {
121
- const el = element();
122
- el.blocked = true;
123
- assert.is(el.blockedReason, null, 'no host-supplied reason');
124
- // One identity assertion against the shared const rather than substring probes
125
- // — the banner and the driver's transcript bubble now read from a single
126
- // source, and this is what stops the two drifting apart again.
127
- assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
128
- });
129
- Suite('blocked folds into sendBlocked — the composer-disable predicate', () => {
130
- // `sendBlocked` is what gates send()/submitMessage(); the textarea and send
131
- // button bind the same `|| x.blocked` term.
132
- const el = element();
133
- assert.is(sendBlocked(el), false, 'idle and unblocked → sends allowed');
134
- el.blocked = true;
135
- assert.is(sendBlocked(el), true, 'blocked → sends refused even while idle');
136
- el.blocked = false;
137
- assert.is(sendBlocked(el), false, 'unblocking re-enables the composer');
138
- });
139
- Suite('effectivePlaceholder swaps to the blocked copy, outranking a pinned agent', () => __awaiter(void 0, void 0, void 0, function* () {
140
- const el = element();
141
- el.pinnedAgentName = 'Booking';
142
- assert.is(el.effectivePlaceholder, 'Message Booking...', 'unblocked → the pinned-agent prompt');
143
- el.blocked = true;
144
- assert.is(el.effectivePlaceholder, 'AI usage limit reached', 'blocked outranks the pin — the composer is disabled, so naming the agent would mislead');
145
- }));
146
- Suite('submitMessage refuses while blocked, with the blocked reason', () => __awaiter(void 0, void 0, void 0, function* () {
147
- const el = element();
148
- el.setBlocked(true, 'Budget exhausted — contact billing');
149
- const result = yield el.submitMessage({ text: 'hello' });
150
- assert.is(result.ok, false, 'a programmatic send is refused');
151
- assert.equal(result.errors, ['Budget exhausted — contact billing'], 'the refusal names the block, not a transient "busy" a caller would retry on');
152
- }));
153
- Suite('submitMessage still refuses with the default copy when no reason was set', () => __awaiter(void 0, void 0, void 0, function* () {
154
- const el = element();
155
- el.blocked = true;
156
- const result = yield el.submitMessage({ text: 'hello' });
157
- assert.is(result.ok, false);
158
- assert.is(result.errors[0], DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
159
- }));
160
- Suite('submitMessage works again once the host unblocks', () => __awaiter(void 0, void 0, void 0, function* () {
161
- // Proves the block is the only thing refusing — and that it is releasable.
162
- const el = element();
163
- el.blocked = true;
164
- assert.is((yield el.submitMessage({ text: 'hi' })).ok, false);
165
- el.blocked = false;
166
- // No driver is wired on an unconnected element, so the send is a no-op that
167
- // still reports ok — what matters is that the refusal is gone.
168
- assert.is(sendBlocked(el), false, 'the gate is open again');
169
- }));
170
- // ── The blockedReason setter (API symmetry) ─────────────────────────────────
171
- Suite('the blockedReason setter round-trips and does not disturb blocked', () => {
172
- // Every other store-backed public accessor on this class is a symmetric pair;
173
- // `blockedReason` being read-only made `el.blockedReason = msg` a compile error
174
- // for typed consumers and a strict-mode TypeError for JS ones.
175
- const el = element();
176
- el.setBlocked(true, 'host copy');
177
- el.blockedReason = 'new copy';
178
- assert.is(el.blocked, true, 'writing the reason must not clear the block');
179
- assert.is(el.effectiveBlockedReason, 'new copy');
180
- });
181
- Suite('assigning blockedReason = null falls back to the default copy', () => {
182
- const el = element();
183
- el.setBlocked(true, 'host copy');
184
- el.blockedReason = null;
185
- assert.is(el.blocked, true);
186
- assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
187
- });
188
- Suite('el.blocked = true after a host reason keeps the reason', () => {
189
- // The driver-latch race at element level: a second block with no reason must
190
- // not blank the host's explanation.
191
- const el = element();
192
- el.setBlocked(true, 'AI usage limit reached ($25.40 of $25). Contact billing.');
193
- el.blocked = true;
194
- assert.is(el.effectiveBlockedReason, 'AI usage limit reached ($25.40 of $25). Contact billing.');
195
- });
196
- // ── The driver latch (GENC-1464) ───────────────────────────────────────────────
197
- /** The raw stored figures, read the way budget-meter.test.ts reads them. */
198
- const storedBudgets = (el) => el._sessionRef.store.aiAssistant.vendorBudgets;
199
- Suite('the transcript override is passed only when a blockedReason actually exists', () => {
200
- // Forcing DEFAULT_BUDGET_EXHAUSTED_MESSAGE here was what made the driver's
201
- // per-wall composed bubble unreachable in every real mount (the banner said
202
- // "switch", the bubble under it never did). `undefined` hands composition to
203
- // the driver; a set reason — host white-labelling, or the vendor-agnostic
204
- // latch's advice-free copy — still wins verbatim.
205
- const read = (el) => el
206
- .transcriptBudgetExhaustedMessage;
207
- const el = element();
208
- assert.is(read(el), undefined, 'no reason -> the driver composes per wall');
209
- el.setBlocked(true, 'Contact ops@acme.com');
210
- assert.is(read(el), 'Contact ops@acme.com', 'a set reason wins verbatim');
211
- el.setBlocked(false);
212
- assert.is(read(el), undefined, 'unblocking hands composition back to the driver');
213
- });
214
- Suite('the partial-wall banner follows the ACTIVE vendor', () => {
215
- // The tester feedback this pins: after switching to a vendor with headroom,
216
- // the banner about the vendor you LEFT must stop nagging — and switching back
217
- // must bring it straight back. The active vendor is the registry default the
218
- // next send resolves to (providerStatuses' isDefault entry).
219
- const walledGemini = { vendorLabel: 'Gemini', budgetUsd: 25, spentUsd: 25.4 };
220
- const active = element();
221
- reachable(active, 'gemini', 'anthropic'); // default = gemini
222
- latch(active, 'budget-exhausted', walledGemini);
223
- assert.is(active.bannerVisible, true, 'a walled ACTIVE vendor announces');
224
- const switched = element();
225
- reachable(switched, 'anthropic', 'gemini'); // default = anthropic, with headroom
226
- latch(switched, 'budget-exhausted', walledGemini);
227
- assert.is(switched.bannerVisible, false, 'a user who switched away is not nagged');
228
- reachable(switched, 'gemini', 'anthropic'); // switch back to the walled vendor
229
- assert.is(switched.bannerVisible, true, 'switching back brings the banner back');
230
- // Conservative fallbacks: unknown active vendor (statuses still loading)
231
- // announces; a vendor-agnostic block announces regardless of the default.
232
- const loading = element();
233
- latch(loading, 'budget-exhausted', walledGemini);
234
- assert.is(loading.bannerVisible, true, 'unknown active vendor stays fail-informative');
235
- const agnostic = element();
236
- reachable(agnostic, 'anthropic', 'gemini');
237
- agnostic.setBlocked(true, 'capped for this desk');
238
- assert.is(agnostic.bannerVisible, true, 'a vendor-agnostic block always announces');
239
- });
240
- Suite('a wall with figures snaps the vendor meter at latch time', () => {
241
- // The GENC-1464 stale-meter finding: hosts refresh the meter on
242
- // `tool-loop-end`, which a walled turn may never reach — the 402's own
243
- // figures are the freshest truth available and must land immediately.
244
- const el = element();
245
- reachable(el, 'anthropic', 'gemini');
246
- latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
247
- assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
248
- assert.ok(typeof el.budgetMeterLastFedAt === 'number', 'the wall feed stamps freshness too');
249
- // A later 402 for the same (already-walled) vendor carries fresher spend —
250
- // the feed is deliberately outside the idempotence guard, like the sweep.
251
- latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 21.5 });
252
- assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 21.5 });
253
- });
254
- Suite('a figure-less or unattributable wall feeds no meter', () => {
255
- // Half a meter is a lie: `VendorBudgetFigures` is total, so one missing
256
- // figure means no feed at all — the wall itself still latches.
257
- const el = element();
258
- reachable(el, 'anthropic', 'gemini');
259
- latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', spentUsd: 21 });
260
- assert.is(storedBudgets(el)['anthropic'], undefined);
261
- assert.is(el.isVendorBlocked('anthropic'), true, 'the latch itself is unaffected');
262
- // No vendor resolves: nothing to key the meter on.
263
- const el2 = element();
264
- reachable(el2, 'anthropic', 'gemini');
265
- latch(el2, 'budget-exhausted', { vendorLabel: 'Acme Gateway', budgetUsd: 20, spentUsd: 21 });
266
- assert.equal(storedBudgets(el2), {});
267
- });
268
- Suite('latchBlockedFrom latches only on budget-exhausted', () => {
269
- const budget = element();
270
- latch(budget, 'budget-exhausted');
271
- assert.is(budget.blocked, true, 'a budget wall latches');
272
- const other = element();
273
- latch(other, 'exception');
274
- assert.is(other.blocked, false, 'an ordinary failure does not lock the composer');
275
- const clean = element();
276
- latch(clean, undefined);
277
- assert.is(clean.blocked, false, 'a clean turn does not lock the composer');
278
- });
279
- Suite('a second latch never clobbers a host-set blockedReason', () => {
280
- // The bus fires from the driver's `finally`, BEFORE sendMessage() resolves, so
281
- // a bus-subscribed host writes its copy first and the return-value latch lands
282
- // second. It must be a no-op, not a rewrite.
283
- const el = element();
284
- el.setBlocked(true, 'custom copy');
285
- latch(el, 'budget-exhausted');
286
- assert.is(el.blockedReason, 'custom copy');
287
- assert.is(el.effectiveBlockedReason, 'custom copy');
288
- });
289
- Suite('the driver latch formats the proxy figures into the banner', () => {
290
- const el = element();
291
- latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });
292
- assert.is(el.blocked, true);
293
- assert.ok(el.effectiveBlockedReason.includes('$25.40'), 'the spend is shown');
294
- assert.ok(el.effectiveBlockedReason.includes('$25'), 'so is the cap');
295
- });
296
- Suite('formatBlockedReason returns undefined when the proxy sent no figures', () => {
297
- var _a;
298
- // `undefined` is the meaningful return: with the slice's "omitted reason keeps
299
- // the existing explanation" rule it is what lets a figureless latch leave a
300
- // host reason intact.
301
- assert.is(formatBlockedReason(undefined), undefined);
302
- assert.is(formatBlockedReason({ vendorLabel: 'Anthropic' }), undefined);
303
- assert.ok((_a = formatBlockedReason({ spentUsd: 5.02, vendorLabel: 'Gemini' })) === null || _a === void 0 ? void 0 : _a.includes('$5.02'));
304
- });
305
- Suite('a figureless latch leaves a host reason intact', () => {
306
- const el = element();
307
- el.setBlocked(true, 'host copy');
308
- latch(el, 'budget-exhausted');
309
- assert.is(el.effectiveBlockedReason, 'host copy');
310
- });
311
- // ── Lifetime + composer inertness ───────────────────────────────────────────
312
- Suite('the latch is per-stateKey — a different session store starts unblocked', () => {
313
- // Pins the documented Lifetime: `blocked` lives on the session store, so it is
314
- // per-stateKey and a session switch (which tears the outgoing store down)
315
- // yields an unblocked one. The host pre-flight is the durable source of truth.
316
- const first = element();
317
- const second = element();
318
- first.blocked = true;
319
- assert.is(first.blocked, true);
320
- assert.is(second.blocked, false, 'a different session store is unaffected');
321
- });
322
- Suite('"Clear" keeps a PARTIAL wall partial — it does not escalate to a full lock', () => {
323
- // The reported failure: `resetSession` preserved `blockedVendors` but wiped
324
- // `providerStatuses`, so `reachableVendors` went empty, the "unknown
325
- // reachability fails safe" rule fired, and one walled vendor out of two turned
326
- // a live composer into a locked one. Nothing reloads the statuses on Clear —
327
- // `loadProviderStatuses()` runs only on connect/session-switch and off the
328
- // observable-registry subscription — so a host with a static registry stayed
329
- // locked until a remount. The statuses describe the registry, not the
330
- // conversation, so they are now preserved across the reset.
331
- const el = element();
332
- reachable(el, 'anthropic', 'gemini');
333
- el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');
334
- assert.is(el.blocked, false, 'precondition: Gemini has headroom, so the composer is live');
335
- resetSession(el);
336
- assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry snapshot survives');
337
- assert.equal(el.blockedVendors, ['anthropic'], 'and so does the wall — Clear refills no budget');
338
- assert.is(el.blocked, false, 'so the composer is still usable after Clear');
339
- assert.is(sendBlocked(el), false);
340
- assert.is(el.bannerVisible, true, 'and the wall is still announced');
341
- assert.ok(el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'));
342
- });
343
- Suite('"Clear" still leaves a FULL wall locked', () => {
344
- // The other direction of the same change: preserving the statuses must not
345
- // make Clear a way out of a wall that really does cover every vendor.
346
- const el = element();
347
- reachable(el, 'anthropic', 'gemini');
348
- el.setVendorBlocked('anthropic', true);
349
- el.setVendorBlocked('gemini', true);
350
- resetSession(el);
351
- assert.is(el.blocked, true);
352
- assert.is(sendBlocked(el), true);
353
- assert.is(el.effectivePlaceholder, 'AI usage limit reached');
354
- });
355
- Suite('"Clear" still wipes the conversation it is there to wipe', () => {
356
- // Guards the preserve-list against over-reach: `providerStatuses` joining it
357
- // must not smuggle in anything that IS conversation state.
358
- const el = element();
359
- reachable(el, 'anthropic');
360
- el.messages = [{ id: '1', role: 'user', content: 'hello' }];
361
- assert.is(el.messages.length, 1);
362
- resetSession(el);
363
- assert.is(el.messages.length, 0, 'the transcript is gone');
364
- assert.equal(el.reachableVendors, ['anthropic'], 'the registry snapshot is not');
365
- });
366
- Suite('handleSuggestionClick is inert while blocked', () => {
367
- // The chips are hidden by the template while blocked, but a host (or a stale
368
- // render) can still reach the handler. Without the guard the suggestion text is
369
- // deposited into a disabled textarea the user cannot clear, because `send()`
370
- // refuses only AFTER `inputValue` has been written.
371
- const el = element();
372
- el.blocked = true;
373
- el.handleSuggestionClick('draft me a report');
374
- assert.is(el.inputValue, '', 'nothing is written into the disabled composer');
375
- });
376
- Suite('fetchSuggestions does not call the driver while blocked', () => {
377
- // Otherwise every blocked turn, pop-in and pin change fires one guaranteed-402
378
- // request, burning a `suggestions.failed` meta event and parking a raw
379
- // transport-error string in `suggestionsState.message` for hosts to find.
380
- const el = element();
381
- let calls = 0;
382
- el.driver = {
383
- isBusy: () => false,
384
- getSuggestions: () => __awaiter(void 0, void 0, void 0, function* () {
385
- calls += 1;
386
- return [];
387
- }),
388
- };
389
- el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } };
390
- el.blocked = true;
391
- el.fetchSuggestions();
392
- assert.is(calls, 0, 'no request is issued against the wall');
393
- assert.is(el.suggestionsState.status, 'idle', 'and no loading state is entered');
394
- });
395
- // ── Per-vendor budgets (GENC-1464) ─────────────────────────────────────────────
396
- //
397
- // The central question this design answers — "which vendor would the NEXT turn
398
- // use?" — has no correct answer and deliberately gets no API: the provider is
399
- // resolved per turn AND per agent, an orchestrated turn picks its agent with an
400
- // LLM classify() call, and sub-agents resolve their own. So `blocked` is instead
401
- // derived: "every vendor the registry can currently REACH is walled". That is
402
- // synchronous, free (the statuses are already loaded), and makes vendor-switch
403
- // recovery a derivation rather than a mutation.
404
- Suite('a walled vendor with another reachable one does NOT block the composer', () => {
405
- // D3 — partial exhaustion blocks only the exhausted vendor.
406
- const el = element();
407
- reachable(el, 'anthropic', 'gemini');
408
- el.setVendorBlocked('anthropic', true);
409
- assert.equal(el.blockedVendors, ['anthropic']);
410
- assert.is(el.blocked, false, 'Gemini still has headroom, so sends stay allowed');
411
- assert.is(sendBlocked(el), false);
412
- assert.is(el.bannerVisible, true, 'but the banner still announces it');
413
- });
414
- Suite('walling every reachable vendor blocks the composer', () => {
415
- const el = element();
416
- reachable(el, 'anthropic', 'gemini');
417
- el.setVendorBlocked('anthropic', true);
418
- el.setVendorBlocked('gemini', true);
419
- assert.is(el.blocked, true);
420
- assert.is(sendBlocked(el), true);
421
- assert.is(el.effectivePlaceholder, 'AI usage limit reached');
422
- });
423
- Suite('a vendor switch releases the block by derivation, with the latch left intact', () => {
424
- // The known-hard part: there is no vendor-switch EVENT to hang a clear on. A
425
- // host that swaps its registry to Gemini fires the observable, the statuses
426
- // reload, Anthropic drops out of `reachableVendors`, and `blocked` goes false
427
- // — nothing mutated the latch, so switching back re-derives the block.
428
- const el = element();
429
- reachable(el, 'anthropic');
430
- el.setVendorBlocked('anthropic', true);
431
- assert.is(el.blocked, true, 'walled and it is the only reachable vendor');
432
- reachable(el, 'gemini');
433
- assert.is(el.blocked, false, 'the switch is recovery, with no host code at all');
434
- assert.equal(el.blockedVendors, ['anthropic'], 'and the latch was never cleared');
435
- reachable(el, 'anthropic');
436
- assert.is(el.blocked, true, 'switching back re-derives the block');
437
- });
438
- Suite('unknown reachability fails safe — any wall blocks everything', () => {
439
- // Providers need not implement `getStatus()`. A host whose providers report
440
- // nothing gets no per-vendor benefit, but it must not get a LESS locked UI
441
- // than before, so an empty reachable set means "any wall blocks everything".
442
- const el = element();
443
- assert.equal(el.reachableVendors, [], 'no statuses loaded');
444
- el.setVendorBlocked('anthropic', true);
445
- assert.is(el.blocked, true);
446
- });
447
- Suite('a host that never adopts the per-vendor API reads `blocked` exactly as before', () => {
448
- // THE back-compat pin: with `blockedVendors` empty the derivation short-circuits
449
- // to the stored boolean, whatever the provider statuses say.
450
- const el = element();
451
- reachable(el, 'anthropic', 'gemini');
452
- assert.is(el.blocked, false);
453
- el.blocked = true;
454
- assert.is(el.blocked, true, 'a vendor-agnostic block still blocks everything');
455
- assert.equal(el.blockedVendors, [], 'and does not invent a per-vendor latch');
456
- el.blocked = false;
457
- assert.is(el.blocked, false);
458
- });
459
- Suite('setBlocked(false) clears the per-vendor latches too', () => {
460
- // What keeps migration-GENC-1464 Option B working verbatim: a host asserting "the
461
- // wall is gone" after its own pre-flight must not be overruled by a driver
462
- // latch it cannot see.
463
- const el = element();
464
- reachable(el, 'anthropic');
465
- el.setVendorBlocked('anthropic', true);
466
- el.setBlocked(false);
467
- assert.is(el.blocked, false);
468
- assert.equal(el.blockedVendors, [], 'the latch is gone, not merely outvoted');
469
- });
470
- Suite('isVendorBlocked answers per vendor, independently of the composer gate', () => {
471
- const el = element();
472
- reachable(el, 'anthropic', 'gemini');
473
- el.setVendorBlocked('gemini', true, 'Gemini budget spent — see billing');
474
- assert.is(el.isVendorBlocked('gemini'), true);
475
- assert.is(el.isVendorBlocked('anthropic'), false);
476
- assert.is(el.blocked, false, 'and the composer is still live');
477
- });
478
- Suite('writing blockedReason does not clear the per-vendor latches', () => {
479
- // `blocked` is derived now, so routing this setter through
480
- // `setBlocked({ blocked: this.blocked })` would pass `false` for a partially
481
- // walled session and wipe the latches as a side effect of setting a string.
482
- const el = element();
483
- reachable(el, 'anthropic', 'gemini');
484
- el.setVendorBlocked('anthropic', true);
485
- el.blockedReason = 'custom copy';
486
- assert.equal(el.blockedVendors, ['anthropic']);
487
- assert.is(el.effectiveBlockedReason, 'custom copy');
488
- });
489
- // ── The per-vendor latch ────────────────────────────────────────────────────
490
- Suite('the latch attributes a wall to the vendor named by the transport label', () => {
491
- const el = element();
492
- reachable(el, 'anthropic', 'gemini');
493
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
494
- assert.equal(el.blockedVendors, ['anthropic']);
495
- assert.is(el.blocked, false, 'Gemini is untouched — D3');
496
- });
497
- Suite('the per-vendor guard lets a SECOND vendor wall land', () => {
498
- // The old global `if (this.blocked) return` would have swallowed this, so a
499
- // session could never record that both vendors were gone — and under a mixed
500
- // registry an Anthropic-only wall would have locked a composer Gemini could
501
- // still serve.
502
- const el = element();
503
- reachable(el, 'anthropic', 'gemini');
504
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
505
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 501, vendorLabel: 'Gemini' });
506
- assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both walls are recorded, in order');
507
- assert.is(el.blocked, true);
508
- });
509
- Suite('two walls with figures read as ONE sentence, not two of the same', () => {
510
- // The shape a user actually meets once both pots are gone. Composing each wall's
511
- // own sentence repeated the boilerplate verbatim — "Gemini's AI usage limit is
512
- // reached (…). Anthropic's AI usage limit is reached (…)." — which reads as a
513
- // stutter and buries the one clause that differs: the figures.
514
- const el = element();
515
- reachable(el, 'anthropic', 'gemini');
516
- latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Gemini' });
517
- latch(el, 'budget-exhausted', { budgetUsd: 50, spentUsd: 62.34, vendorLabel: 'Anthropic' });
518
- assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Gemini ($25.40 of $25.00) and Anthropic ($62.34 of $50.00). ' +
519
- 'Contact your administrator to raise them.');
520
- });
521
- Suite('host copy is never folded into the list — it survives beside composed copy', () => {
522
- // The fold is only ever applied to copy the banner could have written itself.
523
- // A host that set its own sentence for one vendor keeps it word for word, and
524
- // the vendor with no copy gets a composed statement rather than being dropped.
525
- const el = element();
526
- reachable(el, 'anthropic', 'gemini');
527
- el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
528
- latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Gemini' });
529
- assert.is(el.effectiveBlockedReason, 'Anthropic spend is capped for this desk. ' +
530
- "Gemini's AI usage limit is reached ($25.40 of $25.00). " +
531
- 'Contact your administrator to raise them.');
532
- });
533
- Suite('a re-latch of an already-walled vendor never clobbers host copy', () => {
534
- const el = element();
535
- reachable(el, 'anthropic', 'gemini');
536
- el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');
537
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
538
- assert.ok(el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'), 'the host per-vendor copy survives the driver latch');
539
- });
540
- Suite('the label wins over a stale driver-supplied vendor', () => {
541
- // On the classification seam the driver's last-resolved provider is the
542
- // PREVIOUS turn's vendor; the label comes from the transport that actually
543
- // said no. Seed the disagreement to prove which one is believed.
544
- const el = element();
545
- reachable(el, 'anthropic', 'gemini');
546
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Gemini', vendor: 'anthropic' }, 'anthropic');
547
- assert.equal(el.blockedVendors, ['gemini']);
548
- });
549
- Suite('a figure-less wall attributes by label, not by the stale hint', () => {
550
- // This replaces a test that pinned the opposite path as correct. A figure-less
551
- // 402 used to reach the latch with NO budget object at all, so the only vendor
552
- // on offer was the bus hint — the driver's last-resolved provider, i.e. the
553
- // PREVIOUS turn's vendor. `chat-driver.test.ts` now proves the driver keeps the
554
- // label on a figure-less wall; this proves the latch prefers it. Seed the
555
- // disagreement, or a constant would satisfy both.
556
- const el = element();
557
- reachable(el, 'anthropic', 'gemini');
558
- latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' }, 'gemini');
559
- assert.equal(el.blockedVendors, ['anthropic'], 'the transport that said no');
560
- });
561
- Suite('the bus vendor hint still attributes a detail that carries no budget', () => {
562
- // `tool-loop-end` is a public topic and its top-level `vendor` is documented
563
- // and additive, so a host may publish the reason and the vendor with no budget
564
- // payload at all. That path stays supported — it is just no longer how the
565
- // driver's own figure-less walls travel.
566
- const el = element();
567
- reachable(el, 'anthropic', 'gemini');
568
- latch(el, 'budget-exhausted', undefined, 'gemini');
569
- assert.equal(el.blockedVendors, ['gemini']);
570
- });
571
- Suite('a wall naming no known vendor falls back to the vendor-agnostic block', () => {
572
- // Fail-safe: blocking the wrong vendor is worse than blocking all of them.
573
- const el = element();
574
- reachable(el, 'anthropic', 'gemini');
575
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Acme AI' });
576
- assert.equal(el.blockedVendors, [], 'nothing is attributed');
577
- assert.is(el.blocked, true, 'and the whole assistant locks, exactly as it used to');
578
- });
579
- // ── Banner copy ─────────────────────────────────────────────────────────────
580
- Suite('partial exhaustion names the walled vendor and the way out', () => {
581
- const el = element();
582
- reachable(el, 'anthropic', 'gemini');
583
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
584
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.");
585
- });
586
- Suite('it says "switch", it does not switch — no auto-switching', () => {
587
- // D3: auto-switching would silently downgrade model quality. The element only
588
- // ever advises; nothing here touches the registry.
589
- const el = element();
590
- reachable(el, 'anthropic', 'gemini');
591
- el.setVendorBlocked('anthropic', true);
592
- assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry is untouched');
593
- assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
594
- });
595
- Suite('full exhaustion of a single vendor asks for a raise, not a switch', () => {
596
- const el = element();
597
- reachable(el, 'anthropic');
598
- latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });
599
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.");
600
- });
601
- Suite('several walled vendors are named together, with plural copy', () => {
602
- const el = element();
603
- reachable(el, 'anthropic', 'gemini');
604
- el.setVendorBlocked('anthropic', true);
605
- el.setVendorBlocked('gemini', true);
606
- assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Anthropic and Gemini. Contact your administrator to raise them.');
607
- });
608
- Suite('a walled vendor with figureless copy still names itself', () => {
609
- // The shape here is the one `budgetDetailOf` actually emits for a figure-less
610
- // 402 — label plus resolved vendor, no numbers — not a hand-made shape the
611
- // driver never produces. `chat-driver.test.ts` pins the emitting end.
612
- const el = element();
613
- reachable(el, 'anthropic');
614
- latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' });
615
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached. Contact your administrator to raise it.");
616
- });
617
- Suite('host per-vendor copy survives a SECOND vendor walling', () => {
618
- // The reported failure: the host reasons were read only on the `walled.length
619
- // === 1` branch, so the moment a second vendor walled every carefully written
620
- // host sentence was replaced by generic copy — contradicting both the
621
- // `setVendorBlocked` JSDoc and the migration doc, and doing it at the exact
622
- // moment the situation got worse.
623
- const el = element();
624
- reachable(el, 'anthropic', 'gemini');
625
- el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
626
- el.setVendorBlocked('gemini', true, 'Gemini spend is capped for this desk.');
627
- assert.is(el.effectiveBlockedReason, 'Anthropic spend is capped for this desk. Gemini spend is capped for this desk. ' +
628
- 'Contact your administrator to raise them.');
629
- });
630
- Suite('a vendor with no copy still gets a sentence beside one that has copy', () => {
631
- // Mixed is the realistic case — e.g. the proxy's `otherVendorAvailable: false`
632
- // sweep wals the second vendor with no figures of its own. The composed set must
633
- // stay complete rather than silently naming a subset.
634
- const el = element();
635
- reachable(el, 'anthropic', 'gemini');
636
- el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
637
- el.setVendorBlocked('gemini', true);
638
- assert.is(el.effectiveBlockedReason, "Anthropic spend is capped for this desk. Gemini's AI usage limit is reached. " +
639
- 'Contact your administrator to raise them.');
640
- });
641
- Suite('three walled vendors with copy read as three statements', () => {
642
- const el = element();
643
- reachable(el, 'anthropic', 'gemini', 'openai');
644
- el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
645
- el.setVendorBlocked('gemini', true, 'Gemini: capped.');
646
- el.setVendorBlocked('openai', true, 'OpenAI: capped.');
647
- assert.is(el.effectiveBlockedReason, 'Anthropic: capped. Gemini: capped. OpenAI: capped. Contact your administrator to raise them.');
648
- });
649
- Suite('only the REACHABLE walled vendors contribute a statement', () => {
650
- // The composition is layered on `relevantBlockedVendors`, not on the raw list,
651
- // so host copy for a vendor the registry has moved away from stays unshown.
652
- const el = element();
653
- reachable(el, 'anthropic');
654
- el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
655
- reachable(el, 'gemini', 'openai');
656
- el.setVendorBlocked('gemini', true, 'Gemini: capped.');
657
- el.setVendorBlocked('openai', true, 'OpenAI: capped.');
658
- assert.is(el.effectiveBlockedReason, 'Gemini: capped. OpenAI: capped. Contact your administrator to raise them.', 'Anthropic is walled and has copy, but the user cannot reach it');
659
- });
660
- Suite('a host blockedReason outranks every composed sentence', () => {
661
- const el = element();
662
- reachable(el, 'anthropic', 'gemini');
663
- el.setVendorBlocked('anthropic', true);
664
- el.setVendorBlocked('gemini', true);
665
- el.blockedReason = 'Contact ops@acme.com';
666
- assert.is(el.effectiveBlockedReason, 'Contact ops@acme.com');
667
- });
668
- Suite('formatBlockedReason composes a vendor statement without an action clause', () => {
669
- // The action is composed at render time, because whether "switch vendor" or
670
- // "contact your administrator" is honest depends on the CURRENT reachable set.
671
- assert.is(formatBlockedReason({ budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Gemini' }, 'gemini'), "Gemini's AI usage limit is reached ($25.40 of $25.00).");
672
- assert.is(formatBlockedReason(undefined, 'gemini'), undefined, 'no figures, no statement');
673
- });
674
- // ── The server's `otherVendorAvailable` verdict ─────────────────────────────
675
- //
676
- // The client can only infer "another vendor is free" from REGISTRY MEMBERSHIP,
677
- // which knows which vendors exist and nothing about their remaining spend. The
678
- // proxy meters the pots and says so on the 402. Where the two disagree, the
679
- // server wins — otherwise the user is told to switch to a vendor that is already
680
- // spent, and burns a second doomed turn learning what arrived in the first
681
- // response.
682
- Suite('`otherVendorAvailable: false` locks the composer on the FIRST wall', () => {
683
- const el = element();
684
- reachable(el, 'anthropic', 'gemini');
685
- latch(el, 'budget-exhausted', {
686
- budgetUsd: 500,
687
- spentUsd: 500,
688
- vendorLabel: 'Anthropic',
689
- otherVendorAvailable: false,
690
- });
691
- assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'every metered pot is walled — the verdict is about the proxy, not this registry');
692
- assert.is(el.blocked, true, 'so `blocked` derives true immediately');
693
- assert.is(sendBlocked(el), true, 'and no second doomed turn is possible');
694
- });
695
- Suite('the early-window banner names only the refuser, never a swept vendor', () => {
696
- // The window: a 402 lands while `loadProviderStatuses()` is still in flight,
697
- // and its `otherVendorAvailable: false` sweep walls every budgeted vendor.
698
- // The unknown-reachability fallback used to surface the swept ones, so the
699
- // banner read "Anthropic's AI usage limit is reached ($25.40 of $25.00).
700
- // Gemini's AI usage limit is reached. Contact your administrator to raise
701
- // them." — naming a vendor this host may not even ship, on the plural branch,
702
- // off nothing but the proxy's headroom verdict. A refusal is nameable
703
- // evidence; a sweep entry is not, until reachability can vouch for it.
704
- const el = element();
705
- assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');
706
- latch(el, 'budget-exhausted', {
707
- budgetUsd: 25,
708
- spentUsd: 25.4,
709
- vendorLabel: 'Anthropic',
710
- otherVendorAvailable: false,
711
- });
712
- assert.is(el.blocked, true, 'locking still counts every wall — that fail-safe is untouched');
713
- assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'and the verdict is still recorded');
714
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.", 'singular, figures, refuser only — nothing the user cannot verify');
715
- // Statuses land with both vendors: reachability now vouches for the swept
716
- // wall, so the banner grows to the honest multi-vendor copy.
717
- reachable(el, 'anthropic', 'gemini');
718
- assert.ok(el.effectiveBlockedReason.includes('Gemini'), 'once reachable, the swept vendor is named after all');
719
- });
720
- Suite('a swept vendor that then REFUSES is named even before the statuses land', () => {
721
- // Provenance upgrades on first-hand evidence: the sweep walled Gemini off the
722
- // proxy's verdict, then the user (say, via a pre-set vendor preference)
723
- // actually hit Gemini and got its own 402. That refusal is nameable — the
724
- // latch clears the swept mark when it writes the wall without `swept`.
725
- const el = element();
726
- latch(el, 'budget-exhausted', {
727
- budgetUsd: 25,
728
- spentUsd: 25.4,
729
- vendorLabel: 'Anthropic',
730
- otherVendorAvailable: false,
731
- });
732
- assert.not.ok(el.effectiveBlockedReason.includes('Gemini'), 'swept: not named yet');
733
- latch(el, 'budget-exhausted', {
734
- budgetUsd: 500,
735
- spentUsd: 512,
736
- vendorLabel: 'Gemini',
737
- otherVendorAvailable: false,
738
- });
739
- assert.ok(el.effectiveBlockedReason.includes('Gemini'), 'refused: named on its own evidence');
740
- assert.ok(el.effectiveBlockedReason.includes('$512.00'), 'with its own figures');
741
- });
742
- Suite('a host-set wall is never treated as swept', () => {
743
- // `setVendorBlocked` from the host is an explicit statement (e.g. its own
744
- // pre-flight consuming GET /api/budget) — first-hand, nameable, statuses or
745
- // not. Only the element's own sweep passes the provenance flag.
746
- const el = element();
747
- assert.equal(el.reachableVendors, []);
748
- el.setVendorBlocked('gemini', true, 'Gemini: capped by pre-flight.');
749
- assert.ok(el.effectiveBlockedReason.includes('Gemini: capped by pre-flight.'));
750
- });
751
- Suite('the verdict survives a wall that arrives BEFORE the statuses load', () => {
752
- // The ordering bug: `activateSession` kicks off `loadProviderStatuses()` without
753
- // awaiting it, so a 402 can land while `reachableVendors` is still empty. The
754
- // sweep used to iterate that empty set, so `otherVendorAvailable: false` — the
755
- // one fact on the 402 the client cannot re-derive — was stored nowhere at all,
756
- // and nothing re-ran it when the statuses arrived. The wall then effectively
757
- // UN-latched: `blocked` was true only while reachability was unknown, so the
758
- // composer came back to life the instant the statuses landed.
759
- const el = element();
760
- assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');
761
- latch(el, 'budget-exhausted', {
762
- budgetUsd: 500,
763
- spentUsd: 500,
764
- vendorLabel: 'Anthropic',
765
- otherVendorAvailable: false,
766
- });
767
- assert.is(el.blocked, true, 'the fail-safe locks it while reachability is unknown');
768
- // …and now the statuses resolve.
769
- reachable(el, 'anthropic', 'gemini');
770
- assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'the verdict was recorded');
771
- assert.is(el.blocked, true, 'so it stays locked, rather than un-latching');
772
- assert.is(sendBlocked(el), true);
773
- assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'and still advises no switch');
774
- });
775
- Suite('a wall before the statuses load still spares Chrome once they arrive', () => {
776
- // The sweep now walls vendors it cannot see, so the `isBudgetedVendor` boundary
777
- // is doing more work than before: it is the only thing keeping an on-device
778
- // provider out of a verdict that is only ever about the proxy's own pots.
779
- const el = element();
780
- latch(el, 'budget-exhausted', {
781
- budgetUsd: 500,
782
- spentUsd: 500,
783
- vendorLabel: 'Anthropic',
784
- otherVendorAvailable: false,
785
- });
786
- reachable(el, 'anthropic', 'chrome');
787
- assert.is(el.isVendorBlocked('chrome'), false, 'Chrome has no pot to exhaust');
788
- assert.is(el.blocked, false, 'so the composer comes back for the vendor that is still free');
789
- assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'));
790
- });
791
- Suite('`otherVendorAvailable: false` vetoes the "switch to X" advice', () => {
792
- const el = element();
793
- reachable(el, 'anthropic', 'gemini');
794
- latch(el, 'budget-exhausted', {
795
- budgetUsd: 500,
796
- spentUsd: 500,
797
- vendorLabel: 'Anthropic',
798
- otherVendorAvailable: false,
799
- });
800
- // One sentence, not one per vendor: the refuser carries the 402's figures and the
801
- // swept vendor carries none (only the proxy's word that it has nothing left), so
802
- // the list names each with whatever it actually has rather than repeating
803
- // "X's AI usage limit is reached" twice.
804
- assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Anthropic ($500.00 of $500.00) and Gemini. ' +
805
- 'Contact your administrator to raise them.');
806
- assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'nowhere to switch to');
807
- });
808
- Suite('`otherVendorAvailable: true` leaves the inference exactly as it was', () => {
809
- // The other direction. A verdict of `true` agrees with the client's guess, so
810
- // nothing about the historical behaviour may change — including the copy.
811
- const el = element();
812
- reachable(el, 'anthropic', 'gemini');
813
- latch(el, 'budget-exhausted', {
814
- budgetUsd: 500,
815
- spentUsd: 500,
816
- vendorLabel: 'Anthropic',
817
- otherVendorAvailable: true,
818
- });
819
- assert.equal(el.blockedVendors, ['anthropic'], 'only the refused vendor');
820
- assert.is(el.blocked, false);
821
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.");
822
- });
823
- Suite('an ABSENT verdict is not a `false` — an older proxy walls nothing extra', () => {
824
- const el = element();
825
- reachable(el, 'anthropic', 'gemini');
826
- latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
827
- assert.equal(el.blockedVendors, ['anthropic']);
828
- assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
829
- });
830
- Suite('the verdict still lands when it arrives on a LATER wall', () => {
831
- // Turn 1 walls Anthropic while Gemini still has headroom; turn 2 hits the same
832
- // wall and reports that Gemini has since gone too. The per-vendor idempotence
833
- // guard would have returned before reading that, dropping the only news the
834
- // second turn carried — so the sweep runs ahead of it.
835
- const el = element();
836
- reachable(el, 'anthropic', 'gemini');
837
- latch(el, 'budget-exhausted', {
838
- budgetUsd: 500,
839
- spentUsd: 500,
840
- vendorLabel: 'Anthropic',
841
- otherVendorAvailable: true,
842
- });
843
- assert.is(el.blocked, false, 'turn 1 leaves Gemini usable');
844
- latch(el, 'budget-exhausted', {
845
- budgetUsd: 500,
846
- spentUsd: 500,
847
- vendorLabel: 'Anthropic',
848
- otherVendorAvailable: false,
849
- });
850
- assert.equal(el.blockedVendors, ['anthropic', 'gemini']);
851
- assert.is(el.blocked, true);
852
- });
853
- Suite('a chat-capable Chrome provider is spared by the sweep AND counts as headroom', () => {
854
- // `otherVendorAvailable` is the proxy speaking about the pots IT meters.
855
- // A host's CUSTOM Chrome provider that implements `chat()` (the shipped one
856
- // does not — see the next test) is never metered, so walling it would lock a
857
- // provider that is still free to use. The gate is capability, not identity.
858
- const el = element();
859
- reachableWithCapabilities(el, ['anthropic', 'chrome']);
860
- latch(el, 'budget-exhausted', {
861
- budgetUsd: 500,
862
- spentUsd: 500,
863
- vendorLabel: 'Anthropic',
864
- otherVendorAvailable: false,
865
- });
866
- assert.is(el.isVendorBlocked('chrome'), false, 'Chrome is untouched');
867
- assert.is(el.blocked, false, 'and the composer stays live');
868
- assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'), 'which is honest advice');
869
- });
870
- Suite('a status-only Chrome provider is NOT headroom — the wall locks the composer', () => {
871
- // The shipped ChromeProvider implements `getStatus()` and no `chat()`. It
872
- // used to count as reachable anyway, so `otherVendorAvailable: false` failed
873
- // to lock the composer and the banner advised switching to a provider that
874
- // cannot serve a turn — every subsequent send burned another doomed 402.
875
- const el = element();
876
- reachableWithCapabilities(el, ['anthropic', 'chrome'], ['chrome']);
877
- latch(el, 'budget-exhausted', {
878
- budgetUsd: 500,
879
- spentUsd: 500,
880
- vendorLabel: 'Anthropic',
881
- otherVendorAvailable: false,
882
- });
883
- assert.equal(el.reachableVendors, ['anthropic'], 'a chat-less provider is not reachable');
884
- assert.is(el.blocked, true, 'so the wall locks the composer');
885
- assert.is(sendBlocked(el), true, 'and no doomed send goes out');
886
- assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'and nobody is advised to switch to a provider that cannot chat');
887
- });
888
- Suite('the sweep spares a chat-capable OpenAI provider — the proxy never meters it', () => {
889
- // Cross-repo contract, not cosmetics. ai-service rejects `openai` up front with
890
- // 400 UNSUPPORTED_PROVIDER, prices no model under it, and computes
891
- // `otherVendorAvailable` over Anthropic and Gemini alone — so a 402 is never a
892
- // statement about OpenAI. While `BUDGETED_VENDORS` listed it, this exact
893
- // scenario walled a chat-capable OpenAI provider and derived `blocked` over
894
- // headroom the user still had. (The SHIPPED OpenAIProvider is status-only and
895
- // is excluded from reachability by the capability gate — the chat-less Chrome
896
- // test above pins that half; this one uses a custom chat-capable provider.)
897
- const el = element();
898
- reachableWithCapabilities(el, ['anthropic', 'openai']);
899
- latch(el, 'budget-exhausted', {
900
- budgetUsd: 500,
901
- spentUsd: 500,
902
- vendorLabel: 'Anthropic',
903
- otherVendorAvailable: false,
904
- });
905
- assert.is(el.isVendorBlocked('openai'), false, 'OpenAI is untouched');
906
- assert.is(el.blocked, false, 'and the composer stays live');
907
- assert.is(sendBlocked(el), false, 'so the next turn is allowed through');
908
- assert.ok(el.effectiveBlockedReason.includes('Switch to OpenAI'), 'which is honest advice');
909
- });
910
- Suite('directly-assigned statuses with no registry entry still count (degrade open)', () => {
911
- // A host (or this suite's own `reachable` helper) may assign
912
- // `providerStatuses` directly, with names the registry cannot resolve. An
913
- // unresolvable name must NOT silently erase a vendor the status layer vouched
914
- // for — the gate drops a vendor only on positive evidence of chat-lessness.
915
- const el = element();
916
- reachable(el, 'anthropic', 'gemini');
917
- assert.equal(el.reachableVendors, ['anthropic', 'gemini']);
918
- });
919
- // ── The banner names only vendors this user can reach ───────────────────────
920
- Suite('the banner never names a vendor the registry can no longer reach', () => {
921
- // The reported failure: a host ships Anthropic-only, Anthropic walls, the host
922
- // swaps its registry to Gemini, Gemini walls. `walled` was unfiltered, so the
923
- // copy read "limits are reached for Anthropic and Gemini" to a user who never
924
- // had an Anthropic key — and the spurious second name flipped the copy onto the
925
- // plural branch, so the closing sentence was wrong too.
926
- const el = element();
927
- reachable(el, 'anthropic');
928
- el.setVendorBlocked('anthropic', true);
929
- reachable(el, 'gemini');
930
- el.setVendorBlocked('gemini', true);
931
- assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both latches are still held');
932
- assert.is(el.effectiveBlockedReason, "Gemini's AI usage limit is reached. Contact your administrator to raise it.", 'but only the reachable one is named, and the copy is singular');
933
- });
934
- Suite('a wall the registry has moved away from stops being announced at all', () => {
935
- // The intermediate state of the same story. With the swap done and nothing
936
- // walled that the user can reach, the banner has no news — and must not fall
937
- // through to the generic "you've reached your AI usage limit" over a composer
938
- // that works fine.
939
- const el = element();
940
- reachable(el, 'anthropic');
941
- el.setVendorBlocked('anthropic', true);
942
- assert.is(el.bannerVisible, true);
943
- reachable(el, 'gemini');
944
- assert.is(el.blocked, false);
945
- assert.is(el.bannerVisible, false, 'nothing reachable is walled');
946
- });
947
- Suite('unknown reachability still names every wall — the fail-safe path', () => {
948
- // With no statuses loaded the filter has nothing to filter against, so it must
949
- // fall back to the unfiltered list: `blocked` already treats any wall as
950
- // blocking everything there, and the banner has to explain why.
951
- const el = element();
952
- assert.equal(el.reachableVendors, [], 'no statuses loaded');
953
- el.setVendorBlocked('anthropic', true);
954
- assert.is(el.blocked, true);
955
- assert.is(el.bannerVisible, true);
956
- assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached. Contact your administrator to raise it.");
957
- });
958
- Suite('three walled vendors read as a list, not a pair', () => {
959
- // Why `formatVendorList` stays a general joiner: `anthropic`, `gemini` and
960
- // `openai` all reach the model through the metered proxy and all raise walls,
961
- // so the two-vendor shortcut would be wrong today, not just later.
962
- const el = element();
963
- reachable(el, 'anthropic', 'gemini', 'openai');
964
- el.setVendorBlocked('anthropic', true);
965
- el.setVendorBlocked('gemini', true);
966
- el.setVendorBlocked('openai', true);
967
- assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Anthropic, Gemini and OpenAI. Contact your administrator to raise them.');
968
- });
969
- // ── `none` is not a vendor ──────────────────────────────────────────────────
970
- Suite("setVendorBlocked rejects 'none' at the boundary", () => {
971
- // `'none'` is the no-provider sentinel. Latching it rendered "none's AI usage
972
- // limit is reached" and could never be released by derivation, because `'none'`
973
- // never appears in `reachableVendors` — so the latch was permanently
974
- // underivable as well as ugly. Both internal callers already filtered it; this
975
- // is the public @beta entry point that did not.
976
- const el = element();
977
- reachable(el, 'anthropic');
978
- el.setVendorBlocked('none', true, 'nonsense');
979
- assert.equal(el.blockedVendors, [], 'nothing was latched');
980
- assert.is(el.isVendorBlocked('none'), false);
981
- assert.is(el.blocked, false, 'and the composer is untouched');
982
- assert.is(el.bannerVisible, false);
983
- assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, 'no "none\'s" copy');
984
- });
985
- Suite("a wall attributed to 'none' still falls back to the vendor-agnostic block", () => {
986
- // The latch's own filter, unchanged — the boundary check is a second line of
987
- // defence, not a replacement for it.
988
- const el = element();
989
- reachable(el, 'anthropic');
990
- latch(el, 'budget-exhausted', undefined, 'none');
991
- assert.equal(el.blockedVendors, []);
992
- assert.is(el.blocked, true, 'fail-safe: block everything rather than name a sentinel');
993
- });
994
- // ── The banner's class attribute ────────────────────────────────────────────
995
- Suite('the banner emits no empty class slots', () => {
996
- // `class="blocked-banner "` — two interpolations, neither applicable — is
997
- // harmless to a browser and noise in every DOM snapshot and innerHTML
998
- // assertion a host writes against this element.
999
- const el = element();
1000
- assert.is(blockedBannerClasses(el), 'blocked-banner', 'unblocked: no modifiers, no padding');
1001
- reachable(el, 'anthropic', 'gemini');
1002
- el.setVendorBlocked('anthropic', true);
1003
- assert.is(blockedBannerClasses(el), 'blocked-banner is-visible is-partial', 'partial wall');
1004
- el.setVendorBlocked('gemini', true);
1005
- assert.is(blockedBannerClasses(el), 'blocked-banner is-visible', 'fully walled');
1006
- });
1007
- // ── The composer's accessible description ───────────────────────────────────
1008
- Suite('the composer points aria-describedby at the banner whenever it says anything', () => {
1009
- // The PARTIAL state is the one this feature exists to create, and it was the
1010
- // one state with no accessible explanation at all: `aria-disabled` and
1011
- // `aria-label` bind on `blocked`, which is false there, and a live composer
1012
- // keeps the host's own placeholder. So a screen-reader user reached the textarea
1013
- // and heard "Type a message" with nothing to say the next turn might be refused.
1014
- const el = element();
1015
- assert.is(composerDescribedBy(el), null, 'unblocked: no description, attribute omitted');
1016
- reachable(el, 'anthropic', 'gemini');
1017
- el.setVendorBlocked('anthropic', true);
1018
- assert.is(el.blocked, false, 'partial: the composer is still usable…');
1019
- assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, '…and now describable');
1020
- el.setVendorBlocked('gemini', true);
1021
- assert.is(el.blocked, true);
1022
- assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, 'fully blocked: still described');
1023
- });
1024
- Suite('the description follows the banner, not the block', () => {
1025
- // Keyed on `bannerVisible`, so a wall the registry has moved away from drops
1026
- // the description at the same moment it drops the banner — otherwise the
1027
- // controls would point at an empty region.
1028
- const el = element();
1029
- reachable(el, 'anthropic');
1030
- el.setVendorBlocked('anthropic', true);
1031
- assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID);
1032
- reachable(el, 'gemini');
1033
- assert.is(el.bannerVisible, false, 'nothing reachable is walled');
1034
- assert.is(composerDescribedBy(el), null, 'so there is nothing to describe the composer with');
1035
- });
1036
- // ── Suggestions ─────────────────────────────────────────────────────────────
1037
- Suite('suggestions resolve against the registry DEFAULT, not the whole gate', () => {
1038
- // The one question in this feature that CAN be answered exactly: both
1039
- // suggestion paths call `providerRegistry.default()`.
1040
- const el = element();
1041
- reachable(el, 'anthropic', 'gemini'); // anthropic is the default
1042
- el.setVendorBlocked('gemini', true);
1043
- assert.is(el.suggestionsBlocked, false, 'the default vendor still has headroom');
1044
- el.setVendorBlocked('gemini', false);
1045
- el.setVendorBlocked('anthropic', true);
1046
- assert.is(el.suggestionsBlocked, true, 'the default vendor is walled');
1047
- assert.is(el.blocked, false, '…while the composer stays live — the two gates differ');
1048
- });
1049
- Suite('fetchSuggestions is skipped when the default vendor is walled', () => {
1050
- const el = element();
1051
- reachable(el, 'anthropic', 'gemini');
1052
- let calls = 0;
1053
- el.driver = {
1054
- isBusy: () => false,
1055
- getSuggestions: () => __awaiter(void 0, void 0, void 0, function* () {
1056
- calls += 1;
1057
- return [];
1058
- }),
1059
- };
1060
- el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } };
1061
- el.setVendorBlocked('anthropic', true);
1062
- el.fetchSuggestions();
1063
- assert.is(calls, 0, 'no guaranteed-402 request against the default vendor');
1064
- });
1065
- Suite.run();