@genesislcap/ai-assistant 15.6.2 → 15.7.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 (76) hide show
  1. package/dist/ai-assistant.api.json +422 -6
  2. package/dist/ai-assistant.d.ts +654 -19
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +289 -32
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  20. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  21. package/dist/dts/main/main.d.ts +435 -19
  22. package/dist/dts/main/main.d.ts.map +1 -1
  23. package/dist/dts/main/main.styles.d.ts.map +1 -1
  24. package/dist/dts/main/main.styles.test.d.ts +2 -0
  25. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  26. package/dist/dts/main/main.template.d.ts +53 -0
  27. package/dist/dts/main/main.template.d.ts.map +1 -1
  28. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  29. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  30. package/dist/dts/state/debug-event-log.d.ts +6 -1
  31. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  32. package/dist/dts/state/session-store.d.ts +11 -0
  33. package/dist/dts/state/session-store.d.ts.map +1 -1
  34. package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
  35. package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
  36. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  37. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  38. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  39. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  40. package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
  41. package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
  42. package/dist/esm/main/blocked-state.test.js +969 -0
  43. package/dist/esm/main/cost-session-banking.test.js +238 -8
  44. package/dist/esm/main/main.js +826 -48
  45. package/dist/esm/main/main.styles.js +72 -0
  46. package/dist/esm/main/main.styles.test.js +99 -0
  47. package/dist/esm/main/main.template.js +121 -4
  48. package/dist/esm/state/ai-assistant-slice.js +145 -7
  49. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  50. package/dist/esm/state/debug-event-log.js +7 -2
  51. package/dist/esm/state/debug-event-log.test.js +49 -1
  52. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  53. package/dist/esm/utils/banked-usage-baselines.js +84 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/docs/migration-GENC-1464.md +562 -0
  56. package/docs/sub_agent.md +20 -3
  57. package/package.json +17 -17
  58. package/src/channel/ai-activity-channel.ts +56 -2
  59. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  60. package/src/components/chat-driver/chat-driver.ts +324 -14
  61. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  62. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  63. package/src/components/settings-modal/settings-modal.styles.ts +63 -18
  64. package/src/components/settings-modal/settings-modal.template.ts +38 -7
  65. package/src/main/blocked-state.test.ts +1316 -0
  66. package/src/main/cost-session-banking.test.ts +283 -11
  67. package/src/main/main.styles.test.ts +130 -0
  68. package/src/main/main.styles.ts +72 -0
  69. package/src/main/main.template.ts +131 -4
  70. package/src/main/main.ts +827 -42
  71. package/src/state/ai-assistant-slice.test.ts +215 -0
  72. package/src/state/ai-assistant-slice.ts +218 -8
  73. package/src/state/debug-event-log.test.ts +63 -0
  74. package/src/state/debug-event-log.ts +7 -2
  75. package/src/state/persistence/session-snapshot.test.ts +22 -0
  76. package/src/utils/banked-usage-baselines.ts +90 -0
