@genesislcap/ai-assistant 15.10.3 → 15.10.5

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
@@ -40,11 +40,21 @@ function triggerReason(trigger) {
40
40
  return 'superseded';
41
41
  }
42
42
  }
43
- /** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
44
- function condenseStub(target, tool, trigger, origLen) {
43
+ /**
44
+ * Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
45
+ * (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
46
+ * file a collapsed `vfs_write` wrote) and the original size.
47
+ *
48
+ * `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
49
+ * reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
50
+ * and must not carry the clause. Identifying information is not what separates them — a stub too
51
+ * bare to say what the call did is how GENC-1494 started.
52
+ */
53
+ function condenseStub(target, tool, trigger, origLen, restorable) {
45
54
  const what = target === 'args' ? 'args' : 'result';
46
55
  const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
47
- return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
56
+ const restore = restorable ? '; re-call to restore' : '';
57
+ return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
48
58
  }
49
59
  /** Stable label for the `context.condensed` meta-event's `trigger` field. */
50
60
  function triggerLabel(trigger) {
@@ -81,10 +91,12 @@ function marksDiscontinuity(kind) {
81
91
  * Collapse stale tool payloads (declared via `condenseWhen`) out of the
82
92
  * model-bound history. Pure over the `history` array — it emits new message
83
93
  * objects and never mutates the input messages (mirroring the agent-masking
84
- * transform). It DOES, however, flip the `reported*` flag on the matching
85
- * `policies` entry the first time a payload collapses — the report-once gate that
86
- * keeps `onCondensed` firing once per (tool call, payload) even though the
87
- * collapse itself re-runs before every provider call.
94
+ * transform). It DOES, however, write two latches onto the matching `policies`
95
+ * entry the first time a payload collapses, both because the collapse itself
96
+ * re-runs before every provider call: the `reported*` flag (the report-once gate
97
+ * that keeps `onCondensed` firing once per (tool call, payload)) and
98
+ * `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
99
+ * once and never rewritten).
88
100
  *
89
101
  * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
90
102
  * collapses the payload; all are monotonic, so the collapse never reverts):
@@ -172,7 +184,20 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
172
184
  };
173
185
  // The first listed trigger that fires — drives the collapse, the stub reason,
174
186
  // and the event label. `undefined` means the payload stays full.
175
- const firstFired = (toolCallId, entry) => triggersOf(entry).find((t) => (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t));
187
+ //
188
+ // Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
189
+ // change under a payload that has already collapsed, which rewrites any reason-bearing stub
190
+ // and costs a second cache break. Both payloads of a call share the latch — they are
191
+ // evaluated in the same pass off the same clocks, so they never disagree about which
192
+ // trigger fired; only the size floor decides whether each one renders.
193
+ const firstFired = (toolCallId, entry) => {
194
+ if (entry.firedTrigger)
195
+ return entry.firedTrigger;
196
+ const fired = triggersOf(entry).find((t) => (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t));
197
+ if (fired)
198
+ entry.firedTrigger = fired;
199
+ return fired;
200
+ };
176
201
  return history.map((msg) => {
177
202
  var _a, _b, _c, _d;
178
203
  // Tool-call ARGS live on the assistant message.
@@ -191,16 +216,23 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
191
216
  return tc;
192
217
  changed = true;
193
218
  const result = entry.policy.args;
194
- // Args must stay a Record; `drop` is an empty object, otherwise stash the
195
- // stub under a single key. Spread `tc` so providerMetadata (e.g. the
196
- // Gemini reasoning signature that must round-trip) and UI fields survive.
197
- const args = result === 'drop'
198
- ? {}
199
- : {
200
- [CONDENSED_ARGS_KEY]: result === 'pointer'
201
- ? condenseStub('args', tc.name, fired, origLen)
202
- : result.replaceWith,
203
- };
219
+ // Args must stay a Record, so every mode stashes its stub under a single key.
220
+ // Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
221
+ // round-trip) and UI fields survive.
222
+ //
223
+ // GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
224
+ // from a call the model made with NO arguments, and the model reads its own history
225
+ // as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
226
+ // a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
227
+ // without content, and burned two hours re-writing files that were already correct.
228
+ // A bare marker is not enough either — it still fails to say WHICH file the call
229
+ // wrote, since `path` went with the args. So `drop` renders the same identifying stub
230
+ // as `pointer`, minus only the restore promise it cannot honour.
231
+ const args = {
232
+ [CONDENSED_ARGS_KEY]: result === 'drop' || result === 'pointer'
233
+ ? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
234
+ : result.replaceWith,
235
+ };
204
236
  if (!entry.reportedArgs) {
205
237
  entry.reportedArgs = true;
206
238
  const stubLen = JSON.stringify(args).length;
@@ -231,11 +263,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
231
263
  const tool = (_d = nameById.get(msg.toolResult.toolCallId)) !== null && _d !== void 0 ? _d : msg.toolResult.toolCallId;
232
264
  const result = entry.policy.response; // `fired` is only set when response is declared
233
265
  // Kept non-empty — Anthropic rejects empty tool_result content.
234
- const content = result === 'drop'
235
- ? '[elided]'
236
- : result === 'pointer'
237
- ? condenseStub('response', tool, fired, origLen)
238
- : result.replaceWith;
266
+ const content = result === 'drop' || result === 'pointer'
267
+ ? condenseStub('response', tool, fired, origLen, result === 'pointer')
268
+ : result.replaceWith;
239
269
  if (!entry.reportedResponse) {
240
270
  entry.reportedResponse = true;
241
271
  onCondensed({
@@ -1,5 +1,5 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { applyCondensation, CONDENSE_MIN_CHARS, } from './condense-history';
2
+ import { applyCondensation, CONDENSE_MIN_CHARS, CONDENSED_ARGS_KEY, } from './condense-history';
3
3
  const suite = createLogicSuite('applyCondensation');
4
4
  // Payloads must clear the driver-level floor to be condensed.
5
5
  const big = (n = CONDENSE_MIN_CHARS + 500) => 'x'.repeat(n);
@@ -154,11 +154,20 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
154
154
  ]);
155
155
  const { on } = sink();
156
156
  const out = applyCondensation(history, policies, ctx(), on);
157
- assert.is(out[1].toolResult.content, '[elided]', 'drop is a minimal non-empty placeholder');
157
+ // `drop` differs from `pointer` ONLY by the restore promise — it still identifies the call,
158
+ // because a stub too bare to say what the call did is how GENC-1494 started.
159
+ assert.match(out[1].toolResult.content, /vfs_read/, 'drop names the tool');
160
+ assert.match(out[1].toolResult.content, /result elided/, 'drop says what was elided');
161
+ assert.not.match(out[1].toolResult.content, /re-call to restore/, 'drop makes no restore promise');
158
162
  assert.match(out[5].toolResult.content, /re-call to restore/, 'pointer carries the restore hint');
159
163
  assert.is(out[9].toolResult.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
160
164
  });
161
- suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
165
+ // GENC-1494: dropped args must NEVER collapse to `{}`. A bare empty object is
166
+ // indistinguishable from a call the model made with no arguments at all, and the model
167
+ // treats its own history as fact — a ui-builder run read its elided `vfs_write` calls as
168
+ // proof it was writing files with no content and re-wrote them for two hours. A bare marker
169
+ // is not enough either: `path` goes with the args, so the stub must still say WHICH file.
170
+ suite('args drop identifies the call; pointer stashes a stub under one key', () => {
162
171
  const history = [
163
172
  asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
164
173
  toolMsg('w1', 'ok'),
@@ -171,9 +180,45 @@ suite('args drop yields an empty object; pointer stashes a stub under one key',
171
180
  ]);
172
181
  const { on } = sink();
173
182
  const out = applyCondensation(history, policies, ctx(), on);
174
- assert.equal(out[0].toolCalls[0].args, {}, 'dropped args is an empty object');
183
+ const dropped = out[0].toolCalls[0].args;
184
+ const stub = String(dropped[CONDENSED_ARGS_KEY]);
185
+ assert.ok(Object.keys(dropped).length > 0, 'dropped args are never empty — that reads as a no-argument call');
186
+ assert.match(stub, /vfs_write/, 'stub names the tool');
187
+ assert.match(stub, /\bA\b/, 'stub names the superseded key — which file this call wrote');
188
+ assert.not.match(stub, /re-call to restore/, 'drop must not promise a restore it cannot honour');
175
189
  assert.equal(out[2].toolCalls[0].args, { path: 'A', content: big() }, 'latest write kept');
176
190
  });
191
+ // GENC-1494: trigger resolution picks the first LISTED trigger that is currently true, so it
192
+ // used to change after the collapse — with the usual `on: [{superseded}, {agentEnd}]`, a payload
193
+ // collapsed at `agentEnd` re-resolved to `superseded` the moment a newer same-key call landed,
194
+ // rewriting the stub and breaking the prompt cache a second time for an already-collapsed
195
+ // payload. In one reported session that was 22/22 args collapses and 97 response collapses.
196
+ suite('the fired trigger is latched, so a collapsed stub is never rewritten', () => {
197
+ const history = [asstCall('r1', 'vfs_read', { path: 'A' }), toolMsg('r1', big())];
198
+ const entry = reg({
199
+ on: [{ kind: 'superseded', by: 'A' }, { kind: 'agentEnd' }],
200
+ response: 'pointer', // pointer embeds the reason, so a re-resolve is visible in the text
201
+ });
202
+ const policies = new Map([['r1', entry]]);
203
+ const { events, on } = sink();
204
+ // Pass 1: the activation has ended but nothing supersedes r1 yet → collapses as agentEnd.
205
+ const first = applyCondensation(history, policies, ctx({ activationEnded: () => true }), on);
206
+ assert.match(first[1].toolResult.content, /agent finished/, 'collapses under agentEnd');
207
+ assert.is(events.length, 1);
208
+ assert.is(events[0].trigger, 'agentEnd');
209
+ // Pass 2: a newer read of the SAME key now exists, which would re-resolve the trigger to
210
+ // `superseded` (it is listed first). The already-collapsed stub must not change.
211
+ const grown = [
212
+ ...history,
213
+ asstCall('r2', 'vfs_read', { path: 'A' }),
214
+ toolMsg('r2', big()),
215
+ ];
216
+ policies.set('r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' }));
217
+ const second = applyCondensation(grown, policies, ctx({ activationEnded: () => true }), on);
218
+ assert.match(second[1].toolResult.content, /agent finished/, 'reason stays pinned to agentEnd');
219
+ assert.not.match(second[1].toolResult.content, /superseded/, 'must not re-resolve');
220
+ assert.is(second[1].toolResult.content, first[1].toolResult.content, 'stub text is byte-stable');
221
+ });
177
222
  // ---------------------------------------------------------------------------
178
223
  // envelope + metadata invariants
179
224
  // ---------------------------------------------------------------------------
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.3",
4
+ "version": "15.10.5",
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.3",
77
- "@genesislcap/genx": "15.10.3",
78
- "@genesislcap/rollup-builder": "15.10.3",
79
- "@genesislcap/ts-builder": "15.10.3",
80
- "@genesislcap/uvu-playwright-builder": "15.10.3",
81
- "@genesislcap/vite-builder": "15.10.3",
82
- "@genesislcap/webpack-builder": "15.10.3",
76
+ "@genesislcap/foundation-testing": "15.10.5",
77
+ "@genesislcap/genx": "15.10.5",
78
+ "@genesislcap/rollup-builder": "15.10.5",
79
+ "@genesislcap/ts-builder": "15.10.5",
80
+ "@genesislcap/uvu-playwright-builder": "15.10.5",
81
+ "@genesislcap/vite-builder": "15.10.5",
82
+ "@genesislcap/webpack-builder": "15.10.5",
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.3",
89
- "@genesislcap/foundation-logger": "15.10.3",
90
- "@genesislcap/foundation-notifications": "15.10.3",
91
- "@genesislcap/foundation-redux": "15.10.3",
92
- "@genesislcap/foundation-ui": "15.10.3",
93
- "@genesislcap/foundation-utils": "15.10.3",
94
- "@genesislcap/rapid-design-system": "15.10.3",
95
- "@genesislcap/web-core": "15.10.3",
88
+ "@genesislcap/foundation-ai": "15.10.5",
89
+ "@genesislcap/foundation-logger": "15.10.5",
90
+ "@genesislcap/foundation-notifications": "15.10.5",
91
+ "@genesislcap/foundation-redux": "15.10.5",
92
+ "@genesislcap/foundation-ui": "15.10.5",
93
+ "@genesislcap/foundation-utils": "15.10.5",
94
+ "@genesislcap/rapid-design-system": "15.10.5",
95
+ "@genesislcap/web-core": "15.10.5",
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": "6e5c9bb135b3da6792c4c3a8e632b09b0bc3fbca"
108
+ "gitHead": "3d50e0bb42cbf0f4a92b4bc74114c2bc7f581e4d"
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');