@artooi/ag-ui-web-component 0.39.0 → 0.41.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +208 -1
  2. package/README.md +116 -36
  3. package/dist/ag-ui-web-component.bundle.js +255 -101
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +3 -2
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +7 -3
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +29 -8
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +11 -4
  12. package/dist/core/attachment.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +14 -2
  14. package/dist/core/client_seed.d.ts.map +1 -1
  15. package/dist/core/tool_outcome.d.ts +4 -3
  16. package/dist/core/tool_outcome.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +19 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.js +1078 -664
  20. package/dist/index.js.map +4 -4
  21. package/dist/tools/tool_catalog.d.ts.map +1 -1
  22. package/dist/ui/composer/composer_attachments.d.ts +6 -1
  23. package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
  24. package/dist/ui/composer/composer_voice.d.ts +3 -0
  25. package/dist/ui/composer/composer_voice.d.ts.map +1 -1
  26. package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
  27. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
  28. package/dist/ui/history/conversation_history.d.ts +30 -4
  29. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  30. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  31. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  32. package/dist/ui/placement/panel_placement.d.ts +1 -1
  33. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  34. package/dist/ui/styles.d.ts +1 -1
  35. package/dist/ui/styles.d.ts.map +1 -1
  36. package/dist/ui/transcript/transcript.d.ts +3 -2
  37. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  38. package/dist/ui/ui_strings.d.ts +2 -0
  39. package/dist/ui/ui_strings.d.ts.map +1 -1
  40. package/package.json +3 -3
  41. package/src/constants.ts +3 -2
  42. package/src/core/ag_ui_chat.ts +217 -64
  43. package/src/core/agui_client.ts +155 -125
  44. package/src/core/attachment.ts +13 -5
  45. package/src/core/client_seed.ts +14 -2
  46. package/src/core/tool_outcome.ts +4 -3
  47. package/src/core/utils.ts +23 -0
  48. package/src/tools/tool_catalog.ts +63 -12
  49. package/src/ui/composer/composer_attachments.ts +64 -41
  50. package/src/ui/composer/composer_voice.ts +5 -0
  51. package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
  52. package/src/ui/history/conversation_history.ts +149 -38
  53. package/src/ui/placement/launcher_drag.ts +104 -89
  54. package/src/ui/placement/panel_placement.ts +27 -3
  55. package/src/ui/styles.ts +137 -14
  56. package/src/ui/transcript/transcript.ts +10 -5
  57. package/src/ui/ui_strings.ts +3 -0
  58. package/src/version.ts +1 -1
