@genesislcap/ai-assistant 15.10.4 → 15.10.6

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.
@@ -1185,6 +1185,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1185
1185
  if (reason !== 'budget-exhausted')
1186
1186
  return;
1187
1187
  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;
1188
+ // Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
1189
+ // itself reported, the moment the wall lands. Hosts refresh the meter on
1190
+ // `tool-loop-end`, and a walled turn may never reach one — the user then
1191
+ // stares at the pre-turn figures next to a banner saying the budget is gone
1192
+ // until something else ends a loop (observed live: the meter only caught up
1193
+ // when the user cancelled the stuck interaction). Deliberately NOT under the
1194
+ // per-vendor idempotence guard below, for the same reason as the sweep: a
1195
+ // later 402 for an already-walled vendor carries fresher spend. Both figures
1196
+ // are required (`VendorBudgetFigures` is total, and half a meter is a lie),
1197
+ // and the write goes through the captured `ref` like every other write here,
1198
+ // so a teardown race cannot land it in a new session's store.
1199
+ if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
1200
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
1201
+ vendor,
1202
+ figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1203
+ });
1204
+ }
1188
1205
  if (!vendor || vendor === 'none') {
1189
1206
  if (!this.blocked) {
1190
1207
  ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
@@ -1312,28 +1329,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1312
1329
  return `${walled.map(statementFor).join(' ')} ${action}`;
1313
1330
  }
1314
1331
  /**
1315
- * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
1332
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
1333
+ * or `undefined` to let the driver compose it per wall.
1316
1334
  *
1317
- * A host-set `blockedReason` still wins, so a white-labelled host does not read
1318
- * its own explanation in the banner and the shipped default directly below it
1319
- * that is the whole reason the driver takes this at all.
1335
+ * A set `blockedReason` still wins a white-labelled host must not read its
1336
+ * own explanation in the banner and shipped copy directly below it, and the
1337
+ * vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
1338
+ * exactly as before.
1320
1339
  *
1321
- * What it deliberately is NOT is
1322
- * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1323
- * composes advice `"…Switch to Gemini to keep going."` which is true only
1324
- * while that vendor has headroom, and the transcript is a permanent record. The
1325
- * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1326
- * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1327
- * freeze that sentence into every later turn's bubble. Reading the latch's own
1328
- * copy instead is structurally incapable of carrying the advice: the only
1329
- * writer of `blockedReason` is the vendor-agnostic branch of
1330
- * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1331
- * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1332
- * banner alone.
1340
+ * `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
1341
+ * fallback: it hands composition to the driver, which builds the bubble from
1342
+ * EACH wall's own 402 naming the exhausted vendor and advising a switch only
1343
+ * when that refusal's `otherVendorAvailable` positively vouched for headroom
1344
+ * elsewhere. That is immune to the staleness this getter used to guard against
1345
+ * by freezing the copy: construction-time advice could outlive its truth
1346
+ * (`getOrCreateDriver` re-runs on an agents swap and would have baked the
1347
+ * sentence into every later bubble), but per-wall advice is derived from the
1348
+ * refusal it annotates and dies with the turn. Forcing the default here was
1349
+ * also what made the driver's composed bubble unreachable in every real
1350
+ * mount the banner advised switching while the bubble under it never did
1351
+ * (the GENC-1464 tester finding, root cause).
1333
1352
  */
1334
1353
  get transcriptBudgetExhaustedMessage() {
1335
1354
  var _a;
1336
- return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1355
+ return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
1337
1356
  }
1338
1357
  /**
1339
1358
  * Name of the agent the user has pinned via the agent picker. `null` means
package/dist/react.cjs CHANGED
@@ -124,14 +124,14 @@ const AiChatInteractionWrapper = React.forwardRef(function AiChatInteractionWrap
124
124
  return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
125
125
  });
126
126
 
127
- const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
127
+ const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
128
128
  const { children, ...rest } = props;
129
- return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
129
+ return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
130
130
  });
131
131
 
132
- const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
132
+ const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
133
133
  const { children, ...rest } = props;
134
- return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
134
+ return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
135
135
  });
136
136
 
137
137
  module.exports = {
@@ -145,6 +145,6 @@ module.exports = {
145
145
  AgentPicker,
146
146
  AiChatBubble,
147
147
  AiChatInteractionWrapper,
148
- AiChatMarkdown,
149
148
  FoundationAiPopoutManager,
149
+ AiChatMarkdown,
150
150
  };
package/dist/react.mjs CHANGED
@@ -122,12 +122,12 @@ export const AiChatInteractionWrapper = React.forwardRef(function AiChatInteract
122
122
  return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
123
123
  });
124
124
 
125
- export const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
125
+ export const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
126
126
  const { children, ...rest } = props;
127
- return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
127
+ return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
128
128
  });
129
129
 
130
- export const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
130
+ export const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
131
131
  const { children, ...rest } = props;
132
- return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
132
+ return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
133
133
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.10.4",
4
+ "version": "15.10.6",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.10.4",
77
- "@genesislcap/genx": "15.10.4",
78
- "@genesislcap/rollup-builder": "15.10.4",
79
- "@genesislcap/ts-builder": "15.10.4",
80
- "@genesislcap/uvu-playwright-builder": "15.10.4",
81
- "@genesislcap/vite-builder": "15.10.4",
82
- "@genesislcap/webpack-builder": "15.10.4",
76
+ "@genesislcap/foundation-testing": "15.10.6",
77
+ "@genesislcap/genx": "15.10.6",
78
+ "@genesislcap/rollup-builder": "15.10.6",
79
+ "@genesislcap/ts-builder": "15.10.6",
80
+ "@genesislcap/uvu-playwright-builder": "15.10.6",
81
+ "@genesislcap/vite-builder": "15.10.6",
82
+ "@genesislcap/webpack-builder": "15.10.6",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.10.4",
89
- "@genesislcap/foundation-logger": "15.10.4",
90
- "@genesislcap/foundation-notifications": "15.10.4",
91
- "@genesislcap/foundation-redux": "15.10.4",
92
- "@genesislcap/foundation-ui": "15.10.4",
93
- "@genesislcap/foundation-utils": "15.10.4",
94
- "@genesislcap/rapid-design-system": "15.10.4",
95
- "@genesislcap/web-core": "15.10.4",
88
+ "@genesislcap/foundation-ai": "15.10.6",
89
+ "@genesislcap/foundation-logger": "15.10.6",
90
+ "@genesislcap/foundation-notifications": "15.10.6",
91
+ "@genesislcap/foundation-redux": "15.10.6",
92
+ "@genesislcap/foundation-ui": "15.10.6",
93
+ "@genesislcap/foundation-utils": "15.10.6",
94
+ "@genesislcap/rapid-design-system": "15.10.6",
95
+ "@genesislcap/web-core": "15.10.6",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "2747f342a1681832c041ac612b854039ef908ab7"
108
+ "gitHead": "2fd12f28a04c8dc20974ab54d3ea143b80229510"
109
109
  }
@@ -2898,6 +2898,77 @@ outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', a
2898
2898
  assert.is(driver.getHistory().at(-1)!.content, 'Contact ops@acme.com');
2899
2899
  });
2900
2900
 
2901
+ // ── The composed default bubble (GENC-1464 tester finding) ─────────────────────
2902
+ // The blocked banner advises switching when another vendor has headroom; the
2903
+ // transcript bubble directly under it used to say only "contact your
2904
+ // administrator" regardless. The two surfaces must agree, and the bubble may
2905
+ // only advise a switch the proxy positively vouched for.
2906
+
2907
+ /** Drive one walled turn and return the transcript's final (bubble) content. */
2908
+ const bubbleAfterWall = async (
2909
+ sessionKey: string,
2910
+ e: BudgetExhaustedError,
2911
+ budgetExhaustedMessage?: string,
2912
+ ): Promise<string> => {
2913
+ clearMetaEventRegistry();
2914
+ const provider: AIProvider = {
2915
+ chat: async (): Promise<ChatMessage> => {
2916
+ throw e;
2917
+ },
2918
+ };
2919
+ const driver = new ChatDriver(makeRegistry(provider), {
2920
+ sessionKey,
2921
+ activityBus: outcomeBus,
2922
+ ...(budgetExhaustedMessage == null ? {} : { budgetExhaustedMessage }),
2923
+ });
2924
+ driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2925
+ await driver.sendMessage('go');
2926
+ return String(driver.getHistory().at(-1)!.content);
2927
+ };
2928
+
2929
+ outcome(
2930
+ 'default bubble advises switching when the 402 vouches for headroom elsewhere',
2931
+ async () => {
2932
+ const content = await bubbleAfterWall(
2933
+ 'outcome-bubble-switch',
2934
+ new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
2935
+ );
2936
+ assert.is(
2937
+ content,
2938
+ "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
2939
+ );
2940
+ },
2941
+ );
2942
+
2943
+ outcome('default bubble stays neutral when every other vendor is exhausted too', async () => {
2944
+ const content = await bubbleAfterWall(
2945
+ 'outcome-bubble-all-walled',
2946
+ new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: false }),
2947
+ );
2948
+ // Advising a switch to a vendor the proxy just said is empty would be worse
2949
+ // than the neutral copy.
2950
+ assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2951
+ });
2952
+
2953
+ outcome('default bubble stays neutral when an older proxy did not say', async () => {
2954
+ const content = await bubbleAfterWall(
2955
+ 'outcome-bubble-no-verdict',
2956
+ new BudgetExhaustedError('Anthropic', 25, 25.4),
2957
+ );
2958
+ // `undefined` is "the proxy did not say", not "another vendor is free".
2959
+ assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2960
+ });
2961
+
2962
+ outcome('a host override wins verbatim even when switching would be honest advice', async () => {
2963
+ const content = await bubbleAfterWall(
2964
+ 'outcome-bubble-override-wins',
2965
+ new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }),
2966
+ 'Contact ops@acme.com',
2967
+ );
2968
+ // The white-label contract: host copy is never composed onto.
2969
+ assert.is(content, 'Contact ops@acme.com');
2970
+ });
2971
+
2901
2972
  // ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