@@ -0,0 +1,969 @@
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
+ Suite('latchBlockedFrom latches only on budget-exhausted', () => {
198
+ const budget = element();
199
+ latch(budget, 'budget-exhausted');
200
+ assert.is(budget.blocked, true, 'a budget wall latches');
201
+ const other = element();
202
+ latch(other, 'exception');
203
+ assert.is(other.blocked, false, 'an ordinary failure does not lock the composer');
204
+ const clean = element();
205
+ latch(clean, undefined);
206
+ assert.is(clean.blocked, false, 'a clean turn does not lock the composer');
207
+ });
208
+ Suite('a second latch never clobbers a host-set blockedReason', () => {
209
+ // The bus fires from the driver's `finally`, BEFORE sendMessage() resolves, so
210
+ // a bus-subscribed host writes its copy first and the return-value latch lands
211
+ // second. It must be a no-op, not a rewrite.
212
+ const el = element();
213
+ el.setBlocked(true, 'custom copy');
214
+ latch(el, 'budget-exhausted');
215
+ assert.is(el.blockedReason, 'custom copy');
216
+ assert.is(el.effectiveBlockedReason, 'custom copy');
217
+ });
218
+ Suite('the driver latch formats the proxy figures into the banner', () => {
219
+ const el = element();
220
+ latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });
221
+ assert.is(el.blocked, true);
222
+ assert.ok(el.effectiveBlockedReason.includes('$25.40'), 'the spend is shown');
223
+ assert.ok(el.effectiveBlockedReason.includes('$25'), 'so is the cap');
224
+ });
225
+ Suite('formatBlockedReason returns undefined when the proxy sent no figures', () => {
226
+ var _a;
227
+ // `undefined` is the meaningful return: with the slice's "omitted reason keeps
228
+ // the existing explanation" rule it is what lets a figureless latch leave a
229
+ // host reason intact.
230
+ assert.is(formatBlockedReason(undefined), undefined);
231
+ assert.is(formatBlockedReason({ vendorLabel: 'Anthropic' }), undefined);
232
+ assert.ok((_a = formatBlockedReason({ spentUsd: 5.02, vendorLabel: 'Gemini' })) === null || _a === void 0 ? void 0 : _a.includes('$5.02'));
233
+ });
234
+ Suite('a figureless latch leaves a host reason intact', () => {
235
+ const el = element();
236
+ el.setBlocked(true, 'host copy');
237
+ latch(el, 'budget-exhausted');
238
+ assert.is(el.effectiveBlockedReason, 'host copy');
239
+ });
240
+ // ── Lifetime + composer inertness ───────────────────────────────────────────
241
+ Suite('the latch is per-stateKey — a different session store starts unblocked', () => {
242
+ // Pins the documented Lifetime: `blocked` lives on the session store, so it is
243
+ // per-stateKey and a session switch (which tears the outgoing store down)
244
+ // yields an unblocked one. The host pre-flight is the durable source of truth.
245
+ const first = element();
246
+ const second = element();
247
+ first.blocked = true;
248
+ assert.is(first.blocked, true);
249
+ assert.is(second.blocked, false, 'a different session store is unaffected');
250
+ });
251
+ Suite('"Clear" keeps a PARTIAL wall partial — it does not escalate to a full lock', () => {
252
+ // The reported failure: `resetSession` preserved `blockedVendors` but wiped
253
+ // `providerStatuses`, so `reachableVendors` went empty, the "unknown
254
+ // reachability fails safe" rule fired, and one walled vendor out of two turned
255
+ // a live composer into a locked one. Nothing reloads the statuses on Clear —
256
+ // `loadProviderStatuses()` runs only on connect/session-switch and off the
257
+ // observable-registry subscription — so a host with a static registry stayed
258
+ // locked until a remount. The statuses describe the registry, not the
259
+ // conversation, so they are now preserved across the reset.
260
+ const el = element();
261
+ reachable(el, 'anthropic', 'gemini');
262
+ el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');
263
+ assert.is(el.blocked, false, 'precondition: Gemini has headroom, so the composer is live');
264
+ resetSession(el);
265
+ assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry snapshot survives');
266
+ assert.equal(el.blockedVendors, ['anthropic'], 'and so does the wall — Clear refills no budget');
267
+ assert.is(el.blocked, false, 'so the composer is still usable after Clear');
268
+ assert.is(sendBlocked(el), false);
269
+ assert.is(el.bannerVisible, true, 'and the wall is still announced');
270
+ assert.ok(el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'));
271
+ });
272
+ Suite('"Clear" still leaves a FULL wall locked', () => {
273
+ // The other direction of the same change: preserving the statuses must not
274
+ // make Clear a way out of a wall that really does cover every vendor.
275
+ const el = element();
276
+ reachable(el, 'anthropic', 'gemini');
277
+ el.setVendorBlocked('anthropic', true);
278
+ el.setVendorBlocked('gemini', true);
279
+ resetSession(el);
280
+ assert.is(el.blocked, true);
281
+ assert.is(sendBlocked(el), true);
282
+ assert.is(el.effectivePlaceholder, 'AI usage limit reached');
283
+ });
284
+ Suite('"Clear" still wipes the conversation it is there to wipe', () => {
285
+ // Guards the preserve-list against over-reach: `providerStatuses` joining it
286
+ // must not smuggle in anything that IS conversation state.
287
+ const el = element();
288
+ reachable(el, 'anthropic');
289
+ el.messages = [{ id: '1', role: 'user', content: 'hello' }];
290
+ assert.is(el.messages.length, 1);
291
+ resetSession(el);
292
+ assert.is(el.messages.length, 0, 'the transcript is gone');
293
+ assert.equal(el.reachableVendors, ['anthropic'], 'the registry snapshot is not');
294
+ });
295
+ Suite('handleSuggestionClick is inert while blocked', () => {
296
+ // The chips are hidden by the template while blocked, but a host (or a stale
297
+ // render) can still reach the handler. Without the guard the suggestion text is
298
+ // deposited into a disabled textarea the user cannot clear, because `send()`
299
+ // refuses only AFTER `inputValue` has been written.
300
+ const el = element();
301
+ el.blocked = true;
302
+ el.handleSuggestionClick('draft me a report');
303
+ assert.is(el.inputValue, '', 'nothing is written into the disabled composer');
304
+ });
305
+ Suite('fetchSuggestions does not call the driver while blocked', () => {
306
+ // Otherwise every blocked turn, pop-in and pin change fires one guaranteed-402
307
+ // request, burning a `suggestions.failed` meta event and parking a raw
308
+ // transport-error string in `suggestionsState.message` for hosts to find.
309
+ const el = element();
310
+ let calls = 0;
311
+ el.driver = {
312
+ isBusy: () => false,
313
+ getSuggestions: () => __awaiter(void 0, void 0, void 0, function* () {
314
+ calls += 1;
315
+ return [];
316
+ }),
317
+ };
318
+ el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } };
319
+ el.blocked = true;
320
+ el.fetchSuggestions();
321
+ assert.is(calls, 0, 'no request is issued against the wall');
322
+ assert.is(el.suggestionsState.status, 'idle', 'and no loading state is entered');
323
+ });
324
+ // ── Per-vendor budgets (GENC-1464) ─────────────────────────────────────────────
325
+ //
326
+ // The central question this design answers — "which vendor would the NEXT turn
327
+ // use?" — has no correct answer and deliberately gets no API: the provider is
328
+ // resolved per turn AND per agent, an orchestrated turn picks its agent with an
329
+ // LLM classify() call, and sub-agents resolve their own. So `blocked` is instead
330
+ // derived: "every vendor the registry can currently REACH is walled". That is
331
+ // synchronous, free (the statuses are already loaded), and makes vendor-switch
332
+ // recovery a derivation rather than a mutation.
333
+ Suite('a walled vendor with another reachable one does NOT block the composer', () => {
334
+ // D3 — partial exhaustion blocks only the exhausted vendor.
335
+ const el = element();
336
+ reachable(el, 'anthropic', 'gemini');
337
+ el.setVendorBlocked('anthropic', true);
338
+ assert.equal(el.blockedVendors, ['anthropic']);
339
+ assert.is(el.blocked, false, 'Gemini still has headroom, so sends stay allowed');
340
+ assert.is(sendBlocked(el), false);
341
+ assert.is(el.bannerVisible, true, 'but the banner still announces it');
342
+ });
343
+ Suite('walling every reachable vendor blocks the composer', () => {
344
+ const el = element();
345
+ reachable(el, 'anthropic', 'gemini');
346
+ el.setVendorBlocked('anthropic', true);
347
+ el.setVendorBlocked('gemini', true);
348
+ assert.is(el.blocked, true);
349
+ assert.is(sendBlocked(el), true);
350
+ assert.is(el.effectivePlaceholder, 'AI usage limit reached');
351
+ });
352
+ Suite('a vendor switch releases the block by derivation, with the latch left intact', () => {
353
+ // The known-hard part: there is no vendor-switch EVENT to hang a clear on. A
354
+ // host that swaps its registry to Gemini fires the observable, the statuses
355
+ // reload, Anthropic drops out of `reachableVendors`, and `blocked` goes false
356
+ // — nothing mutated the latch, so switching back re-derives the block.
357
+ const el = element();
358
+ reachable(el, 'anthropic');
359
+ el.setVendorBlocked('anthropic', true);
360
+ assert.is(el.blocked, true, 'walled and it is the only reachable vendor');
361
+ reachable(el, 'gemini');
362
+ assert.is(el.blocked, false, 'the switch is recovery, with no host code at all');
363
+ assert.equal(el.blockedVendors, ['anthropic'], 'and the latch was never cleared');
364
+ reachable(el, 'anthropic');
365
+ assert.is(el.blocked, true, 'switching back re-derives the block');
366
+ });
367
+ Suite('unknown reachability fails safe — any wall blocks everything', () => {
368
+ // Providers need not implement `getStatus()`. A host whose providers report
369
+ // nothing gets no per-vendor benefit, but it must not get a LESS locked UI
370
+ // than before, so an empty reachable set means "any wall blocks everything".
371
+ const el = element();
372
+ assert.equal(el.reachableVendors, [], 'no statuses loaded');
373
+ el.setVendorBlocked('anthropic', true);
374
+ assert.is(el.blocked, true);
375
+ });
376
+ Suite('a host that never adopts the per-vendor API reads `blocked` exactly as before', () => {
377
+ // THE back-compat pin: with `blockedVendors` empty the derivation short-circuits
378
+ // to the stored boolean, whatever the provider statuses say.
379
+ const el = element();
380
+ reachable(el, 'anthropic', 'gemini');
381
+ assert.is(el.blocked, false);
382
+ el.blocked = true;
383
+ assert.is(el.blocked, true, 'a vendor-agnostic block still blocks everything');
384
+ assert.equal(el.blockedVendors, [], 'and does not invent a per-vendor latch');
385
+ el.blocked = false;
386
+ assert.is(el.blocked, false);
387
+ });
388
+ Suite('setBlocked(false) clears the per-vendor latches too', () => {
389
+ // What keeps migration-GENC-1464 Option B working verbatim: a host asserting "the
390
+ // wall is gone" after its own pre-flight must not be overruled by a driver
391
+ // latch it cannot see.
392
+ const el = element();
393
+ reachable(el, 'anthropic');
394
+ el.setVendorBlocked('anthropic', true);
395
+ el.setBlocked(false);
396
+ assert.is(el.blocked, false);
397
+ assert.equal(el.blockedVendors, [], 'the latch is gone, not merely outvoted');
398
+ });
399
+ Suite('isVendorBlocked answers per vendor, independently of the composer gate', () => {
400
+ const el = element();
401
+ reachable(el, 'anthropic', 'gemini');
402
+ el.setVendorBlocked('gemini', true, 'Gemini budget spent — see billing');
403
+ assert.is(el.isVendorBlocked('gemini'), true);
404
+ assert.is(el.isVendorBlocked('anthropic'), false);
405
+ assert.is(el.blocked, false, 'and the composer is still live');
406
+ });
407
+ Suite('writing blockedReason does not clear the per-vendor latches', () => {
408
+ // `blocked` is derived now, so routing this setter through
409
+ // `setBlocked({ blocked: this.blocked })` would pass `false` for a partially
410
+ // walled session and wipe the latches as a side effect of setting a string.
411
+ const el = element();
412
+ reachable(el, 'anthropic', 'gemini');
413
+ el.setVendorBlocked('anthropic', true);
414
+ el.blockedReason = 'custom copy';
415
+ assert.equal(el.blockedVendors, ['anthropic']);
416
+ assert.is(el.effectiveBlockedReason, 'custom copy');
417
+ });
418
+ // ── The per-vendor latch ────────────────────────────────────────────────────
419
+ Suite('the latch attributes a wall to the vendor named by the transport label', () => {
420
+ const el = element();
421
+ reachable(el, 'anthropic', 'gemini');
422
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
423
+ assert.equal(el.blockedVendors, ['anthropic']);
424
+ assert.is(el.blocked, false, 'Gemini is untouched — D3');
425
+ });
426
+ Suite('the per-vendor guard lets a SECOND vendor wall land', () => {
427
+ // The old global `if (this.blocked) return` would have swallowed this, so a
428
+ // session could never record that both vendors were gone — and under a mixed
429
+ // registry an Anthropic-only wall would have locked a composer Gemini could
430
+ // still serve.
431
+ const el = element();
432
+ reachable(el, 'anthropic', 'gemini');
433
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
434
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 501, vendorLabel: 'Gemini' });
435
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both walls are recorded, in order');
436
+ assert.is(el.blocked, true);
437
+ });
438
+ Suite('a re-latch of an already-walled vendor never clobbers host copy', () => {
439
+ const el = element();
440
+ reachable(el, 'anthropic', 'gemini');
441
+ el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');
442
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
443
+ assert.ok(el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'), 'the host per-vendor copy survives the driver latch');
444
+ });
445
+ Suite('the label wins over a stale driver-supplied vendor', () => {
446
+ // On the classification seam the driver's last-resolved provider is the
447
+ // PREVIOUS turn's vendor; the label comes from the transport that actually
448
+ // said no. Seed the disagreement to prove which one is believed.
449
+ const el = element();
450
+ reachable(el, 'anthropic', 'gemini');
451
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Gemini', vendor: 'anthropic' }, 'anthropic');
452
+ assert.equal(el.blockedVendors, ['gemini']);
453
+ });
454
+ Suite('a figure-less wall attributes by label, not by the stale hint', () => {
455
+ // This replaces a test that pinned the opposite path as correct. A figure-less
456
+ // 402 used to reach the latch with NO budget object at all, so the only vendor
457
+ // on offer was the bus hint — the driver's last-resolved provider, i.e. the
458
+ // PREVIOUS turn's vendor. `chat-driver.test.ts` now proves the driver keeps the
459
+ // label on a figure-less wall; this proves the latch prefers it. Seed the
460
+ // disagreement, or a constant would satisfy both.
461
+ const el = element();
462
+ reachable(el, 'anthropic', 'gemini');
463
+ latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' }, 'gemini');
464
+ assert.equal(el.blockedVendors, ['anthropic'], 'the transport that said no');
465
+ });
466
+ Suite('the bus vendor hint still attributes a detail that carries no budget', () => {
467
+ // `tool-loop-end` is a public topic and its top-level `vendor` is documented
468
+ // and additive, so a host may publish the reason and the vendor with no budget
469
+ // payload at all. That path stays supported — it is just no longer how the
470
+ // driver's own figure-less walls travel.
471
+ const el = element();
472
+ reachable(el, 'anthropic', 'gemini');
473
+ latch(el, 'budget-exhausted', undefined, 'gemini');
474
+ assert.equal(el.blockedVendors, ['gemini']);
475
+ });
476
+ Suite('a wall naming no known vendor falls back to the vendor-agnostic block', () => {
477
+ // Fail-safe: blocking the wrong vendor is worse than blocking all of them.
478
+ const el = element();
479
+ reachable(el, 'anthropic', 'gemini');
480
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Acme AI' });
481
+ assert.equal(el.blockedVendors, [], 'nothing is attributed');
482
+ assert.is(el.blocked, true, 'and the whole assistant locks, exactly as it used to');
483
+ });
484
+ // ── Banner copy ─────────────────────────────────────────────────────────────
485
+ Suite('partial exhaustion names the walled vendor and the way out', () => {
486
+ const el = element();
487
+ reachable(el, 'anthropic', 'gemini');
488
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
489
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.");
490
+ });
491
+ Suite('it says "switch", it does not switch — no auto-switching', () => {
492
+ // D3: auto-switching would silently downgrade model quality. The element only
493
+ // ever advises; nothing here touches the registry.
494
+ const el = element();
495
+ reachable(el, 'anthropic', 'gemini');
496
+ el.setVendorBlocked('anthropic', true);
497
+ assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry is untouched');
498
+ assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
499
+ });
500
+ Suite('full exhaustion of a single vendor asks for a raise, not a switch', () => {
501
+ const el = element();
502
+ reachable(el, 'anthropic');
503
+ latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });
504
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.");
505
+ });
506
+ Suite('several walled vendors are named together, with plural copy', () => {
507
+ const el = element();
508
+ reachable(el, 'anthropic', 'gemini');
509
+ el.setVendorBlocked('anthropic', true);
510
+ el.setVendorBlocked('gemini', true);
511
+ assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Anthropic and Gemini. Contact your administrator to raise them.');
512
+ });
513
+ Suite('a walled vendor with figureless copy still names itself', () => {
514
+ // The shape here is the one `budgetDetailOf` actually emits for a figure-less
515
+ // 402 — label plus resolved vendor, no numbers — not a hand-made shape the
516
+ // driver never produces. `chat-driver.test.ts` pins the emitting end.
517
+ const el = element();
518
+ reachable(el, 'anthropic');
519
+ latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' });
520
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached. Contact your administrator to raise it.");
521
+ });
522
+ Suite('host per-vendor copy survives a SECOND vendor walling', () => {
523
+ // The reported failure: the host reasons were read only on the `walled.length
524
+ // === 1` branch, so the moment a second vendor walled every carefully written
525
+ // host sentence was replaced by generic copy — contradicting both the
526
+ // `setVendorBlocked` JSDoc and the migration doc, and doing it at the exact
527
+ // moment the situation got worse.
528
+ const el = element();
529
+ reachable(el, 'anthropic', 'gemini');
530
+ el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
531
+ el.setVendorBlocked('gemini', true, 'Gemini spend is capped for this desk.');
532
+ assert.is(el.effectiveBlockedReason, 'Anthropic spend is capped for this desk. Gemini spend is capped for this desk. ' +
533
+ 'Contact your administrator to raise them.');
534
+ });
535
+ Suite('a vendor with no copy still gets a sentence beside one that has copy', () => {
536
+ // Mixed is the realistic case — e.g. the proxy's `otherVendorAvailable: false`
537
+ // sweep wals the second vendor with no figures of its own. The composed set must
538
+ // stay complete rather than silently naming a subset.
539
+ const el = element();
540
+ reachable(el, 'anthropic', 'gemini');
541
+ el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
542
+ el.setVendorBlocked('gemini', true);
543
+ assert.is(el.effectiveBlockedReason, "Anthropic spend is capped for this desk. Gemini's AI usage limit is reached. " +
544
+ 'Contact your administrator to raise them.');
545
+ });
546
+ Suite('three walled vendors with copy read as three statements', () => {
547
+ const el = element();
548
+ reachable(el, 'anthropic', 'gemini', 'openai');
549
+ el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
550
+ el.setVendorBlocked('gemini', true, 'Gemini: capped.');
551
+ el.setVendorBlocked('openai', true, 'OpenAI: capped.');
552
+ assert.is(el.effectiveBlockedReason, 'Anthropic: capped. Gemini: capped. OpenAI: capped. Contact your administrator to raise them.');
553
+ });
554
+ Suite('only the REACHABLE walled vendors contribute a statement', () => {
555
+ // The composition is layered on `relevantBlockedVendors`, not on the raw list,
556
+ // so host copy for a vendor the registry has moved away from stays unshown.
557
+ const el = element();
558
+ reachable(el, 'anthropic');
559
+ el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
560
+ reachable(el, 'gemini', 'openai');
561
+ el.setVendorBlocked('gemini', true, 'Gemini: capped.');
562
+ el.setVendorBlocked('openai', true, 'OpenAI: capped.');
563
+ 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');
564
+ });
565
+ Suite('a host blockedReason outranks every composed sentence', () => {
566
+ const el = element();
567
+ reachable(el, 'anthropic', 'gemini');
568
+ el.setVendorBlocked('anthropic', true);
569
+ el.setVendorBlocked('gemini', true);
570
+ el.blockedReason = 'Contact ops@acme.com';
571
+ assert.is(el.effectiveBlockedReason, 'Contact ops@acme.com');
572
+ });
573
+ Suite('formatBlockedReason composes a vendor statement without an action clause', () => {
574
+ // The action is composed at render time, because whether "switch vendor" or
575
+ // "contact your administrator" is honest depends on the CURRENT reachable set.
576
+ assert.is(formatBlockedReason({ budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Gemini' }, 'gemini'), "Gemini's AI usage limit is reached ($25.40 of $25.00).");
577
+ assert.is(formatBlockedReason(undefined, 'gemini'), undefined, 'no figures, no statement');
578
+ });
579
+ // ── The server's `otherVendorAvailable` verdict ─────────────────────────────
580
+ //
581
+ // The client can only infer "another vendor is free" from REGISTRY MEMBERSHIP,
582
+ // which knows which vendors exist and nothing about their remaining spend. The
583
+ // proxy meters the pots and says so on the 402. Where the two disagree, the
584
+ // server wins — otherwise the user is told to switch to a vendor that is already
585
+ // spent, and burns a second doomed turn learning what arrived in the first
586
+ // response.
587
+ Suite('`otherVendorAvailable: false` locks the composer on the FIRST wall', () => {
588
+ const el = element();
589
+ reachable(el, 'anthropic', 'gemini');
590
+ latch(el, 'budget-exhausted', {
591
+ budgetUsd: 500,
592
+ spentUsd: 500,
593
+ vendorLabel: 'Anthropic',
594
+ otherVendorAvailable: false,
595
+ });
596
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'every metered pot is walled — the verdict is about the proxy, not this registry');
597
+ assert.is(el.blocked, true, 'so `blocked` derives true immediately');
598
+ assert.is(sendBlocked(el), true, 'and no second doomed turn is possible');
599
+ });
600
+ Suite('the early-window banner names only the refuser, never a swept vendor', () => {
601
+ // The window: a 402 lands while `loadProviderStatuses()` is still in flight,
602
+ // and its `otherVendorAvailable: false` sweep walls every budgeted vendor.
603
+ // The unknown-reachability fallback used to surface the swept ones, so the
604
+ // banner read "Anthropic's AI usage limit is reached ($25.40 of $25.00).
605
+ // Gemini's AI usage limit is reached. Contact your administrator to raise
606
+ // them." — naming a vendor this host may not even ship, on the plural branch,
607
+ // off nothing but the proxy's headroom verdict. A refusal is nameable
608
+ // evidence; a sweep entry is not, until reachability can vouch for it.
609
+ const el = element();
610
+ assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');
611
+ latch(el, 'budget-exhausted', {
612
+ budgetUsd: 25,
613
+ spentUsd: 25.4,
614
+ vendorLabel: 'Anthropic',
615
+ otherVendorAvailable: false,
616
+ });
617
+ assert.is(el.blocked, true, 'locking still counts every wall — that fail-safe is untouched');
618
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'and the verdict is still recorded');
619
+ 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');
620
+ // Statuses land with both vendors: reachability now vouches for the swept
621
+ // wall, so the banner grows to the honest multi-vendor copy.
622
+ reachable(el, 'anthropic', 'gemini');
623
+ assert.ok(el.effectiveBlockedReason.includes('Gemini'), 'once reachable, the swept vendor is named after all');
624
+ });
625
+ Suite('a swept vendor that then REFUSES is named even before the statuses land', () => {
626
+ // Provenance upgrades on first-hand evidence: the sweep walled Gemini off the
627
+ // proxy's verdict, then the user (say, via a pre-set vendor preference)
628
+ // actually hit Gemini and got its own 402. That refusal is nameable — the
629
+ // latch clears the swept mark when it writes the wall without `swept`.
630
+ const el = element();
631
+ latch(el, 'budget-exhausted', {
632
+ budgetUsd: 25,
633
+ spentUsd: 25.4,
634
+ vendorLabel: 'Anthropic',
635
+ otherVendorAvailable: false,
636
+ });
637
+ assert.not.ok(el.effectiveBlockedReason.includes('Gemini'), 'swept: not named yet');
638
+ latch(el, 'budget-exhausted', {
639
+ budgetUsd: 500,
640
+ spentUsd: 512,
641
+ vendorLabel: 'Gemini',
642
+ otherVendorAvailable: false,
643
+ });
644
+ assert.ok(el.effectiveBlockedReason.includes('Gemini'), 'refused: named on its own evidence');
645
+ assert.ok(el.effectiveBlockedReason.includes('$512.00'), 'with its own figures');
646
+ });
647
+ Suite('a host-set wall is never treated as swept', () => {
648
+ // `setVendorBlocked` from the host is an explicit statement (e.g. its own
649
+ // pre-flight consuming GET /api/budget) — first-hand, nameable, statuses or
650
+ // not. Only the element's own sweep passes the provenance flag.
651
+ const el = element();
652
+ assert.equal(el.reachableVendors, []);
653
+ el.setVendorBlocked('gemini', true, 'Gemini: capped by pre-flight.');
654
+ assert.ok(el.effectiveBlockedReason.includes('Gemini: capped by pre-flight.'));
655
+ });
656
+ Suite('the verdict survives a wall that arrives BEFORE the statuses load', () => {
657
+ // The ordering bug: `activateSession` kicks off `loadProviderStatuses()` without
658
+ // awaiting it, so a 402 can land while `reachableVendors` is still empty. The
659
+ // sweep used to iterate that empty set, so `otherVendorAvailable: false` — the
660
+ // one fact on the 402 the client cannot re-derive — was stored nowhere at all,
661
+ // and nothing re-ran it when the statuses arrived. The wall then effectively
662
+ // UN-latched: `blocked` was true only while reachability was unknown, so the
663
+ // composer came back to life the instant the statuses landed.
664
+ const el = element();
665
+ assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');
666
+ latch(el, 'budget-exhausted', {
667
+ budgetUsd: 500,
668
+ spentUsd: 500,
669
+ vendorLabel: 'Anthropic',
670
+ otherVendorAvailable: false,
671
+ });
672
+ assert.is(el.blocked, true, 'the fail-safe locks it while reachability is unknown');
673
+ // …and now the statuses resolve.
674
+ reachable(el, 'anthropic', 'gemini');
675
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'the verdict was recorded');
676
+ assert.is(el.blocked, true, 'so it stays locked, rather than un-latching');
677
+ assert.is(sendBlocked(el), true);
678
+ assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'and still advises no switch');
679
+ });
680
+ Suite('a wall before the statuses load still spares Chrome once they arrive', () => {
681
+ // The sweep now walls vendors it cannot see, so the `isBudgetedVendor` boundary
682
+ // is doing more work than before: it is the only thing keeping an on-device
683
+ // provider out of a verdict that is only ever about the proxy's own pots.
684
+ const el = element();
685
+ latch(el, 'budget-exhausted', {
686
+ budgetUsd: 500,
687
+ spentUsd: 500,
688
+ vendorLabel: 'Anthropic',
689
+ otherVendorAvailable: false,
690
+ });
691
+ reachable(el, 'anthropic', 'chrome');
692
+ assert.is(el.isVendorBlocked('chrome'), false, 'Chrome has no pot to exhaust');
693
+ assert.is(el.blocked, false, 'so the composer comes back for the vendor that is still free');
694
+ assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'));
695
+ });
696
+ Suite('`otherVendorAvailable: false` vetoes the "switch to X" advice', () => {
697
+ const el = element();
698
+ reachable(el, 'anthropic', 'gemini');
699
+ latch(el, 'budget-exhausted', {
700
+ budgetUsd: 500,
701
+ spentUsd: 500,
702
+ vendorLabel: 'Anthropic',
703
+ otherVendorAvailable: false,
704
+ });
705
+ // The refusing vendor carries the 402's figures, so the multi-vendor branch
706
+ // composes them in rather than dropping to the generic list — the swept vendor
707
+ // has no figures of its own (only the proxy's word), so it gets the boilerplate.
708
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($500.00 of $500.00). " +
709
+ "Gemini's AI usage limit is reached. Contact your administrator to raise them.");
710
+ assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'nowhere to switch to');
711
+ });
712
+ Suite('`otherVendorAvailable: true` leaves the inference exactly as it was', () => {
713
+ // The other direction. A verdict of `true` agrees with the client's guess, so
714
+ // nothing about the historical behaviour may change — including the copy.
715
+ const el = element();
716
+ reachable(el, 'anthropic', 'gemini');
717
+ latch(el, 'budget-exhausted', {
718
+ budgetUsd: 500,
719
+ spentUsd: 500,
720
+ vendorLabel: 'Anthropic',
721
+ otherVendorAvailable: true,
722
+ });
723
+ assert.equal(el.blockedVendors, ['anthropic'], 'only the refused vendor');
724
+ assert.is(el.blocked, false);
725
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.");
726
+ });
727
+ Suite('an ABSENT verdict is not a `false` — an older proxy walls nothing extra', () => {
728
+ const el = element();
729
+ reachable(el, 'anthropic', 'gemini');
730
+ latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });
731
+ assert.equal(el.blockedVendors, ['anthropic']);
732
+ assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
733
+ });
734
+ Suite('the verdict still lands when it arrives on a LATER wall', () => {
735
+ // Turn 1 walls Anthropic while Gemini still has headroom; turn 2 hits the same
736
+ // wall and reports that Gemini has since gone too. The per-vendor idempotence
737
+ // guard would have returned before reading that, dropping the only news the
738
+ // second turn carried — so the sweep runs ahead of it.
739
+ const el = element();
740
+ reachable(el, 'anthropic', 'gemini');
741
+ latch(el, 'budget-exhausted', {
742
+ budgetUsd: 500,
743
+ spentUsd: 500,
744
+ vendorLabel: 'Anthropic',
745
+ otherVendorAvailable: true,
746
+ });
747
+ assert.is(el.blocked, false, 'turn 1 leaves Gemini usable');
748
+ latch(el, 'budget-exhausted', {
749
+ budgetUsd: 500,
750
+ spentUsd: 500,
751
+ vendorLabel: 'Anthropic',
752
+ otherVendorAvailable: false,
753
+ });
754
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini']);
755
+ assert.is(el.blocked, true);
756
+ });
757
+ Suite('a chat-capable Chrome provider is spared by the sweep AND counts as headroom', () => {
758
+ // `otherVendorAvailable` is the proxy speaking about the pots IT meters.
759
+ // A host's CUSTOM Chrome provider that implements `chat()` (the shipped one
760
+ // does not — see the next test) is never metered, so walling it would lock a
761
+ // provider that is still free to use. The gate is capability, not identity.
762
+ const el = element();
763
+ reachableWithCapabilities(el, ['anthropic', 'chrome']);
764
+ latch(el, 'budget-exhausted', {
765
+ budgetUsd: 500,
766
+ spentUsd: 500,
767
+ vendorLabel: 'Anthropic',
768
+ otherVendorAvailable: false,
769
+ });
770
+ assert.is(el.isVendorBlocked('chrome'), false, 'Chrome is untouched');
771
+ assert.is(el.blocked, false, 'and the composer stays live');
772
+ assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'), 'which is honest advice');
773
+ });
774
+ Suite('a status-only Chrome provider is NOT headroom — the wall locks the composer', () => {
775
+ // The shipped ChromeProvider implements `getStatus()` and no `chat()`. It
776
+ // used to count as reachable anyway, so `otherVendorAvailable: false` failed
777
+ // to lock the composer and the banner advised switching to a provider that
778
+ // cannot serve a turn — every subsequent send burned another doomed 402.
779
+ const el = element();
780
+ reachableWithCapabilities(el, ['anthropic', 'chrome'], ['chrome']);
781
+ latch(el, 'budget-exhausted', {
782
+ budgetUsd: 500,
783
+ spentUsd: 500,
784
+ vendorLabel: 'Anthropic',
785
+ otherVendorAvailable: false,
786
+ });
787
+ assert.equal(el.reachableVendors, ['anthropic'], 'a chat-less provider is not reachable');
788
+ assert.is(el.blocked, true, 'so the wall locks the composer');
789
+ assert.is(sendBlocked(el), true, 'and no doomed send goes out');
790
+ assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'and nobody is advised to switch to a provider that cannot chat');
791
+ });
792
+ Suite('the sweep spares a chat-capable OpenAI provider — the proxy never meters it', () => {
793
+ // Cross-repo contract, not cosmetics. ai-service rejects `openai` up front with
794
+ // 400 UNSUPPORTED_PROVIDER, prices no model under it, and computes
795
+ // `otherVendorAvailable` over Anthropic and Gemini alone — so a 402 is never a
796
+ // statement about OpenAI. While `BUDGETED_VENDORS` listed it, this exact
797
+ // scenario walled a chat-capable OpenAI provider and derived `blocked` over
798
+ // headroom the user still had. (The SHIPPED OpenAIProvider is status-only and
799
+ // is excluded from reachability by the capability gate — the chat-less Chrome
800
+ // test above pins that half; this one uses a custom chat-capable provider.)
801
+ const el = element();
802
+ reachableWithCapabilities(el, ['anthropic', 'openai']);
803
+ latch(el, 'budget-exhausted', {
804
+ budgetUsd: 500,
805
+ spentUsd: 500,
806
+ vendorLabel: 'Anthropic',
807
+ otherVendorAvailable: false,
808
+ });
809
+ assert.is(el.isVendorBlocked('openai'), false, 'OpenAI is untouched');
810
+ assert.is(el.blocked, false, 'and the composer stays live');
811
+ assert.is(sendBlocked(el), false, 'so the next turn is allowed through');
812
+ assert.ok(el.effectiveBlockedReason.includes('Switch to OpenAI'), 'which is honest advice');
813
+ });
814
+ Suite('directly-assigned statuses with no registry entry still count (degrade open)', () => {
815
+ // A host (or this suite's own `reachable` helper) may assign
816
+ // `providerStatuses` directly, with names the registry cannot resolve. An
817
+ // unresolvable name must NOT silently erase a vendor the status layer vouched
818
+ // for — the gate drops a vendor only on positive evidence of chat-lessness.
819
+ const el = element();
820
+ reachable(el, 'anthropic', 'gemini');
821
+ assert.equal(el.reachableVendors, ['anthropic', 'gemini']);
822
+ });
823
+ // ── The banner names only vendors this user can reach ───────────────────────
824
+ Suite('the banner never names a vendor the registry can no longer reach', () => {
825
+ // The reported failure: a host ships Anthropic-only, Anthropic walls, the host
826
+ // swaps its registry to Gemini, Gemini walls. `walled` was unfiltered, so the
827
+ // copy read "limits are reached for Anthropic and Gemini" to a user who never
828
+ // had an Anthropic key — and the spurious second name flipped the copy onto the
829
+ // plural branch, so the closing sentence was wrong too.
830
+ const el = element();
831
+ reachable(el, 'anthropic');
832
+ el.setVendorBlocked('anthropic', true);
833
+ reachable(el, 'gemini');
834
+ el.setVendorBlocked('gemini', true);
835
+ assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both latches are still held');
836
+ 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');
837
+ });
838
+ Suite('a wall the registry has moved away from stops being announced at all', () => {
839
+ // The intermediate state of the same story. With the swap done and nothing
840
+ // walled that the user can reach, the banner has no news — and must not fall
841
+ // through to the generic "you've reached your AI usage limit" over a composer
842
+ // that works fine.
843
+ const el = element();
844
+ reachable(el, 'anthropic');
845
+ el.setVendorBlocked('anthropic', true);
846
+ assert.is(el.bannerVisible, true);
847
+ reachable(el, 'gemini');
848
+ assert.is(el.blocked, false);
849
+ assert.is(el.bannerVisible, false, 'nothing reachable is walled');
850
+ });
851
+ Suite('unknown reachability still names every wall — the fail-safe path', () => {
852
+ // With no statuses loaded the filter has nothing to filter against, so it must
853
+ // fall back to the unfiltered list: `blocked` already treats any wall as
854
+ // blocking everything there, and the banner has to explain why.
855
+ const el = element();
856
+ assert.equal(el.reachableVendors, [], 'no statuses loaded');
857
+ el.setVendorBlocked('anthropic', true);
858
+ assert.is(el.blocked, true);
859
+ assert.is(el.bannerVisible, true);
860
+ assert.is(el.effectiveBlockedReason, "Anthropic's AI usage limit is reached. Contact your administrator to raise it.");
861
+ });
862
+ Suite('three walled vendors read as a list, not a pair', () => {
863
+ // Why `formatVendorList` stays a general joiner: `anthropic`, `gemini` and
864
+ // `openai` all reach the model through the metered proxy and all raise walls,
865
+ // so the two-vendor shortcut would be wrong today, not just later.
866
+ const el = element();
867
+ reachable(el, 'anthropic', 'gemini', 'openai');
868
+ el.setVendorBlocked('anthropic', true);
869
+ el.setVendorBlocked('gemini', true);
870
+ el.setVendorBlocked('openai', true);
871
+ assert.is(el.effectiveBlockedReason, 'AI usage limits are reached for Anthropic, Gemini and OpenAI. Contact your administrator to raise them.');
872
+ });
873
+ // ── `none` is not a vendor ──────────────────────────────────────────────────
874
+ Suite("setVendorBlocked rejects 'none' at the boundary", () => {
875
+ // `'none'` is the no-provider sentinel. Latching it rendered "none's AI usage
876
+ // limit is reached" and could never be released by derivation, because `'none'`
877
+ // never appears in `reachableVendors` — so the latch was permanently
878
+ // underivable as well as ugly. Both internal callers already filtered it; this
879
+ // is the public @beta entry point that did not.
880
+ const el = element();
881
+ reachable(el, 'anthropic');
882
+ el.setVendorBlocked('none', true, 'nonsense');
883
+ assert.equal(el.blockedVendors, [], 'nothing was latched');
884
+ assert.is(el.isVendorBlocked('none'), false);
885
+ assert.is(el.blocked, false, 'and the composer is untouched');
886
+ assert.is(el.bannerVisible, false);
887
+ assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, 'no "none\'s" copy');
888
+ });
889
+ Suite("a wall attributed to 'none' still falls back to the vendor-agnostic block", () => {
890
+ // The latch's own filter, unchanged — the boundary check is a second line of
891
+ // defence, not a replacement for it.
892
+ const el = element();
893
+ reachable(el, 'anthropic');
894
+ latch(el, 'budget-exhausted', undefined, 'none');
895
+ assert.equal(el.blockedVendors, []);
896
+ assert.is(el.blocked, true, 'fail-safe: block everything rather than name a sentinel');
897
+ });
898
+ // ── The banner's class attribute ────────────────────────────────────────────
899
+ Suite('the banner emits no empty class slots', () => {
900
+ // `class="blocked-banner "` — two interpolations, neither applicable — is
901
+ // harmless to a browser and noise in every DOM snapshot and innerHTML
902
+ // assertion a host writes against this element.
903
+ const el = element();
904
+ assert.is(blockedBannerClasses(el), 'blocked-banner', 'unblocked: no modifiers, no padding');
905
+ reachable(el, 'anthropic', 'gemini');
906
+ el.setVendorBlocked('anthropic', true);
907
+ assert.is(blockedBannerClasses(el), 'blocked-banner is-visible is-partial', 'partial wall');
908
+ el.setVendorBlocked('gemini', true);
909
+ assert.is(blockedBannerClasses(el), 'blocked-banner is-visible', 'fully walled');
910
+ });
911
+ // ── The composer's accessible description ───────────────────────────────────
912
+ Suite('the composer points aria-describedby at the banner whenever it says anything', () => {
913
+ // The PARTIAL state is the one this feature exists to create, and it was the
914
+ // one state with no accessible explanation at all: `aria-disabled` and
915
+ // `aria-label` bind on `blocked`, which is false there, and a live composer
916
+ // keeps the host's own placeholder. So a screen-reader user reached the textarea
917
+ // and heard "Type a message" with nothing to say the next turn might be refused.
918
+ const el = element();
919
+ assert.is(composerDescribedBy(el), null, 'unblocked: no description, attribute omitted');
920
+ reachable(el, 'anthropic', 'gemini');
921
+ el.setVendorBlocked('anthropic', true);
922
+ assert.is(el.blocked, false, 'partial: the composer is still usable…');
923
+ assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, '…and now describable');
924
+ el.setVendorBlocked('gemini', true);
925
+ assert.is(el.blocked, true);
926
+ assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, 'fully blocked: still described');
927
+ });
928
+ Suite('the description follows the banner, not the block', () => {
929
+ // Keyed on `bannerVisible`, so a wall the registry has moved away from drops
930
+ // the description at the same moment it drops the banner — otherwise the
931
+ // controls would point at an empty region.
932
+ const el = element();
933
+ reachable(el, 'anthropic');
934
+ el.setVendorBlocked('anthropic', true);
935
+ assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID);
936
+ reachable(el, 'gemini');
937
+ assert.is(el.bannerVisible, false, 'nothing reachable is walled');
938
+ assert.is(composerDescribedBy(el), null, 'so there is nothing to describe the composer with');
939
+ });
940
+ // ── Suggestions ─────────────────────────────────────────────────────────────
941
+ Suite('suggestions resolve against the registry DEFAULT, not the whole gate', () => {
942
+ // The one question in this feature that CAN be answered exactly: both
943
+ // suggestion paths call `providerRegistry.default()`.
944
+ const el = element();
945
+ reachable(el, 'anthropic', 'gemini'); // anthropic is the default
946
+ el.setVendorBlocked('gemini', true);
947
+ assert.is(el.suggestionsBlocked, false, 'the default vendor still has headroom');
948
+ el.setVendorBlocked('gemini', false);
949
+ el.setVendorBlocked('anthropic', true);
950
+ assert.is(el.suggestionsBlocked, true, 'the default vendor is walled');
951
+ assert.is(el.blocked, false, '…while the composer stays live — the two gates differ');
952
+ });
953
+ Suite('fetchSuggestions is skipped when the default vendor is walled', () => {
954
+ const el = element();
955
+ reachable(el, 'anthropic', 'gemini');
956
+ let calls = 0;
957
+ el.driver = {
958
+ isBusy: () => false,
959
+ getSuggestions: () => __awaiter(void 0, void 0, void 0, function* () {
960
+ calls += 1;
961
+ return [];
962
+ }),
963
+ };
964
+ el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } };
965
+ el.setVendorBlocked('anthropic', true);
966
+ el.fetchSuggestions();
967
+ assert.is(calls, 0, 'no guaranteed-402 request against the default vendor');
968
+ });
969
+ Suite.run();