@genesislcap/ai-assistant 14.461.2 → 14.462.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.
@@ -1094,6 +1094,56 @@ interactionPresentation('presentation is absent when the option is omitted', asy
1094
1094
 
1095
1095
  interactionPresentation.run();
1096
1096
 
1097
+ // ---------------------------------------------------------------------------
1098
+ // interaction activity-bus signals (GENC-1346) — the driver brackets a parked
1099
+ // widget interaction with `interaction-requested` / `interaction-resolved`, so
1100
+ // turn-aware UI can distinguish "actively computing" from "parked awaiting the
1101
+ // user". No tool-loop event fires at a park boundary, so these are the signal.
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
1105
+
1106
+ interactionBus.after(() => {
1107
+ agenticActivityBus.close();
1108
+ });
1109
+
1110
+ interactionBus('brackets a park with interaction-requested then -resolved', async () => {
1111
+ const events: string[] = [];
1112
+ const unsubs = [
1113
+ agenticActivityBus.subscribe('interaction-requested', () => events.push('requested')),
1114
+ agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved')),
1115
+ ];
1116
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1117
+
1118
+ const pending = driver.requestInteraction('w', {});
1119
+ assert.equal(events, ['requested'], 'parking fires interaction-requested');
1120
+
1121
+ const id = driver.getHistory().at(-1)!.interaction!.interactionId;
1122
+ driver.resolveInteraction(id, { status: 'approved' });
1123
+ await pending;
1124
+ assert.equal(events, ['requested', 'resolved'], 'resolving fires interaction-resolved');
1125
+
1126
+ unsubs.forEach((u) => u());
1127
+ driver.dispose();
1128
+ });
1129
+
1130
+ interactionBus('a timed-out interaction still fires interaction-resolved', async () => {
1131
+ const events: string[] = [];
1132
+ const unsub = agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved'));
1133
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1134
+
1135
+ // Never resolved by a user — the timeout path runs the same teardown, so it
1136
+ // must signal the bus too (else the button would stay enabled after a timeout).
1137
+ const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
1138
+ await pending;
1139
+ assert.equal(events, ['resolved'], 'the timeout resolution path also signals the bus');
1140
+
1141
+ unsub();
1142
+ driver.dispose();
1143
+ });
1144
+
1145
+ interactionBus.run();
1146
+
1097
1147
  // ---------------------------------------------------------------------------
1098
1148
  // interaction timeout — requestInteraction({ timeoutMs }) resolves with a
1099
1149
  // status:'timeout' result (never rejects) and closes the widget read-only.
@@ -1187,3 +1237,243 @@ interactionCost(
1187
1237
  );
1188
1238
 
1189
1239
  interactionCost.run();
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // observable provider registry — runtime provider switching (GENC-1346)
1243
+ //
1244
+ // When the host registers an observable registry and swaps providers at
1245
+ // runtime, the driver must drop its memoised lookups so the next turn resolves
1246
+ // the new provider, re-emit `provider-changed` even when the resolved *name* is
1247
+ // unchanged, and (critically) never leak its subscription — including for child
1248
+ // sub-agent drivers that complete normally.
1249
+ // ---------------------------------------------------------------------------
1250
+
1251
+ interface ObservableTestRegistry extends AIProviderRegistry {
1252
+ subscribe(listener: () => void): () => void;
1253
+ /** Swap the provider returned by `get`/`default` and notify subscribers. */
1254
+ swap(provider: AIProvider): void;
1255
+ /** Live subscriber count — lets tests assert there's no listener leak. */
1256
+ listenerCount(): number;
1257
+ }
1258
+
1259
+ /**
1260
+ * A single-name (`'high'`) observable registry. `get`/`default` always return
1261
+ * the current provider, so swapping it mid-session models a same-name vendor
1262
+ * switch (the tier name stays `'high'`, the provider underneath changes).
1263
+ */
1264
+ const makeObservableRegistry = (initial: AIProvider): ObservableTestRegistry => {
1265
+ let current = initial;
1266
+ const listeners = new Set<() => void>();
1267
+ return {
1268
+ get: () => current,
1269
+ default: () => current,
1270
+ defaultName: () => 'high',
1271
+ names: () => ['high'],
1272
+ getStatus: async () => null,
1273
+ listStatuses: async () => [],
1274
+ subscribe(listener: () => void) {
1275
+ listeners.add(listener);
1276
+ return () => {
1277
+ listeners.delete(listener);
1278
+ };
1279
+ },
1280
+ swap(provider: AIProvider) {
1281
+ current = provider;
1282
+ for (const l of Array.from(listeners)) l();
1283
+ },
1284
+ listenerCount: () => listeners.size,
1285
+ };
1286
+ };
1287
+
1288
+ const makeDriverWithRegistry = (config: AgentConfig, registry: AIProviderRegistry): ChatDriver => {
1289
+ const driver = new ChatDriver(registry, {}, [], undefined, undefined, 50, 5, undefined, '');
1290
+ driver.applyAgent(config);
1291
+ return driver;
1292
+ };
1293
+
1294
+ const observable = createLogicSuite('ChatDriver observable provider registry');
1295
+
1296
+ observable.after(() => {
1297
+ agenticActivityBus.close();
1298
+ });
1299
+
1300
+ observable(
1301
+ 'a registry change clears the resolved-provider cache so the next turn uses the new provider',
1302
+ async () => {
1303
+ const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
1304
+ const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
1305
+ const registry = makeObservableRegistry(providerA);
1306
+ // A static provider name means lookups go through `resolvedProviderCache` —
1307
+ // the cache that must self-invalidate on a registry change.
1308
+ const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
1309
+
1310
+ await driver.sendMessage('first');
1311
+ assert.is(providerA.advertisedPerCall.length, 1, 'turn 1 resolves the original provider');
1312
+ assert.is(providerB.advertisedPerCall.length, 0);
1313
+
1314
+ registry.swap(providerB); // notify → cache cleared
1315
+
1316
+ await driver.sendMessage('second');
1317
+ assert.is(providerB.advertisedPerCall.length, 1, 'turn 2 resolves the swapped-in provider');
1318
+ assert.is(providerA.advertisedPerCall.length, 1, 'the stale provider is not reused');
1319
+
1320
+ driver.dispose();
1321
+ },
1322
+ );
1323
+
1324
+ observable('re-emits provider-changed on a same-name swap', async () => {
1325
+ const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
1326
+ const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
1327
+ const registry = makeObservableRegistry(providerA);
1328
+ const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
1329
+
1330
+ const names: string[] = [];
1331
+ driver.addEventListener('provider-changed', (e) => {
1332
+ names.push((e as CustomEvent<{ name: string }>).detail.name);
1333
+ });
1334
+
1335
+ await driver.sendMessage('first');
1336
+ registry.swap(providerB);
1337
+ await driver.sendMessage('second');
1338
+
1339
+ // The resolved name ('high') never changes, but the swap resets the
1340
+ // last-dispatched name so the cog can refresh — two events, not one.
1341
+ assert.equal(names, ['high', 'high']);
1342
+
1343
+ driver.dispose();
1344
+ });
1345
+
1346
+ observable('a non-observable registry is a no-op — turn runs, dispose does not throw', async () => {
1347
+ const provider = scriptedProvider([{ role: 'assistant', content: 'hi' }]);
1348
+ const driver = makeDriverWithRegistry(agent({ name: 'plain' }), makeRegistry(provider));
1349
+ await driver.sendMessage('go');
1350
+ assert.is(provider.advertisedPerCall.length, 1);
1351
+ driver.dispose(); // no subscription was wired — must still be safe
1352
+ });
1353
+
1354
+ observable('a child sub-agent driver unsubscribes on completion — no listener leak', async () => {
1355
+ const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
1356
+ const registry = makeObservableRegistry(provider);
1357
+ const parent = delegatingParent(completingWorker({ ok: true }), () => {});
1358
+ const driver = makeDriverWithRegistry(parent, registry);
1359
+
1360
+ assert.is(registry.listenerCount(), 1, 'the parent driver subscribed on construction');
1361
+
1362
+ await driver.sendMessage('go');
1363
+ // The child subscribed during the run; if it didn't clean up on its (normal)
1364
+ // completion the registry would now hold two listeners.
1365
+ assert.is(registry.listenerCount(), 1, 'the completed child unsubscribed');
1366
+
1367
+ driver.dispose();
1368
+ assert.is(registry.listenerCount(), 0, 'the parent unsubscribed on dispose');
1369
+ });
1370
+
1371
+ observable.run();
1372
+
1373
+ // ---------------------------------------------------------------------------
1374
+ // per-message model attribution (GENC-1346)
1375
+ //
1376
+ // Each model-produced assistant message carries `model` (the concrete model id
1377
+ // the active provider's getStatus reports) and `providerName` (the registry slot
1378
+ // it resolved under), so the exported debug log shows which model produced each
1379
+ // message — and, since tool calls ride on the assistant message, each tool call.
1380
+ // ---------------------------------------------------------------------------
1381
+
1382
+ /** A provider that replays scripted replies and reports `model` via getStatus
1383
+ * (omitted entirely when `model` is undefined, to model a provider with no
1384
+ * status). */
1385
+ const modelProvider = (model: string | undefined, responses: ChatMessage[]): AIProvider => {
1386
+ const queue = [...responses];
1387
+ const provider: AIProvider = {
1388
+ chat: async (): Promise<ChatMessage> => queue.shift() ?? { role: 'assistant', content: 'done' },
1389
+ };
1390
+ if (model !== undefined) {
1391
+ provider.getStatus = async () => ({ provider: 'gemini', model });
1392
+ }
1393
+ return provider;
1394
+ };
1395
+
1396
+ const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
1397
+
1398
+ modelAttr.after(() => {
1399
+ agenticActivityBus.close();
1400
+ });
1401
+
1402
+ modelAttr('stamps the resolved model and registry name onto an assistant reply', async () => {
1403
+ const provider = modelProvider('gemini-2.5-flash-lite', [
1404
+ { role: 'assistant', content: 'hi there' },
1405
+ ]);
1406
+ const driver = makeDriver(agent({ name: 'plain' }), provider);
1407
+
1408
+ await driver.sendMessage('hello');
1409
+
1410
+ const reply = driver.getHistory().find((m) => m.role === 'assistant');
1411
+ assert.ok(reply, 'assistant reply present');
1412
+ assert.is(reply!.model, 'gemini-2.5-flash-lite', 'model id read from provider getStatus');
1413
+ // makeRegistry registers a single provider under the name 'test'.
1414
+ assert.is(reply!.providerName, 'test', 'registry slot the turn resolved under');
1415
+
1416
+ driver.dispose();
1417
+ });
1418
+
1419
+ modelAttr(
1420
+ 'attributes a tool-calling assistant message (and so its tool calls) to the model',
1421
+ async () => {
1422
+ const provider = modelProvider('claude-haiku-4-5-20251001', [
1423
+ callsTool('noop', 't1'),
1424
+ { role: 'assistant', content: 'finished' },
1425
+ ]);
1426
+ const config = agent({
1427
+ name: 'withTool',
1428
+ toolDefinitions: [def('noop')],
1429
+ toolHandlers: { noop: async () => 'ok' },
1430
+ });
1431
+ const driver = makeDriver(config, provider);
1432
+
1433
+ await driver.sendMessage('go');
1434
+
1435
+ const toolCallMsg = driver.getHistory().find((m) => (m.toolCalls?.length ?? 0) > 0);
1436
+ assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
1437
+ assert.is(toolCallMsg!.model, 'claude-haiku-4-5-20251001');
1438
+ assert.is(toolCallMsg!.providerName, 'test');
1439
+
1440
+ driver.dispose();
1441
+ },
1442
+ );
1443
+
1444
+ modelAttr('picks up a model swapped behind a stable name', async () => {
1445
+ const before = modelProvider('gemini-2.5-flash-lite', [{ role: 'assistant', content: 'A' }]);
1446
+ const after = modelProvider('gemini-2.5-pro', [{ role: 'assistant', content: 'B' }]);
1447
+ const registry = makeObservableRegistry(before);
1448
+ const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
1449
+
1450
+ await driver.sendMessage('first');
1451
+ registry.swap(after); // notify → model cache cleared, next turn re-resolves
1452
+ await driver.sendMessage('second');
1453
+
1454
+ const replies = driver.getHistory().filter((m) => m.role === 'assistant');
1455
+ assert.is(replies.at(-2)?.model, 'gemini-2.5-flash-lite', 'first turn keeps the original model');
1456
+ assert.is(replies.at(-1)?.model, 'gemini-2.5-pro', 'after the swap the new model is stamped');
1457
+ // The tier name ('high') never changed across the swap — only the model behind it.
1458
+ assert.is(replies.at(-1)?.providerName, 'high');
1459
+
1460
+ driver.dispose();
1461
+ });
1462
+
1463
+ modelAttr('omits the model key entirely when the provider reports no status', async () => {
1464
+ const provider = modelProvider(undefined, [{ role: 'assistant', content: 'no status here' }]);
1465
+ const driver = makeDriver(agent({ name: 'plain' }), provider);
1466
+
1467
+ await driver.sendMessage('hi');
1468
+
1469
+ const reply = driver.getHistory().find((m) => m.role === 'assistant');
1470
+ assert.ok(reply, 'assistant reply present');
1471
+ // No getStatus → no model. The key is left off, not set to undefined, so the
1472
+ // exported log carries no dead `model` line for this message.
1473
+ assert.not.ok('model' in reply!, 'model key is absent, not present-as-undefined');
1474
+ assert.is(reply!.providerName, 'test', 'provider name is still recorded');
1475
+
1476
+ driver.dispose();
1477
+ });
1478
+
1479
+ modelAttr.run();
@@ -14,7 +14,10 @@ import type {
14
14
  SubAgentFailureReason,
15
15
  SubAgentRequestOptions,
16
16
  } from '@genesislcap/foundation-ai';
17
- import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
17
+ import {
18
+ isObservableAIProviderRegistry,
19
+ MalformedFunctionCallError,
20
+ } from '@genesislcap/foundation-ai';
18
21
  import { agenticActivityBus } from '../../channel/ai-activity-bus';
19
22
  import type {
20
23
  AgentConfig,
@@ -339,6 +342,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
339
342
  private lastResolvedProviderName?: string;
340
343
  /** Last dispatched `provider-changed` name; avoids duplicate events on stable turns. */
341
344
  private lastDispatchedProviderName?: string;
345
+ /**
346
+ * Concrete model id of the provider resolved for the current turn, read from
347
+ * its `getStatus()` and stamped onto the resulting assistant message so the
348
+ * debug log carries per-message model lineage. Re-resolved each turn in
349
+ * `resolveProviderForTurn`.
350
+ */
351
+ private lastResolvedModel?: string;
352
+ /**
353
+ * Memoised `name → model id` lookups, so the per-turn model resolution doesn't
354
+ * re-await `getStatus()` every turn. Cleared (with `resolvedProviderCache`) on
355
+ * an observable-registry change, so a model swapped behind a stable name is
356
+ * picked up on the next turn.
357
+ */
358
+ private resolvedModelCache = new Map<string, string | undefined>();
359
+ /**
360
+ * Unsubscribe handle for the provider-registry change listener (only set when
361
+ * the injected registry is observable). Called in `dispose` so the long-lived
362
+ * registry doesn't retain this driver — see the constructor and the sub-agent
363
+ * teardown in `runSubAgent`.
364
+ */
365
+ private unsubscribeRegistry?: () => void;
342
366
 
343
367
  constructor(
344
368
  private readonly providerRegistry: AIProviderRegistry,
@@ -371,6 +395,24 @@ export class ChatDriver extends EventTarget implements AiDriver {
371
395
  this.primerHistory = primerHistory;
372
396
  this.maxFoldOperations = maxFoldOperations;
373
397
  this.maxTurnSnapshots = maxTurnSnapshots;
398
+ // Runtime provider switching: when the host registered an observable
399
+ // registry, drop our memoised provider lookups whenever its mapping/default
400
+ // changes so the next turn re-resolves against the new providers. Resetting
401
+ // `lastDispatchedProviderName` forces the next `resolveProviderForTurn` to
402
+ // re-emit `provider-changed` even when the resolved *name* is unchanged
403
+ // (e.g. a tier name like 'high' kept, but the vendor underneath swapped) —
404
+ // that's what lets status UI refresh on a same-name switch. Feature-detect
405
+ // means immutable/empty registries are a no-op. Child sub-agent drivers get
406
+ // the same registry and so subscribe here too; each cleans up in `dispose`.
407
+ if (isObservableAIProviderRegistry(this.providerRegistry)) {
408
+ this.unsubscribeRegistry = this.providerRegistry.subscribe(() => {
409
+ this.resolvedProviderCache.clear();
410
+ this.resolvedModelCache.clear();
411
+ this.lastResolvedProviderName = undefined;
412
+ this.lastResolvedModel = undefined;
413
+ this.lastDispatchedProviderName = undefined;
414
+ });
415
+ }
374
416
  }
375
417
 
376
418
  /**
@@ -390,6 +432,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
390
432
  */
391
433
  dispose(): void {
392
434
  this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
435
+ // Detach from the provider registry so the long-lived registry doesn't pin
436
+ // this driver (and its closure) after teardown. Guard-cleared so a second
437
+ // dispose is a no-op, matching this method's idempotent contract.
438
+ this.unsubscribeRegistry?.();
439
+ this.unsubscribeRegistry = undefined;
393
440
  }
394
441
 
395
442
  /**
@@ -540,6 +587,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
540
587
  resolvedName = name;
541
588
  }
542
589
  this.lastResolvedProviderName = resolvedName;
590
+ this.lastResolvedModel = await this.resolveModelForProvider(resolvedName, provider);
543
591
  if (resolvedName !== this.lastDispatchedProviderName) {
544
592
  this.lastDispatchedProviderName = resolvedName;
545
593
  recordMetaEvent(this.sessionKey, 'provider.selected', {
@@ -553,6 +601,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
553
601
  return provider;
554
602
  }
555
603
 
604
+ /**
605
+ * Resolve the concrete model id for a provider via its optional `getStatus()`,
606
+ * memoised by registry name. Used to stamp `model` onto outgoing messages.
607
+ * Best-effort: a provider without `getStatus`, a null status, or a throw all
608
+ * yield `undefined` — model attribution is diagnostic, never load-bearing.
609
+ */
610
+ private async resolveModelForProvider(
611
+ name: string,
612
+ provider: AIProvider,
613
+ ): Promise<string | undefined> {
614
+ if (this.resolvedModelCache.has(name)) return this.resolvedModelCache.get(name);
615
+ let model: string | undefined;
616
+ try {
617
+ model = (await provider.getStatus?.())?.model;
618
+ } catch {
619
+ model = undefined;
620
+ }
621
+ this.resolvedModelCache.set(name, model);
622
+ return model;
623
+ }
624
+
556
625
  /**
557
626
  * Resolve a per-turn config input that is either a static value or a function
558
627
  * of the turn context — the value-or-resolver shape shared by `provider`,
@@ -921,6 +990,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
921
990
  component: componentName,
922
991
  agent: this.activeAgentName,
923
992
  });
993
+ // Signal the park boundary on the activity bus so turn-aware UI can tell
994
+ // "actively computing" from "parked awaiting the user" — the latter is a
995
+ // safe window for actions disallowed mid-request (e.g. switching provider
996
+ // during a long journey step). Paired with `interaction-resolved`.
997
+ agenticActivityBus.publish('interaction-requested', undefined);
924
998
  if (chatInputDuringExecution) {
925
999
  this.dispatchEvent(
926
1000
  new CustomEvent('interaction-start', {
@@ -989,6 +1063,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
989
1063
  this.dispatchEvent(new CustomEvent('interaction-stop', { detail: { interactionId } }));
990
1064
  }
991
1065
  recordMetaEvent(this.sessionKey, 'interaction.resolved', { interactionId });
1066
+ // The park is ending and the loop is about to resume computing — paired
1067
+ // with `interaction-requested`. Fires for every resolution path (user
1068
+ // completion, timeout, cancellation), since all route through here.
1069
+ agenticActivityBus.publish('interaction-resolved', undefined);
992
1070
  interaction.resolve(result);
993
1071
  this.pendingInteractions.delete(interactionId);
994
1072
  } else {
@@ -1262,6 +1340,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
1262
1340
  this.lifecycleController.signal.removeEventListener('abort', disposeChild);
1263
1341
  child.removeEventListener('history-updated', forwardTrace);
1264
1342
  child.removeEventListener('provider-changed', forwardProviderChanged);
1343
+ // Tear the child down on every exit path, not just timeout/parent-abort.
1344
+ // A child that completes normally is otherwise never disposed, so its
1345
+ // provider-registry subscription (wired in the ChatDriver constructor)
1346
+ // would leak — the long-lived registry would retain every completed
1347
+ // sub-agent driver. dispose() is idempotent and only aborts the (already
1348
+ // settled) lifecycle, so the snapshot/completion reads below still work.
1349
+ child.dispose();
1265
1350
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
1266
1351
  }
1267
1352
 
@@ -1767,6 +1852,22 @@ export class ChatDriver extends EventTarget implements AiDriver {
1767
1852
  throw e;
1768
1853
  }
1769
1854
 
1855
+ // Attribute the response to the concrete model + registry slot that
1856
+ // produced it (resolved for this turn in `resolveProviderForTurn`). Carried
1857
+ // on the assistant message so the debug-log timeline shows per-message
1858
+ // model lineage — and thus per-tool-call, since tool calls ride on the
1859
+ // assistant message that requests them. Harmless on a response later
1860
+ // discarded as empty/retried; only kept copies reach history.
1861
+ //
1862
+ // Attach each key only when resolved: a provider with no `getStatus` (etc.)
1863
+ // leaves the key off entirely rather than carrying it as `undefined`.
1864
+ // JSON.stringify already drops undefined from the exported log, so this is
1865
+ // chiefly about keeping the in-memory message shape honest.
1866
+ if (this.lastResolvedModel !== undefined) response.model = this.lastResolvedModel;
1867
+ if (this.lastResolvedProviderName !== undefined) {
1868
+ response.providerName = this.lastResolvedProviderName;
1869
+ }
1870
+
1770
1871
  const isThinkingStep = response.content && response.toolCalls?.length;
1771
1872
  const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
1772
1873
 
package/src/main/main.ts CHANGED
@@ -28,7 +28,7 @@ import type {
28
28
  ChatInputDuringExecutionMode,
29
29
  ChatMessage,
30
30
  } from '@genesislcap/foundation-ai';
31
- import { AIProviderRegistry } from '@genesislcap/foundation-ai';
31
+ import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
32
32
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
33
33
  import {
34
34
  customElement,
@@ -592,6 +592,8 @@ export class FoundationAiAssistant extends GenesisElement {
592
592
  private driverCleanup?: () => void;
593
593
  private loadingTimer: ReturnType<typeof setTimeout> | undefined;
594
594
  private unsubBus?: () => void;
595
+ /** Unsubscribe handle for the provider-registry change listener (observable registries only). */
596
+ private unsubProviderRegistry?: () => void;
595
597
  private haloStartPublished = false;
596
598
  /** Fingerprint of the agents array used to build the current driver. Used by agentsChanged to skip spurious rebuilds. */
597
599
  private _driverAgentsKey?: string;
@@ -1117,6 +1119,21 @@ export class FoundationAiAssistant extends GenesisElement {
1117
1119
  this.fetchSuggestions();
1118
1120
  void this.resolveContextLimit();
1119
1121
  void this.loadProviderStatuses();
1122
+ // When the host registered an observable registry (runtime provider
1123
+ // switching), refresh the displayed model/limit and the provider list the
1124
+ // instant its contents change — so the header reflects the new provider
1125
+ // immediately on switch, not only when the next turn re-emits
1126
+ // `provider-changed`. Feature-detected: a no-op for immutable registries.
1127
+ // Re-subscribed per connect (docking/popout remounts); balanced in
1128
+ // disconnectedCallback.
1129
+ this.unsubProviderRegistry?.();
1130
+ this.unsubProviderRegistry = undefined;
1131
+ if (isObservableAIProviderRegistry(this.providerRegistry)) {
1132
+ this.unsubProviderRegistry = this.providerRegistry.subscribe(() => {
1133
+ void this.resolveContextLimit();
1134
+ void this.loadProviderStatuses();
1135
+ });
1136
+ }
1120
1137
  if (this.messagesEl) {
1121
1138
  this._scrollListener = () => {
1122
1139
  this._userScrolledAway =
@@ -1150,6 +1167,8 @@ export class FoundationAiAssistant extends GenesisElement {
1150
1167
  this.unwireDriver();
1151
1168
  this.unsubBus?.();
1152
1169
  this.unsubBus = undefined;
1170
+ this.unsubProviderRegistry?.();
1171
+ this.unsubProviderRegistry = undefined;
1153
1172
  this._executionCompletionUnsub?.();
1154
1173
  this._executionCompletionUnsub = undefined;
1155
1174
  if (this.messagesEl && this._scrollListener) {
@@ -247,7 +247,7 @@ export const DEBUG_LOG_README: readonly string[] = [
247
247
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
248
248
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
249
249
  'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',
250
- "kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
250
+ "kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
251
251
  "kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
252
252
  "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.",
253
253
  "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'.",