@artooi/ag-ui-web-component 0.9.0 → 0.11.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 (61) hide show
  1. package/CHANGELOG.md +104 -7
  2. package/README.md +89 -7
  3. package/dist/ag-ui-web-component.bundle.js +168 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +39 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +28 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/attachment.d.ts +5 -0
  10. package/dist/core/attachment.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +8 -0
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  14. package/dist/core/upload_attachment.d.ts +8 -2
  15. package/dist/core/upload_attachment.d.ts.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +887 -128
  19. package/dist/index.js.map +4 -4
  20. package/dist/ui/approval_card.d.ts +51 -0
  21. package/dist/ui/approval_card.d.ts.map +1 -0
  22. package/dist/ui/attachment_chips.d.ts.map +1 -1
  23. package/dist/ui/attachment_tray.d.ts +7 -1
  24. package/dist/ui/attachment_tray.d.ts.map +1 -1
  25. package/dist/ui/question_card.d.ts +52 -0
  26. package/dist/ui/question_card.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +5 -3
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/skills_menu.d.ts.map +1 -1
  30. package/dist/ui/styles.d.ts +1 -1
  31. package/dist/ui/styles.d.ts.map +1 -1
  32. package/dist/ui/thoughts_block.d.ts +2 -2
  33. package/dist/ui/thoughts_block.d.ts.map +1 -1
  34. package/dist/ui/thread_drawer.d.ts.map +1 -1
  35. package/dist/ui/tool_call_card.d.ts.map +1 -1
  36. package/dist/ui/ui_strings.d.ts +16 -0
  37. package/dist/ui/ui_strings.d.ts.map +1 -1
  38. package/dist/ui/voice_input.d.ts +9 -1
  39. package/dist/ui/voice_input.d.ts.map +1 -1
  40. package/dist/version.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/core/ag_ui_chat.ts +251 -14
  43. package/src/core/agui_client.ts +95 -9
  44. package/src/core/attachment.ts +21 -1
  45. package/src/core/conversation_store.ts +84 -18
  46. package/src/core/remote_conversation_store.ts +24 -3
  47. package/src/core/upload_attachment.ts +8 -1
  48. package/src/index.ts +14 -0
  49. package/src/ui/approval_card.ts +119 -0
  50. package/src/ui/attachment_chips.ts +5 -0
  51. package/src/ui/attachment_tray.ts +50 -5
  52. package/src/ui/question_card.ts +216 -0
  53. package/src/ui/relative_time.ts +8 -3
  54. package/src/ui/skills_menu.ts +6 -0
  55. package/src/ui/styles.ts +130 -9
  56. package/src/ui/thoughts_block.ts +3 -2
  57. package/src/ui/thread_drawer.ts +94 -9
  58. package/src/ui/tool_call_card.ts +6 -0
  59. package/src/ui/ui_strings.ts +30 -0
  60. package/src/ui/voice_input.ts +21 -1
  61. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import type { Context, Message, Tool } from "@ag-ui/core";