@@ -57,8 +57,15 @@ export class ComposerAttachments {
57
57
  * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
58
58
  * input + drag-and-drop, and mount the tray. With neither, the affordance
59
59
  * stays hidden and the chat degrades to text-only.
60
+ *
61
+ * Called on every connect, so it starts by taking down the tray the last
62
+ * connection mounted: that one was disposed when the element left, and the
63
+ * attributes that decide whether there is a tray at all may have changed
64
+ * since. The shell outlives a connection, so its listeners go under `signal`.
60
65
  */
61
- wire(): void {
66
+ wire(signal: AbortSignal): void {
67
+ this.#tray?.element.remove();
68
+ this.#tray = null;
62
69
  const url = this.#host.element.getAttribute("data-attachments-url");
63
70
  const upload = this.#host.uploadHandler() ?? this.#defaultUploadHandler(url);
64
71
  if (upload === null) {
@@ -82,8 +89,8 @@ export class ComposerAttachments {
82
89
  this.#host.slot.appendChild(this.#tray.element);
83
90
  this.#host.fileInput.accept = accept;
84
91
  this.#host.button.hidden = false;
85
- this.#enableDragAndDrop();
86
- this.#enablePaste(tray);
92
+ this.#enableDragAndDrop(signal);
93
+ this.#enablePaste(tray, signal);
87
94
  }
88
95
 
89
96
  /** The queueing behind `AgUiChat.attachFile`, whose doc is the contract. */
@@ -136,25 +143,37 @@ export class ComposerAttachments {
136
143
  }
137
144
 
138
145
  /** Accept files dropped anywhere on the chat shell into the tray. */
139
- #enableDragAndDrop(): void {
146
+ #enableDragAndDrop(signal: AbortSignal): void {
140
147
  const chat = this.#host.chat;
141
- chat.addEventListener("dragover", (event) => {
142
- event.preventDefault();
143
- chat.classList.add("chat--dragover");
144
- });
145
- chat.addEventListener("dragleave", () => {
146
- chat.classList.remove("chat--dragover");
147
- });
148
- chat.addEventListener("drop", (event) => {
149
- event.preventDefault();
150
- chat.classList.remove("chat--dragover");
151
- const files = event.dataTransfer?.files;
152
- if (files !== undefined) {
153
- for (const file of Array.from(files)) {
154
- this.#tray?.add(file);
148
+ chat.addEventListener(
149
+ "dragover",
150
+ (event) => {
151
+ event.preventDefault();
152
+ chat.classList.add("chat--dragover");
153
+ },
154
+ { signal },
155
+ );
156
+ chat.addEventListener(
157
+ "dragleave",
158
+ () => {
159
+ chat.classList.remove("chat--dragover");
160
+ },
161
+ { signal },
162
+ );
163
+ chat.addEventListener(
164
+ "drop",
165
+ (event) => {
166
+ event.preventDefault();
167
+ chat.classList.remove("chat--dragover");
168
+ const files = event.dataTransfer?.files;
169
+ if (files !== undefined) {
170
+ for (const file of Array.from(files)) {
171
+ this.#tray?.add(file);
172
+ }
155
173
  }
156
- }
157
- });
174
+ },
175
+ { signal },
176
+ );
158
177
  }
159
178
 
160
179
  /**
@@ -228,27 +247,31 @@ export class ComposerAttachments {
228
247
  * clipboard, and swallowing the words someone meant to paste in order to
229
248
  * attach a picture they did not is the worse of the two failures.
230
249
  */
231
- #enablePaste(tray: AttachmentTray): void {
232
- this.#host.chat.addEventListener("paste", (event: ClipboardEvent) => {
233
- // Nullish rather than a null check: the property is typed as nullable,
234
- // and an engine that fires a plain Event for a paste leaves it absent
235
- // instead, which is not the same value and is the same situation.
236
- const clipboard = event.clipboardData ?? null;
237
- if (clipboard === null) {
238
- return;
239
- }
240
- const files = Array.from(clipboard.files);
241
- if (files.length === 0) {
242
- this.#pasteLongTextAsFile(event, clipboard, tray);
243
- return;
244
- }
245
- if (clipboard.getData("text/plain") === "") {
246
- event.preventDefault();
247
- }
248
- for (const file of files) {
249
- this.#tray?.add(named(file));
250
- }
251
- });
250
+ #enablePaste(tray: AttachmentTray, signal: AbortSignal): void {
251
+ this.#host.chat.addEventListener(
252
+ "paste",
253
+ (event: ClipboardEvent) => {
254
+ // Nullish rather than a null check: the property is typed as nullable,
255
+ // and an engine that fires a plain Event for a paste leaves it absent
256
+ // instead, which is not the same value and is the same situation.
257
+ const clipboard = event.clipboardData ?? null;
258
+ if (clipboard === null) {
259
+ return;
260
+ }
261
+ const files = Array.from(clipboard.files);
262
+ if (files.length === 0) {
263
+ this.#pasteLongTextAsFile(event, clipboard, tray);
264
+ return;
265
+ }
266
+ if (clipboard.getData("text/plain") === "") {
267
+ event.preventDefault();
268
+ }
269
+ for (const file of files) {
270
+ this.#tray?.add(named(file));
271
+ }
272
+ },
273
+ { signal },
274
+ );
252
275
  }
253
276
 
