@artooi/ag-ui-web-component 0.41.2 → 0.42.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.
@@ -55,8 +55,11 @@ export interface ToolExecution {
55
55
  * Executes a frontend tool call.
56
56
  *
57
57
  * Returns the {@link ToolExecution} to post back to the agent, or `null` when
58
- * the call is not a frontend tool the host owns — a server-side tool the server
59
- * already executed, which the client must not re-run.
58
+ * there is nothing to post: the call is not a frontend tool the host owns (a
59
+ * server-side tool the server already executed, which the client must not
60
+ * re-run), or its run was stopped or handed over while the call waited. A
61
+ * stopped call is left unanswered here, like the calls after it in the round,
62
+ * and the next request answers it as not finished.
60
63
  */
61
64
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
62
65
 
@@ -317,8 +320,10 @@ export class AgUiClient {
317
320
  readonly #unfinishedMessage: string;
318
321
  readonly #declinedMessage: string;
319
322
  readonly #maxToolRounds: number;
320
- // Set by cancel(); reset at the top of each #run(). Checked by the loop so
321
- // a cancel between frontend-tool rounds doesn't start another round.
323
+ // Set by cancel(); reset as send() or resume() begins, before either runs
324
+ // any host code. Checked by the loop so a cancel between frontend-tool
325
+ // rounds doesn't start another round, and again just before each request,
326
+ // so a cancel from host code the round itself runs is not sent anyway.
322
327
  #cancelled = false;
323
328
 
324
329
  constructor(config: AgUiClientConfig) {
@@ -388,6 +393,12 @@ export class AgUiClient {
388
393
  * {@link messageAttachments}.
389
394
  */
390
395
  async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
396
+ // A new interaction, so the Stop of the one before it is forgotten here --
397
+ // ahead of the save below, which is the host's store. Forgotten as the run
398
+ // began instead, a Stop that store made (a new chat started from the save)
399
+ // was erased before anything read it, and the turn went out regardless,
400
+ // into the conversation being left and with all of its history.
401
+ this.#cancelled = false;
391
402
  // In `metadata` because it is the one field on a message `@ag-ui/client`
392
403
  // declares open by key. 1.0 strips every undeclared key from the input it
393
404
  // sends, so refs at the top level of the message -- where every release
@@ -410,11 +421,14 @@ export class AgUiClient {
410
421
  * can be asked again.
411
422
  *
412
423
  * Returns the retained history, or `null` when there is nothing to retry (no
413
- * user message has been sent yet). **Truncates only** -- the caller re-renders
414
- * from the returned list and then calls {@link resume}, because the transcript
415
- * belongs to the element and a client that reached into it would own two
416
- * things. Running here instead would stream the new answer in underneath the
417
- * old one.
424
+ * user message has been sent yet). The shortened history is saved before it
425
+ * replaces the current one, so a save that throws changes nothing and the
426
+ * throw propagates.
427
+ *
428
+ * **Truncates only** -- the caller re-renders from the returned list and then
429
+ * calls {@link resume}, because the transcript belongs to the element and a
430
+ * client that reached into it would own two things. Running here instead
431
+ * would stream the new answer in underneath the old one.
418
432
  *
419
433
  * Re-running answers the question the agent was last asked, rather than
420
434
  * telling it its answer was wrong, which is what makes the result a
@@ -439,8 +453,15 @@ export class AgUiClient {
439
453
  return null;
440
454
  }
441
455
  const kept = messages.slice(0, lastUser + 1);
456
+ // Saved before the history is shortened, so a store that throws leaves it
457
+ // as it was and the throw reaches the caller, as a refused save does from
458
+ // send(). Shortened first, the refusal left this client without the answer
459
+ // while the caller's transcript still showed it, and the next request then
460
+ // asked a follow-up about something the agent was never told it had said.
461
+ // Handed over directly rather than through #persist, which saves the list
462
+ // the agent holds, because the agent does not hold this one yet.
463
+ this.#onPersist(kept);
442
464
  this.#agent.setMessages(kept);
443
- this.#persist();
444
465
  return kept;
445
466
  }
446
467
 
@@ -450,6 +471,10 @@ export class AgUiClient {
450
471
  * message; it continues the conversation already in history.
451
472
  */
452
473
  async resume(): Promise<void> {
474
+ // A new interaction, as in send(). No host code runs between here and the
475
+ // loop, so for a resume this is the same moment the run used to forget a
476
+ // Stop at; it moved here so that both ways in forget it in one place.
477
+ this.#cancelled = false;
453
478
  await this.#run();
454
479
  }
455
480
 
@@ -465,6 +490,13 @@ export class AgUiClient {
465
490
  * disconnect). Safe to call with no run in flight. Partial text already
466
491
  * streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
467
492
  * instead of `onError`, and `onSettled` still follows.
493
+ *
494
+ * Also stops a {@link send} or {@link resume} whose request has not been made
495
+ * yet, when host code it runs on the way calls this: the save of the turn,
496
+ * the tool and context providers, the save answering a call left open. No
497
+ * request is made then, and `onCancelled` and `onSettled` fire as for any
498
+ * other Stop. A cancel with nothing in flight is forgotten when the next
499
+ * send or resume begins.
468
500
  */
469
501
  cancel(): void {
470
502
  this.#cancelled = true;
@@ -472,7 +504,6 @@ export class AgUiClient {
472
504
  }
473
505
 
474
506
  async #run(): Promise<void> {
475
- this.#cancelled = false;
476
507
  try {
477
508
  await this.#runLoop();
478
509
  // `@ag-ui/client` filters abort errors inside `runAgent` (it resolves
@@ -637,7 +668,10 @@ export class AgUiClient {
637
668
  let resuming: ReadonlySet<string> = new Set();
638
669
  for (let round = 0; round < this.#maxToolRounds; round += 1) {
639
670
  // A cancel during the previous round's tool execution lands here: the
640
- // running handler completed, but no further round starts.
671
+ // running handler completed, but no further round starts. The check
672
+ // before the request below would stop the round as well; this one keeps
673
+ // a stopped loop from running the providers and the store for a round
674
+ // it will not send.
641
675
  if (this.#cancelled) {
642
676
  return;
643
677
  }
@@ -651,6 +685,15 @@ export class AgUiClient {
651
685
  params.resume = resume;
652
686
  }
653
687
  this.#answerUnansweredCalls(resuming);
688
+ // Everything above since the check at the top is host code -- the tool
689
+ // and context providers, and the store saving the answers just made -- and
690
+ // any of it can be what stopped the run, by starting a new chat or
691
+ // changing the principal. `@ag-ui/client` makes a fresh abort controller
692
+ // for each run, so the abort `cancel()` made in that time reached
693
+ // nothing, and only this check keeps the request from going out.
694
+ if (this.#cancelled) {
695
+ return;
696
+ }
654
697
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
655
698
  resume = undefined;
656
699
  resuming = new Set();
@@ -53,9 +53,12 @@ export class StorageScope {
53
53
  // remounts, but re-resolves if the host answers the warning with an `id`.
54
54
  #generatedNs = "";
55
55
  #generatedFor = "";
56
- // The `sessionStorage`-backed store, which the element may therefore re-scope
57
- // on a principal change. `null` when the host injected a store of its own
58
- // kind, whose keying the element does not know and must not guess at.
56
+ // The `sessionStorage`-backed store this scope handed out, which the element
57
+ // may therefore re-scope on a principal change -- while it is still the store
58
+ // in use, which rescopeStore asks, since a host can assign its own at any
59
+ // time. `null` until connecting has met the built-in store at all. A store of
60
+ // the host's own kind is never held here: the element does not know its
61
+ // keying and must not guess at it.
59
62
  #builtinStore: SessionStorageStore | null = null;
60
63
 
61
64
  constructor(host: StorageScopeHost) {
@@ -115,12 +118,22 @@ export class StorageScope {
115
118
  }
116
119
 
117
120
  /**
118
- * Rebuild the built-in store under `namespace`, or `null` for a store of the
119
- * host's own kind, which holds its data somewhere the element cannot see and
120
- * has to scope itself.
121
+ * Rebuild the built-in store under `namespace`, or `null` when `current` --
122
+ * the store the element is using now, unwrapped from any remote it wrapped
123
+ * it in -- is not the one this scope handed out.
124
+ *
125
+ * Asked of the store in use rather than of what connecting remembered,
126
+ * because the two part company the moment a host assigns a store of its own
127
+ * to a connected element. Remembering was enough to tell a host's store from
128
+ * the built-in one at connect; after it, the built-in one was still
129
+ * remembered, so a key change replaced the host's store with a fresh
130
+ * `sessionStorage` one -- and a host that had chosen to keep message bodies
131
+ * off the client found every one of them cached in the tab again. A store
132
+ * the element did not make holds its data somewhere the element cannot see,
133
+ * and has to scope itself.
121
134
  */
122
- rescopeStore(namespace: string): SessionStorageStore | null {
123
- if (this.#builtinStore === null) {
135
+ rescopeStore(current: ClientConversationStore, namespace: string): SessionStorageStore | null {
136
+ if (current !== this.#builtinStore) {
124
137
  return null;
125
138
  }
126
139
  this.#builtinStore = new SessionStorageStore(namespace);
@@ -14,6 +14,7 @@ import {
14
14
  requestConfirmation,
15
15
  } from "../ui/interrupts/confirmation_card.js";
16
16
  import type { PendingDecision } from "../ui/interrupts/pending_decision.js";
17
+ import type { ToolCallCard } from "../ui/progress/tool_call_card.js";
17
18
  import type { Transcript } from "../ui/transcript/transcript.js";
18
19
  import type { UiStrings } from "../ui/ui_strings.js";
19
20
  import type { ClientTool } from "./client_tool_registry.js";
@@ -56,6 +57,13 @@ export interface ToolDispatchHost {
56
57
  readonly conversationStore: () => ClientConversationStore;
57
58
  /** The active thread's id. */
58
59
  readonly threadId: () => string;
60
+ /**
61
+ * The element's current tenure: an identity it replaces on every handover
62
+ * from one principal to another, and never otherwise. Only ever compared, so
63
+ * a call can tell whether the principal it was dispatched for is still the
64
+ * one the element serves.
65
+ */
66
+ readonly tenure: () => object;
59
67
  }
60
68
 
61
69
  /**
@@ -118,6 +126,12 @@ export class ToolDispatch {
118
126
  * Execute one frontend tool call the round produced, against its own card:
119
127
  * refused when the page moved under the round, asked about when a rule gates
120
128
  * it, and settled with what the handler returned or threw.
129
+ *
130
+ * Every await below is a place the run can end, so each is followed by a
131
+ * check that it has not. A Stop while the host's predicate is pending ends
132
+ * the call there, and one while the card is open declines it. A handover
133
+ * during any of the three leaves the call with no transcript to draw in and
134
+ * no store to write to, so it does neither.
121
135
  */
122
136
  async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
123
137
  // A skill load already rendered as a notice on the stream; it is never a
@@ -126,6 +140,15 @@ export class ToolDispatch {
126
140
  if (skillNameFrom(call) !== null) {
127
141
  return null;
128
142
  }
143
+ // Fixed here, as the call is dispatched, rather than read when they are
144
+ // used. The client calls this in the same task as its own check that the
145
+ // run was not stopped, so these are the run's principal and thread; read
146
+ // after the handler instead, they were whoever was current by then. A
147
+ // navigating handler's failure cleared the checkpoint of the thread New
148
+ // chat had just moved to, leaving its own behind, and across a handover
149
+ // it wrote into the next principal's store.
150
+ const tenure = this.#host.tenure();
151
+ const threadId = this.#host.threadId();
129
152
  const card = this.#host.transcript.cardFor(call);
130
153
  this.#host.transcript.forgetCard(call.id);
131
154
  // Kept after the card leaves the awaiting cards: a tool that renders into the
@@ -180,7 +203,23 @@ export class ToolDispatch {
180
203
  // lives only in the DOM, and the DOM is what a reload throws away.
181
204
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
182
205
  }
206
+ // The run is suspended on the host's predicate as much as on a card, and a
207
+ // predicate is free to take as long as one: it may ask a server. So the
208
+ // wait is opened for it the same way, and a Stop -- or a handover, which
209
+ // stops the run first -- aborts it. Unchecked, a predicate that answered
210
+ // after the Stop opened its card or ran its handler for a run the user had
211
+ // ended, and after a handover it ran the previous principal's handler on
212
+ // the next principal's page.
213
+ //
214
+ // Held by "draws no card when the predicate answers after Stop", and for
215
+ // the handover by "does not run the previous principal's handler after a
216
+ // handover", in tool_dispatch.test.ts.
217
+ const asking = this.#host.decision.open();
183
218
  const rule = await this.#confirmationRule(call, tool);
219
+ this.#host.decision.close();
220
+ if (asking.aborted) {
221
+ return this.#abandoned(card);
222
+ }
184
223
  if (rule === "unanswered") {
185
224
  // Settled as a refusal and returned as one, so the run carries on to its
186
225
  // next round the way it does after a decline and the agent can say what
@@ -216,6 +255,17 @@ export class ToolDispatch {
216
255
  this.#host.transcript.follow();
217
256
  const accepted = await decision;
218
257
  this.#host.decision.close();
258
+ // A handover while the card was open declines it, as any Stop does, but
259
+ // the transcript the decline would be drawn into is the next principal's
260
+ // by now, and the client the refusal goes to writes nothing for a tenure
261
+ // that has ended. Unchecked, the pending indicator the decline shows was
262
+ // left in the next principal's empty transcript with nothing to remove it.
263
+ //
264
+ // Held by "draws nothing for the next principal when a handover declines
265
+ // the card" in tool_dispatch.test.ts.
266
+ if (this.#host.tenure() !== tenure) {
267
+ return null;
268
+ }
219
269
  card.recordDecision(accepted ? "approved" : "declined");
220
270
  if (!accepted) {
221
271
  const message = this.#host.strings().declinedAction;
@@ -227,6 +277,18 @@ export class ToolDispatch {
227
277
  // the user had explicitly refused.
228
278
  return { content: message, outcome: TOOL_OUTCOME.DENIED };
229
279
  }
280
+ // Answered yes, and then stopped before that answer was heard: a click
281
+ // on Confirm and a Stop in the same task, so the card had already
282
+ // resolved by the time the abort reached it. The person agreed to the
283
+ // call, so it is not declined; the run it belonged to is over, so it does
284
+ // not run either. The server-side gate treats approve-then-Stop the same
285
+ // way.
286
+ //
287
+ // Held by "does not run a call confirmed in the same task as Stop" in
288
+ // tool_dispatch.test.ts.
289
+ if (signal.aborted) {
290
+ return this.#abandoned(card);
291
+ }
230
292
  }
231
293
  // A navigating tool reloads only without a client-side router; with a
232
294
  // host `navigate()` (SPA) it routes in-page and the loop just continues.
@@ -235,12 +297,30 @@ export class ToolDispatch {
235
297
  // Checkpoint before the handler reloads the page; the history (incl.
236
298
  // this tool call) was already persisted when the run that produced it
237
299
  // settled. The result is supplied on the next mount via the resume path.
238
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
300
+ //
301
+ // Nothing has been awaited since the last check that the run is still
302
+ // going, so the live thread is the dispatch one here too. Written under
303
+ // the dispatch thread all the same, so the clear below is certain to
304
+ // remove what this wrote.
305
+ this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
239
306
  }
240
307
  try {
241
308
  // The call id lets a handler that renders into the transcript find its
242
309
  // own card; handlers that only act on the page ignore it.
243
310
  const result = await tool.handler(call.args, call.id);
311
+ // A handler cannot be aborted, and a plain Stop while it ran keeps its
312
+ // result: the action happened on the page, and the agent is told so on
313
+ // the next request. A handover is different. The transcript this would
314
+ // draw into is the next principal's now, and the client the result goes
315
+ // to writes nothing for a tenure that has ended, so the result has no
316
+ // one left to reach. Unchecked, the pending indicator was drawn into the
317
+ // next principal's empty transcript, where nothing would ever remove it.
318
+ //
319
+ // Held by "draws nothing for the next principal when a handler resolves
320
+ // after a handover" in tool_dispatch.test.ts.
321
+ if (this.#host.tenure() !== tenure) {
322
+ return null;
323
+ }
244
324
  // Drawn from the arguments rather than the result, so the live path and
245
325
  // the replay path render the same thing from the same input.
246
326
  if (tool.render !== undefined) {
@@ -255,9 +335,20 @@ export class ToolDispatch {
255
335
  this.#host.transcript.showPending();
256
336
  return { content };
257
337
  } catch (error) {
338
+ // The same handover check as on success, and here it also guards a
339
+ // write: the store is read live, and after a handover it is the next
340
+ // principal's, so the clear below removed their checkpoint rather than
341
+ // this call's -- which the handover's purge had already removed.
342
+ //
343
+ // Held by "leaves the next principal's checkpoint alone when a handler
344
+ // fails after a handover" in tool_dispatch.test.ts.
345
+ if (this.#host.tenure() !== tenure) {
346
+ return null;
347
+ }
258
348
  if (navigates) {
259
- // The navigation never happened; drop the dangling checkpoint.
260
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
349
+ // The navigation never happened; drop the dangling checkpoint, from
350
+ // the thread it was written to.
351
+ this.#host.conversationStore().saveCheckpoint(threadId, null);
261
352
  }
262
353
  // The handler's own message, verbatim, in two places at once: the card,
263
354
  // which the user sees, and the tool result, which goes to the endpoint,
@@ -479,6 +570,26 @@ export class ToolDispatch {
479
570
  return isDestructive(tool.parameters) ? "destructive" : null;
480
571
  }
481
572
 
573
+ /**
574
+ * Settle a call whose run ended while it waited, and give the loop nothing.
575
+ *
576
+ * The card is settled here because nothing else will: dispatch took it out
577
+ * of the end-of-run sweep before it waited, so left alone it read "running…"
578
+ * for good. It says the call did not finish, which is true -- nothing ran and
579
+ * nobody refused it.
580
+ *
581
+ * `null` rather than a result, because a stopped run has no next round to
582
+ * send one in. The loop moves on and finds the run stopped, and the call is
583
+ * left unanswered like every call after it in the round, so the client
584
+ * answers it as not finished on the next request -- in the same words as
585
+ * this card. After a handover the card is in a transcript already cleared,
586
+ * and settling it there is seen by no one.
587
+ */
588
+ #abandoned(card: ToolCallCard): null {
589
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
590
+ return null;
591
+ }
592
+
482
593
  /**
483
594
  * Whether the page moved since the current round's context was built.
484
595
  *
@@ -100,6 +100,16 @@ export class VoiceInput {
100
100
  this.#fail(this.#strings.transcriptionFailed);
101
101
  return;
102
102
  }
103
+ if (this.#disposed) {
104
+ // The permission prompt outlived the control: the element left, or
105
+ // user-key changed, while it was open. Recording now would light the
106
+ // browser's indicator for a button nobody can see or stop, and dispose
107
+ // has already run, so nothing else would ever release these tracks.
108
+ for (const track of stream.getTracks()) {
109
+ track.stop();
110
+ }
111
+ return;
112
+ }
103
113
  this.#stream = stream;
104
114
  this.#chunks = [];
105
115
  this.#hitCap = false;
@@ -136,10 +146,16 @@ export class VoiceInput {
136
146
  }
137
147
 
138
148
  /**
139
- * Tear the control down, for a host element removed mid-recording. Stops any
140
- * live `MediaRecorder`, releases the mic tracks so the browser's recording
141
- * indicator clears, and suppresses the pending transcription — a
142
- * disconnected control must not fire `onText` into a detached element.
149
+ * Tear the control down, for a host element removed mid-recording or handed
150
+ * to the next principal. Stops any live `MediaRecorder`, releases the mic
151
+ * tracks so the browser's recording indicator clears, and suppresses the
152
+ * pending transcription — a disconnected control must not fire `onText` into
153
+ * a detached element.
154
+ *
155
+ * Disposal is final: nothing un-disposes an instance, and the host's next
156
+ * wiring builds a new one. So the disposed flag alone tells a stale
157
+ * continuation from a live one, and a clip already posted when this runs is
158
+ * dropped whenever its answer arrives.
143
159
  */
144
160
  dispose(): void {
145
161
  this.#disposed = true;
@@ -160,6 +176,15 @@ export class VoiceInput {
160
176
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
161
177
  try {
162
178
  const text = await this.#transcribe(audio);
179
+ if (this.#disposed) {
180
+ // Disposed while the clip was in flight. The check above ran before the
181
+ // await and cannot see this: on a change of user-key the host disposes
182
+ // and wires a fresh mic over the same composer, so delivering now
183
+ // would put the previous principal's words in the next one's box.
184
+ // The button is left as it was, too: it is no longer this control's
185
+ // to change.
186
+ return;
187
+ }
163
188
  this.#setState("idle");
164
189
  if (this.#hitCap) {
165
190
  // #setState has just reset the tooltip to the idle label, so this goes
@@ -188,6 +213,12 @@ export class VoiceInput {
188
213
  }
189
214
 
190
215
  #fail(message: string): void {
216
+ // Both callers reach here after an await, a permission prompt or a
217
+ // transcription, either of which can settle after dispose. A disposed
218
+ // control has nothing to release and no tooltip to draw.
219
+ if (this.#disposed) {
220
+ return;
221
+ }
191
222
  this.#releaseStream();
192
223
  this.#recorder = null;
193
224
  this.#setState("idle");