1
+ import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
2
2
  import {
3
3
  DEFAULT_ATTACHMENT_MAX_BYTES,
4
4
  MESSAGE_ROLE,
@@ -20,10 +20,20 @@ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
20
20
  import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
21
21
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
22
22
  import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
23
+ import {
24
+ type ApprovalRenderer,
25
+ type ApprovalRequest,
26
+ requestApproval,
27
+ } from "../ui/approval_card.js";
23
28
  import { renderAttachmentChips } from "../ui/attachment_chips.js";
24
29
  import { AttachmentTray } from "../ui/attachment_tray.js";
25
30
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
26
31
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
32
+ import {
33
+ type QuestionRenderer,
34
+ type QuestionRequest,
35
+ requestQuestion,
36
+ } from "../ui/question_card.js";
27
37
  import { renderMarkdown } from "../ui/render_markdown.js";
28
38
  import { wrapWords } from "../ui/reveal_words.js";
29
39
  import { SkillsMenu } from "../ui/skills_menu.js";
@@ -37,6 +47,7 @@ import {
37
47
  AgUiClient,
38
48
  type AgUiClientHandlers,
39
49
  type AgUiToolCall,
50
+ type InterruptResponse,
40
51
  type ToolExecution,
41
52
  } from "./agui_client.js";
42
53
  import { type AttachmentRef, messageAttachments } from "./attachment.js";
@@ -68,7 +79,7 @@ export interface ToggleDetail {
68
79
  /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
69
80
  const COLLAPSED_KEY = "ag-ui-chat:collapsed";
70
81
 
71
- /** Per-tab persistence key for the built-in theme toggle (THEME-1). */
82
+ /** Per-tab persistence key for the built-in theme toggle. */
72
83
  const THEME_KEY = "ag-ui-chat:theme";
73
84
 
74
85
  /**
@@ -101,6 +112,36 @@ export class AgUiChat extends HTMLElement {
101
112
  /** When true, destructive tools execute without a confirmation modal. */
102
113
  autoConfirm = false;
103
114
 
115
+ /**
116
+ * When true, the built-in `ask_user` frontend tool is offered to the agent:
117
+ * calling it renders an inline question card (radio choices and/or a free-text
118
+ * field) and returns the user's answer. Off by default — like the other
119
+ * built-in tool groups (route / page-action), it is opt-in so it doesn't
120
+ * change the advertised catalog until a host asks for it.
121
+ */
122
+ askUser = false;
123
+
124
+ /**
125
+ * Optional full replacement for the `ask_user` question UI. When set, calling
126
+ * `ask_user` invokes this instead of the built-in inline card: the host
127
+ * renders whatever it likes (a native modal, a framework component, …) and
128
+ * resolves with the answer. Unset (default) uses the built-in
129
+ * {@link requestQuestion} card — style that via the `strings` override and the
130
+ * `question*` CSS `::part()`s. Requires {@link askUser} to be enabled.
131
+ */
132
+ askUserRenderer: QuestionRenderer | null = null;
133
+
134
+ /**
135
+ * Optional full replacement for the server-side-tool approval UI. When set, an
136
+ * approval interrupt invokes this instead of the built-in inline approval
137
+ * card: the host renders whatever it likes and resolves `true` to approve /
138
+ * `false` to deny. Unset (default) uses the built-in {@link requestApproval}
139
+ * card — style that via the `strings` override and the `approval*` CSS
140
+ * `::part()`s. The gate itself is enabled server-side; this only changes how
141
+ * the decision is collected.
142
+ */
143
+ approvalRenderer: ApprovalRenderer | null = null;
144
+
104
145
  /**
105
146
  * Optional per-call confirmation predicate. When set, it is authoritative:
106
147
  * given a tool name + args it decides whether *this* call needs confirmation
@@ -259,7 +300,7 @@ export class AgUiChat extends HTMLElement {
259
300
  readonly #attachButton: HTMLButtonElement;
260
301
  readonly #fileInput: HTMLInputElement;
261
302
  readonly #attachSlot: HTMLDivElement;
262
- /** Optional built-in header theme toggle (THEME-1); shown only with `data-theme-toggle`. */
303
+ /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
263
304
  readonly #themeToggle: HTMLButtonElement;
264
305
  /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
265
306
  readonly #rail: HTMLButtonElement;
@@ -288,18 +329,25 @@ export class AgUiChat extends HTMLElement {
288
329
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
289
330
  #streamDeltas = 0;
290
331
  #pending: HTMLDivElement | null = null;
291
- // The current assistant turn's grouping container (WELL-1). One `.answer`
332
+ // The current assistant turn's grouping container. One `.answer`
292
333
  // wraps everything a single answer produces — streamed text, tool cards, the
293
334
  // pending indicator — so it can be boxed as one "well" by CSS. Opened on the
294
335
  // turn's first run start, closed at settle, so it spans the whole multi-round
295
336
  // frontend-tool loop (which is several AG-UI runs), not one run. `null`
296
337
  // between turns; user bubbles never enter it.
297
338
  #currentGroup: HTMLDivElement | null = null;
298
- // The current turn's streamed-reasoning region (THINK-1), shown at the top of
339
+ // The current turn's streamed-reasoning region, shown at the top of
299
340
  // the answer group while a reasoning model thinks and collapsed once the
300
341
  // answer's first text token arrives. `null` outside a reasoning turn.
301
342
  #thoughts: ThoughtsBlock | null = null;
302
343
  #threadId = "";
344
+ // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
345
+ // active thread), so two instances on one origin don't clobber each other.
346
+ // Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
347
+ #storageNs = "";
348
+ // Bumped on every #rehydrate; a replay whose generation is stale (a newer
349
+ // thread switch started while it awaited a slow store) drops its result.
350
+ #rehydrateGeneration = 0;
303
351
  #initialMessages: readonly Message[] = [];
304
352
  // Skill catalog by source; merged backend → embed → client (later wins).
305
353
  #backendSkills: readonly Skill[] = [];
@@ -423,9 +471,84 @@ export class AgUiChat extends HTMLElement {
423
471
  return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
424
472
  }
425
473
 
426
- /** All built-in (route + page + page-action) frontend tools. */
474
+ /** All built-in (route + page + page-action + ask_user) frontend tools. */
427
475
  #builtinTools(): ClientTool[] {
428
- return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
476
+ return [
477
+ ...this.#routeTools(),
478
+ ...this.#pageTools(),
479
+ ...this.#pageActionTools(),
480
+ ...this.#askUserTool(),
481
+ ];
482
+ }
483
+
484
+ /**
485
+ * The built-in `ask_user` frontend tool, or `[]` when {@link askUser} is off.
486
+ *
487
+ * A generic "ask the user a typed question" primitive: the agent calls it, the
488
+ * client executes it locally via the normal frontend-tool path (rendering a
489
+ * {@link requestQuestion} card), and the chosen/typed answer flows back as the
490
+ * tool result — no new protocol, reusing the machinery already in place.
491
+ */
492
+ #askUserTool(): ClientTool[] {
493
+ if (!this.askUser) {
494
+ return [];
495
+ }
496
+ return [
497
+ {
498
+ name: "ask_user",
499
+ description:
500
+ "Ask the user a question and wait for their answer. Provide `options` for a " +
501
+ "multiple-choice prompt; set `allow_custom` to also accept a free-text answer.",
502
+ parameters: {
503
+ type: "object",
504
+ properties: {
505
+ question: { type: "string", description: "The question to ask the user." },
506
+ options: {
507
+ type: "array",
508
+ items: { type: "string" },
509
+ description: "Preset choices offered as radio buttons.",
510
+ },
511
+ allow_custom: {
512
+ type: "boolean",
513
+ description: "Allow a free-text answer in addition to any options.",
514
+ },
515
+ },
516
+ required: ["question"],
517
+ },
518
+ handler: (args) => this.#askUser(args),
519
+ },
520
+ ];
521
+ }
522
+
523
+ /** Render the `ask_user` question card and resolve with the user's answer. */
524
+ async #askUser(args: Record<string, unknown>): Promise<string> {
525
+ const question = typeof args["question"] === "string" ? args["question"] : "";
526
+ const request: QuestionRequest = { question };
527
+ const rawOptions = args["options"];
528
+ if (Array.isArray(rawOptions)) {
529
+ request.options = rawOptions.filter((option): option is string => typeof option === "string");
530
+ }
531
+ if (args["allow_custom"] === true) {
532
+ request.allowCustom = true;
533
+ }
534
+ // The run is suspended on the card; a Stop aborts the controller, resolving
535
+ // it with an empty answer (the run is then cancelled).
536
+ this.#confirmAbort = new AbortController();
537
+ const signal = this.#confirmAbort.signal;
538
+ this.#hidePending();
539
+ // A host-supplied renderer takes full control of the UI; otherwise the
540
+ // built-in inline card renders into the current answer group.
541
+ const answer =
542
+ this.askUserRenderer !== null
543
+ ? await this.askUserRenderer(request, { signal })
544
+ : await requestQuestion(this.#ensureGroup(), request, {
545
+ signal,
546
+ strings: this.#strings,
547
+ });
548
+ this.#confirmAbort = null;
549
+ this.#updateEmptyState();
550
+ this.#messages.scrollTop = this.#messages.scrollHeight;
551
+ return answer;
429
552
  }
