@genesislcap/ai-assistant 14.452.1 → 14.453.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 (29) hide show
  1. package/dist/ai-assistant.api.json +221 -2
  2. package/dist/ai-assistant.d.ts +93 -1
  3. package/dist/dts/components/ai-driver/ai-driver.d.ts +13 -0
  4. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.d.ts +47 -0
  6. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +12 -0
  8. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  9. package/dist/dts/main/main.d.ts +16 -0
  10. package/dist/dts/main/main.d.ts.map +1 -1
  11. package/dist/dts/main/main.styles.d.ts.map +1 -1
  12. package/dist/dts/main/main.template.d.ts.map +1 -1
  13. package/dist/dts/main/main.types.d.ts +5 -1
  14. package/dist/dts/main/main.types.d.ts.map +1 -1
  15. package/dist/esm/components/chat-driver/chat-driver.js +129 -1
  16. package/dist/esm/components/chat-driver/chat-driver.test.js +190 -0
  17. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -0
  18. package/dist/esm/main/main.js +49 -11
  19. package/dist/esm/main/main.styles.js +26 -0
  20. package/dist/esm/main/main.template.js +27 -11
  21. package/package.json +16 -16
  22. package/src/components/ai-driver/ai-driver.ts +15 -0
  23. package/src/components/chat-driver/chat-driver.test.ts +227 -0
  24. package/src/components/chat-driver/chat-driver.ts +136 -1
  25. package/src/components/orchestrating-driver/orchestrating-driver.ts +24 -0
  26. package/src/main/main.styles.ts +26 -0
  27. package/src/main/main.template.ts +30 -11
  28. package/src/main/main.ts +48 -11
  29. package/src/main/main.types.ts +5 -1
