@genesislcap/ai-assistant 15.6.2 → 15.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +613 -6
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +254 -10
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +394 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +11 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +969 -0
  37. package/dist/esm/main/main.js +704 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +145 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1316 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -2,6 +2,7 @@ import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
3
  import {
4
4
  aiAssistantSlice,
5
+ type AiAssistantSessionState,
5
6
  createDefaultSessionState,
6
7
  type LoadSessionPayload,
7
8
  } from './ai-assistant-slice';
@@ -11,6 +12,10 @@ import {
11
12
  const {
12
13
  loadSession,
13
14
  resetSession,
15
+ setBlocked,
16
+ setBlockedReason,
17
+ setVendorBlocked,
18
+ setInputValue,
14
19
  setMessages,
15
20
  setState,
16
21
  setSessionMenuOpen,
@@ -118,4 +123,214 @@ Suite('setSessionMenuOpen toggles the lifecycle-menu flag; resetSession clears i
118
123
  assert.is(cleared.sessionMenuOpen, false);
119
124
  });
120
125
 
126
+ // GENC-1464: the backend-blocked latch (exhausted AI budget).
127
+ Suite('setBlocked latches the flag and its reason', () => {
128
+ let state = createDefaultSessionState();
129
+ assert.is(state.blocked, false);
130
+ assert.is(state.blockedReason, null);
131
+
132
+ state = reduce(state, setBlocked({ blocked: true }));
133
+ assert.is(state.blocked, true);
134
+ assert.is(state.blockedReason, null, 'no reason given → null, so the element uses its default');
135
+
136
+ state = reduce(
137
+ state,
138
+ setBlocked({ blocked: true, reason: 'Budget exhausted — contact billing' }),
139
+ );
140
+ assert.is(state.blockedReason, 'Budget exhausted — contact billing');
141
+ });
142
+
143
+ Suite('unblocking always clears the reason', () => {
144
+ // A stale explanation must never outlive the condition it described.
145
+ let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'gone' }));
146
+ state = reduce(state, setBlocked({ blocked: false }));
147
+ assert.is(state.blocked, false);
148
+ assert.is(state.blockedReason, null);
149
+ });
150
+
151
+ Suite('resetSession preserves the block — "New chat" does not refill the budget', () => {
152
+ // Deliberately unlike sessionMenuOpen/compacting above: this is a backend
153
+ // condition, not conversation state, so Clear must not hand the user a
154
+ // working-looking composer that fails on the next send.
155
+ let state = reduce(
156
+ createDefaultSessionState(),
157
+ setBlocked({ blocked: true, reason: 'no funds' }),
158
+ );
159
+ state = reduce(state, setMessages([{ role: 'user', content: 'x' }] as ChatMessage[]));
160
+
161
+ const cleared = reduce(state, resetSession());
162
+
163
+ assert.is(cleared.messages.length, 0, 'the transcript is still cleared');
164
+ assert.is(cleared.blocked, true, 'the block survives Clear');
165
+ assert.is(cleared.blockedReason, 'no funds', 'and so does its reason');
166
+ });
167
+
168
+ Suite('re-latching without a reason preserves the host explanation', () => {
169
+ // The race this fixes is deterministic, not theoretical: `tool-loop-end` is
170
+ // published from the driver's `finally`, i.e. BEFORE `sendMessage()` resolves.
171
+ // So a bus-subscribed host (what the migration guide's Option C recommends)
172
+ // writes its detailed copy first, and the element's own latch — which may have
173
+ // no figures to offer — lands a moment later. Treating an OMITTED reason as
174
+ // "clear it" replaced the host's copy with generic copy every single time.
175
+ let state = reduce(
176
+ createDefaultSessionState(),
177
+ setBlocked({ blocked: true, reason: 'contact ops@acme.com' }),
178
+ );
179
+
180
+ state = reduce(state, setBlocked({ blocked: true }));
181
+
182
+ assert.is(state.blocked, true);
183
+ assert.is(state.blockedReason, 'contact ops@acme.com', 'the host copy survives the re-latch');
184
+ });
185
+
186
+ Suite('reason:null clears the explanation while staying blocked', () => {
187
+ // The explicit escape hatch the omitted case gives up: `null` still clears.
188
+ let state = reduce(
189
+ createDefaultSessionState(),
190
+ setBlocked({ blocked: true, reason: 'stale copy' }),
191
+ );
192
+
193
+ state = reduce(state, setBlocked({ blocked: true, reason: null }));
194
+
195
+ assert.is(state.blocked, true);
196
+ assert.is(state.blockedReason, null, 'the banner falls back to the default copy');
197
+ });
198
+
199
+ Suite('resetSession still resets everything NOT on the preserved list', () => {
200
+ // Pins the boundary from the other side, so the preserved list cannot quietly
201
+ // grow: these are conversation/UI state and Clear must wipe them.
202
+ let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'r' }));
203
+ state = reduce(state, setSessionMenuOpen(true));
204
+ state = reduce(state, setInputValue('half-typed message'));
205
+
206
+ const cleared = reduce(state, resetSession());
207
+
208
+ assert.is(cleared.inputValue, '', 'the composer draft is reset');
209
+ assert.is(cleared.sessionMenuOpen, false, 'the panel flag is reset');
210
+ assert.is(cleared.blocked, true, '…while the block is still preserved');
211
+ });
212
+
213
+ // ── Per-vendor latches (GENC-1464 per-vendor budgets) ──────────────────────────
214
+
215
+ Suite('setVendorBlocked walls one vendor and keeps the wall order', () => {
216
+ let state = reduce(
217
+ createDefaultSessionState(),
218
+ setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'anthropic copy' }),
219
+ );
220
+ assert.equal(state.blockedVendors, ['anthropic']);
221
+ assert.equal(state.blockedVendorReasons, { anthropic: 'anthropic copy' });
222
+ assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
223
+
224
+ state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
225
+ assert.equal(
226
+ state.blockedVendors,
227
+ ['anthropic', 'gemini'],
228
+ 'order is the order they were walled',
229
+ );
230
+ });
231
+
232
+ Suite('walling an already-walled vendor is idempotent', () => {
233
+ let state = reduce(
234
+ createDefaultSessionState(),
235
+ setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'first' }),
236
+ );
237
+ state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'second' }));
238
+ assert.equal(state.blockedVendors, ['gemini'], 'no duplicate entry');
239
+ assert.is(state.blockedVendorReasons.gemini, 'second');
240
+ });
241
+
242
+ Suite('the per-vendor reason honours the same three cases as setBlocked', () => {
243
+ // The same two-writer race (driver latch landing after a bus-subscribed host)
244
+ // applies per vendor, so "omitted keeps" has to hold here too.
245
+ let state = reduce(
246
+ createDefaultSessionState(),
247
+ setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'host copy' }),
248
+ );
249
+
250
+ state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true }));
251
+ assert.is(state.blockedVendorReasons.anthropic, 'host copy', 'omitted keeps');
252
+
253
+ state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: null }));
254
+ assert.is(state.blockedVendorReasons.anthropic, undefined, 'null clears');
255
+ });
256
+
257
+ Suite('releasing a vendor drops it and its copy', () => {
258
+ let state = reduce(
259
+ createDefaultSessionState(),
260
+ setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }),
261
+ );
262
+ state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
263
+
264
+ state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: false }));
265
+
266
+ assert.equal(state.blockedVendors, ['gemini']);
267
+ assert.is(state.blockedVendorReasons.anthropic, undefined);
268
+ });
269
+
270
+ Suite('setBlocked(false) also clears every per-vendor latch', () => {
271
+ // THE back-compat rule. A host asserting "the wall is gone" after its own
272
+ // pre-flight (migration-GENC-1464 Option B) must not be overruled by a driver
273
+ // latch it cannot see — without this, per-vendor would silently make the
274
+ // documented unblock stop working.
275
+ let state = reduce(
276
+ createDefaultSessionState(),
277
+ setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }),
278
+ );
279
+ state = reduce(state, setBlocked({ blocked: true, reason: 'global' }));
280
+
281
+ state = reduce(state, setBlocked({ blocked: false }));
282
+
283
+ assert.is(state.blocked, false);
284
+ assert.is(state.blockedReason, null);
285
+ assert.equal(state.blockedVendors, []);
286
+ assert.equal(state.blockedVendorReasons, {});
287
+ });
288
+
289
+ Suite('setBlockedReason writes the copy without disturbing any latch', () => {
290
+ // It exists precisely so the element's `blockedReason` setter cannot route
291
+ // through `setBlocked(false, …)` — which would now clear the vendor latches.
292
+ let state = reduce(
293
+ createDefaultSessionState(),
294
+ setVendorBlocked({ vendor: 'gemini', blocked: true }),
295
+ );
296
+
297
+ state = reduce(state, setBlockedReason('host copy'));
298
+
299
+ assert.is(state.blockedReason, 'host copy');
300
+ assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
301
+ assert.equal(state.blockedVendors, ['gemini'], 'and so are the per-vendor latches');
302
+ });
303
+
304
+ Suite('resetSession preserves the per-vendor latches too', () => {
305
+ let state = reduce(
306
+ createDefaultSessionState(),
307
+ setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'no funds' }),
308
+ );
309
+ state = reduce(state, setMessages([{ role: 'user', content: 'hi' }] as ChatMessage[]));
310
+
311
+ const cleared = reduce(state, resetSession());
312
+
313
+ assert.equal(cleared.messages, [], 'the conversation is wiped');
314
+ assert.equal(cleared.blockedVendors, ['anthropic'], 'Clear does not refill a vendor budget');
315
+ assert.equal(cleared.blockedVendorReasons, { anthropic: 'no funds' });
316
+ });
317
+
318
+ // `resetSession` preserves its keys through `Object.assign`, whose source parameter
319
+ // is structurally permissive: a typo like `blockedResaon` would compile clean,
320
+ // silently stop preserving the real key, AND leak a junk key into serialised state.
321
+ // The reducer therefore annotates its literal `satisfies
322
+ // Partial<AiAssistantSessionState>`, which is a COMPILE-time guarantee — so the
323
+ // durable proof is a type fixture, not a runtime assertion.
324
+ //
325
+ // This mirrors the reducer's clause on the same typo. If `satisfies` ever stopped
326
+ // rejecting excess properties (a TS behaviour change, or someone widening the
327
+ // annotated type), the `@ts-expect-error` below becomes unused and the BUILD fails
328
+ // — which is exactly the signal wanted. The runtime suites above are its
329
+ // behavioural companions: they pin the preserved list from both sides.
330
+ const _typoIsRejectedAtCompileTime = {
331
+ // @ts-expect-error — an excess/misspelled key must be rejected by `satisfies`
332
+ blockedResaon: null,
333
+ } satisfies Partial<AiAssistantSessionState>;
334
+ void _typoIsRejectedAtCompileTime;
335
+
121
336
  Suite.run();
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AggregateUsage,
3
3
  AIProviderRegistryStatusEntry,