430
553
 
431
554
  /** Resolve a tool by name: built-in tools first, then the registry. */
@@ -470,25 +593,34 @@ export class AgUiChat extends HTMLElement {
470
593
  }
471
594
 
472
595
  connectedCallback(): void {
596
+ // Resolve the per-instance storage namespace (id, else endpoint) before any
597
+ // key read/write, so this instance doesn't share collapsed/theme/thread
598
+ // state with another on the same origin.
599
+ this.#storageNs = this.id !== "" ? this.id : this.endpoint;
473
600
  // Resolve the string table before rendering any chrome (defaults are the
474
601
  // floor; `data-strings` then the `strings` property layer over them).
475
602
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
476
603
  // Restore a theme the built-in toggle persisted last visit (opt-in only, so
477
604
  // it never overrides a host that drives `theme` itself).
478
605
  if (this.getAttribute("data-theme-toggle") !== null) {
479
- const saved = sessionStorage.getItem(THEME_KEY);
606
+ const saved = this.#readScopedItem(THEME_KEY);
480
607
  if (saved !== null) {
481
608
  this.setAttribute("theme", saved);
482
609
  }
483
610
  }
484
611
  this.#render();
485
612
  this.#drawer.setStrings(this.#strings);
486
- if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
613
+ if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
487
614
  this.setAttribute("collapsed", "");
488
615
  }
