@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.
Files changed (142) hide show
  1. package/CHANGELOG.md +515 -1
  2. package/README.md +232 -56
  3. package/dist/ag-ui-web-component.bundle.js +370 -139
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +17 -14
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/activity_registry.d.ts +46 -0
  8. package/dist/core/activity_registry.d.ts.map +1 -0
  9. package/dist/core/ag_ui_chat.d.ts +23 -10
  10. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  11. package/dist/core/agui_client.d.ts +28 -0
  12. package/dist/core/agui_client.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +27 -0
  14. package/dist/core/client_seed.d.ts.map +1 -0
  15. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  16. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +80 -0
  18. package/dist/core/run_handlers.d.ts.map +1 -0
  19. package/dist/core/storage_scope.d.ts +110 -0
  20. package/dist/core/storage_scope.d.ts.map +1 -0
  21. package/dist/core/tool_outcome.d.ts +2 -3
  22. package/dist/core/tool_outcome.d.ts.map +1 -1
  23. package/dist/core/utils.d.ts +46 -0
  24. package/dist/core/utils.d.ts.map +1 -1
  25. package/dist/index.js +16268 -14747
  26. package/dist/index.js.map +4 -4
  27. package/dist/skills/skill_catalog.d.ts +67 -0
  28. package/dist/skills/skill_catalog.d.ts.map +1 -0
  29. package/dist/skills/skill_name_from.d.ts +12 -0
  30. package/dist/skills/skill_name_from.d.ts.map +1 -0
  31. package/dist/tools/tool_catalog.d.ts +79 -0
  32. package/dist/tools/tool_catalog.d.ts.map +1 -0
  33. package/dist/tools/tool_dispatch.d.ts +104 -0
  34. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  35. package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
  36. package/dist/ui/composer/auto_grow.d.ts +10 -0
  37. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  38. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  39. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  40. package/dist/ui/composer/composer_voice.d.ts +48 -0
  41. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  42. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  43. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  44. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  45. package/dist/ui/fill_ui_string.d.ts +32 -0
  46. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  47. package/dist/ui/history/conversation_history.d.ts +203 -0
  48. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  49. package/dist/ui/history/relative_time.d.ts.map +1 -1
  50. package/dist/ui/history/thread_drawer.d.ts +0 -14
  51. package/dist/ui/history/thread_drawer.d.ts.map +1 -1
  52. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
  53. package/dist/ui/interrupts/pending_decision.d.ts +23 -0
  54. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  55. package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
  56. package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
  57. package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
  58. package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
  59. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  60. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  61. package/dist/ui/placement/panel_placement.d.ts +119 -0
  62. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  63. package/dist/ui/progress/run_announcer.d.ts +41 -0
  64. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  65. package/dist/ui/progress/subagent_progress.d.ts +58 -0
  66. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  67. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  68. package/dist/ui/shell/adopt_styles.d.ts +23 -0
  69. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  70. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  71. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  72. package/dist/ui/shell/header_button.d.ts +10 -0
  73. package/dist/ui/shell/header_button.d.ts.map +1 -0
  74. package/dist/ui/shell/icon_element.d.ts +12 -0
  75. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  76. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  77. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  78. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  79. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  80. package/dist/ui/styles.d.ts +1 -1
  81. package/dist/ui/styles.d.ts.map +1 -1
  82. package/dist/ui/transcript/answer_actions.d.ts +39 -0
  83. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  84. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  85. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  86. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  87. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  88. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  89. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  90. package/dist/ui/transcript/transcript.d.ts +171 -0
  91. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  92. package/dist/ui/ui_strings.d.ts +29 -1
  93. package/dist/ui/ui_strings.d.ts.map +1 -1
  94. package/package.json +1 -1
  95. package/src/constants.ts +17 -15
  96. package/src/core/activity_registry.ts +133 -0
  97. package/src/core/ag_ui_chat.ts +945 -4419
  98. package/src/core/agui_client.ts +172 -16
  99. package/src/core/client_seed.ts +27 -0
  100. package/src/core/read_max_tool_rounds.ts +13 -0
  101. package/src/core/remote_conversation_store.ts +1 -1
  102. package/src/core/run_handlers.ts +501 -0
  103. package/src/core/storage_scope.ts +279 -0
  104. package/src/core/tool_outcome.ts +5 -3
  105. package/src/core/utils.ts +109 -0
  106. package/src/skills/skill_catalog.ts +166 -0
  107. package/src/skills/skill_name_from.ts +19 -0
  108. package/src/tools/tool_catalog.ts +354 -0
  109. package/src/tools/tool_dispatch.ts +521 -0
  110. package/src/ui/composer/attachment_tray.ts +6 -5
  111. package/src/ui/composer/auto_grow.ts +12 -0
  112. package/src/ui/composer/composer_attachments.ts +324 -0
  113. package/src/ui/composer/composer_voice.ts +93 -0
  114. package/src/ui/composer/voice_input.ts +4 -4
  115. package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
  116. package/src/ui/fill_ui_string.ts +41 -0
  117. package/src/ui/history/conversation_history.ts +699 -0
  118. package/src/ui/history/relative_time.ts +5 -4
  119. package/src/ui/history/thread_drawer.ts +3 -50
  120. package/src/ui/interrupts/confirmation_card.ts +4 -2
  121. package/src/ui/interrupts/pending_decision.ts +34 -0
  122. package/src/ui/placement/is_collapsible_placement.ts +19 -0
  123. package/src/ui/placement/is_draggable_placement.ts +23 -0
  124. package/src/ui/placement/launcher_drag.ts +104 -89
  125. package/src/ui/placement/panel_placement.ts +1130 -0
  126. package/src/ui/progress/run_announcer.ts +67 -0
  127. package/src/ui/progress/subagent_progress.ts +201 -0
  128. package/src/ui/progress/tool_call_card.ts +2 -0
  129. package/src/ui/shell/adopt_styles.ts +28 -0
  130. package/src/ui/shell/glyph_slot.ts +12 -0
  131. package/src/ui/shell/header_button.ts +21 -0
  132. package/src/ui/shell/icon_element.ts +33 -0
  133. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  134. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  135. package/src/ui/styles.ts +301 -70
  136. package/src/ui/transcript/answer_actions.ts +139 -0
  137. package/src/ui/transcript/answer_stream.ts +123 -0
  138. package/src/ui/transcript/render_or_warn.ts +22 -0
  139. package/src/ui/transcript/starter_chips.ts +42 -0
  140. package/src/ui/transcript/transcript.ts +429 -0
  141. package/src/ui/ui_strings.ts +38 -1
  142. package/src/version.ts +1 -1
