@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
@@ -1 +1 @@
1
- {"version":3,"file":"main.template.d.ts","sourceRoot":"","sources":["../../../src/main/main.template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AASH,OAAO,EAA2B,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,QAAQ,CAAC;AAwIpD,gBAAgB;AAChB,eAAO,MAAM,6BAA6B,GACxC,oBAAoB,MAAM,KACzB,YAAY,CAAC,qBAAqB,CA8hBpC,CAAC"}
1
+ {"version":3,"file":"main.template.d.ts","sourceRoot":"","sources":["../../../src/main/main.template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AASH,OAAO,EAAuC,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,QAAQ,CAAC;AAwIpD,gBAAgB;AAChB,eAAO,MAAM,6BAA6B,GACxC,oBAAoB,MAAM,KACzB,YAAY,CAAC,qBAAqB,CAijBpC,CAAC"}
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * State of the AI assistant component.
3
3
  *
4
+ * `cancelling` is a refinement of `loading`: a stop was requested but the turn
5
+ * is still winding down (e.g. a tool mid-execution finishing). It is "busy"
6
+ * like `loading` — use the host's `busy` getter for "is a turn active" gates.
7
+ *
4
8
  * @beta
5
9
  */
6
- export type AiAssistantState = 'idle' | 'loading' | 'error';
10
+ export type AiAssistantState = 'idle' | 'loading' | 'cancelling' | 'error';
7
11
  /**
8
12
  * Controls the pop-out button shown in the assistant header.
9
13
  * - `"expand"` — assistant is embedded in the bubble dialog; button expands it to a layout panel.
@@ -1 +1 @@
1
- {"version":3,"file":"main.types.d.ts","sourceRoot":"","sources":["../../../src/main/main.types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE/C,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAElE;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAE,GAC3C;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;;;;CASwB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,cAAc,CAAC;AAE/D;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAkC,oBAAoB,EAAE,CAAC"}
1
+ {"version":3,"file":"main.types.d.ts","sourceRoot":"","sources":["../../../src/main/main.types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC;AAE3E;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE/C,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAElE;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAE,GAC3C;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;;;;CASwB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,cAAc,CAAC;AAE/D;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAkC,oBAAoB,EAAE,CAAC"}
@@ -114,6 +114,24 @@ export class ChatDriver extends EventTarget {
114
114
  this.turnSnapshots = [];
115
115
  /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
116
116
  this.globalTurnIndex = 0;
117
+ /**
118
+ * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
119
+ * Threaded into every provider call as `ChatRequestOptions.signal`, so a
120
+ * disposed driver's in-flight LLM request is cancelled instead of running on
121
+ * to completion or the transport timeout. Also passed to prompt/tool
122
+ * factories via `SystemPromptContext.signal`.
123
+ */
124
+ this.lifecycleController = new AbortController();
125
+ /**
126
+ * Per-turn abort controller, reset at the start of every turn by `beginTurn`.
127
+ * Aborted by `cancel()` (user stop) and chained to `lifecycleController` so a
128
+ * driver dispose also ends the current turn. Its signal — not the lifecycle
129
+ * one — is what reaches the provider call, so a turn can be cancelled without
130
+ * bricking the driver for the next message.
131
+ */
132
+ this.turnController = new AbortController();
133
+ /** True when the current turn was stopped via `cancel()` (vs a dispose). Drives the "Stopped." marker. */
134
+ this.turnCancelled = false;
117
135
  /**
118
136
  * Caches validated provider lookups per name within the current agent. Cleared
119
137
  * by `applyAgent` so each new agent's static/function-resolved names are
@@ -141,6 +159,65 @@ export class ChatDriver extends EventTarget {
141
159
  this.maxFoldOperations = maxFoldOperations;
142
160
  this.maxTurnSnapshots = maxTurnSnapshots;
143
161
  }
162
+ /**
163
+ * Tear down the driver: aborts the lifecycle signal so any in-flight provider
164
+ * request (and prompt/tool factories awaiting it) cancels instead of running
165
+ * on to completion or the transport timeout. Called by the host on driver
166
+ * swap and by `OrchestratingDriver.dispose()`. Idempotent.
167
+ */
168
+ dispose() {
169
+ this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
170
+ }
171
+ /**
172
+ * Stop the current turn (user "stop" button). Aborts the in-flight provider
173
+ * request immediately; if a tool is mid-execution it runs to completion and
174
+ * the loop bails at the next boundary (tools are atomic). No-op when idle.
175
+ * The driver stays usable for the next message.
176
+ */
177
+ cancel() {
178
+ if (!this.busy)
179
+ return;
180
+ this.turnCancelled = true;
181
+ this.turnController.abort(new DOMException('Cancelled by user', 'AbortError'));
182
+ }
183
+ /**
184
+ * Start a fresh per-turn abort scope. Chains `lifecycleController` into the
185
+ * new `turnController` so a dispose mid-turn also aborts the request.
186
+ */
187
+ beginTurn() {
188
+ this.turnCancelled = false;
189
+ this.turnController = new AbortController();
190
+ const lifecycle = this.lifecycleController.signal;
191
+ if (lifecycle.aborted) {
192
+ this.turnController.abort(lifecycle.reason);
193
+ this.unlinkLifecycleFromTurn = undefined;
194
+ return;
195
+ }
196
+ const onDispose = () => this.turnController.abort(lifecycle.reason);
197
+ lifecycle.addEventListener('abort', onDispose, { once: true });
198
+ this.unlinkLifecycleFromTurn = () => lifecycle.removeEventListener('abort', onDispose);
199
+ }
200
+ /** Detach the lifecycle→turn link so a long-lived lifecycle signal doesn't accumulate listeners. */
201
+ endTurn() {
202
+ var _a;
203
+ (_a = this.unlinkLifecycleFromTurn) === null || _a === void 0 ? void 0 : _a.call(this);
204
+ this.unlinkLifecycleFromTurn = undefined;
205
+ }
206
+ /**
207
+ * Finish a turn whose signal aborted. A user cancel adds a subtle "Stopped."
208
+ * marker; a dispose-driven abort stops quietly (the widget is gone and the
209
+ * cached history would otherwise gain a stray marker on remount).
210
+ */
211
+ completeAbortedTurn() {
212
+ if (this.turnCancelled) {
213
+ logger.warn('ChatDriver: turn cancelled by user');
214
+ this.appendToHistory({ role: 'system-event', content: 'Stopped.' });
215
+ }
216
+ else {
217
+ logger.warn('ChatDriver: turn aborted (driver disposed)');
218
+ }
219
+ return { reason: 'done' };
220
+ }
144
221
  /**
145
222
  * Swap in a new agent's configuration. Called by OrchestratingDriver before
146
223
  * each specialist turn so the shared driver runs with the right tools and prompt.
@@ -602,6 +679,7 @@ export class ChatDriver extends EventTarget {
602
679
  if (this.busy || (!userInput.trim() && !(attachments === null || attachments === void 0 ? void 0 : attachments.length)))
603
680
  return { reason: 'done' };
604
681
  this.busy = true;
682
+ this.beginTurn();
605
683
  this.subAgentCompletion = undefined;
606
684
  this.subAgentFailure = undefined;
607
685
  this.agentReleaseRequested = false;
@@ -637,6 +715,7 @@ export class ChatDriver extends EventTarget {
637
715
  durationMs: Date.now() - this.turnStartedAt,
638
716
  });
639
717
  this.busy = false;
718
+ this.endTurn();
640
719
  agenticActivityBus.publish('tool-loop-end', undefined);
641
720
  }
642
721
  });
@@ -708,6 +787,16 @@ export class ChatDriver extends EventTarget {
708
787
  // Mark before the first turn so the child forces tool use and reports a
709
788
  // typed failure (rather than user-facing text) if it never completes.
710
789
  child.markAsSubAgent();
790
+ // Propagate disposal: if this (parent) driver is torn down while the
791
+ // sub-agent is mid-flight, dispose the child too so its in-flight request
792
+ // aborts. Detached in the `finally` below once the sub-agent completes.
793
+ const disposeChild = () => child.dispose();
794
+ if (this.lifecycleController.signal.aborted) {
795
+ disposeChild();
796
+ }
797
+ else {
798
+ this.lifecycleController.signal.addEventListener('abort', disposeChild, { once: true });
799
+ }
711
800
  child.applyAgent(Object.assign(Object.assign({}, subConfig), { primerHistory: effectivePrimer }));
712
801
  // Route interactions back through this driver so widgets render in the
713
802
  // parent's (ultimately the root's) history and resolve via the same
@@ -738,6 +827,7 @@ export class ChatDriver extends EventTarget {
738
827
  yield child.sendMessage(task !== null && task !== void 0 ? task : '');
739
828
  }
740
829
  finally {
830
+ this.lifecycleController.signal.removeEventListener('abort', disposeChild);
741
831
  child.removeEventListener('history-updated', forwardTrace);
742
832
  child.removeEventListener('provider-changed', forwardProviderChanged);
743
833
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
@@ -776,6 +866,7 @@ export class ChatDriver extends EventTarget {
776
866
  if (this.busy)
777
867
  return { reason: 'done' };
778
868
  this.busy = true;
869
+ this.beginTurn();
779
870
  this.subAgentCompletion = undefined;
780
871
  this.subAgentFailure = undefined;
781
872
  this.turnStartedAt = Date.now();
@@ -809,6 +900,7 @@ export class ChatDriver extends EventTarget {
809
900
  durationMs: Date.now() - this.turnStartedAt,
810
901
  });
811
902
  this.busy = false;
903
+ this.endTurn();
812
904
  agenticActivityBus.publish('tool-loop-end', undefined);
813
905
  }
814
906
  });
@@ -983,11 +1075,17 @@ export class ChatDriver extends EventTarget {
983
1075
  let firstLlmCall = !!currentInput;
984
1076
  while (iterations < this.maxToolIterations) {
985
1077
  iterations += 1;
1078
+ // A cancel (or dispose) that landed while the previous iteration's tool
1079
+ // batch was running takes effect here — before issuing another LLM call.
1080
+ // An abort during the call itself is handled by the catch below.
1081
+ if (this.turnController.signal.aborted) {
1082
+ return this.completeAbortedTurn();
1083
+ }
986
1084
  const promptCtx = {
987
1085
  agentName: (_a = this.activeAgentName) !== null && _a !== void 0 ? _a : '',
988
1086
  history: this.history,
989
1087
  turnIndex: iterations - 1,
990
- signal: new AbortController().signal,
1088
+ signal: this.turnController.signal,
991
1089
  };
992
1090
  // Re-resolve dynamic tool definitions before each LLM call. The static
993
1091
  // case is a no-op (factory is undefined and `this.toolDefinitions` was
@@ -1070,6 +1168,9 @@ export class ChatDriver extends EventTarget {
1070
1168
  // Strip fold-only properties (foldEvent, foldPath) before sending to provider
1071
1169
  tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
1072
1170
  attachments: attachmentsForCall,
1171
+ // Per-turn signal: aborts on user cancel, and (via beginTurn's chain)
1172
+ // on driver dispose. Cancels the in-flight request either way.
1173
+ signal: this.turnController.signal,
1073
1174
  // Sub-agents must finish by calling a tool (their completion tool), never
1074
1175
  // by emitting a free-text turn — force tool use so the provider can't
1075
1176
  // return a bare text answer. Top-level agents stay on the default 'auto'.
@@ -1121,6 +1222,33 @@ export class ChatDriver extends EventTarget {
1121
1222
  }
1122
1223
  return { reason: 'done' };
1123
1224
  }
1225
+ // A request timeout from the transport (tagged `TimeoutError`) is not a
1226
+ // bug on our end — surface it distinctly instead of letting it fall
1227
+ // through to the generic "something went wrong" catch. No auto-retry:
1228
+ // the timeout ceiling is already minutes long, so a silent retry would
1229
+ // just make the user wait again.
1230
+ if (e instanceof DOMException && e.name === 'TimeoutError') {
1231
+ logger.error('ChatDriver: request timed out', e);
1232
+ recordTurnError(this.sessionKey, 'exception', {
1233
+ agent: this.activeAgentName,
1234
+ provider: this.lastResolvedProviderName,
1235
+ name: e.name,
1236
+ message: e.message,
1237
+ });
1238
+ this.appendToHistory({
1239
+ role: 'assistant',
1240
+ content: 'The request timed out. You can ask me to try again, or break this into a smaller step.',
1241
+ });
1242
+ return { reason: 'done' };
1243
+ }
1244
+ // The request was aborted: either a user cancel (turnController) or a
1245
+ // driver dispose (lifecycleController, chained into the turn). A user
1246
+ // cancel adds a "Stopped." marker; a dispose stops quietly. Handled
1247
+ // before the transient-retry below so an intentional abort/timeout is
1248
+ // never retried.
1249
+ if (e instanceof DOMException && e.name === 'AbortError') {
1250
+ return this.completeAbortedTurn();
1251
+ }
1124
1252
  // A transient provider/transport error should retry the SAME iteration a bounded
1125
1253
  // number of times rather than tearing down the whole turn (which strands the
1126
1254
  // agent's unflushed buffer behind an opaque error message).
@@ -198,6 +198,196 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', () =
198
198
  }));
199
199
  stale.run();
200
200
  // ---------------------------------------------------------------------------
201
+ // timeout handling — a transport `TimeoutError` surfaces a distinct message
202
+ // ---------------------------------------------------------------------------
203
+ const timeout = createLogicSuite('ChatDriver timeout handling');
204
+ /** A provider whose `chat` rejects with the `TimeoutError` the transports tag
205
+ * a request timeout with (see gemini/anthropic `post`). */
206
+ const timesOutProvider = () => ({
207
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
208
+ throw new DOMException('Gemini request timed out', 'TimeoutError');
209
+ }),
210
+ });
211
+ timeout('surfaces a timeout-specific message instead of the generic failure', () => __awaiter(void 0, void 0, void 0, function* () {
212
+ var _a, _b;
213
+ clearMetaEventRegistry();
214
+ const config = agent({
215
+ name: 'Static',
216
+ toolDefinitions: [def('noop')],
217
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
218
+ });
219
+ const sessionKey = 'timeout-test';
220
+ const driver = makeDriver(config, timesOutProvider(), sessionKey);
221
+ const result = yield driver.sendMessage('go');
222
+ assert.is(result.reason, 'done');
223
+ // The turn ends with the timeout message, NOT the generic "something went wrong".
224
+ const last = driver.getHistory().at(-1);
225
+ assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
226
+ assert.ok(last.content.includes('timed out'), 'message names the timeout');
227
+ assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic catch');
228
+ // Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
229
+ // not swallowed silently.
230
+ const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
231
+ assert.ok(err, 'a turn.error should be recorded');
232
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.reason, 'exception');
233
+ assert.is((_b = err.detail) === null || _b === void 0 ? void 0 : _b.name, 'TimeoutError');
234
+ }));
235
+ timeout.run();
236
+ // ---------------------------------------------------------------------------
237
+ // cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
238
+ // ---------------------------------------------------------------------------
239
+ const cancel = createLogicSuite('ChatDriver cancellation');
240
+ cancel('threads a non-aborted signal into the provider call by default', () => __awaiter(void 0, void 0, void 0, function* () {
241
+ const config = agent({
242
+ name: 'Static',
243
+ toolDefinitions: [def('noop')],
244
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
245
+ });
246
+ const capture = {};
247
+ const provider = {
248
+ chat: (_h, _m, options) => __awaiter(void 0, void 0, void 0, function* () {
249
+ capture.signal = options === null || options === void 0 ? void 0 : options.signal;
250
+ return { role: 'assistant', content: 'hi' };
251
+ }),
252
+ };
253
+ const driver = makeDriver(config, provider, 'signal-plumb');
254
+ yield driver.sendMessage('go');
255
+ assert.ok(capture.signal, 'provider received an options.signal');
256
+ assert.not.ok(capture.signal.aborted, 'and it is not aborted on a normal turn');
257
+ }));
258
+ cancel('dispose() aborts the in-flight request and stops quietly', () => __awaiter(void 0, void 0, void 0, function* () {
259
+ var _a;
260
+ clearMetaEventRegistry();
261
+ const config = agent({
262
+ name: 'Static',
263
+ toolDefinitions: [def('noop')],
264
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
265
+ });
266
+ // A provider that hangs until its signal aborts, then rejects with the
267
+ // signal's reason — mirroring how the transport surfaces an aborted fetch.
268
+ const capture = {};
269
+ const provider = {
270
+ chat: (_h, _m, options) => {
271
+ const signal = options === null || options === void 0 ? void 0 : options.signal;
272
+ capture.signal = signal;
273
+ return new Promise((_resolve, reject) => {
274
+ if (!signal)
275
+ return;
276
+ if (signal.aborted) {
277
+ reject(signal.reason);
278
+ return;
279
+ }
280
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
281
+ });
282
+ },
283
+ };
284
+ const driver = makeDriver(config, provider, 'cancel-test');
285
+ const pending = driver.sendMessage('go');
286
+ driver.dispose();
287
+ const result = yield pending;
288
+ assert.is(result.reason, 'done');
289
+ assert.ok((_a = capture.signal) === null || _a === void 0 ? void 0 : _a.aborted, 'the provider signal aborted on dispose');
290
+ assert.is(capture.signal.reason.name, 'AbortError');
291
+ // Quiet: no assistant message appended (would otherwise pollute cached history).
292
+ const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
293
+ assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
294
+ assert.not.ok(driver.getHistory().some((m) => { var _a; return (_a = m.content) === null || _a === void 0 ? void 0 : _a.includes('something went wrong'); }), 'must not surface the generic failure');
295
+ }));
296
+ cancel('cancel() stops the turn with a "Stopped." marker and leaves the driver usable', () => __awaiter(void 0, void 0, void 0, function* () {
297
+ clearMetaEventRegistry();
298
+ const config = agent({
299
+ name: 'Static',
300
+ toolDefinitions: [def('noop')],
301
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
302
+ });
303
+ let call = 0;
304
+ const provider = {
305
+ chat: (_h, _m, options) => {
306
+ call += 1;
307
+ if (call === 1) {
308
+ // First turn hangs until its signal aborts (the user cancel). Must also
309
+ // handle the already-aborted case: cancel can land before chat runs
310
+ // (during the provider-resolution await), so the signal may arrive
311
+ // aborted — mirror how real fetch rejects immediately in that case.
312
+ const signal = options.signal;
313
+ return new Promise((_resolve, reject) => {
314
+ if (signal.aborted) {
315
+ reject(signal.reason);
316
+ return;
317
+ }
318
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
319
+ });
320
+ }
321
+ // Second turn resolves normally — proves the driver wasn't bricked.
322
+ return Promise.resolve({ role: 'assistant', content: 'second response' });
323
+ },
324
+ };
325
+ const driver = makeDriver(config, provider, 'cancel-stop');
326
+ const pending = driver.sendMessage('go');
327
+ driver.cancel();
328
+ const result = yield pending;
329
+ assert.is(result.reason, 'done');
330
+ // Subtle 'Stopped.' marker (system-event, not a generic failure).
331
+ const last = driver.getHistory().at(-1);
332
+ assert.is(last === null || last === void 0 ? void 0 : last.role, 'system-event');
333
+ assert.is(last === null || last === void 0 ? void 0 : last.content, 'Stopped.');
334
+ // The driver is still usable for the next message.
335
+ const second = yield driver.sendMessage('again');
336
+ assert.is(second.reason, 'done');
337
+ assert.ok(driver.getHistory().some((m) => m.content === 'second response'), 'a fresh turn runs after a cancel');
338
+ }));
339
+ cancel('a tool already running finishes, then the loop stops at the boundary', () => __awaiter(void 0, void 0, void 0, function* () {
340
+ let toolFinished = false;
341
+ let secondChatCalled = false;
342
+ const config = agent({
343
+ name: 'Static',
344
+ toolDefinitions: [def('slow')],
345
+ toolHandlers: {
346
+ slow: () => __awaiter(void 0, void 0, void 0, function* () {
347
+ // Cancel mid-tool: the tool must still run to completion.
348
+ driver.cancel();
349
+ yield Promise.resolve();
350
+ toolFinished = true;
351
+ return 'tool done';
352
+ }),
353
+ },
354
+ });
355
+ let call = 0;
356
+ const provider = {
357
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
358
+ call += 1;
359
+ if (call === 1) {
360
+ return {
361
+ role: 'assistant',
362
+ content: '',
363
+ toolCalls: [{ id: 't1', name: 'slow', args: {} }],
364
+ };
365
+ }
366
+ secondChatCalled = true;
367
+ return { role: 'assistant', content: 'should not be reached' };
368
+ }),
369
+ };
370
+ const driver = makeDriver(config, provider, 'cancel-tool');
371
+ const result = yield driver.sendMessage('go');
372
+ assert.is(result.reason, 'done');
373
+ assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
374
+ assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
375
+ const last = driver.getHistory().at(-1);
376
+ assert.is(last === null || last === void 0 ? void 0 : last.role, 'system-event');
377
+ assert.is(last === null || last === void 0 ? void 0 : last.content, 'Stopped.');
378
+ }));
379
+ cancel('cancel() is a no-op when no turn is running', () => {
380
+ const config = agent({
381
+ name: 'Static',
382
+ toolDefinitions: [def('noop')],
383
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
384
+ });
385
+ const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');
386
+ driver.cancel(); // must not throw or append anything while idle
387
+ assert.is(driver.getHistory().length, 0);
388
+ });
389
+ cancel.run();
390
+ // ---------------------------------------------------------------------------
201
391
  // sub-agents — forced tool use + typed completion/failure union (GENC-1312)