489
616
  this.#syncRail();
490
617
  this.#initSkills();
491
618
  void this.#fetchToolCatalog();
619
+ // Namespace the built-in default store too (a host-injected store is used
620
+ // verbatim). Must precede #wireThreadStore, which wraps the current store.
621
+ if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
622
+ this.conversationStore = new SessionStorageStore(this.#storageNs);
623
+ }
492
624
  this.#wireThreadStore();
493
625
  this.#wireAttachments();
494
626
  this.#wireVoice();
@@ -496,6 +628,20 @@ export class AgUiChat extends HTMLElement {
496
628
  void this.#rehydrate();
497
629
  }
498
630
 
631
+ /**
632
+ * Tear down live resources when the element leaves the DOM (a removed node, a
633
+ * client-side route swap): cancel the in-flight run so its SSE stream closes,
634
+ * abort any in-flight uploads so they don't orphan server-side files, and
635
+ * release the mic so the browser's recording indicator clears. Without this a
636
+ * removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
637
+ * `MediaRecorder`.
638
+ */
639
+ disconnectedCallback(): void {
640
+ this.#cancelRun();
641
+ this.#attachTray?.dispose();
642
+ this.#voice?.dispose();
643
+ }
644
+
499
645
  /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
500
646
  #readStringOverrides(): Partial<UiStrings> {
501
647
  const raw = this.getAttribute("data-strings");
@@ -544,7 +690,10 @@ export class AgUiChat extends HTMLElement {
544
690
  if (url === null) {
545
691
  return null;
546
692
  }
547
- return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
693
+ // Forward the tray's abort signal so removing a chip (or tearing the
694
+ // element down) cancels the XHR.
695
+ return (file, onProgress, signal) =>
696
+ uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
548
697
  }
549
698
 
550
699
  /**
@@ -770,7 +919,7 @@ export class AgUiChat extends HTMLElement {
770
919
  } else {
771
920
  this.removeAttribute("collapsed");
772
921
  }
773
- sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
922
+ sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
774
923
  this.#syncRail();
775
924
  this.dispatchEvent(
776
925
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
@@ -795,10 +944,28 @@ export class AgUiChat extends HTMLElement {
795
944
  toggleTheme(): void {
796
945
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
797
946
  this.setAttribute("theme", next);
798
- sessionStorage.setItem(THEME_KEY, next);
947
+ sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
799
948
  this.#syncThemeGlyph();
800
949
  }
801
950
 
951
+ /** This instance's namespaced form of an origin-scoped storage key. */
952
+ #storageKey(base: string): string {
953
+ return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
954
+ }
955
+
956
+ /**
957
+ * Read a namespaced origin-scoped value, falling back once to the legacy
958
+ * pre-namespacing global key (left in place) so an existing collapsed/theme
959
+ * preference survives the upgrade.
960
+ */
961
+ #readScopedItem(base: string): string | null {
962
+ const scoped = sessionStorage.getItem(this.#storageKey(base));
963
+ if (scoped !== null || this.#storageNs === "") {
964
+ return scoped;
965
+ }
966
+ return sessionStorage.getItem(base);
967
+ }
968
+
802
969
  /** Reflect the current theme on the toggle: show the destination's glyph. */