@@ -0,0 +1,521 @@
1
+ import type { Context, Interrupt } from "@ag-ui/core";
2
+ import { READ_PAGE_TOOL, TOOL_CALL_STATUS, TOOL_OUTCOME, X_CONFIRM_KEY } from "../constants.js";
3
+ import type { AgUiToolCall, InterruptResponse, ToolExecution } from "../core/agui_client.js";
4
+ import type { ClientConversationStore } from "../core/conversation_store.js";
5
+ import { skillNameFrom } from "../skills/skill_name_from.js";
6
+ import { fillUiString } from "../ui/fill_ui_string.js";
7
+ import {
8
+ type ApprovalRenderer,
9
+ type ApprovalRequest,
10
+ requestApproval,
11
+ } from "../ui/interrupts/approval_card.js";
12
+ import {
13
+ type ConfirmationRequest,
14
+ requestConfirmation,
15
+ } from "../ui/interrupts/confirmation_card.js";
16
+ import type { PendingDecision } from "../ui/interrupts/pending_decision.js";
17
+ import type { Transcript } from "../ui/transcript/transcript.js";
18
+ import type { UiStrings } from "../ui/ui_strings.js";
19
+ import type { ClientTool } from "./client_tool_registry.js";
20
+ import { isDestructive } from "./is_destructive.js";
21
+ import { isNavigates } from "./is_navigates.js";
22
+ import type { PageMap } from "./page_map.js";
23
+ import type { ToolCatalog } from "./tool_catalog.js";
24
+
25
+ /** What tool dispatch needs from the element that owns it. */
26
+ export interface ToolDispatchHost {
27
+ /** The custom element, which a host's predicate and renderer are called on. */
28
+ readonly element: HTMLElement;
29
+ /** The transcript the cards and prompts are drawn in. */
30
+ readonly transcript: Transcript;
31
+ /** The tools this round advertised, and their implementations. */
32
+ readonly tools: ToolCatalog;
33
+ /** The decision a run is suspended on, which a Stop abandons. */
34
+ readonly decision: PendingDecision;
35
+ /** The resolved string table. */
36
+ readonly strings: () => UiStrings;
37
+ /** Say one short thing to a screen reader. */
38
+ readonly announce: (message: string) => void;
39
+ /** The element's `autoConfirm`. */
40
+ readonly autoConfirm: () => boolean;
41
+ /** The element's `confirmPredicate`. */
42
+ readonly confirmPredicate: () =>
43
+ | ((toolName: string, args: Record<string, unknown>) => boolean | Promise<boolean>)
44
+ | null;
45
+ /** The element's `getPageMap`. */
46
+ readonly getPageMap: () => (() => PageMap) | null;
47
+ /** The element's `navigate`. */
48
+ readonly navigate: () => ((path: string) => void) | null;
49
+ /** The element's `approveWithEdits`. */
50
+ readonly approveWithEdits: () => boolean;
51
+ /** The element's `approvalRenderer`. */
52
+ readonly approvalRenderer: () => ApprovalRenderer | null;
53
+ /** The element's `getContext`, which a host may have replaced. */
54
+ readonly getContext: () => Context[];
55
+ /** The element's `conversationStore`, read per use. */
56
+ readonly conversationStore: () => ClientConversationStore;
57
+ /** The active thread's id. */
58
+ readonly threadId: () => string;
59
+ }
60
+
61
+ /**
62
+ * Running the frontend tool calls a round produced, and answering the server's
63
+ * interrupts: the page each round's context described, the confirmation rules,
64
+ * and the tools the user waived confirmation for.
65
+ *
66
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
67
+ * instance.
68
+ */
69
+ export class ToolDispatch {
70
+ readonly #host: ToolDispatchHost;
71
+ /**
72
+ * Tool names the user waived confirmation for, for the life of this element
73
+ * or until the principal changes, whichever comes first.
74
+ *
75
+ * Per instance and never persisted: a session decision that outlived the tab
76
+ * would be a permanent grant made by one click, which is the thing
77
+ * `autoConfirm` already exists to say deliberately. Cleared with the element,
78
+ * and by {@link forgetWaivers} when the host names a different principal.
79
+ */
80
+ readonly #sessionApproved = new Set<string>();
81
+ // The page the current round's context describes, captured when that context
82
+ // was built. `null` until the first round. Compared in `execute` to
83
+ // catch a page that moved under a round still in flight.
84
+ #contextHref: string | null = null;
85
+
86
+ constructor(host: ToolDispatchHost) {
87
+ this.#host = host;
88
+ }
89
+
90
+ /**
91
+ * Forget every tool the user waived confirmation for.
92
+ *
93
+ * A waiver is one person's click, and it is only ever that person's to give.
94
+ * `user-key` exists because a logout is a navigation inside one tab rather
95
+ * than a remount, so the element outlives the principal who clicked Always
96
+ * allow -- and without this the next one's destructive calls would run on the
97
+ * previous one's say-so, with no card to show that anyone was asked.
98
+ */
99
+ forgetWaivers(): void {
100
+ this.#sessionApproved.clear();
101
+ }
102
+
103
+ /**
104
+ * Build a round's context, recording which page it describes.
105
+ *
106
+ * The AG-UI client re-invokes this at the top of **every** tool round, not
107
+ * once per `send()`, so the page map the agent sees is already refreshed
108
+ * between rounds and the href captured here is the page it was shown for
109
+ * *this* round. {@link execute} compares against it to catch a page that
110
+ * moved under a round still in flight.
111
+ */
112
+ buildContext(): Context[] {
113
+ this.#contextHref = window.location.href;
114
+ return this.#host.getContext();
115
+ }
116
+
117
+ /**
118
+ * Execute one frontend tool call the round produced, against its own card:
119
+ * refused when the page moved under the round, asked about when a rule gates
120
+ * it, and settled with what the handler returned or threw.
121
+ */
122
+ async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
123
+ // A skill load already rendered as a notice on the stream; it is never a
124
+ // client tool and its result is pydantic-ai's business, so it must not
125
+ // acquire a card here on the way to the not-finished fallback below.
126
+ if (skillNameFrom(call) !== null) {
127
+ return null;
128
+ }
129
+ const card = this.#host.transcript.cardFor(call);
130
+ this.#host.transcript.forgetCard(call.id);
131
+ // Kept after the card leaves the awaiting cards: a tool that renders into the
132
+ // transcript places itself against its own card, and by the time it runs the
133
+ // card is no longer reachable by id.
134
+ this.#host.transcript.setCardElement(call.id, card.element);
135
+ // Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
136
+ // this round. A host that offers `delete_record` only on the page where
137
+ // deleting makes sense has said something about *this* run, and a call
138
+ // arriving anyway (a hallucinated name, or one steered by text the model
139
+ // just read) must not find the handler that happens to be registered
140
+ // mount-wide. Treated exactly as an unknown name rather than as a refusal:
141
+ // withholding a tool and never registering it are the same statement, and
142
+ // the branch below already says the honest thing for both.
143
+ const tool = this.#host.tools.wasAdvertised(call.name)
144
+ ? this.#host.tools.resolve(call.name)
145
+ : null;
146
+ if (tool === null) {
147
+ // Not a client tool. A server-side tool's real output arrives via
148
+ // `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
149
+ // fall back when it didn't. When no result ever arrived, the call wasn't
150
+ // executed by either side (no handler, no server result), so the card says
151
+ // it did not finish -- the same words the client sends the agent for it on
152
+ // the next request -- rather than claiming a success nothing achieved. We
153
+ // do NOT show the pending indicator: nothing here triggers another client
154
+ // round, so it would hang after the run ended.
155
+ if (!this.#host.transcript.isServerSettled(call.id)) {
156
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
157
+ }
158
+ return null;
159
+ }
160
+ // The page moved under this round. Acting now would target whatever
161
+ // matches on the new page, and the case worth preventing is a same-named
162
+ // control matching silently — the only way the agent acts on the wrong page
163
+ // without either side noticing.
164
+ //
165
+ // Must precede the confirmation prompt, so the user is never asked to
166
+ // approve an action about to be refused. Navigating tools are exempt, since
167
+ // moving the page is their job, as is read_page, the documented recovery.
168
+ // Gated on a page-map provider: without one there is no read_page to
169
+ // recommend and the host's tools are not page-scoped anyway.
170
+ if (
171
+ this.#host.getPageMap() !== null &&
172
+ call.name !== READ_PAGE_TOOL &&
173
+ !isNavigates(tool.parameters) &&
174
+ this.#pageMoved()
175
+ ) {
176
+ const message = this.#host.strings().pageMoved;
177
+ card.settle(TOOL_CALL_STATUS.ERROR, message);
178
+ this.#host.transcript.showPending();
179
+ // Stated so a reload settles this card the same way. The card's own status
180
+ // lives only in the DOM, and the DOM is what a reload throws away.
181
+ return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
182
+ }
183
+ const rule = await this.#confirmationRule(call, tool);
184
+ if (rule === "unanswered") {
185
+ // Settled as a refusal and returned as one, so the run carries on to its
186
+ // next round the way it does after a decline and the agent can say what
187
+ // happened. Not recorded as a decision: no person was asked, and "declined
188
+ // by you" would be a claim about someone who never saw a card.
189
+ const message = this.#host.strings().confirmCheckFailed;
190
+ card.settle(TOOL_CALL_STATUS.DECLINED, message);
191
+ this.#host.transcript.showPending();
192
+ return { content: message, outcome: TOOL_OUTCOME.DENIED };
193
+ }
194
+ if (rule !== null) {
195
+ const request: ConfirmationRequest = { toolName: call.name, args: call.args };
196
+ const confirmText = tool.parameters[X_CONFIRM_KEY];
197
+ if (typeof confirmText === "string") {
198
+ request.message = confirmText;
199
+ }
200
+ // The run loop is suspended on this card; a Stop while it's open aborts
201
+ // the controller, resolving the decision as declined.
202
+ const signal = this.#host.decision.open();
203
+ // Into the turn's answer group, like every other inline card. Appending
204
+ // to the message list made it a sibling *after* the group, so anything
205
+ // that streamed afterwards rendered above it and the prompt drifted to
206
+ // the foot of the turn no matter when it was asked.
207
+ const decision = requestConfirmation(this.#host.transcript.ensureGroup(), request, {
208
+ signal,
209
+ strings: this.#host.strings(),
210
+ // Offered only where it can be honoured -- see `#confirmationRule`.
211
+ ...(rule === "destructive"
212
+ ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
213
+ : {}),
214
+ });
215
+ this.#host.transcript.updateEmptyState();
216
+ this.#host.transcript.follow();
217
+ const accepted = await decision;
218
+ this.#host.decision.close();
219
+ card.recordDecision(accepted ? "approved" : "declined");
220
+ if (!accepted) {
221
+ const message = this.#host.strings().declinedAction;
222
+ card.settle(TOOL_CALL_STATUS.DECLINED, message);
223
+ this.#host.transcript.showPending();
224
+ // The one outcome with no error text and no server involvement at all:
225
+ // a person said no in this browser. Nothing else records that, so
226
+ // without the annotation the reload showed a green card for an action
227
+ // the user had explicitly refused.
228
+ return { content: message, outcome: TOOL_OUTCOME.DENIED };
229
+ }
230
+ }
231
+ // A navigating tool reloads only without a client-side router; with a
232
+ // host `navigate()` (SPA) it routes in-page and the loop just continues.
233
+ const navigates = isNavigates(tool.parameters) && this.#host.navigate() === null;
234
+ if (navigates) {
235
+ // Checkpoint before the handler reloads the page; the history (incl.
236
+ // this tool call) was already persisted when the run that produced it
237
+ // settled. The result is supplied on the next mount via the resume path.
238
+ this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
239
+ }
240
+ try {
241
+ // The call id lets a handler that renders into the transcript find its
242
+ // own card; handlers that only act on the page ignore it.
243
+ const result = await tool.handler(call.args, call.id);
244
+ // Drawn from the arguments rather than the result, so the live path and
245
+ // the replay path render the same thing from the same input.
246
+ if (tool.render !== undefined) {
247
+ this.#host.transcript.renderToolOutput(tool.render, call);
248
+ }
249
+ if (navigates) {
250
+ card.settle(TOOL_CALL_STATUS.DONE, this.#host.strings().navigating);
251
+ return { content: "", halt: true };
252
+ }
253
+ const content = JSON.stringify(result ?? null);
254
+ card.settle(TOOL_CALL_STATUS.DONE, content);
255
+ this.#host.transcript.showPending();
256
+ return { content };
257
+ } catch (error) {
258
+ if (navigates) {
259
+ // The navigation never happened; drop the dangling checkpoint.
260
+ this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
261
+ }
262
+ // The handler's own message, verbatim, in two places at once: the card,
263
+ // which the user sees, and the tool result, which goes to the endpoint,
264
+ // is persisted there and is replayed to the model on every later round.
265
+ // Kept verbatim because a real reason is what lets the agent recover —
266
+ // and said out loud on `registerTool`, because the second destination is
267
+ // invisible from the host's side and is not one it can take back.
268
+ const message = error instanceof Error ? error.message : String(error);
269
+ card.settle(TOOL_CALL_STATUS.ERROR, message);
270
+ this.#host.transcript.showPending();
271
+ return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Render an approval card per server-side-tool interrupt and collect the
277
+ * user's decisions (approve → run it, deny → decline it).
278
+ *
279
+ * **One card per gated call, in that call's own tool card, all at once.** A run
280
+ * can defer several calls, and the wire answers each independently — so the UI
281
+ * has to let a person answer each independently, which means saying which is
282
+ * which. The prompt cannot: it comes from the tool's `x-confirm` and is
283
+ * identical for every call of that tool. The tool card can, by position, and it
284
+ * is already showing the arguments. Asking them serially was the other half of
285
+ * the problem: the second question only appeared once the first was answered,
286
+ * so a person could neither compare them nor tell that more were coming.
287
+ *
288
+ * Each gated card is marked `deferred` for the wait. That is not cosmetic — at
289
+ * `pending` it read "running…" while the stream was over and the server idle.
290
+ *
291
+ * The run is suspended on these cards. A Stop while any is open aborts the
292
+ * shared pending decision, resolving every still-open card as
293
+ * denied. An approved tool runs on the follow-up resume run and streams its
294
+ * result into the same card (returned to `pending`, since it now really is
295
+ * running); a denied one settles here, as no result will ever arrive.
296
+ */
297
+ async resolveInterrupts(
298
+ interrupts: readonly Interrupt[],
299
+ ): Promise<Record<string, InterruptResponse>> {
300
+ // One controller covers the whole batch: a single Stop denies all of them.
301
+ const signal = this.#host.decision.open();
302
+ // The run has stopped and is waiting on a person. Nothing else on screen
303
+ // says so to a screen reader: the cards appear inside the transcript, which
304
+ // is deliberately not a live region, so without this the run simply goes
305
+ // quiet and the user has no reason to go looking.
306
+ this.#host.announce(
307
+ fillUiString(this.#host.strings().announceAwaitingDecision, { count: interrupts.length }),
308
+ );
309
+ this.#host.transcript.hidePending();
310
+ const answered = await Promise.all(
311
+ interrupts.map(async (interrupt) => {
312
+ const card =
313
+ interrupt.toolCallId !== undefined
314
+ ? this.#host.transcript.card(interrupt.toolCallId)
315
+ : undefined;
316
+ const request: ApprovalRequest = {};
317
+ const phrase = confirmPhrase(interrupt) ?? interrupt.message;
318
+ if (phrase !== undefined) {
319
+ request.message = phrase;
320
+ }
321
+ const toolName = card?.element.getAttribute("data-tool-name");
322
+ if (toolName !== null && toolName !== undefined) {
323
+ request.toolName = toolName;
324
+ }
325
+ // Offered only where it can be honoured: the host has said its agent
326
+ // accepts `editedArgs`, and this interrupt named a call whose arguments
327
+ // we still hold.
328
+ let editedArgs: Record<string, unknown> | undefined;
329
+ const editable = this.#host.approveWithEdits() && card !== undefined;
330
+ if (editable) {
331
+ request.args = card.args;
332
+ }
333
+ card?.mark(TOOL_CALL_STATUS.DEFERRED);
334
+ // The built-in card renders into the gated call's own card, falling back
335
+ // to the answer group when the interrupt names no call we hold one for.
336
+ const builtIn = (): Promise<boolean> =>
337
+ requestApproval(card?.approvalSlot ?? this.#host.transcript.ensureGroup(), request, {
338
+ signal,
339
+ strings: this.#host.strings(),
340
+ ...(editable
341
+ ? {
342
+ onEdit: (args: Record<string, unknown>) => {
343
+ editedArgs = args;
344
+ },
345
+ }
346
+ : {}),
347
+ });
348
+ // A host-supplied renderer takes full control of the approval UI.
349
+ const renderer = this.#host.approvalRenderer();
350
+ let approved: boolean;
351
+ if (renderer === null) {
352
+ approved = await builtIn();
353
+ } else {
354
+ // Awaited here rather than inside a helper, so an answering renderer
355
+ // takes exactly as many turns to be heard as it always did.
356
+ try {
357
+ // Called on the element, as `this.approvalRenderer(...)` always was.
358
+ approved = await renderer.call(this.#host.element, request, { signal });
359
+ } catch (error) {
360
+ approved = await this.#afterRendererFailed(error, signal, interrupt.id, builtIn);
361
+ }
362
+ }
363
+ // Same annotation as the client-side confirmation gate. Without it the
364
+ // two gates read differently for the same act: a locally-confirmed call
365
+ // said who let it through and a server-gated one said nothing, which is
366
+ // backwards, since the server-side gate is the one guarding the tools
367
+ // that actually run on the backend.
368
+ card?.recordDecision(approved ? "approved" : "declined");
369
+ if (approved) {
370
+ card?.mark(TOOL_CALL_STATUS.PENDING);
371
+ } else {
372
+ // No TOOL_CALL_RESULT will stream for a denied tool — settle its card
373
+ // now rather than leaving it hanging until the onSettled sweep.
374
+ card?.settle(TOOL_CALL_STATUS.DECLINED, this.#host.strings().declinedAction);
375
+ }
376
+ return { id: interrupt.id, approved, editedArgs };
377
+ }),
378
+ );
379
+ this.#host.transcript.updateEmptyState();
380
+ this.#host.transcript.follow();
381
+ this.#host.decision.close();
382
+ const responses: Record<string, InterruptResponse> = {};
383
+ for (const { id, approved, editedArgs } of answered) {
384
+ // `editedArgs` rides only when the user actually changed something, so a
385
+ // server can tell "approved as proposed" from "approved, but like this".
386
+ responses[id] = approved
387
+ ? {
388
+ status: "resolved",
389
+ payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
390
+ }
391
+ : { status: "cancelled" };
392
+ }
393
+ return responses;
394
+ }
395
+
396
+ /**
397
+ * Answer an interrupt whose host renderer threw or rejected instead of
398
+ * answering: put it to the built-in card.
399
+ *
400
+ * The renderer is presentation, not a guard: it decides how the question
401
+ * looks, never whether it is asked. Uncaught, one failure rejected the whole
402
+ * batch, so the run ended on an error bubble quoting the host's message, the
403
+ * server was never answered, and the end-of-run sweep settled the gated card
404
+ * as a green "done" for a call that never ran. The built-in card still puts
405
+ * the decision to a person, so nothing runs without a click, and the run
406
+ * carries on as if no renderer had been set. Reported the way a failed
407
+ * `render` is, and for the same reason: survived is not the same as findable.
408
+ *
409
+ * Except when the wait was already abandoned. A renderer honouring its signal
410
+ * rejects once a Stop fires it, which is the signal working rather than the
411
+ * renderer failing, and a card drawn then would ask about a run the user just
412
+ * ended. So it resolves as not approved, which is what the built-in card
413
+ * resolves on the same abort, and says nothing.
414
+ */
415
+ #afterRendererFailed(
416
+ error: unknown,
417
+ signal: AbortSignal,
418
+ interruptId: string,
419
+ builtIn: () => Promise<boolean>,
420
+ ): Promise<boolean> {
421
+ if (signal.aborted) {
422
+ return Promise.resolve(false);
423
+ }
424
+ // Named by interrupt rather than by tool: a batch can gate several calls of
425
+ // one tool, and the id is the one thing that tells them apart.
426
+ console.warn(
427
+ `ag-ui-chat: approvalRenderer failed for interrupt ${interruptId}, so the built-in approval card asks instead`,
428
+ error,
429
+ );
430
+ return builtIn();
431
+ }
432
+
433
+ /**
434
+ * Which rule gates `call`, or `null` when it runs straight through, or
435
+ * `"unanswered"` when the host's predicate threw instead of deciding.
436
+ *
437
+ * The rule, rather than a bare boolean, because it decides whether the user
438
+ * may *waive* the prompt for the rest of the session. Only the default
439
+ * `x-destructive` gate is waivable: `confirmPredicate` is documented as
440
+ * authoritative, so letting one click retire it would silently defeat a host
441
+ * policy — and the session allowlist is consulted on the same path it can
442
+ * be added from, so the button is never offered where honouring it would be
443
+ * refused.
444
+ */
445
+ async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
446
+ if (this.#host.autoConfirm()) {
447
+ return null;
448
+ }
449
+ const predicate = this.#host.confirmPredicate();
450
+ if (predicate !== null) {
451
+ try {
452
+ // Called on the element, as `this.confirmPredicate(...)` always was.
453
+ return (await predicate.call(this.#host.element, call.name, call.args)) === true
454
+ ? "predicate"
455
+ : null;
456
+ } catch (error) {
457
+ // A guard that cannot answer has not said the call is safe, and for a
458
+ // tool with no `x-destructive` flag the predicate is the only guard
459
+ // there is. So it fails closed: the call is refused outright, rather
460
+ // than run, and rather than put to a card whose one click would run
461
+ // what the host's own policy could not vouch for.
462
+ //
463
+ // Uncaught, the throw ended the run on an error bubble quoting the
464
+ // host's message and left this call's card reading "running…" for
465
+ // good, since dispatch had already taken it out of the settle sweep.
466
+ // The message goes to the console instead, where a render failure is
467
+ // reported, and not on to the endpoint: unlike a handler's, it was
468
+ // never written for the model to read.
469
+ console.warn(
470
+ `ag-ui-chat: confirmPredicate failed for tool ${call.name}, so the call was refused`,
471
+ error,
472
+ );
473
+ return "unanswered";
474
+ }
475
+ }
476
+ if (this.#sessionApproved.has(call.name)) {
477
+ return null;
478
+ }
479
+ return isDestructive(tool.parameters) ? "destructive" : null;
480
+ }
481
+
482
+ /**
483
+ * Whether the page moved since the current round's context was built.
484
+ *
485
+ * `null` means no round has built context yet (nothing to compare), which is
486
+ * not a move.
487
+ */
488
+ #pageMoved(): boolean {
489
+ return this.#contextHref !== null && this.#contextHref !== window.location.href;
490
+ }
491
+ }
492
+
493
+ /**
494
+ * A server-authored question for a gated call, read off the interrupt's metadata.
495
+ *
496
+ * The question an AG-UI interrupt carries by default is the call itself, spelled
497
+ * out: `Approve create_event({"title": "Design sync", …})?`. Accurate, and not
498
+ * something to put in front of a person. A client-side confirmation has
499
+ * `x-confirm` on the tool's schema for exactly this, so the same key is read here
500
+ * — whichever end gates a call, the phrase comes from one place, and a server
501
+ * that supplies none keeps the generated text.
502
+ *
503
+ * Narrowed rather than trusted: `metadata` is `Record<string, any>` on the wire,
504
+ * so anything at all can arrive under that key, and a non-string would render as
505
+ * "[object Object]" in the one place a person is being asked to allow a write.
506
+ */
507
+ function confirmPhrase(interrupt: Interrupt): string | undefined {
508
+ const phrase = interrupt.metadata?.[X_CONFIRM_KEY];
509
+ return typeof phrase === "string" && phrase.trim() !== "" ? phrase : undefined;
510
+ }
511
+
512
+ /**
513
+ * Why a client tool call is gated behind the confirmation card, or refused
514
+ * before one is drawn.
515
+ *
516
+ * Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
517
+ * the session. `confirmPredicate` is documented as authoritative, so a call it
518
+ * gates keeps asking. `"unanswered"` is a predicate that threw instead of
519
+ * answering, and the call it was asked about is refused without a card.
520
+ */
521
+ type ConfirmationRule = "destructive" | "predicate" | "unanswered";
@@ -2,6 +2,8 @@ import { randomUUID } from "@ag-ui/client";
2
2
  import { ATTACHMENT_STATUS } from "../../constants.js";
3
3
  import type { AttachmentRef } from "../../core/attachment.js";
4
4
  import type { UploadHandler } from "../../core/upload_attachment.js";
5
+ import { commaTokens } from "../../core/utils.js";
6
+ import { fillUiString } from "../fill_ui_string.js";
5
7
  import { formatBytes, iconFor } from "../transcript/attachment_chips.js";
6
8
  import { DEFAULT_UI_STRINGS, type UiStrings } from "../ui_strings.js";
7
9
 
@@ -141,7 +143,7 @@ export class AttachmentTray {
141
143
  /** The size/type rejection reason for a file, or `null` when accepted. */
142
144
  #reject(file: File): string | null {
143
145
  if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
144
- return this.#strings.tooLarge.replace("{size}", formatBytes(this.#config.maxBytes));
146
+ return fillUiString(this.#strings.tooLarge, { size: formatBytes(this.#config.maxBytes) });
145
147
  }
146
148
  if (!accepts(this.#config.accept, file)) {
147
149
  return this.#strings.fileTypeNotAllowed;
@@ -273,10 +275,9 @@ export class AttachmentTray {
273
275
 
274
276
  /** Whether `file` matches an `<input accept>` list (`""` accepts anything). */
275
277
  function accepts(accept: string, file: File): boolean {
276
- const tokens = accept
277
- .split(",")
278
- .map((token) => token.trim().toLowerCase())
279
- .filter((token) => token !== "");
278
+ // Lower-cased after the shared split rather than before the filter: no
279
+ // non-empty token lower-cases to an empty one, so the order cannot matter.
280
+ const tokens = commaTokens(accept).map((token) => token.toLowerCase());
280
281
  if (tokens.length === 0) {
281
282
  return true;
282
283
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Size the field to its content: one row when empty, growing with what is
3
+ * typed until the CSS ceiling takes over and it scrolls.
4
+ *
5
+ * Resetting to `auto` first is what makes it shrink again — `scrollHeight`
6
+ * never reports less than the current height, so measuring without the reset
7
+ * would ratchet the composer taller and never back down.
8
+ */
9
+ export function autoGrow(input: HTMLTextAreaElement): void {
10
+ input.style.height = "auto";
11
+ input.style.height = `${input.scrollHeight}px`;
12
+ }