@@ -593,6 +593,32 @@ export const styles = css `
593
593
  background-color: var(--neutral-layer-2);
594
594
  }
595
595
 
596
+ /* Right-hand control column, mirroring .input-left-controls — holds the
597
+ Stop button (shown while a turn runs) stacked above the Send button. The
598
+ Stop button is an icon (stop → spinner), so the column width is driven by
599
+ Send and stays stable whether a turn is running or idle. */
600
+ .input-right-controls {
601
+ display: flex;
602
+ flex-direction: column;
603
+ align-items: stretch;
604
+ gap: calc(var(--design-unit) * 1px);
605
+ }
606
+
607
+ /* The "stopping" state shows a spinner — rotate it (the icon set's glyph is
608
+ static). Transform-only, so it never affects layout/width. */
609
+ @keyframes ai-stop-spin {
610
+ to {
611
+ transform: rotate(360deg);
612
+ }
613
+ }
614
+
615
+ .stop-icon-spinning {
616
+ /* inline-block so the icon host is transformable — an inline element
617
+ ignores the transform, which is why the spinner appeared frozen. */
618
+ display: inline-block;
619
+ animation: ai-stop-spin 0.8s linear infinite;
620
+ }
621
+
596
622
  .input-left-controls {
597
623
  display: flex;
598
624
  flex-direction: column;
@@ -15,7 +15,7 @@
15
15
  * lines let the formatter inject whitespace between them.
16
16
  */
17
17
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
18
- import { html, ref, repeat, when } from '@genesislcap/web-core';
18
+ import { classNames, html, ref, repeat, when } from '@genesislcap/web-core';
19
19
  import { ANIMATION_DEFS } from './main.types';
20
20
  function unknownToolPayload(tc) {
21
21
  if (!isChatToolCallUnknown(tc))
@@ -206,7 +206,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
206
206
  part="agent-toggle-button"
207
207
  appearance="stealth"
208
208
  title=${(x) => x.agentToggleTitle}
209
- ?disabled=${(x) => x.state === 'loading' || x.pinLocked}
209
+ ?disabled=${(x) => x.busy || x.pinLocked}
210
210
  @click=${(x) => x.toggleAgentPicker()}
211
211
  >
212
212
  ${when((x) => x.agentPickerOpen, html `<${iconTag} name="chevron-down"></${iconTag}>`)}
@@ -227,7 +227,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
227
227
  part="attach-button"
228
228
  appearance="stealth"
229
229
  title=${(x) => { var _a; return `Attach file (${(_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles})`; }}
230
- ?disabled=${(x) => x.state === 'loading'}
230
+ ?disabled=${(x) => x.busy}
231
231
  @click=${(x) => x.triggerFileInput()}
232
232
  ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
233
233
  `;
@@ -496,7 +496,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
496
496
  `)}
497
497
  ${when((x) => x.agentPickerEnabled && x.agentPickerOpen, agentPickerPanelTemplate)}
498
498
  ${when((x) => !x.composerHiddenByConfig &&
499
- !(x.state === 'loading' && x.effectiveChatInputDuringExecution === 'hidden'), html `
499
+ !(x.busy && x.effectiveChatInputDuringExecution === 'hidden'), html `
500
500
  <div class="input-row" part="input-row">
501
501
  ${when((x) => { var _a; return x.agentPickerEnabled || !!((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles); }, inputLeftControlsTemplate)}
502
502
  <${textareaTag}
@@ -505,7 +505,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
505
505
  part="input"
506
506
  placeholder=${(x) => x.effectivePlaceholder}
507
507
  :value=${(x) => x.inputValue}
508
- ?disabled=${(x) => x.state === 'loading'}
508
+ ?disabled=${(x) => x.busy}
509
509
  @input=${(x, c) => (x.inputValue = c.event.target.value)}
510
510
  @keydown=${(x, c) => {
511
511
  if (c.event.key === 'Enter' &&
@@ -517,12 +517,28 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
517
517
  return true;
518
518
  }}
519
519
  ></${textareaTag}>
520
- <${buttonTag}
521
- class="send-button"
522
- part="send-button"
523
- ?disabled=${(x) => x.state === 'loading' || (!x.inputValue.trim() && !x.attachments.length)}
524
- @click=${(x) => x.handleSendClick()}
525
- >Send</${buttonTag}>
520
+ <div class="input-right-controls">
521
+ ${when((x) => { var _a; return x.busy && !((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.hideStopButton); }, html `
522
+ <${buttonTag}
523
+ class="stop-button"
524
+ part="stop-button"
525
+ appearance="stealth"
526
+ title="${(x) => (x.state === 'cancelling' ? 'Stopping…' : 'Stop')}"
527
+ aria-label="${(x) => (x.state === 'cancelling' ? 'Stopping' : 'Stop')}"
528
+ ?disabled=${(x) => x.state === 'cancelling'}
529
+ @click=${(x) => x.handleStopClick()}
530
+ ><${iconTag}
531
+ class="${(x) => classNames('stop-icon', ['stop-icon-spinning', x.state === 'cancelling'])}"
532
+ name="${(x) => (x.state === 'cancelling' ? 'spinner' : 'stop')}"
533
+ ></${iconTag}></${buttonTag}>
534
+ `)}
535
+ <${buttonTag}
536
+ class="send-button"
537
+ part="send-button"
538
+ ?disabled=${(x) => x.busy || (!x.inputValue.trim() && !x.attachments.length)}
539
+ @click=${(x) => x.handleSendClick()}
540
+ >Send</${buttonTag}>
541
+ </div>
526
542
  </div>
527
543
  `)}
528
544
  <ai-halo-overlay
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "14.452.1",
4
+ "version": "14.453.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.452.1",
68
- "@genesislcap/genx": "14.452.1",
69
- "@genesislcap/rollup-builder": "14.452.1",
70
- "@genesislcap/ts-builder": "14.452.1",
71
- "@genesislcap/uvu-playwright-builder": "14.452.1",
72
- "@genesislcap/vite-builder": "14.452.1",
73
- "@genesislcap/webpack-builder": "14.452.1",
67
+ "@genesislcap/foundation-testing": "14.453.0",
68
+ "@genesislcap/genx": "14.453.0",
69
+ "@genesislcap/rollup-builder": "14.453.0",
70
+ "@genesislcap/ts-builder": "14.453.0",
71
+ "@genesislcap/uvu-playwright-builder": "14.453.0",
72
+ "@genesislcap/vite-builder": "14.453.0",
73
+ "@genesislcap/webpack-builder": "14.453.0",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.452.1",
79
- "@genesislcap/foundation-logger": "14.452.1",
80
- "@genesislcap/foundation-redux": "14.452.1",
81
- "@genesislcap/foundation-ui": "14.452.1",
82
- "@genesislcap/foundation-utils": "14.452.1",
83
- "@genesislcap/rapid-design-system": "14.452.1",
84
- "@genesislcap/web-core": "14.452.1",
78
+ "@genesislcap/foundation-ai": "14.453.0",
79
+ "@genesislcap/foundation-logger": "14.453.0",
80
+ "@genesislcap/foundation-redux": "14.453.0",
81
+ "@genesislcap/foundation-ui": "14.453.0",
82
+ "@genesislcap/foundation-utils": "14.453.0",
83
+ "@genesislcap/rapid-design-system": "14.453.0",
84
+ "@genesislcap/web-core": "14.453.0",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "57cd7afd42c9a1554432603c66e4e5f750c3dc08"
96
+ "gitHead": "510e12f2d2b7d210c77d629427fa4081912bd99f"
97
97
  }