803
970
  #syncThemeGlyph(): void {
804
971
  const dark = this.getAttribute("theme") === "dark";
@@ -875,7 +1042,16 @@ export class AgUiChat extends HTMLElement {
875
1042
  * result from the page we landed on.
876
1043
  */
877
1044
  async #rehydrate(): Promise<void> {
1045
+ // Guard against a thread-switch race: with a slow remote store, picking
1046
+ // thread B then C would interleave both replays into one transcript. Each
1047
+ // rehydrate claims a generation before awaiting and bails if a newer one
1048
+ // started meanwhile (its `#resetState` already cleared the transcript).
1049
+ this.#rehydrateGeneration += 1;
1050
+ const generation = this.#rehydrateGeneration;
878
1051
  const messages = await this.conversationStore.loadMessages(this.#threadId);
1052
+ if (generation !== this.#rehydrateGeneration) {
1053
+ return;
1054
+ }
879
1055
  if (messages !== null) {
880
1056
  this.#initialMessages = messages;
881
1057
  for (const message of messages) {
@@ -973,7 +1149,7 @@ export class AgUiChat extends HTMLElement {
973
1149
  * stays literal text (no need to parse what the user typed, and it avoids
974
1150
  * rendering user-authored markup).
975
1151
  *
976
- * Assistant bubbles land in the current answer group (WELL-1), opening one if
1152
+ * Assistant bubbles land in the current answer group, opening one if
977
1153
  * needed; a user bubble closes the prior group and sits directly in the list
978
1154
  * (the well wraps the *assistant* turn, the user message precedes it).
979
1155
  */
@@ -1060,7 +1236,7 @@ export class AgUiChat extends HTMLElement {
1060
1236
  collapse.addEventListener("click", () => this.toggleCollapsed());
1061
1237
 
1062
1238
  controls.append(history, newChat);
1063
- // Optional built-in theme toggle (THEME-1): off unless the host opts in, so
1239
+ // Optional built-in theme toggle: off unless the host opts in, so
1064
1240
  // it never competes with a host-supplied switch in `slot="header-actions"`.
1065
1241
  if (this.getAttribute("data-theme-toggle") !== null) {
1066
1242
  this.#themeToggle.type = "button";
@@ -1120,6 +1296,7 @@ export class AgUiChat extends HTMLElement {
1120
1296
  });
1121
1297
 
1122
1298
  this.#skillHint.className = "skill-hint";
1299
+ this.#skillHint.setAttribute("part", "skill-hint");
1123
1300
  this.#skillHint.hidden = true;
1124
1301
 
1125
1302
  // File-upload affordance: a 📎 button (hidden until `data-attachments-url`
@@ -1269,6 +1446,14 @@ export class AgUiChat extends HTMLElement {
1269
1446
  }
1270
1447
 
1271
1448
  async #submit(): Promise<void> {
1449
+ // Ignore a submit while a run is in flight — the single choke point for
1450
+ // both Enter and the Send button. The button already turns into Stop, but
1451
+ // Enter has no such guard; without this it would start a second concurrent
1452
+ // SSE run that orphans the first (unabortable) and lets the second run's
1453
+ // settle sweep corrupt the first's still-pending tool cards.
1454
+ if (this.#running) {
1455
+ return;
1456
+ }
1272
1457
  const content = this.#input.value.trim();
1273
1458
  const attachments = this.#attachTray?.readyRefs() ?? [];
1274
1459
  // Allow an attachments-only message (no typed text), but nothing empty.
@@ -1320,6 +1505,7 @@ export class AgUiChat extends HTMLElement {
1320
1505
  getTools: () => this.getTools(),
1321
1506
  getContext: () => this.getContext(),
1322
1507
  executeTool: (call) => this.#executeTool(call),
1508
+ resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
1323
1509
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
1324
1510
  connectionLostMessage: this.#strings.connectionLost,
1325
1511
  });
@@ -1410,6 +1596,57 @@ export class AgUiChat extends HTMLElement {
1410
1596
  }
1411
1597
  }
1412
1598
 
1599
+ /**
1600
+ * Render an approval card per server-side-tool interrupt and collect the
1601
+ * user's decisions (approve → run it, deny → decline it).
1602
+ *
1603
+ * The run is suspended on these cards; a Stop while any is open aborts the
1604
+ * shared {@link #confirmAbort} controller, resolving every still-open card as
1605
+ * denied (and the client loop then sees the cancellation and stops). An
1606
+ * approved tool runs on the follow-up (resume) run and streams its result
1607
+ * back into the same pending card; a denied one is settled here, since no
1608
+ * result will ever arrive for it.
1609
+ */
1610
+ async #resolveInterrupts(
1611
+ interrupts: readonly Interrupt[],
1612
+ ): Promise<Record<string, InterruptResponse>> {
1613
+ const responses: Record<string, InterruptResponse> = {};
1614
+ // One controller covers the whole batch: a single Stop denies all of them.
1615
+ this.#confirmAbort = new AbortController();
1616
+ this.#hidePending();
1617
+ for (const interrupt of interrupts) {
1618
+ const request: ApprovalRequest = {};
1619
+ if (interrupt.message !== undefined) {
1620
+ request.message = interrupt.message;
1621
+ }
1622
+ const card =
1623
+ interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
1624
+ const toolName = card?.element.getAttribute("data-tool-name");
1625
+ if (toolName !== null && toolName !== undefined) {
1626
+ request.toolName = toolName;
1627
+ }
1628
+ const signal = this.#confirmAbort.signal;
1629
+ // A host-supplied renderer takes full control of the approval UI;
1630
+ // otherwise the built-in inline card renders into the current answer group.
1631
+ const approved =
1632
+ this.approvalRenderer !== null
1633
+ ? await this.approvalRenderer(request, { signal })
1634
+ : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
1635
+ this.#updateEmptyState();
1636
+ this.#messages.scrollTop = this.#messages.scrollHeight;
1637
+ if (approved) {
1638
+ responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
1639
+ } else {
1640
+ responses[interrupt.id] = { status: "cancelled" };
1641
+ // No TOOL_CALL_RESULT will stream for a denied tool — settle its pending
1642
+ // card now rather than leaving it hanging until the onSettled sweep.
1643
+ card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
1644
+ }
1645
+ }
1646
+ this.#confirmAbort = null;
1647
+ return responses;
1648
+ }
1649
+
1413
1650
  #handlers(): AgUiClientHandlers {
1414
1651
  return {
1415
1652
  onRunStart: () => {
@@ -1,5 +1,11 @@
1
- import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
2
- import type { Context, Message, Tool } from "@ag-ui/core";
1
+ import {
2
+ type AbstractAgent,
3
+ type AgentSubscriber,
4
+ buildResumeArray,
5
+ type RunAgentParameters,
6
+ randomUUID,
7
+ } from "@ag-ui/client";
8
+ import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
3
9
  import { MAX_TOOL_ROUNDS } from "../constants.js";
4
10
  import type { AttachmentRef } from "./attachment.js";
5
11
 
@@ -33,6 +39,27 @@ export interface ToolExecution {
33
39
  */
34
40
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
35
41
 
42
+ /**
43
+ * One user decision for a server-side-tool approval interrupt. Structurally
44
+ * matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
45
+ * {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
46
+ * an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
47
+ */
48
+ export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
49
+
50
+ /**
51
+ * Resolves the approval interrupts a run finished on, keyed by interrupt id.
52
+ *
53
+ * When a gated **server-side** tool defers instead of executing, the run
54
+ * finishes on an AG-UI interrupt outcome; the host renders an approval card per
55
+ * interrupt and returns each decision here, and the loop resumes the run with
56
+ * the answers. Omit for agents that never gate server-side tools — an
57
+ * unresolved interrupt then simply ends the loop.
58
+ */
59
+ export type ResolveInterrupts = (
60
+ interrupts: readonly Interrupt[],
61
+ ) => Promise<Record<string, InterruptResponse>>;
62
+
36
63
  /**
37
64
  * Callbacks the {@link AgUiClient} invokes as a run progresses. The host
38
65
  * (the `<ag-ui-chat>` element) implements these to render streaming text and
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
93
120
  handlers: AgUiClientHandlers;
94
121
  /** Executes frontend tool calls. Omit for server-only tool sets. */
95
122
  executeTool?: ExecuteTool;
123
+ /**
124
+ * Resolves server-side-tool approval interrupts. Omit when no server-side
125
+ * tool is gated for approval — an interrupt then ends the loop unanswered.
126
+ */
127
+ resolveInterrupts?: ResolveInterrupts;
96
128
  /**
97
129
  * Invoked with the latest history whenever it changes, so the host can
98
130
  * persist it for durability across page reloads. Omit to keep the
@@ -134,6 +166,7 @@ export class AgUiClient {
134
166
  readonly #getTools: () => Tool[];
135
167
  readonly #getContext: () => Context[];
136
168
  readonly #executeTool: ExecuteTool | null;
169
+ readonly #resolveInterrupts: ResolveInterrupts | null;
137
170
  readonly #onPersist: (messages: readonly Message[]) => void;
138
171
  readonly #connectionLostMessage: string;
139
172
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
@@ -146,6 +179,7 @@ export class AgUiClient {
146
179
  this.#getTools = config.getTools ?? (() => []);
147
180
  this.#getContext = config.getContext ?? (() => []);
148
181
  this.#executeTool = config.executeTool ?? null;
182
+ this.#resolveInterrupts = config.resolveInterrupts ?? null;
149
183
  this.#onPersist = config.onPersist ?? (() => {});
150
184
  this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
151
185
  }
@@ -241,6 +275,11 @@ export class AgUiClient {
241
275
  }
242
276
 
243
277
  async #runLoop(): Promise<void> {
278
+ // Carries the resolved approval answers into the *next* run when a round
279
+ // finished on a server-side-tool interrupt. Distinct from the public
280
+ // resume() navigation-reload path (which continues an unfinished
281
+ // frontend-tool round after a page load) — this stays inside one #run().
282
+ let resume: ResumeEntry[] | undefined;
244
283
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
245
284
  // A cancel during the previous round's frontend-tool execution lands
246
285
  // here: the running handler completed, but no further round starts.
@@ -248,11 +287,16 @@ export class AgUiClient {
248
287
  return;
249
288
  }
250
289
  const pending: AgUiToolCall[] = [];
251
- const runState = { terminal: false };
252
- await this.#agent.runAgent(
253
- { tools: this.#getTools(), context: this.#getContext() },
254
- this.#buildSubscriber(pending, runState),
255
- );
290
+ const runState: RunState = { terminal: false, errored: false, interrupts: [] };
291
+ const params: RunAgentParameters = {
292
+ tools: this.#getTools(),
293
+ context: this.#getContext(),
294
+ };
295
+ if (resume !== undefined) {
296
+ params.resume = resume;
297
+ }
298
+ await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
299
+ resume = undefined;
256
300
  this.#onPersist(this.#agent.messages);
257
301
  // Cancelled mid-stream: the user said stop — don't execute the tool
258
302
  // calls collected before the abort.
@@ -265,6 +309,29 @@ export class AgUiClient {
265
309
  if (!runState.terminal) {
266
310
  throw new ConnectionLostError(this.#connectionLostMessage);
267
311
  }
312
+ // RUN_ERROR is terminal: the agent already reported the failure via
313
+ // onError. Don't execute the tool calls collected before it or start
314
+ // another round — that would run into a broken context and surface a
315
+ // confusing second error. Any pending tool card is swept at onSettled.
316
+ if (runState.errored) {
317
+ return;
318
+ }
319
+ // A gated server-side tool deferred instead of executing: the run finished
320
+ // on an interrupt outcome. Ask the host to resolve each interrupt, then
321
+ // re-enter the loop carrying the answers — the follow-up run runs or denies
322
+ // the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
323
+ // over the frontend-tool sweep below: a server-side tool isn't ours to run.
324
+ if (runState.interrupts.length > 0) {
325
+ if (this.#resolveInterrupts === null) {
326
+ return;
327
+ }
328
+ const responses = await this.#resolveInterrupts(runState.interrupts);
329
+ if (this.#cancelled) {
330
+ return;
331
+ }
332
+ resume = buildResumeArray(runState.interrupts, responses);
333
+ continue;
334
+ }
268
335
  if (this.#executeTool === null || pending.length === 0) {
269
336
  return;
270
337
  }
@@ -294,7 +361,7 @@ export class AgUiClient {
294
361
  }
295
362
  }
296
363
 
297
- #buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
364
+ #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
298
365
  const h = this.#handlers;
299
366
  return {
300
367
  onRunInitialized() {
@@ -318,7 +385,7 @@ export class AgUiClient {
318
385
  onToolCallResultEvent({ event }) {
319
386
  h.onToolResult(event.toolCallId, event.content);
320
387
  },
321
- // Reasoning (THINK-1). `@ag-ui/client` already maps the deprecated
388
+ // Reasoning. `@ag-ui/client` already maps the deprecated
322
389
  // THINKING_* events onto these REASONING_* callbacks, so handling the
323
390
  // reasoning family alone covers both protocol versions.
324
391
  onReasoningStartEvent() {
@@ -330,8 +397,19 @@ export class AgUiClient {
330
397
  onReasoningEndEvent() {
331
398
  h.onReasoningEnd();
332
399
  },
400
+ onRunFinishedEvent(params) {
401
+ // RUN_FINISHED is terminal for both a normal finish and an interrupt.
402
+ // Capturing the interrupts here (rather than reading the agent's
403
+ // `pendingInterrupts` field afterwards) keeps the loop self-contained
404
+ // and independent of that field's cross-run clearing semantics.
405
+ runState.terminal = true;
406
+ if (params.outcome === "interrupt") {
407
+ runState.interrupts = params.interrupts;
408
+ }
409
+ },
333
410
  onRunErrorEvent({ event }) {
334
411
  runState.terminal = true;
412
+ runState.errored = true;
335
413
  h.onError(event.message);
336
414
  },
337
415
  onRunFinalized() {
@@ -342,6 +420,14 @@ export class AgUiClient {
342
420
  }
343
421
  }
344
422
 
423
+ /** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
424
+ interface RunState {
425
+ terminal: boolean;
426
+ errored: boolean;
427
+ /** Approval interrupts a run finished on (empty for a normal finish). */
428
+ interrupts: Interrupt[];
429
+ }
430
+
345
431
  /**
346
432
  * Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
347
433
  * with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
@@ -32,8 +32,28 @@ export interface AttachmentRef {
32
32
  * restored conversation re-renders its attachment chips. The server's strict
33
33
  * `RunAgentInput` validation ignores the unknown field — the model learns the
34
34
  * ids from the run context manifest instead.
35
+ *
36
+ * The persisted array is untrusted (it can be hand-edited, truncated, or
37
+ * corrupted in storage), so every entry is validated and malformed ones are
38
+ * dropped — a `null` or shapeless entry would otherwise throw in `iconFor` and
39
+ * abort the whole history replay.
35
40
  */
36
41
  export function messageAttachments(message: Message): readonly AttachmentRef[] {
37
42
  const refs = (message as { attachments?: unknown }).attachments;
38
- return Array.isArray(refs) ? (refs as readonly AttachmentRef[]) : [];
43
+ return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
44
+ }
45
+
46
+ /** Whether an unknown value is a structurally valid {@link AttachmentRef}. */
47
+ function isAttachmentRef(value: unknown): value is AttachmentRef {
48
+ if (typeof value !== "object" || value === null) {
49
+ return false;
50
+ }
51
+ const ref = value as Record<string, unknown>;
52
+ return (
53
+ typeof ref["id"] === "string" &&
54
+ typeof ref["name"] === "string" &&
55
+ typeof ref["mime"] === "string" &&
56
+ typeof ref["size"] === "number" &&
57
+ (ref["url"] === undefined || typeof ref["url"] === "string")
58
+ );
39
59
  }