4
+ AIProviderType,
4
5
  ChatInputDuringExecutionMode,
5
6
  ChatMessage,
6
7
  } from '@genesislcap/foundation-ai';
@@ -62,6 +63,10 @@ export interface AiAssistantSessionState {
62
63
  * Snapshot of every registered provider's status, in registration order.
63
64
  * Populated on connect (or on settings-open if not yet loaded) and reused
64
65
  * for the lifetime of the session — provider registration is static.
66
+ *
67
+ * Describes the registry rather than the conversation, so it survives
68
+ * {@link resetSession} ("Clear" / "New chat"); see the reasoning there. Only
69
+ * a registry change (via the observable-registry subscription) rewrites it.
65
70
  */
66
71
  providerStatuses: AIProviderRegistryStatusEntry[];
67
72
  activeAgent: Omit<AgentConfig, 'toolHandlers'> | undefined;
@@ -117,6 +122,66 @@ export interface AiAssistantSessionState {
117
122
  * being clobbered** when the restored history loads.
118
123
  */
119
124
  restoring: boolean;
125
+ /**
126
+ * Whether the assistant is **blocked by a backend condition** and cannot send
127
+ * at all — today, an exhausted AI-spend budget (GENC-1464). Unlike `compacting`
128
+ * and `restoring`, which are short-lived in-flight flags that clear
129
+ * themselves, this is a **latched** state: nothing the user or the element
130
+ * does clears it, because nothing they can do fixes it. It stays set until the
131
+ * host writes `blocked = false` (typically after a fresh pre-flight shows
132
+ * budget available again).
133
+ *
134
+ * Also unlike those two, it does **not** replace the transcript — the user
135
+ * keeps their conversation and gets a banner above a disabled composer.
136
+ *
137
+ * In the slice so the banner + input-disable stay in sync across pop-in/out.
138
+ * Preserved across `resetSession` ("Clear" / "New chat"): starting a new chat
139
+ * does not refill the budget.
140
+ *
141
+ * **Vendor-agnostic.** Under per-vendor budgets this means "blocked whatever
142
+ * vendor you use" — a host-set block, or a wall that could not be attributed
143
+ * to a vendor. A wall that WAS attributed lands in
144
+ * {@link AiAssistantSessionState.blockedVendors} instead, and the element
145
+ * derives its public `blocked` from the two together.
146
+ */
147
+ blocked: boolean;
148
+ /**
149
+ * Host- or driver-supplied explanation shown in the blocked banner. `null`
150
+ * falls back to the element's default copy. Meaningless while
151
+ * {@link AiAssistantSessionState.blocked} is false.
152
+ */
153
+ blockedReason: string | null;
154
+ /**
155
+ * Vendors walled by the AI-spend budget (GENC-1464 per-vendor), in the order they
156
+ * were walled.
157
+ *
158
+ * An **array**, not a `Set` and not one `Record<AIProviderType, {...}>`:
159
+ * a `Set` is neither plain JSON for the redux store nor structured-cloneable
160
+ * across the tab channel; the order is what the multi-vendor banner copy
161
+ * ("Anthropic and Gemini") reads; and a combined record would conflate "not
162
+ * walled" with "walled, no reason".
163
+ *
164
+ * Empty for every host that never adopts the per-vendor API — which is what
165
+ * makes the element's derived `blocked` byte-identical to the old stored one.
166
+ */
167
+ blockedVendors: AIProviderType[];
168
+ /**
169
+ * The subset of {@link AiAssistantSessionState.blockedVendors} walled by the
170
+ * `otherVendorAvailable: false` SWEEP rather than by a refusal of their own.
171
+ * Provenance, not a second wall list: for locking, `blockedVendors` remains
172
+ * the whole truth. The distinction exists for the banner's unknown-reachability
173
+ * fallback — a refusal is a fact about a vendor the user just used and is
174
+ * nameable anywhere; a sweep entry is a headroom fact about the PROXY, and
175
+ * naming it before the statuses reveal whether the host even ships that vendor
176
+ * put a vendor in front of the user that may not be theirs.
177
+ */
178
+ sweptVendors: AIProviderType[];
179
+ /**
180
+ * Per-vendor banner copy, keyed by a member of
181
+ * {@link AiAssistantSessionState.blockedVendors}. A missing entry falls back to
182
+ * copy composed from the vendor's display name.
183
+ */
184
+ blockedVendorReasons: Partial<Record<AIProviderType, string>>;
120
185
  /** Draft text in the input box — preserved across pop-in/pop-out cycles. */
121
186
  inputValue: string;
122
187
  /** Live trace from a currently-executing sub-agent. Cleared on sub-agent-stop. */
@@ -179,6 +244,11 @@ export function createDefaultSessionState(): AiAssistantSessionState {
179
244
  sessionMenuOpen: false,
180
245
  compacting: false,
181
246
  restoring: false,
247
+ blocked: false,
248
+ blockedReason: null,
249
+ blockedVendors: [],
250
+ sweptVendors: [],
251
+ blockedVendorReasons: {},
182
252
  inputValue: '',
183
253
  liveSubAgentTrace: [],
184
254
  liveSubAgentName: null,
@@ -258,6 +328,105 @@ export const aiAssistantSlice = createSlice({
258
328
  setRestoring(state, action: PayloadAction<boolean>) {
259
329
  state.restoring = action.payload;
260
330
  },
331
+ /**
332
+ * Latch (or release) the backend-blocked state.
333
+ *
334
+ * `reason` is only meaningful while blocking, and it distinguishes three
335
+ * cases rather than two:
336
+ *
337
+ * - **omitted** — a re-latch that leaves the existing explanation intact.
338
+ * Load-bearing: the block has two writers (the host, and the driver's
339
+ * automatic latch), and the driver's write lands AFTER a bus-subscribed
340
+ * host has already set its own detailed copy. Treating "no reason" as
341
+ * "clear the reason" made that sequence replace the host's figures with
342
+ * generic copy every time, deterministically.
343
+ * - **a string** — replaces the explanation.
344
+ * - **`null`** — explicitly clears the explanation while staying blocked;
345
+ * the banner falls back to the element's default copy.
346
+ *
347
+ * Unblocking always clears it, so a stale explanation can never outlive the
348
+ * condition it described.
349
+ *
350
+ * Unblocking **also clears every per-vendor latch**, and that is the single
351
+ * rule that keeps per-vendor budgets non-breaking. A host asserting "the wall
352
+ * is gone" (the documented pre-flight, migration-GENC-1464 Option B) must not be
353
+ * silently overruled by a driver latch it cannot see; without this, the same
354
+ * `setBlocked(false)` that used to unblock would leave the composer locked.
355
+ */
356
+ setBlocked(state, action: PayloadAction<{ blocked: boolean; reason?: string | null }>) {
357
+ state.blocked = action.payload.blocked;
358
+ if (!action.payload.blocked) {
359
+ state.blockedReason = null;
360
+ state.blockedVendors = [];
361
+ state.sweptVendors = [];
362
+ state.blockedVendorReasons = {};
363
+ } else if (action.payload.reason !== undefined) {
364
+ state.blockedReason = action.payload.reason;
365
+ }
366
+ },
367
+ /**
368
+ * Write the banner explanation without touching the flag.
369
+ *
370
+ * Separate from {@link setBlocked} rather than expressed as
371
+ * `setBlocked({ blocked: current, reason })`, because the element's public
372
+ * `blocked` is now DERIVED: re-latching with the derived value would pass
373
+ * `false` for a partially-walled session and so clear every per-vendor latch
374
+ * as a side effect of setting a string.
375
+ */
376
+ setBlockedReason(state, action: PayloadAction<string | null>) {
377
+ state.blockedReason = action.payload;
378
+ },
379
+ /**
380
+ * Wall (or release) a single vendor's budget.
381
+ *
382
+ * The `reason` trichotomy mirrors {@link setBlocked}'s exactly, and for the
383
+ * same reason: the same two-writer race (the driver's latch landing after a
384
+ * bus-subscribed host has written its own copy) applies per vendor.
385
+ *
386
+ * Adding an already-walled vendor is idempotent — the array is a set with an
387
+ * order, so the vendor keeps the position it was first walled at.
388
+ */
389
+ setVendorBlocked(
390
+ state,
391
+ action: PayloadAction<{
392
+ vendor: AIProviderType;
393
+ blocked: boolean;
394
+ reason?: string | null;
395
+ /**
396
+ * Marks the wall as SWEEP-derived (see `sweptVendors`). Only the
397
+ * element's `otherVendorAvailable: false` sweep passes `true`; a host
398
+ * call or a refusal latch leaves it unset, which also UPGRADES a
399
+ * previously swept vendor — a refusal (or an explicit host statement)
400
+ * is nameable evidence, and first-hand evidence beats the verdict.
401
+ */
402
+ swept?: boolean;
403
+ }>,
404
+ ) {
405
+ const { vendor, blocked, reason, swept } = action.payload;
406
+ const index = state.blockedVendors.indexOf(vendor);
407
+ const sweptIndex = state.sweptVendors.indexOf(vendor);
408
+ if (blocked) {
409
+ if (index < 0) state.blockedVendors.push(vendor);
410
+ if (swept) {
411
+ // Provenance is FIRST-writer-wins: marking swept only records a wall
412
+ // this call itself created. The sweep site already skips walled
413
+ // vendors, so this guard is belt-and-braces against a future caller
414
+ // downgrading a refused wall to "just the proxy's word".
415
+ if (index < 0 && sweptIndex < 0) state.sweptVendors.push(vendor);
416
+ } else if (sweptIndex >= 0) {
417
+ state.sweptVendors.splice(sweptIndex, 1);
418
+ }
419
+ if (reason === null) {
420
+ delete state.blockedVendorReasons[vendor];
421
+ } else if (reason !== undefined) {
422
+ state.blockedVendorReasons[vendor] = reason;
423
+ }
424
+ } else {
425
+ if (index >= 0) state.blockedVendors.splice(index, 1);
426
+ if (sweptIndex >= 0) state.sweptVendors.splice(sweptIndex, 1);
427
+ delete state.blockedVendorReasons[vendor];
428
+ }
429
+ },
261
430
  setInputValue(state, action: PayloadAction<string>) {
262
431
  state.inputValue = action.payload;
263
432
  },
@@ -299,21 +468,62 @@ export const aiAssistantSlice = createSlice({
299
468
  },
300
469
  /**
301
470
  * Wipe the session back to a fresh default — the "Clear" / "New chat" action.
302
- * The user's **display preferences** (tool-call / thinking / narration / agent-switch
303
- * visibility + animations) are deliberately preserved across Clear: they are settings,
304
- * not conversation state, and are persisted independently of the snapshot (GENC-1351).
305
- * Without this they'd flip to their all-off defaults in the live store on Clear, since
306
- * `clearSession` re-applies no baseline and the idempotent setters suppress a re-apply.
471
+ *
472
+ * Three categories survive, for three different reasons keep them distinct:
473
+ *
474
+ * 1. The user's **display preferences** (tool-call / thinking / narration /
475
+ * agent-switch visibility + animations). They are settings, not
476
+ * conversation state, and are persisted independently of the snapshot
477
+ * (GENC-1351). Without this they'd flip to their all-off defaults in the
478
+ * live store on Clear, since `clearSession` re-applies no baseline and the
479
+ * idempotent setters suppress a re-apply.
480
+ * 2. The **backend block** (`blocked` / `blockedReason`, and the per-vendor
481
+ * latches beside them). Not a preference at
482
+ * all — a standing backend condition that Clear cannot change. It is here
483
+ * because starting a new chat does not refill the AI budget, so clearing
484
+ * it would hand the user a working-looking composer that fails on the very
485
+ * next send.
486
+ * 3. The **provider statuses**. They describe the REGISTRY, not the
487
+ * conversation — provider registration is static for the store's lifetime
488
+ * and this snapshot is refreshed from the registry, never from a turn. They
489
+ * are session state only because that is where the element's store lives.
490
+ *
491
+ * Dropping them on Clear is not cosmetic: the element derives `blocked`
492
+ * from "every REACHABLE vendor is walled", and an empty reachable set fails
493
+ * safe to "blocked". So wiping the statuses while keeping the per-vendor
494
+ * latches (category 2) escalated one walled vendor out of two into a fully
495
+ * locked composer, and nothing reloads them — `loadProviderStatuses()` runs
496
+ * only on connect/session-switch and from the observable-registry
497
+ * subscription, so a host with a static registry stayed locked until a
498
+ * remount. Preserving is the fix rather than re-loading after reset,
499
+ * because the reload is async: it would still flash a fully locked composer
500
+ * for a turn of the event loop, and would break a host whose registry is
501
+ * immutable (nothing to re-listen to) or whose `listStatuses()` rejects.
502
+ *
503
+ * Stated separately so a future contributor deciding what belongs in this
504
+ * list does not "tidy" the block out on the grounds that it is not a display
505
+ * preference.
307
506
  */
308
507
  resetSession(state) {
309
- const preservedPrefs = {
508
+ // `satisfies` gives this literal excess-property checking and per-field type
509
+ // checking, neither of which `Object.assign` provides — its source parameter
510
+ // is structurally permissive, so a typo (`blockedResaon`) would compile
511
+ // clean, silently stop preserving the real key, AND add a junk key to the
512
+ // draft that then leaks into serialised state.
513
+ const preservedAcrossReset = {
310
514
  showToolCalls: state.showToolCalls,
311
515
  showThinkingSteps: state.showThinkingSteps,
312
516
  showNarration: state.showNarration,
313
517
  showAgentSwitchIndicator: state.showAgentSwitchIndicator,
314
518
  enabledAnimations: state.enabledAnimations,
315
- };
316
- Object.assign(state, createDefaultSessionState(), preservedPrefs);
519
+ providerStatuses: state.providerStatuses,
520
+ blocked: state.blocked,
521
+ blockedReason: state.blockedReason,
522
+ blockedVendors: state.blockedVendors,
523
+ sweptVendors: state.sweptVendors,
524
+ blockedVendorReasons: state.blockedVendorReasons,
525
+ } satisfies Partial<AiAssistantSessionState>;
526
+ Object.assign(state, createDefaultSessionState(), preservedAcrossReset);
317
527
  },
318
528
  },
319
529
  selectors: {},
@@ -1,7 +1,9 @@
1
+ import type { SubAgentFailureReason, TurnFailureReason } from '@genesislcap/foundation-ai';
1
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
3
  import {
3
4
  clearMetaEventRegistry,
4
5
  clearSession,
6
+ DEBUG_LOG_README,
5
7
  getMetaEvents,
6
8
  type MetaEvent,
7
9
  mergeMetaEvents,
@@ -86,4 +88,65 @@ suite('clearSession drops one key without touching others', () => {
86
88
  assert.is(getMetaEvents('parent').length, 1);
87
89
  });
88
90
 
91
+ // ── README ↔ taxonomy sync (GENC-1464) ─────────────────────────────────────────
92
+ // DEBUG_LOG_README is emitted as the FIRST key of the exported debug log
93
+ // specifically so an agent reading the JSON does not have to reverse-engineer it.
94
+ // Its reason enumerations therefore read as exhaustive and will be trusted — but
95
+ // they had already drifted twice (`refusal` was missing from both) before anyone
96
+ // noticed, because "the doc lives next to the catalogue" is not enforcement.
97
+ //
98
+ // The `Record<..., true>` below is the enforcement: adding a union member without
99
+ // listing it here is a COMPILE error, and listing it without documenting it is a
100
+ // test failure. So the next added reason fails in the one place the drift keeps
101
+ // happening.
102
+
103
+ const ALL_TURN_FAILURE_REASONS = Object.keys({
104
+ exception: true,
105
+ 'malformed-function-call': true,
106
+ 'empty-response': true,
107
+ 'unknown-tool-limit': true,
108
+ 'max-iterations': true,
109
+ 'response-truncated': true,
110
+ refusal: true,
111
+ 'budget-exhausted': true,
112
+ } satisfies Record<TurnFailureReason, true>) as TurnFailureReason[];
113
+
114
+ const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
115
+ max_iterations: true,
116
+ malformed_tool_call: true,
117
+ empty_response: true,
118
+ unknown_tool_limit: true,
119
+ timeout: true,
120
+ response_truncated: true,
121
+ refusal: true,
122
+ budget_exhausted: true,
123
+ } satisfies Record<SubAgentFailureReason, true>) as SubAgentFailureReason[];
124
+
125
+ const README_TEXT = DEBUG_LOG_README.join('\n');
126
+
127
+ suite('DEBUG_LOG_README enumerates every TurnFailureReason', () => {
128
+ for (const reason of ALL_TURN_FAILURE_REASONS) {
129
+ assert.ok(
130
+ README_TEXT.includes(reason),
131
+ `turn.error reason '${reason}' is missing from DEBUG_LOG_README`,
132
+ );
133
+ }
134
+ });
135
+
136
+ suite('DEBUG_LOG_README enumerates every SubAgentFailureReason', () => {
137
+ for (const reason of ALL_SUB_AGENT_FAILURE_REASONS) {
138
+ assert.ok(
139
+ README_TEXT.includes(reason),
140
+ `subagent.failed reason '${reason}' is missing from DEBUG_LOG_README`,
141
+ );
142
+ }
143
+ });
144
+
145
+ suite('DEBUG_LOG_README documents the budget diagnostics that ride turn.error', () => {
146
+ // `recordTurnError('budget-exhausted', …)` attaches these; an agent triaging a
147
+ // support ticket needs to know to look for them.
148
+ assert.ok(README_TEXT.includes('budgetUsd'));
149
+ assert.ok(README_TEXT.includes('spentUsd'));
150
+ });
151
+
89
152
  suite.run();
@@ -307,7 +307,12 @@ export function clearSession(key: string): void {
307
307
  * Human/agent-facing guide emitted as the first key of the exported debug log,
308
308
  * so whoever opens the JSON (often an AI agent) knows how to read it without
309
309
  * reverse-engineering the shape. Kept here next to the event catalogue it
310
- * describes so the two stay in sync.
310
+ * describes but proximity alone demonstrably did NOT keep the two in sync
311
+ * (`refusal` was missing from both reason lists for a whole release), so
312
+ * `debug-event-log.test.ts` now asserts that every `TurnFailureReason` and every
313
+ * `SubAgentFailureReason` is named here, and fails the build when one is added
314
+ * without a matching sentence. Enumerations below read as exhaustive and WILL be
315
+ * trusted by an agent reading the JSON — keep them so.
311
316
  */
312
317
  export const DEBUG_LOG_README: readonly string[] = [
313
318
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
@@ -319,7 +324,7 @@ export const DEBUG_LOG_README: readonly string[] = [
319
324
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
320
325
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
321
326
  "Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
322
- 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
327
+ 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
323
328
  'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
324
329
  "`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, token usage, session cost), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
325
330
  'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
@@ -44,6 +44,28 @@ Suite('createPersistedSession stamps version + savedAt and keeps agentSnapshots'
44
44
  assert.equal(s.agentSnapshots, { A: { machine: { state: 'x', context: {} } } });
45
45
  });
46
46
 
47
+ // GENC-1464 — the blocked latch is deliberately NOT persisted. The snapshot is
48
+ // per-stateKey and long-lived, so a persisted latch would survive an out-of-band
49
+ // budget raise with no in-element way to clear it: the user would return to a
50
+ // permanently dead composer against a budget that has headroom. Re-deriving the
51
+ // state (host pre-flight on mount, or the next 402) is strictly safer than
52
+ // remembering it. This test exists so a future "helpful" addition trips here and
53
+ // has to read that reasoning first — see `FoundationAiAssistant.blocked`'s
54
+ // Lifetime paragraph and docs/migration-GENC-1464.md §Scope.
55
+ Suite('the blocked latch is deliberately not persisted', () => {
56
+ const s = base() as unknown as Record<string, unknown>;
57
+ assert.not.ok('blocked' in s, 'blocked must not enter the persisted snapshot');
58
+ assert.not.ok('blockedReason' in s, 'blockedReason must not enter the persisted snapshot');
59
+ // The per-vendor latches inherit the same lifetime, so they inherit the same
60
+ // rule — a persisted per-vendor wall is exactly as stale as a persisted global
61
+ // one, just harder to notice because only one vendor looks broken.
62
+ assert.not.ok('blockedVendors' in s, 'blockedVendors must not enter the persisted snapshot');
63
+ assert.not.ok(
64
+ 'blockedVendorReasons' in s,
65
+ 'blockedVendorReasons must not enter the persisted snapshot',
66
+ );
67
+ });
68
+
47
69
  Suite('createPersistedSession defaults agentSnapshots to {}', () => {
48
70
  const s = createPersistedSession({
49
71
  sessionKey: 'k',