@genesislcap/ai-assistant 15.6.2 → 15.7.0

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 (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +613 -6
  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 +254 -10
  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/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +394 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +11 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +969 -0
  37. package/dist/esm/main/main.js +704 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +145 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1316 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -22,7 +22,7 @@
22
22
  // =============================================================================
23
23
  var FoundationAiAssistant_1;
24
24
  import { __awaiter, __decorate, __rest } from "tslib";
25
- import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
25
+ import { AIProviderRegistry, BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
26
26
  import { showNotificationDialog } from '@genesislcap/foundation-notifications';
27
27
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
28
28
  import { customElement, DOM, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
@@ -110,6 +110,84 @@ const COMPOSER_MIN_HEIGHT_PX = 48;
110
110
  const COMPOSER_MAX_HEIGHT_PX = 400;
111
111
  /** Keep at least this much of the message list visible while growing the composer. */
112
112
  const COMPOSER_MIN_MESSAGES_PX = 80;
113
+ /**
114
+ * Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
115
+ * fixed and short, and NOT host-overridable — the banner directly above it
116
+ * carries the detail, and that one IS overridable via `setBlocked(true, reason)`.
117
+ * The banner's own default copy is `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, read
118
+ * straight from `foundation-ai` so the banner and the driver's transcript bubble
119
+ * cannot drift apart.
120
+ */
121
+ const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
122
+ /**
123
+ * Closing sentence for a wall the user cannot route around. Singular/plural
124
+ * variants because the banner names one vendor or several.
125
+ */
126
+ const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
127
+ const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
128
+ /**
129
+ * Banner copy built from the figures a 402 actually reported, or `undefined`
130
+ * when the proxy sent none.
131
+ *
132
+ * `undefined` is the meaningful return, not a fallback: paired with the
133
+ * slice's "a re-latch without a reason keeps the existing explanation" rule, it
134
+ * makes a figureless driver latch leave a host-supplied reason intact rather
135
+ * than blanking it back to the default at the exact moment the wall is hit.
136
+ *
137
+ * Exported for the unit test that pins the formatting; not part of the element
138
+ * API.
139
+ *
140
+ * @internal
141
+ */
142
+ export function formatBlockedReason(budget, vendor) {
143
+ if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
144
+ return undefined;
145
+ const money = (v) => (v == null ? 'an unknown amount' : `$${v.toFixed(2)}`);
146
+ const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
147
+ // A per-vendor statement carries NO action clause, because the right action is
148
+ // not knowable at latch time: whether "switch vendor" or "contact your
149
+ // administrator" is the honest advice depends on whether any OTHER vendor still
150
+ // has headroom, which only the composing getter can see. The vendor-agnostic
151
+ // form keeps its historical trailing sentence — nothing composes onto it.
152
+ return vendor
153
+ ? `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`
154
+ : `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
155
+ }
156
+ /**
157
+ * Human-readable name for a vendor, falling back to its raw type.
158
+ *
159
+ * `'none'` needs no special case: it is the "no provider configured" sentinel,
160
+ * it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
161
+ * refuses to attribute a wall to it, and
162
+ * {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
163
+ * nothing can put it in front of this function. The `?? vendor` fallback is for a
164
+ * vendor type added to `AIProviderType` without a label, which would print the
165
+ * type rather than nothing.
166
+ */
167
+ function vendorDisplayName(vendor) {
168
+ var _a;
169
+ return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
170
+ }
171
+ /**
172
+ * `a`, `a and b`, `a, b and c`.
173
+ *
174
+ * The general joiner is kept rather than hardcoded to a pair because the list
175
+ * is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
176
+ * the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
177
+ * 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
178
+ * not require an edit here. Its upper bound is not an assertion in a comment — it is
179
+ * the key count of `VENDOR_LABELS`, since every element of every list passed here
180
+ * comes from `blockedVendors` or `reachableVendors`, both of which hold
181
+ * `AIProviderType` values, and the type has five members of which `'none'` is
182
+ * excluded from both.
183
+ */
184
+ function formatVendorList(vendors) {
185
+ var _a;
186
+ const names = vendors.map(vendorDisplayName);
187
+ if (names.length <= 1)
188
+ return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
189
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
190
+ }
113
191
  // Register supporting components when the main component module is imported.
114
192
  avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
115
193
  /**
@@ -394,14 +472,25 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
394
472
  }
395
473
  /**
396
474
  * True when a new send must be refused: a turn is running (`busy`), a page-reload
397
- * restore is loading history that would clobber it (`restoring`), or a manual
398
- * compaction is rewriting history (`compacting`). Mirrors the composer's
399
- * `?disabled` gate so a **programmatic** `send`/`submitMessage` (e.g. a host's
400
- * custom input while the built-in composer is hidden) can't slip past it and have
401
- * its message wiped when the restored/compacted history lands (GENC-1351 §6).
475
+ * restore is loading history that would clobber it (`restoring`), a manual
476
+ * compaction is rewriting history (`compacting`), or a backend condition has
477
+ * locked the assistant outright (`blocked` e.g. an exhausted AI budget).
478
+ * Mirrors the composer's `?disabled` gate so a **programmatic**
479
+ * `send`/`submitMessage` (e.g. a host's custom input while the built-in composer
480
+ * is hidden) can't slip past it and have its message wiped when the
481
+ * restored/compacted history lands (GENC-1351 §6).
402
482
  */
403
483
  get sendBlocked() {
404
- return this.busy || this.restoring || this.compacting;
484
+ return this.busy || this.restoring || this.compacting || this.blocked;
485
+ }
486
+ /**
487
+ * Why a programmatic send was refused, for `submitMessage`'s `errors`. A
488
+ * backend block is called out distinctly because — unlike the transient
489
+ * "busy" cases — waiting and retrying will never clear it, and a caller
490
+ * looping on "Assistant is busy" would spin forever.
491
+ */
492
+ sendRefusalReason() {
493
+ return this.blocked ? this.effectiveBlockedReason : 'Assistant is busy';
405
494
  }
406
495
  /**
407
496
  * Re-runs `agentsChanged` if the live `agents` array no longer matches the
@@ -590,6 +679,538 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
590
679
  var _a;
591
680
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setRestoring(value);
592
681
  }
682
+ /**
683
+ * Whether the assistant is blocked by a backend condition and cannot send —
684
+ * today, an exhausted AI-spend budget (GENC-1464). While true the composer is
685
+ * disabled, `send`/`submitMessage` refuse, suggestions stop being fetched, and
686
+ * a persistent banner (`part="blocked-banner"`) sits above the composer
687
+ * explaining why. The transcript stays visible and scrollable throughout —
688
+ * unlike `compacting` and `restoring`, this does not replace the conversation.
689
+ *
690
+ * **Latched.** Nothing in the element clears it: not a new turn, not "Clear"
691
+ * / "New chat", not a pop-in/out. It stays set until the host writes
692
+ * `false` — because nothing the user can do inside the assistant refills a
693
+ * budget. Hosts typically set it from their own pre-flight budget check on
694
+ * mount, and clear it once a later check shows headroom again.
695
+ *
696
+ * The driver also latches it automatically when a turn ends with the
697
+ * `'budget-exhausted'` failure reason — off the `tool-loop-end` activity bus
698
+ * (which also covers a sub-agent wall, a turn this element did not start, and
699
+ * an element swapped in mid-turn) and off the driver's return value (which
700
+ * covers a wall hit during multi-agent classification, where no tool loop ran
701
+ * and so no bus event fires). So a host that does no pre-flight at all still
702
+ * gets a correct locked UI the moment the first 402 lands. The bus topic is
703
+ * tab-scoped, so a wall hit in a popped-out window latches the main window
704
+ * too — correct for a shared spend cap.
705
+ *
706
+ * **Lifetime — read this before relying on it as your source of truth.** The
707
+ * latch is in-memory and per-`stateKey`:
708
+ *
709
+ * - It does **not** survive `switchSession`. Switching away tears the outgoing
710
+ * session's store down entirely, so switching back yields a fresh, unblocked
711
+ * store.
712
+ * - It does **not** survive a page reload. It is deliberately absent from the
713
+ * persisted session snapshot: the snapshot is long-lived, so a persisted
714
+ * latch would outlive an out-of-band budget raise with no in-element way to
715
+ * clear it — a stale lock is worse than re-deriving the state.
716
+ *
717
+ * The durable source of truth is therefore the **host's pre-flight**, which
718
+ * should run on mount and on every session switch. See
719
+ * `docs/migration-GENC-1464.md` §"How to adopt", Option B.
720
+ *
721
+ * @beta
722
+ */
723
+ get blocked() {
724
+ var _a;
725
+ const session = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
726
+ if (!session)
727
+ return false;
728
+ if (session.blocked)
729
+ return true;
730
+ // Byte-identical to the old stored read for every host that never adopts the
731
+ // per-vendor API — which is what makes this derivation non-breaking.
732
+ if (session.blockedVendors.length === 0)
733
+ return false;
734
+ // Unknown reachability (statuses not loaded yet, or providers that report no
735
+ // status) fails SAFE — any wall blocks everything, exactly today's semantics —
736
+ // and needs no clause of its own: `every` on the empty set is vacuously true.
737
+ return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
738
+ }
739
+ set blocked(value) {
740
+ var _a;
741
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
742
+ }
743
+ /**
744
+ * The distinct vendors the registry can currently reach, from the provider
745
+ * statuses this element already loads on connect and refreshes on every
746
+ * observable-registry change.
747
+ *
748
+ * This is the element's answer to "which vendor would the next turn use" — a
749
+ * question that has **no** correct answer and deliberately gets no API. The
750
+ * provider is resolved per turn AND per agent: `activeProviderInput` may be an
751
+ * async function of the turn's context, an orchestrated turn picks its agent
752
+ * with an LLM `classify()` call, and sub-agents resolve their own providers.
753
+ * Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
754
+ * multi-agent hosts per-vendor budgets exist for.
755
+ *
756
+ * Asking instead which vendors are REACHABLE is answerable, synchronous, and
757
+ * free — and it is enough: the composer must stay live while any reachable
758
+ * vendor has headroom, and must lock when none does. It also makes vendor-switch
759
+ * recovery a derivation rather than a mutation: a host that swaps its registry
760
+ * to another vendor fires the observable, the statuses reload, the walled vendor
761
+ * drops out of this set, and `blocked` goes false with every latch left intact.
762
+ *
763
+ * @beta
764
+ */
765
+ get reachableVendors() {
766
+ var _a, _b;
767
+ const seen = new Set();
768
+ for (const entry of this.providerStatuses) {
769
+ const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
770
+ if (!provider || provider === 'none')
771
+ continue;
772
+ // Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
773
+ // `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
774
+ // implement `getStatus()` only. Counting a chat-less provider here made
775
+ // every consumer wrong at once: it was headroom for the `blocked`
776
+ // derivation (so `otherVendorAvailable: false` failed to lock the
777
+ // composer and each send burned another doomed 402), and it was a switch
778
+ // target for the banner ("Switch to Chrome to keep going" toward a
779
+ // provider that cannot chat).
780
+ //
781
+ // The gate is CAPABILITY, not vendor identity, so a host's custom
782
+ // chat-capable OpenAI provider still counts — the same distinction
783
+ // `validate-providers.ts` already enforces by throwing. And it drops a
784
+ // vendor only on positive evidence: statuses normally come from
785
+ // `providerRegistry.listStatuses()`, whose `name` always resolves, but a
786
+ // host (or test) that assigns `providerStatuses` directly may have no
787
+ // matching registry entry, and an unresolvable name must not silently
788
+ // erase a vendor the status layer vouched for.
789
+ const registered = (_b = this.providerRegistry) === null || _b === void 0 ? void 0 : _b.get(entry.name);
790
+ if (registered && typeof registered.chat !== 'function')
791
+ continue;
792
+ seen.add(provider);
793
+ }
794
+ return [...seen];
795
+ }
796
+ /**
797
+ * Vendors currently walled by the AI-spend budget, in the order they were
798
+ * walled. Empty for a host that never adopts the per-vendor API.
799
+ *
800
+ * May include a vendor this registry cannot reach — either because the registry
801
+ * moved on after the wall was latched, or because a 402's
802
+ * `otherVendorAvailable: false` walls every vendor the proxy meters (which is
803
+ * what that verdict is a statement about; see
804
+ * {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
805
+ * internally — {@link FoundationAiAssistant.blocked} asks only about the
806
+ * reachable set, and the banner reads the reachability-filtered
807
+ * {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
808
+ * this list itself should filter it against its own registry.
809
+ *
810
+ * Read-only on purpose: a settable array would let a host write a partial list
811
+ * and silently orphan the per-vendor banner copy.
812
+ * {@link FoundationAiAssistant.setVendorBlocked} is the write path.
813
+ *
814
+ * @beta
815
+ */
816
+ get blockedVendors() {
817
+ var _a, _b;
818
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendors) !== null && _b !== void 0 ? _b : [];
819
+ }
820
+ /**
821
+ * Whether this specific vendor's budget is walled — regardless of whether any
822
+ * other vendor still has headroom.
823
+ *
824
+ * @beta
825
+ */
826
+ isVendorBlocked(vendor) {
827
+ return this.blockedVendors.includes(vendor);
828
+ }
829
+ /**
830
+ * Wall (or release) one vendor's budget, optionally with banner copy for it.
831
+ * The per-vendor mirror of {@link FoundationAiAssistant.setBlocked}.
832
+ *
833
+ * Walling a vendor does **not** on its own disable the composer: while another
834
+ * reachable vendor has headroom the assistant stays usable and the banner tells
835
+ * the user to switch. Only when every reachable vendor is walled does `blocked`
836
+ * become true.
837
+ *
838
+ * `reason` is composed into the banner sentence for that vendor, **however many
839
+ * vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
840
+ * It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
841
+ * is the override. Omitting it keeps whatever explanation that vendor already
842
+ * carried, so a driver latch landing after a host one cannot blank it.
843
+ *
844
+ * `'none'` is rejected with a warning rather than accepted: it is the "no
845
+ * provider configured" sentinel, not a vendor. Latching it would put the raw
846
+ * sentinel in the banner ("none's AI usage limit is reached") and could never
847
+ * be undone by derivation, because `'none'` never appears in
848
+ * {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
849
+ * become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
850
+ * for a vendor-agnostic block.
851
+ *
852
+ * @beta
853
+ */
854
+ /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
855
+ isVendorSwept(vendor) {
856
+ var _a, _b;
857
+ return ((_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
858
+ }
859
+ setVendorBlocked(vendor, blocked, reason) {
860
+ var _a;
861
+ if (vendor === 'none') {
862
+ logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
863
+ 'ignoring. Use setBlocked() for a vendor-agnostic block.');
864
+ return;
865
+ }
866
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
867
+ }
868
+ /**
869
+ * The walled vendors the user can still be routed to — i.e.
870
+ * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
871
+ * which is the only set the banner may name.
872
+ *
873
+ * A wall the registry can no longer reach is not news: it cannot be hit, and
874
+ * naming it puts a vendor in front of the user that is not theirs. The concrete
875
+ * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
876
+ * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
877
+ * usage limits are reached for Anthropic and Gemini" to a user who has never
878
+ * had an Anthropic key. The spurious second name also flips the copy onto the
879
+ * plural branch, so even the closing sentence is wrong.
880
+ *
881
+ * When nothing is reachable YET (the statuses are still loading — the exact
882
+ * window the pre-flight and the cross-tab bus latch into), the fallback keeps
883
+ * every wall for LOCKING (`blocked` still derives true) but names only the
884
+ * vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
885
+ * sweep added. A refusal is a fact about a vendor this user just used; a sweep
886
+ * entry is the proxy's headroom verdict about a vendor the host may not even
887
+ * ship — naming it here reproduced the exact failure above from the other
888
+ * direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
889
+ * and flipped the copy onto the plural branch with it. Self-corrects when the
890
+ * statuses land: from then on reachability, not provenance, decides.
891
+ *
892
+ * @internal
893
+ */
894
+ get relevantBlockedVendors() {
895
+ var _a, _b;
896
+ const walled = this.blockedVendors;
897
+ const reachable = this.reachableVendors;
898
+ if (reachable.length === 0) {
899
+ const swept = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : [];
900
+ return walled.filter((v) => !swept.includes(v));
901
+ }
902
+ return walled.filter((v) => reachable.includes(v));
903
+ }
904
+ /**
905
+ * Whether the blocked banner has anything to say — a vendor-agnostic block, OR
906
+ * at least one walled vendor the registry can still reach. Broader than
907
+ * {@link FoundationAiAssistant.blocked} on purpose: partial exhaustion leaves
908
+ * the composer live but still needs to be announced, because the next turn may
909
+ * route to the walled vendor and fail.
910
+ *
911
+ * Reads the reachability-filtered list for the same reason the copy does — a
912
+ * host that has swapped its registry away from the walled vendor has nothing
913
+ * left to announce, and would otherwise get a banner that falls through to the
914
+ * generic default copy over a perfectly usable composer.
915
+ *
916
+ * @internal
917
+ */
918
+ get bannerVisible() {
919
+ return this.blocked || this.relevantBlockedVendors.length > 0;
920
+ }
921
+ /**
922
+ * Whether a suggestions fetch would hit a wall.
923
+ *
924
+ * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
925
+ * go to the registry **default** and never to a per-agent override
926
+ * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
927
+ * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
928
+ * suggestions call would use is knowable, and this is the one place in the
929
+ * feature where that is true.
930
+ *
931
+ * Same rationale as the guard it replaces: a doomed suggestions call burns a
932
+ * `suggestions.failed` meta event and parks a raw transport error in
933
+ * `suggestionsState` for hosts to find.
934
+ *
935
+ * @internal
936
+ */
937
+ get suggestionsBlocked() {
938
+ var _a, _b;
939
+ if (this.blocked)
940
+ return true;
941
+ const defaultVendor = (_b = (_a = this.providerStatuses.find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
942
+ return !!defaultVendor && defaultVendor !== 'none' && this.isVendorBlocked(defaultVendor);
943
+ }
944
+ /**
945
+ * Explanation shown in the blocked banner, or `null` for the element's default
946
+ * copy. Only meaningful while {@link FoundationAiAssistant.blocked} is true;
947
+ * setting `blocked = false` clears it.
948
+ *
949
+ * Writable, and symmetric with every other store-backed accessor on this
950
+ * class: writing it re-latches with the CURRENT `blocked` value, so it changes
951
+ * the copy without disturbing the flag. Assigning `null` clears the
952
+ * explanation while staying blocked (the banner falls back to the default
953
+ * copy). {@link FoundationAiAssistant.setBlocked} remains the way to write
954
+ * both in one atomic action.
955
+ *
956
+ * @beta
957
+ */
958
+ get blockedReason() {
959
+ var _a, _b;
960
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedReason) !== null && _b !== void 0 ? _b : null;
961
+ }
962
+ set blockedReason(value) {
963
+ var _a;
964
+ // Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
965
+ // is derived now, so re-latching with it would pass `false` for a partially
966
+ // walled session and clear every per-vendor latch as a side effect of setting
967
+ // a string.
968
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
969
+ }
970
+ /**
971
+ * Latch the backend block off a turn outcome. The single decision point for
972
+ * both latch sites (the `tool-loop-end` bus subscription and `send()`'s
973
+ * return-value check), so the rule cannot diverge between them.
974
+ *
975
+ * Idempotent and one-way **per vendor**: it never unblocks, and it never
976
+ * re-writes an existing wall. That guard is load-bearing, not just an
977
+ * optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
978
+ * `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
979
+ * migration guide's Option C recommends) sets its own detailed reason and the
980
+ * return-value latch would otherwise land a moment later and blank it.
981
+ *
982
+ * The guard being per-vendor rather than global is the whole difference. A
983
+ * single global "already blocked, do nothing" would swallow a second vendor's
984
+ * wall — so a session walled on Anthropic could never record that Gemini went
985
+ * too, and under a mixed registry an Anthropic-only wall would lock a composer
986
+ * that Gemini could still serve.
987
+ *
988
+ * **Which field is authoritative**, in order:
989
+ *
990
+ * 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
991
+ * so it can never be stale. But it is a static per-transport string, so a
992
+ * white-labelled or multiplexing gateway fronting several upstreams leaves
993
+ * it unclaimed by any vendor.
994
+ * 2. `budget.vendor` — which the driver resolved from the label where it could,
995
+ * and otherwise from the proxy's own `vendor` field on the 402. It is
996
+ * therefore NOT simply `vendorLabel` normalised, and the two can disagree;
997
+ * that fallback is the only attribution on offer for the gateway case above.
998
+ * 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
999
+ * last-resolved provider. Last resort because it CAN be stale: an
1000
+ * orchestrated turn classifies against the registry default, a provider the
1001
+ * chat driver may never have resolved, so its last-resolved provider there is
1002
+ * the previous turn's vendor or nothing at all.
1003
+ *
1004
+ * A wall that names no recognised vendor falls back to the vendor-agnostic
1005
+ * block — fail-safe, and identical to the pre-per-vendor behaviour.
1006
+ *
1007
+ * **`otherVendorAvailable: false` is server-known truth and outranks every
1008
+ * inference made here.** The proxy meters the pots, so only it can say whether
1009
+ * anything else has headroom; this element can only observe which vendors the
1010
+ * registry can REACH, which says nothing about their remaining spend. So when
1011
+ * the proxy says no, every other *budgeted* vendor is walled in the same pass.
1012
+ * That is what makes `blocked` derive true — locking the composer on the first
1013
+ * response instead of after a second doomed turn — and what empties the "free"
1014
+ * set the banner would otherwise have advised switching to.
1015
+ *
1016
+ * The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
1017
+ * {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
1018
+ * asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
1019
+ * not await it), so a wall landing before the statuses resolve would have swept
1020
+ * an empty set — silently discarding the one fact on the 402 the client cannot
1021
+ * re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
1022
+ * the verdict effectively un-latch: the single wall derived `blocked` only
1023
+ * while the reachable set was empty, so the moment the statuses landed the
1024
+ * composer came back to life against a budget the proxy had already said was
1025
+ * gone. Sweeping the metered vendor list instead makes the verdict independent
1026
+ * of load order and of registry membership, which is exactly what it is: a
1027
+ * statement about the proxy's pots, not about this user's registry.
1028
+ *
1029
+ * Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
1030
+ * whether every REACHABLE vendor is walled, and the banner reads
1031
+ * {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
1032
+ * back down to the reachable set before naming any of them.
1033
+ *
1034
+ * One boundary remains on the sweep: it walls only vendors the proxy meters,
1035
+ * enforced structurally by iterating `BUDGETED_VENDORS` itself rather than
1036
+ * filtering a wider set through a predicate. Chrome runs on-device with no pot
1037
+ * to exhaust, so "no other vendor has budget" is not a statement about it, and
1038
+ * "Switch to Chrome to keep going." stays honest advice.
1039
+ *
1040
+ * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
1041
+ * both halves of that matter. Last, so the vendor that actually refused this
1042
+ * turn heads the wall order the multi-vendor banner reads. Outside the guard,
1043
+ * because the verdict can arrive on a LATER 402 for a vendor that is already
1044
+ * walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
1045
+ * it again and reports that Gemini has since gone too — and an early return
1046
+ * would drop exactly the news the second turn was there to deliver.
1047
+ *
1048
+ * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
1049
+ * @param ref - session store to write through; defaults to the live one. `send()`
1050
+ * passes the ref it captured before its awaits, since a lifecycle event during the
1051
+ * turn may already have cleared `_sessionRef`.
1052
+ * @param budget - what the 402 reported: figures when the proxy sent any, the
1053
+ * refusing vendor, and its `otherVendorAvailable` verdict.
1054
+ * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
1055
+ * when the budget payload names none.
1056
+ *
1057
+ * @internal
1058
+ */
1059
+ latchBlockedFrom(reason, ref = this._sessionRef, budget, vendorHint) {
1060
+ var _a, _b;
1061
+ if (reason !== 'budget-exhausted')
1062
+ return;
1063
+ const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
1064
+ if (!vendor || vendor === 'none') {
1065
+ if (!this.blocked) {
1066
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
1067
+ }
1068
+ }
1069
+ else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
1070
+ // The idempotence guard protects a host-supplied reason from being
1071
+ // overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
1072
+ // one (the sweep writes no reason, and any host call clears the swept
1073
+ // mark), so upgrading it on a first-hand refusal clobbers nothing and
1074
+ // gains the vendor its own figures plus a place in the early-window
1075
+ // banner (see `relevantBlockedVendors`).
1076
+ if (this.reachableVendors.length === 0) {
1077
+ // Not fatal — `blocked` still derives to true, so the UI is correct — but
1078
+ // the host gets no per-vendor benefit and would otherwise have no signal.
1079
+ logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
1080
+ 'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
1081
+ 'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
1082
+ 'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
1083
+ }
1084
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
1085
+ vendor,
1086
+ blocked: true,
1087
+ reason: formatBlockedReason(budget, vendor),
1088
+ });
1089
+ }
1090
+ // Last, so the vendor that actually refused this turn heads the wall order the
1091
+ // multi-vendor banner reads — and unconditionally, NOT under the per-vendor
1092
+ // idempotence guard above, because the verdict can arrive on a later 402 for a
1093
+ // vendor that is already walled.
1094
+ if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
1095
+ return;
1096
+ // Over the metered vendor list, NOT the reachable set: the statuses load
1097
+ // asynchronously, so a reachability-scoped sweep dropped the verdict entirely
1098
+ // when the wall beat them home. `relevantBlockedVendors` decides what the
1099
+ // banner may NAME — reachability-filtered when the statuses are in, and
1100
+ // refusals-only while they are not, which is what `swept: true` records.
1101
+ for (const other of BUDGETED_VENDORS) {
1102
+ if (other === vendor || this.isVendorBlocked(other))
1103
+ continue;
1104
+ // No reason: the slice's "an omitted reason keeps the existing explanation"
1105
+ // rule applies, and there are no figures for this vendor — only the proxy's
1106
+ // word that it has nothing left. `swept: true` records exactly that
1107
+ // provenance, so the unknown-reachability banner can decline to name it.
1108
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Block or unblock the assistant, optionally with custom banner copy.
1113
+ * Equivalent to the {@link FoundationAiAssistant.blocked} setter, plus the
1114
+ * reason in one atomic write.
1115
+ *
1116
+ * Blocking writes the **vendor-agnostic** block — what a host with one budget
1117
+ * pot means — and does not populate
1118
+ * {@link FoundationAiAssistant.blockedVendors}. Unblocking clears the
1119
+ * per-vendor walls as well, so a host asserting "the wall is gone" after its
1120
+ * own pre-flight cannot be silently overruled by a driver latch it never saw.
1121
+ *
1122
+ * @beta
1123
+ */
1124
+ setBlocked(blocked, reason) {
1125
+ var _a;
1126
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
1127
+ }
1128
+ /**
1129
+ * Copy shown in the blocked banner, in ascending specificity:
1130
+ *
1131
+ * 1. the host's `blockedReason` — a whole-banner override, unchanged;
1132
+ * 2. copy composed from the walled vendors (their per-vendor reasons when set,
1133
+ * otherwise their names) plus the action that is actually available;
1134
+ * 3. the default budget-exhaustion message.
1135
+ *
1136
+ * The action clause is computed here rather than stored, because whether
1137
+ * "switch vendor" or "contact your administrator" is honest depends on whether
1138
+ * another reachable vendor still has headroom — which changes when the registry
1139
+ * changes, long after the wall was latched. Telling a user to switch to a
1140
+ * vendor that is also exhausted is worse than saying nothing.
1141
+ *
1142
+ * The `free` set is a client-side inference — "reachable and not known to be
1143
+ * walled" — and it is only ever allowed to be one. Where the proxy has told us
1144
+ * otherwise (`otherVendorAvailable: false`) the latch has already walled those
1145
+ * vendors, so they are not in `free` here and no "switch to X" clause can be
1146
+ * composed from them. Both lists are filtered by reachability, so the copy
1147
+ * names only vendors this user's registry can actually route to.
1148
+ *
1149
+ * **Per-vendor copy is composed however many vendors are walled**, which is
1150
+ * what {@link FoundationAiAssistant.setVendorBlocked} and
1151
+ * `docs/migration-GENC-1464.md` both promise. It previously reached only the
1152
+ * single-vendor branch, so a host that had carefully set copy for each of its
1153
+ * vendors watched all of it vanish the moment a second one walled — replaced by
1154
+ * generic copy, at the exact moment the situation got worse.
1155
+ *
1156
+ * The compact "AI usage limits are reached for A and B." list is kept for the
1157
+ * case it was written for: NO walled vendor carries copy, so a statement per
1158
+ * vendor would just repeat one boilerplate sentence per name. As soon as any of
1159
+ * them does carry copy, the branch lists one statement per vendor instead —
1160
+ * falling back to that same boilerplate for the ones that have none, so the
1161
+ * sentence set stays complete rather than silently naming a subset.
1162
+ *
1163
+ * @internal
1164
+ */
1165
+ get effectiveBlockedReason() {
1166
+ var _a;
1167
+ if (this.blockedReason)
1168
+ return this.blockedReason;
1169
+ const walled = this.relevantBlockedVendors;
1170
+ if (walled.length === 0)
1171
+ return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1172
+ const free = this.reachableVendors.filter((v) => !walled.includes(v));
1173
+ const action = free.length
1174
+ ? `Switch to ${formatVendorList(free)} to keep going.`
1175
+ : walled.length > 1
1176
+ ? RAISE_LIMITS_ACTION
1177
+ : RAISE_LIMIT_ACTION;
1178
+ const reasons = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendorReasons;
1179
+ const statementFor = (v) => { var _a; return (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : `${vendorDisplayName(v)}'s AI usage limit is reached.`; };
1180
+ if (walled.length === 1)
1181
+ return `${statementFor(walled[0])} ${action}`;
1182
+ // Several walled vendors carry several sets of figures, which do not fit one
1183
+ // sentence. With no per-vendor copy to preserve, name them in a list rather
1184
+ // than repeat the same boilerplate sentence per vendor.
1185
+ if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
1186
+ return `AI usage limits are reached for ${formatVendorList(walled)}. ${action}`;
1187
+ }
1188
+ return `${walled.map(statementFor).join(' ')} ${action}`;
1189
+ }
1190
+ /**
1191
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
1192
+ *
1193
+ * A host-set `blockedReason` still wins, so a white-labelled host does not read
1194
+ * its own explanation in the banner and the shipped default directly below it —
1195
+ * that is the whole reason the driver takes this at all.
1196
+ *
1197
+ * What it deliberately is NOT is
1198
+ * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1199
+ * composes advice — `"…Switch to Gemini to keep going."` — which is true only
1200
+ * while that vendor has headroom, and the transcript is a permanent record. The
1201
+ * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1202
+ * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1203
+ * freeze that sentence into every later turn's bubble. Reading the latch's own
1204
+ * copy instead is structurally incapable of carrying the advice: the only
1205
+ * writer of `blockedReason` is the vendor-agnostic branch of
1206
+ * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1207
+ * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1208
+ * banner alone.
1209
+ */
1210
+ get transcriptBudgetExhaustedMessage() {
1211
+ var _a;
1212
+ return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1213
+ }
593
1214
  /**
594
1215
  * Name of the agent the user has pinned via the agent picker. `null` means
595
1216
  * automatic routing (Auto). Persisted on the session store, so it survives
@@ -1353,6 +1974,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1353
1974
  maxFoldOperations: agent.maxFoldOperations,
1354
1975
  maxTurnSnapshots: agent.maxTurnSnapshots,
1355
1976
  activityBus: agenticActivityBus,
1977
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1356
1978
  });
1357
1979
  }
1358
1980
  return new ChatDriver(this.providerRegistry, {
@@ -1361,6 +1983,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1361
1983
  maxTurnSnapshots: agent.maxTurnSnapshots,
1362
1984
  sessionKey: (_c = this.getStateKey()) !== null && _c !== void 0 ? _c : '',
1363
1985
  activityBus: agenticActivityBus,
1986
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1364
1987
  });
1365
1988
  }
1366
1989
  /**
@@ -1669,7 +2292,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1669
2292
  });
1670
2293
  }
1671
2294
  connectedCallback() {
1672
- var _a;
2295
+ var _a, _b;
1673
2296
  // Initialise the store reference BEFORE super.connectedCallback() so that
1674
2297
  // the first FAST render has access to the store. The store Proxy calls
1675
2298
  // Observable.track(observableStore, sliceName) whenever a slice is read,
@@ -1707,6 +2330,18 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1707
2330
  });
1708
2331
  }
1709
2332
  this.wireAppSettingsProvider();
2333
+ // Latch the backend block off the activity bus (GENC-1464). This is the primary
2334
+ // seam: it fires for a turn this element did not start, for a sub-agent's
2335
+ // wall (the child inherits the parent's bus and publishes its own
2336
+ // `tool-loop-end`), and it writes through whichever element is connected NOW
2337
+ // rather than the one that happened to call `send()`. Re-subscribed per
2338
+ // connect (docking/popout remounts); balanced in `disconnectedCallback`.
2339
+ (_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
2340
+ this._unsubBudgetLatch = agenticActivityBus.subscribe('tool-loop-end', (d) =>
2341
+ // Pass the figures through: this seam wins the latch for an in-loop wall (the
2342
+ // common case), so dropping them here would render the generic banner copy in
2343
+ // exactly the path `ChatDriverResult.budget` was widened to serve.
2344
+ this.latchBlockedFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
1710
2345
  if (this.messagesEl) {
1711
2346
  this._scrollListener = () => {
1712
2347
  this._userScrolledAway =
@@ -1796,7 +2431,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1796
2431
  this.switchSession(currentKey !== null && currentKey !== void 0 ? currentKey : targetKey, targetKey);
1797
2432
  }
1798
2433
  disconnectedCallback() {
1799
- var _a, _b, _c, _d, _e, _f;
2434
+ var _a, _b, _c, _d, _e, _f, _g;
1800
2435
  window.removeEventListener('pagehide', this._handlePageHide);
1801
2436
  // Write out anything the debounce is still holding — the timer would otherwise fire against
1802
2437
  // a torn-down element, or not at all.
@@ -1814,10 +2449,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1814
2449
  this.unwireDriver();
1815
2450
  (_a = this.unsubBus) === null || _a === void 0 ? void 0 : _a.call(this);
1816
2451
  this.unsubBus = undefined;
1817
- (_b = this.unsubProviderRegistry) === null || _b === void 0 ? void 0 : _b.call(this);
2452
+ (_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
2453
+ this._unsubBudgetLatch = undefined;
2454
+ (_c = this.unsubProviderRegistry) === null || _c === void 0 ? void 0 : _c.call(this);
1818
2455
  this.unsubProviderRegistry = undefined;
1819
2456
  this.unwireAppSettingsProvider();
1820
- (_c = this._executionCompletionUnsub) === null || _c === void 0 ? void 0 : _c.call(this);
2457
+ (_d = this._executionCompletionUnsub) === null || _d === void 0 ? void 0 : _d.call(this);
1821
2458
  this._executionCompletionUnsub = undefined;
1822
2459
  // Note: the restore-core autosave AND diagnostics-append timers live on the
1823
2460
  // shared per-session persister (GENC-1351 §5.10), not here — they must survive a
@@ -1835,12 +2472,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1835
2472
  // the panel re-mounts open.
1836
2473
  this._agentPickerToggle.finalize();
1837
2474
  if (this.settingsOpen) {
1838
- (_d = this.settingsModal) === null || _d === void 0 ? void 0 : _d.close();
2475
+ (_e = this.settingsModal) === null || _e === void 0 ? void 0 : _e.close();
1839
2476
  this.settingsOpen = false;
1840
2477
  }
1841
2478
  this._sessionMenuToggle.finalize();
1842
2479
  // Capture before clearing — `wasBusy` reads the driver, which is dropped below.
1843
- this.logMeta('assistant.disconnected', { wasBusy: (_f = (_e = this.driver) === null || _e === void 0 ? void 0 : _e.isBusy()) !== null && _f !== void 0 ? _f : false });
2480
+ this.logMeta('assistant.disconnected', { wasBusy: (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) !== null && _g !== void 0 ? _g : false });
1844
2481
  // Clear local references only — driver and store stay in their registries.
1845
2482
  this.driver = undefined;
1846
2483
  this._sessionRef = undefined;
@@ -2696,6 +3333,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2696
3333
  * message will go; otherwise falls back to the host-provided placeholder.
2697
3334
  */
2698
3335
  get effectivePlaceholder() {
3336
+ // The block outranks the agent pin: while blocked the composer is disabled
3337
+ // anyway, so naming the pinned agent would only imply a send is possible.
3338
+ if (this.blocked)
3339
+ return BLOCKED_PLACEHOLDER;
2699
3340
  if (this.pinnedAgentName)
2700
3341
  return `Message ${this.pinnedAgentName}...`;
2701
3342
  return this.placeholder;
@@ -3079,6 +3720,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3079
3720
  (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.cancel) === null || _b === void 0 ? void 0 : _b.call(_a);
3080
3721
  }
3081
3722
  handleSuggestionClick(suggestion) {
3723
+ // Belt-and-braces with the template gate that hides the chips while blocked:
3724
+ // `send()` refuses, but `inputValue` would already have been written, leaving
3725
+ // the chip's text stranded in a disabled textarea the user cannot clear.
3726
+ if (this.sendBlocked)
3727
+ return;
3082
3728
  this.inputValue = suggestion;
3083
3729
  this.send();
3084
3730
  }
@@ -3107,7 +3753,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3107
3753
  return __awaiter(this, void 0, void 0, function* () {
3108
3754
  var _a, _b, _c;
3109
3755
  if (this.sendBlocked) {
3110
- return { ok: false, errors: ['Assistant is busy'] };
3756
+ return { ok: false, errors: [this.sendRefusalReason()] };
3111
3757
  }
3112
3758
  let nextAttachments = [];
3113
3759
  if ((_a = input.files) === null || _a === void 0 ? void 0 : _a.length) {
@@ -3120,7 +3766,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3120
3766
  // (Cast widens through TS's narrowing of `this.state` from the earlier
3121
3767
  // early-return; the getter can return a different value across an await.)
3122
3768
  if (this.sendBlocked) {
3123
- return { ok: false, errors: ['Assistant is busy'] };
3769
+ return { ok: false, errors: [this.sendRefusalReason()] };
3124
3770
  }
3125
3771
  if (errors.length) {
3126
3772
  this.attachmentErrors = errors;
@@ -3152,6 +3798,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3152
3798
  if (!this.driver || !suggestionsConfig || suggestionsConfig.behavior === 'never') {
3153
3799
  return;
3154
3800
  }
3801
+ // A guaranteed 402. This runs from the post-turn `finally` (the turn that
3802
+ // just latched), from `connectedCallback` (a pop-in against a blocked store),
3803
+ // and from the `pinnedAgentName` setter — which resets `suggestionsState`
3804
+ // first, defeating the already-fetched guard below. So without this a blocked
3805
+ // session still fires one doomed request per turn, per pop-in and per pin
3806
+ // change, each one burning a `suggestions.failed` meta event and parking a
3807
+ // raw transport-error string in `suggestionsState.message`.
3808
+ //
3809
+ // Resolved against the registry DEFAULT, not the whole composer gate: both
3810
+ // suggestion paths call `providerRegistry.default()`, so under partial
3811
+ // exhaustion this is knowable exactly — and suggestions stay available when
3812
+ // the default vendor is the one with headroom.
3813
+ if (this.suggestionsBlocked) {
3814
+ return;
3815
+ }
3155
3816
  if (suggestionsConfig.behavior === 'initial' && this.messages.length > 0) {
3156
3817
  return;
3157
3818
  }
@@ -3233,7 +3894,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3233
3894
  ? `${input}\n\n*Attached: ${pendingAttachments.map((a) => a.name).join(', ')}*`
3234
3895
  : input;
3235
3896
  try {
3236
- yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
3897
+ const result = yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
3898
+ // Latch the backend block the moment the driver reports it, so the composer
3899
+ // locks without waiting on the host's next pre-flight. The bus subscription
3900
+ // in `connectedCallback` covers most walls; this covers the one it cannot —
3901
+ // a wall hit during multi-agent classification, where no tool loop ran and
3902
+ // so no `tool-loop-end` was published. `latchBlockedFrom` is idempotent, so
3903
+ // the two seams overlapping is harmless.
3904
+ if ((result === null || result === void 0 ? void 0 : result.reason) === 'done') {
3905
+ this.latchBlockedFrom(result.failureReason, capturedSessionRef, result.budget);
3906
+ }
3237
3907
  }
3238
3908
  finally {
3239
3909
  this.stopLoadingTimer();
@@ -3413,6 +4083,24 @@ __decorate([
3413
4083
  __decorate([
3414
4084
  volatile
3415
4085
  ], FoundationAiAssistant.prototype, "busy", null);
4086
+ __decorate([
4087
+ volatile
4088
+ ], FoundationAiAssistant.prototype, "blocked", null);
4089
+ __decorate([
4090
+ volatile
4091
+ ], FoundationAiAssistant.prototype, "reachableVendors", null);
4092
+ __decorate([
4093
+ volatile
4094
+ ], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
4095
+ __decorate([
4096
+ volatile
4097
+ ], FoundationAiAssistant.prototype, "bannerVisible", null);
4098
+ __decorate([
4099
+ volatile
4100
+ ], FoundationAiAssistant.prototype, "suggestionsBlocked", null);
4101
+ __decorate([
4102
+ volatile
4103
+ ], FoundationAiAssistant.prototype, "effectiveBlockedReason", null);
3416
4104
  __decorate([
3417
4105
  volatile
3418
4106
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);