@artooi/ag-ui-web-component 0.41.3 → 0.43.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/CHANGELOG.md +304 -1
- package/README.md +113 -24
- package/dist/ag-ui-web-component.bundle.js +33 -33
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/constants.d.ts +3 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +49 -9
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +20 -7
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/storage_scope.d.ts +14 -4
- package/dist/core/storage_scope.d.ts.map +1 -1
- package/dist/index.js +729 -115
- package/dist/index.js.map +3 -3
- package/dist/tools/tool_dispatch.d.ts +23 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -1
- package/dist/ui/composer/voice_input.d.ts +10 -4
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/history/conversation_history.d.ts +84 -1
- package/dist/ui/history/conversation_history.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +5 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +3 -1
- package/src/core/ag_ui_chat.ts +498 -70
- package/src/core/agui_client.ts +55 -12
- package/src/core/storage_scope.ts +21 -8
- package/src/tools/tool_dispatch.ts +153 -6
- package/src/ui/composer/voice_input.ts +79 -14
- package/src/ui/history/conversation_history.ts +392 -56
- package/src/ui/ui_strings.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -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,23 @@ export interface ConversationHistoryHost {
|
|
|
91
96
|
* client that is not running.
|
|
92
97
|
*/
|
|
93
98
|
readonly releaseClient: () => void;
|
|
94
|
-
/**
|
|
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 starts loading, not only
|
|
102
|
+
* 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: the
|
|
107
|
+
* built-in Send parks its turn, a Retry and a pick refuse. Let go of when the
|
|
108
|
+
* run starts, by a Stop, or by {@link RunHold.release}.
|
|
109
|
+
*
|
|
110
|
+
* A host's send refuses too, unless `restored` is given: then the hold is a
|
|
111
|
+
* restore's with no run to resume, and a host's send waits for `restored` to
|
|
112
|
+
* settle and sends after it, with the conversation drawn. `restored` settles
|
|
113
|
+
* once that restore has ended, or has been stopped, and never rejects.
|
|
114
|
+
*/
|
|
115
|
+
readonly holdRun: (restored: Promise<void> | null) => RunHold;
|
|
96
116
|
/** Stop the in-flight run. */
|
|
97
117
|
readonly cancelRun: () => void;
|
|
98
118
|
/** Drop the in-memory run and transcript, leaving the thread untouched. */
|
|
@@ -101,6 +121,33 @@ export interface ConversationHistoryHost {
|
|
|
101
121
|
readonly setRunning: (running: boolean) => void;
|
|
102
122
|
}
|
|
103
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A hold {@link ConversationHistoryHost.holdRun} took.
|
|
126
|
+
*
|
|
127
|
+
* It says nothing about whether the restore that took it was stopped. A
|
|
128
|
+
* restore asks its generation that, because a host's send that waited on a
|
|
129
|
+
* restore with no run to resume takes the hold over from it, and a restore
|
|
130
|
+
* stopped by a newer one finds that one's hold in its place -- see
|
|
131
|
+
* {@link ConversationHistory.rehydrate}.
|
|
132
|
+
*/
|
|
133
|
+
interface RunHold {
|
|
134
|
+
/**
|
|
135
|
+
* Let go, if nothing else has, and send what the composer parked behind it,
|
|
136
|
+
* because no run is going to settle and send it.
|
|
137
|
+
*/
|
|
138
|
+
readonly release: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The conversation a restore set out to restore: which restore, by its
|
|
143
|
+
* generation, and the store and thread it reads.
|
|
144
|
+
*/
|
|
145
|
+
interface RestoreClaim {
|
|
146
|
+
readonly generation: number;
|
|
147
|
+
readonly store: ClientConversationStore;
|
|
148
|
+
readonly threadId: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
104
151
|
/**
|
|
105
152
|
* Which conversation is on screen, and how one gets there: restoring it from
|
|
106
153
|
* the store, switching to another from the conversation list, and continuing
|
|
@@ -131,9 +178,20 @@ export class ConversationHistory {
|
|
|
131
178
|
* the one it continued is still on screen when it saves.
|
|
132
179
|
*/
|
|
133
180
|
#cleared = 0;
|
|
134
|
-
|
|
135
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Which restore may draw: bumped by every rehydrate, and by every clear of
|
|
183
|
+
* the conversation on screen. A restore whose generation is stale draws
|
|
184
|
+
* nothing more and resumes nothing: a newer one has started, or New chat, a
|
|
185
|
+
* thread switch or a `user-key` handover has cleared what it was restoring.
|
|
186
|
+
*/
|
|
136
187
|
#generation = 0;
|
|
188
|
+
/**
|
|
189
|
+
* The conversation the latest restore claimed, written by every restore as
|
|
190
|
+
* it starts; `null` before the first. It speaks for the restore now current
|
|
191
|
+
* only while its generation is the current one, because clearing the
|
|
192
|
+
* conversation moves that on. See `#forgetStopped` for the one reader.
|
|
193
|
+
*/
|
|
194
|
+
#claim: RestoreClaim | null = null;
|
|
137
195
|
/** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
|
|
138
196
|
#runIndex: RunIndex | null = null;
|
|
139
197
|
/**
|
|
@@ -141,6 +199,17 @@ export class ConversationHistory {
|
|
|
141
199
|
* reaches it. `null` when none is running.
|
|
142
200
|
*/
|
|
143
201
|
#continuation: AgUiClient | null = null;
|
|
202
|
+
/**
|
|
203
|
+
* Settles what a host's send waits on while the restore now current, one
|
|
204
|
+
* with no run to resume, loads and replays; `null` when no such restore is
|
|
205
|
+
* under way. Called when that restore ends, and as soon as it is stopped by
|
|
206
|
+
* anything that moves the generation, so a send waiting on a conversation
|
|
207
|
+
* that is no longer on screen hears so at once rather than when an
|
|
208
|
+
* abandoned load answers.
|
|
209
|
+
*
|
|
210
|
+
* Also what tells a pick refused for a load from one refused for a run.
|
|
211
|
+
*/
|
|
212
|
+
#loading: (() => void) | null = null;
|
|
144
213
|
|
|
145
214
|
constructor(host: ConversationHistoryHost) {
|
|
146
215
|
this.#host = host;
|
|
@@ -191,6 +260,24 @@ export class ConversationHistory {
|
|
|
191
260
|
// The element clears the conversation through here, so a continuation
|
|
192
261
|
// still saving afterwards learns the conversation is no longer this one.
|
|
193
262
|
this.#cleared += 1;
|
|
263
|
+
// And so does a restore still loading or replaying it. Only a newer restore
|
|
264
|
+
// used to stop one, so New chat during a slow load drew the conversation
|
|
265
|
+
// being left into the new one once the store answered, and handed it to the
|
|
266
|
+
// new chat's client as the history its first turn would carry.
|
|
267
|
+
this.#generation += 1;
|
|
268
|
+
// A host's send waiting on that restore is addressed to the conversation
|
|
269
|
+
// just cleared. The element's Stop has let go of it already; this wakes it
|
|
270
|
+
// to find so, rather than leaving it waiting on a load nobody wants -- for
|
|
271
|
+
// good, if that load never answers. The drop tests in
|
|
272
|
+
// `ag_ui_chat_send_during_restore.test.ts` hang without it.
|
|
273
|
+
this.#endWait();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Settle what waits on the restore now loading, if anything does, and forget it. */
|
|
277
|
+
#endWait(): void {
|
|
278
|
+
const end = this.#loading;
|
|
279
|
+
this.#loading = null;
|
|
280
|
+
end?.();
|
|
194
281
|
}
|
|
195
282
|
|
|
196
283
|
/** Delete the active thread if nothing was ever sent in it. */
|
|
@@ -326,16 +413,25 @@ export class ConversationHistory {
|
|
|
326
413
|
// end it. Nor is the earlier run cancelled for it: a pick in a panel is not
|
|
327
414
|
// a Stop, and what is streaming may be the answer the user is waiting on.
|
|
328
415
|
//
|
|
329
|
-
// Both checks, because they see different
|
|
330
|
-
// composer's own state
|
|
331
|
-
//
|
|
332
|
-
//
|
|
416
|
+
// Both checks, because they see different things. `running` is the
|
|
417
|
+
// composer's own state: it spans every round of an interaction, from the
|
|
418
|
+
// moment a send or a retry is taken rather than from its run's first
|
|
419
|
+
// event, which is a microtask behind it. A continuation is not the
|
|
420
|
+
// composer's, and is recorded here the moment it starts.
|
|
333
421
|
//
|
|
334
422
|
// Said at the composer, as an empty composer is below, because the row
|
|
335
423
|
// closed the panel before this ran. The typed turn stays where it is --
|
|
336
424
|
// it is what the user wants sent once the run is done -- and the caret
|
|
337
425
|
// goes back to it, where Escape stops the run.
|
|
338
|
-
|
|
426
|
+
//
|
|
427
|
+
// A restore with no run to resume holds the composer too, and there is
|
|
428
|
+
// no answer to wait for and nothing Escape can stop, so it says the
|
|
429
|
+
// conversation is still loading instead. A restore that will resume a
|
|
430
|
+
// run keeps the running wording: what it is loading is that run.
|
|
431
|
+
const strings = this.#host.strings();
|
|
432
|
+
this.#refuse(
|
|
433
|
+
this.#loading === null ? strings.continueWhileRunning : strings.continueWhileLoading,
|
|
434
|
+
);
|
|
339
435
|
return;
|
|
340
436
|
}
|
|
341
437
|
const content = this.#host.input.value.trim();
|
|
@@ -355,6 +451,12 @@ export class ConversationHistory {
|
|
|
355
451
|
this.#refuse(this.#host.strings().continueNeedsTurn);
|
|
356
452
|
return;
|
|
357
453
|
}
|
|
454
|
+
// Typed in the composer and sent by a press, so it is recallable as any
|
|
455
|
+
// other turn is. Recorded as it leaves the box, where the element's own
|
|
456
|
+
// send records one, rather than once it is sent: the box is empty from
|
|
457
|
+
// here, and a continuation that fails before its run starts has still
|
|
458
|
+
// taken the turn out of it.
|
|
459
|
+
this.#host.recordTurn(content);
|
|
358
460
|
this.#host.input.value = "";
|
|
359
461
|
this.#host.autoGrow();
|
|
360
462
|
const cleared = this.#cleared;
|
|
@@ -480,62 +582,288 @@ export class ConversationHistory {
|
|
|
480
582
|
* because the resume path answers it from the page the reload landed on; that
|
|
481
583
|
* exclusion is held by "resumes with the landed page's result, and its card says
|
|
482
584
|
* so" in `ag_ui_chat_reload_mid_run.test.ts`.
|
|
585
|
+
*
|
|
586
|
+
* **Every restore holds the composer from before the load.** Until the
|
|
587
|
+
* store answers, the conversation's client can only be seeded from nothing.
|
|
588
|
+
* With a remote store that is a real request, and a send, a Retry or a pick
|
|
589
|
+
* in it was refused by nothing: each built the conversation's client before
|
|
590
|
+
* the conversation had loaded, so a send went out carrying none of it, its
|
|
591
|
+
* first save replaced the stored one, and the replay then drew the stored
|
|
592
|
+
* conversation under the new turn. The client stayed cached without it, so
|
|
593
|
+
* every later turn went out without it too. So the composer is held as a
|
|
594
|
+
* send holds it: what was typed parks and goes out after the replay, and a
|
|
595
|
+
* Retry and a pick are refused -- a Retry has nothing on screen to retry, and
|
|
596
|
+
* after the load it would re-ask the previous visit's last question.
|
|
597
|
+
*
|
|
598
|
+
* **A host's send waits for a restore with no run to resume.** It is
|
|
599
|
+
* usually a prompt fired as the element mounts, which no host could time
|
|
600
|
+
* against a load nothing announces, and refusing it would lose the turn.
|
|
601
|
+
* So it waits for this restore to end, taking the hold over as it does,
|
|
602
|
+
* and then sends with the conversation drawn above it; what the built-in
|
|
603
|
+
* Send parked goes out after its run. Stopped while it waits, it is dropped,
|
|
604
|
+
* as a send stopped by its own submit listener is.
|
|
605
|
+
*
|
|
606
|
+
* **A restore that resumes refuses a host's send instead.** The resume is a
|
|
607
|
+
* run, and the restore ends by starting it, so a send that waited would only
|
|
608
|
+
* reach that run and be refused there. The resume gives way to nothing: what
|
|
609
|
+
* was typed parks behind the resumed run and goes out after its answer.
|
|
610
|
+
*
|
|
611
|
+
* The other way round, the resume standing down for the send, was rejected.
|
|
612
|
+
* The send that got in first had already gone out without the conversation,
|
|
613
|
+
* so standing down kept that loss, and it also dropped the run the navigation
|
|
614
|
+
* was part of, which the user asked for before the reload: the agent stopped
|
|
615
|
+
* halfway through the task, with the navigating call never answered.
|
|
616
|
+
*
|
|
617
|
+
* **A restore stops where it was stopped, held or not.** New chat, a thread
|
|
618
|
+
* switch, a `user-key` handover, a newer restore or the element leaving the
|
|
619
|
+
* page can each come while the store answers, and from host code the restore
|
|
620
|
+
* runs itself: the activity and tool renderers each replayed message draws
|
|
621
|
+
* through, and `navigationResult`. From then on it draws nothing more and
|
|
622
|
+
* resumes nothing. The hold cannot say so, because a host's send that
|
|
623
|
+
* waited may hold it by then. So the restore asks its generation, which clearing
|
|
624
|
+
* the conversation moves on as a newer restore does, and asks it after the
|
|
625
|
+
* load, after each replayed message -- as a Retry asks after each one -- and
|
|
626
|
+
* once more before the resume. Stopped by anything but the element leaving
|
|
627
|
+
* the page, it also forgets the checkpoint it loaded, since that was a Stop
|
|
628
|
+
* for the run it would have resumed -- unless what replaced it is a restore
|
|
629
|
+
* of the same conversation, still current, which answers for that run
|
|
630
|
+
* instead; see `#forgetStopped`.
|
|
483
631
|
*/
|
|
484
632
|
async rehydrate(): Promise<void> {
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
633
|
+
// Claimed before awaiting, and checked by `#restoring` wherever the restore
|
|
634
|
+
// has waited or run host code. With a slow remote store, picking thread B
|
|
635
|
+
// then C interleaved both replays into one transcript, and New chat drew
|
|
636
|
+
// the conversation being left into the new one.
|
|
489
637
|
this.#generation += 1;
|
|
490
638
|
const generation = this.#generation;
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
// the
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
639
|
+
// Read before the load rather than after it, because a checkpoint means
|
|
640
|
+
// this restore ends by starting a run, and the window a send could start a
|
|
641
|
+
// second one in is the load, not the resume -- see "A restore that resumes"
|
|
642
|
+
// above. The store's checkpoint is synchronous and the built-in remote store
|
|
643
|
+
// keeps it in this tab, so nothing the load fetches can change it. And
|
|
644
|
+
// before the replay, which has to know the one unanswered call a reload was
|
|
645
|
+
// *expected* by, so as not to settle it as abandoned.
|
|
646
|
+
//
|
|
647
|
+
// The thread and the store are taken once, here, because a restore that is
|
|
648
|
+
// stopped forgets its checkpoint where it read it: by then the active
|
|
649
|
+
// thread can be a new chat's, and the store scoped to the next principal.
|
|
650
|
+
const threadId = this.#threadId;
|
|
651
|
+
const store = this.#host.conversationStore();
|
|
652
|
+
// Claimed with the generation, store and thread just taken, whether or not
|
|
653
|
+
// there is a checkpoint: a restore of the same conversation that finds none
|
|
654
|
+
// has found it resumed or forgotten already, so an older one has nothing
|
|
655
|
+
// left there to forget. Claiming only with a checkpoint would differ only
|
|
656
|
+
// where one was written after this restore started, which an older restore
|
|
657
|
+
// should not wipe either, so no test tells the two apart. See
|
|
658
|
+
// `#claimedByCurrent`.
|
|
659
|
+
this.#claim = { generation, store, threadId };
|
|
660
|
+
const checkpoint = store.loadCheckpoint(threadId);
|
|
661
|
+
// What a host's send waits on, for a restore with no run to resume, and
|
|
662
|
+
// the hold either kind takes. Taken before the load for the same reason
|
|
663
|
+
// the checkpoint is read before it: the load is the window.
|
|
664
|
+
//
|
|
665
|
+
// No earlier wait is left to settle here: every restore but the first
|
|
666
|
+
// follows a clear of the conversation on screen -- a reload, a thread
|
|
667
|
+
// switch, a handover, the element coming back -- and `forgetRestored`
|
|
668
|
+
// settled and forgot it then. So a restore that will resume finds none.
|
|
669
|
+
const restored =
|
|
670
|
+
checkpoint === null
|
|
671
|
+
? new Promise<void>((resolve) => {
|
|
672
|
+
this.#loading = resolve;
|
|
673
|
+
})
|
|
674
|
+
: null;
|
|
675
|
+
const waiting = this.#loading;
|
|
676
|
+
const hold = this.#host.holdRun(restored);
|
|
500
677
|
try {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
678
|
+
// Held while the store answers. A remote store answers after first paint,
|
|
679
|
+
// and a conversation it is still fetching is more likely to have messages
|
|
680
|
+
// than not, so without this the page would paint the greeting and a centred
|
|
681
|
+
// composer and then drop the composer the moment they land. The built-in
|
|
682
|
+
// store answers in a microtask, before paint, so for it this never reaches
|
|
683
|
+
// the screen. Released in `finally` so a store that rejects cannot leave the
|
|
684
|
+
// layout held for good, and only by the restore that is still current.
|
|
685
|
+
this.#host.element.setAttribute("data-restoring", "");
|
|
686
|
+
let messages: readonly Message[] | null;
|
|
687
|
+
try {
|
|
688
|
+
messages = await store.loadMessages(threadId);
|
|
689
|
+
} finally {
|
|
690
|
+
if (generation === this.#generation) {
|
|
691
|
+
this.#host.element.removeAttribute("data-restoring");
|
|
692
|
+
}
|
|
505
693
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
694
|
+
// Stopped while the store answered. Going on drew the conversation being
|
|
695
|
+
// left into the chat that replaced it, and resumed its run there, on a
|
|
696
|
+
// client seeded from what the restore had just drawn.
|
|
697
|
+
if (!this.#restoring(generation)) {
|
|
698
|
+
this.#forgetStopped(store, threadId, checkpoint);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (messages !== null) {
|
|
702
|
+
const unfinished = this.#host.strings().callNotFinished;
|
|
703
|
+
const restored = answerUnansweredCalls(
|
|
704
|
+
messages,
|
|
705
|
+
// The shape the store holds for a call the client answered the same
|
|
706
|
+
// way: its tool message, with the outcome in its metadata.
|
|
707
|
+
(toolCallId) => ({
|
|
708
|
+
id: randomUUID(),
|
|
709
|
+
role: "tool",
|
|
710
|
+
content: unfinished,
|
|
711
|
+
toolCallId,
|
|
712
|
+
metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
|
|
713
|
+
}),
|
|
714
|
+
new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
|
|
715
|
+
);
|
|
716
|
+
this.#restored = restored;
|
|
717
|
+
for (const message of restored) {
|
|
718
|
+
this.replay(message);
|
|
719
|
+
// A replay draws through the host's activity and tool renderers,
|
|
720
|
+
// which can stop the restore as well as anything outside it can.
|
|
721
|
+
// Checked after each one, as a Retry checks, so the rest of the
|
|
722
|
+
// conversation being left is not drawn into the chat that replaced it,
|
|
723
|
+
// and after the last, so its run is not resumed there.
|
|
724
|
+
if (!this.#restoring(generation)) {
|
|
725
|
+
this.#forgetStopped(store, threadId, checkpoint);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (checkpoint !== null) {
|
|
731
|
+
await this.#resumeFrom(checkpoint, generation);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.#noticeIfRunUnfinished(messages);
|
|
735
|
+
} finally {
|
|
736
|
+
// The resumed run starting has let go of the hold already, as a send's
|
|
737
|
+
// does, and a request that then fails still started one: the client
|
|
738
|
+
// reports a run before it sends. Still held here only if none started --
|
|
739
|
+
// the store rejected the load, or the store or the host threw on the way
|
|
740
|
+
// to the resume -- and then this is what sends a turn the composer parked
|
|
741
|
+
// meanwhile, since no run is going to settle and send it.
|
|
742
|
+
//
|
|
743
|
+
// A host's send that waited on this restore has taken the hold over, so
|
|
744
|
+
// the release does nothing and the send goes first; what was parked goes
|
|
745
|
+
// out after its run. Woken only after the replay, here, rather than when
|
|
746
|
+
// the store answers, or its turn would be drawn above the conversation.
|
|
747
|
+
// Only this restore's wait: one that was stopped was woken by what
|
|
748
|
+
// stopped it, and the wait now current, if any, is a newer restore's.
|
|
749
|
+
// Waking that one here sent its turn before its conversation was drawn,
|
|
750
|
+
// which "drops the waiting send on a drawer switch, and a send then
|
|
751
|
+
// carries the picked thread" in `ag_ui_chat_send_during_restore.test.ts`
|
|
752
|
+
// holds.
|
|
753
|
+
hold.release();
|
|
754
|
+
if (this.#loading === waiting) {
|
|
755
|
+
this.#endWait();
|
|
532
756
|
}
|
|
533
757
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Whether the restore that claimed `generation` may go on: nothing has
|
|
762
|
+
* replaced or cleared the conversation it is restoring, and the element is
|
|
763
|
+
* still on the page.
|
|
764
|
+
*
|
|
765
|
+
* On the page, because leaving it is the one stop that clears nothing: the
|
|
766
|
+
* element is not reset until it connects again, and connecting restores the
|
|
767
|
+
* conversation afresh. A restore with nothing to resume went on regardless,
|
|
768
|
+
* drawing into a node that had gone and running the host's renderers for it.
|
|
769
|
+
* One that resumes learnt it from the Stop letting go of its hold; nothing
|
|
770
|
+
* asks the hold that now, so this is what keeps the resume's request in.
|
|
771
|
+
*
|
|
772
|
+
* One `&&`, so coverage cannot tell whether either half is held. Removing the
|
|
773
|
+
* generation fails "draws nothing into a new chat started while it loads" in
|
|
774
|
+
* `conversation_history.test.ts`, and "drops a stale thread replay when a
|
|
775
|
+
* newer switch started" in `ag_ui_chat.test.ts`; removing the connection
|
|
776
|
+
* fails "draws nothing into an element that left the page while it loads".
|
|
777
|
+
*/
|
|
778
|
+
#restoring(generation: number): boolean {
|
|
779
|
+
return generation === this.#generation && this.#host.element.isConnected;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Forget the checkpoint a stopped restore loaded, when what stopped it also
|
|
784
|
+
* stopped the run it was about to resume.
|
|
785
|
+
*
|
|
786
|
+
* New chat, a thread switch, a `user-key` handover and a newer restore all
|
|
787
|
+
* move the generation, and each is a Stop for that run. Left in the store,
|
|
788
|
+
* the checkpoint outlived the Stop: coming back to the conversation later
|
|
789
|
+
* resumed the navigating call with whatever page was current by then.
|
|
790
|
+
* Forgotten, the call is answered as not finished there, as any stopped call
|
|
791
|
+
* is.
|
|
792
|
+
*
|
|
793
|
+
* Not while the restore now current claimed the same conversation, which it
|
|
794
|
+
* has in two cases. It is this restore, when only the element left the page,
|
|
795
|
+
* which moves no generation: a move or a framework re-render is not a
|
|
796
|
+
* farewell, and the restore connecting starts has to find the checkpoint to
|
|
797
|
+
* resume from. Or it is a newer restore of the same thread in the same store
|
|
798
|
+
* -- `reload()`, switching away and back, or the element connecting again --
|
|
799
|
+
* which read the checkpoint as it started, or found it gone, and answers for
|
|
800
|
+
* it from then on: its resume clears it, and when it is stopped in turn this
|
|
801
|
+
* same rule decides.
|
|
802
|
+
*
|
|
803
|
+
* The newer restore's case used to forget it too, on the grounds that the
|
|
804
|
+
* newer one had read it and would resume once from that copy. It did until
|
|
805
|
+
* the element left the page while the newer one loaded: that one stood down
|
|
806
|
+
* keeping the checkpoint for the reconnect, as a departure should, but the
|
|
807
|
+
* store no longer held it, so the element came back with no run to resume.
|
|
808
|
+
*
|
|
809
|
+
* Written to the store and the thread it was read from. After a handover
|
|
810
|
+
* that is the previous principal's: the built-in store's clear is a removal
|
|
811
|
+
* from a namespace the handover has already purged, so it changes nothing,
|
|
812
|
+
* and nothing reaches the next principal's. A host's store is the same object
|
|
813
|
+
* either side of a handover; one the host replaced is held by "still refuses
|
|
814
|
+
* a send for the newer restore once the older has stood down".
|
|
815
|
+
*
|
|
816
|
+
* Two conditions. Clearing with no checkpoint fails "draws nothing into a new
|
|
817
|
+
* chat started while it loads", which runs no store code for one. Clearing
|
|
818
|
+
* what the current restore claimed fails "resumes nothing once the element
|
|
819
|
+
* leaves the page while the store answers", where the claim is this
|
|
820
|
+
* restore's own and the re-inserted element resumes, and "resumes once the
|
|
821
|
+
* element is back when the host reloads and it then leaves the page", where
|
|
822
|
+
* it is the reload's. The claim's own three are named on `#claimedByCurrent`.
|
|
823
|
+
*/
|
|
824
|
+
#forgetStopped(
|
|
825
|
+
store: ClientConversationStore,
|
|
826
|
+
threadId: string,
|
|
827
|
+
checkpoint: NavigationCheckpoint | null,
|
|
828
|
+
): void {
|
|
829
|
+
if (checkpoint !== null && !this.#claimedByCurrent(store, threadId)) {
|
|
830
|
+
store.saveCheckpoint(threadId, null);
|
|
537
831
|
}
|
|
538
|
-
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Whether the restore now current claimed the conversation a stopped restore
|
|
836
|
+
* read its checkpoint from: the same thread, in the same store.
|
|
837
|
+
*
|
|
838
|
+
* A claim from an earlier generation speaks for nobody: the conversation was
|
|
839
|
+
* cleared after it was made, so whatever it would have resumed was stopped,
|
|
840
|
+
* and that holds of a stopped restore's own claim as much as of any other.
|
|
841
|
+
* The store is compared as an object, because a store the host assigned
|
|
842
|
+
* since is a different record of the conversation, and the one the
|
|
843
|
+
* checkpoint was read from still has to forget it.
|
|
844
|
+
*
|
|
845
|
+
* As an object, a store the element builds for itself -- the built-in one
|
|
846
|
+
* under a namespace, and the remote one `data-threads-url` wraps around it
|
|
847
|
+
* -- is a different one after a reconnect, which builds it afresh over the
|
|
848
|
+
* same storage, so a restore from before the reconnect forgets the
|
|
849
|
+
* checkpoint there once it answers. The reconnect's own restore read it as
|
|
850
|
+
* it started and resumes from that copy, as a newer restore always did here;
|
|
851
|
+
* what that copy cannot survive is the element leaving again before the
|
|
852
|
+
* load answers.
|
|
853
|
+
*
|
|
854
|
+
* One `&&` of three, so coverage cannot tell whether any is held. Dropping
|
|
855
|
+
* the generation fails "forgets the checkpoint when New chat stops the
|
|
856
|
+
* resume, so the call ends as not finished", where the claim is the stopped
|
|
857
|
+
* restore's own; dropping the store fails "still refuses a send for the newer
|
|
858
|
+
* restore once the older has stood down", whose host replaces the store
|
|
859
|
+
* between the two; dropping the thread fails "forgets the checkpoint of a
|
|
860
|
+
* thread switched away from, though the next has one of its own".
|
|
861
|
+
*/
|
|
862
|
+
#claimedByCurrent(store: ClientConversationStore, threadId: string): boolean {
|
|
863
|
+
const claim = this.#claim;
|
|
864
|
+
return (
|
|
865
|
+
claim?.generation === this.#generation && claim.store === store && claim.threadId === threadId
|
|
866
|
+
);
|
|
539
867
|
}
|
|
540
868
|
|
|
541
869
|
/**
|
|
@@ -696,13 +1024,21 @@ export class ConversationHistory {
|
|
|
696
1024
|
* guard is held by "resumes a checkpointed navigating tool call on mount" in
|
|
697
1025
|
* `ag_ui_chat.test.ts`, whose transcript never made the call.
|
|
698
1026
|
*/
|
|
699
|
-
async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
|
|
1027
|
+
async #resumeFrom(checkpoint: NavigationCheckpoint, generation: number): Promise<void> {
|
|
700
1028
|
this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
|
|
701
1029
|
const client = this.#host.ensureClient();
|
|
702
1030
|
// Called on the element, as `this.navigationResult(...)` always was.
|
|
703
1031
|
const landed = JSON.stringify(
|
|
704
1032
|
this.#host.navigationResult().call(this.#host.element, checkpoint),
|
|
705
1033
|
);
|
|
1034
|
+
// The store's save and `navigationResult` are host code, and either can
|
|
1035
|
+
// have stopped the restore. Going on answered the call and resumed anyway:
|
|
1036
|
+
// on the client taken before `navigationResult` ran, which nothing held any
|
|
1037
|
+
// more, with its tools still driving the page -- or, stopped by the save, on
|
|
1038
|
+
// the new chat's own client, which the line above had just built.
|
|
1039
|
+
if (!this.#restoring(generation)) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
706
1042
|
client.addToolResult(checkpoint.toolCallId, landed);
|
|
707
1043
|
this.#host.transcript.card(checkpoint.toolCallId)?.settle(TOOL_CALL_STATUS.DONE, landed);
|
|
708
1044
|
await client.resume();
|
package/src/ui/ui_strings.ts
CHANGED
|
@@ -120,6 +120,11 @@ export interface UiStrings {
|
|
|
120
120
|
continueNeedsTurn: string;
|
|
121
121
|
/** Composer hint when a run continuation was picked while a run is in flight. */
|
|
122
122
|
continueWhileRunning: string;
|
|
123
|
+
/**
|
|
124
|
+
* Composer hint when a run continuation was picked while the stored
|
|
125
|
+
* conversation is still loading.
|
|
126
|
+
*/
|
|
127
|
+
continueWhileLoading: string;
|
|
123
128
|
|
|
124
129
|
// ── Composer ────────────────────────────────────────────────────────────────
|
|
125
130
|
/** `aria-label` of the message textarea. */
|
|
@@ -388,6 +393,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
388
393
|
notConnected: "This chat isn’t connected to an agent, so the message wasn’t sent.",
|
|
389
394
|
continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
|
|
390
395
|
continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
|
|
396
|
+
continueWhileLoading: "Wait for the conversation to finish loading, then pick a run to continue.",
|
|
391
397
|
skillNeeds: "“{title}” needs {fields} — fill it in below, then send.",
|
|
392
398
|
|
|
393
399
|
message: "Message",
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION: string = "0.
|
|
1
|
+
export const VERSION: string = "0.43.0";
|