2902
2973
  // `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
2903
2974
  // parent too". Nothing parent-side read it, so the parent's tool loop appended
@@ -2947,6 +3018,42 @@ outcome('a sub-agent budget wall ends the parent turn without another model call
2947
3018
  assert.is(driver.getHistory().at(-1)!.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2948
3019
  });
2949
3020
 
3021
+ outcome(
3022
+ "a sub-agent wall with headroom elsewhere composes the parent's switch advice",
3023
+ async () => {
3024
+ // The parent's bubble is composed from the latched `budgetWallDetail` — on the
3025
+ // sub-agent path there is NO error object in scope, only the failure payload
3026
+ // the child attributed (this pins the exact seam a scope bug once lived on).
3027
+ clearMetaEventRegistry();
3028
+ let parentCalls = 0;
3029
+ const provider: AIProvider = {
3030
+ chat: async (
3031
+ _h: ChatMessage[],
3032
+ _u: string,
3033
+ options?: ChatRequestOptions,
3034
+ ): Promise<ChatMessage> => {
3035
+ if ((options?.tools ?? []).some((t) => t.name === 'delegate')) {
3036
+ parentCalls += 1;
3037
+ return callsTool('delegate', `d${parentCalls}`);
3038
+ }
3039
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, {
3040
+ otherVendorAvailable: true,
3041
+ });
3042
+ },
3043
+ };
3044
+ const parent = delegatingParent(walledWorker(), () => undefined);
3045
+ const driver = makeDriver(parent, provider, 'outcome-subagent-budget-switch', outcomeBus);
3046
+
3047
+ const result: ChatDriverResult = await driver.sendMessage('go');
3048
+
3049
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
3050
+ assert.is(
3051
+ driver.getHistory().at(-1)!.content,
3052
+ "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.",
3053
+ );
3054
+ },
3055
+ );
3056
+
2950
3057
  outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", async () => {
