@genesislcap/ai-assistant 14.494.0 → 14.496.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 (46) hide show
  1. package/dist/ai-assistant.api.json +588 -214
  2. package/dist/ai-assistant.d.ts +157 -6
  3. package/dist/chat-driver.cjs +5608 -0
  4. package/dist/chat-driver.cjs.map +7 -0
  5. package/dist/chat-driver.mjs +5566 -0
  6. package/dist/chat-driver.mjs.map +7 -0
  7. package/dist/custom-elements.json +713 -333
  8. package/dist/dts/channel/ai-activity-bus.d.ts +36 -0
  9. package/dist/dts/channel/ai-activity-bus.d.ts.map +1 -1
  10. package/dist/dts/channel/ai-activity-channel.d.ts +10 -1
  11. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  12. package/dist/dts/chat-driver-node.d.ts +28 -0
  13. package/dist/dts/chat-driver-node.d.ts.map +1 -0
  14. package/dist/dts/components/chat-driver/chat-driver.d.ts +66 -5
  15. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  16. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
  18. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  19. package/dist/dts/config/config.d.ts +37 -2
  20. package/dist/dts/config/config.d.ts.map +1 -1
  21. package/dist/dts/index.d.ts +1 -0
  22. package/dist/dts/index.d.ts.map +1 -1
  23. package/dist/dts/main/main.d.ts.map +1 -1
  24. package/dist/dts/state/debug-event-log.d.ts +8 -12
  25. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  26. package/dist/esm/channel/ai-activity-bus.js +48 -12
  27. package/dist/esm/chat-driver-node.js +33 -0
  28. package/dist/esm/components/chat-driver/chat-driver.js +77 -25
  29. package/dist/esm/components/chat-driver/chat-driver.test.js +160 -36
  30. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +7 -1
  31. package/dist/esm/main/main.js +8 -1
  32. package/dist/esm/main/popout-interaction-gate.test.js +6 -10
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +29 -19
  35. package/scripts/build-chat-driver-node.mjs +42 -0
  36. package/src/channel/ai-activity-bus.ts +64 -10
  37. package/src/channel/ai-activity-channel.ts +9 -1
  38. package/src/chat-driver-node.ts +54 -0
  39. package/src/components/chat-driver/chat-driver.test.ts +237 -50
  40. package/src/components/chat-driver/chat-driver.ts +151 -39
  41. package/src/components/orchestrating-driver/orchestrating-driver.ts +10 -11
  42. package/src/config/config.ts +40 -1
  43. package/src/index.ts +1 -0
  44. package/src/main/main.ts +8 -11
  45. package/src/main/popout-interaction-gate.test.ts +6 -11
  46. package/src/state/debug-event-log.ts +9 -18
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Node-safe headless entry point for the AI assistant driver stack (`@genesislcap/ai-assistant/chat-driver`).
3
+ *
4
+ * Re-exports ONLY the framework-agnostic pieces — {@link ChatDriver}, `OrchestratingDriver`, the
5
+ * agent-config surface (`defineAgent`, `defineStatefulAgent`, and the config/input types), and the
6
+ * activity-bus types/`NOOP_ACTIVITY_BUS` — WITHOUT the DOM/FAST component layer that the package's
7
+ * main entry (`.`) pulls in via `main/main`. This lets a Node / CommonJS host (server, unit tests,
8
+ * benches) construct and drive `ChatDriver` over `foundation-ai` providers with no jsdom and no
9
+ * custom-element registration.
10
+ *
11
+ * Every module re-exported here is DOM-clean at import time (no `window`/`document`/`sessionStorage`
12
+ * eval-time access, no `@genesislcap/web-core`). The activity bus is imported for its types and the
13
+ * no-op default; its cross-tab `BroadcastChannel` is created lazily and only in a browser, so
14
+ * pulling this entry into Node opens nothing.
15
+ *
16
+ * @packageDocumentation
17
+ * @beta
18
+ */
19
+ export * from './components/chat-driver';
20
+ export * from './components/orchestrating-driver';
21
+ export * from './channel/ai-activity-channel';
22
+ export * from './channel/ai-activity-bus';
23
+ export * from './config/config';
24
+ export * from './config/define-stateful-agent';
25
+ export * from './config/fallback-agents';
26
+ // Provider-construction surface, re-exported from `@genesislcap/foundation-ai` so a headless
27
+ // consumer builds its registry from the SAME bundled foundation-ai instance the driver uses. That
28
+ // shared identity matters: `ChatDriver`'s malformed/truncated handling does `instanceof` on the
29
+ // transports' error classes, so a second foundation-ai copy (a separate dep) would silently break
30
+ // those checks. NAMED re-exports only — `export *` would drag in the FAST-DI module
31
+ // (`@microsoft/fast-foundation` → `fast-element` → `document` at eval); by naming just these,
32
+ // esbuild tree-shakes the DI module out and the bundle stays Node-loadable.
33
+ export { AnthropicProvider, AnthropicTransport, GeminiProvider, GeminiTransport, MutableAIProviderRegistry, isObservableAIProviderRegistry, } from '@genesislcap/foundation-ai';
@@ -1,6 +1,6 @@
1
1
  import { __awaiter, __rest } from "tslib";
