@artooi/ag-ui-web-component 0.41.3 → 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.
package/dist/index.js CHANGED
@@ -1731,11 +1731,19 @@ var ToolDispatch = class {
1731
1731
  * Execute one frontend tool call the round produced, against its own card:
1732
1732
  * refused when the page moved under the round, asked about when a rule gates
1733
1733
  * it, and settled with what the handler returned or threw.
1734
+ *
1735
+ * Every await below is a place the run can end, so each is followed by a
1736
+ * check that it has not. A Stop while the host's predicate is pending ends
1737
+ * the call there, and one while the card is open declines it. A handover
1738
+ * during any of the three leaves the call with no transcript to draw in and
1739
+ * no store to write to, so it does neither.
1734
1740
  */
1735
1741
  async execute(call) {
1736
1742
  if (skillNameFrom(call) !== null) {
1737
1743
  return null;
1738
1744
  }
1745
+ const tenure = this.#host.tenure();
1746
+ const threadId = this.#host.threadId();
1739
1747
  const card = this.#host.transcript.cardFor(call);
1740
1748
  this.#host.transcript.forgetCard(call.id);
1741
1749
  this.#host.transcript.setCardElement(call.id, card.element);
@@ -1752,7 +1760,12 @@ var ToolDispatch = class {
1752
1760
  this.#host.transcript.showPending();
1753
1761
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
1754
1762
  }
1763
+ const asking = this.#host.decision.open();
1755
1764
  const rule = await this.#confirmationRule(call, tool);
1765
+ this.#host.decision.close();
1766
+ if (asking.aborted) {
1767
+ return this.#abandoned(card);
1768
+ }
1756
1769
  if (rule === "unanswered") {
1757
1770
  const message = this.#host.strings().confirmCheckFailed;
1758
1771
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
@@ -1776,6 +1789,9 @@ var ToolDispatch = class {
1776
1789
  this.#host.transcript.follow();
1777
1790
  const accepted = await decision;
1778
1791
  this.#host.decision.close();
1792
+ if (this.#host.tenure() !== tenure) {
1793
+ return null;
1794
+ }
1779
1795
  card.recordDecision(accepted ? "approved" : "declined");
1780
1796
  if (!accepted) {
1781
1797
  const message = this.#host.strings().declinedAction;
@@ -1783,13 +1799,19 @@ var ToolDispatch = class {
1783
1799
  this.#host.transcript.showPending();
1784
1800
  return { content: message, outcome: TOOL_OUTCOME.DENIED };
1785
1801
  }
1802
+ if (signal.aborted) {
1803
+ return this.#abandoned(card);
1804
+ }
1786
1805
  }
1787
1806
  const navigates = isNavigates(tool.parameters) && this.#host.navigate() === null;
1788
1807
  if (navigates) {
1789
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
1808
+ this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
1790
1809
  }
1791
1810
  try {
1792
1811
  const result = await tool.handler(call.args, call.id);
1812
+ if (this.#host.tenure() !== tenure) {
1813
+ return null;
1814
+ }
1793
1815
  if (tool.render !== void 0) {
1794
1816
  this.#host.transcript.renderToolOutput(tool.render, call);
1795
1817
  }
@@ -1802,8 +1824,11 @@ var ToolDispatch = class {
1802
1824
  this.#host.transcript.showPending();
1803
1825
  return { content };
1804
1826
  } catch (error) {
1827
+ if (this.#host.tenure() !== tenure) {
1828
+ return null;
1829
+ }
1805
1830
  if (navigates) {
1806
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
1831
+ this.#host.conversationStore().saveCheckpoint(threadId, null);
1807
1832
  }
1808
1833
  const message = error instanceof Error ? error.message : String(error);
1809
1834
  card.settle(TOOL_CALL_STATUS.ERROR, message);
@@ -1960,6 +1985,25 @@ var ToolDispatch = class {
1960
1985
  }
1961
1986
  return isDestructive(tool.parameters) ? "destructive" : null;
1962
1987
  }
1988
+ /**
1989
+ * Settle a call whose run ended while it waited, and give the loop nothing.
1990
+ *
1991
+ * The card is settled here because nothing else will: dispatch took it out
1992
+ * of the end-of-run sweep before it waited, so left alone it read "running…"
1993
+ * for good. It says the call did not finish, which is true -- nothing ran and
1994
+ * nobody refused it.
1995
+ *
1996
+ * `null` rather than a result, because a stopped run has no next round to
1997
+ * send one in. The loop moves on and finds the run stopped, and the call is
1998
+ * left unanswered like every call after it in the round, so the client
1999
+ * answers it as not finished on the next request -- in the same words as
2000
+ * this card. After a handover the card is in a transcript already cleared,
2001
+ * and settling it there is seen by no one.
2002
+ */
2003
+ #abandoned(card) {
2004
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
2005
+ return null;
2006
+ }
1963
2007
  /**
1964
2008
  * Whether the page moved since the current round's context was built.
1965
2009
  *
@@ -3065,6 +3109,12 @@ var VoiceInput = class {
3065
3109
  this.#fail(this.#strings.transcriptionFailed);
3066
3110
  return;
3067
3111
  }
3112
+ if (this.#disposed) {
3113
+ for (const track of stream.getTracks()) {
3114
+ track.stop();
3115
+ }
3116
+ return;
3117
+ }
3068
3118
  this.#stream = stream;
3069
3119
  this.#chunks = [];
3070
3120
  this.#hitCap = false;
@@ -3095,10 +3145,16 @@ var VoiceInput = class {
3095
3145
  }
3096
3146
  }
3097
3147
  /**
3098
- * Tear the control down, for a host element removed mid-recording. Stops any
3099
- * live `MediaRecorder`, releases the mic tracks so the browser's recording
3100
- * indicator clears, and suppresses the pending transcription — a
3101
- * disconnected control must not fire `onText` into a detached element.
3148
+ * Tear the control down, for a host element removed mid-recording or handed
3149
+ * to the next principal. Stops any live `MediaRecorder`, releases the mic
3150
+ * tracks so the browser's recording indicator clears, and suppresses the
3151
+ * pending transcription — a disconnected control must not fire `onText` into
3152
+ * a detached element.
3153
+ *
3154
+ * Disposal is final: nothing un-disposes an instance, and the host's next
3155
+ * wiring builds a new one. So the disposed flag alone tells a stale
3156
+ * continuation from a live one, and a clip already posted when this runs is
3157
+ * dropped whenever its answer arrives.
3102
3158
  */
3103
3159
  dispose() {
3104
3160
  this.#disposed = true;
@@ -3118,6 +3174,9 @@ var VoiceInput = class {
3118
3174
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
3119
3175
  try {
3120
3176
  const text4 = await this.#transcribe(audio);
3177
+ if (this.#disposed) {
3178
+ return;
3179
+ }
3121
3180
  this.#setState("idle");
3122
3181
  if (this.#hitCap) {
3123
3182
  this.element.title = fillUiString(this.#strings.recordingLimit, {
@@ -3141,6 +3200,9 @@ var VoiceInput = class {
3141
3200
  this.#stream = null;
3142
3201
  }
3143
3202
  #fail(message) {
3203
+ if (this.#disposed) {
3204
+ return;
3205
+ }
3144
3206
  this.#releaseStream();
3145
3207
  this.#recorder = null;
3146
3208
  this.#setState("idle");
@@ -4104,6 +4166,8 @@ function toolStatusFromOutcome(outcome) {
4104
4166
  }
4105
4167
 
4106
4168
  // src/ui/history/conversation_history.ts
4169
+ var UNHELD = Object.freeze({ release: () => {
4170
+ } });
4107
4171
  var ConversationHistory = class {
4108
4172
  #host;
4109
4173
  /** The active thread's id. Empty until the element connects. */
@@ -4119,9 +4183,20 @@ var ConversationHistory = class {
4119
4183
  * the one it continued is still on screen when it saves.
4120
4184
  */
4121
4185
  #cleared = 0;
4122
- // Bumped on every rehydrate; a replay whose generation is stale (a newer
4123
- // thread switch started while it awaited a slow store) drops its result.
4186
+ /**
4187
+ * Which restore may draw: bumped by every rehydrate, and by every clear of
4188
+ * the conversation on screen. A restore whose generation is stale draws
4189
+ * nothing more and resumes nothing: a newer one has started, or New chat, a
4190
+ * thread switch or a `user-key` handover has cleared what it was restoring.
4191
+ */
4124
4192
  #generation = 0;
4193
+ /**
4194
+ * The conversation the latest restore claimed, written by every restore as
4195
+ * it starts; `null` before the first. It speaks for the restore now current
4196
+ * only while its generation is the current one, because clearing the
4197
+ * conversation moves that on. See `#forgetStopped` for the one reader.
4198
+ */
4199
+ #claim = null;
4125
4200
  /** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
4126
4201
  #runIndex = null;
4127
4202
  /**
@@ -4169,6 +4244,7 @@ var ConversationHistory = class {
4169
4244
  forgetRestored() {
4170
4245
  this.#restored = [];
4171
4246
  this.#cleared += 1;
4247
+ this.#generation += 1;
4172
4248
  }
4173
4249
  /** Delete the active thread if nothing was ever sent in it. */
4174
4250
  reapUnsent() {
@@ -4281,6 +4357,7 @@ var ConversationHistory = class {
4281
4357
  this.#refuse(this.#host.strings().continueNeedsTurn);
4282
4358
  return;
4283
4359
  }
4360
+ this.#host.recordTurn(content);
4284
4361
  this.#host.input.value = "";
4285
4362
  this.#host.autoGrow();
4286
4363
  const cleared = this.#cleared;
@@ -4357,48 +4434,197 @@ var ConversationHistory = class {
4357
4434
  * because the resume path answers it from the page the reload landed on; that
4358
4435
  * exclusion is held by "resumes with the landed page's result, and its card says
4359
4436
  * so" in `ag_ui_chat_reload_mid_run.test.ts`.
4437
+ *
4438
+ * **A restore that resumes holds the composer from before the load.** The
4439
+ * resume is a run, and it starts only once the store has answered. With a
4440
+ * remote store that is a real request, and a send, a Retry or a pick in it
4441
+ * was refused by nothing: each built the conversation's client before the
4442
+ * conversation had loaded, so a send went out carrying none of it and its
4443
+ * first save replaced the stored one, and the resume then ran on that same
4444
+ * client as a second run, answering a call the request no longer held. So
4445
+ * the composer is held as a send holds it, and the resume gives way to
4446
+ * nothing: what was typed parks behind the resumed run and goes out after its
4447
+ * answer, and a host's send or Retry is refused as it is behind any run.
4448
+ *
4449
+ * The other way round, the resume standing down for the send, was rejected.
4450
+ * The send that got in first had already gone out without the conversation,
4451
+ * so standing down kept that loss, and it also dropped the run the navigation
4452
+ * was part of, which the user asked for before the reload: the agent stopped
4453
+ * halfway through the task, with the navigating call never answered.
4454
+ *
4455
+ * **A restore stops where it was stopped, held or not.** New chat, a thread
4456
+ * switch, a `user-key` handover, a newer restore or the element leaving the
4457
+ * page can each come while the store answers, and from host code the restore
4458
+ * runs itself: the activity and tool renderers each replayed message draws
4459
+ * through, and `navigationResult`. From then on it draws nothing more and
4460
+ * resumes nothing. The hold cannot say so, because a restore with no run to
4461
+ * resume holds nothing. So the restore asks its generation, which clearing
4462
+ * the conversation moves on as a newer restore does, and asks it after the
4463
+ * load, after each replayed message -- as a Retry asks after each one -- and
4464
+ * once more before the resume. Stopped by anything but the element leaving
4465
+ * the page, it also forgets the checkpoint it loaded, since that was a Stop
4466
+ * for the run it would have resumed -- unless what replaced it is a restore
4467
+ * of the same conversation, still current, which answers for that run
4468
+ * instead; see `#forgetStopped`.
4469
+ *
4470
+ * What a restore with no run to resume still does not do is refuse a send
4471
+ * made while it loads. That send goes out without the stored conversation,
4472
+ * and its save replaces it.
4360
4473
  */
4361
4474
  async rehydrate() {
4362
4475
  this.#generation += 1;
4363
4476
  const generation = this.#generation;
4364
- this.#host.element.setAttribute("data-restoring", "");
4365
- let messages;
4477
+ const threadId = this.#threadId;
4478
+ const store = this.#host.conversationStore();
4479
+ this.#claim = { generation, store, threadId };
4480
+ const checkpoint = store.loadCheckpoint(threadId);
4481
+ const hold = checkpoint === null ? UNHELD : this.#host.holdRun();
4366
4482
  try {
4367
- messages = await this.#host.conversationStore().loadMessages(this.#threadId);
4368
- } finally {
4369
- if (generation === this.#generation) {
4370
- this.#host.element.removeAttribute("data-restoring");
4483
+ this.#host.element.setAttribute("data-restoring", "");
4484
+ let messages;
4485
+ try {
4486
+ messages = await store.loadMessages(threadId);
4487
+ } finally {
4488
+ if (generation === this.#generation) {
4489
+ this.#host.element.removeAttribute("data-restoring");
4490
+ }
4371
4491
  }
4372
- }
4373
- if (generation !== this.#generation) {
4374
- return;
4375
- }
4376
- const checkpoint = this.#host.conversationStore().loadCheckpoint(this.#threadId);
4377
- if (messages !== null) {
4378
- const unfinished = this.#host.strings().callNotFinished;
4379
- const restored = answerUnansweredCalls(
4380
- messages,
4381
- // The shape the store holds for a call the client answered the same way:
4382
- // its tool message, with the outcome in its metadata.
4383
- (toolCallId) => ({
4384
- id: randomUUID3(),
4385
- role: "tool",
4386
- content: unfinished,
4387
- toolCallId,
4388
- metadata: { outcome: TOOL_OUTCOME.INTERRUPTED }
4389
- }),
4390
- new Set(checkpoint === null ? [] : [checkpoint.toolCallId])
4391
- );
4392
- this.#restored = restored;
4393
- for (const message of restored) {
4394
- this.replay(message);
4492
+ if (!this.#restoring(generation)) {
4493
+ this.#forgetStopped(store, threadId, checkpoint);
4494
+ return;
4495
+ }
4496
+ if (messages !== null) {
4497
+ const unfinished = this.#host.strings().callNotFinished;
4498
+ const restored = answerUnansweredCalls(
4499
+ messages,
4500
+ // The shape the store holds for a call the client answered the same
4501
+ // way: its tool message, with the outcome in its metadata.
4502
+ (toolCallId) => ({
4503
+ id: randomUUID3(),
4504
+ role: "tool",
4505
+ content: unfinished,
4506
+ toolCallId,
4507
+ metadata: { outcome: TOOL_OUTCOME.INTERRUPTED }
4508
+ }),
4509
+ new Set(checkpoint === null ? [] : [checkpoint.toolCallId])
4510
+ );
4511
+ this.#restored = restored;
4512
+ for (const message of restored) {
4513
+ this.replay(message);
4514
+ if (!this.#restoring(generation)) {
4515
+ this.#forgetStopped(store, threadId, checkpoint);
4516
+ return;
4517
+ }
4518
+ }
4395
4519
  }
4520
+ if (checkpoint !== null) {
4521
+ await this.#resumeFrom(checkpoint, generation);
4522
+ return;
4523
+ }
4524
+ this.#noticeIfRunUnfinished(messages);
4525
+ } finally {
4526
+ hold.release();
4396
4527
  }
4397
- if (checkpoint !== null) {
4398
- await this.#resumeFrom(checkpoint);
4399
- return;
4528
+ }
4529
+ /**
4530
+ * Whether the restore that claimed `generation` may go on: nothing has
4531
+ * replaced or cleared the conversation it is restoring, and the element is
4532
+ * still on the page.
4533
+ *
4534
+ * On the page, because leaving it is the one stop that clears nothing: the
4535
+ * element is not reset until it connects again, and connecting restores the
4536
+ * conversation afresh. A restore with nothing to resume went on regardless,
4537
+ * drawing into a node that had gone and running the host's renderers for it.
4538
+ * One that resumes learnt it from the Stop letting go of its hold; nothing
4539
+ * asks the hold that now, so this is what keeps the resume's request in.
4540
+ *
4541
+ * One `&&`, so coverage cannot tell whether either half is held. Removing the
4542
+ * generation fails "draws nothing into a new chat started while it loads" in
4543
+ * `conversation_history.test.ts`, and "drops a stale thread replay when a
4544
+ * newer switch started" in `ag_ui_chat.test.ts`; removing the connection
4545
+ * fails "draws nothing into an element that left the page while it loads".
4546
+ */
4547
+ #restoring(generation) {
4548
+ return generation === this.#generation && this.#host.element.isConnected;
4549
+ }
4550
+ /**
4551
+ * Forget the checkpoint a stopped restore loaded, when what stopped it also
4552
+ * stopped the run it was about to resume.
4553
+ *
4554
+ * New chat, a thread switch, a `user-key` handover and a newer restore all
4555
+ * move the generation, and each is a Stop for that run. Left in the store,
4556
+ * the checkpoint outlived the Stop: coming back to the conversation later
4557
+ * resumed the navigating call with whatever page was current by then.
4558
+ * Forgotten, the call is answered as not finished there, as any stopped call
4559
+ * is.
4560
+ *
4561
+ * Not while the restore now current claimed the same conversation, which it
4562
+ * has in two cases. It is this restore, when only the element left the page,
4563
+ * which moves no generation: a move or a framework re-render is not a
4564
+ * farewell, and the restore connecting starts has to find the checkpoint to
4565
+ * resume from. Or it is a newer restore of the same thread in the same store
4566
+ * -- `reload()`, switching away and back, or the element connecting again --
4567
+ * which read the checkpoint as it started, or found it gone, and answers for
4568
+ * it from then on: its resume clears it, and when it is stopped in turn this
4569
+ * same rule decides.
4570
+ *
4571
+ * The newer restore's case used to forget it too, on the grounds that the
4572
+ * newer one had read it and would resume once from that copy. It did until
4573
+ * the element left the page while the newer one loaded: that one stood down
4574
+ * keeping the checkpoint for the reconnect, as a departure should, but the
4575
+ * store no longer held it, so the element came back with no run to resume.
4576
+ *
4577
+ * Written to the store and the thread it was read from. After a handover
4578
+ * that is the previous principal's: the built-in store's clear is a removal
4579
+ * from a namespace the handover has already purged, so it changes nothing,
4580
+ * and nothing reaches the next principal's. A host's store is the same object
4581
+ * either side of a handover; one the host replaced is held by "still refuses
4582
+ * a send for the newer restore once the older has stood down".
4583
+ *
4584
+ * Two conditions. Clearing with no checkpoint fails "draws nothing into a new
4585
+ * chat started while it loads", which runs no store code for one. Clearing
4586
+ * what the current restore claimed fails "resumes nothing once the element
4587
+ * leaves the page while the store answers", where the claim is this
4588
+ * restore's own and the re-inserted element resumes, and "resumes once the
4589
+ * element is back when the host reloads and it then leaves the page", where
4590
+ * it is the reload's. The claim's own three are named on `#claimedByCurrent`.
4591
+ */
4592
+ #forgetStopped(store, threadId, checkpoint) {
4593
+ if (checkpoint !== null && !this.#claimedByCurrent(store, threadId)) {
4594
+ store.saveCheckpoint(threadId, null);
4400
4595
  }
4401
- this.#noticeIfRunUnfinished(messages);
4596
+ }
4597
+ /**
4598
+ * Whether the restore now current claimed the conversation a stopped restore
4599
+ * read its checkpoint from: the same thread, in the same store.
4600
+ *
4601
+ * A claim from an earlier generation speaks for nobody: the conversation was
4602
+ * cleared after it was made, so whatever it would have resumed was stopped,
4603
+ * and that holds of a stopped restore's own claim as much as of any other.
4604
+ * The store is compared as an object, because a store the host assigned
4605
+ * since is a different record of the conversation, and the one the
4606
+ * checkpoint was read from still has to forget it.
4607
+ *
4608
+ * As an object, a store the element builds for itself -- the built-in one
4609
+ * under a namespace, and the remote one `data-threads-url` wraps around it
4610
+ * -- is a different one after a reconnect, which builds it afresh over the
4611
+ * same storage, so a restore from before the reconnect forgets the
4612
+ * checkpoint there once it answers. The reconnect's own restore read it as
4613
+ * it started and resumes from that copy, as a newer restore always did here;
4614
+ * what that copy cannot survive is the element leaving again before the
4615
+ * load answers.
4616
+ *
4617
+ * One `&&` of three, so coverage cannot tell whether any is held. Dropping
4618
+ * the generation fails "forgets the checkpoint when New chat stops the
4619
+ * resume, so the call ends as not finished", where the claim is the stopped
4620
+ * restore's own; dropping the store fails "still refuses a send for the newer
4621
+ * restore once the older has stood down", whose host replaces the store
4622
+ * between the two; dropping the thread fails "forgets the checkpoint of a
4623
+ * thread switched away from, though the next has one of its own".
4624
+ */
4625
+ #claimedByCurrent(store, threadId) {
4626
+ const claim = this.#claim;
4627
+ return claim?.generation === this.#generation && claim.store === store && claim.threadId === threadId;
4402
4628
  }
4403
4629
  /**
4404
4630
  * Notice a previous run that never produced a response.
@@ -4518,12 +4744,15 @@ var ConversationHistory = class {
4518
4744
  * guard is held by "resumes a checkpointed navigating tool call on mount" in
4519
4745
  * `ag_ui_chat.test.ts`, whose transcript never made the call.
4520
4746
  */
4521
- async #resumeFrom(checkpoint) {
4747
+ async #resumeFrom(checkpoint, generation) {
4522
4748
  this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
4523
4749
  const client = this.#host.ensureClient();
4524
4750
  const landed = JSON.stringify(
4525
4751
  this.#host.navigationResult().call(this.#host.element, checkpoint)
4526
4752
  );
4753
+ if (!this.#restoring(generation)) {
4754
+ return;
4755
+ }
4527
4756
  client.addToolResult(checkpoint.toolCallId, landed);
4528
4757
  this.#host.transcript.card(checkpoint.toolCallId)?.settle(TOOL_CALL_STATUS.DONE, landed);
4529
4758
  await client.resume();
@@ -14999,8 +15228,10 @@ var AgUiClient = class {
14999
15228
  #unfinishedMessage;
15000
15229
  #declinedMessage;
15001
15230
  #maxToolRounds;
15002
- // Set by cancel(); reset at the top of each #run(). Checked by the loop so
15003
- // a cancel between frontend-tool rounds doesn't start another round.
15231
+ // Set by cancel(); reset as send() or resume() begins, before either runs
15232
+ // any host code. Checked by the loop so a cancel between frontend-tool
15233
+ // rounds doesn't start another round, and again just before each request,
15234
+ // so a cancel from host code the round itself runs is not sent anyway.
15004
15235
  #cancelled = false;
15005
15236
  constructor(config) {
15006
15237
  this.#agent = config.agent;
@@ -15056,6 +15287,7 @@ var AgUiClient = class {
15056
15287
  * {@link messageAttachments}.
15057
15288
  */
15058
15289
  async send(content, attachments = []) {
15290
+ this.#cancelled = false;
15059
15291
  const message = {
15060
15292
  id: randomUUID4(),
15061
15293
  role: "user",
@@ -15071,11 +15303,14 @@ var AgUiClient = class {
15071
15303
  * can be asked again.
15072
15304
  *
15073
15305
  * Returns the retained history, or `null` when there is nothing to retry (no
15074
- * user message has been sent yet). **Truncates only** -- the caller re-renders
15075
- * from the returned list and then calls {@link resume}, because the transcript
15076
- * belongs to the element and a client that reached into it would own two
15077
- * things. Running here instead would stream the new answer in underneath the
15078
- * old one.
15306
+ * user message has been sent yet). The shortened history is saved before it
15307
+ * replaces the current one, so a save that throws changes nothing and the
15308
+ * throw propagates.
15309
+ *
15310
+ * **Truncates only** -- the caller re-renders from the returned list and then
15311
+ * calls {@link resume}, because the transcript belongs to the element and a
15312
+ * client that reached into it would own two things. Running here instead
15313
+ * would stream the new answer in underneath the old one.
15079
15314
  *
15080
15315
  * Re-running answers the question the agent was last asked, rather than
15081
15316
  * telling it its answer was wrong, which is what makes the result a
@@ -15097,8 +15332,8 @@ var AgUiClient = class {
15097
15332
  return null;
15098
15333
  }
15099
15334
  const kept = messages.slice(0, lastUser + 1);
15335
+ this.#onPersist(kept);
15100
15336
  this.#agent.setMessages(kept);
15101
- this.#persist();
15102
15337
  return kept;
15103
15338
  }
15104
15339
  /**
@@ -15107,6 +15342,7 @@ var AgUiClient = class {
15107
15342
  * message; it continues the conversation already in history.
15108
15343
  */
15109
15344
  async resume() {
15345
+ this.#cancelled = false;
15110
15346
  await this.#run();
15111
15347
  }
15112
15348
  /** Append a frontend tool result to history (used by the resume path). */
@@ -15120,13 +15356,19 @@ var AgUiClient = class {
15120
15356
  * disconnect). Safe to call with no run in flight. Partial text already
15121
15357
  * streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
15122
15358
  * instead of `onError`, and `onSettled` still follows.
15359
+ *
15360
+ * Also stops a {@link send} or {@link resume} whose request has not been made
15361
+ * yet, when host code it runs on the way calls this: the save of the turn,
15362
+ * the tool and context providers, the save answering a call left open. No
15363
+ * request is made then, and `onCancelled` and `onSettled` fire as for any
15364
+ * other Stop. A cancel with nothing in flight is forgotten when the next
15365
+ * send or resume begins.
15123
15366
  */
15124
15367
  cancel() {
15125
15368
  this.#cancelled = true;
15126
15369
  this.#agent.abortRun();
15127
15370
  }
15128
15371
  async #run() {
15129
- this.#cancelled = false;
15130
15372
  try {
15131
15373
  await this.#runLoop();
15132
15374
  if (this.#cancelled) {
@@ -15284,6 +15526,9 @@ var AgUiClient = class {
15284
15526
  params.resume = resume;
15285
15527
  }
15286
15528
  this.#answerUnansweredCalls(resuming);
15529
+ if (this.#cancelled) {
15530
+ return;
15531
+ }
15287
15532
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
15288
15533
  resume = void 0;
15289
15534
  resuming = /* @__PURE__ */ new Set();
@@ -16206,9 +16451,12 @@ var StorageScope = class {
16206
16451
  // remounts, but re-resolves if the host answers the warning with an `id`.
16207
16452
  #generatedNs = "";
16208
16453
  #generatedFor = "";
16209
- // The `sessionStorage`-backed store, which the element may therefore re-scope
16210
- // on a principal change. `null` when the host injected a store of its own
16211
- // kind, whose keying the element does not know and must not guess at.
16454
+ // The `sessionStorage`-backed store this scope handed out, which the element
16455
+ // may therefore re-scope on a principal change -- while it is still the store
16456
+ // in use, which rescopeStore asks, since a host can assign its own at any
16457
+ // time. `null` until connecting has met the built-in store at all. A store of
16458
+ // the host's own kind is never held here: the element does not know its
16459
+ // keying and must not guess at it.
16212
16460
  #builtinStore = null;
16213
16461
  constructor(host) {
16214
16462
  this.#host = host;
@@ -16262,12 +16510,22 @@ var StorageScope = class {
16262
16510
  return this.#builtinStore;
16263
16511
  }
16264
16512
  /**
16265
- * Rebuild the built-in store under `namespace`, or `null` for a store of the
16266
- * host's own kind, which holds its data somewhere the element cannot see and
16267
- * has to scope itself.
16513
+ * Rebuild the built-in store under `namespace`, or `null` when `current` --
16514
+ * the store the element is using now, unwrapped from any remote it wrapped
16515
+ * it in -- is not the one this scope handed out.
16516
+ *
16517
+ * Asked of the store in use rather than of what connecting remembered,
16518
+ * because the two part company the moment a host assigns a store of its own
16519
+ * to a connected element. Remembering was enough to tell a host's store from
16520
+ * the built-in one at connect; after it, the built-in one was still
16521
+ * remembered, so a key change replaced the host's store with a fresh
16522
+ * `sessionStorage` one -- and a host that had chosen to keep message bodies
16523
+ * off the client found every one of them cached in the tab again. A store
16524
+ * the element did not make holds its data somewhere the element cannot see,
16525
+ * and has to scope itself.
16268
16526
  */
16269
- rescopeStore(namespace) {
16270
- if (this.#builtinStore === null) {
16527
+ rescopeStore(current, namespace) {
16528
+ if (current !== this.#builtinStore) {
16271
16529
  return null;
16272
16530
  }
16273
16531
  this.#builtinStore = new SessionStorageStore(namespace);
@@ -16791,6 +17049,13 @@ var AgUiChat = class extends HTMLElement {
16791
17049
  #queuedRow = document.createElement("div");
16792
17050
  /** How far back the composer has been walked; null while the user is typing. */
16793
17051
  #recallIndex = null;
17052
+ /**
17053
+ * What the walk last put in the composer, read back from it. A walk is live
17054
+ * only while the composer still holds exactly this, so a write from anywhere
17055
+ * else ends it. Stale while no walk is in progress, which is harmless: the
17056
+ * comparison can only ever end a walk, never start one.
17057
+ */
17058
+ #recalled = "";
16794
17059
  /**
16795
17060
  * Re-clamp the dragged launcher when the window changes size. Bound once as
16796
17061
  * a field so `removeEventListener` on disconnect gets the same reference.
@@ -16826,6 +17091,39 @@ var AgUiChat = class extends HTMLElement {
16826
17091
  * history replay would draw the conversation a second time beneath it.
16827
17092
  */
16828
17093
  #connectedBefore = false;
17094
+ /**
17095
+ * The `user-key` whose state the element holds -- the transcript, the recall
17096
+ * history, the Always allow waivers and the shared state -- or `null` before
17097
+ * the first connect, when it holds nobody's.
17098
+ *
17099
+ * Not the same thing as the attribute. The attribute names the principal the
17100
+ * host says is here now, and it can change while the element is out of the
17101
+ * document, where nothing acts on it; this is who the state still belongs
17102
+ * to. Connecting compares the two, which is how a key changed while detached
17103
+ * still gets the handover a live change gets.
17104
+ */
17105
+ #principalKey = null;
17106
+ /**
17107
+ * The current principal's tenure: replaced on every handover from one
17108
+ * principal to another, and captured by each client as it is built, so that
17109
+ * a client can tell whether the principal it was built for is still the one
17110
+ * this element serves.
17111
+ *
17112
+ * A client outlives the handover that stops it. A stopped run saves what it
17113
+ * had once its request closes, a tool handler cannot be aborted and its
17114
+ * result is kept, and an event already read off the wire is still applied --
17115
+ * all of it after the store was purged and scoped to whoever arrived, so
17116
+ * each one wrote the previous principal's words into the next one's storage
17117
+ * or state. Writing into the previous principal's store instead would only
17118
+ * re-create what the purge just removed, so such a client writes nothing.
17119
+ *
17120
+ * An identity rather than the key itself, for two reasons. The key's first
17121
+ * arrival is an adoption, not a handover, and a run in flight across it is
17122
+ * the same person's and keeps saving. And a key that leaves and comes back
17123
+ * is a second tenure: the first one's stopped run must not re-create a
17124
+ * conversation the handover purged.
17125
+ */
17126
+ #tenure = {};
16829
17127
  /**
16830
17128
  * The remote store `data-threads-url` wrapped around the conversation store,
16831
17129
  * and the store inside it, so connecting again can wrap that store rather
@@ -16844,6 +17142,33 @@ var AgUiChat = class extends HTMLElement {
16844
17142
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
16845
17143
  // rounds, but the user must still be able to stop there.
16846
17144
  #running = false;
17145
+ /**
17146
+ * The send {@link sendMessage} has taken, or the retry {@link retryLastTurn}
17147
+ * has, whose run has not started yet; null otherwise.
17148
+ *
17149
+ * `running` turns on when the client starts the run, a microtask after it is
17150
+ * asked for one, and a send does two things before it asks: it draws the
17151
+ * turn, and it dispatches the submit event, whose listeners run in the middle
17152
+ * of the send. A send from one of them, or a second call in the same task,
17153
+ * passed every guard and started a second run on the same client -- two
17154
+ * requests, and the listener's turn drawn between the user's turn and its
17155
+ * answer. It is the gap a picked checkpoint closes by counting from the pick,
17156
+ * closed the same way for a send. A retry asks for its run the same way, so
17157
+ * it takes the same hold, and so does a restore that will resume a run, from
17158
+ * before it asks the store for the conversation, through the history host's
17159
+ * `holdRun`.
17160
+ *
17161
+ * Let go of once the run starts, because `running` holds from then until the
17162
+ * run settles, and a send held past that would refuse a host's follow-up from
17163
+ * the run-finished event, which fires as the run settles. Let go of by the
17164
+ * send's, the retry's or the restore's end when its run never starts.
17165
+ *
17166
+ * An object rather than a flag, so a send or a retry ending releases only
17167
+ * what it took. By the time either ends, the turn queued behind it has
17168
+ * usually taken the field for itself, and a Stop lets go of it for the next
17169
+ * send to take; the earlier one ending must release neither.
17170
+ */
17171
+ #sending = null;
16847
17172
  /** The decision a run is suspended on, which a Stop abandons. */
16848
17173
  #decision = new PendingDecision();
16849
17174
  /**
@@ -16933,7 +17258,10 @@ var AgUiChat = class extends HTMLElement {
16933
17258
  approvalRenderer: () => this.approvalRenderer,
16934
17259
  getContext: () => this.getContext(),
16935
17260
  conversationStore: () => this.conversationStore,
16936
- threadId: () => this.#history.threadId
17261
+ threadId: () => this.#history.threadId,
17262
+ // Read live and compared, the way each client compares the one it was
17263
+ // built with: see #tenure.
17264
+ tenure: () => this.#tenure
16937
17265
  });
16938
17266
  this.#runHandlers = new RunHandlers({
16939
17267
  element: this,
@@ -17069,6 +17397,7 @@ var AgUiChat = class extends HTMLElement {
17069
17397
  // A continuation sends only what the composer holds. The tray is not
17070
17398
  // read, so nothing rides along to be drawn or announced.
17071
17399
  announceTurn: (content) => this.#announceTurn(content, []),
17400
+ recordTurn: (content) => this.#recordTurn(content),
17072
17401
  autoGrow: () => autoGrow(this.#input),
17073
17402
  continuationEnded: () => this.#flushQueued(),
17074
17403
  client: () => this.#client,
@@ -17077,7 +17406,37 @@ var AgUiChat = class extends HTMLElement {
17077
17406
  releaseClient: () => {
17078
17407
  this.#client = null;
17079
17408
  },
17080
- running: () => this.#running,
17409
+ // A held send or retry is as much in flight to a pick as a run is: it has
17410
+ // asked this element's client for a run that has not started, and a
17411
+ // continuation started beside it was a second run in one conversation.
17412
+ // So is a restore that will resume a run, which holds through the same
17413
+ // field from before the store answers.
17414
+ running: () => this.#running || this.#sending !== null,
17415
+ // The hold a send takes, taken for a restore that will resume a run, so
17416
+ // what refuses a send held short of its run refuses one made while the
17417
+ // store is still answering: `sendMessage` returns, Send parks the turn,
17418
+ // a Retry and a pick refuse. The same field rather than a flag beside it,
17419
+ // because what lets go of a send's hold is right for this one too. The
17420
+ // resumed run starting lets go, in `#setRunning`, so there is no gap
17421
+ // after the resume is asked for, and a Stop does, in `#cancelRun`. A flag
17422
+ // the restore cleared itself would still be set when the resumed run
17423
+ // settles and sends the parked turn, so that send would be refused after
17424
+ // the turn had left the queue, and lost.
17425
+ //
17426
+ // Whether the restore was stopped is not read off this hold, because one
17427
+ // with nothing to resume takes none; the restore asks its own generation.
17428
+ holdRun: () => {
17429
+ const hold = {};
17430
+ this.#sending = hold;
17431
+ return {
17432
+ release: () => {
17433
+ if (this.#sending === hold) {
17434
+ this.#sending = null;
17435
+ this.#flushQueued();
17436
+ }
17437
+ }
17438
+ };
17439
+ },
17081
17440
  cancelRun: () => this.#cancelRun(),
17082
17441
  resetState: () => this.#resetState(),
17083
17442
  setRunning: (running) => this.#setRunning(running)
@@ -17387,6 +17746,12 @@ var AgUiChat = class extends HTMLElement {
17387
17746
  }
17388
17747
  this.#syncLauncher();
17389
17748
  this.#skills.init();
17749
+ const held = this.#principalKey;
17750
+ const changed = held !== null && held !== this.userKey;
17751
+ if (changed) {
17752
+ this.#changePrincipal(held, this.userKey);
17753
+ }
17754
+ this.#principalKey = this.userKey;
17390
17755
  this.conversationStore = this.#storage.scopeStore(this.#unwrappedStore(), this.userKey);
17391
17756
  window.addEventListener("resize", this.#onViewportResize);
17392
17757
  window.visualViewport?.addEventListener("resize", this.#onViewportResize);
@@ -17398,6 +17763,9 @@ var AgUiChat = class extends HTMLElement {
17398
17763
  this.#history.adoptActiveThread();
17399
17764
  queueMicrotask(() => this.#startup());
17400
17765
  void this.#history.rehydrate();
17766
+ if (changed) {
17767
+ void this.#history.refreshDrawer();
17768
+ }
17401
17769
  this.#connected = true;
17402
17770
  }
17403
17771
  /**
@@ -17722,23 +18090,49 @@ var AgUiChat = class extends HTMLElement {
17722
18090
  * logout, because a logout is a navigation (or, in a single-page app, not
17723
18091
  * even that) rather than a tab close. Nothing remounts, so the host naming
17724
18092
  * the new principal is the only signal the element will ever get.
18093
+ *
18094
+ * Two callers. A live change runs all of it. connectedCallback runs it for a
18095
+ * key that changed while the element was out of the document, and there it
18096
+ * runs before the store is scoped and before `#connected` is set -- which is
18097
+ * what `live` reads, since `isConnected` is already true by then. That call
18098
+ * does the half about what storage and the element still hold. The half
18099
+ * about the principal arriving -- the store scoped under the new key, its
18100
+ * active thread, the replay -- is what connecting goes on to do anyway, and
18101
+ * run here too it would read the old store before the new one exists,
18102
+ * minting a thread pointer in the namespace just purged and replaying the
18103
+ * history twice. Nor would it leave alone a store the host assigned while
18104
+ * the element was out.
17725
18105
  */
17726
18106
  #changePrincipal(previousKey, nextKey) {
18107
+ this.#principalKey = nextKey;
18108
+ const live = this.#connected;
17727
18109
  const previous = this.#storage.conversationNamespace(previousKey);
17728
18110
  const next = this.#storage.conversationNamespace(nextKey);
17729
18111
  if (previousKey === "") {
17730
18112
  SessionStorageStore.adopt(previous, next);
17731
- this.#rescopeStore(next);
18113
+ if (live) {
18114
+ this.#rescopeStore(next);
18115
+ }
17732
18116
  return;
17733
18117
  }
18118
+ this.#tenure = {};
17734
18119
  SessionStorageStore.purge(previous);
17735
- this.#rescopeStore(next);
18120
+ if (live) {
18121
+ this.#rescopeStore(next);
18122
+ }
17736
18123
  this.#dispatch.forgetWaivers();
17737
18124
  this.#cancelRun();
17738
18125
  this.#resetState();
17739
18126
  this.#sharedState = {};
18127
+ this.#input.value = "";
18128
+ this.#onInput();
17740
18129
  this.#setRunning(false);
17741
18130
  this.#setUnread(0);
18131
+ if (!live) {
18132
+ return;
18133
+ }
18134
+ this.#voice.dispose();
18135
+ this.#voice.wire();
17742
18136
  this.#history.adoptActiveThread();
17743
18137
  void this.#history.rehydrate();
17744
18138
  void this.#history.refreshDrawer();
@@ -17747,13 +18141,17 @@ var AgUiChat = class extends HTMLElement {
17747
18141
  * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
17748
18142
  * `data-threads-url` exactly as connecting did.
17749
18143
  *
17750
- * A store of the host's own kind is left alone: a store that holds its data
17751
- * somewhere the element cannot see has to scope itself. The transcript on
18144
+ * A store the host assigned is left alone, whether before connecting or
18145
+ * since: a store that holds its data somewhere the element cannot see has to
18146
+ * scope itself. The exception is a plain `SessionStorageStore` assigned
18147
+ * before connecting, which connecting cannot tell from the default and so
18148
+ * took for the element's own. Hence the unwrapped store in use is what the scope is asked
18149
+ * about, rather than what it remembered when connecting. The transcript on
17752
18150
  * screen is still cleared either way — the host swapped principals, and that
17753
18151
  * much is the element's to act on.
17754
18152
  */
17755
18153
  #rescopeStore(namespace) {
17756
- const store = this.#storage.rescopeStore(namespace);
18154
+ const store = this.#storage.rescopeStore(this.#unwrappedStore(), namespace);
17757
18155
  if (store === null) {
17758
18156
  return;
17759
18157
  }
@@ -17876,8 +18274,17 @@ var AgUiChat = class extends HTMLElement {
17876
18274
  *
17877
18275
  * History is truncated to the most recent user message inclusive and the run
17878
18276
  * repeats, so the agent answers what it was asked rather than being told its
17879
- * last answer was wrong. Returns `false` when there is nothing to retry or a
17880
- * run is already in flight.
18277
+ * last answer was wrong. Returns `false` when there is nothing to retry, or
18278
+ * while anything that refuses a send is in flight: a run, a send or another
18279
+ * retry whose run has not started yet, a checkpoint continuation, or a
18280
+ * reload's restore that will resume a run. Also `false` when host code it
18281
+ * runs before its run starts -- the store's save, or an activity or tool
18282
+ * renderer the replay draws through -- has stopped it by starting a new chat
18283
+ * or changing `user-key`. A tool or context provider that stops it does so
18284
+ * inside the client's resume, past the last point this can see: nothing is
18285
+ * sent, and this resolves `true`, because both a run starting and a Stop let
18286
+ * go of the hold it checks. Rejects with the store's error when the store
18287
+ * refuses the save, and then the conversation is as it was.
17881
18288
  *
17882
18289
  * Public because a host with its own message UI wants the same button, and
17883
18290
  * because the failed-run notice reaches it from outside the action row.
@@ -17888,20 +18295,35 @@ var AgUiChat = class extends HTMLElement {
17888
18295
  * again -- unless the user waived it for this session.
17889
18296
  */
17890
18297
  async retryLastTurn() {
17891
- if (this.#running) {
17892
- return false;
17893
- }
17894
- const client = this.#ensureClient();
17895
- const kept = client.truncateToLastUser();
17896
- if (kept === null) {
18298
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
17897
18299
  return false;
17898
18300
  }
17899
- this.#clearTranscript();
17900
- for (const message of kept) {
17901
- this.#history.replay(message);
18301
+ const hold = {};
18302
+ this.#sending = hold;
18303
+ try {
18304
+ const client = this.#ensureClient();
18305
+ const kept = client.truncateToLastUser();
18306
+ if (kept === null) {
18307
+ return false;
18308
+ }
18309
+ if (this.#sending !== hold) {
18310
+ return false;
18311
+ }
18312
+ this.#clearTranscript();
18313
+ for (const message of kept) {
18314
+ this.#history.replay(message);
18315
+ if (this.#sending !== hold) {
18316
+ return false;
18317
+ }
18318
+ }
18319
+ await client.resume();
18320
+ return true;
18321
+ } finally {
18322
+ if (this.#sending === hold) {
18323
+ this.#sending = null;
18324
+ this.#flushQueued();
18325
+ }
17902
18326
  }
17903
- await client.resume();
17904
- return true;
17905
18327
  }
17906
18328
  /**
17907
18329
  * Append a message bubble and return it.
@@ -18213,6 +18635,9 @@ var AgUiChat = class extends HTMLElement {
18213
18635
  * which is what every shell and every coding agent means by this. Arrowing
18214
18636
  * forward past the newest empties the composer again rather than sticking on
18215
18637
  * it, so the way out is the same key that got you in.
18638
+ *
18639
+ * A walk ends with any write into the composer that is not its own, so that
18640
+ * whatever was written is never replaced by the next step.
18216
18641
  */
18217
18642
  #recallHistory(event) {
18218
18643
  const back = event.key === "ArrowUp";
@@ -18223,6 +18648,9 @@ var AgUiChat = class extends HTMLElement {
18223
18648
  if (drafts.length === 0) {
18224
18649
  return;
18225
18650
  }
18651
+ if (this.#input.value !== this.#recalled) {
18652
+ this.#recallIndex = null;
18653
+ }
18226
18654
  if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
18227
18655
  return;
18228
18656
  }
@@ -18233,6 +18661,7 @@ var AgUiChat = class extends HTMLElement {
18233
18661
  event.preventDefault();
18234
18662
  this.#recallIndex = next < 0 ? null : next;
18235
18663
  this.#input.value = next < 0 ? "" : drafts[next];
18664
+ this.#recalled = this.#input.value;
18236
18665
  this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
18237
18666
  autoGrow(this.#input);
18238
18667
  }
@@ -18243,15 +18672,11 @@ var AgUiChat = class extends HTMLElement {
18243
18672
  * observes the disconnect).
18244
18673
  */
18245
18674
  #cancelRun() {
18246
- for (const text4 of this.#queued) {
18247
- if (this.#sentDrafts[0] !== text4) {
18248
- this.#sentDrafts.unshift(text4);
18249
- }
18250
- }
18251
18675
  this.#queued.length = 0;
18252
18676
  this.#renderQueued();
18253
18677
  this.#decision.abort();
18254
18678
  this.#client?.cancel();
18679
+ this.#sending = null;
18255
18680
  this.#history.stopContinuation();
18256
18681
  }
18257
18682
  /**
@@ -18264,6 +18689,9 @@ var AgUiChat = class extends HTMLElement {
18264
18689
  #setRunning(running) {
18265
18690
  const settled = this.#running && !running;
18266
18691
  this.#running = running;
18692
+ if (running) {
18693
+ this.#sending = null;
18694
+ }
18267
18695
  const label = running ? this.#strings.stop : this.#strings.send;
18268
18696
  this.#send.title = label;
18269
18697
  this.#send.setAttribute("aria-label", label);
@@ -18314,13 +18742,29 @@ var AgUiChat = class extends HTMLElement {
18314
18742
  this.#queuedRow.appendChild(chip);
18315
18743
  }
18316
18744
  }
18745
+ /**
18746
+ * Put a turn that has left the composer at the front of the recall history,
18747
+ * and start the next walk from it.
18748
+ *
18749
+ * Every turn the user typed and pressed a key to send comes through here:
18750
+ * sent at once, queued behind a run, or sent to continue a checkpoint. A
18751
+ * host's own `sendMessage` does not, because its text was never in the
18752
+ * composer, and the arrow keys walk back through what the user wrote there.
18753
+ */
18754
+ #recordTurn(content) {
18755
+ if (content !== "" && this.#sentDrafts[0] !== content) {
18756
+ this.#sentDrafts.unshift(content);
18757
+ }
18758
+ this.#recallIndex = null;
18759
+ }
18317
18760
  async #submit() {
18318
18761
  const content = this.#input.value.trim();
18319
18762
  const attachments = this.#attachments.tray?.readyRefs() ?? [];
18320
18763
  if (content === "" && attachments.length === 0) {
18321
18764
  return;
18322
18765
  }
18323
- if (this.#running || this.#history.continuation !== null) {
18766
+ this.#recordTurn(content);
18767
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
18324
18768
  if (content !== "") {
18325
18769
  this.#queued.push(content);
18326
18770
  this.#renderQueued();
@@ -18329,10 +18773,6 @@ var AgUiChat = class extends HTMLElement {
18329
18773
  }
18330
18774
  return;
18331
18775
  }
18332
- if (content !== "" && this.#sentDrafts[0] !== content) {
18333
- this.#sentDrafts.unshift(content);
18334
- }
18335
- this.#recallIndex = null;
18336
18776
  this.#input.value = "";
18337
18777
  autoGrow(this.#input);
18338
18778
  if (this.#attachments.tray?.hasPending() === true) {
@@ -18360,19 +18800,34 @@ var AgUiChat = class extends HTMLElement {
18360
18800
  *
18361
18801
  * No-ops on an empty message, and while a run or a picked checkpoint's
18362
18802
  * continuation is in flight, since a second concurrent run would orphan the
18363
- * first. Unlike the built-in Send it does not queue: it returns, and the
18364
- * caller keeps what it tried to send. Nor does it consult the tray -- what
18365
- * you pass is what is sent.
18803
+ * first. A send is in flight from the moment it is taken rather than from
18804
+ * its run's start, so this no-ops from a {@link SUBMIT_EVENT} listener and
18805
+ * for a second call in the same task too.
18806
+ * Unlike the built-in Send it does not queue: it returns, and the caller
18807
+ * keeps what it tried to send. Nor does it consult the tray -- what you pass
18808
+ * is what is sent.
18366
18809
  */
18367
18810
  async sendMessage(content, attachments = []) {
18368
- if (this.#running || this.#history.continuation !== null || content === "" && attachments.length === 0) {
18811
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null || content === "" && attachments.length === 0) {
18369
18812
  return;
18370
18813
  }
18371
- if (this.#transcript.isEmpty()) {
18372
- this.setAttribute("data-composer-settling", "");
18814
+ const send = {};
18815
+ this.#sending = send;
18816
+ try {
18817
+ if (this.#transcript.isEmpty()) {
18818
+ this.setAttribute("data-composer-settling", "");
18819
+ }
18820
+ this.#announceTurn(content, attachments);
18821
+ if (this.#sending !== send) {
18822
+ return;
18823
+ }
18824
+ await this.#client_send(content, attachments);
18825
+ } finally {
18826
+ if (this.#sending === send) {
18827
+ this.#sending = null;
18828
+ this.#flushQueued();
18829
+ }
18373
18830
  }
18374
- this.#announceTurn(content, attachments);
18375
- await this.#client_send(content, attachments);
18376
18831
  }
18377
18832
  /**
18378
18833
  * Put a turn the user sent on screen, and tell the host it was sent.
@@ -18472,6 +18927,7 @@ var AgUiChat = class extends HTMLElement {
18472
18927
  */
18473
18928
  #buildClient(seed) {
18474
18929
  const threadId = this.#history.threadId;
18930
+ const tenure = this.#tenure;
18475
18931
  const agent = this.agentFactory({
18476
18932
  endpoint: seed.endpoint,
18477
18933
  headers: this.#requestHeaders(),
@@ -18496,12 +18952,30 @@ var AgUiChat = class extends HTMLElement {
18496
18952
  // list per thread. For the conversation's own client that is its
18497
18953
  // history; a continuation holds only what it adds, and writes the
18498
18954
  // conversation it continues ahead of that.
18955
+ //
18956
+ // Every message list a client saves reaches the store through here -- a
18957
+ // send, a round's end, a tool's result, the answers it writes for calls
18958
+ // left open or declined by a Stop, a Stop's last save -- and so does a
18959
+ // `data-threads-url` store's local copy, which is the only thing that
18960
+ // wrapper does on a save. So this is the one gate for them. `onSaved` is
18961
+ // behind it as well: it hands the saved conversation on as the seed of
18962
+ // the next client, which would be the next principal's.
18499
18963
  onPersist: (messages) => {
18964
+ if (tenure !== this.#tenure) {
18965
+ return;
18966
+ }
18500
18967
  const conversation = [...seed.follows, ...messages];
18501
18968
  this.conversationStore.saveMessages(threadId, conversation);
18502
18969
  seed.onSaved?.(conversation);
18503
18970
  },
18504
- onStateChanged: (state) => this.#onSharedStateChanged(state),
18971
+ // Shared state is the next principal's first `RunAgentInput.state`, and
18972
+ // the handover has just emptied it. A snapshot already read off the wire
18973
+ // is applied after the Stop, and would put it back.
18974
+ onStateChanged: (state) => {
18975
+ if (tenure === this.#tenure) {
18976
+ this.#onSharedStateChanged(state);
18977
+ }
18978
+ },
18505
18979
  connectionLostMessage: this.#strings.connectionLost,
18506
18980
  unfinishedMessage: this.#strings.callNotFinished,
18507
18981
  declinedMessage: this.#strings.declinedAction,
@@ -18663,7 +19137,7 @@ function setControlValue(el2, value) {
18663
19137
  }
18664
19138
 
18665
19139
  // src/version.ts
18666
- var VERSION = "0.41.3";
19140
+ var VERSION = "0.42.0";
18667
19141
  export {
18668
19142
  ATTACHMENT_EVENT,
18669
19143
  AgUiChat,