@genesislcap/ai-assistant 15.3.2 → 15.3.3-alpha-4899ddd.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 +603 -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 +236 -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 +384 -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 +143 -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 +10 -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 +913 -0
  37. package/dist/esm/main/main.js +680 -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 +128 -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 +1243 -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 +680 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +181 -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
package/src/main/main.ts CHANGED
@@ -23,12 +23,22 @@
23
23
 
24
24
  import type {
25
25
  AIProviderRegistryStatusEntry,
26
+ AIProviderType,
26
27
  ChatAttachment,
27
28
  ChatConfig,
29
+ ChatDriverResult,
28
30
  ChatInputDuringExecutionMode,
29
31
  ChatMessage,
32
+ TurnFailureReason,
33
+ } from '@genesislcap/foundation-ai';
34
+ import {
35
+ AIProviderRegistry,
36
+ BUDGETED_VENDORS,
37
+ DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
38
+ isObservableAIProviderRegistry,
39
+ VENDOR_LABELS,
40
+ vendorTypeOfLabel,
30
41
  } from '@genesislcap/foundation-ai';
31
- import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
32
42
  import { showNotificationDialog } from '@genesislcap/foundation-notifications';
33
43
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
34
44
  import type { Modal } from '@genesislcap/rapid-design-system';
@@ -196,6 +206,88 @@ const COMPOSER_MAX_HEIGHT_PX = 400;
196
206
  /** Keep at least this much of the message list visible while growing the composer. */
197
207
  const COMPOSER_MIN_MESSAGES_PX = 80;
198
208
 
