@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.
@@ -64,6 +64,11 @@ export interface ConversationHistoryHost {
64
64
  * own send does before it starts a run.
65
65
  */
66
66
  readonly announceTurn: (content: string) => void;
67
+ /**
68
+ * Record a turn taken from the composer for arrow-key recall, as the
69
+ * element's own send records one.
70
+ */
71
+ readonly recordTurn: (content: string) => void;
67
72
  /** Resize the composer to its content. */
68
73
  readonly autoGrow: () => void;
69
74
  /**
@@ -91,8 +96,18 @@ export interface ConversationHistoryHost {
91
96
  * client that is not running.
92
97
  */
93
98
  readonly releaseClient: () => void;
94
- /** Whether an interaction is in flight, which the composer owns. */
99
+ /**
100
+ * Whether an interaction is in flight, which the composer owns: from the
101
+ * moment a send or a retry is taken, or a restore that will resume a run
102
+ * starts loading, not only once its run has started.
103
+ */
95
104
  readonly running: () => boolean;
105
+ /**
106
+ * Hold the composer as a send holds it from the moment it is taken: a send
107
+ * refuses, the built-in Send parks its turn, a Retry and a pick refuse. Let
108
+ * go of when the run starts, by a Stop, or by {@link RunHold.release}.
109
+ */
110
+ readonly holdRun: () => RunHold;
96
111
  /** Stop the in-flight run. */
97
112
  readonly cancelRun: () => void;
98
113
  /** Drop the in-memory run and transcript, leaving the thread untouched. */
@@ -101,6 +116,40 @@ export interface ConversationHistoryHost {
101
116
  readonly setRunning: (running: boolean) => void;
102
117
  }
103
118
 
119
+ /**
120
+ * A hold {@link ConversationHistoryHost.holdRun} took.
121
+ *
122
+ * It says nothing about whether the restore that took it was stopped. A
123
+ * restore asks its generation that, because a restore with no run to resume
124
+ * holds nothing and can be stopped all the same -- see
125
+ * {@link ConversationHistory.rehydrate}.
126
+ */
127
+ interface RunHold {
128
+ /**
129
+ * Let go, if nothing else has, and send what the composer parked behind it,
130
+ * because no run is going to settle and send it.
131
+ */
132
+ readonly release: () => void;
133
+ }
134
+
135
+ /**
136
+ * What a restore with no run to resume holds: nothing, so it has nothing to
137
+ * let go of. Every restore awaits the store, the built-in one included, and
138
+ * holding all of them would refuse a host's send made as the element connects.
139
+ * Only a restore that is about to start a run has one to collide with.
140
+ */
141
+ const UNHELD: RunHold = Object.freeze({ release: () => {} });
142
+
143
+ /**
144
+ * The conversation a restore set out to restore: which restore, by its
145
+ * generation, and the store and thread it reads.
146
+ */
147
+ interface RestoreClaim {
148
+ readonly generation: number;
149
+ readonly store: ClientConversationStore;
150
+ readonly threadId: string;
151
+ }
152
+
104
153
  /**
105
154
  * Which conversation is on screen, and how one gets there: restoring it from
106
155
  * the store, switching to another from the conversation list, and continuing
@@ -131,9 +180,20 @@ export class ConversationHistory {
131
180
  * the one it continued is still on screen when it saves.
132
181
  */
133
182
  #cleared = 0;
134
- // Bumped on every rehydrate; a replay whose generation is stale (a newer
135
- // thread switch started while it awaited a slow store) drops its result.
183
+ /**
184
+ * Which restore may draw: bumped by every rehydrate, and by every clear of
185
+ * the conversation on screen. A restore whose generation is stale draws
186
+ * nothing more and resumes nothing: a newer one has started, or New chat, a
187
+ * thread switch or a `user-key` handover has cleared what it was restoring.
188
+ */
136
189
  #generation = 0;
190
+ /**
191
+ * The conversation the latest restore claimed, written by every restore as
192
+ * it starts; `null` before the first. It speaks for the restore now current
193
+ * only while its generation is the current one, because clearing the
194
+ * conversation moves that on. See `#forgetStopped` for the one reader.
195
+ */
196
+ #claim: RestoreClaim | null = null;
137
197
  /** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
138
198
  #runIndex: RunIndex | null = null;
139
199
  /**
@@ -191,6 +251,11 @@ export class ConversationHistory {
191
251
  // The element clears the conversation through here, so a continuation
192
252
  // still saving afterwards learns the conversation is no longer this one.
193
253
  this.#cleared += 1;
254
+ // And so does a restore still loading or replaying it. Only a newer restore
255
+ // used to stop one, so New chat during a slow load drew the conversation
256
+ // being left into the new one once the store answered, and handed it to the
257
+ // new chat's client as the history its first turn would carry.
258
+ this.#generation += 1;
194
259
  }
195
260
 
196
261
  /** Delete the active thread if nothing was ever sent in it. */
@@ -326,10 +391,11 @@ export class ConversationHistory {
326
391
  // end it. Nor is the earlier run cancelled for it: a pick in a panel is not
327
392
  // a Stop, and what is streaming may be the answer the user is waiting on.
328
393
  //
329
- // Both checks, because they see different moments. `running` is the
330
- // composer's own state and spans every round of an interaction, but it
331
- // is set when the run's first event arrives; a continuation is recorded
332
- // here the moment it starts.
394
+ // Both checks, because they see different things. `running` is the
395
+ // composer's own state: it spans every round of an interaction, from the
396
+ // moment a send or a retry is taken rather than from its run's first
397
+ // event, which is a microtask behind it. A continuation is not the
398
+ // composer's, and is recorded here the moment it starts.
333
399
  //
334
400
  // Said at the composer, as an empty composer is below, because the row
335
401
  // closed the panel before this ran. The typed turn stays where it is --
@@ -355,6 +421,12 @@ export class ConversationHistory {
355
421
  this.#refuse(this.#host.strings().continueNeedsTurn);
356
422
  return;
357
423
  }
424
+ // Typed in the composer and sent by a press, so it is recallable as any
425
+ // other turn is. Recorded as it leaves the box, where the element's own
426
+ // send records one, rather than once it is sent: the box is empty from
427
+ // here, and a continuation that fails before its run starts has still
428
+ // taken the turn out of it.
429
+ this.#host.recordTurn(content);
358
430
  this.#host.input.value = "";
359
431
  this.#host.autoGrow();
360
432
  const cleared = this.#cleared;
@@ -480,62 +552,249 @@ export class ConversationHistory {
480
552
  * because the resume path answers it from the page the reload landed on; that
481
553
  * exclusion is held by "resumes with the landed page's result, and its card says
482
554
  * so" in `ag_ui_chat_reload_mid_run.test.ts`.
555
+ *
556
+ * **A restore that resumes holds the composer from before the load.** The
557
+ * resume is a run, and it starts only once the store has answered. With a
558
+ * remote store that is a real request, and a send, a Retry or a pick in it
559
+ * was refused by nothing: each built the conversation's client before the
560
+ * conversation had loaded, so a send went out carrying none of it and its
561
+ * first save replaced the stored one, and the resume then ran on that same
562
+ * client as a second run, answering a call the request no longer held. So
563
+ * the composer is held as a send holds it, and the resume gives way to
564
+ * nothing: what was typed parks behind the resumed run and goes out after its
565
+ * answer, and a host's send or Retry is refused as it is behind any run.
566
+ *
567
+ * The other way round, the resume standing down for the send, was rejected.
568
+ * The send that got in first had already gone out without the conversation,
569
+ * so standing down kept that loss, and it also dropped the run the navigation
570
+ * was part of, which the user asked for before the reload: the agent stopped
571
+ * halfway through the task, with the navigating call never answered.
572
+ *
573
+ * **A restore stops where it was stopped, held or not.** New chat, a thread
574
+ * switch, a `user-key` handover, a newer restore or the element leaving the
575
+ * page can each come while the store answers, and from host code the restore
576
+ * runs itself: the activity and tool renderers each replayed message draws
577
+ * through, and `navigationResult`. From then on it draws nothing more and
578
+ * resumes nothing. The hold cannot say so, because a restore with no run to
579
+ * resume holds nothing. So the restore asks its generation, which clearing
580
+ * the conversation moves on as a newer restore does, and asks it after the
581
+ * load, after each replayed message -- as a Retry asks after each one -- and
582
+ * once more before the resume. Stopped by anything but the element leaving
583
+ * the page, it also forgets the checkpoint it loaded, since that was a Stop
584
+ * for the run it would have resumed -- unless what replaced it is a restore
585
+ * of the same conversation, still current, which answers for that run
586
+ * instead; see `#forgetStopped`.
587
+ *
588
+ * What a restore with no run to resume still does not do is refuse a send
589
+ * made while it loads. That send goes out without the stored conversation,
590
+ * and its save replaces it.
483
591
  */
484
592
  async rehydrate(): Promise<void> {
485
- // Guard against a thread-switch race: with a slow remote store, picking
486
- // thread B then C would interleave both replays into one transcript. Each
487
- // rehydrate claims a generation before awaiting and bails if a newer one
488
- // started meanwhile (its reset already cleared the transcript).
593
+ // Claimed before awaiting, and checked by `#restoring` wherever the restore
594
+ // has waited or run host code. With a slow remote store, picking thread B
595
+ // then C interleaved both replays into one transcript, and New chat drew
596
+ // the conversation being left into the new one.
489
597
  this.#generation += 1;
490
598
  const generation = this.#generation;
491
- // Held while the store answers. A remote store answers after first paint,
492
- // and a conversation it is still fetching is more likely to have messages
493
- // than not, so without this the page would paint the greeting and a centred
494
- // composer and then drop the composer the moment they land. The built-in
495
- // store answers in a microtask, before paint, so for it this never reaches
496
- // the screen. Released in `finally` so a store that rejects cannot leave the
497
- // layout held for good, and only by the restore that is still current.
498
- this.#host.element.setAttribute("data-restoring", "");
499
- let messages: readonly Message[] | null;
599
+ // Read before the load rather than after it, because a checkpoint means
600
+ // this restore ends by starting a run, and the window a send could start a
601
+ // second one in is the load, not the resume -- see "A restore that resumes"
602
+ // above. The store's checkpoint is synchronous and the built-in remote store
603
+ // keeps it in this tab, so nothing the load fetches can change it. And
604
+ // before the replay, which has to know the one unanswered call a reload was
605
+ // *expected* by, so as not to settle it as abandoned.
606
+ //
607
+ // The thread and the store are taken once, here, because a restore that is
608
+ // stopped forgets its checkpoint where it read it: by then the active
609
+ // thread can be a new chat's, and the store scoped to the next principal.
610
+ const threadId = this.#threadId;
611
+ const store = this.#host.conversationStore();
612
+ // Claimed with the generation, store and thread just taken, whether or not
613
+ // there is a checkpoint: a restore of the same conversation that finds none
614
+ // has found it resumed or forgotten already, so an older one has nothing
615
+ // left there to forget. Claiming only with a checkpoint would differ only
616
+ // where one was written after this restore started, which an older restore
617
+ // should not wipe either, so no test tells the two apart. See
618
+ // `#claimedByCurrent`.
619
+ this.#claim = { generation, store, threadId };
620
+ const checkpoint = store.loadCheckpoint(threadId);
621
+ const hold = checkpoint === null ? UNHELD : this.#host.holdRun();
500
622
  try {
501
- messages = await this.#host.conversationStore().loadMessages(this.#threadId);
502
- } finally {
503
- if (generation === this.#generation) {
504
- this.#host.element.removeAttribute("data-restoring");
623
+ // Held while the store answers. A remote store answers after first paint,
624
+ // and a conversation it is still fetching is more likely to have messages
625
+ // than not, so without this the page would paint the greeting and a centred
626
+ // composer and then drop the composer the moment they land. The built-in
627
+ // store answers in a microtask, before paint, so for it this never reaches
628
+ // the screen. Released in `finally` so a store that rejects cannot leave the
629
+ // layout held for good, and only by the restore that is still current.
630
+ this.#host.element.setAttribute("data-restoring", "");
631
+ let messages: readonly Message[] | null;
632
+ try {
633
+ messages = await store.loadMessages(threadId);
634
+ } finally {
635
+ if (generation === this.#generation) {
636
+ this.#host.element.removeAttribute("data-restoring");
637
+ }
505
638
  }
506
- }
507
- if (generation !== this.#generation) {
508
- return;
509
- }
510
- // Read before the replay rather than after it: the checkpointed call is the
511
- // one unanswered call a reload was *expected* by, and the replay has to know
512
- // which it is so as not to settle it as abandoned.
513
- const checkpoint = this.#host.conversationStore().loadCheckpoint(this.#threadId);
514
- if (messages !== null) {
515
- const unfinished = this.#host.strings().callNotFinished;
516
- const restored = answerUnansweredCalls(
517
- messages,
518
- // The shape the store holds for a call the client answered the same way:
519
- // its tool message, with the outcome in its metadata.
520
- (toolCallId) => ({
521
- id: randomUUID(),
522
- role: "tool",
523
- content: unfinished,
524
- toolCallId,
525
- metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
526
- }),
527
- new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
528
- );
529
- this.#restored = restored;
530
- for (const message of restored) {
531
- this.replay(message);
639
+ // Stopped while the store answered. Going on drew the conversation being
640
+ // left into the chat that replaced it, and resumed its run there, on a
641
+ // client seeded from what the restore had just drawn.
642
+ if (!this.#restoring(generation)) {
643
+ this.#forgetStopped(store, threadId, checkpoint);
644
+ return;
645
+ }
646
+ if (messages !== null) {
647
+ const unfinished = this.#host.strings().callNotFinished;
648
+ const restored = answerUnansweredCalls(
649
+ messages,
650
+ // The shape the store holds for a call the client answered the same
651
+ // way: its tool message, with the outcome in its metadata.
652
+ (toolCallId) => ({
653
+ id: randomUUID(),
654
+ role: "tool",
655
+ content: unfinished,
656
+ toolCallId,
657
+ metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
658
+ }),
659
+ new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
660
+ );
661
+ this.#restored = restored;
662
+ for (const message of restored) {
663
+ this.replay(message);
664
+ // A replay draws through the host's activity and tool renderers,
665
+ // which can stop the restore as well as anything outside it can.
666
+ // Checked after each one, as a Retry checks, so the rest of the
667
+ // conversation being left is not drawn into the chat that replaced it,
668
+ // and after the last, so its run is not resumed there.
669
+ if (!this.#restoring(generation)) {
670
+ this.#forgetStopped(store, threadId, checkpoint);
671
+ return;
672
+ }
673
+ }
532
674
  }
675
+ if (checkpoint !== null) {
676
+ await this.#resumeFrom(checkpoint, generation);
677
+ return;
678
+ }
679
+ this.#noticeIfRunUnfinished(messages);
680
+ } finally {
681
+ // The resumed run starting has let go of the hold already, as a send's
682
+ // does, and a request that then fails still started one: the client
683
+ // reports a run before it sends. Still held here only if none started --
684
+ // the store rejected the load, or the store or the host threw on the way
685
+ // to the resume -- and then this is what sends a turn the composer parked
686
+ // meanwhile, since no run is going to settle and send it.
687
+ hold.release();
533
688
  }
534
- if (checkpoint !== null) {
535
- await this.#resumeFrom(checkpoint);
536
- return;
689
+ }
690
+
691
+ /**
692
+ * Whether the restore that claimed `generation` may go on: nothing has
693
+ * replaced or cleared the conversation it is restoring, and the element is
694
+ * still on the page.
695
+ *
696
+ * On the page, because leaving it is the one stop that clears nothing: the
697
+ * element is not reset until it connects again, and connecting restores the
698
+ * conversation afresh. A restore with nothing to resume went on regardless,
699
+ * drawing into a node that had gone and running the host's renderers for it.
700
+ * One that resumes learnt it from the Stop letting go of its hold; nothing
701
+ * asks the hold that now, so this is what keeps the resume's request in.
702
+ *
703
+ * One `&&`, so coverage cannot tell whether either half is held. Removing the
704
+ * generation fails "draws nothing into a new chat started while it loads" in
705
+ * `conversation_history.test.ts`, and "drops a stale thread replay when a
706
+ * newer switch started" in `ag_ui_chat.test.ts`; removing the connection
707
+ * fails "draws nothing into an element that left the page while it loads".
708
+ */
709
+ #restoring(generation: number): boolean {
710
+ return generation === this.#generation && this.#host.element.isConnected;
711
+ }
712
+
713
+ /**
714
+ * Forget the checkpoint a stopped restore loaded, when what stopped it also
715
+ * stopped the run it was about to resume.
716
+ *
717
+ * New chat, a thread switch, a `user-key` handover and a newer restore all
718
+ * move the generation, and each is a Stop for that run. Left in the store,
719
+ * the checkpoint outlived the Stop: coming back to the conversation later
720
+ * resumed the navigating call with whatever page was current by then.
721
+ * Forgotten, the call is answered as not finished there, as any stopped call
722
+ * is.
723
+ *
724
+ * Not while the restore now current claimed the same conversation, which it
725
+ * has in two cases. It is this restore, when only the element left the page,
726
+ * which moves no generation: a move or a framework re-render is not a
727
+ * farewell, and the restore connecting starts has to find the checkpoint to
728
+ * resume from. Or it is a newer restore of the same thread in the same store
729
+ * -- `reload()`, switching away and back, or the element connecting again --
730
+ * which read the checkpoint as it started, or found it gone, and answers for
731
+ * it from then on: its resume clears it, and when it is stopped in turn this
732
+ * same rule decides.
733
+ *
734
+ * The newer restore's case used to forget it too, on the grounds that the
735
+ * newer one had read it and would resume once from that copy. It did until
736
+ * the element left the page while the newer one loaded: that one stood down
737
+ * keeping the checkpoint for the reconnect, as a departure should, but the
738
+ * store no longer held it, so the element came back with no run to resume.
739
+ *
740
+ * Written to the store and the thread it was read from. After a handover
741
+ * that is the previous principal's: the built-in store's clear is a removal
742
+ * from a namespace the handover has already purged, so it changes nothing,
743
+ * and nothing reaches the next principal's. A host's store is the same object
744
+ * either side of a handover; one the host replaced is held by "still refuses
745
+ * a send for the newer restore once the older has stood down".
746
+ *
747
+ * Two conditions. Clearing with no checkpoint fails "draws nothing into a new
748
+ * chat started while it loads", which runs no store code for one. Clearing
749
+ * what the current restore claimed fails "resumes nothing once the element
750
+ * leaves the page while the store answers", where the claim is this
751
+ * restore's own and the re-inserted element resumes, and "resumes once the
752
+ * element is back when the host reloads and it then leaves the page", where
753
+ * it is the reload's. The claim's own three are named on `#claimedByCurrent`.
754
+ */
755
+ #forgetStopped(
756
+ store: ClientConversationStore,
757
+ threadId: string,
758
+ checkpoint: NavigationCheckpoint | null,
759
+ ): void {
760
+ if (checkpoint !== null && !this.#claimedByCurrent(store, threadId)) {
761
+ store.saveCheckpoint(threadId, null);
537
762
  }
538
- this.#noticeIfRunUnfinished(messages);
763
+ }
764
+
765
+ /**
766
+ * Whether the restore now current claimed the conversation a stopped restore
767
+ * read its checkpoint from: the same thread, in the same store.
768
+ *
769
+ * A claim from an earlier generation speaks for nobody: the conversation was
770
+ * cleared after it was made, so whatever it would have resumed was stopped,
771
+ * and that holds of a stopped restore's own claim as much as of any other.
772
+ * The store is compared as an object, because a store the host assigned
773
+ * since is a different record of the conversation, and the one the
774
+ * checkpoint was read from still has to forget it.
775
+ *
776
+ * As an object, a store the element builds for itself -- the built-in one
777
+ * under a namespace, and the remote one `data-threads-url` wraps around it
778
+ * -- is a different one after a reconnect, which builds it afresh over the
779
+ * same storage, so a restore from before the reconnect forgets the
780
+ * checkpoint there once it answers. The reconnect's own restore read it as
781
+ * it started and resumes from that copy, as a newer restore always did here;
782
+ * what that copy cannot survive is the element leaving again before the
783
+ * load answers.
784
+ *
785
+ * One `&&` of three, so coverage cannot tell whether any is held. Dropping
786
+ * the generation fails "forgets the checkpoint when New chat stops the
787
+ * resume, so the call ends as not finished", where the claim is the stopped
788
+ * restore's own; dropping the store fails "still refuses a send for the newer
789
+ * restore once the older has stood down", whose host replaces the store
790
+ * between the two; dropping the thread fails "forgets the checkpoint of a
791
+ * thread switched away from, though the next has one of its own".
792
+ */
793
+ #claimedByCurrent(store: ClientConversationStore, threadId: string): boolean {
794
+ const claim = this.#claim;
795
+ return (
796
+ claim?.generation === this.#generation && claim.store === store && claim.threadId === threadId
797
+ );
539
798
  }
540
799
 
541
800
  /**
@@ -696,13 +955,21 @@ export class ConversationHistory {
696
955
  * guard is held by "resumes a checkpointed navigating tool call on mount" in
697
956
  * `ag_ui_chat.test.ts`, whose transcript never made the call.
698
957
  */
699
- async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
958
+ async #resumeFrom(checkpoint: NavigationCheckpoint, generation: number): Promise<void> {
700
959
  this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
701
960
  const client = this.#host.ensureClient();
702
961
  // Called on the element, as `this.navigationResult(...)` always was.
703
962
  const landed = JSON.stringify(
704
963
  this.#host.navigationResult().call(this.#host.element, checkpoint),
705
964
  );
965
+ // The store's save and `navigationResult` are host code, and either can
966
+ // have stopped the restore. Going on answered the call and resumed anyway:
967
+ // on the client taken before `navigationResult` ran, which nothing held any
968
+ // more, with its tools still driving the page -- or, stopped by the save, on
969
+ // the new chat's own client, which the line above had just built.
970
+ if (!this.#restoring(generation)) {
971
+ return;
972
+ }
706
973
  client.addToolResult(checkpoint.toolCallId, landed);
707
974
  this.#host.transcript.card(checkpoint.toolCallId)?.settle(TOOL_CALL_STATUS.DONE, landed);
708
975
  await client.resume();
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.41.3";
1
+ export const VERSION: string = "0.42.0";