202
392
  //
203
393
  // A child sub-agent driver shares the parent's provider registry, so one
@@ -62,6 +62,12 @@ export class OrchestratingDriver extends EventTarget {
62
62
  * so long-running `onActivate` work can bail if the session disconnects.
63
63
  */
64
64
  this.lifecycleAbortController = new AbortController();
65
+ /**
66
+ * Set by `cancel()` for the duration of a `sendMessage` run. Breaks the
67
+ * handoff loop so we don't classify or start another agent turn after the
68
+ * user stops. Reset at the start of each `sendMessage`.
69
+ */
70
+ this.cancelled = false;
65
71
  /**
66
72
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
67
73
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -177,6 +183,7 @@ export class OrchestratingDriver extends EventTarget {
177
183
  }
178
184
  sendMessage(input, attachments) {
179
185
  return __awaiter(this, void 0, void 0, function* () {
186
+ this.cancelled = false;
180
187
  const history = this.chatDriver.getHistory();
181
188
  // Emit the user message immediately so the UI reflects it during the classify
182
189
  // round-trip — without this the chat appears frozen until classify returns.
@@ -192,6 +199,11 @@ export class OrchestratingDriver extends EventTarget {
192
199
  let handoffSummary = '';
193
200
  let remainingTask = '';
194
201
  while (true) {
202
+ // Cancelled before a (next) agent turn started — e.g. during classify.
203
+ // The chat driver appends its own "Stopped." marker when a turn it was
204
+ // running is cancelled, so we just stop here without a duplicate.
205
+ if (this.cancelled)
206
+ break;
195
207
  // oxlint-disable-next-line no-await-in-loop
196
208
  yield this.applyAgent(currentAgent);
197
209
  let result;
@@ -370,6 +382,15 @@ export class OrchestratingDriver extends EventTarget {
370
382
  this.dispatchEvent(new CustomEvent('agent-changed', { detail: undefined }));
371
383
  });
372
384
  }
385
+ /**
386
+ * Stop the current turn (user "stop" button). Cancels the inner chat driver's
387
+ * in-flight request and breaks the handoff loop so no further agent turn or
388
+ * classify runs. The driver stays usable for the next message.
389
+ */
390
+ cancel() {
391
+ this.cancelled = true;
392
+ this.chatDriver.cancel();
393
+ }
373
394
  /**
374
395
  * Fire `onDeactivate` on the current active agent and abort any pending
375
396
  * lifecycle work. Called by the host on session teardown so machines can
@@ -391,6 +412,8 @@ export class OrchestratingDriver extends EventTarget {
391
412
  }
392
413
  }
393
414
  this.lifecycleAbortController.abort();
415
+ // Tear down the inner driver too, so any in-flight provider request aborts.
416
+ this.chatDriver.dispose();
394
417
  this.activeAgent = undefined;
395
418
  });
396
419
  }
@@ -200,10 +200,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
200
200
  this.logMeta('state.changed', { from: prev, to: value });
201
201
  }
202
202
  this.syncShowHalo();
203
- // When the agent finishes (loading!loading) the input row reappears (or
204
- // becomes enabled) — refocus it so the user can type immediately, but only
205
- // if they haven't moved on to something else on the page.
206
- if (prev === 'loading' && value !== 'loading') {
203
+ // When the turn finishes (a busy state a settled one) the input row
204
+ // reappears (or becomes enabled) — refocus it so the user can type
205
+ // immediately, but only if they haven't moved on to something else.
206
+ // `cancelling` is busy too, so loading cancelling must NOT settle yet.
207
+ if (this.isBusyState(prev) && !this.isBusyState(value)) {
207
208
  this.maybeAutoFocusChatInput();
208
209
  // Apply any agents swap deferred by the busy guard in `agentsChanged`.
209
210
  // Host-side `agents` getters typically cache a stable reference between
@@ -212,6 +213,20 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
212
213
  this.applyDeferredAgentsSwap();
213
214
  }
214
215
  }
216
+ /** Whether a state value counts as "busy" — a turn is active. */
217
+ isBusyState(s) {
218
+ return s === 'loading' || s === 'cancelling';
219
+ }
220
+ /**
221
+ * True while a turn is active — `loading` or `cancelling` (a stop was
222
+ * requested but the turn is still winding down). The single "is a turn
223
+ * running" predicate; UI gates use this so `cancelling` behaves like
224
+ * `loading` everywhere. `@volatile` so the template re-evaluates it when the
225
+ * store-backed `state` changes.
226
+ */
227
+ get busy() {
228
+ return this.isBusyState(this.state);
229
+ }
215
230
  /**
216
231
  * Re-runs `agentsChanged` if the live `agents` array no longer matches the
217
232
  * fingerprint of the currently installed driver. Used to apply swaps that
@@ -463,7 +478,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
463
478
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setInputValue(value);
464
479
  }
465
480
  syncShowHalo() {
466
- if (this.state !== 'loading') {
481
+ if (!this.busy) {
467
482
  this.showHalo = 'no';
468
483
  return;
469
484
  }
@@ -477,7 +492,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
477
492
  }
478
493
  /** True when there is a pending (unresolved) interaction — disables the popout button. */
479
494
  get hasActivePendingInteraction() {
480
- if (this.state !== 'loading')
495
+ if (!this.busy)
481
496
  return false;
482
497
  const last = this.messages[this.messages.length - 1];
483
498
  return !!(last === null || last === void 0 ? void 0 : last.interaction) && !last.interaction.resolved;
@@ -909,7 +924,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
909
924
  document.addEventListener('focusin', this._handleGlobalInteraction, true);
910
925
  // Initial focus on mount when idle (skips during a mid-lifecycle reattach
911
926
  // where state is loading and the input may be hidden or disabled).
912
- if (this.state !== 'loading')
927
+ if (!this.busy)
913
928
  this.maybeAutoFocusChatInput();
914
929
  logger.debug('FoundationAiAssistant connected');
915
930
  this.logMeta('assistant.connected', {
@@ -1006,7 +1021,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1006
1021
  // individual step gets a fresh window before the spinner appears.
1007
1022
  // If the last message is a blocking interaction, stop the timer — the AI is
1008
1023
  // waiting for the user, not computing.
1009
- if (this.state === 'loading') {
1024
+ if (this.busy) {
1010
1025
  const last = this.messages[this.messages.length - 1];
1011
1026
  if (last === null || last === void 0 ? void 0 : last.interaction) {
1012
1027
  this.stopLoadingTimer();
@@ -1486,6 +1501,19 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1486
1501
  handleSendClick() {
1487
1502
  this.send();
1488
1503
  }
1504
+ /**
1505
+ * User clicked Stop. Cancels the current turn via the driver and flips the
1506
+ * button to "Stopping…" until the turn winds down (a tool mid-execution
1507
+ * finishes first). Guarded so a second click is a no-op.
1508
+ */
1509
+ handleStopClick() {
1510
+ var _a, _b;
1511
+ // Only valid mid-turn; once `cancelling`, a second click is a no-op.
1512
+ if (this.state !== 'loading')
1513
+ return;
1514
+ this.state = 'cancelling';
1515
+ (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.cancel) === null || _b === void 0 ? void 0 : _b.call(_a);
1516
+ }
1489
1517
  handleSuggestionClick(suggestion) {
1490
1518
  this.inputValue = suggestion;
1491
1519
  this.send();
@@ -1514,7 +1542,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1514
1542
  submitMessage(input) {
1515
1543
  return __awaiter(this, void 0, void 0, function* () {
1516
1544
  var _a, _b, _c;
1517
- if (this.state === 'loading') {
1545
+ if (this.busy) {
1518
1546
  return { ok: false, errors: ['Assistant is busy'] };
1519
1547
  }
1520
1548
  let nextAttachments = [];
@@ -1527,7 +1555,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1527
1555
  // own first await — so no further race window exists.
1528
1556
  // (Cast widens through TS's narrowing of `this.state` from the earlier
1529
1557
  // early-return; the getter can return a different value across an await.)
1530
- if (this.state === 'loading') {
1558
+ if (this.busy) {
1531
1559
  return { ok: false, errors: ['Assistant is busy'] };
1532
1560
  }
1533
1561
  if (errors.length) {
@@ -1614,7 +1642,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1614
1642
  return __awaiter(this, void 0, void 0, function* () {
1615
1643
  var _a;
1616
1644
  const input = this.inputValue.trim();
1617
- if ((!input && !this.attachments.length) || this.state === 'loading')
1645
+ if ((!input && !this.attachments.length) || this.busy)
1618
1646
  return;
1619
1647
  // Capture the session ref before any await. If a lifecycle event occurs during
1620
1648
  // execution, disconnectedCallback clears this._sessionRef — but the captured
@@ -1645,7 +1673,14 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1645
1673
  // Write directly via captured ref — this._sessionRef may be null if the element
1646
1674
  // disconnected mid-execution. The state setter also calls syncShowHalo(); replicate
1647
1675
  // that here for the case where this element is still connected.
1676
+ const prevState = capturedSessionRef === null || capturedSessionRef === void 0 ? void 0 : capturedSessionRef.store.aiAssistant.state;
1648
1677
  capturedSessionRef === null || capturedSessionRef === void 0 ? void 0 : capturedSessionRef.actions.aiAssistant.setState('idle');
1678
+ // The setter's `state.changed` log is bypassed on this direct write, so
1679
+ // emit it here — otherwise the debug timeline shows the turn entering
1680
+ // loading/cancelling but never returning to idle.
1681
+ if (prevState && prevState !== 'idle') {
1682
+ this.logMeta('state.changed', { from: prevState, to: 'idle' });
1683
+ }
1649
1684
  this.syncShowHalo();
1650
1685
  this.fetchSuggestions();
1651
1686
  // setState was dispatched directly via the captured ref (not via the
@@ -1715,6 +1750,9 @@ __decorate([
1715
1750
  __decorate([
1716
1751
  attr({ attribute: 'debug-redux', mode: 'boolean' })
1717
1752
  ], FoundationAiAssistant.prototype, "debugRedux", void 0);
1753
+ __decorate([
1754
+ volatile
1755
+ ], FoundationAiAssistant.prototype, "busy", null);
1718
1756
  __decorate([
1719
1757
  volatile
1720
1758
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);