2
2
  import { isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
3
- import { agenticActivityBus } from '../../channel/ai-activity-bus';
3
+ import { NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
4
4
  import { resolveChatProvider } from '../../config/validate-providers';
5
5
  import { clearSession, getMetaEvents, mergeMetaEvents, recordMetaEvent, recordTurnError, recordTurnRetry, } from '../../state/debug-event-log';
6
6
  import { createInteractionContext, } from '../../state/interaction-context';
@@ -65,13 +65,9 @@ const HANDOFF_TOOL_RESULT_PLACEHOLDER = 'Handoff to another specialist — routi
65
65
  * @beta
66
66
  */
67
67
  export class ChatDriver extends EventTarget {
68
- constructor(providerRegistry, toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
69
- /** Session identity used to file meta events onto the shared debug-log timeline. */
70
- sessionKey = '') {
68
+ constructor(providerRegistry, config = {}) {
71
69
  super();
72
70
  this.providerRegistry = providerRegistry;
73
- this.maxToolIterations = maxToolIterations;
74
- this.sessionKey = sessionKey;
75
71
  this.history = [];
76
72
  this.busy = false;
77
73
  /** Epoch ms when the current turn loop began — drives the `turn.end` duration. */
@@ -216,6 +212,10 @@ export class ChatDriver extends EventTarget {
216
212
  * picked up on the next turn.
217
213
  */
218
214
  this.resolvedStatusCache = new Map();
215
+ const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, } = config;
216
+ this.maxToolIterations = maxToolIterations;
217
+ this.sessionKey = sessionKey;
218
+ this.activityBus = activityBus;
219
219
  if (typeof toolHandlers === 'function') {
220
220
  this.toolHandlersFactory = toolHandlers;
221
221
  this.toolHandlers = {};
@@ -337,6 +337,30 @@ export class ChatDriver extends EventTarget {
337
337
  }
338
338
  return { reason: 'done' };
339
339
  }
340
+ /**
341
+ * Build the `done` loop result, carrying the typed failure reason when the turn
342
+ * bailed (PTC-0). The discriminant stays `'done'` either way — the same value a
343
+ * clean turn returns — so consumers matching on `reason === 'done'` are unchanged;
344
+ * `failureReason` is simply present on a failure and absent on success. Omitted
345
+ * (rather than set to `undefined`) so a happy-path result stays byte-identical to
346
+ * the historical `{ reason: 'done' }`.
347
+ */
348
+ turnDone(failureReason) {
349
+ return failureReason ? { reason: 'done', failureReason } : { reason: 'done' };
350
+ }
351
+ /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
352
+ static failureReasonOf(result) {
353
+ return result.reason === 'done' ? result.failureReason : undefined;
354
+ }
355
+ /**
356
+ * Build the `tool-loop-end` event detail for a turn's result. A failure carries a
357
+ * `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
358
+ * kept byte-identical so subscribers see exactly what they always have.
359
+ */
360
+ static loopEndDetail(result) {
361
+ const failureReason = ChatDriver.failureReasonOf(result);
362
+ return failureReason ? { failureReason } : undefined;
363
+ }
340
364
  /**
341
365
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
342
366
  * each specialist turn so the shared driver runs with the right tools and prompt.
@@ -385,6 +409,8 @@ export class ChatDriver extends EventTarget {
385
409
  this.activeToolChoiceInput = config.toolChoice;
386
410
  this.activeCachePolicyInput = config.cachePolicy;
387
411
  this.activeTailContextInput = config.tailContext;
412
+ this.activeResponseSchemaInput = config.responseSchema;
413
+ this.activeFallbacks = config.fallbacks;
388
414
  this.activeOnUnresolvedTool = config.onUnresolvedTool;
389
415
  this.resolvedProviderCache.clear();
390
416
  this.lastResolvedProviderName = undefined;
@@ -931,7 +957,7 @@ export class ChatDriver extends EventTarget {
931
957
  // "actively computing" from "parked awaiting the user" — the latter is a
932
958
  // safe window for actions disallowed mid-request (e.g. switching provider
933
959
  // during a long journey step). Paired with `interaction-resolved`.
934
- agenticActivityBus.publish('interaction-requested', undefined);
960
+ this.activityBus.publish('interaction-requested', undefined);
935
961
  if (chatInputDuringExecution) {
936
962
  this.dispatchEvent(new CustomEvent('interaction-start', {
937
963
  detail: { interactionId, chatInputDuringExecution },
@@ -996,7 +1022,7 @@ export class ChatDriver extends EventTarget {
996
1022
  // The park is ending and the loop is about to resume computing — paired
997
1023
  // with `interaction-requested`. Fires for every resolution path (user
998
1024
  // completion, timeout, cancellation), since all route through here.
999
- agenticActivityBus.publish('interaction-resolved', undefined);
1025
+ this.activityBus.publish('interaction-resolved', undefined);
1000
1026
  interaction.resolve(result);
1001
1027
  this.pendingInteractions.delete(interactionId);
1002
1028
  // Tear down the live context on RESOLVE (not on element unmount) — this closes
@@ -1061,9 +1087,12 @@ export class ChatDriver extends EventTarget {
1061
1087
  phase: 'sendMessage',
1062
1088
  agent: this.activeAgentName,
1063
1089
  });
1064
- agenticActivityBus.publish('tool-loop-start', undefined);
1090
+ this.activityBus.publish('tool-loop-start', undefined);
1091
+ // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
1092
+ let result = { reason: 'done' };
1065
1093
  try {
1066
- return yield this.runToolLoop(userInput, attachments);
1094
+ result = yield this.runToolLoop(userInput, attachments);
1095
+ return result;
1067
1096
  }
1068
1097
  catch (e) {
1069
1098
  logger.error('ChatDriver error:', e);
@@ -1078,7 +1107,8 @@ export class ChatDriver extends EventTarget {
1078
1107
  role: 'assistant',
1079
1108
  content: 'Sorry, something went wrong on my end. Please try again in a moment.',
1080
1109
  });
1081
- return { reason: 'done' };
1110
+ result = this.turnDone('exception');
1111
+ return result;
1082
1112
  }
1083
1113
  finally {
1084
1114
  recordMetaEvent(this.sessionKey, 'turn.end', {
@@ -1088,7 +1118,7 @@ export class ChatDriver extends EventTarget {
1088
1118
  });
1089
1119
  this.busy = false;
1090
1120
  this.endTurn();
1091
- agenticActivityBus.publish('tool-loop-end', undefined);
1121
+ this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1092
1122
  }
1093
1123
  });
1094
1124
  }
@@ -1211,7 +1241,12 @@ export class ChatDriver extends EventTarget {
1211
1241
  // be harvested into THIS session on completion and then discarded.
1212
1242
  const invocationId = crypto.randomUUID();
1213
1243
  const childSessionKey = `${this.sessionKey}::sub:${invocationId}`;
1214
- const child = new ChatDriver(this.providerRegistry, {}, [], undefined, undefined, undefined, undefined, undefined, childSessionKey);
1244
+ const child = new ChatDriver(this.providerRegistry, {
1245
+ sessionKey: childSessionKey,
1246
+ // Inherit the parent's bus so the sub-agent's tool-loop events still surface
1247
+ // (off-browser this is the shared no-op).
1248
+ activityBus: this.activityBus,
1249
+ });
1215
1250
  // Mark before the first turn so the child forces tool use and reports a
1216
1251
  // typed failure (rather than user-facing text) if it never completes.
1217
1252
  child.markAsSubAgent();
@@ -1372,9 +1407,12 @@ export class ChatDriver extends EventTarget {
1372
1407
  phase: 'continueFromHistory',
1373
1408
  agent: this.activeAgentName,
1374
1409
  });
1375
- agenticActivityBus.publish('tool-loop-start', undefined);
1410
+ this.activityBus.publish('tool-loop-start', undefined);
1411
+ // Captured so the `finally` can carry the turn's outcome onto `tool-loop-end`.
1412
+ let result = { reason: 'done' };
1376
1413
  try {
1377
- return yield this.runToolLoop('', undefined, transientPrimer);
1414
+ result = yield this.runToolLoop('', undefined, transientPrimer);
1415
+ return result;
1378
1416
  }
1379
1417
  catch (e) {
1380
1418
  logger.error('ChatDriver error:', e);
@@ -1389,7 +1427,8 @@ export class ChatDriver extends EventTarget {
1389
1427
  role: 'assistant',
1390
1428
  content: 'Sorry, something went wrong on my end. Please try again in a moment.',
1391
1429
  });
1392
- return { reason: 'done' };
1430
+ result = this.turnDone('exception');
1431
+ return result;
1393
1432
  }
1394
1433
  finally {
1395
1434
  recordMetaEvent(this.sessionKey, 'turn.end', {
@@ -1399,7 +1438,7 @@ export class ChatDriver extends EventTarget {
1399
1438
  });
1400
1439
  this.busy = false;
1401
1440
  this.endTurn();
1402
- agenticActivityBus.publish('tool-loop-end', undefined);
1441
+ this.activityBus.publish('tool-loop-end', ChatDriver.loopEndDetail(result));
1403
1442
  }
1404
1443
  });
1405
1444
  }
@@ -1686,13 +1725,14 @@ export class ChatDriver extends EventTarget {
1686
1725
  // provider is resolved — static value or a function of the turn context
1687
1726
  // (which carries the live state for stateful agents). Resolved before the
1688
1727
  // snapshot so the debug log records the exact request config the model saw.
1689
- const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext] =
1728
+ const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema,] =
1690
1729
  // oxlint-disable-next-line no-await-in-loop
1691
1730
  yield Promise.all([
1692
1731
  this.resolveTurnInput(this.activeTemperatureInput, promptCtx),
1693
1732
  this.resolveTurnInput(this.activeToolChoiceInput, promptCtx),
1694
1733
  this.resolveTurnInput(this.activeCachePolicyInput, promptCtx),
1695
1734
  this.resolveTurnInput(this.activeTailContextInput, promptCtx),
1735
+ this.resolveTurnInput(this.activeResponseSchemaInput, promptCtx),
1696
1736
  ]);
1697
1737
  // The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
1698
1738
  // cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
@@ -1740,6 +1780,12 @@ export class ChatDriver extends EventTarget {
1740
1780
  cachePolicy: resolvedCachePolicy,
1741
1781
  // Framed volatile context injected at the message tail (never stored). Undefined → none.
1742
1782
  tailContext,
1783
+ // Structured-output schema for this turn (agent/state-resolved). When set, the transport
1784
+ // constrains the final answer to it natively where the model supports it. Undefined → free text.
1785
+ responseSchema: resolvedResponseSchema,
1786
+ // Refusal-fallback chain (e.g. Fable 5 → Opus 4.8). Passed to the provider; applied
1787
+ // server-side where supported. Undefined → no fallback.
1788
+ fallbacks: this.activeFallbacks,
1743
1789
  };
1744
1790
  // Resolve the active provider for this turn. Static names were validated
1745
1791
  // in `applyAgent`; function-form names are validated on first resolution
@@ -1784,7 +1830,7 @@ export class ChatDriver extends EventTarget {
1784
1830
  content: 'While working on your request, I repeatedly called my tools incorrectly. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1785
1831
  });
1786
1832
  }
1787
- return { reason: 'done' };
1833
+ return this.turnDone('malformed-function-call');
1788
1834
  }
1789
1835
  // The response was truncated at the provider's output-token cap while it
1790
1836
  // still carried a tool call — its arguments are incomplete and unusable.
@@ -1813,7 +1859,7 @@ export class ChatDriver extends EventTarget {
1813
1859
  content: 'My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once — try breaking your request into smaller steps.',
1814
1860
  });
1815
1861
  }
1816
- return { reason: 'done' };
1862
+ return this.turnDone('response-truncated');
1817
1863
  }
1818
1864
  // A request timeout from the transport (tagged `TimeoutError`) is not a
1819
1865
  // bug on our end — surface it distinctly instead of letting it fall
@@ -1839,7 +1885,9 @@ export class ChatDriver extends EventTarget {
1839
1885
  content: 'The request timed out. You can ask me to try again, or break this into a smaller step.',
1840
1886
  });
1841
1887
  }
1842
- return { reason: 'done' };
1888
+ // Recorded as `exception` above (there is no separate `timeout` member of
1889
+ // TurnFailureReason for the main turn); surface the same reason here.
1890
+ return this.turnDone('exception');
1843
1891
  }
1844
1892
  // The request was aborted: either a user cancel (turnController) or a
1845
1893
  // driver dispose (lifecycleController, chained into the turn). A user
@@ -1899,7 +1947,7 @@ export class ChatDriver extends EventTarget {
1899
1947
  content: 'While working on your request, I repeatedly generated a blank response. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1900
1948
  });
1901
1949
  }
1902
- return { reason: 'done' };
1950
+ return this.turnDone('empty-response');
1903
1951
  }
1904
1952
  else {
1905
1953
  // Split one model response into separate, individually-toggleable messages so each has its
@@ -2213,7 +2261,7 @@ export class ChatDriver extends EventTarget {
2213
2261
  content: "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue.",
2214
2262
  });
2215
2263
  }
2216
- return { reason: 'done' };
2264
+ return this.turnDone('unknown-tool-limit');
2217
2265
  }
2218
2266
  const firstContinuation = systemCalls[0];
2219
2267
  if (firstContinuation) {
@@ -2223,9 +2271,12 @@ export class ChatDriver extends EventTarget {
2223
2271
  // Sub-agent early exit — checked here so the exit point mirrors the
2224
2272
  // system-call pattern above. Set by completeSubAgent() in a tool handler.
2225
2273
  if (this.subAgentCompletion) {
2226
- return { reason: 'done' };
2274
+ return this.turnDone();
2227
2275
  }
2228
2276
  }
2277
+ // The loop fell through: either it hit the iteration cap (a failure) or it
2278
+ // broke on a clean final answer (success). Only the former carries a reason.
2279
+ let failureReason;
2229
2280
  if (iterations >= this.maxToolIterations) {
2230
2281
  logger.warn('ChatDriver: reached max tool iterations, stopping');
2231
2282
  recordTurnError(this.sessionKey, 'max-iterations', {
@@ -2244,8 +2295,9 @@ export class ChatDriver extends EventTarget {
2244
2295
  content: "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
2245
2296
  });
2246
2297
  }
2298
+ failureReason = 'max-iterations';
2247
2299
  }
2248
- return { reason: 'done' };
2300
+ return this.turnDone(failureReason);
2249
2301
  });
2250
2302
  }
2251
2303
  appendToHistory(message) {
@@ -1,7 +1,7 @@
1
1
  import { __awaiter } from "tslib";
2
- import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
2
+ import { isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
3
3
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
- import { agenticActivityBus } from '../../channel/ai-activity-bus';
4
+ import { AgenticActivityBus } from '../../channel/ai-activity-bus';
5
5
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
6
6
  import { sumCosts } from '../../utils/sum-costs';
7
7
  import { sumTokens } from '../../utils/sum-tokens';
@@ -65,8 +65,13 @@ const callsTool = (name, id) => ({
65
65
  toolCalls: [{ id, name, args: {} }],
66
66
  });
67
67
  const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
68
- const makeDriver = (config, provider, sessionKey = '') => {
69
- const driver = new ChatDriver(makeRegistry(provider), {}, [], undefined, undefined, 50, 5, undefined, sessionKey);
68
+ const makeDriver = (config, provider, sessionKey = '', activityBus) => {
69
+ const driver = new ChatDriver(makeRegistry(provider), {
70
+ maxToolIterations: 50,
71
+ maxFoldOperations: 5,
72
+ sessionKey,
73
+ activityBus,
74
+ });
70
75
  driver.applyAgent(config);
71
76
  return driver;
72
77
  };
@@ -85,12 +90,6 @@ const unresolvedEvents = (sessionKey) => getMetaEvents(sessionKey)
85
90
  // stale tool detection — stateful agent advances past a tool's state
86
91
  // ---------------------------------------------------------------------------
87
92
  const stale = createLogicSuite('ChatDriver stale-tool detection');
88
- // The driver imports the `agenticActivityBus` singleton, which opens a
89
- // BroadcastChannel at module load. An open channel keeps the test page alive
90
- // and hangs the runner, so close it once the suite finishes.
91
- stale.after(() => {
92
- agenticActivityBus.close();
93
- });
94
93
  stale('guides the model when it calls a tool that an earlier state exposed', () => __awaiter(void 0, void 0, void 0, function* () {
95
94
  // State A exposes tool_a; calling it advances to state B, which exposes only
96
95
  // tool_b. A factory-form agent narrows the tool set per turn, mirroring how
@@ -220,9 +219,6 @@ stale.run();
220
219
  // onUnresolvedTool hook — an agent can redirect an unresolved tool call
221
220
  // ---------------------------------------------------------------------------
222
221
  const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');
223
- hook.after(() => {
224
- agenticActivityBus.close();
225
- });
226
222
  hook('replaces the default for a hallucinated tool when the hook returns a string', () => __awaiter(void 0, void 0, void 0, function* () {
227
223
  const config = agent({
228
224
  name: 'Hooked',
@@ -498,11 +494,6 @@ cancel.run();
498
494
  // worker's turn(s), in order.
499
495
  // ---------------------------------------------------------------------------
500
496
  const subagent = createLogicSuite('ChatDriver sub-agents');
501
- subagent.after(() => {
502
- // Safe to call again even if `stale` already closed it — close() is
503
- // idempotent and cross-tab publishes are guarded by `&& this.channel`.
504
- agenticActivityBus.close();
505
- });
506
497
  /** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
507
498
  const completingWorker = (result) => agent({
508
499
  name: 'worker',
@@ -1116,16 +1107,17 @@ interactionContextLifecycle.run();
1116
1107
  // user". No tool-loop event fires at a park boundary, so these are the signal.
1117
1108
  // ---------------------------------------------------------------------------
1118
1109
  const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
1119
- interactionBus.after(() => {
1120
- agenticActivityBus.close();
1121
- });
1110
+ // These two tests need to observe what the driver publishes, so they inject a local
1111
+ // in-memory bus (no `crossTabEvents` → no BroadcastChannel is ever opened, nothing to close)
1112
+ // rather than the shared singleton. Every other suite lets the driver default to the no-op bus.
1122
1113
  interactionBus('brackets a park with interaction-requested then -resolved', () => __awaiter(void 0, void 0, void 0, function* () {
1123
1114
  const events = [];
1115
+ const bus = new AgenticActivityBus();
1124
1116
  const unsubs = [
1125
- agenticActivityBus.subscribe('interaction-requested', () => events.push('requested')),
1126
- agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved')),
1117
+ bus.subscribe('interaction-requested', () => events.push('requested')),
1118
+ bus.subscribe('interaction-resolved', () => events.push('resolved')),
1127
1119
  ];
1128
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1120
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1129
1121
  const pending = driver.requestInteraction('w', {});
1130
1122
  assert.equal(events, ['requested'], 'parking fires interaction-requested');
1131
1123
  const id = driver.getHistory().at(-1).interaction.interactionId;
@@ -1137,8 +1129,9 @@ interactionBus('brackets a park with interaction-requested then -resolved', () =
1137
1129
  }));
1138
1130
  interactionBus('a timed-out interaction still fires interaction-resolved', () => __awaiter(void 0, void 0, void 0, function* () {
1139
1131
  const events = [];
1140
- const unsub = agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved'));
1141
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1132
+ const bus = new AgenticActivityBus();
1133
+ const unsub = bus.subscribe('interaction-resolved', () => events.push('resolved'));
1134
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1142
1135
  // Never resolved by a user — the timeout path runs the same teardown, so it
1143
1136
  // must signal the bus too (else the button would stay enabled after a timeout).
1144
1137
  const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
@@ -1239,14 +1232,11 @@ const makeObservableRegistry = (initial) => {
1239
1232
  };
1240
1233
  };
1241
1234
  const makeDriverWithRegistry = (config, registry) => {
1242
- const driver = new ChatDriver(registry, {}, [], undefined, undefined, 50, 5, undefined, '');
1235
+ const driver = new ChatDriver(registry, { maxToolIterations: 50, maxFoldOperations: 5 });
1243
1236
  driver.applyAgent(config);
1244
1237
  return driver;
1245
1238
  };
1246
1239
  const observable = createLogicSuite('ChatDriver observable provider registry');
1247
- observable.after(() => {
1248
- agenticActivityBus.close();
1249
- });
1250
1240
  observable('a registry change clears the resolved-provider cache so the next turn uses the new provider', () => __awaiter(void 0, void 0, void 0, function* () {
1251
1241
  const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
1252
1242
  const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
@@ -1323,9 +1313,6 @@ const modelProvider = (model, responses) => {
1323
1313
  return provider;
1324
1314
  };
1325
1315
  const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
1326
- modelAttr.after(() => {
1327
- agenticActivityBus.close();
1328
- });
1329
1316
  modelAttr('stamps the resolved model and registry name onto an assistant reply', () => __awaiter(void 0, void 0, void 0, function* () {
1330
1317
  const provider = modelProvider('gemini-2.5-flash-lite', [
1331
1318
  { role: 'assistant', content: 'hi there' },
@@ -1392,9 +1379,6 @@ modelAttr.run();
1392
1379
  // condenseWhen — tool-declared context condensation (wiring through the loop)
1393
1380
  // ---------------------------------------------------------------------------
1394
1381
  const condense = createLogicSuite('ChatDriver condenseWhen');
1395
- condense.after(() => {
1396
- agenticActivityBus.close();
1397
- });
1398
1382
  const bigBody = 'A'.repeat(2000);
1399
1383
  /** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
1400
1384
  const readsPath = (id, path) => ({
@@ -1621,3 +1605,143 @@ condense('phaseEnd: collapses at endPhase() while the agent keeps running', () =
1621
1605
  assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'phaseEnd');
1622
1606
  }));
1623
1607
  condense.run();
1608
+ // ---------------------------------------------------------------------------
1609
+ // turn-outcome surfacing (PTC-0) — the typed failure reason must reach BOTH
1610
+ // the loop-boundary result (`ChatDriverResult.failureReason`) and the
1611
+ // `tool-loop-end` activity-bus event detail. Historically every exit flattened
1612
+ // to `{ reason: 'done' }` and the bus detail was `undefined`; these lock the
1613
+ // two seams together, one per `TurnFailureReason`, plus a happy-path compat
1614
+ // check that the legacy shape is byte-unchanged.
1615
+ // ---------------------------------------------------------------------------
1616
+ const outcome = createLogicSuite('ChatDriver turn-outcome surfacing');
1617
+ // This branch routes activity events to an INJECTED bus (default NOOP), not the module
1618
+ // singleton, so these tests inject a local bus and observe the same seam the driver publishes to.
1619
+ const outcomeBus = new AgenticActivityBus();
1620
+ outcome.after(() => {
1621
+ outcomeBus.close();
1622
+ });
1623
+ /**
1624
+ * Subscribe to `tool-loop-end` and expose the most recent detail seen. `fired()` reports
1625
+ * whether the event was seen at all — distinct from `detail()`, which is `undefined` both
1626
+ * when no event fired and when a clean turn fired with the historical `undefined` detail.
1627
+ */
1628
+ const captureLoopEnd = () => {
1629
+ let last;
1630
+ let seen = false;
1631
+ const stop = outcomeBus.subscribe('tool-loop-end', (d) => {
1632
+ last = d;
1633
+ seen = true;
1634
+ });
1635
+ return { detail: () => (seen ? last : undefined), fired: () => seen, stop };
1636
+ };
1637
+ /** A ChatDriver with an explicit (small) tool-iteration cap. */
1638
+ const makeCappedDriver = (config, provider, maxIterations, sessionKey = '') => {
1639
+ const driver = new ChatDriver(makeRegistry(provider), {
1640
+ maxToolIterations: maxIterations,
1641
+ maxFoldOperations: 5,
1642
+ sessionKey,
1643
+ activityBus: outcomeBus,
1644
+ });
1645
+ driver.applyAgent(config);
1646
+ return driver;
1647
+ };
1648
+ /**
1649
+ * Assert that a turn driven by `provider` surfaces `expected` at BOTH seams.
1650
+ * `driverFor` lets the max-iterations case swap in a low cap.
1651
+ */
1652
+ const assertSurfacesReason = (label_1, provider_1, expected_1, ...args_1) => __awaiter(void 0, [label_1, provider_1, expected_1, ...args_1], void 0, function* (label, provider, expected, driverFor = (c, p, k) => makeDriver(c, p, k, outcomeBus)) {
1653
+ var _a;
1654
+ clearMetaEventRegistry();
1655
+ const sessionKey = `outcome-${label}`;
1656
+ const config = agent({
1657
+ name: 'Static',
1658
+ toolDefinitions: [def('noop')],
1659
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
1660
+ });
1661
+ const driver = driverFor(config, provider, sessionKey);
1662
+ const cap = captureLoopEnd();
1663
+ const result = yield driver.sendMessage('go');
1664
+ // Seam 1 — the loop-boundary result. Discriminant stays 'done' (compat).
1665
+ assert.is(result.reason, 'done', `[${label}] discriminant stays 'done'`);
1666
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, expected, `[${label}] result.failureReason surfaces the typed reason`);
1667
+ // Seam 2 — the activity-bus tool-loop-end detail.
1668
+ assert.ok(cap.detail(), `[${label}] a tool-loop-end event fired`);
1669
+ assert.is(cap.detail().failureReason, expected, `[${label}] the bus detail carries the same reason`);
1670
+ // Consistency: the debug-log turn.error records the same taxonomy.
1671
+ const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
1672
+ assert.ok(err, `[${label}] a turn.error is recorded`);
1673
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.reason, expected, `[${label}] the debug-log reason matches`);
1674
+ cap.stop();
1675
+ });
1676
+ /** A provider that returns a MALFORMED_FUNCTION_CALL on every call. */
1677
+ const malformedProvider = () => ({
1678
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1679
+ throw new MalformedFunctionCallError('bad call');
1680
+ }),
1681
+ });
1682
+ /** A provider that returns an empty response on every call. */
1683
+ const emptyProvider = () => ({
1684
+ chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: '' }); }),
1685
+ });
1686
+ /** A provider that throws a ResponseTruncatedError (deterministic, no retry). */
1687
+ const truncatedProvider = () => ({
1688
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1689
+ throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
1690
+ }),
1691
+ });
1692
+ /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
1693
+ const throwingProvider = () => ({
1694
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1695
+ throw new Error('boom');
1696
+ }),
1697
+ });
1698
+ /** A provider that never stops calling a valid tool (drives the iteration cap). */
1699
+ const neverStopsProvider = () => {
1700
+ let n = 0;
1701
+ return {
1702
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1703
+ const id = `noop-${n}`;
1704
+ n += 1;
1705
+ return { role: 'assistant', content: '', toolCalls: [{ id, name: 'noop', args: {} }] };
1706
+ }),
1707
+ };
1708
+ };
1709
+ /** A provider that keeps calling a tool with no handler (hallucinated → limit). */
1710
+ const hallucinatedProvider = () => scriptedProvider(Array.from({ length: 6 }, (_u, i) => callsTool('ghost', `ghost-${i}`)));
1711
+ outcome('malformed-function-call surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1712
+ yield assertSurfacesReason('malformed', malformedProvider(), 'malformed-function-call');
1713
+ }));
1714
+ outcome('empty-response surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1715
+ yield assertSurfacesReason('empty', emptyProvider(), 'empty-response');
1716
+ }));
1717
+ outcome('response-truncated surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1718
+ yield assertSurfacesReason('truncated', truncatedProvider(), 'response-truncated');
1719
+ }));
1720
+ outcome('exception surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1721
+ yield assertSurfacesReason('exception', throwingProvider(), 'exception');
1722
+ }));
1723
+ outcome('unknown-tool-limit surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1724
+ yield assertSurfacesReason('unknown-tool', hallucinatedProvider(), 'unknown-tool-limit');
1725
+ }));
1726
+ outcome('max-iterations surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1727
+ yield assertSurfacesReason('max-iter', neverStopsProvider(), 'max-iterations', (c, p, k) => makeCappedDriver(c, p, 2, k));
1728
+ }));
1729
+ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)', () => __awaiter(void 0, void 0, void 0, function* () {
1730
+ clearMetaEventRegistry();
1731
+ const config = agent({ name: 'Static' });
1732
+ // Plain-text reply ends the turn cleanly on the first call.
1733
+ const driver = makeDriver(config, scriptedProvider([{ role: 'assistant', content: 'hi' }]), 'outcome-ok', outcomeBus);
1734
+ const cap = captureLoopEnd();
1735
+ const result = yield driver.sendMessage('go');
1736
+ // The result is exactly `{ reason: 'done' }` — no `failureReason` key added.
1737
+ assert.equal(result, { reason: 'done' }, 'happy-path result is the historical shape');
1738
+ assert.not.ok('failureReason' in result, 'no failureReason key is present on a clean turn');
1739
+ // The bus event still fires, and its detail is the historical `undefined` — a clean
1740
+ // turn emits no detail object at all (byte-shape compat), not `{ failureReason: undefined }`.
1741
+ assert.ok(cap.fired(), 'a tool-loop-end event fired');
1742
+ assert.is(cap.detail(), undefined, 'a clean turn emits the historical undefined detail');
1743
+ // And no turn.error was recorded.
1744
+ assert.not.ok(getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'), 'a clean turn records no turn.error');
1745
+ cap.stop();
1746
+ }));
1747
+ outcome.run();
@@ -109,7 +109,13 @@ export class OrchestratingDriver extends EventTarget {
109
109
  const rawFallback = fallbacks[0];
110
110
  this.fallback = rawFallback
111
111
  ? Object.assign(Object.assign({}, rawFallback), { systemPrompt: buildFallbackSystemPrompt(rawFallback, this.specialists) }) : undefined;
112
- this.chatDriver = new ChatDriver(providerRegistry, {}, [], undefined, undefined, options.maxToolIterations, options.maxFoldOperations, options.maxTurnSnapshots, this.sessionKey);
112
+ this.chatDriver = new ChatDriver(providerRegistry, {
113
+ maxToolIterations: options.maxToolIterations,
114
+ maxFoldOperations: options.maxFoldOperations,
115
+ maxTurnSnapshots: options.maxTurnSnapshots,
116
+ sessionKey: this.sessionKey,
117
+ activityBus: options.activityBus,
118
+ });
113
119
  // Proxy events from the shared driver
114
120
  this.chatDriver.addEventListener('history-updated', (e) => {
115
121
  this.dispatchEvent(new CustomEvent('history-updated', { detail: e.detail }));
@@ -1056,9 +1056,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1056
1056
  maxToolIterations: agent.maxToolIterations,
1057
1057
  maxFoldOperations: agent.maxFoldOperations,
1058
1058
  maxTurnSnapshots: agent.maxTurnSnapshots,
1059
+ activityBus: agenticActivityBus,
1059
1060
  });
1060
1061
  }
