@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.
- package/dist/ai-assistant.api.json +221 -2
- package/dist/ai-assistant.d.ts +93 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts +13 -0
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +47 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +12 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +16 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +5 -1
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +129 -1
- package/dist/esm/components/chat-driver/chat-driver.test.js +190 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -0
- package/dist/esm/main/main.js +49 -11
- package/dist/esm/main/main.styles.js +26 -0
- package/dist/esm/main/main.template.js +27 -11
- package/package.json +16 -16
- package/src/components/ai-driver/ai-driver.ts +15 -0
- package/src/components/chat-driver/chat-driver.test.ts +227 -0
- package/src/components/chat-driver/chat-driver.ts +136 -1
- package/src/components/orchestrating-driver/orchestrating-driver.ts +24 -0
- package/src/main/main.styles.ts +26 -0
- package/src/main/main.template.ts +30 -11
- package/src/main/main.ts +48 -11
- 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,
|
|
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
|
|
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:
|
|
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
|
}
|
package/dist/esm/main/main.js
CHANGED
|
@@ -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
|
|
204
|
-
// becomes enabled) — refocus it so the user can type
|
|
205
|
-
// if they haven't moved on to something else
|
|
206
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|