2951
3058
  // A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
2952
3059
  // `config.provider`). The wall reaches the parent only as a tool result, so
@@ -289,8 +289,13 @@ export interface ChatDriverConfig {
289
289
  */
290
290
  activityBus?: ActivityBus;
291
291
  /**
292
- * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464).
293
- * Defaults to `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`.
292
+ * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464),
293
+ * used VERBATIM — set it only to own the copy entirely (white-labelling).
294
+ * When unset, the driver composes the bubble per wall: the neutral
295
+ * `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, upgraded with "switch to another AI
296
+ * provider" advice only when the 402's `otherVendorAvailable` positively says
297
+ * another metered vendor still has headroom — the same rule the blocked
298
+ * banner applies, so the two surfaces can no longer contradict each other.
294
299
  *
295
300
  * The assistant element already lets a host override the blocked **banner**
296
301
  * via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
@@ -652,8 +657,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
652
657
  private readonly sessionKey: string;
653
658
  /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
654
659
  private readonly activityBus: ActivityBus;
655
- /** Transcript copy for a budget wall — see `ChatDriverConfig.budgetExhaustedMessage`. */
656
- private readonly budgetExhaustedMessage: string;
660
+ /**
661
+ * Host override for the budget-wall transcript copy, used verbatim — see
662
+ * `ChatDriverConfig.budgetExhaustedMessage`. `undefined` means no override,
663
+ * and the bubble is composed per wall by {@link ChatDriver.budgetExhaustedBubble}.
664
+ */
665
+ private readonly budgetExhaustedMessageOverride?: string;
657
666
  /**
658
667
  * Set the moment a budget wall is observed anywhere in this turn — this
659
668
  * driver's own 402, or a sub-agent's (which surfaces here only as a
@@ -702,13 +711,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
702
711
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
703
712
  sessionKey = '',
704
713
  activityBus = NOOP_ACTIVITY_BUS,
705
- budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
714
+ budgetExhaustedMessage,
706
715
  } = config;
707
716
  this.maxToolIterations = maxToolIterations;
708
717
  this.condenseBatchCalls = condenseBatchCalls;
709
718
  this.sessionKey = sessionKey;
710
719
  this.activityBus = activityBus;
711
- this.budgetExhaustedMessage = budgetExhaustedMessage;
720
+ this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
712
721
  if (typeof toolHandlers === 'function') {
713
722
  this.toolHandlersFactory = toolHandlers;
714
723
  this.toolHandlers = {};
@@ -870,6 +879,32 @@ export class ChatDriver extends EventTarget implements AiDriver {
870
879
  *
871
880
  * @internal
872
881
  */
882
+ private budgetExhaustedBubble(wall?: {
883
+ vendorLabel?: string;
884
+ otherVendorAvailable?: boolean;
885
+ }): string {
886
+ // A host override wins verbatim — the white-label contract on
887
+ // `ChatDriverConfig.budgetExhaustedMessage` (its copy may not even be
888
+ // English; composing onto it would mangle it).
889
+ if (this.budgetExhaustedMessageOverride != null) return this.budgetExhaustedMessageOverride;
890
+ // Only a POSITIVE `otherVendorAvailable` earns the switch advice. `false`
891
+ // means every other metered vendor is exhausted too, and `undefined` means
892
+ // an older proxy did not say — in both cases advertising a switch to a
893
+ // vendor that may itself be walled is worse than the neutral default. This
894
+ // mirrors the blocked banner's rule, and closes the gap where the banner
895
+ // said "switch" while the transcript bubble directly under it said only
896
+ // "contact your administrator" (GENC-1464 tester finding).
897
+ if (wall?.otherVendorAvailable !== true) return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
898
+ // The label is display copy, nothing more — attribution (which vendor gets
899
+ // WALLED) stays `budgetDetailOf`'s job. The parameter is the shared shape of
900
+ // both call sites: the thrown `BudgetExhaustedError` on the direct path, and
901
+ // the latched `budgetWallDetail` on the sub-agent path.
902
+ const subject = wall?.vendorLabel
903
+ ? `${wall.vendorLabel}'s AI usage limit`
904
+ : 'your AI usage limit';
905
+ return `You've reached ${subject}. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.`;
906
+ }
907
+
873
908
  reportBudgetExhausted(
874
909
  e: BudgetExhaustedError,
875
910
  pendingUserMessage?: ChatMessage,
@@ -894,7 +929,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
894
929
  spentUsd: e.spentUsd,
895
930
  isSubAgent: this.isSubAgent,
896
931
  });