@@ -83,4 +83,19 @@ export interface AiDriver extends EventTarget {
83
83
  * @beta
84
84
  */
85
85
  getActiveProviderName(): string;
86
+
87
+ /**
88
+ * Tear down the driver, aborting any in-flight provider request. Called by
89
+ * the host when it swaps in a replacement driver. Optional because not every
90
+ * implementation needs teardown; the host invokes it when present.
91
+ */
92
+ dispose?(): void | Promise<void>;
93
+
94
+ /**
95
+ * Stop the current turn in response to a user "stop" action. Aborts the
96
+ * in-flight provider request; a tool already executing runs to completion and
97
+ * the loop stops at the next boundary. The driver stays usable afterwards.
98
+ * Optional; the host wires the stop button to it when present.
99
+ */
100
+ cancel?(): void;
86
101
  }
@@ -319,6 +319,233 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
319
319
 
320
320
  stale.run();
321
321
 
322
+ // ---------------------------------------------------------------------------
323
+ // timeout handling — a transport `TimeoutError` surfaces a distinct message
324
+ // ---------------------------------------------------------------------------
325
+
326
+ const timeout = createLogicSuite('ChatDriver timeout handling');
327
+
328
+ /** A provider whose `chat` rejects with the `TimeoutError` the transports tag
329
+ * a request timeout with (see gemini/anthropic `post`). */
330
+ const timesOutProvider = (): AIProvider => ({
331
+ chat: async (): Promise<ChatMessage> => {
332
+ throw new DOMException('Gemini request timed out', 'TimeoutError');
333
+ },
334
+ });
335
+
336
+ timeout('surfaces a timeout-specific message instead of the generic failure', async () => {
337
+ clearMetaEventRegistry();
338
+ const config = agent({
339
+ name: 'Static',
340
+ toolDefinitions: [def('noop')],
341
+ toolHandlers: { noop: async () => 'ok' },
342
+ });
343
+ const sessionKey = 'timeout-test';
344
+ const driver = makeDriver(config, timesOutProvider(), sessionKey);
345
+
346
+ const result = await driver.sendMessage('go');
347
+ assert.is(result.reason, 'done');
348
+
349
+ // The turn ends with the timeout message, NOT the generic "something went wrong".
350
+ const last = driver.getHistory().at(-1);
351
+ assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
352
+ assert.ok(last!.content.includes('timed out'), 'message names the timeout');
353
+ assert.not.ok(
354
+ last!.content.includes('something went wrong'),
355
+ 'must not fall through to the generic catch',
356
+ );
357
+
358
+ // Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
359
+ // not swallowed silently.
360
+ const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
361
+ assert.ok(err, 'a turn.error should be recorded');
362
+ assert.is(err!.detail?.reason, 'exception');
363
+ assert.is(err!.detail?.name, 'TimeoutError');
364
+ });
365
+
366
+ timeout.run();
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const cancel = createLogicSuite('ChatDriver cancellation');
373
+
374
+ cancel('threads a non-aborted signal into the provider call by default', async () => {
375
+ const config = agent({
376
+ name: 'Static',
377
+ toolDefinitions: [def('noop')],
378
+ toolHandlers: { noop: async () => 'ok' },
379
+ });
380
+ const capture: { signal?: AbortSignal } = {};
381
+ const provider: AIProvider = {
382
+ chat: async (_h, _m, options): Promise<ChatMessage> => {
383
+ capture.signal = options?.signal;
384
+ return { role: 'assistant', content: 'hi' };
385
+ },
386
+ };
387
+ const driver = makeDriver(config, provider, 'signal-plumb');
388
+
389
+ await driver.sendMessage('go');
390
+
391
+ assert.ok(capture.signal, 'provider received an options.signal');
392
+ assert.not.ok(capture.signal!.aborted, 'and it is not aborted on a normal turn');
393
+ });
394
+
395
+ cancel('dispose() aborts the in-flight request and stops quietly', async () => {
396
+ clearMetaEventRegistry();
397
+ const config = agent({
398
+ name: 'Static',
399
+ toolDefinitions: [def('noop')],
400
+ toolHandlers: { noop: async () => 'ok' },
401
+ });
402
+ // A provider that hangs until its signal aborts, then rejects with the
403
+ // signal's reason — mirroring how the transport surfaces an aborted fetch.
404
+ const capture: { signal?: AbortSignal } = {};
405
+ const provider: AIProvider = {
406
+ chat: (_h, _m, options): Promise<ChatMessage> => {
407
+ const signal = options?.signal;
408
+ capture.signal = signal;
409
+ return new Promise<ChatMessage>((_resolve, reject) => {
410
+ if (!signal) return;
411
+ if (signal.aborted) {
412
+ reject(signal.reason);
413
+ return;
414
+ }
415
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
416
+ });
417
+ },
418
+ };
419
+ const driver = makeDriver(config, provider, 'cancel-test');
420
+
421
+ const pending = driver.sendMessage('go');
422
+ driver.dispose();
423
+ const result = await pending;
424
+
425
+ assert.is(result.reason, 'done');
426
+ assert.ok(capture.signal?.aborted, 'the provider signal aborted on dispose');
427
+ assert.is(capture.signal!.reason.name, 'AbortError');
428
+
429
+ // Quiet: no assistant message appended (would otherwise pollute cached history).
430
+ const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
431
+ assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
432
+ assert.not.ok(
433
+ driver.getHistory().some((m) => m.content?.includes('something went wrong')),
434
+ 'must not surface the generic failure',
435
+ );
436
+ });
437
+
438
+ cancel(
439
+ 'cancel() stops the turn with a "Stopped." marker and leaves the driver usable',
440
+ async () => {
441
+ clearMetaEventRegistry();
442
+ const config = agent({
443
+ name: 'Static',
444
+ toolDefinitions: [def('noop')],
445
+ toolHandlers: { noop: async () => 'ok' },
446
+ });
447
+ let call = 0;
448
+ const provider: AIProvider = {
449
+ chat: (_h, _m, options): Promise<ChatMessage> => {
450
+ call += 1;
451
+ if (call === 1) {
452
+ // First turn hangs until its signal aborts (the user cancel). Must also
453
+ // handle the already-aborted case: cancel can land before chat runs
454
+ // (during the provider-resolution await), so the signal may arrive
455
+ // aborted — mirror how real fetch rejects immediately in that case.
456
+ const signal = options!.signal!;
457
+ return new Promise<ChatMessage>((_resolve, reject) => {
458
+ if (signal.aborted) {
459
+ reject(signal.reason);
460
+ return;
461
+ }
462
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
463
+ });
464
+ }
465
+ // Second turn resolves normally — proves the driver wasn't bricked.
466
+ return Promise.resolve({ role: 'assistant', content: 'second response' });
467
+ },
468
+ };
469
+ const driver = makeDriver(config, provider, 'cancel-stop');
470
+
471
+ const pending = driver.sendMessage('go');
472
+ driver.cancel();
473
+ const result = await pending;
474
+
475
+ assert.is(result.reason, 'done');
476
+ // Subtle 'Stopped.' marker (system-event, not a generic failure).
477
+ const last = driver.getHistory().at(-1);
478
+ assert.is(last?.role, 'system-event');
479
+ assert.is(last?.content, 'Stopped.');
480
+
481
+ // The driver is still usable for the next message.
482
+ const second = await driver.sendMessage('again');
483
+ assert.is(second.reason, 'done');
484
+ assert.ok(
485
+ driver.getHistory().some((m) => m.content === 'second response'),
486
+ 'a fresh turn runs after a cancel',
487
+ );
488
+ },
489
+ );
490
+
491
+ cancel('a tool already running finishes, then the loop stops at the boundary', async () => {
492
+ let toolFinished = false;
493
+ let secondChatCalled = false;
494
+ const config = agent({
495
+ name: 'Static',
496
+ toolDefinitions: [def('slow')],
497
+ toolHandlers: {
498
+ slow: async () => {
499
+ // Cancel mid-tool: the tool must still run to completion.
500
+ driver.cancel();
501
+ await Promise.resolve();
502
+ toolFinished = true;
503
+ return 'tool done';
504
+ },
505
+ },
506
+ });
507
+ let call = 0;
508
+ const provider: AIProvider = {
509
+ chat: async (): Promise<ChatMessage> => {
510
+ call += 1;
511
+ if (call === 1) {
512
+ return {
513
+ role: 'assistant',
514
+ content: '',
515
+ toolCalls: [{ id: 't1', name: 'slow', args: {} }],
516
+ };
517
+ }
518
+ secondChatCalled = true;
519
+ return { role: 'assistant', content: 'should not be reached' };
520
+ },
521
+ };
522
+ const driver = makeDriver(config, provider, 'cancel-tool');
523
+
524
+ const result = await driver.sendMessage('go');
525
+
526
+ assert.is(result.reason, 'done');
527
+ assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
528
+ assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
529
+ const last = driver.getHistory().at(-1);
530
+ assert.is(last?.role, 'system-event');
531
+ assert.is(last?.content, 'Stopped.');
532
+ });
533
+
534
+ cancel('cancel() is a no-op when no turn is running', () => {
535
+ const config = agent({
536
+ name: 'Static',
537
+ toolDefinitions: [def('noop')],
538
+ toolHandlers: { noop: async () => 'ok' },
539
+ });
540
+ const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');
541
+
542
+ driver.cancel(); // must not throw or append anything while idle
543
+
544
+ assert.is(driver.getHistory().length, 0);
545
+ });
546
+
547
+ cancel.run();
548
+
322
549
  // ---------------------------------------------------------------------------
