@genesislcap/ai-assistant 15.6.2 → 15.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/ai-assistant.api.json +422 -6
  2. package/dist/ai-assistant.d.ts +654 -19
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +289 -32
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  20. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  21. package/dist/dts/main/main.d.ts +435 -19
  22. package/dist/dts/main/main.d.ts.map +1 -1
  23. package/dist/dts/main/main.styles.d.ts.map +1 -1
  24. package/dist/dts/main/main.styles.test.d.ts +2 -0
  25. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  26. package/dist/dts/main/main.template.d.ts +53 -0
  27. package/dist/dts/main/main.template.d.ts.map +1 -1
  28. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  29. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  30. package/dist/dts/state/debug-event-log.d.ts +6 -1
  31. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  32. package/dist/dts/state/session-store.d.ts +11 -0
  33. package/dist/dts/state/session-store.d.ts.map +1 -1
  34. package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
  35. package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
  36. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  37. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  38. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  39. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  40. package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
  41. package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
  42. package/dist/esm/main/blocked-state.test.js +969 -0
  43. package/dist/esm/main/cost-session-banking.test.js +238 -8
  44. package/dist/esm/main/main.js +826 -48
  45. package/dist/esm/main/main.styles.js +72 -0
  46. package/dist/esm/main/main.styles.test.js +99 -0
  47. package/dist/esm/main/main.template.js +121 -4
  48. package/dist/esm/state/ai-assistant-slice.js +145 -7
  49. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  50. package/dist/esm/state/debug-event-log.js +7 -2
  51. package/dist/esm/state/debug-event-log.test.js +49 -1
  52. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  53. package/dist/esm/utils/banked-usage-baselines.js +84 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/docs/migration-GENC-1464.md +562 -0
  56. package/docs/sub_agent.md +20 -3
  57. package/package.json +17 -17
  58. package/src/channel/ai-activity-channel.ts +56 -2
  59. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  60. package/src/components/chat-driver/chat-driver.ts +324 -14
  61. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  62. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  63. package/src/components/settings-modal/settings-modal.styles.ts +63 -18
  64. package/src/components/settings-modal/settings-modal.template.ts +38 -7
  65. package/src/main/blocked-state.test.ts +1316 -0
  66. package/src/main/cost-session-banking.test.ts +283 -11
  67. package/src/main/main.styles.test.ts +130 -0
  68. package/src/main/main.styles.ts +72 -0
  69. package/src/main/main.template.ts +131 -4
  70. package/src/main/main.ts +827 -42
  71. package/src/state/ai-assistant-slice.test.ts +215 -0
  72. package/src/state/ai-assistant-slice.ts +218 -8
  73. package/src/state/debug-event-log.test.ts +63 -0
  74. package/src/state/debug-event-log.ts +7 -2
  75. package/src/state/persistence/session-snapshot.test.ts +22 -0
  76. package/src/utils/banked-usage-baselines.ts +90 -0
