@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.
- package/dist/ai-assistant.api.json +422 -6
- package/dist/ai-assistant.d.ts +654 -19
- package/dist/chat-driver.cjs +285 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +285 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +289 -32
- package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/main/blocked-state.test.d.ts +2 -0
- package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +435 -19
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.styles.test.d.ts +2 -0
- package/dist/dts/main/main.styles.test.d.ts.map +1 -0
- package/dist/dts/main/main.template.d.ts +53 -0
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +6 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +11 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
- package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +263 -21
- package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
- package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
- package/dist/esm/main/blocked-state.test.js +969 -0
- package/dist/esm/main/cost-session-banking.test.js +238 -8
- package/dist/esm/main/main.js +826 -48
- package/dist/esm/main/main.styles.js +72 -0
- package/dist/esm/main/main.styles.test.js +99 -0
- package/dist/esm/main/main.template.js +121 -4
- package/dist/esm/state/ai-assistant-slice.js +145 -7
- package/dist/esm/state/ai-assistant-slice.test.js +138 -1
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/esm/utils/banked-usage-baselines.js +84 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +562 -0
- package/docs/sub_agent.md +20 -3
- package/package.json +17 -17
- package/src/channel/ai-activity-channel.ts +56 -2
- package/src/components/chat-driver/chat-driver.test.ts +549 -0
- package/src/components/chat-driver/chat-driver.ts +324 -14
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
- package/src/components/settings-modal/settings-modal.styles.ts +63 -18
- package/src/components/settings-modal/settings-modal.template.ts +38 -7
- package/src/main/blocked-state.test.ts +1316 -0
- package/src/main/cost-session-banking.test.ts +283 -11
- package/src/main/main.styles.test.ts +130 -0
- package/src/main/main.styles.ts +72 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +827 -42
- package/src/state/ai-assistant-slice.test.ts +215 -0
- package/src/state/ai-assistant-slice.ts +218 -8
- package/src/state/debug-event-log.test.ts +63 -0
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/session-snapshot.test.ts +22 -0
- 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();
|