@artooi/ag-ui-web-component 0.38.0 → 0.40.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 +515 -1
- package/README.md +232 -56
- package/dist/ag-ui-web-component.bundle.js +370 -139
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +17 -14
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +23 -10
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +80 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +110 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +2 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +46 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +16268 -14747
- package/dist/index.js.map +4 -4
- package/dist/skills/skill_catalog.d.ts +67 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +79 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +104 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +203 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -1
- package/dist/ui/history/thread_drawer.d.ts +0 -14
- package/dist/ui/history/thread_drawer.d.ts.map +1 -1
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
- package/dist/ui/interrupts/pending_decision.d.ts +23 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
- package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
- package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +6 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
- package/dist/ui/placement/panel_placement.d.ts +119 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +58 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
- package/dist/ui/shell/adopt_styles.d.ts +23 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +10 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/transcript/answer_actions.d.ts +39 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +171 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +29 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -15
- package/src/core/activity_registry.ts +133 -0
- package/src/core/ag_ui_chat.ts +945 -4419
- package/src/core/agui_client.ts +172 -16
- package/src/core/client_seed.ts +27 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +1 -1
- package/src/core/run_handlers.ts +501 -0
- package/src/core/storage_scope.ts +279 -0
- package/src/core/tool_outcome.ts +5 -3
- package/src/core/utils.ts +109 -0
- package/src/skills/skill_catalog.ts +166 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/tool_catalog.ts +354 -0
- package/src/tools/tool_dispatch.ts +521 -0
- package/src/ui/composer/attachment_tray.ts +6 -5
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/voice_input.ts +4 -4
- package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/conversation_history.ts +699 -0
- package/src/ui/history/relative_time.ts +5 -4
- package/src/ui/history/thread_drawer.ts +3 -50
- package/src/ui/interrupts/confirmation_card.ts +4 -2
- package/src/ui/interrupts/pending_decision.ts +34 -0
- package/src/ui/placement/is_collapsible_placement.ts +19 -0
- package/src/ui/placement/is_draggable_placement.ts +23 -0
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +1130 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/subagent_progress.ts +201 -0
- package/src/ui/progress/tool_call_card.ts +2 -0
- package/src/ui/shell/adopt_styles.ts +28 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +21 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/styles.ts +301 -70
- package/src/ui/transcript/answer_actions.ts +139 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/transcript.ts +429 -0
- package/src/ui/ui_strings.ts +38 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { randomUUID } from "@ag-ui/client";
|
|
2
|
+
import type { Message } from "@ag-ui/core";
|
|
3
|
+
import { MESSAGE_ROLE, TOOL_OUTCOME } from "../../constants.js";
|
|
4
|
+
import type { ActivityRegistry } from "../../core/activity_registry.js";
|
|
5
|
+
import type { AgUiClient } from "../../core/agui_client.js";
|
|
6
|
+
import { messageAttachments } from "../../core/attachment.js";
|
|
7
|
+
import type { ClientSeed } from "../../core/client_seed.js";
|
|
8
|
+
import type {
|
|
9
|
+
ClientConversationStore,
|
|
10
|
+
NavigationCheckpoint,
|
|
11
|
+
} from "../../core/conversation_store.js";
|
|
12
|
+
import type { MessageRole } from "../../core/message_role.js";
|
|
13
|
+
import { RunIndex } from "../../core/run_index.js";
|
|
14
|
+
import { toolStatusFromOutcome } from "../../core/tool_outcome.js";
|
|
15
|
+
import { answerUnansweredCalls, mintThread } from "../../core/utils.js";
|
|
16
|
+
import type { ToolCatalog } from "../../tools/tool_catalog.js";
|
|
17
|
+
import type { AnswerActions } from "../transcript/answer_actions.js";
|
|
18
|
+
import { renderAttachmentChips } from "../transcript/attachment_chips.js";
|
|
19
|
+
import type { Transcript } from "../transcript/transcript.js";
|
|
20
|
+
import type { UiStrings } from "../ui_strings.js";
|
|
21
|
+
import type { CheckpointMenu, CheckpointVerb } from "./checkpoint_menu.js";
|
|
22
|
+
import type { RelativeTimeFormatter } from "./relative_time.js";
|
|
23
|
+
import type { ThreadDrawer } from "./thread_drawer.js";
|
|
24
|
+
|
|
25
|
+
/** What conversation history needs from the element that owns it. */
|
|
26
|
+
export interface ConversationHistoryHost {
|
|
27
|
+
/**
|
|
28
|
+
* The custom element: the attributes history reads and stamps, and the
|
|
29
|
+
* `this` a host's `agentFactory` and `navigationResult` are called with.
|
|
30
|
+
*/
|
|
31
|
+
readonly element: HTMLElement;
|
|
32
|
+
/** The conversation list, which the element builds and renders. */
|
|
33
|
+
readonly drawer: ThreadDrawer;
|
|
34
|
+
/** The checkpoint panel, which the element builds and renders. */
|
|
35
|
+
readonly checkpoints: CheckpointMenu;
|
|
36
|
+
/** The transcript a restored conversation is replayed into. */
|
|
37
|
+
readonly transcript: Transcript;
|
|
38
|
+
/** The action row under each finished answer. */
|
|
39
|
+
readonly actions: AnswerActions;
|
|
40
|
+
/** The activity renderers, which redraw a restored activity. */
|
|
41
|
+
readonly activities: ActivityRegistry;
|
|
42
|
+
/** The frontend tools, whose renderers a restored call is redrawn with. */
|
|
43
|
+
readonly tools: ToolCatalog;
|
|
44
|
+
/** The composer, whose text a continuation sends. */
|
|
45
|
+
readonly input: HTMLTextAreaElement;
|
|
46
|
+
/** The one-line hint above the composer. */
|
|
47
|
+
readonly hint: HTMLDivElement;
|
|
48
|
+
/** The resolved string table. */
|
|
49
|
+
readonly strings: () => UiStrings;
|
|
50
|
+
/** The element's `conversationStore`, read per use. */
|
|
51
|
+
readonly conversationStore: () => ClientConversationStore;
|
|
52
|
+
/** The element's `formatRelativeTime`. */
|
|
53
|
+
readonly formatRelativeTime: () => RelativeTimeFormatter | null;
|
|
54
|
+
/** The element's `navigationResult`. */
|
|
55
|
+
readonly navigationResult: () => (checkpoint: NavigationCheckpoint) => unknown;
|
|
56
|
+
/** The request headers, having first reported the destination if it is foreign. */
|
|
57
|
+
readonly headersFor: (url: string) => Record<string, string>;
|
|
58
|
+
/** The configured cookie policy as `fetch` spells it. */
|
|
59
|
+
readonly requestCredentials: () => RequestCredentials | undefined;
|
|
60
|
+
/** The element's `appendMessage`, which a restored bubble opens through. */
|
|
61
|
+
readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
|
|
62
|
+
/** Resize the composer to its content. */
|
|
63
|
+
readonly autoGrow: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* A continuation has ended, whether or not it ever ran.
|
|
66
|
+
*
|
|
67
|
+
* The composer parks a turn typed while one is in flight, and learns a run
|
|
68
|
+
* has settled from its own events -- which never arrive for a continuation
|
|
69
|
+
* that failed before starting one. Without this, such a turn stayed parked
|
|
70
|
+
* with nothing left to release it.
|
|
71
|
+
*/
|
|
72
|
+
readonly continuationEnded: () => void;
|
|
73
|
+
/** The conversation's own client, or `null` until one is built. */
|
|
74
|
+
readonly client: () => AgUiClient | null;
|
|
75
|
+
/** The conversation's own client, built on first use. */
|
|
76
|
+
readonly ensureClient: () => AgUiClient;
|
|
77
|
+
/**
|
|
78
|
+
* A client from the same construction as the conversation's own, differing
|
|
79
|
+
* only in what `seed` says. A continuation's comes from here so that nothing
|
|
80
|
+
* added to one can be missing from the other.
|
|
81
|
+
*/
|
|
82
|
+
readonly buildClient: (seed: ClientSeed) => AgUiClient;
|
|
83
|
+
/**
|
|
84
|
+
* Forget the conversation's own client, so the next one is built from
|
|
85
|
+
* {@link ConversationHistory.restored}. Nothing is cancelled: this is for a
|
|
86
|
+
* client that is not running.
|
|
87
|
+
*/
|
|
88
|
+
readonly releaseClient: () => void;
|
|
89
|
+
/** Whether an interaction is in flight, which the composer owns. */
|
|
90
|
+
readonly running: () => boolean;
|
|
91
|
+
/** Stop the in-flight run. */
|
|
92
|
+
readonly cancelRun: () => void;
|
|
93
|
+
/** Drop the in-memory run and transcript, leaving the thread untouched. */
|
|
94
|
+
readonly resetState: () => void;
|
|
95
|
+
/** Swap the composer between Send and Stop, which the composer owns. */
|
|
96
|
+
readonly setRunning: (running: boolean) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which conversation is on screen, and how one gets there: restoring it from
|
|
101
|
+
* the store, switching to another from the conversation list, and continuing
|
|
102
|
+
* a run from the checkpoint panel.
|
|
103
|
+
*
|
|
104
|
+
* The one writer of the active thread's id and of the messages a restore
|
|
105
|
+
* seeded the next client with. The element reads both when it builds its
|
|
106
|
+
* client, and asks for a transition -- adopt the store's thread, mint a new
|
|
107
|
+
* one -- rather than writing either. Clearing the run, the transcript and the
|
|
108
|
+
* composer around a transition stays the element's, because it spans every
|
|
109
|
+
* part of it; history calls back for that.
|
|
110
|
+
*
|
|
111
|
+
* Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
|
|
112
|
+
* instance.
|
|
113
|
+
*/
|
|
114
|
+
export class ConversationHistory {
|
|
115
|
+
readonly #host: ConversationHistoryHost;
|
|
116
|
+
/** The active thread's id. Empty until the element connects. */
|
|
117
|
+
#threadId = "";
|
|
118
|
+
/**
|
|
119
|
+
* The conversation as last written for the next client the element builds to
|
|
120
|
+
* start from: what the last restore replayed, or what a checkpoint
|
|
121
|
+
* continuation last saved after it. Emptied with the rest of the in-memory run.
|
|
122
|
+
*/
|
|
123
|
+
#restored: readonly Message[] = [];
|
|
124
|
+
/**
|
|
125
|
+
* Counts the conversations cleared away, so a continuation can tell whether
|
|
126
|
+
* the one it continued is still on screen when it saves.
|
|
127
|
+
*/
|
|
128
|
+
#cleared = 0;
|
|
129
|
+
// Bumped on every rehydrate; a replay whose generation is stale (a newer
|
|
130
|
+
// thread switch started while it awaited a slow store) drops its result.
|
|
131
|
+
#generation = 0;
|
|
132
|
+
/** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
|
|
133
|
+
#runIndex: RunIndex | null = null;
|
|
134
|
+
/**
|
|
135
|
+
* The checkpoint continuation in flight, so stopping the conversation's run
|
|
136
|
+
* reaches it. `null` when none is running.
|
|
137
|
+
*/
|
|
138
|
+
#continuation: AgUiClient | null = null;
|
|
139
|
+
|
|
140
|
+
constructor(host: ConversationHistoryHost) {
|
|
141
|
+
this.#host = host;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The active thread's id. */
|
|
145
|
+
get threadId(): string {
|
|
146
|
+
return this.#threadId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The conversation as last written, for seeding the next client. */
|
|
150
|
+
get restored(): readonly Message[] {
|
|
151
|
+
return this.#restored;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The checkpoint continuation in flight, or `null` when none is running. */
|
|
155
|
+
get continuation(): AgUiClient | null {
|
|
156
|
+
return this.#continuation;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Point at the thread the store says is active. */
|
|
160
|
+
adoptActiveThread(): void {
|
|
161
|
+
this.#threadId = this.#host.conversationStore().threadId();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Make a freshly minted thread the active one. */
|
|
165
|
+
startThread(): void {
|
|
166
|
+
this.#threadId = mintThread(this.#host.conversationStore());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stop the checkpoint continuation in flight, if there is one.
|
|
171
|
+
*
|
|
172
|
+
* A continuation runs on a client of its own, which the element never held,
|
|
173
|
+
* so Stop cancelled the conversation's client and left this one streaming:
|
|
174
|
+
* the button read Stop and did nothing, and New chat, a thread switch or
|
|
175
|
+
* removing the element carried on drawing the resumed answer into whatever
|
|
176
|
+
* came next. The element calls this wherever it stops its own run.
|
|
177
|
+
*/
|
|
178
|
+
stopContinuation(): void {
|
|
179
|
+
this.#continuation?.cancel();
|
|
180
|
+
this.#continuation = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Forget the messages the last restore seeded, with the rest of the run. */
|
|
184
|
+
forgetRestored(): void {
|
|
185
|
+
this.#restored = [];
|
|
186
|
+
// The element clears the conversation through here, so a continuation
|
|
187
|
+
// still saving afterwards learns the conversation is no longer this one.
|
|
188
|
+
this.#cleared += 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Delete the active thread if nothing was ever sent in it. */
|
|
192
|
+
reapUnsent(): void {
|
|
193
|
+
// A thread nothing was ever sent in has nothing to come back to, and the
|
|
194
|
+
// drawer never listed it — so reap it here rather than strand one record
|
|
195
|
+
// per press of a button whose whole use is being pressed again.
|
|
196
|
+
if (this.#host.conversationStore().isUnsent?.(this.#threadId) === true) {
|
|
197
|
+
this.#host.conversationStore().clear(this.#threadId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The run index, built once from `data-runs-url`; `null` when unset. */
|
|
202
|
+
runs(): RunIndex | null {
|
|
203
|
+
const url = this.#host.element.getAttribute("data-runs-url");
|
|
204
|
+
if (url === null || url === "") {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (this.#runIndex === null) {
|
|
208
|
+
this.#runIndex = new RunIndex(
|
|
209
|
+
url,
|
|
210
|
+
() => this.#host.headersFor(url),
|
|
211
|
+
() => this.#host.requestCredentials(),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return this.#runIndex;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Open the conversation list, dismissing the checkpoint panel. */
|
|
218
|
+
openThreads(): void {
|
|
219
|
+
// Two overlapping surfaces, so opening one dismisses the other. Clicking away
|
|
220
|
+
// already covers the built-in buttons, but a host driving its own chrome
|
|
221
|
+
// through these methods raises no pointer event — and the drawer would then
|
|
222
|
+
// open *underneath* a popover still floating over it.
|
|
223
|
+
this.#host.checkpoints.close();
|
|
224
|
+
void this.refreshDrawer();
|
|
225
|
+
this.#host.drawer.open();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Open the checkpoint panel, dismissing the conversation list. */
|
|
229
|
+
openCheckpoints(): void {
|
|
230
|
+
// The other half of the pair — see `openThreads`.
|
|
231
|
+
this.#host.drawer.close();
|
|
232
|
+
void this.#refreshCheckpoints();
|
|
233
|
+
this.#host.checkpoints.open();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Switch the active conversation to an existing thread and replay it. */
|
|
237
|
+
async switchThread(threadId: string): Promise<void> {
|
|
238
|
+
if (threadId === this.#threadId) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.#host.cancelRun();
|
|
242
|
+
this.#host.resetState();
|
|
243
|
+
this.#host.conversationStore().setActiveThread(threadId);
|
|
244
|
+
this.#threadId = threadId;
|
|
245
|
+
this.#host.setRunning(false);
|
|
246
|
+
await this.rehydrate();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Rename a thread, then reload the list that shows its title. */
|
|
250
|
+
renameThread(threadId: string, title: string): void {
|
|
251
|
+
this.#host.conversationStore().renameThread(threadId, title);
|
|
252
|
+
void this.refreshDrawer();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Delete a thread; if it was the active one, fall back to a fresh chat. */
|
|
256
|
+
deleteThread(threadId: string): void {
|
|
257
|
+
const wasActive = threadId === this.#threadId;
|
|
258
|
+
if (wasActive) {
|
|
259
|
+
this.#host.cancelRun();
|
|
260
|
+
}
|
|
261
|
+
this.#host.conversationStore().clear(threadId);
|
|
262
|
+
if (wasActive) {
|
|
263
|
+
this.#host.resetState();
|
|
264
|
+
this.adoptActiveThread();
|
|
265
|
+
this.#host.setRunning(false);
|
|
266
|
+
}
|
|
267
|
+
void this.refreshDrawer();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Reload the drawer's thread list, marking the active thread. */
|
|
271
|
+
async refreshDrawer(): Promise<void> {
|
|
272
|
+
this.#host.drawer.setRelativeTimeFormatter(this.#host.formatRelativeTime());
|
|
273
|
+
this.#host.drawer.setThreads(
|
|
274
|
+
await this.#host.conversationStore().listThreads(),
|
|
275
|
+
this.#threadId,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Load the checkpoint panel with the runs that can actually be continued. */
|
|
280
|
+
async #refreshCheckpoints(): Promise<void> {
|
|
281
|
+
const index = this.runs();
|
|
282
|
+
// Pushed at render rather than at connect: `formatRelativeTime` is a
|
|
283
|
+
// property, so a host may set it long after the element mounted.
|
|
284
|
+
this.#host.checkpoints.setRelativeTimeFormatter(this.#host.formatRelativeTime());
|
|
285
|
+
this.#host.checkpoints.setRuns(index === null ? [] : await index.continuable());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Continue `runId` as a **new** run, seeded server-side from its snapshot.
|
|
290
|
+
*
|
|
291
|
+
* Uses a short-lived agent pointed at the resume / fork endpoint and seeded
|
|
292
|
+
* with no history, because those endpoints supply the prior turns from the
|
|
293
|
+
* snapshot and re-sending them would duplicate. A separate agent makes that
|
|
294
|
+
* structural and mints the fresh `run_id` the endpoints also require.
|
|
295
|
+
*
|
|
296
|
+
* Built by the same construction as the conversation's own client, so the
|
|
297
|
+
* continuation streams into the same transcript the user is looking at and
|
|
298
|
+
* runs under the same state, tools and bounds. It is the run in flight while
|
|
299
|
+
* it lasts: {@link stopContinuation} is how the element's Stop reaches it.
|
|
300
|
+
*
|
|
301
|
+
* What it adds joins the conversation. Its saves write the conversation on
|
|
302
|
+
* screen ahead of its exchange, and each one hands that whole list to the
|
|
303
|
+
* next client the element builds, so the next ordinary message is sent with
|
|
304
|
+
* the exchange it follows. The conversation's own client never held it, and
|
|
305
|
+
* was sending without it.
|
|
306
|
+
*/
|
|
307
|
+
async continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
|
|
308
|
+
const index = this.runs();
|
|
309
|
+
if (index === null) {
|
|
310
|
+
// Unreachable from the built-in control: the header button is only
|
|
311
|
+
// rendered when `runs()` is configured, so a row to pick cannot exist
|
|
312
|
+
// without one. A host calling `openCheckpoints()` regardless gets the
|
|
313
|
+
// documented empty panel, which has no rows either. Typed, not silent.
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (this.#host.running() || this.#continuation !== null) {
|
|
317
|
+
// Refused rather than started beside it. The panel opens and its rows
|
|
318
|
+
// take a pick while a run streams, and a second run would draw its answer
|
|
319
|
+
// into the turn still arriving; a second continuation would also replace
|
|
320
|
+
// the one Stop reaches, leaving the first streaming where nothing could
|
|
321
|
+
// end it. Nor is the earlier run cancelled for it: a pick in a panel is not
|
|
322
|
+
// a Stop, and what is streaming may be the answer the user is waiting on.
|
|
323
|
+
//
|
|
324
|
+
// Both checks, because they see different moments. `running` is the
|
|
325
|
+
// composer's own state and spans every round of an interaction, but it
|
|
326
|
+
// is set when the run's first event arrives; a continuation is recorded
|
|
327
|
+
// here the moment it starts.
|
|
328
|
+
//
|
|
329
|
+
// Said at the composer, as an empty composer is below, because the row
|
|
330
|
+
// closed the panel before this ran. The typed turn stays where it is --
|
|
331
|
+
// it is what the user wants sent once the run is done -- and the caret
|
|
332
|
+
// goes back to it, where Escape stops the run.
|
|
333
|
+
this.#refuse(this.#host.strings().continueWhileRunning);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const content = this.#host.input.value.trim();
|
|
337
|
+
if (content === "") {
|
|
338
|
+
// A continuation sends *only* the next turn -- the snapshot supplies
|
|
339
|
+
// everything before it -- so with an empty composer there is nothing to
|
|
340
|
+
// send. Returning here was the same failure the endpoint guard above had:
|
|
341
|
+
// the row's button closes the panel before this runs, so the widget
|
|
342
|
+
// visibly reacted and then did nothing, which reads as a resume that was
|
|
343
|
+
// attempted and lost rather than one that never started.
|
|
344
|
+
//
|
|
345
|
+
// Said at the composer rather than in the transcript, because that is
|
|
346
|
+
// where the fix goes and because the hint clears itself on the first
|
|
347
|
+
// keystroke -- a transcript notice for a recoverable slip would outlive
|
|
348
|
+
// the slip. Focus follows for the same reason applying a skill moves it when
|
|
349
|
+
// a template is short of a field.
|
|
350
|
+
this.#refuse(this.#host.strings().continueNeedsTurn);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.#host.input.value = "";
|
|
354
|
+
this.#host.autoGrow();
|
|
355
|
+
const cleared = this.#cleared;
|
|
356
|
+
const client = this.#host.buildClient({
|
|
357
|
+
endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
|
|
358
|
+
// The seed the endpoints assume: nothing. The snapshot is the history.
|
|
359
|
+
initialMessages: [],
|
|
360
|
+
// What its saves write ahead of the exchange: the conversation on screen,
|
|
361
|
+
// in the form the store holds it. From the conversation's own client when
|
|
362
|
+
// there is one, because that client keeps how each call ended beside its
|
|
363
|
+
// messages rather than on them; otherwise what the last restore or
|
|
364
|
+
// continuation wrote, which is already in that form.
|
|
365
|
+
//
|
|
366
|
+
// All of it, even where this forks an earlier run and the server's
|
|
367
|
+
// snapshot stops there: what is saved is what the screen shows.
|
|
368
|
+
follows: this.#host.client()?.annotatedMessages ?? this.#restored,
|
|
369
|
+
onSaved: (conversation) => {
|
|
370
|
+
// A continuation stopped by New chat or a thread switch saves once its
|
|
371
|
+
// request closes, into its own thread; the conversation now on screen
|
|
372
|
+
// is not the one it continued.
|
|
373
|
+
if (cleared !== this.#cleared) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// The conversation's own client holds the conversation without this
|
|
377
|
+
// exchange, and would send it that way. Released rather than patched:
|
|
378
|
+
// the next client is built from this list exactly as a reload builds
|
|
379
|
+
// one from the store, outcomes included.
|
|
380
|
+
//
|
|
381
|
+
// Nothing can run on the released client in between, because the
|
|
382
|
+
// composer refuses to start one while a continuation is in flight --
|
|
383
|
+
// it parks the turn instead. It said here that only a script in the
|
|
384
|
+
// same task could, which was wrong by a whole request: `running` does
|
|
385
|
+
// not turn on until the continuation's first event, and a person who
|
|
386
|
+
// typed in that window got a second run against the snapshot
|
|
387
|
+
// `follows` had already frozen, so whichever saved last dropped the
|
|
388
|
+
// other's turn.
|
|
389
|
+
this.#restored = conversation;
|
|
390
|
+
this.#host.releaseClient();
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
this.#continuation = client;
|
|
394
|
+
try {
|
|
395
|
+
await client.send(content);
|
|
396
|
+
} finally {
|
|
397
|
+
// Only if it is still the one in flight: stopping forgets it at once, and
|
|
398
|
+
// a continuation started after that one is not this one to forget.
|
|
399
|
+
//
|
|
400
|
+
// In a finally because the send can fail before its run ever starts. The
|
|
401
|
+
// first save goes through the host's `conversationStore` synchronously
|
|
402
|
+
// inside it, and a store is the host's to replace: the built-in one
|
|
403
|
+
// swallows a write the browser refused, a server-backed one need not. A
|
|
404
|
+
// throw there left this pointing at a client that would never run, so
|
|
405
|
+
// every later pick was refused with no Stop to clear it -- the composer's
|
|
406
|
+
// button is Send until a run reports a start -- and `#liveClient` went on
|
|
407
|
+
// handing that dead client the shared state a host wrote.
|
|
408
|
+
if (this.#continuation === client) {
|
|
409
|
+
this.#continuation = null;
|
|
410
|
+
this.#host.continuationEnded();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Say at the composer why a picked run did not continue, and put the caret
|
|
417
|
+
* there. The hint clears itself on the next keystroke.
|
|
418
|
+
*/
|
|
419
|
+
#refuse(reason: string): void {
|
|
420
|
+
this.#host.hint.textContent = reason;
|
|
421
|
+
this.#host.hint.hidden = false;
|
|
422
|
+
this.#host.input.focus();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Restore the conversation from the store on mount, then — if a navigating
|
|
427
|
+
* tool reloaded the page mid-run — resume the loop by supplying that tool's
|
|
428
|
+
* result from the page we landed on.
|
|
429
|
+
*
|
|
430
|
+
* Any other call the stored run left unanswered is answered on the way in, as
|
|
431
|
+
* not finished, because no run is left to answer it.
|
|
432
|
+
*
|
|
433
|
+
* **What the store holds after a reload mid-run.** The run loop persists a
|
|
434
|
+
* round when its stream ends, which is *before* it asks about a gated call,
|
|
435
|
+
* runs a frontend tool, or collects a server-side approval. A reload in that
|
|
436
|
+
* window leaves the round's calls stored with no result, and the request that
|
|
437
|
+
* would have produced one died with the page.
|
|
438
|
+
*
|
|
439
|
+
* **Why not finished, and not declined.** Stop declines an open card because
|
|
440
|
+
* pressing it is a person answering the question. A reload answers nothing, and
|
|
441
|
+
* the stored shape is the same whether the round waited on a person or on a
|
|
442
|
+
* handler the reload killed, so declined would be unproven for the first and
|
|
443
|
+
* false for the second. A marker saved while a card is open could tell them
|
|
444
|
+
* apart, but every host store would have to round-trip it, and a store that
|
|
445
|
+
* dropped it would fall back to this wording anyway -- which is true of both.
|
|
446
|
+
*
|
|
447
|
+
* The answers come from the same helper the client runs before each request,
|
|
448
|
+
* so a restored card and the result the next request carries cannot disagree.
|
|
449
|
+
* Giving them to the replay, rather than leaving the client to add them, is
|
|
450
|
+
* what settles each card from its result. The checkpointed call is excluded,
|
|
451
|
+
* because the resume path answers it from the page the reload landed on; that
|
|
452
|
+
* exclusion is held by "still resumes with the landed page's result, and is not
|
|
453
|
+
* declined" in `ag_ui_chat_reload_mid_run.test.ts`.
|
|
454
|
+
*/
|
|
455
|
+
async rehydrate(): Promise<void> {
|
|
456
|
+
// Guard against a thread-switch race: with a slow remote store, picking
|
|
457
|
+
// thread B then C would interleave both replays into one transcript. Each
|
|
458
|
+
// rehydrate claims a generation before awaiting and bails if a newer one
|
|
459
|
+
// started meanwhile (its reset already cleared the transcript).
|
|
460
|
+
this.#generation += 1;
|
|
461
|
+
const generation = this.#generation;
|
|
462
|
+
// Held while the store answers. A remote store answers after first paint,
|
|
463
|
+
// and a conversation it is still fetching is more likely to have messages
|
|
464
|
+
// than not, so without this the page would paint the greeting and a centred
|
|
465
|
+
// composer and then drop the composer the moment they land. The built-in
|
|
466
|
+
// store answers in a microtask, before paint, so for it this never reaches
|
|
467
|
+
// the screen. Released in `finally` so a store that rejects cannot leave the
|
|
468
|
+
// layout held for good, and only by the restore that is still current.
|
|
469
|
+
this.#host.element.setAttribute("data-restoring", "");
|
|
470
|
+
let messages: readonly Message[] | null;
|
|
471
|
+
try {
|
|
472
|
+
messages = await this.#host.conversationStore().loadMessages(this.#threadId);
|
|
473
|
+
} finally {
|
|
474
|
+
if (generation === this.#generation) {
|
|
475
|
+
this.#host.element.removeAttribute("data-restoring");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (generation !== this.#generation) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Read before the replay rather than after it: the checkpointed call is the
|
|
482
|
+
// one unanswered call a reload was *expected* by, and the replay has to know
|
|
483
|
+
// which it is so as not to settle it as abandoned.
|
|
484
|
+
const checkpoint = this.#host.conversationStore().loadCheckpoint(this.#threadId);
|
|
485
|
+
if (messages !== null) {
|
|
486
|
+
const unfinished = this.#host.strings().callNotFinished;
|
|
487
|
+
const restored = answerUnansweredCalls(
|
|
488
|
+
messages,
|
|
489
|
+
// The shape the store holds for a call the client answered the same way:
|
|
490
|
+
// its tool message, with the outcome it annotates onto the stored copy.
|
|
491
|
+
// Cast at the AG-UI boundary, as the client's own annotation is.
|
|
492
|
+
(toolCallId) =>
|
|
493
|
+
({
|
|
494
|
+
id: randomUUID(),
|
|
495
|
+
role: "tool",
|
|
496
|
+
content: unfinished,
|
|
497
|
+
toolCallId,
|
|
498
|
+
outcome: TOOL_OUTCOME.INTERRUPTED,
|
|
499
|
+
}) as Message,
|
|
500
|
+
new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
|
|
501
|
+
);
|
|
502
|
+
this.#restored = restored;
|
|
503
|
+
for (const message of restored) {
|
|
504
|
+
this.replay(message);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (checkpoint !== null) {
|
|
508
|
+
await this.#resumeFrom(checkpoint);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.#noticeIfRunUnfinished(messages);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Notice a previous run that never produced a response.
|
|
516
|
+
*
|
|
517
|
+
* {@link AgUiClient.send} persists the user's message before starting the
|
|
518
|
+
* run, so a transcript ending on that user message means nothing came back.
|
|
519
|
+
* The transcript's shape alone detects it, needing no store method and no
|
|
520
|
+
* `pagehide` listener — neither of which fires on a crash or force-quit.
|
|
521
|
+
*
|
|
522
|
+
* An agent-initiated reload is not this case: a navigating tool leaves a
|
|
523
|
+
* checkpoint and resumes, so the caller returns early on one.
|
|
524
|
+
*
|
|
525
|
+
* Deliberately a notice, never a resume. AG-UI has no resume-an-aborted-run
|
|
526
|
+
* primitive, and re-sending the accumulated messages is semantically a new
|
|
527
|
+
* run, so any server-side tool already executed would run a second time.
|
|
528
|
+
*/
|
|
529
|
+
#noticeIfRunUnfinished(messages: readonly Message[] | null): void {
|
|
530
|
+
const last = messages?.at(-1);
|
|
531
|
+
if (last === undefined || last.role !== MESSAGE_ROLE.USER) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.#host.transcript.appendNotice("⚠", this.#host.strings().runInterrupted, "interrupted");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Replay a restored message: text bubbles *and* tool activity. An assistant
|
|
539
|
+
* turn may carry `toolCalls` (rendered as cards) and/or text; a `tool` turn
|
|
540
|
+
* carries a result that settles the matching card. So a refreshed page shows
|
|
541
|
+
* the full transcript — tool calls and their results — not just the prose.
|
|
542
|
+
*/
|
|
543
|
+
replay(message: Message): void {
|
|
544
|
+
const text = typeof message.content === "string" ? message.content : "";
|
|
545
|
+
if (message.role === MESSAGE_ROLE.USER) {
|
|
546
|
+
const attachments = messageAttachments(message);
|
|
547
|
+
if (text !== "" || attachments.length > 0) {
|
|
548
|
+
const bubble = this.#host.appendMessage(MESSAGE_ROLE.USER, text);
|
|
549
|
+
if (attachments.length > 0) {
|
|
550
|
+
bubble.appendChild(renderAttachmentChips(attachments));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (message.role === MESSAGE_ROLE.ASSISTANT) {
|
|
556
|
+
if (text !== "") {
|
|
557
|
+
// Restored history must appear statically — entrance animations
|
|
558
|
+
// (fade / word) are for freshly-arriving messages. On reload the whole
|
|
559
|
+
// transcript mounts at once, so animating every bubble's text in
|
|
560
|
+
// parallel looks wrong. Mark it so the fade CSS skips it, and don't
|
|
561
|
+
// wrap words.
|
|
562
|
+
const restoredBubble = this.#host.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
|
|
563
|
+
restoredBubble.classList.add("message--restored");
|
|
564
|
+
this.#host.actions.attach(restoredBubble);
|
|
565
|
+
}
|
|
566
|
+
// Narrowed rather than trusted, for the same reason `messageAttachments`
|
|
567
|
+
// narrows the neighbouring field: anything that throws in this loop aborts
|
|
568
|
+
// the replay at this message, and every later turn silently disappears from
|
|
569
|
+
// the transcript. See `restoredToolCalls`.
|
|
570
|
+
for (const call of restoredToolCalls(message.toolCalls)) {
|
|
571
|
+
const restored = {
|
|
572
|
+
id: call.id,
|
|
573
|
+
name: call.function.name,
|
|
574
|
+
args: this.#parseArgs(call.function.arguments),
|
|
575
|
+
};
|
|
576
|
+
// Restored history goes through the same interception as the live
|
|
577
|
+
// stream — otherwise a reload resurrects the raw `load_capability`
|
|
578
|
+
// card the live path deliberately replaced.
|
|
579
|
+
if (this.#host.transcript.noticeIfSkillLoad(restored)) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
this.#host.transcript.setCardElement(
|
|
583
|
+
restored.id,
|
|
584
|
+
this.#host.transcript.cardFor(restored).element,
|
|
585
|
+
);
|
|
586
|
+
// Only `render` is replayed, never `handler`. A restored transcript
|
|
587
|
+
// redraws what the call drew; it must not re-run what the call *did*.
|
|
588
|
+
// Only the renderer is handed over, never the tool. The guarantee that
|
|
589
|
+
// a reload cannot re-run a tool's *effect* is worth more than a comment
|
|
590
|
+
// saying so: this signature cannot reach `handler`, so a later
|
|
591
|
+
// maintainer adding a "no render? fall back to the handler" convenience
|
|
592
|
+
// here has to change the type first, which is exactly the moment the
|
|
593
|
+
// question should be asked.
|
|
594
|
+
const render = this.#host.tools.resolve(restored.name)?.render;
|
|
595
|
+
if (render !== undefined) {
|
|
596
|
+
this.#host.transcript.renderToolOutput(render, restored);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (message.role === "activity") {
|
|
602
|
+
// The client materialises a pushed activity as a message of its own, so a
|
|
603
|
+
// chart's data is in the transcript already and survives a reload. Only
|
|
604
|
+
// the drawing had to be put back.
|
|
605
|
+
const activity = message as unknown as { activityType?: unknown; content?: unknown };
|
|
606
|
+
if (typeof activity.activityType === "string") {
|
|
607
|
+
this.#host.activities.draw(message.id, activity.activityType, activity.content);
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (message.role === "tool") {
|
|
612
|
+
const card = this.#host.transcript.card(message.toolCallId);
|
|
613
|
+
if (card !== undefined) {
|
|
614
|
+
// The outcome `AgUiClient` annotated onto the persisted message, read
|
|
615
|
+
// back through the same mapping the live path uses -- so a card that
|
|
616
|
+
// said "declined" before the reload still says it after. Narrowed off
|
|
617
|
+
// `unknown` rather than trusted, like every other field read out of the
|
|
618
|
+
// store: `Message` does not declare it, a host store may not round-trip
|
|
619
|
+
// it, and history written before this shipped has none. All three land
|
|
620
|
+
// on DONE, which is what this line did unconditionally.
|
|
621
|
+
card.settle(
|
|
622
|
+
toolStatusFromOutcome((message as { outcome?: unknown }).outcome),
|
|
623
|
+
message.content,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Parse a tool call's JSON `arguments` from history into an object. */
|
|
630
|
+
#parseArgs(raw: unknown): Record<string, unknown> {
|
|
631
|
+
if (typeof raw !== "string") {
|
|
632
|
+
// A restored call whose `arguments` are missing or not a string still has
|
|
633
|
+
// a name worth showing, so this renders an empty-args card rather than
|
|
634
|
+
// dropping the card.
|
|
635
|
+
return {};
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
const parsed: unknown = JSON.parse(raw);
|
|
639
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
640
|
+
return parsed as Record<string, unknown>;
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
// Malformed history — fall back to empty args rather than failing replay.
|
|
644
|
+
}
|
|
645
|
+
return {};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Complete the checkpointed navigating tool call and continue the run. */
|
|
649
|
+
async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
|
|
650
|
+
this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
|
|
651
|
+
const client = this.#host.ensureClient();
|
|
652
|
+
client.addToolResult(
|
|
653
|
+
checkpoint.toolCallId,
|
|
654
|
+
// Called on the element, as `this.navigationResult(...)` always was.
|
|
655
|
+
JSON.stringify(this.#host.navigationResult().call(this.#host.element, checkpoint)),
|
|
656
|
+
);
|
|
657
|
+
await client.resume();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** One tool call as a restored assistant message carries it. */
|
|
662
|
+
interface RestoredToolCall {
|
|
663
|
+
readonly id: string;
|
|
664
|
+
readonly function: { readonly name: string; readonly arguments?: unknown };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The tool calls a restored assistant turn carries, with anything shapeless dropped.
|
|
669
|
+
*
|
|
670
|
+
* Narrowing here rather than trusting the declared type, for three reasons that
|
|
671
|
+
* point the same way.
|
|
672
|
+
*
|
|
673
|
+
* **`null` is a value this field really takes.** `@ag-ui/core` types `toolCalls`
|
|
674
|
+
* as optional (`z.ZodOptional`), so TypeScript offers only `undefined` — but the
|
|
675
|
+
* protocol's Python models declare `tool_calls: list[ToolCall] | None`, and a
|
|
676
|
+
* server dumping them without `exclude_none` sends `null`. The two SDKs disagree
|
|
677
|
+
* about the wire, and a client cannot afford to take either one's word for it.
|
|
678
|
+
*
|
|
679
|
+
* **A throw here costs the rest of the transcript.** This runs inside the replay
|
|
680
|
+
* of stored history, one message at a time; an exception aborts the whole replay,
|
|
681
|
+
* so a single bad entry silently truncates the conversation from that point on —
|
|
682
|
+
* with no error state and nothing on screen to explain the gap.
|
|
683
|
+
*
|
|
684
|
+
* **Storage is untrusted anyway** — hand-edited, truncated, written by an older
|
|
685
|
+
* version, or supplied by a host's own store. `messageAttachments` already takes
|
|
686
|
+
* exactly this stance for the neighbouring field on the same message.
|
|
687
|
+
*/
|
|
688
|
+
function restoredToolCalls(value: unknown): readonly RestoredToolCall[] {
|
|
689
|
+
return Array.isArray(value) ? value.filter(isRestoredToolCall) : [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Whether an unknown history entry has enough shape to render a tool card. */
|
|
693
|
+
function isRestoredToolCall(value: unknown): value is RestoredToolCall {
|
|
694
|
+
if (typeof value !== "object" || value === null) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
const call = value as { id?: unknown; function?: { name?: unknown } };
|
|
698
|
+
return typeof call.id === "string" && typeof call.function?.name === "string";
|
|
699
|
+
}
|