209
+ /**
210
+ * Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
211
+ * fixed and short, and NOT host-overridable — the banner directly above it
212
+ * carries the detail, and that one IS overridable via `setBlocked(true, reason)`.
213
+ * The banner's own default copy is `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, read
214
+ * straight from `foundation-ai` so the banner and the driver's transcript bubble
215
+ * cannot drift apart.
216
+ */
217
+ const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
218
+
219
+ /**
220
+ * Closing sentence for a wall the user cannot route around. Singular/plural
221
+ * variants because the banner names one vendor or several.
222
+ */
223
+ const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
224
+ const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
225
+
226
+ /**
227
+ * Banner copy built from the figures a 402 actually reported, or `undefined`
228
+ * when the proxy sent none.
229
+ *
230
+ * `undefined` is the meaningful return, not a fallback: paired with the
231
+ * slice's "a re-latch without a reason keeps the existing explanation" rule, it
232
+ * makes a figureless driver latch leave a host-supplied reason intact rather
233
+ * than blanking it back to the default at the exact moment the wall is hit.
234
+ *
235
+ * Exported for the unit test that pins the formatting; not part of the element
236
+ * API.
237
+ *
238
+ * @internal
239
+ */
240
+ export function formatBlockedReason(
241
+ budget?: Extract<ChatDriverResult, { reason: 'done' }>['budget'],
242
+ vendor?: AIProviderType,
243
+ ): string | undefined {
244
+ if (!budget || (budget.budgetUsd == null && budget.spentUsd == null)) return undefined;
245
+ const money = (v?: number) => (v == null ? 'an unknown amount' : `$${v.toFixed(2)}`);
246
+ const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
247
+ // A per-vendor statement carries NO action clause, because the right action is
248
+ // not knowable at latch time: whether "switch vendor" or "contact your
249
+ // administrator" is the honest advice depends on whether any OTHER vendor still
250
+ // has headroom, which only the composing getter can see. The vendor-agnostic
251
+ // form keeps its historical trailing sentence — nothing composes onto it.
252
+ return vendor
253
+ ? `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`
254
+ : `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
255
+ }
256
+
257
+ /**
258
+ * Human-readable name for a vendor, falling back to its raw type.
259
+ *
260
+ * `'none'` needs no special case: it is the "no provider configured" sentinel,
261
+ * it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
262
+ * refuses to attribute a wall to it, and
263
+ * {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
264
+ * nothing can put it in front of this function. The `?? vendor` fallback is for a
265
+ * vendor type added to `AIProviderType` without a label, which would print the
266
+ * type rather than nothing.
267
+ */
268
+ function vendorDisplayName(vendor: AIProviderType): string {
269
+ return (VENDOR_LABELS as Partial<Record<AIProviderType, string>>)[vendor] ?? vendor;
270
+ }
271
+
272
+ /**
273
+ * `a`, `a and b`, `a, b and c`.
274
+ *
275
+ * The general joiner is kept rather than hardcoded to a pair because the list
276
+ * is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
277
+ * the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
278
+ * 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
279
+ * not require an edit here. Its upper bound is not an assertion in a comment — it is
280
+ * the key count of `VENDOR_LABELS`, since every element of every list passed here
281
+ * comes from `blockedVendors` or `reachableVendors`, both of which hold
282
+ * `AIProviderType` values, and the type has five members of which `'none'` is
283
+ * excluded from both.
284
+ */
285
+ function formatVendorList(vendors: readonly AIProviderType[]): string {
286
+ const names = vendors.map(vendorDisplayName);
287
+ if (names.length <= 1) return names[0] ?? '';
288
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
289
+ }
290
+
199
291
  // Register supporting components when the main component module is imported.
200
292
  avoidTreeShaking(
201
293
  AiChatMarkdown,
@@ -410,14 +502,26 @@ export class FoundationAiAssistant extends GenesisElement {
410
502
 
411
503
  /**
412
504
  * True when a new send must be refused: a turn is running (`busy`), a page-reload
413
- * restore is loading history that would clobber it (`restoring`), or a manual
414
- * compaction is rewriting history (`compacting`). Mirrors the composer's
415
- * `?disabled` gate so a **programmatic** `send`/`submitMessage` (e.g. a host's
416
- * custom input while the built-in composer is hidden) can't slip past it and have
417
- * its message wiped when the restored/compacted history lands (GENC-1351 §6).
505
+ * restore is loading history that would clobber it (`restoring`), a manual
506
+ * compaction is rewriting history (`compacting`), or a backend condition has
507
+ * locked the assistant outright (`blocked` — e.g. an exhausted AI budget).
508
+ * Mirrors the composer's `?disabled` gate so a **programmatic**
509
+ * `send`/`submitMessage` (e.g. a host's custom input while the built-in composer
510
+ * is hidden) can't slip past it and have its message wiped when the
511
+ * restored/compacted history lands (GENC-1351 §6).
418
512
  */
419
513
  private get sendBlocked(): boolean {
420
- return this.busy || this.restoring || this.compacting;
514
+ return this.busy || this.restoring || this.compacting || this.blocked;
515
+ }
516
+
517
+ /**
518
+ * Why a programmatic send was refused, for `submitMessage`'s `errors`. A
519
+ * backend block is called out distinctly because — unlike the transient
520
+ * "busy" cases — waiting and retrying will never clear it, and a caller
521
+ * looping on "Assistant is busy" would spin forever.
522
+ */
523
+ private sendRefusalReason(): string {
524
+ return this.blocked ? this.effectiveBlockedReason : 'Assistant is busy';
421
525
  }
422
526
 
423
527
  /**
@@ -589,6 +693,521 @@ export class FoundationAiAssistant extends GenesisElement {
589
693
  this._sessionRef?.actions.aiAssistant.setRestoring(value);
590
694
  }
591
695
 
696
+ /**
697
+ * Whether the assistant is blocked by a backend condition and cannot send —
698
+ * today, an exhausted AI-spend budget (GENC-1464). While true the composer is
699
+ * disabled, `send`/`submitMessage` refuse, suggestions stop being fetched, and
700
+ * a persistent banner (`part="blocked-banner"`) sits above the composer
701
+ * explaining why. The transcript stays visible and scrollable throughout —
702
+ * unlike `compacting` and `restoring`, this does not replace the conversation.
703
+ *
704
+ * **Latched.** Nothing in the element clears it: not a new turn, not "Clear"
705
+ * / "New chat", not a pop-in/out. It stays set until the host writes
706
+ * `false` — because nothing the user can do inside the assistant refills a
707
+ * budget. Hosts typically set it from their own pre-flight budget check on
708
+ * mount, and clear it once a later check shows headroom again.
709
+ *
710
+ * The driver also latches it automatically when a turn ends with the
711
+ * `'budget-exhausted'` failure reason — off the `tool-loop-end` activity bus
712
+ * (which also covers a sub-agent wall, a turn this element did not start, and
713
+ * an element swapped in mid-turn) and off the driver's return value (which
714
+ * covers a wall hit during multi-agent classification, where no tool loop ran
715
+ * and so no bus event fires). So a host that does no pre-flight at all still
716
+ * gets a correct locked UI the moment the first 402 lands. The bus topic is
717
+ * tab-scoped, so a wall hit in a popped-out window latches the main window
718
+ * too — correct for a shared spend cap.
719
+ *
720
+ * **Lifetime — read this before relying on it as your source of truth.** The
721
+ * latch is in-memory and per-`stateKey`:
722
+ *
723
+ * - It does **not** survive `switchSession`. Switching away tears the outgoing
724
+ * session's store down entirely, so switching back yields a fresh, unblocked
725
+ * store.
726
+ * - It does **not** survive a page reload. It is deliberately absent from the
727
+ * persisted session snapshot: the snapshot is long-lived, so a persisted
728
+ * latch would outlive an out-of-band budget raise with no in-element way to
729
+ * clear it — a stale lock is worse than re-deriving the state.
730
+ *
731
+ * The durable source of truth is therefore the **host's pre-flight**, which
732
+ * should run on mount and on every session switch. See
733
+ * `docs/migration-GENC-1464.md` §"How to adopt", Option B.
734
+ *
735
+ * @beta
736
+ */
737
+ @volatile
738
+ get blocked(): boolean {
739
+ const session = this._sessionRef?.store.aiAssistant;
740
+ if (!session) return false;
741
+ if (session.blocked) return true;
742
+ // Byte-identical to the old stored read for every host that never adopts the
743
+ // per-vendor API — which is what makes this derivation non-breaking.
744
+ if (session.blockedVendors.length === 0) return false;
745
+ // Unknown reachability (statuses not loaded yet, or providers that report no
746
+ // status) fails SAFE — any wall blocks everything, exactly today's semantics —
747
+ // and needs no clause of its own: `every` on the empty set is vacuously true.
748
+ return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
749
+ }
750
+ set blocked(value: boolean) {
751
+ this._sessionRef?.actions.aiAssistant.setBlocked({ blocked: value });
752
+ }
753
+
754
+ /**
755
+ * The distinct vendors the registry can currently reach, from the provider
756
+ * statuses this element already loads on connect and refreshes on every
757
+ * observable-registry change.
758
+ *
759
+ * This is the element's answer to "which vendor would the next turn use" — a
760
+ * question that has **no** correct answer and deliberately gets no API. The
761
+ * provider is resolved per turn AND per agent: `activeProviderInput` may be an
762
+ * async function of the turn's context, an orchestrated turn picks its agent
763
+ * with an LLM `classify()` call, and sub-agents resolve their own providers.
764
+ * Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
765
+ * multi-agent hosts per-vendor budgets exist for.
766
+ *
767
+ * Asking instead which vendors are REACHABLE is answerable, synchronous, and
768
+ * free — and it is enough: the composer must stay live while any reachable
769
+ * vendor has headroom, and must lock when none does. It also makes vendor-switch
770
+ * recovery a derivation rather than a mutation: a host that swaps its registry
771
+ * to another vendor fires the observable, the statuses reload, the walled vendor
772
+ * drops out of this set, and `blocked` goes false with every latch left intact.
773
+ *
774
+ * @beta
775
+ */
776
+ @volatile
777
+ get reachableVendors(): readonly AIProviderType[] {
778
+ const seen = new Set<AIProviderType>();
779
+ for (const entry of this.providerStatuses) {
780
+ const provider = entry.status?.provider;
781
+ if (!provider || provider === 'none') continue;
782
+ // Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
783
+ // `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
784
+ // implement `getStatus()` only. Counting a chat-less provider here made
785
+ // every consumer wrong at once: it was headroom for the `blocked`
786
+ // derivation (so `otherVendorAvailable: false` failed to lock the
787
+ // composer and each send burned another doomed 402), and it was a switch
788
+ // target for the banner ("Switch to Chrome to keep going" toward a
789
+ // provider that cannot chat).
790
+ //
791
+ // The gate is CAPABILITY, not vendor identity, so a host's custom
792
+ // chat-capable OpenAI provider still counts — the same distinction
793
+ // `validate-providers.ts` already enforces by throwing. And it drops a
794
+ // vendor only on positive evidence: statuses normally come from
795
+ // `providerRegistry.listStatuses()`, whose `name` always resolves, but a
796
+ // host (or test) that assigns `providerStatuses` directly may have no
797
+ // matching registry entry, and an unresolvable name must not silently
798
+ // erase a vendor the status layer vouched for.
799
+ const registered = this.providerRegistry?.get(entry.name);
800
+ if (registered && typeof registered.chat !== 'function') continue;
801
+ seen.add(provider);
802
+ }
803
+ return [...seen];
804
+ }
805
+
806
+ /**
807
+ * Vendors currently walled by the AI-spend budget, in the order they were
808
+ * walled. Empty for a host that never adopts the per-vendor API.
809
+ *
810
+ * May include a vendor this registry cannot reach — either because the registry
811
+ * moved on after the wall was latched, or because a 402's
812
+ * `otherVendorAvailable: false` walls every vendor the proxy meters (which is
813
+ * what that verdict is a statement about; see
814
+ * {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
815
+ * internally — {@link FoundationAiAssistant.blocked} asks only about the
816
+ * reachable set, and the banner reads the reachability-filtered
817
+ * {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
818
+ * this list itself should filter it against its own registry.
819
+ *
820
+ * Read-only on purpose: a settable array would let a host write a partial list
821
+ * and silently orphan the per-vendor banner copy.
822
+ * {@link FoundationAiAssistant.setVendorBlocked} is the write path.
823
+ *
824
+ * @beta
825
+ */
826
+ get blockedVendors(): readonly AIProviderType[] {
827
+ return this._sessionRef?.store.aiAssistant.blockedVendors ?? [];
828
+ }
829
+
830
+ /**
831
+ * Whether this specific vendor's budget is walled — regardless of whether any
832
+ * other vendor still has headroom.
833
+ *
834
+ * @beta
835
+ */
836
+ isVendorBlocked(vendor: AIProviderType): boolean {
837
+ return this.blockedVendors.includes(vendor);
838
+ }
839
+
840
+ /**
841
+ * Wall (or release) one vendor's budget, optionally with banner copy for it.
842
+ * The per-vendor mirror of {@link FoundationAiAssistant.setBlocked}.
843
+ *
844
+ * Walling a vendor does **not** on its own disable the composer: while another
845
+ * reachable vendor has headroom the assistant stays usable and the banner tells
846
+ * the user to switch. Only when every reachable vendor is walled does `blocked`
847
+ * become true.
848
+ *
849
+ * `reason` is composed into the banner sentence for that vendor, **however many
850
+ * vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
851
+ * It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
852
+ * is the override. Omitting it keeps whatever explanation that vendor already
853
+ * carried, so a driver latch landing after a host one cannot blank it.
854
+ *
855
+ * `'none'` is rejected with a warning rather than accepted: it is the "no
856
+ * provider configured" sentinel, not a vendor. Latching it would put the raw
857
+ * sentinel in the banner ("none's AI usage limit is reached") and could never
858
+ * be undone by derivation, because `'none'` never appears in
859
+ * {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
860
+ * become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
861
+ * for a vendor-agnostic block.
862
+ *
863
+ * @beta
864
+ */
865
+ setVendorBlocked(vendor: AIProviderType, blocked: boolean, reason?: string | null): void {
866
+ if (vendor === 'none') {
867
+ logger.warn(
868
+ "FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
869
+ 'ignoring. Use setBlocked() for a vendor-agnostic block.',
870
+ );
871
+ return;
872
+ }
873
+ this._sessionRef?.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
874
+ }
875
+
876
+ /**
877
+ * The walled vendors the user can still be routed to — i.e.
878
+ * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
879
+ * which is the only set the banner may name.
880
+ *
881
+ * A wall the registry can no longer reach is not news: it cannot be hit, and
882
+ * naming it puts a vendor in front of the user that is not theirs. The concrete
883
+ * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
884
+ * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
885
+ * usage limits are reached for Anthropic and Gemini" to a user who has never
886
+ * had an Anthropic key. The spurious second name also flips the copy onto the
887
+ * plural branch, so even the closing sentence is wrong.
888
+ *
889
+ * Falls back to the unfiltered list when nothing is reachable, which is the
890
+ * same fail-safe {@link FoundationAiAssistant.blocked} takes there: unknown
891
+ * reachability means every wall counts, so every wall is also worth naming.
892
+ *
893
+ * @internal
894
+ */
895
+ @volatile
896
+ private get relevantBlockedVendors(): readonly AIProviderType[] {
897
+ const walled = this.blockedVendors;
898
+ const reachable = this.reachableVendors;
899
+ if (reachable.length === 0) return walled;
900
+ return walled.filter((v) => reachable.includes(v));
901
+ }
902
+
903
+ /**
904
+ * Whether the blocked banner has anything to say — a vendor-agnostic block, OR
905
+ * at least one walled vendor the registry can still reach. Broader than
906
+ * {@link FoundationAiAssistant.blocked} on purpose: partial exhaustion leaves
907
+ * the composer live but still needs to be announced, because the next turn may
908
+ * route to the walled vendor and fail.
909
+ *
910
+ * Reads the reachability-filtered list for the same reason the copy does — a
911
+ * host that has swapped its registry away from the walled vendor has nothing
912
+ * left to announce, and would otherwise get a banner that falls through to the
913
+ * generic default copy over a perfectly usable composer.
914
+ *
915
+ * @internal
916
+ */
917
+ @volatile
918
+ get bannerVisible(): boolean {
919
+ return this.blocked || this.relevantBlockedVendors.length > 0;
920
+ }
921
+
922
+ /**
923
+ * Whether a suggestions fetch would hit a wall.
924
+ *
925
+ * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
926
+ * go to the registry **default** and never to a per-agent override
927
+ * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
928
+ * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
929
+ * suggestions call would use is knowable, and this is the one place in the
930
+ * feature where that is true.
931
+ *
932
+ * Same rationale as the guard it replaces: a doomed suggestions call burns a
933
+ * `suggestions.failed` meta event and parks a raw transport error in
934
+ * `suggestionsState` for hosts to find.
935
+ *
936
+ * @internal
937
+ */
938
+ @volatile
939
+ get suggestionsBlocked(): boolean {
940
+ if (this.blocked) return true;
941
+ const defaultVendor = this.providerStatuses.find((e) => e.isDefault)?.status?.provider;
942
+ return !!defaultVendor && defaultVendor !== 'none' && this.isVendorBlocked(defaultVendor);
943
+ }
944
+
945
+ /**
946
+ * Explanation shown in the blocked banner, or `null` for the element's default
947
+ * copy. Only meaningful while {@link FoundationAiAssistant.blocked} is true;
948
+ * setting `blocked = false` clears it.
949
+ *
950
+ * Writable, and symmetric with every other store-backed accessor on this
951
+ * class: writing it re-latches with the CURRENT `blocked` value, so it changes
952
+ * the copy without disturbing the flag. Assigning `null` clears the
953
+ * explanation while staying blocked (the banner falls back to the default
954
+ * copy). {@link FoundationAiAssistant.setBlocked} remains the way to write
955
+ * both in one atomic action.
956
+ *
957
+ * @beta
958
+ */
959
+ get blockedReason(): string | null {
960
+ return this._sessionRef?.store.aiAssistant.blockedReason ?? null;
961
+ }
962
+ set blockedReason(value: string | null) {
963
+ // Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
964
+ // is derived now, so re-latching with it would pass `false` for a partially
965
+ // walled session and clear every per-vendor latch as a side effect of setting
966
+ // a string.
967
+ this._sessionRef?.actions.aiAssistant.setBlockedReason(value);
968
+ }
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
+ * (`isBudgetedVendor`). Chrome runs on-device with no pot to exhaust, so "no
1036
+ * other vendor has budget" is not a statement about it, and "Switch to Chrome
1037
+ * to keep going." stays honest advice.
1038
+ *
1039
+ * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
1040
+ * both halves of that matter. Last, so the vendor that actually refused this
1041
+ * turn heads the wall order the multi-vendor banner reads. Outside the guard,
1042
+ * because the verdict can arrive on a LATER 402 for a vendor that is already
1043
+ * walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
1044
+ * it again and reports that Gemini has since gone too — and an early return
1045
+ * would drop exactly the news the second turn was there to deliver.
1046
+ *
1047
+ * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
1048
+ * @param ref - session store to write through; defaults to the live one. `send()`
1049
+ * passes the ref it captured before its awaits, since a lifecycle event during the
1050
+ * turn may already have cleared `_sessionRef`.
1051
+ * @param budget - what the 402 reported: figures when the proxy sent any, the
1052
+ * refusing vendor, and its `otherVendorAvailable` verdict.
1053
+ * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
1054
+ * when the budget payload names none.
1055
+ *
1056
+ * @internal
1057
+ */
1058
+ private latchBlockedFrom(
1059
+ reason?: TurnFailureReason,
1060
+ ref = this._sessionRef,
1061
+ budget?: Extract<ChatDriverResult, { reason: 'done' }>['budget'],
1062
+ vendorHint?: AIProviderType,
1063
+ ): void {
1064
+ if (reason !== 'budget-exhausted') return;
1065
+ const vendor = vendorTypeOfLabel(budget?.vendorLabel) ?? budget?.vendor ?? vendorHint;
1066
+ if (!vendor || vendor === 'none') {
1067
+ if (!this.blocked) {
1068
+ ref?.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
1069
+ }
1070
+ } else if (!this.isVendorBlocked(vendor)) {
1071
+ if (this.reachableVendors.length === 0) {
1072
+ // Not fatal — `blocked` still derives to true, so the UI is correct — but
1073
+ // the host gets no per-vendor benefit and would otherwise have no signal.
1074
+ logger.warn(
1075
+ `FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
1076
+ 'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
1077
+ 'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
1078
+ 'does is chat-less and so cannot serve a turn (implement chat() on at least one).',
1079
+ );
1080
+ }
1081
+ ref?.actions.aiAssistant.setVendorBlocked({
1082
+ vendor,
1083
+ blocked: true,
1084
+ reason: formatBlockedReason(budget, vendor),
1085
+ });
1086
+ }
1087
+ // Last, so the vendor that actually refused this turn heads the wall order the
1088
+ // multi-vendor banner reads — and unconditionally, NOT under the per-vendor
1089
+ // idempotence guard above, because the verdict can arrive on a later 402 for a
1090
+ // vendor that is already walled.
1091
+ if (budget?.otherVendorAvailable !== false) return;
1092
+ // Over the metered vendor list, NOT the reachable set: the statuses load
1093
+ // asynchronously, so a reachability-scoped sweep dropped the verdict entirely
1094
+ // when the wall beat them home. `relevantBlockedVendors` filters the extra
1095
+ // walls back out before the banner names anyone.
1096
+ for (const other of BUDGETED_VENDORS) {
1097
+ if (other === vendor || this.isVendorBlocked(other)) continue;
1098
+ // No reason: the slice's "an omitted reason keeps the existing explanation"
1099
+ // rule applies, and there are no figures for this vendor — only the proxy's
1100
+ // word that it has nothing left. The banner names it either way.
1101
+ ref?.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true });
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * Block or unblock the assistant, optionally with custom banner copy.
1107
+ * Equivalent to the {@link FoundationAiAssistant.blocked} setter, plus the
1108
+ * reason in one atomic write.
1109
+ *
1110
+ * Blocking writes the **vendor-agnostic** block — what a host with one budget
1111
+ * pot means — and does not populate
1112
+ * {@link FoundationAiAssistant.blockedVendors}. Unblocking clears the
1113
+ * per-vendor walls as well, so a host asserting "the wall is gone" after its
1114
+ * own pre-flight cannot be silently overruled by a driver latch it never saw.
1115
+ *
1116
+ * @beta
1117
+ */
1118
+ setBlocked(blocked: boolean, reason?: string | null): void {
1119
+ this._sessionRef?.actions.aiAssistant.setBlocked({ blocked, reason });
1120
+ }
1121
+
1122
+ /**
1123
+ * Copy shown in the blocked banner, in ascending specificity:
1124
+ *
1125
+ * 1. the host's `blockedReason` — a whole-banner override, unchanged;
1126
+ * 2. copy composed from the walled vendors (their per-vendor reasons when set,
1127
+ * otherwise their names) plus the action that is actually available;
1128
+ * 3. the default budget-exhaustion message.
1129
+ *
1130
+ * The action clause is computed here rather than stored, because whether
1131
+ * "switch vendor" or "contact your administrator" is honest depends on whether
1132
+ * another reachable vendor still has headroom — which changes when the registry
1133
+ * changes, long after the wall was latched. Telling a user to switch to a
1134
+ * vendor that is also exhausted is worse than saying nothing.
1135
+ *
1136
+ * The `free` set is a client-side inference — "reachable and not known to be
1137
+ * walled" — and it is only ever allowed to be one. Where the proxy has told us
1138
+ * otherwise (`otherVendorAvailable: false`) the latch has already walled those
1139
+ * vendors, so they are not in `free` here and no "switch to X" clause can be
1140
+ * composed from them. Both lists are filtered by reachability, so the copy
1141
+ * names only vendors this user's registry can actually route to.
1142
+ *
1143
+ * **Per-vendor copy is composed however many vendors are walled**, which is
1144
+ * what {@link FoundationAiAssistant.setVendorBlocked} and
1145
+ * `docs/migration-GENC-1464.md` both promise. It previously reached only the
1146
+ * single-vendor branch, so a host that had carefully set copy for each of its
1147
+ * vendors watched all of it vanish the moment a second one walled — replaced by
1148
+ * generic copy, at the exact moment the situation got worse.
1149
+ *
1150
+ * The compact "AI usage limits are reached for A and B." list is kept for the
1151
+ * case it was written for: NO walled vendor carries copy, so a statement per
1152
+ * vendor would just repeat one boilerplate sentence per name. As soon as any of
1153
+ * them does carry copy, the branch lists one statement per vendor instead —
1154
+ * falling back to that same boilerplate for the ones that have none, so the
1155
+ * sentence set stays complete rather than silently naming a subset.
1156
+ *
1157
+ * @internal
1158
+ */
1159
+ @volatile
1160
+ get effectiveBlockedReason(): string {
1161
+ if (this.blockedReason) return this.blockedReason;
1162
+
1163
+ const walled = this.relevantBlockedVendors;
1164
+ if (walled.length === 0) return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1165
+
1166
+ const free = this.reachableVendors.filter((v) => !walled.includes(v));
1167
+ const action = free.length
1168
+ ? `Switch to ${formatVendorList(free)} to keep going.`
1169
+ : walled.length > 1
1170
+ ? RAISE_LIMITS_ACTION
1171
+ : RAISE_LIMIT_ACTION;
1172
+
1173
+ const reasons = this._sessionRef?.store.aiAssistant.blockedVendorReasons;
1174
+ const statementFor = (v: AIProviderType) =>
1175
+ reasons?.[v] ?? `${vendorDisplayName(v)}'s AI usage limit is reached.`;
1176
+
1177
+ if (walled.length === 1) return `${statementFor(walled[0])} ${action}`;
1178
+ // Several walled vendors carry several sets of figures, which do not fit one
1179
+ // sentence. With no per-vendor copy to preserve, name them in a list rather
1180
+ // than repeat the same boilerplate sentence per vendor.
1181
+ if (!walled.some((v) => reasons?.[v])) {
1182
+ return `AI usage limits are reached for ${formatVendorList(walled)}. ${action}`;
1183
+ }
1184
+ return `${walled.map(statementFor).join(' ')} ${action}`;
1185
+ }
1186
+
1187
+ /**
1188
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
1189
+ *
1190
+ * A host-set `blockedReason` still wins, so a white-labelled host does not read
1191
+ * its own explanation in the banner and the shipped default directly below it —
1192
+ * that is the whole reason the driver takes this at all.
1193
+ *
1194
+ * What it deliberately is NOT is
1195
+ * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1196
+ * composes advice — `"…Switch to Gemini to keep going."` — which is true only
1197
+ * while that vendor has headroom, and the transcript is a permanent record. The
1198
+ * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1199
+ * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1200
+ * freeze that sentence into every later turn's bubble. Reading the latch's own
1201
+ * copy instead is structurally incapable of carrying the advice: the only
1202
+ * writer of `blockedReason` is the vendor-agnostic branch of
1203
+ * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1204
+ * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1205
+ * banner alone.
1206
+ */
1207
+ private get transcriptBudgetExhaustedMessage(): string {
1208
+ return this.blockedReason ?? DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1209
+ }
1210
+
592
1211
  /**
593
1212
  * Name of the agent the user has pinned via the agent picker. `null` means
594
1213
  * automatic routing (Auto). Persisted on the session store, so it survives
@@ -893,6 +1512,8 @@ export class FoundationAiAssistant extends GenesisElement {
893
1512
  private driverCleanup?: () => void;
894
1513
  private loadingTimer: ReturnType<typeof setTimeout> | undefined;
895
1514
  private unsubBus?: () => void;
1515
+ /** Unsubscribe handle for the `tool-loop-end` budget latch (GENC-1464). */
1516
+ private _unsubBudgetLatch?: () => void;
896
1517
  /** Unsubscribe handle for the provider-registry change listener (observable registries only). */
897
1518
  private unsubProviderRegistry?: () => void;
898
1519
  /** Unsubscribe handle for {@link (AssistantAppSettingsProvider:interface).subscribe}. */
@@ -1250,6 +1871,7 @@ export class FoundationAiAssistant extends GenesisElement {
1250
1871
  maxFoldOperations: agent.maxFoldOperations,
1251
1872
  maxTurnSnapshots: agent.maxTurnSnapshots,
1252
1873
  activityBus: agenticActivityBus,
1874
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1253
1875
  });
1254
1876
  }
1255
1877
 
@@ -1259,6 +1881,7 @@ export class FoundationAiAssistant extends GenesisElement {
1259
1881
  maxTurnSnapshots: agent.maxTurnSnapshots,
1260
1882
  sessionKey: this.getStateKey() ?? '',
1261
1883
  activityBus: agenticActivityBus,
1884
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1262
1885
  });
