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