@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
@@ -2,16 +2,22 @@ import type {
2
2
  AIProvider,
3
3
  AIProviderRegistry,
4
4
  CachePolicy,
5
+ ChatDriverResult,
5
6
  ChatMessage,
6
7
  ChatRequestOptions,
7
8
  ChatToolCall,
8
9
  ChatToolChoice,
9
10
  ChatToolDefinition,
10
11
  InteractionResult,
12
+ TurnFailureReason,
13
+ } from '@genesislcap/foundation-ai';
14
+ import {
15
+ isChatToolCallUnknown,
16
+ MalformedFunctionCallError,
17
+ ResponseTruncatedError,
11
18
  } from '@genesislcap/foundation-ai';
12
- import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
13
19
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
14
- import { agenticActivityBus } from '../../channel/ai-activity-bus';
20
+ import { type ActivityBus, AgenticActivityBus } from '../../channel/ai-activity-bus';
15
21
  import type { AgentConfig } from '../../config/config';
16
22
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
17
23
  import { sumCosts } from '../../utils/sum-costs';
@@ -114,18 +120,18 @@ const callsTool = (name: string, id: string): ChatMessage => ({
114
120
  const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
115
121
  ({ description: 'test agent', ...overrides }) as AgentConfig;
116
122
 
117
- const makeDriver = (config: AgentConfig, provider: AIProvider, sessionKey = ''): ChatDriver => {
118
- const driver = new ChatDriver(
119
- makeRegistry(provider),
120
- {},
121
- [],
122
- undefined,
123
- undefined,
124
- 50,
125
- 5,
126
- undefined,
123
+ const makeDriver = (
124
+ config: AgentConfig,
125
+ provider: AIProvider,
126
+ sessionKey = '',
127
+ activityBus?: ActivityBus,
128
+ ): ChatDriver => {
129
+ const driver = new ChatDriver(makeRegistry(provider), {
130
+ maxToolIterations: 50,
131
+ maxFoldOperations: 5,
127
132
  sessionKey,
128
- );
133
+ activityBus,
134
+ });
129
135
  driver.applyAgent(config);
130
136
  return driver;
131
137
  };
@@ -153,13 +159,6 @@ const unresolvedEvents = (sessionKey: string): Array<Record<string, unknown>> =>
153
159
 
154
160
  const stale = createLogicSuite('ChatDriver stale-tool detection');
155
161
 
156
- // The driver imports the `agenticActivityBus` singleton, which opens a
157
- // BroadcastChannel at module load. An open channel keeps the test page alive
158
- // and hangs the runner, so close it once the suite finishes.
159
- stale.after(() => {
160
- agenticActivityBus.close();
161
- });
162
-
163
162
  stale('guides the model when it calls a tool that an earlier state exposed', async () => {
164
163
  // State A exposes tool_a; calling it advances to state B, which exposes only
165
164
  // tool_b. A factory-form agent narrows the tool set per turn, mirroring how
@@ -357,10 +356,6 @@ stale.run();
357
356
 
358
357
  const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');
359
358
 
360
- hook.after(() => {
361
- agenticActivityBus.close();
362
- });
363
-
364
359
  hook('replaces the default for a hallucinated tool when the hook returns a string', async () => {
365
360
  const config = agent({
366
361
  name: 'Hooked',
@@ -716,12 +711,6 @@ cancel.run();
716
711
 
717
712
  const subagent = createLogicSuite('ChatDriver sub-agents');
718
713
 
719
- subagent.after(() => {
720
- // Safe to call again even if `stale` already closed it — close() is
721
- // idempotent and cross-tab publishes are guarded by `&& this.channel`.
722
- agenticActivityBus.close();
723
- });
724
-
725
714
  /** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
726
715
  const completingWorker = (result: unknown): AgentConfig =>
727
716
  agent({
@@ -1530,17 +1519,18 @@ interactionContextLifecycle.run();
1530
1519
 
1531
1520
  const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
1532
1521
 
1533
- interactionBus.after(() => {
1534
- agenticActivityBus.close();
1535
- });
1522
+ // These two tests need to observe what the driver publishes, so they inject a local
1523
+ // in-memory bus (no `crossTabEvents` → no BroadcastChannel is ever opened, nothing to close)
1524
+ // rather than the shared singleton. Every other suite lets the driver default to the no-op bus.
1536
1525
 
1537
1526
  interactionBus('brackets a park with interaction-requested then -resolved', async () => {
1538
1527
  const events: string[] = [];
1528
+ const bus = new AgenticActivityBus();
1539
1529
  const unsubs = [
1540
- agenticActivityBus.subscribe('interaction-requested', () => events.push('requested')),
1541
- agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved')),
1530
+ bus.subscribe('interaction-requested', () => events.push('requested')),
1531
+ bus.subscribe('interaction-resolved', () => events.push('resolved')),
1542
1532
  ];
1543
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1533
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1544
1534
 
1545
1535
  const pending = driver.requestInteraction('w', {});
1546
1536
  assert.equal(events, ['requested'], 'parking fires interaction-requested');
@@ -1556,8 +1546,9 @@ interactionBus('brackets a park with interaction-requested then -resolved', asyn
1556
1546
 
1557
1547
  interactionBus('a timed-out interaction still fires interaction-resolved', async () => {
1558
1548
  const events: string[] = [];
1559
- const unsub = agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved'));
1560
- const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1549
+ const bus = new AgenticActivityBus();
1550
+ const unsub = bus.subscribe('interaction-resolved', () => events.push('resolved'));
1551
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
1561
1552
 
1562
1553
  // Never resolved by a user — the timeout path runs the same teardown, so it
1563
1554
  // must signal the bus too (else the button would stay enabled after a timeout).
@@ -1713,17 +1704,13 @@ const makeObservableRegistry = (initial: AIProvider): ObservableTestRegistry =>
1713
1704
  };
1714
1705
 
1715
1706
  const makeDriverWithRegistry = (config: AgentConfig, registry: AIProviderRegistry): ChatDriver => {
1716
- const driver = new ChatDriver(registry, {}, [], undefined, undefined, 50, 5, undefined, '');
1707
+ const driver = new ChatDriver(registry, { maxToolIterations: 50, maxFoldOperations: 5 });
1717
1708
  driver.applyAgent(config);
1718
1709
  return driver;
1719
1710
  };
1720
1711
 
1721
1712
  const observable = createLogicSuite('ChatDriver observable provider registry');
1722
1713
 
1723
- observable.after(() => {
1724
- agenticActivityBus.close();
1725
- });
1726
-
1727
1714
  observable(
1728
1715
  'a registry change clears the resolved-provider cache so the next turn uses the new provider',
1729
1716
  async () => {
@@ -1822,10 +1809,6 @@ const modelProvider = (model: string | undefined, responses: ChatMessage[]): AIP
1822
1809
 
1823
1810
  const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
1824
1811
 
1825
- modelAttr.after(() => {
1826
- agenticActivityBus.close();
1827
- });
1828
-
1829
1812
  modelAttr('stamps the resolved model and registry name onto an assistant reply', async () => {
1830
1813
  const provider = modelProvider('gemini-2.5-flash-lite', [
1831
1814
  { role: 'assistant', content: 'hi there' },
@@ -1912,9 +1895,6 @@ modelAttr.run();
1912
1895
  // ---------------------------------------------------------------------------
1913
1896
 
1914
1897
  const condense = createLogicSuite('ChatDriver condenseWhen');
1915
- condense.after(() => {
1916
- agenticActivityBus.close();
1917
- });
1918
1898
 
1919
1899
  const bigBody = 'A'.repeat(2000);
1920
1900
 
@@ -2175,3 +2155,210 @@ condense('phaseEnd: collapses at endPhase() while the agent keeps running', asyn
2175
2155
  });
2176
2156
 
2177
2157
  condense.run();
2158
+
2159
+ // ---------------------------------------------------------------------------
2160
+ // turn-outcome surfacing (PTC-0) — the typed failure reason must reach BOTH
2161
+ // the loop-boundary result (`ChatDriverResult.failureReason`) and the
2162
+ // `tool-loop-end` activity-bus event detail. Historically every exit flattened
2163
+ // to `{ reason: 'done' }` and the bus detail was `undefined`; these lock the
2164
+ // two seams together, one per `TurnFailureReason`, plus a happy-path compat
2165
+ // check that the legacy shape is byte-unchanged.
2166
+ // ---------------------------------------------------------------------------
2167
+
2168
+ const outcome = createLogicSuite('ChatDriver turn-outcome surfacing');
2169
+
2170
+ // This branch routes activity events to an INJECTED bus (default NOOP), not the module
2171
+ // singleton, so these tests inject a local bus and observe the same seam the driver publishes to.
2172
+ const outcomeBus = new AgenticActivityBus();
2173
+
2174
+ outcome.after(() => {
2175
+ outcomeBus.close();
2176
+ });
2177
+
2178
+ /**
2179
+ * Subscribe to `tool-loop-end` and expose the most recent detail seen. `fired()` reports
2180
+ * whether the event was seen at all — distinct from `detail()`, which is `undefined` both
2181
+ * when no event fired and when a clean turn fired with the historical `undefined` detail.
2182
+ */
2183
+ const captureLoopEnd = (): {
2184
+ detail: () => { failureReason?: TurnFailureReason } | undefined;
2185
+ fired: () => boolean;
2186
+ stop: () => void;
2187
+ } => {
2188
+ let last: { failureReason?: TurnFailureReason } | undefined;
2189
+ let seen = false;
2190
+ const stop = outcomeBus.subscribe('tool-loop-end', (d) => {
2191
+ last = d;
2192
+ seen = true;
2193
+ });
2194
+ return { detail: () => (seen ? last : undefined), fired: () => seen, stop };
2195
+ };
2196
+
2197
+ /** A ChatDriver with an explicit (small) tool-iteration cap. */
2198
+ const makeCappedDriver = (
2199
+ config: AgentConfig,
2200
+ provider: AIProvider,
2201
+ maxIterations: number,
2202
+ sessionKey = '',
2203
+ ): ChatDriver => {
2204
+ const driver = new ChatDriver(makeRegistry(provider), {
2205
+ maxToolIterations: maxIterations,
2206
+ maxFoldOperations: 5,
2207
+ sessionKey,
2208
+ activityBus: outcomeBus,
2209
+ });
2210
+ driver.applyAgent(config);
2211
+ return driver;
2212
+ };
2213
+
2214
+ /**
2215
+ * Assert that a turn driven by `provider` surfaces `expected` at BOTH seams.
2216
+ * `driverFor` lets the max-iterations case swap in a low cap.
2217
+ */
2218
+ const assertSurfacesReason = async (
2219
+ label: string,
2220
+ provider: AIProvider,
2221
+ expected: TurnFailureReason,
2222
+ driverFor: (config: AgentConfig, provider: AIProvider, sessionKey: string) => ChatDriver = (
2223
+ c,
2224
+ p,
2225
+ k,
2226
+ ) => makeDriver(c, p, k, outcomeBus),
2227
+ ): Promise<void> => {
2228
+ clearMetaEventRegistry();
2229
+ const sessionKey = `outcome-${label}`;
2230
+ const config = agent({
2231
+ name: 'Static',
2232
+ toolDefinitions: [def('noop')],
2233
+ toolHandlers: { noop: async () => 'ok' },
2234
+ });
2235
+ const driver = driverFor(config, provider, sessionKey);
2236
+ const cap = captureLoopEnd();
2237
+
2238
+ const result: ChatDriverResult = await driver.sendMessage('go');
2239
+
2240
+ // Seam 1 — the loop-boundary result. Discriminant stays 'done' (compat).
2241
+ assert.is(result.reason, 'done', `[${label}] discriminant stays 'done'`);
2242
+ assert.is(
2243
+ result.reason === 'done' ? result.failureReason : undefined,
2244
+ expected,
2245
+ `[${label}] result.failureReason surfaces the typed reason`,
2246
+ );
2247
+
2248
+ // Seam 2 — the activity-bus tool-loop-end detail.
2249
+ assert.ok(cap.detail(), `[${label}] a tool-loop-end event fired`);
2250
+ assert.is(
2251
+ cap.detail()!.failureReason,
2252
+ expected,
2253
+ `[${label}] the bus detail carries the same reason`,
2254
+ );
2255
+
2256
+ // Consistency: the debug-log turn.error records the same taxonomy.
2257
+ const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
2258
+ assert.ok(err, `[${label}] a turn.error is recorded`);
2259
+ assert.is(err!.detail?.reason, expected, `[${label}] the debug-log reason matches`);
2260
+
2261
+ cap.stop();
2262
+ };
2263
+
2264
+ /** A provider that returns a MALFORMED_FUNCTION_CALL on every call. */
2265
+ const malformedProvider = (): AIProvider => ({
2266
+ chat: async (): Promise<ChatMessage> => {
2267
+ throw new MalformedFunctionCallError('bad call');
2268
+ },
2269
+ });
2270
+
2271
+ /** A provider that returns an empty response on every call. */
2272
+ const emptyProvider = (): AIProvider => ({
2273
+ chat: async (): Promise<ChatMessage> => ({ role: 'assistant', content: '' }),
2274
+ });
2275
+
2276
+ /** A provider that throws a ResponseTruncatedError (deterministic, no retry). */
2277
+ const truncatedProvider = (): AIProvider => ({
2278
+ chat: async (): Promise<ChatMessage> => {
2279
+ throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
2280
+ },
2281
+ });
2282
+
2283
+ /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
2284
+ const throwingProvider = (): AIProvider => ({
2285
+ chat: async (): Promise<ChatMessage> => {
2286
+ throw new Error('boom');
2287
+ },
2288
+ });
2289
+
2290
+ /** A provider that never stops calling a valid tool (drives the iteration cap). */
2291
+ const neverStopsProvider = (): AIProvider => {
2292
+ let n = 0;
2293
+ return {
2294
+ chat: async (): Promise<ChatMessage> => {
2295
+ const id = `noop-${n}`;
2296
+ n += 1;
2297
+ return { role: 'assistant', content: '', toolCalls: [{ id, name: 'noop', args: {} }] };
2298
+ },
2299
+ };
2300
+ };
2301
+
2302
+ /** A provider that keeps calling a tool with no handler (hallucinated → limit). */
2303
+ const hallucinatedProvider = (): AIProvider =>
2304
+ scriptedProvider(Array.from({ length: 6 }, (_u, i) => callsTool('ghost', `ghost-${i}`)));
2305
+
2306
+ outcome('malformed-function-call surfaces at both seams', async () => {
2307
+ await assertSurfacesReason('malformed', malformedProvider(), 'malformed-function-call');
2308
+ });
2309
+
2310
+ outcome('empty-response surfaces at both seams', async () => {
2311
+ await assertSurfacesReason('empty', emptyProvider(), 'empty-response');
2312
+ });
2313
+
2314
+ outcome('response-truncated surfaces at both seams', async () => {
2315
+ await assertSurfacesReason('truncated', truncatedProvider(), 'response-truncated');
2316
+ });
2317
+
2318
+ outcome('exception surfaces at both seams', async () => {
2319
+ await assertSurfacesReason('exception', throwingProvider(), 'exception');
2320
+ });
2321
+
2322
+ outcome('unknown-tool-limit surfaces at both seams', async () => {
2323
+ await assertSurfacesReason('unknown-tool', hallucinatedProvider(), 'unknown-tool-limit');
2324
+ });
2325
+
2326
+ outcome('max-iterations surfaces at both seams', async () => {
2327
+ await assertSurfacesReason('max-iter', neverStopsProvider(), 'max-iterations', (c, p, k) =>
2328
+ makeCappedDriver(c, p, 2, k),
2329
+ );
2330
+ });
2331
+
2332
+ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)', async () => {
2333
+ clearMetaEventRegistry();
2334
+ const config = agent({ name: 'Static' });
2335
+ // Plain-text reply ends the turn cleanly on the first call.
2336
+ const driver = makeDriver(
2337
+ config,
2338
+ scriptedProvider([{ role: 'assistant', content: 'hi' }]),
2339
+ 'outcome-ok',
2340
+ outcomeBus,
2341
+ );
2342
+ const cap = captureLoopEnd();
2343
+
2344
+ const result: ChatDriverResult = await driver.sendMessage('go');
2345
+
2346
+ // The result is exactly `{ reason: 'done' }` — no `failureReason` key added.
2347
+ assert.equal(result, { reason: 'done' }, 'happy-path result is the historical shape');
2348
+ assert.not.ok('failureReason' in result, 'no failureReason key is present on a clean turn');
2349
+
2350
+ // The bus event still fires, and its detail is the historical `undefined` — a clean
2351
+ // turn emits no detail object at all (byte-shape compat), not `{ failureReason: undefined }`.
2352
+ assert.ok(cap.fired(), 'a tool-loop-end event fired');
2353
+ assert.is(cap.detail(), undefined, 'a clean turn emits the historical undefined detail');
2354
+
2355
+ // And no turn.error was recorded.
2356
+ assert.not.ok(
2357
+ getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'),
2358
+ 'a clean turn records no turn.error',
2359
+ );
2360
+
2361
+ cap.stop();
2362
+ });
2363
+
2364
+ outcome.run();