1061
- return new ChatDriver(this.providerRegistry, {}, [], undefined, undefined, agent.maxToolIterations, agent.maxFoldOperations, agent.maxTurnSnapshots, (_c = this.getStateKey()) !== null && _c !== void 0 ? _c : '');
1062
+ return new ChatDriver(this.providerRegistry, {
1063
+ maxToolIterations: agent.maxToolIterations,
1064
+ maxFoldOperations: agent.maxFoldOperations,
1065
+ maxTurnSnapshots: agent.maxTurnSnapshots,
1066
+ sessionKey: (_c = this.getStateKey()) !== null && _c !== void 0 ? _c : '',
1067
+ activityBus: agenticActivityBus,
1068
+ });
1062
1069
  }
1063
1070
  /**
1064
1071
  * Attaches event listeners to the current driver. Stores a cleanup function
@@ -1,5 +1,4 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { agenticActivityBus } from '../channel/ai-activity-bus';
3
2
  import { FoundationAiAssistant } from './main';
4
3
  // Hold a reference so the custom-element registration isn't tree-shaken.
5
4
  FoundationAiAssistant;
@@ -11,16 +10,13 @@ FoundationAiAssistant;
11
10
  // (`main.template.ts`). This suite pins the GATE LOGIC (`hasActivePendingInteraction`)
12
11
  // that binding depends on, so a change to when the gate opens/closes is caught.
13
12
  //
14
- // Note: this deliberately does NOT mount the assistant. Mounting runs
15
- // `connectedCallback`, which activates the `agenticActivityBus` `BroadcastChannel` and
16
- // leaves the runner's event loop open (the suite hangs on exit). A getter-level test
17
- // needs no mount and stays fast + reliable. `document.createElement` only upgrades the
18
- // element (constructor) it does not connect so no bus subscription is created;
19
- // importing the module still opens the module-load channel, so close it after the suite.
13
+ // Note: this deliberately does NOT mount the assistant. Mounting runs `connectedCallback`,
14
+ // which subscribes to `agenticActivityBus` — opening a cross-tab `BroadcastChannel` in a browser
15
+ // env and leaving the runner's event loop open. A getter-level test needs no mount and stays fast
16
+ // + reliable. `document.createElement` only upgrades the element (constructor) — it does not
17
+ // connectso nothing subscribes, and the bus opens its channel lazily on first use, so none is
18
+ // ever created here (no teardown needed).
20
19
  const Suite = createLogicSuite('FoundationAiAssistant popout interaction gate');
21
- Suite.after(() => {
22
- agenticActivityBus.close();
23
- });
24
20
  /** A fresh (unconnected) element with a fake session store wired in. */
25
21
  function elementWith(state, messages) {
26
22
  const el = document.createElement('foundation-ai-assistant');