897
- this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
932
+ this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedBubble(e) });
898
933
  return this.turnDone('budget-exhausted', budgetDetailOf(e));
899
934
  }
900
935
 
@@ -2506,7 +2541,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
2506
2541
  if (this.isSubAgent) {
2507
2542
  this.failSubAgent('budget_exhausted', this.budgetWallDetail);
2508
2543
  } else {
2509
- this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
2544
+ // Composed from the DETAIL, not an error object: on this path the wall
2545
+ // arrived as a sub-agent's failure payload — `budgetWallDetail`, first
2546
+ // attribution wins — and an unattributable child wall (detail
2547
+ // undefined) composes the neutral default.
2548
+ this.appendToHistory({
2549
+ role: 'assistant',
2550
+ content: this.budgetExhaustedBubble(this.budgetWallDetail),
2551
+ });
2510
2552
  }
2511
2553
  // Carried onto the result so `loopEndDetail` publishes the refusing
2512
2554
  // vendor rather than falling through to `lastResolvedProvider`, and the
@@ -289,6 +289,65 @@ Suite('el.blocked = true after a host reason keeps the reason', () => {
289
289
 
290
290
  // ── The driver latch (GENC-1464) ───────────────────────────────────────────────
291
291
 
292
+ /** The raw stored figures, read the way budget-meter.test.ts reads them. */
293
+ const storedBudgets = (el: FoundationAiAssistant) =>
294
+ (
295
+ el as unknown as {
296
+ _sessionRef: { store: { aiAssistant: { vendorBudgets: Record<string, unknown> } } };
297
+ }
298
+ )._sessionRef.store.aiAssistant.vendorBudgets;
299
+
300
+ Suite('the transcript override is passed only when a blockedReason actually exists', () => {
301
+ // Forcing DEFAULT_BUDGET_EXHAUSTED_MESSAGE here was what made the driver's
302
+ // per-wall composed bubble unreachable in every real mount (the banner said
303
+ // "switch", the bubble under it never did). `undefined` hands composition to
304
+ // the driver; a set reason — host white-labelling, or the vendor-agnostic
305
+ // latch's advice-free copy — still wins verbatim.
306
+ const read = (el: FoundationAiAssistant) =>
307
+ (el as unknown as { transcriptBudgetExhaustedMessage?: string })
308
+ .transcriptBudgetExhaustedMessage;
309
+
310
+ const el = element();
311
+ assert.is(read(el), undefined, 'no reason -> the driver composes per wall');
312
+
313
+ el.setBlocked(true, 'Contact ops@acme.com');
314
+ assert.is(read(el), 'Contact ops@acme.com', 'a set reason wins verbatim');
315
+
316
+ el.setBlocked(false);
317
+ assert.is(read(el), undefined, 'unblocking hands composition back to the driver');
318
+ });
319
+
320
+ Suite('a wall with figures snaps the vendor meter at latch time', () => {
321
+ // The GENC-1464 stale-meter finding: hosts refresh the meter on
322
+ // `tool-loop-end`, which a walled turn may never reach — the 402's own
323
+ // figures are the freshest truth available and must land immediately.
324
+ const el = element();
325
+ reachable(el, 'anthropic', 'gemini');
326
+ latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
327
+ assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
328
+
329
+ // A later 402 for the same (already-walled) vendor carries fresher spend —
330
+ // the feed is deliberately outside the idempotence guard, like the sweep.
331
+ latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 21.5 });
332
+ assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 21.5 });
333
+ });
334
+
335
+ Suite('a figure-less or unattributable wall feeds no meter', () => {
336
+ // Half a meter is a lie: `VendorBudgetFigures` is total, so one missing
337
+ // figure means no feed at all — the wall itself still latches.
338
+ const el = element();
339
+ reachable(el, 'anthropic', 'gemini');
340
+ latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', spentUsd: 21 });
341
+ assert.is(storedBudgets(el)['anthropic'], undefined);
342
+ assert.is(el.isVendorBlocked('anthropic'), true, 'the latch itself is unaffected');
343
+
344
+ // No vendor resolves: nothing to key the meter on.
345
+ const el2 = element();
346
+ reachable(el2, 'anthropic', 'gemini');
347
+ latch(el2, 'budget-exhausted', { vendorLabel: 'Acme Gateway', budgetUsd: 20, spentUsd: 21 });
348
+ assert.equal(storedBudgets(el2), {});
349
+ });
350
+
292
351
  Suite('latchBlockedFrom latches only on budget-exhausted', () => {
293
352
  const budget = element();
294
353
  latch(budget, 'budget-exhausted');
package/src/main/main.ts CHANGED
@@ -1232,6 +1232,23 @@ export class FoundationAiAssistant extends GenesisElement {
1232
1232
  ): void {
1233
1233
  if (reason !== 'budget-exhausted') return;
1234
1234
  const vendor = vendorTypeOfLabel(budget?.vendorLabel) ?? budget?.vendor ?? vendorHint;
1235
+ // Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
1236
+ // itself reported, the moment the wall lands. Hosts refresh the meter on
1237
+ // `tool-loop-end`, and a walled turn may never reach one — the user then
1238
+ // stares at the pre-turn figures next to a banner saying the budget is gone
1239
+ // until something else ends a loop (observed live: the meter only caught up
1240
+ // when the user cancelled the stuck interaction). Deliberately NOT under the
1241
+ // per-vendor idempotence guard below, for the same reason as the sweep: a
1242
+ // later 402 for an already-walled vendor carries fresher spend. Both figures
1243
+ // are required (`VendorBudgetFigures` is total, and half a meter is a lie),
1244
+ // and the write goes through the captured `ref` like every other write here,
1245
+ // so a teardown race cannot land it in a new session's store.
1246
+ if (vendor && vendor !== 'none' && budget?.budgetUsd != null && budget?.spentUsd != null) {
1247
+ ref?.actions.aiAssistant.setVendorBudget({
1248
+ vendor,
1249
+ figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1250
+ });
1251
+ }
1235
1252
  if (!vendor || vendor === 'none') {
1236
1253
  if (!this.blocked) {
1237
1254
  ref?.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
@@ -1362,27 +1379,29 @@ export class FoundationAiAssistant extends GenesisElement {
1362
1379
  }
1363
1380
 
1364
1381
  /**
1365
- * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
1382
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
1383
+ * or `undefined` to let the driver compose it per wall.
1366
1384
  *
1367
- * A host-set `blockedReason` still wins, so a white-labelled host does not read
1368
- * its own explanation in the banner and the shipped default directly below it
1369
- * that is the whole reason the driver takes this at all.
1385
+ * A set `blockedReason` still wins a white-labelled host must not read its
1386
+ * own explanation in the banner and shipped copy directly below it, and the
1387
+ * vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
1388
+ * exactly as before.
1370
1389
  *
1371
- * What it deliberately is NOT is
1372
- * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1373
- * composes advice `"…Switch to Gemini to keep going."` which is true only
1374
- * while that vendor has headroom, and the transcript is a permanent record. The
1375
- * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1376
- * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1377
- * freeze that sentence into every later turn's bubble. Reading the latch's own
1378
- * copy instead is structurally incapable of carrying the advice: the only
1379
- * writer of `blockedReason` is the vendor-agnostic branch of
1380
- * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1381
- * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1382
- * banner alone.
1390
+ * `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
1391
+ * fallback: it hands composition to the driver, which builds the bubble from
1392
+ * EACH wall's own 402 naming the exhausted vendor and advising a switch only
1393
+ * when that refusal's `otherVendorAvailable` positively vouched for headroom
1394
+ * elsewhere. That is immune to the staleness this getter used to guard against
1395
+ * by freezing the copy: construction-time advice could outlive its truth
1396
+ * (`getOrCreateDriver` re-runs on an agents swap and would have baked the
1397
+ * sentence into every later bubble), but per-wall advice is derived from the
1398
+ * refusal it annotates and dies with the turn. Forcing the default here was
1399
+ * also what made the driver's composed bubble unreachable in every real
1400
+ * mount the banner advised switching while the bubble under it never did
1401
+ * (the GENC-1464 tester finding, root cause).
1383
1402
  */
1384
- private get transcriptBudgetExhaustedMessage(): string {
1385
- return this.blockedReason ?? DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1403
+ private get transcriptBudgetExhaustedMessage(): string | undefined {
1404
+ return this.blockedReason ?? undefined;
1386
1405
  }
1387
1406
 
1388
1407
  /**