323
550
  // sub-agents — forced tool use + typed completion/failure union (GENC-1312)
324
551
  //
@@ -273,6 +273,28 @@ export class ChatDriver extends EventTarget implements AiDriver {
273
273
  private debugSnapshotter?: () => unknown;
274
274
  private readonly maxTurnSnapshots: number;
275
275
 
276
+ /**
277
+ * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
278
+ * Threaded into every provider call as `ChatRequestOptions.signal`, so a
279
+ * disposed driver's in-flight LLM request is cancelled instead of running on
280
+ * to completion or the transport timeout. Also passed to prompt/tool
281
+ * factories via `SystemPromptContext.signal`.
282
+ */
283
+ private readonly lifecycleController = new AbortController();
284
+
285
+ /**
286
+ * Per-turn abort controller, reset at the start of every turn by `beginTurn`.
287
+ * Aborted by `cancel()` (user stop) and chained to `lifecycleController` so a
288
+ * driver dispose also ends the current turn. Its signal — not the lifecycle
289
+ * one — is what reaches the provider call, so a turn can be cancelled without
290
+ * bricking the driver for the next message.
291
+ */
292
+ private turnController = new AbortController();
293
+ /** True when the current turn was stopped via `cancel()` (vs a dispose). Drives the "Stopped." marker. */
294
+ private turnCancelled = false;
295
+ /** Detaches the lifecycle→turn abort link at turn end; set by `beginTurn`. */
296
+ private unlinkLifecycleFromTurn?: () => void;
297
+
276
298
  /**
277
299
  * Active agent's provider selector (static name or per-turn resolver).
278
300
  * `undefined` means "use the registry default".
@@ -322,6 +344,67 @@ export class ChatDriver extends EventTarget implements AiDriver {
322
344
  this.maxTurnSnapshots = maxTurnSnapshots;
323
345
  }
324
346
 
347
+ /**
348
+ * Tear down the driver: aborts the lifecycle signal so any in-flight provider
349
+ * request (and prompt/tool factories awaiting it) cancels instead of running
350
+ * on to completion or the transport timeout. Called by the host on driver
351
+ * swap and by `OrchestratingDriver.dispose()`. Idempotent.
352
+ */
353
+ dispose(): void {
354
+ this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
355
+ }
356
+
357
+ /**
358
+ * Stop the current turn (user "stop" button). Aborts the in-flight provider
359
+ * request immediately; if a tool is mid-execution it runs to completion and
360
+ * the loop bails at the next boundary (tools are atomic). No-op when idle.
361
+ * The driver stays usable for the next message.
362
+ */
363
+ cancel(): void {
364
+ if (!this.busy) return;
365
+ this.turnCancelled = true;
366
+ this.turnController.abort(new DOMException('Cancelled by user', 'AbortError'));
367
+ }
368
+
369
+ /**
370
+ * Start a fresh per-turn abort scope. Chains `lifecycleController` into the
371
+ * new `turnController` so a dispose mid-turn also aborts the request.
372
+ */
373
+ private beginTurn(): void {
374
+ this.turnCancelled = false;
375
+ this.turnController = new AbortController();
376
+ const lifecycle = this.lifecycleController.signal;
377
+ if (lifecycle.aborted) {
378
+ this.turnController.abort(lifecycle.reason);
379
+ this.unlinkLifecycleFromTurn = undefined;
380
+ return;
381
+ }
382
+ const onDispose = () => this.turnController.abort(lifecycle.reason);
383
+ lifecycle.addEventListener('abort', onDispose, { once: true });
384
+ this.unlinkLifecycleFromTurn = () => lifecycle.removeEventListener('abort', onDispose);
385
+ }
386
+
387
+ /** Detach the lifecycle→turn link so a long-lived lifecycle signal doesn't accumulate listeners. */
388
+ private endTurn(): void {
389
+ this.unlinkLifecycleFromTurn?.();
390
+ this.unlinkLifecycleFromTurn = undefined;
391
+ }
392
+
393
+ /**
394
+ * Finish a turn whose signal aborted. A user cancel adds a subtle "Stopped."
395
+ * marker; a dispose-driven abort stops quietly (the widget is gone and the
396
+ * cached history would otherwise gain a stray marker on remount).
397
+ */
398
+ private completeAbortedTurn(): ChatDriverResult {
399
+ if (this.turnCancelled) {
400
+ logger.warn('ChatDriver: turn cancelled by user');
401
+ this.appendToHistory({ role: 'system-event', content: 'Stopped.' });
402
+ } else {
403
+ logger.warn('ChatDriver: turn aborted (driver disposed)');
404
+ }
405
+ return { reason: 'done' };
406
+ }
407
+
325
408
  /**
326
409
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
327
410
  * each specialist turn so the shared driver runs with the right tools and prompt.
@@ -822,6 +905,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
822
905
  if (this.busy || (!userInput.trim() && !attachments?.length)) return { reason: 'done' };
823
906
 
824
907
  this.busy = true;
908
+ this.beginTurn();
825
909
  this.subAgentCompletion = undefined;
826
910
  this.subAgentFailure = undefined;
827
911
  this.agentReleaseRequested = false;
@@ -856,6 +940,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
856
940
  durationMs: Date.now() - this.turnStartedAt,
857
941
  });
858
942
  this.busy = false;
943
+ this.endTurn();
859
944
  agenticActivityBus.publish('tool-loop-end', undefined);
860
945
  }
861
946
  }
@@ -963,6 +1048,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
963
1048
  // Mark before the first turn so the child forces tool use and reports a
964
1049
  // typed failure (rather than user-facing text) if it never completes.
965
1050
  child.markAsSubAgent();
1051
+ // Propagate disposal: if this (parent) driver is torn down while the
1052
+ // sub-agent is mid-flight, dispose the child too so its in-flight request
1053
+ // aborts. Detached in the `finally` below once the sub-agent completes.
1054
+ const disposeChild = () => child.dispose();
1055
+ if (this.lifecycleController.signal.aborted) {
1056
+ disposeChild();
1057
+ } else {
1058
+ this.lifecycleController.signal.addEventListener('abort', disposeChild, { once: true });
1059
+ }
966
1060
  child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
967
1061
  // Route interactions back through this driver so widgets render in the
968
1062
  // parent's (ultimately the root's) history and resolve via the same
@@ -1002,6 +1096,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1002
1096
  try {
1003
1097
  await child.sendMessage(task ?? '');
1004
1098
  } finally {
1099
+ this.lifecycleController.signal.removeEventListener('abort', disposeChild);
1005
1100
  child.removeEventListener('history-updated', forwardTrace);
1006
1101
  child.removeEventListener('provider-changed', forwardProviderChanged);
1007
1102
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
@@ -1042,6 +1137,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1042
1137
  if (this.busy) return { reason: 'done' };
1043
1138
 
1044
1139
  this.busy = true;
1140
+ this.beginTurn();
1045
1141
  this.subAgentCompletion = undefined;
1046
1142
  this.subAgentFailure = undefined;
1047
1143
  this.turnStartedAt = Date.now();
@@ -1073,6 +1169,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1073
1169
  durationMs: Date.now() - this.turnStartedAt,
1074
1170
  });
1075
1171
  this.busy = false;
1172
+ this.endTurn();
1076
1173
  agenticActivityBus.publish('tool-loop-end', undefined);
1077
1174
  }
1078
1175
  }
@@ -1278,11 +1375,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
1278
1375
  while (iterations < this.maxToolIterations) {
1279
1376
  iterations += 1;
1280
1377
 
1378
+ // A cancel (or dispose) that landed while the previous iteration's tool
1379
+ // batch was running takes effect here — before issuing another LLM call.
1380
+ // An abort during the call itself is handled by the catch below.
1381
+ if (this.turnController.signal.aborted) {
1382
+ return this.completeAbortedTurn();
1383
+ }
1384
+
1281
1385
  const promptCtx: SystemPromptContext = {
1282
1386
  agentName: this.activeAgentName ?? '',
1283
1387
  history: this.history,
1284
1388
  turnIndex: iterations - 1,
1285
- signal: new AbortController().signal,
1389
+ signal: this.turnController.signal,
1286
1390
  };
1287
1391
 
1288
1392
  // Re-resolve dynamic tool definitions before each LLM call. The static
@@ -1377,6 +1481,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1377
1481
  // Strip fold-only properties (foldEvent, foldPath) before sending to provider
1378
1482
  tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
1379
1483
  attachments: attachmentsForCall,
1484
+ // Per-turn signal: aborts on user cancel, and (via beginTurn's chain)
1485
+ // on driver dispose. Cancels the in-flight request either way.
1486
+ signal: this.turnController.signal,
1380
1487
  // Sub-agents must finish by calling a tool (their completion tool), never
1381
1488
  // by emitting a free-text turn — force tool use so the provider can't
1382
1489
  // return a bare text answer. Top-level agents stay on the default 'auto'.
@@ -1431,6 +1538,34 @@ export class ChatDriver extends EventTarget implements AiDriver {
1431
1538
  }
1432
1539
  return { reason: 'done' };
1433
1540
  }
1541
+ // A request timeout from the transport (tagged `TimeoutError`) is not a
1542
+ // bug on our end — surface it distinctly instead of letting it fall
1543
+ // through to the generic "something went wrong" catch. No auto-retry:
1544
+ // the timeout ceiling is already minutes long, so a silent retry would
1545
+ // just make the user wait again.
1546
+ if (e instanceof DOMException && e.name === 'TimeoutError') {
1547
+ logger.error('ChatDriver: request timed out', e);
1548
+ recordTurnError(this.sessionKey, 'exception', {
1549
+ agent: this.activeAgentName,
1550
+ provider: this.lastResolvedProviderName,
1551
+ name: e.name,
1552
+ message: e.message,
1553
+ });
1554
+ this.appendToHistory({
1555
+ role: 'assistant',
1556
+ content:
1557
+ 'The request timed out. You can ask me to try again, or break this into a smaller step.',
1558
+ });
1559
+ return { reason: 'done' };
1560
+ }
1561
+ // The request was aborted: either a user cancel (turnController) or a
1562
+ // driver dispose (lifecycleController, chained into the turn). A user
1563
+ // cancel adds a "Stopped." marker; a dispose stops quietly. Handled
1564
+ // before the transient-retry below so an intentional abort/timeout is
1565
+ // never retried.
1566
+ if (e instanceof DOMException && e.name === 'AbortError') {
1567
+ return this.completeAbortedTurn();
1568
+ }
1434
1569
  // A transient provider/transport error should retry the SAME iteration a bounded
1435
1570
  // number of times rather than tearing down the whole turn (which strands the
1436
1571
  // agent's unflushed buffer behind an opaque error message).