@@ -1,5 +1,10 @@
1
1
  import type { AggregateUsage, ChatConfig, ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import {
4
+ getBankedBaseline,
5
+ resetBankedBaselines,
6
+ setBankedBaseline,
7
+ } from '../utils/banked-usage-baselines';
3
8
  import {
4
9
  clearCostSessionHistory,
5
10
  loadCostSessionHistory,
@@ -48,6 +53,12 @@ type Internals = {
48
53
  /**
49
54
  * An unconnected element with just enough session state wired for `finalizeCostSession`:
50
55
  * the live usage it projects, the transcript it titles from, and the key it upserts against.
56
+ *
57
+ * Banked baselines are module state keyed by session (so remounts share one), which means they
58
+ * outlive an element. Cleared by default, because every suite but the remount ones is modelling
59
+ * a *fresh page load* — and because leaving them would leak one suite's baseline into the next
60
+ * under the shared `PROJECT` key. Pass `remount: true` to model a new element joining a session
61
+ * that has already banked, which is the case this file exists to pin.
51
62
  */
52
63
  function element(options: {
53
64
  scope: string;
@@ -56,7 +67,9 @@ function element(options: {
56
67
  persistenceActive?: boolean;
57
68
  loaded?: boolean;
58
69
  providerStatuses?: unknown[];
70
+ remount?: boolean;
59
71
  }): FoundationAiAssistant {
72
+ if (!options.remount) resetBankedBaselines();
60
73
  const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
61
74
  const messages: ChatMessage[] = [{ role: 'user', content: 'build me a blotter' } as ChatMessage];
62
75
  const internals = el as unknown as Internals;
@@ -351,10 +364,10 @@ Suite(
351
364
  );
352
365
 
353
366
  Suite('deleting a provider row whose id differs from its projectKey still drops the memo', () => {
354
- // `_bankedFor` holds a projectKey; the delete action passes a row id. A host provider can assign
355
- // any id, so comparing across the two namespaces skipped the reset and the next turn resurrected
356
- // the deleted total. Reachable when a provider load lands AFTER a finalize has banked: our own
357
- // upsert would otherwise have rewritten the row's id to the projectKey.
367
+ // Baselines are keyed by projectKey; the delete action passes a row id. A host provider can
368
+ // assign any id, so deleting by the wrong namespace left the baseline in place and the next
369
+ // turn resurrected the deleted total. Reachable when a provider load lands AFTER a finalize has
370
+ // banked: our own upsert would otherwise have rewritten the row's id to the projectKey.
358
371
  const scope = 'ledger-remove-id';
359
372
  clearCostSessionHistory(scope);
360
373
  const el = element({
@@ -363,13 +376,10 @@ Suite('deleting a provider row whose id differs from its projectKey still drops
363
376
  rows: [row({ id: 'host-assigned-uuid', projectKey: PROJECT, usage: usage(10) })],
364
377
  persistenceActive: false,
365
378
  });
366
- // Memo already populated for this project, as it would be after a turn.
367
- const internals = el as unknown as Internals & {
368
- _bankedFor?: string;
369
- _bankedUsage: AggregateUsage;
370
- };
371
- internals._bankedFor = PROJECT;
372
- internals._bankedUsage = usage(10);
379
+ // Baseline already resolved for this project, as it would be after a turn — set directly
380
+ // rather than by finalizing, because our own upsert would rewrite the row's id to the
381
+ // projectKey and the id mismatch under test would vanish with it.
382
+ setBankedBaseline(PROJECT, usage(10));
373
383
 
374
384
  el.removeCostSessionFromHistory('host-assigned-uuid');
375
385
  assert.is(el.costSessionHistory.length, 0);
@@ -379,6 +389,250 @@ Suite('deleting a provider row whose id differs from its projectKey still drops
379
389
  clearCostSessionHistory(scope);
380
390
  });
381
391
 
392
+ Suite('a remount mid-session does not re-bank what the row already holds', () => {
393
+ // The reported regression (GENC-1475). Reproduces the field report exactly: with persistence
394
+ // off, the host remounted the assistant part-way through a session (a layout re-render — the
395
+ // debug log showed seven connects in one session) and the LIFETIME figure jumped by the whole
396
+ // session-to-date while the live figure kept climbing normally.
397
+ //
398
+ // The new element joins a live session: same key, same transcript, and rows that already
399
+ // contain the previous instance's upsert. An element-scoped baseline re-derived here and, with
400
+ // no restore to defer to, banked `row.usage` — which already included this very transcript.
401
+ const scope = 'bank-remount';
402
+ clearCostSessionHistory(scope);
403
+ const first = element({
404
+ scope,
405
+ live: usage(6),
406
+ rows: [row({ usage: usage(1), banked: usage(1) })],
407
+ persistenceActive: false,
408
+ });
409
+ first.finalizeCostSession();
410
+ assert.is(first.costSessionHistory[0].usage.costUsd, 7, '1 banked + 6 live');
411
+
412
+ // Remount: a brand new element wired to the same session, handed the rows as they now stand.
413
+ const second = element({
414
+ scope,
415
+ live: usage(6),
416
+ rows: first.costSessionHistory,
417
+ persistenceActive: false,
418
+ remount: true,
419
+ });
420
+ second.finalizeCostSession();
421
+
422
+ assert.is(second.costSessionHistory[0].usage.costUsd, 7, 'remount must not inflate the row');
423
+ assert.is(second.costSessionHistory[0].banked?.costUsd, 1, 'baseline unchanged by the remount');
424
+
425
+ // And the session keeps accruing normally afterwards, on top of the SAME baseline.
426
+ const third = element({
427
+ scope,
428
+ live: usage(9),
429
+ rows: second.costSessionHistory,
430
+ persistenceActive: false,
431
+ remount: true,
432
+ });
433
+ third.finalizeCostSession();
434
+ assert.is(third.costSessionHistory[0].usage.costUsd, 10, '1 banked + 9 live');
435
+ clearCostSessionHistory(scope);
436
+ });
437
+
438
+ Suite('a page reload still absorbs the prior total after a remount banked nothing new', () => {
439
+ // The other half of the pairing: dropping the baseline on remount is wrong, but keeping it
440
+ // across a *page load* would be too — the transcript starts empty there (persistence off), so
441
+ // the row's whole total must be banked. `resetBankedBaselines` stands in for the realm dying.
442
+ const scope = 'bank-reload';
443
+ clearCostSessionHistory(scope);
444
+ const before = element({
445
+ scope,
446
+ live: usage(6),
447
+ rows: [row({ usage: usage(1), banked: usage(1) })],
448
+ persistenceActive: false,
449
+ });
450
+ before.finalizeCostSession();
451
+ assert.is(before.costSessionHistory[0].usage.costUsd, 7);
452
+
453
+ const afterReload = element({
454
+ scope,
455
+ live: usage(2),
456
+ rows: before.costSessionHistory,
457
+ persistenceActive: false,
458
+ });
459
+ afterReload.finalizeCostSession();
460
+ assert.is(afterReload.costSessionHistory[0].usage.costUsd, 9, 'prior 7 banked, this load adds 2');
461
+ clearCostSessionHistory(scope);
462
+ });
463
+
464
+ Suite('clearing the transcript re-baselines instead of discarding spend', () => {
465
+ // Clear empties the transcript, so the row's total now predates what is on screen and has to be
466
+ // banked wholesale. Merely dropping the baseline would re-derive through `persistenceActive`,
467
+ // which with persistence ON carries the row's OLD banked figure forward and silently loses
468
+ // every turn between that baseline and the clear.
469
+ const scope = 'bank-clear-session';
470
+ clearCostSessionHistory(scope);
471
+ const el = element({
472
+ scope,
473
+ live: usage(6),
474
+ rows: [row({ usage: usage(10), banked: usage(4) })],
475
+ persistenceActive: true,
476
+ });
477
+ el.finalizeCostSession();
478
+ assert.is(el.costSessionHistory[0].usage.costUsd, 10);
479
+
480
+ // `clearSession` itself tears down and rebuilds the driver, which an unconnected element has
481
+ // none of — so drive the one step under test. `clearSession` reads the live figure before
482
+ // `resetSession` zeroes it and passes it in; the 6 here is that capture.
483
+ const priv = el as unknown as {
484
+ rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void;
485
+ _sessionRef: { store: { aiAssistant: { sessionUsage: AggregateUsage } } };
486
+ _costHistoryLoaded: boolean;
487
+ };
488
+ priv.rebaselineOnClearedTranscript(PROJECT, usage(6));
489
+
490
+ // A fresh transcript worth 3, on top of the whole 10 the project had accrued.
491
+ priv._sessionRef.store.aiAssistant.sessionUsage = usage(3);
492
+ el.finalizeCostSession();
493
+ assert.is(el.costSessionHistory[0].usage.costUsd, 13, 'pre-clear spend kept, new turn added');
494
+ assert.is(el.costSessionHistory[0].banked?.costUsd, 10, 'whole pre-clear total banked');
495
+ clearCostSessionHistory(scope);
496
+ });
497
+
498
+ Suite('clearing while the ledger is still loading must not pin a baseline', () => {
499
+ // Review case. A stamped baseline is sticky — `bankedUsageFor` short-circuits on any stored
500
+ // entry and a zero usage is a *resolved* value there. On the provider path the row list is empty
501
+ // and `_costHistoryLoaded` false while the read is in flight, so a clear landing in that window
502
+ // used to pin a baseline derived from no rows at all. The load arriving afterwards with the
503
+ // project's real total was then never consulted, and the next write replaced its row with
504
+ // `banked: 0` — the project's whole history gone.
505
+ const scope = 'bank-clear-unloaded';
506
+ clearCostSessionHistory(scope);
507
+ const el = element({ scope, live: usage(5), loaded: false });
508
+ const priv = el as unknown as {
509
+ rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void;
510
+ _sessionRef: { store: { aiAssistant: { sessionUsage: AggregateUsage } } };
511
+ _costHistoryLoaded: boolean;
512
+ };
513
+
514
+ // Clear arrives before the provider read lands: rows are empty and nothing is known yet.
515
+ priv.rebaselineOnClearedTranscript(PROJECT, usage(5));
516
+ assert.is(getBankedBaseline(PROJECT), undefined, 'nothing pinned from a ledger we cannot see');
517
+
518
+ // The read lands with this project's real history, and a turn follows.
519
+ el.costSessionHistory = [row({ usage: usage(42), banked: usage(40) })];
520
+ priv._costHistoryLoaded = true;
521
+ priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
522
+ el.finalizeCostSession();
523
+
524
+ assert.is(el.costSessionHistory[0].usage.costUsd, 43, "the project's 42 survives the race");
525
+ assert.is(el.costSessionHistory[0].banked?.costUsd, 42, 'baseline re-derived from real rows');
526
+ clearCostSessionHistory(scope);
527
+ });
528
+
529
+ Suite('clearing banks a transcript the row has not caught up with', () => {
530
+ // Review case: without this, the suite passed identically against the OLD derivation
531
+ // (`existing?.usage`), because everywhere else the row already agreed with baseline + live.
532
+ //
533
+ // The formulas only diverge when the row is STALE relative to the transcript — which is exactly
534
+ // what the new one exists for, since `finalizeCostSession` skips its write while the ledger is
535
+ // unloaded and while the session has no usage. No finalize here, so the row lags by 3.
536
+ const scope = 'bank-clear-stale-row';
537
+ clearCostSessionHistory(scope);
538
+ const el = element({
539
+ scope,
540
+ live: usage(3),
541
+ rows: [row({ usage: usage(42), banked: usage(40) })],
542
+ persistenceActive: false,
543
+ });
544
+ const priv = el as unknown as {
545
+ rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void;
546
+ _sessionRef: { store: { aiAssistant: { sessionUsage: AggregateUsage } } };
547
+ };
548
+
549
+ priv.rebaselineOnClearedTranscript(PROJECT, usage(3));
550
+ // 42 from the row plus the 3 the discarded transcript proved. The old row-read banked 42 flat.
551
+ assert.is(getBankedBaseline(PROJECT)?.costUsd, 45, 'the stale 3 is banked, not dropped');
552
+
553
+ priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
554
+ el.finalizeCostSession();
555
+ assert.is(el.costSessionHistory[0].usage.costUsd, 46, '45 banked + the new turn');
556
+ clearCostSessionHistory(scope);
557
+ });
558
+
559
+ Suite('a memoised baseline survives a clear during a reload instead of being dropped', () => {
560
+ // Review case. The guard is broader than the derivation needs: once a baseline is memoised the
561
+ // new figure is determined without any rows, so deleting it there throws away something correct.
562
+ // Concretely — row at usage 120 / banked 90, baseline memoised at 90, 30 on screen — dropping it
563
+ // made the next turn re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
564
+ const scope = 'bank-clear-memoised';
565
+ clearCostSessionHistory(scope);
566
+ const el = element({
567
+ scope,
568
+ live: usage(30),
569
+ rows: [row({ usage: usage(120), banked: usage(90) })],
570
+ persistenceActive: true,
571
+ });
572
+ el.finalizeCostSession();
573
+ assert.is(getBankedBaseline(PROJECT)?.costUsd, 90, 'baseline memoised off the restored row');
574
+
575
+ // A chatConfig rebind puts the ledger back in flight, and the clear lands inside that window.
576
+ const priv = el as unknown as {
577
+ rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void;
578
+ _sessionRef: { store: { aiAssistant: { sessionUsage: AggregateUsage } } };
579
+ _costHistoryLoaded: boolean;
580
+ };
581
+ priv._costHistoryLoaded = false;
582
+ priv.rebaselineOnClearedTranscript(PROJECT, usage(30));
583
+ assert.is(getBankedBaseline(PROJECT)?.costUsd, 120, '90 kept and the discarded 30 added');
584
+
585
+ priv._costHistoryLoaded = true;
586
+ priv._sessionRef.store.aiAssistant.sessionUsage = usage(5);
587
+ el.finalizeCostSession();
588
+ assert.is(el.costSessionHistory[0].usage.costUsd, 125, 'row moves forward, never backwards');
589
+ clearCostSessionHistory(scope);
590
+ });
591
+
592
+ Suite('clearing with the ledger disabled pins nothing, even though it reads as loaded', () => {
593
+ // Review case. `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a deliberately
594
+ // empty list when `enabled` is false — "nothing will be loaded", not "rows are known" — so the
595
+ // flag alone let a clear pin a baseline from a ledger that was never read. A host enabling
596
+ // history afterwards (an async setting, a feature flag) then had its real row buried by it.
597
+ const scope = 'bank-clear-disabled';
598
+ clearCostSessionHistory(scope);
599
+ const el = element({ scope, live: usage(3) });
600
+ el.chatConfig = { costHistory: { enabled: false, scope } } as ChatConfig;
601
+ const priv = el as unknown as {
602
+ rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void;
603
+ _sessionRef: { store: { aiAssistant: { sessionUsage: AggregateUsage } } };
604
+ _costHistoryLoaded: boolean;
605
+ };
606
+ assert.is(priv._costHistoryLoaded, true, 'reads as loaded despite nothing being known');
607
+
608
+ priv.rebaselineOnClearedTranscript(PROJECT, usage(3));
609
+ assert.is(getBankedBaseline(PROJECT), undefined, 'nothing pinned while the ledger is off');
610
+
611
+ // History switched on afterwards, and this project turns out to have real spend.
612
+ el.chatConfig = { costHistory: { enabled: true, scope, title: 'Trade Blotter' } } as ChatConfig;
613
+ el.costSessionHistory = [row({ usage: usage(50), banked: usage(50) })];
614
+ priv._costHistoryLoaded = true;
615
+ priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
616
+ el.finalizeCostSession();
617
+
618
+ assert.is(el.costSessionHistory[0].usage.costUsd, 51, "the project's 50 is not buried");
619
+ clearCostSessionHistory(scope);
620
+ });
621
+
622
+ Suite('a host slotting its own build history does not have to enable our ledger', () => {
623
+ // Review case. `enabled` governs the BUILT-IN ledger and defaults false, while the Usage tab
624
+ // shows for showCost/showTokens which default true. Once the section's ?hidden binding became
625
+ // effective, short-circuiting on `enabled` above the slot check hid a host's own markup — and
626
+ // the slot is the only thing in that container.
627
+ const el = element({ scope: 'slotted-history', live: usage(1) });
628
+ el.chatConfig = { costHistory: { enabled: false } } as ChatConfig;
629
+ assert.is(el.settingsCostHistorySectionVisible, false, 'nothing slotted, ledger off → hidden');
630
+
631
+ el.settingsCostHistorySlotted = [document.createElement('div')];
632
+ assert.is(el.settingsCostHistorySectionVisible, true, 'slotted content shows without `enabled`');
633
+ assert.is(el.settingsCostHistoryHeadingVisible, true, 'and the heading comes with it');
634
+ });
635
+
382
636
  Suite('hides the lifetime section when no state key at all can produce a row', () => {
383
637
  const el = element({ scope: 'ledger-nokey', live: usage(3) });
384
638
  assert.is(el.settingsCostLifetimeVisible, true, 'visible with a key');
@@ -404,4 +658,22 @@ Suite('labels the live figures by whether the transcript is authoritative', () =
404
658
  assert.is(without.liveUsageIsProjectLifetime, false, 'not restored → this session only');
405
659
  });
406
660
 
661
+ Suite('a stored baseline is isolated from the row and the caller it passed through', () => {
662
+ // A baseline arrives as a reference into a ledger row (`resolveBankedUsage` returns `row.banked`
663
+ // or `row.usage`) and is read back onto the NEXT row's `banked` field, so an uncloned map entry
664
+ // is reachable from page-global state, two rows, and any host handed the row list. Mutating
665
+ // either end must not move the stored figure, or a project's baseline is silently rewritten for
666
+ // the rest of the page.
667
+ resetBankedBaselines();
668
+ const source = usage(10);
669
+ setBankedBaseline(PROJECT, source);
670
+
671
+ source.costUsd = 999;
672
+ assert.is(getBankedBaseline(PROJECT)?.costUsd, 10, 'mutating the source cannot reach the map');
673
+
674
+ const readBack = getBankedBaseline(PROJECT)!;
675
+ readBack.costUsd = 777;
676
+ assert.is(getBankedBaseline(PROJECT)?.costUsd, 10, 'mutating a read copy cannot reach the map');
677
+ });
678
+
407
679
  Suite.run();
@@ -0,0 +1,130 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { styles } from './main.styles';
3
+
4
+ // CSS is not otherwise unit-tested in this package, so this covers the two
5
+ // properties of the BLOCKED BANNER that are cheap to assert from the generated
6
+ // text and that a reviewer had to catch by eye:
7
+ //
8
+ // 1. Design-system tokens are consumed UNFALLBACKED, as every other rule in the
9
+ // file does. A `var(--token, fallback)` here is dead code at best — both
10
+ // tokens below ship with defaults — and actively wrong at worst: the blocked
11
+ // banner's old `var(--neutral-layer-2, transparent)` would, if it ever fired,
12
+ // have produced a banner with no surface at all.
13
+ // 2. The blocked banner's visual treatment hangs off `.is-visible`, not the bare
14
+ // class, because the container stays mounted while unblocked so that its
15
+ // `role="status"` region is registered before the text arrives (see
16
+ // main.template.ts). Collapsing the empty container would undo that.
17
+ //
18
+ // Scope: the banner's own rules, and nothing else. Both negative assertions were
19
+ // written for one component, but an earlier version grepped the WHOLE stylesheet
20
+ // — quietly turning a banner test into a global style policy nobody agreed to, so
21
+ // any future rule anywhere in an 800-line sheet that legitimately wanted a
22
+ // fallback on either token would have failed a test named after the banner.
23
+
24
+ const suite = createLogicSuite('main.styles blocked-banner rules');
25
+
26
+ /**
27
+ * The generated stylesheet text. `css` composes nested `ElementStyles` (this file
28
+ * builds the sheet from several partials), so flatten recursively rather than
29
+ * only reading the top level — a shallow read silently returns almost nothing,
30
+ * which would make every `assert.not.ok` below pass vacuously. The first test
31
+ * guards exactly that.
32
+ */
33
+ const flatten = (value: unknown): string => {
34
+ if (typeof value === 'string') return value;
35
+ if (Array.isArray(value)) return value.map(flatten).join('\n');
36
+ const nested = (value as { styles?: unknown } | null | undefined)?.styles;
37
+ return nested ? flatten(nested) : '';
38
+ };
39
+
40
+ const cssText = flatten(styles);
41
+
42
+ /**
43
+ * Every declaration block whose selector mentions `.blocked-banner`, joined.
44
+ *
45
+ * Deliberately crude — one regex over the generated text, no CSS parser — but
46
+ * enough to keep these assertions about the banner rather than about the whole
47
+ * sheet, which was the only thing wrong with them. It matches
48
+ * `<selector> { <declarations> }` for any selector containing `.blocked-banner`,
49
+ * so it picks up the bare class, the `.is-visible` / `.is-partial` modifiers, the
50
+ * shared composer-width group and the copy inside the container query alike.
51
+ */
52
+ const bannerRules = (cssText.match(/[^{}]*\.blocked-banner[^{}]*\{[^}]*\}/g) ?? []).join('\n');
53
+
54
+ /** Declarations of the bare-class rule — the one that applies while empty. */
55
+ const emptyStateRule = cssText.match(/[\s{}](\.blocked-banner)\s*\{([^}]*)\}/)?.[2] ?? '';
56
+
57
+ suite('the banner rules were extracted as text (guards the assertions below)', () => {
58
+ assert.ok(cssText.includes('.blocked-banner'), 'the banner rules are in the sheet');
59
+ assert.ok(bannerRules.includes('.is-visible'), 'the extraction found the modifier rules');
60
+ assert.ok(emptyStateRule.length > 0, 'and isolated the bare-class rule');
61
+ });
62
+
63
+ suite('the banner consumes design-system tokens without dead fallbacks', () => {
64
+ assert.not.ok(
65
+ bannerRules.includes('var(--neutral-layer-2,'),
66
+ '--neutral-layer-2 has a DS default and is used unfallbacked everywhere else here',
67
+ );
68
+ assert.not.ok(
69
+ bannerRules.includes('var(--warning-color,'),
70
+ '--warning-color has a DS default and is used unfallbacked by toast/badge styles',
71
+ );
72
+ });
73
+
74
+ suite('the blocked banner keeps its unblocked container visually inert', () => {
75
+ // `::part(blocked-banner)` matches at all times, so the empty state must set
76
+ // nothing that reserves space — and must not remove the container from the
77
+ // accessibility tree, which is where the `role="status"` region is registered.
78
+ //
79
+ // Asserted against the bare-class rule's own declarations rather than by
80
+ // grepping for a `:empty` selector nobody wrote: this catches the mistake as it
81
+ // would actually be made — a padding or border creeping onto the always-present
82
+ // container — instead of one specific spelling of it.
83
+ //
84
+ // `margin-block` rather than `margin`: the shared composer-width group sets
85
+ // `margin-inline: auto` on the banner along with the rest of the composer
86
+ // column, which centres it and reserves nothing.
87
+ assert.ok(cssText.includes('.blocked-banner.is-visible'), 'visuals hang off is-visible');
88
+ for (const property of ['display: none', 'padding', 'border', 'background', 'margin-block']) {
89
+ assert.not.ok(
90
+ emptyStateRule.includes(property),
91
+ `the always-present container must not set ${property} — that is .is-visible's job`,
92
+ );
93
+ }
94
+ });
95
+
96
+ suite('the visible banner hands its end margin to the resize handle', () => {
97
+ // Reviewer's manual-testing note: the banner's block-end margin plus the 7px
98
+ // handle stacked into a gap the hover pill was NOT centred in (it centres in
99
+ // the handle alone). The adjacency rule collapses the two so the handle IS
100
+ // the gap. What gets pinned is the INVARIANT, not the selector's existence:
101
+ // both rules must read the ONE shared token, because the cancellation only
102
+ // works while the banner's margin and the handle's pull are equal-and-
103
+ // opposite — two hand-kept magnitudes ~80 lines apart regress silently, and
104
+ // an existence-only assertion stayed green while they did.
105
+ assert.ok(
106
+ /:host\s*\{[^}]*--blocked-banner-gap:/.test(cssText),
107
+ 'the shared gap token is declared on the host (a sibling cannot read one on the banner)',
108
+ );
109
+ assert.ok(
110
+ /\.blocked-banner\.is-visible\s*\{[^}]*margin-block:\s*var\(--blocked-banner-gap\)/.test(
111
+ cssText,
112
+ ),
113
+ "the banner's block margin IS the token",
114
+ );
115
+ assert.ok(
116
+ /\.blocked-banner\.is-visible \+ \.composer-resize-handle\s*\{[^}]*margin-block-start:\s*calc\(var\(--blocked-banner-gap\) \* -1\)/.test(
117
+ cssText,
118
+ ),
119
+ 'and the handle pulls up by exactly that token, negated — equal and opposite by construction',
120
+ );
121
+ });
122
+
123
+ suite('partial exhaustion has its own modifier, layered on the visible state', () => {
124
+ // `.is-visible` carries the layout and surface for BOTH states; `.is-partial`
125
+ // only softens it. Additive, never a replacement — so host CSS keyed on the
126
+ // visible state needs to know nothing about partial exhaustion.
127
+ assert.ok(cssText.includes('.blocked-banner.is-partial'), 'the partial modifier exists');
128
+ });
129
+
130
+ suite.run();
@@ -16,6 +16,19 @@ const baseStyles = css`
16
16
  float still triggers at 1500px. */
17
17
  --ai-conversation-max-width: 1500px;
18
18
 
19
+ /* INTERNAL — not a supported override point, despite sitting beside one.
20
+ Deliberately NOT --ai-*-namespaced and absent from docs/styling.md: the
21
+ documented banner surface is ::part(blocked-banner), and this token is
22
+ plumbing, not a knob. It is the banner's block margin AND the exact
23
+ amount the resize handle pulls itself up by when it follows the banner —
24
+ one name, on the host only because a sibling cannot read a custom
25
+ property declared on the banner (they inherit downward), so the
26
+ cancellation is structural: change the gap here and both rules move
27
+ together, instead of two magnitudes ~80 lines apart that only prose kept
28
+ in sync. A host that overrides it anyway gets a consistent gap, but that
29
+ is a side effect, not a promise. */
30
+ --blocked-banner-gap: calc(var(--design-unit) * 1px);
31
+
19
32
  display: flex;
20
33
  flex-direction: column;
21
34
  height: 100%;
@@ -441,6 +454,7 @@ const baseStyles = css`
441
454
  .attachment-chips,
442
455
  .agent-picker-panel,
443
456
  .session-menu-panel,
457
+ .blocked-banner,
444
458
  chat-suggestions {
445
459
  box-sizing: border-box;
446
460
  width: 100%;
@@ -464,6 +478,13 @@ const baseStyles = css`
464
478
  border-radius: 12px;
465
479
  }
466
480
 
481
+ /* The banner joins the same floating-card family, but keeps its own
482
+ warning-toned border — overriding it with the neutral stroke would erase
483
+ the one colour signal it carries. Only the radius needs to match. */
484
+ .blocked-banner.is-visible {
485
+ border-radius: 12px;
486
+ }
487
+
467
488
  /* chat-suggestions paints its background on an inner element, so clip it to the
468
489
  rounded corners — otherwise that square inner background bleeds past the radius
469
490
  and the corners read as square with the border hidden behind them. */
@@ -617,6 +638,45 @@ const baseStyles = css`
617
638
  font-size: 0.9em;
618
639
  }
619
640
 
641
+ /* Backend-blocked banner (GENC-1464) — sits between the transcript and the
642
+ disabled composer. Flat and quiet rather than an error toast: the condition
643
+ is standing, not a momentary failure, so it should read as part of the
644
+ chrome the user is now working within. */
645
+
646
+ /* The container is ALWAYS in the DOM so the live region is registered before the
647
+ text arrives (see main.template.ts) — so the bare selector carries only layout,
648
+ and an unblocked banner is an empty, zero-height, invisible box. Everything
649
+ that would make it visible hangs off .is-visible. */
650
+ .blocked-banner {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: calc(var(--design-unit) * 2px);
654
+ }
655
+
656
+ .blocked-banner.is-visible {
657
+ /* Block axis only. The inline-axis margin is owned by the composer width group
658
+ above (margin-inline: auto), which centres the banner on the same column as
659
+ the input row — an inline margin here would fight it. */
660
+ margin-block: var(--blocked-banner-gap);
661
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
662
+ border: calc(var(--stroke-width) * 1px) solid var(--warning-color);
663
+ border-radius: calc(var(--control-corner-radius) * 1px);
664
+ background: var(--neutral-layer-2);
665
+ color: var(--neutral-foreground-rest);
666
+ }
667
+
668
+ /* Partial exhaustion: at least one vendor is walled but the composer is still
669
+ live, so the banner is advice rather than a wall. Softened to match — same
670
+ layout, same part, lower alarm. */
671
+ .blocked-banner.is-partial {
672
+ border-color: var(--neutral-stroke-rest);
673
+ }
674
+
675
+ .blocked-banner-text {
676
+ font-size: 0.9em;
677
+ line-height: 1.4;
678
+ }
679
+
620
680
  .chat-input {
621
681
  flex: 1;
622
682
  resize: none;
@@ -663,6 +723,18 @@ const baseStyles = css`
663
723
  opacity: 70%;
664
724
  }
665
725
 
726
+ /* With the banner visible, its block-end margin and the handle's height used
727
+ to STACK into an ~11px void between the exhausted message and the composer
728
+ — and the hover pill centres in the handle's own 7px slice, i.e. below the
729
+ middle of the gap the user actually sees. Pull the handle up over the
730
+ banner's end margin instead: the handle's height becomes the whole gap and
731
+ the pill lands dead-centre of it. FAST's when-anchors are comment nodes,
732
+ so the adjacency holds whenever the handle renders; without a handle
733
+ (resize disabled) the banner keeps its normal margin before the input row. */
734
+ .blocked-banner.is-visible + .composer-resize-handle {
735
+ margin-block-start: calc(var(--blocked-banner-gap) * -1);
736
+ }
737
+
666
738
  .file-input {
667
739
  display: none;
668
740
  }