1263
1886
  }
1264
1887
 
@@ -1622,6 +2245,19 @@ export class FoundationAiAssistant extends GenesisElement {
1622
2245
  });
1623
2246
  }
1624
2247
  this.wireAppSettingsProvider();
2248
+ // Latch the backend block off the activity bus (GENC-1464). This is the primary
2249
+ // seam: it fires for a turn this element did not start, for a sub-agent's
2250
+ // wall (the child inherits the parent's bus and publishes its own
2251
+ // `tool-loop-end`), and it writes through whichever element is connected NOW
2252
+ // rather than the one that happened to call `send()`. Re-subscribed per
2253
+ // connect (docking/popout remounts); balanced in `disconnectedCallback`.
2254
+ this._unsubBudgetLatch?.();
2255
+ this._unsubBudgetLatch = agenticActivityBus.subscribe('tool-loop-end', (d) =>
2256
+ // Pass the figures through: this seam wins the latch for an in-loop wall (the
2257
+ // common case), so dropping them here would render the generic banner copy in
2258
+ // exactly the path `ChatDriverResult.budget` was widened to serve.
2259
+ this.latchBlockedFrom(d?.failureReason, undefined, d?.budget, d?.vendor),
2260
+ );
1625
2261
  if (this.messagesEl) {
1626
2262
  this._scrollListener = () => {
1627
2263
  this._userScrolledAway =
@@ -1725,6 +2361,8 @@ export class FoundationAiAssistant extends GenesisElement {
1725
2361
  this.unwireDriver();
1726
2362
  this.unsubBus?.();
1727
2363
  this.unsubBus = undefined;
2364
+ this._unsubBudgetLatch?.();
2365
+ this._unsubBudgetLatch = undefined;
1728
2366
  this.unsubProviderRegistry?.();
1729
2367
  this.unsubProviderRegistry = undefined;
1730
2368
  this.unwireAppSettingsProvider();
@@ -2634,6 +3272,9 @@ export class FoundationAiAssistant extends GenesisElement {
2634
3272
  */
2635
3273
  @volatile
2636
3274
  get effectivePlaceholder(): string {
3275
+ // The block outranks the agent pin: while blocked the composer is disabled
3276
+ // anyway, so naming the pinned agent would only imply a send is possible.
3277
+ if (this.blocked) return BLOCKED_PLACEHOLDER;
2637
3278
  if (this.pinnedAgentName) return `Message ${this.pinnedAgentName}...`;
2638
3279
  return this.placeholder;
2639
3280
  }
@@ -3024,6 +3665,10 @@ export class FoundationAiAssistant extends GenesisElement {
3024
3665
  }
3025
3666
 
3026
3667
  handleSuggestionClick(suggestion: string) {
3668
+ // Belt-and-braces with the template gate that hides the chips while blocked:
3669
+ // `send()` refuses, but `inputValue` would already have been written, leaving
3670
+ // the chip's text stranded in a disabled textarea the user cannot clear.
3671
+ if (this.sendBlocked) return;
3027
3672
  this.inputValue = suggestion;
3028
3673
  this.send();
3029
3674
  }
@@ -3051,7 +3696,7 @@ export class FoundationAiAssistant extends GenesisElement {
3051
3696
  */
3052
3697
  async submitMessage(input: { text?: string; files?: File[] }): Promise<SubmitMessageResult> {
3053
3698
  if (this.sendBlocked) {
3054
- return { ok: false, errors: ['Assistant is busy'] };
3699
+ return { ok: false, errors: [this.sendRefusalReason()] };
3055
3700
  }
3056
3701
 
3057
3702
  let nextAttachments: ChatAttachment[] = [];
@@ -3065,7 +3710,7 @@ export class FoundationAiAssistant extends GenesisElement {
3065
3710
  // (Cast widens through TS's narrowing of `this.state` from the earlier
3066
3711
  // early-return; the getter can return a different value across an await.)
3067
3712
  if (this.sendBlocked) {
3068
- return { ok: false, errors: ['Assistant is busy'] };
3713
+ return { ok: false, errors: [this.sendRefusalReason()] };
3069
3714
  }
3070
3715
  if (errors.length) {
3071
3716
  this.attachmentErrors = errors;
@@ -3097,6 +3742,22 @@ export class FoundationAiAssistant extends GenesisElement {
3097
3742
  return;
3098
3743
  }
3099
3744
 
3745
+ // A guaranteed 402. This runs from the post-turn `finally` (the turn that
3746
+ // just latched), from `connectedCallback` (a pop-in against a blocked store),
3747
+ // and from the `pinnedAgentName` setter — which resets `suggestionsState`
3748
+ // first, defeating the already-fetched guard below. So without this a blocked
3749
+ // session still fires one doomed request per turn, per pop-in and per pin
3750
+ // change, each one burning a `suggestions.failed` meta event and parking a
3751
+ // raw transport-error string in `suggestionsState.message`.
3752
+ //
3753
+ // Resolved against the registry DEFAULT, not the whole composer gate: both
3754
+ // suggestion paths call `providerRegistry.default()`, so under partial
3755
+ // exhaustion this is knowable exactly — and suggestions stay available when
3756
+ // the default vendor is the one with headroom.
3757
+ if (this.suggestionsBlocked) {
3758
+ return;
3759
+ }
3760
+
3100
3761
  if (suggestionsConfig.behavior === 'initial' && this.messages.length > 0) {
3101
3762
  return;
3102
3763
  }
@@ -3186,7 +3847,16 @@ export class FoundationAiAssistant extends GenesisElement {
3186
3847
  ? `${input}\n\n*Attached: ${pendingAttachments.map((a) => a.name).join(', ')}*`
3187
3848
  : input;
3188
3849
  try {
3189
- await this.driver?.sendMessage(displayInput, pendingAttachments);
3850
+ const result = await this.driver?.sendMessage(displayInput, pendingAttachments);
3851
+ // Latch the backend block the moment the driver reports it, so the composer
3852
+ // locks without waiting on the host's next pre-flight. The bus subscription
3853
+ // in `connectedCallback` covers most walls; this covers the one it cannot —
3854
+ // a wall hit during multi-agent classification, where no tool loop ran and
3855
+ // so no `tool-loop-end` was published. `latchBlockedFrom` is idempotent, so
3856
+ // the two seams overlapping is harmless.
3857
+ if (result?.reason === 'done') {
3858
+ this.latchBlockedFrom(result.failureReason, capturedSessionRef, result.budget);
3859
+ }
3190
3860
  } finally {
3191
3861
  this.stopLoadingTimer();
3192
3862
  this._pendingSendPrompt = undefined;