254
277
  /** Tell the host what the tray now holds — see {@link ATTACHMENT_EVENT}. */
@@ -44,8 +44,13 @@ export class ComposerVoice {
44
44
  * built-in POST endpoint. The control records via `MediaRecorder` and drops
45
45
  * the transcript into the composer; with neither configured the mic stays
46
46
  * hidden and the chat is text-only.
47
+ *
48
+ * Called on every connect, so it starts by taking down the mic the last
49
+ * connection mounted, which was released when the element left.
47
50
  */
48
51
  wire(): void {
52
+ this.#voice?.element.remove();
53
+ this.#voice = null;
49
54
  const url = this.#host.element.getAttribute("data-transcribe-url");
50
55
  const transcribe = this.#host.transcribeHandler() ?? this.#defaultTranscribeHandler(url);
51
56
  if (transcribe === null) {
@@ -99,8 +99,13 @@ export class TranscriptQuoteOffer {
99
99
  this.#pageQuote = null;
100
100
  }
101
101
 
102
- /** Build the offer and listen for settled selections in the transcript. */
103
- mount(): void {
102
+ /**
103
+ * Build the offer and listen for settled selections in the transcript.
104
+ *
105
+ * The button and the transcript outlive a connection, so the listeners go
106
+ * under `signal`, which the element aborts when it leaves the document.
107
+ */
108
+ mount(signal: AbortSignal): void {
104
109
  const button = this.button;
105
110
  button.className = "quote-selection";
106
111
  button.type = "button";
@@ -111,23 +116,31 @@ export class TranscriptQuoteOffer {
111
116
  // selection first, and by the time a click lands there is nothing left to
112
117
  // quote. Preventing the default keeps the selection alive long enough to
113
118
  // read it.
114
- button.addEventListener("mousedown", (event) => {
115
- event.preventDefault();
116
- });
117
- button.addEventListener("click", () => {
118
- this.#host.quote(this.#quoting);
119
- window.getSelection()?.removeAllRanges();
120
- this.#hide();
121
- });
119
+ button.addEventListener(
120
+ "mousedown",
121
+ (event) => {
122
+ event.preventDefault();
123
+ },
124
+ { signal },
125
+ );
126
+ button.addEventListener(
127
+ "click",
128
+ () => {
129
+ this.#host.quote(this.#quoting);
130
+ window.getSelection()?.removeAllRanges();
131
+ this.#hide();
132
+ },
133
+ { signal },
134
+ );
122
135
 
123
136
  // A settled selection, by either input. `mouseup` rather than
124
137
  // `selectionchange` so the offer does not chase the pointer mid-drag; the
125
138
  // second half of the same gesture, `mousedown`, retires the previous offer
126
139
  // before the new selection exists.
127
140
  const messages = this.#host.messages;
128
- messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
129
- messages.addEventListener("keyup", () => this.#onSelectionSettled());
130
- messages.addEventListener("mousedown", () => this.#hide());
141
+ messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event), { signal });
142
+ messages.addEventListener("keyup", () => this.#onSelectionSettled(), { signal });
143
+ messages.addEventListener("mousedown", () => this.#hide(), { signal });
131
144
  }
132
145
 
133
146
  /** Whether the transcript offers to quote what the user selects. */
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "@ag-ui/client";
2
- import type { Message } from "@ag-ui/core";
2
+ import { contentToText, type Message } from "@ag-ui/core";
3
3
  import { MESSAGE_ROLE, TOOL_OUTCOME } from "../../constants.js";
4
4
  import type { ActivityRegistry } from "../../core/activity_registry.js";
5
5
  import type { AgUiClient } from "../../core/agui_client.js";
@@ -12,7 +12,7 @@ import type {
12
12
  import type { MessageRole } from "../../core/message_role.js";
13
13
  import { RunIndex } from "../../core/run_index.js";
14
14
  import { toolStatusFromOutcome } from "../../core/tool_outcome.js";
15
- import { answerUnansweredCalls, mintThread } from "../../core/utils.js";
15
+ import { answerUnansweredCalls, metadataOrTopLevel, mintThread } from "../../core/utils.js";
16
16
  import type { ToolCatalog } from "../../tools/tool_catalog.js";
17
17
  import type { AnswerActions } from "../transcript/answer_actions.js";
18
18
  import { renderAttachmentChips } from "../transcript/attachment_chips.js";
@@ -61,6 +61,17 @@ export interface ConversationHistoryHost {
61
61
  readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
62
62
  /** Resize the composer to its content. */
63
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;
64
75
  /** The conversation's own client, built on first use. */
65
76
  readonly ensureClient: () => AgUiClient;
66
77
  /**
@@ -69,6 +80,14 @@ export interface ConversationHistoryHost {
69
80
  * added to one can be missing from the other.
70
81
  */
71
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;
72
91
  /** Stop the in-flight run. */
73
92
  readonly cancelRun: () => void;
74
93
  /** Drop the in-memory run and transcript, leaving the thread untouched. */
@@ -97,10 +116,16 @@ export class ConversationHistory {
97
116
  /** The active thread's id. Empty until the element connects. */
98
117
  #threadId = "";
99
118
  /**
100
- * The messages the last restore replayed, which seed the next client the
101
- * element builds. Emptied with the rest of the in-memory run.
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.
102
122
  */
103
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;
104
129
  // Bumped on every rehydrate; a replay whose generation is stale (a newer
105
130
  // thread switch started while it awaited a slow store) drops its result.
106
131
  #generation = 0;
@@ -121,11 +146,16 @@ export class ConversationHistory {
121
146
  return this.#threadId;
122
147
  }
123
148
 
124
- /** The messages the last restore replayed, for seeding the next client. */
149
+ /** The conversation as last written, for seeding the next client. */
125
150
  get restored(): readonly Message[] {
126
151
  return this.#restored;
127
152
  }
128
153
 
154
+ /** The checkpoint continuation in flight, or `null` when none is running. */
155
+ get continuation(): AgUiClient | null {
156
+ return this.#continuation;
157
+ }
158
+
129
159
  /** Point at the thread the store says is active. */
130
160
  adoptActiveThread(): void {
131
161
  this.#threadId = this.#host.conversationStore().threadId();
@@ -153,6 +183,9 @@ export class ConversationHistory {
153
183
  /** Forget the messages the last restore seeded, with the rest of the run. */
154
184
  forgetRestored(): void {
155
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;
156
189
  }
157
190
 
158
191
  /** Delete the active thread if nothing was ever sent in it. */
@@ -258,13 +291,18 @@ export class ConversationHistory {
258
291
  * Uses a short-lived agent pointed at the resume / fork endpoint and seeded
259
292
  * with no history, because those endpoints supply the prior turns from the
260
293
  * snapshot and re-sending them would duplicate. A separate agent makes that
261
- * structural — the main agent keeps its own history — and mints the fresh
262
- * `run_id` the endpoints also require.
294
+ * structural and mints the fresh `run_id` the endpoints also require.
263
295
  *
264
296
  * Built by the same construction as the conversation's own client, so the
265
297
  * continuation streams into the same transcript the user is looking at and
266
298
  * runs under the same state, tools and bounds. It is the run in flight while
267
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.
268
306
  */
269
307
  async continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
270
308
  const index = this.runs();
@@ -275,6 +313,26 @@ export class ConversationHistory {
275
313
  // documented empty panel, which has no rows either. Typed, not silent.
276
314
  return;
277
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
+ }
278
336
  const content = this.#host.input.value.trim();
279
337
  if (content === "") {
280
338
  // A continuation sends *only* the next turn -- the snapshot supplies
@@ -289,31 +347,81 @@ export class ConversationHistory {
289
347
  // keystroke -- a transcript notice for a recoverable slip would outlive
290
348
  // the slip. Focus follows for the same reason applying a skill moves it when
291
349
  // a template is short of a field.
292
- this.#host.hint.textContent = this.#host.strings().continueNeedsTurn;
293
- this.#host.hint.hidden = false;
294
- this.#host.input.focus();
350
+ this.#refuse(this.#host.strings().continueNeedsTurn);
295
351
  return;
296
352
  }
297
353
  this.#host.input.value = "";
298
354
  this.#host.autoGrow();
355
+ const cleared = this.#cleared;
299
356
  const client = this.#host.buildClient({
300
357
  endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
301
358
  // The seed the endpoints assume: nothing. The snapshot is the history.
302
359
  initialMessages: [],
303
- // Nor does it write the store. Its agent holds only the new turn and its
304
- // answer, and a store keeps one list per thread, so saving what this
305
- // client has would replace the conversation with its last exchange.
306
- persist: false,
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()?.messages ?? 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
+ },
307
392
  });
308
393
  this.#continuation = client;
309
- await client.send(content);
310
- // Only if it is still the one in flight: stopping forgets it at once, and a
311
- // continuation started after that one is not this one to forget.
312
- if (this.#continuation === client) {
313
- this.#continuation = null;
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
+ }
314
412
  }
315
413
  }
316
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
+
317
425
  /**
318
426
  * Restore the conversation from the store on mount, then — if a navigating
319
427
  * tool reloaded the page mid-run — resume the loop by supplying that tool's
@@ -379,16 +487,14 @@ export class ConversationHistory {
379
487
  const restored = answerUnansweredCalls(
380
488
  messages,
381
489
  // The shape the store holds for a call the client answered the same way:
382
- // its tool message, with the outcome it annotates onto the stored copy.
383
- // Cast at the AG-UI boundary, as the client's own annotation is.
384
- (toolCallId) =>
385
- ({
386
- id: randomUUID(),
387
- role: "tool",
388
- content: unfinished,
389
- toolCallId,
390
- outcome: TOOL_OUTCOME.INTERRUPTED,
391
- }) as Message,
490
+ // its tool message, with the outcome in its metadata.
491
+ (toolCallId) => ({
492
+ id: randomUUID(),
493
+ role: "tool",
494
+ content: unfinished,
495
+ toolCallId,
496
+ metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
497
+ }),
392
498
  new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
393
499
  );
394
500
  this.#restored = restored;
@@ -503,16 +609,21 @@ export class ConversationHistory {
503
609
  if (message.role === "tool") {
504
610
  const card = this.#host.transcript.card(message.toolCallId);
505
611
  if (card !== undefined) {
506
- // The outcome `AgUiClient` annotated onto the persisted message, read
507
- // back through the same mapping the live path uses -- so a card that
508
- // said "declined" before the reload still says it after. Narrowed off
509
- // `unknown` rather than trusted, like every other field read out of the
510
- // store: `Message` does not declare it, a host store may not round-trip
511
- // it, and history written before this shipped has none. All three land
512
- // on DONE, which is what this line did unconditionally.
612
+ // The outcome in the message's metadata -- folded there from a server's
613
+ // result by `@ag-ui/client`, or written there by `AgUiClient` for a
614
+ // result it made itself -- read back through the same mapping the live
615
+ // path uses, so a card that said "declined" before the reload still
616
+ // says it after. History stored by an earlier release has it at the
617
+ // top level instead, which is read when the metadata has none. Narrowed
618
+ // off `unknown` rather than trusted, like every other field read out of
619
+ // the store: metadata is open by key, a host store may not round-trip
620
+ // it, and history written before outcomes existed has none. All three
621
+ // land on DONE, which is what this line did unconditionally.
513
622
  card.settle(
514
- toolStatusFromOutcome((message as { outcome?: unknown }).outcome),
515
- message.content,
623
+ toolStatusFromOutcome(metadataOrTopLevel(message, "outcome")),
624
+ // Flattened exactly as the live path flattens it, so a reload shows
625
+ // the card the user watched settle.
626
+ contentToText(message.content),
516
627
  );
517
628
  }
518
629
  }