@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,501 @@
1
+ import {
2
+ CUSTOM_AGENT_EVENT,
3
+ INVALIDATE_CUSTOM_NAME,
4
+ INVALIDATE_EVENT,
5
+ MESSAGE_ROLE,
6
+ RUN_FINISHED_EVENT,
7
+ SUBAGENT_CUSTOM_NAME,
8
+ TOOL_CALL_STATUS,
9
+ } from "../constants.js";
10
+ import { attachCopyButtons } from "../ui/excerpts/attach_copy_buttons.js";
11
+ import type { RunAnnouncer } from "../ui/progress/run_announcer.js";
12
+ import type { SubAgentProgress } from "../ui/progress/subagent_progress.js";
13
+ import type { AnswerActions } from "../ui/transcript/answer_actions.js";
14
+ import type { AnswerStream } from "../ui/transcript/answer_stream.js";
15
+ import type { Transcript } from "../ui/transcript/transcript.js";
16
+ import type { UiStrings } from "../ui/ui_strings.js";
17
+ import type { ActivityRegistry } from "./activity_registry.js";
18
+ import type { AgUiClientHandlers } from "./agui_client.js";
19
+ import type { CustomAgentDetail } from "./events/custom_agent_detail.js";
20
+ import type { InvalidateDetail } from "./events/invalidate_detail.js";
21
+ import type { RunFinishedDetail } from "./events/run_finished_detail.js";
22
+ import type { ToolRun } from "./events/tool_run.js";
23
+ import type { MessageRole } from "./message_role.js";
24
+ import { toolStatusFromOutcome } from "./tool_outcome.js";
25
+
26
+ /** What the run's event handlers need from the element that owns them. */
27
+ export interface RunHandlersHost {
28
+ /** The custom element, which the run's events are dispatched on. */
29
+ readonly element: HTMLElement;
30
+ /** The transcript the run draws into. */
31
+ readonly transcript: Transcript;
32
+ /** The answer streaming into its bubble. */
33
+ readonly stream: AnswerStream;
34
+ /** The action row under each finished answer. */
35
+ readonly actions: AnswerActions;
36
+ /** The activity renderers, and the blocks they drew. */
37
+ readonly activities: ActivityRegistry;
38
+ /** A delegated sub-agent's progress, on the card that delegated. */
39
+ readonly subagents: SubAgentProgress;
40
+ /** The screen-reader status region. */
41
+ readonly announcer: RunAnnouncer;
42
+ /** The resolved string table. */
43
+ readonly strings: () => UiStrings;
44
+ /** Whether an interaction is already in flight, which the composer owns. */
45
+ readonly running: () => boolean;
46
+ /** Swap the composer between Send and Stop; settling sends a queued turn. */
47
+ readonly setRunning: (running: boolean) => void;
48
+ /** The element's `appendMessage`, which a failed run's bubble opens through. */
49
+ readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
50
+ /** Count an answer that finished while the widget was collapsed. */
51
+ readonly noteUnread: () => void;
52
+ }
53
+
54
+ /**
55
+ * What one conversation's runs accumulate for the host between an
56
+ * interaction's first round and its settle.
57
+ *
58
+ * A record per conversation rather than fields on the controller, because a
59
+ * run can outlive the conversation it belongs to. Cancelling is a request, not
60
+ * an ending: a stopped run reports how it ended once its request has closed --
61
+ * or once a host tool's handler, which cancelling cannot interrupt, has
62
+ * returned -- and by then New chat may have cleared the transcript and the next
63
+ * message may already be running. Each handler table keeps the record it was
64
+ * built with, so that report describes the run that made it and nothing the
65
+ * next conversation did.
66
+ */
67
+ interface RunLedger {
68
+ /**
69
+ * Tool calls made during the current interaction, in the order they started,
70
+ * so {@link RUN_FINISHED_EVENT} can report them once the whole thing settles.
71
+ * Spans tool rounds and an approval interrupt; emptied when the event fires.
72
+ */
73
+ readonly tools: { readonly id: string; readonly name: string }[];
74
+ /**
75
+ * Which of those calls had a result streamed back, and so ran on the server.
76
+ *
77
+ * The transcript keeps the same fact for its cards, and this used to be read
78
+ * from there. But New chat clears the transcript, and a run it cut off
79
+ * reports afterwards -- so a booking the server made came out as the host's
80
+ * own tool, which is the one side the documented listener skips a refetch for.
81
+ */
82
+ readonly serverSettled: Set<string>;
83
+ /**
84
+ * Keys announced during this interaction, de-duplicated in first-seen order.
85
+ *
86
+ * Per element, never module-level: a second mounted chat is a second run, and
87
+ * sharing this would tell one page to refetch on the other's writes. Emptied
88
+ * by {@link RunHandlers.#dispatchRunFinished}, which is the one place that has
89
+ * read it.
90
+ */
91
+ readonly invalidated: Set<string>;
92
+ /**
93
+ * Whether this turn already announced how it ended.
94
+ *
95
+ * `onSettled` is the terminal guarantee and fires however the run ended, so
96
+ * it is the only place that can promise the user hears *something*. But a
97
+ * stopped or failed run has already said the truer thing from `onCancelled`
98
+ * or `onError`, and "assistant answered" after "response stopped" is worse
99
+ * than silence.
100
+ */
101
+ announcedOutcome: boolean;
102
+ }
103
+
104
+ /** An empty ledger, for a conversation nothing has run in yet. */
105
+ function emptyLedger(): RunLedger {
106
+ return {
107
+ tools: [],
108
+ serverSettled: new Set<string>(),
109
+ invalidated: new Set<string>(),
110
+ announcedOutcome: false,
111
+ };
112
+ }
113
+
114
+ /** Handlers with the same signatures as `table`, each doing nothing once `attached` is false. */
115
+ function whileAttached<T extends object>(table: T, attached: () => boolean): T {
116
+ const guarded: Record<string, (...args: unknown[]) => void> = {};
117
+ for (const [name, handler] of Object.entries(table) as [string, (...args: unknown[]) => void][]) {
118
+ guarded[name] = (...args) => {
119
+ if (attached()) {
120
+ handler(...args);
121
+ }
122
+ };
123
+ }
124
+ return guarded as T;
125
+ }
126
+
127
+ /**
128
+ * The AG-UI event handlers a run is drawn by, and what one interaction
129
+ * accumulates for the host between its first round and its settle: the tool
130
+ * calls it made, the keys it invalidated, and whether it already said how it
131
+ * ended.
132
+ *
133
+ * A controller rather than a factory. The handlers hold nothing of their own,
134
+ * but that bookkeeping is state, and it has never belonged to one handler
135
+ * table: a table is built per client -- the conversation's own, rebuilt after
136
+ * a reset, and one for each checkpoint continuation -- and every table built
137
+ * for one conversation reports into the same bookkeeping. {@link forClient}
138
+ * still returns a fresh table per call.
139
+ *
140
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
141
+ * instance.
142
+ */
143
+ export class RunHandlers {
144
+ readonly #host: RunHandlersHost;
145
+ /**
146
+ * The bookkeeping of the conversation on screen. Replaced rather than
147
+ * emptied by {@link detach}, so a table built before then keeps its own.
148
+ */
149
+ #ledger: RunLedger = emptyLedger();
150
+
151
+ constructor(host: RunHandlersHost) {
152
+ this.#host = host;
153
+ }
154
+
155
+ /**
156
+ * Detach every handler table built so far, because the conversation they
157
+ * drew into has been cleared away.
158
+ *
159
+ * The element calls this when it resets, having first cancelled what was
160
+ * running. A cancelled run still ends later, and a detached table still
161
+ * reports to the host what that run did: a run stopped part-way may already
162
+ * have written something, and {@link RUN_FINISHED_EVENT} fires on
163
+ * cancellation for exactly that reason. Everything else it would do -- draw a
164
+ * stopped note, announce, put the composer back to Send, settle the pending
165
+ * cards -- would land in a transcript and a composer that now belong to
166
+ * another conversation, possibly mid-run, and is dropped. So is any event the
167
+ * run streams after the reset, invalidations included: an abort closes the
168
+ * request those would arrive on, so what a cancelled run still delivers is
169
+ * how it ended.
170
+ */
171
+ detach(): void {
172
+ this.#ledger = emptyLedger();
173
+ }
174
+
175
+ /**
176
+ * A handler table for one AG-UI client. Every table built for the current
177
+ * conversation draws into the same transcript and shares its bookkeeping, so
178
+ * a continuation run reports into the interaction the user is looking at.
179
+ */
180
+ forClient(): AgUiClientHandlers {
181
+ const ledger = this.#ledger;
182
+ const attached = (): boolean => ledger === this.#ledger;
183
+ const { onSettled, ...drawing } = this.#tableFor(ledger);
184
+ return {
185
+ ...whileAttached(drawing, attached),
186
+ onSettled: () => {
187
+ if (attached()) {
188
+ onSettled();
189
+ }
190
+ // Last, so a listener that refetches sees a transcript that has already
191
+ // stopped changing -- and outside the check, because what a run did is
192
+ // true whichever conversation is on screen when it ends.
193
+ this.#dispatchRunFinished(ledger);
194
+ },
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The handler table itself, recording into `ledger`. {@link forClient}
200
+ * decides how much of it still runs once the table is detached.
201
+ */
202
+ #tableFor(ledger: RunLedger): AgUiClientHandlers {
203
+ return {
204
+ onRunStart: () => {
205
+ // Per *round*, so guard on the turn: a run that calls three tools fires
206
+ // this three times and the user needs telling once.
207
+ if (!this.#host.running()) {
208
+ ledger.announcedOutcome = false;
209
+ this.#host.announcer.announce(this.#host.strings().announceResponding);
210
+ }
211
+ this.#host.setRunning(true);
212
+ // Open the answer group on the turn's first run so the pending
213
+ // indicator (and everything after) lands inside the well. Idempotent:
214
+ // later rounds of the same turn reuse it.
215
+ this.#host.transcript.ensureGroup();
216
+ this.#host.transcript.showPending();
217
+ },
218
+ onReasoningStart: () => {
219
+ // The model is thinking: swap the pending dots for a live thoughts
220
+ // region at the top of the turn's answer group.
221
+ this.#host.transcript.hidePending();
222
+ this.#host.transcript.showThoughts();
223
+ },
224
+ onReasoningDelta: (buffer) => {
225
+ this.#host.transcript.showThoughts().stream(buffer);
226
+ },
227
+ onReasoningEnd: () => {
228
+ // Leave the region expanded until the answer text starts — it collapses
229
+ // on the first text delta (onTextDelta).
230
+ },
231
+ onTextDelta: (buffer) => {
232
+ this.#host.transcript.hidePending();
233
+ // The answer has begun — fold the thoughts away so they don't crowd it.
234
+ this.#host.transcript.collapseThoughts();
235
+ this.#host.stream.queue(buffer);
236
+ this.#host.stream.countDelta();
237
+ },
238
+ onTextEnd: (buffer) => {
239
+ // A text message that carried no content is a declaration, not an
240
+ // answer, and drawing one puts an empty bubble above every tool call.
241
+ //
242
+ // `TOOL_CALL_START` names the assistant message a call belongs to, and
243
+ // a response whose first part is a tool call has no text to open that
244
+ // message with. pydantic-ai 2.37 started opening and closing an empty
245
+ // one there so the id names a message the stream actually announced --
246
+ // before that it named one no event carried, which a client could only
247
+ // answer by inventing an id of its own that matches nothing echoed
248
+ // back. So this envelope is a correctness fix upstream, it is legal
249
+ // AG-UI, and any server may send one.
250
+ //
251
+ // `ConversationHistory.replay` already declines to draw a bubble for
252
+ // an assistant message with no text. Without the same rule
253
+ // here the live transcript and the reloaded one disagree about the
254
+ // same conversation, which is the harder half of the bug to notice.
255
+ if (buffer === "") {
256
+ this.#host.stream.end();
257
+ return;
258
+ }
259
+ const bubble = this.#host.stream.into(buffer);
260
+ // Only reveal word-by-word when the message arrived at once. If it
261
+ // streamed across multiple deltas it already revealed progressively, so
262
+ // wrapping it now would re-animate the whole message — the awkward
263
+ // "finished response replays one word at a time" bug.
264
+ if (this.#host.stream.deltas <= 1) {
265
+ this.#host.transcript.revealWords(bubble);
266
+ }
267
+ attachCopyButtons(bubble, this.#host.strings());
268
+ this.#host.actions.attach(bubble);
269
+ this.#host.stream.end();
270
+ this.#host.noteUnread();
271
+ },
272
+ onToolCall: (call) => {
273
+ this.#host.transcript.hidePending();
274
+ // A skill activation is an ordinary `load_capability` tool call — the
275
+ // deferred-capability mechanism pydantic-ai already uses — so it arrives
276
+ // here rather than on a channel of its own. Render it as a notice and
277
+ // *return*: falling through would show a raw tool card beside the chip,
278
+ // which is worse than the card alone.
279
+ if (this.#host.transcript.noticeIfSkillLoad(call)) {
280
+ return;
281
+ }
282
+ // Recorded after the skill-load return: a capability load is the agent
283
+ // arranging itself, not work a host's data could have moved under.
284
+ ledger.tools.push({ id: call.id, name: call.name });
285
+ this.#host.transcript.cardFor(call);
286
+ },
287
+ onActivity: (activityType, content, messageId) => {
288
+ this.#host.activities.draw(messageId, activityType, content);
289
+ },
290
+ onCustomEvent: (name, value) => {
291
+ if (name === INVALIDATE_CUSTOM_NAME) {
292
+ this.#dispatchInvalidation(ledger, value);
293
+ return;
294
+ }
295
+ if (name === SUBAGENT_CUSTOM_NAME) {
296
+ this.#host.subagents.report(value);
297
+ return;
298
+ }
299
+ // Straight out to the host page, uninterpreted. This is the imperative
300
+ // carrier: whatever it means, it means it to the page, not to the
301
+ // transcript -- so it is dispatched and deliberately not rendered,
302
+ // persisted or replayed. A host that does not know the name simply has
303
+ // no listener, which is the graceful outcome the open field is for.
304
+ this.#host.element.dispatchEvent(
305
+ new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
306
+ detail: { name, value },
307
+ bubbles: true,
308
+ composed: true,
309
+ }),
310
+ );
311
+ },
312
+ // The delegation's own lifetime, on the protocol's events rather than the
313
+ // CUSTOM channel its steps ride. Both end at the same panel.
314
+ onSubAgentStarted: (subagentRunId, agent, parentToolCallId) => {
315
+ this.#host.subagents.start(subagentRunId, agent, parentToolCallId);
316
+ },
317
+ onSubAgentFinished: (subagentRunId) => {
318
+ this.#host.subagents.finish(subagentRunId);
319
+ },
320
+ onSubAgentError: (subagentRunId, message) => {
321
+ this.#host.subagents.fail(subagentRunId, message);
322
+ },
323
+ onMessagesSnapshot: () => {
324
+ // Honoured for persistence and announced, not re-rendered.
325
+ //
326
+ // The store follows the server, because the server is authoritative
327
+ // about what the conversation *is* -- and it would follow it anyway:
328
+ // `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
329
+ // and the run loop persists `agent.messages`. What was wrong was that
330
+ // it happened in silence, so the screen and the store disagreed and
331
+ // nobody found out until a reload served a transcript they had never
332
+ // seen. That is not reportable as a bug; it is reportable as "the chat
333
+ // lost my messages".
334
+ //
335
+ // Re-rendering from the snapshot was the other candidate and is
336
+ // declined: a snapshot can land mid-run, and rebuilding the transcript
337
+ // then would destroy the in-flight run's own UI state -- the streaming
338
+ // bubble, the open answer group, and every tool card keyed by call id,
339
+ // some of which are still waiting on results. Telling the reader costs
340
+ // none of that, and this is the same answer the same question already
341
+ // got for compaction, one handler up.
342
+ this.#host.transcript.appendNotice(
343
+ "\u{1F504}",
344
+ this.#host.strings().historyReplaced,
345
+ "history-replaced",
346
+ );
347
+ },
348
+ onToolResult: (toolCallId, content, outcome) => {
349
+ const card = this.#host.transcript.card(toolCallId);
350
+ if (card === undefined) {
351
+ return;
352
+ }
353
+ // Settled as the server says it ended, not as "it ended". This path used
354
+ // to pass DONE unconditionally, so a refusal arrived as a green card
355
+ // with the reason folded inside it -- a booking the server declined
356
+ // read, at a glance, as a booking that was made. An absent or
357
+ // unrecognised outcome still means DONE, so every server written before
358
+ // the field existed renders exactly as it did.
359
+ card.settle(toolStatusFromOutcome(outcome), content);
360
+ this.#host.transcript.markServerSettled(toolCallId);
361
+ ledger.serverSettled.add(toolCallId);
362
+ // The card stops being the live thing the moment it settles, and the
363
+ // server goes straight back to the model with the result -- a wait with
364
+ // nothing on screen to own it, and the longest one in a run when the
365
+ // result is a large inlined attachment being re-sent with every request.
366
+ // The dots go back where ``onToolCall`` took them from, after the card,
367
+ // and whatever comes next clears them: reasoning, the first text delta,
368
+ // the round ending, or ``onSettled``'s terminal guarantee.
369
+ //
370
+ // Not the same case as the one ``ToolDispatch.execute`` refuses to show
371
+ // them for. That runs after the run has ended, so there is nothing left to
372
+ // clear them and they would hang -- which is what happened before 0.2.1
373
+ // and is why they were removed from here too. The terminal guarantee
374
+ // that shipped in the same release is what makes showing them safe now.
375
+ this.#host.transcript.showPending();
376
+ },
377
+ onActivityChanged: (messageId, activityType, content) => {
378
+ this.#host.activities.draw(messageId, activityType, content);
379
+ },
380
+ onRunEnd: () => {
381
+ // Per-round end; the button stays on Stop until the whole interaction
382
+ // settles — the user must be able to cancel between tool rounds.
383
+ this.#host.transcript.hidePending();
384
+ this.#host.stream.end();
385
+ },
386
+ onError: (message) => {
387
+ ledger.announcedOutcome = true;
388
+ this.#host.announcer.announce(this.#host.strings().announceFailed);
389
+ this.#host.transcript.hidePending();
390
+ const bubble = this.#host.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
391
+ bubble.classList.add("message--failed");
392
+ // A failure is the one message whose action row is only worth having
393
+ // for Retry: there is nothing here worth copying and nothing to rate.
394
+ // A dropped connection with no way back was the whole of the gap --
395
+ // uploads had a retry and runs did not.
396
+ //
397
+ // Not a `run-notice`: that element's contract is that it "never
398
+ // settles, takes no action, and carries no controls", and is explicitly
399
+ // "distinct from an error, which is a failure". This is a failure, so
400
+ // it stays an error and gains the control instead.
401
+ this.#host.actions.attach(bubble, { rateable: false });
402
+ this.#host.transcript.revealWords(bubble);
403
+ this.#host.stream.end();
404
+ },
405
+ onCancelled: () => {
406
+ // Deliberate stop, not a failure: keep whatever partial text already
407
+ // streamed and add a muted note instead of an error bubble.
408
+ ledger.announcedOutcome = true;
409
+ this.#host.announcer.announce(this.#host.strings().announceStopped);
410
+ this.#host.transcript.hidePending();
411
+ this.#host.transcript.appendStoppedNote();
412
+ this.#host.stream.end();
413
+ },
414
+ onSettled: () => {
415
+ // Terminal guarantee: whatever path ended the run, return to rest.
416
+ if (!ledger.announcedOutcome) {
417
+ this.#host.announcer.announce(this.#host.strings().announceAnswerReady);
418
+ }
419
+ this.#host.transcript.hidePending();
420
+ this.#host.setRunning(false);
421
+ this.#host.stream.end();
422
+ // A tool card still pending at settle would hang forever: a call Stop or
423
+ // RUN_ERROR ended before it ran, a server tool whose result never
424
+ // streamed because the connection dropped, an approved call Stop kept
425
+ // from resuming. None of them finished and nobody refused them, so the
426
+ // card says exactly that, in the words the client sends the agent for
427
+ // the same call on the next request.
428
+ for (const card of this.#host.transcript.cards()) {
429
+ if (!card.settled) {
430
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
431
+ }
432
+ }
433
+ this.#host.transcript.closeGroup();
434
+ },
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Tell the host the interaction is over and what ran in it.
440
+ *
441
+ * Last thing in `onSettled`, so a listener that refetches sees a transcript
442
+ * that has already stopped changing. `side` is read from the streamed-result
443
+ * bookkeeping rather than from the tool list: whether a call executed on the
444
+ * server is a fact about the run, and a name can appear on both sides across a
445
+ * conversation.
446
+ */
447
+ #dispatchRunFinished(ledger: RunLedger): void {
448
+ const tools: ToolRun[] = ledger.tools.map(({ id, name }) => ({
449
+ name,
450
+ side: ledger.serverSettled.has(id) ? "server" : "client",
451
+ }));
452
+ const invalidated = [...ledger.invalidated];
453
+ ledger.tools.length = 0;
454
+ ledger.serverSettled.clear();
455
+ ledger.invalidated.clear();
456
+ this.#host.element.dispatchEvent(
457
+ new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
458
+ detail: { tools, invalidated },
459
+ bubbles: true,
460
+ composed: true,
461
+ }),
462
+ );
463
+ }
464
+
465
+ /**
466
+ * Route one invalidation to the host, and remember it for the run summary.
467
+ *
468
+ * Dispatched immediately rather than only at the end, because that is what
469
+ * makes a long multi-step run feel live -- the list refreshes as the third of
470
+ * eight writes lands. The accumulated set rides
471
+ * {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
472
+ * upgrades by reading one extra field instead of adding a listener.
473
+ *
474
+ * Nothing is rendered, persisted or replayed. An invalidation is an
475
+ * imperative: it has no place in the transcript and no meaning once acted on,
476
+ * and replaying one on every thread load would be a refetch storm. That is the
477
+ * whole reason the server sends it as `CUSTOM` rather than as an activity.
478
+ */
479
+ #dispatchInvalidation(ledger: RunLedger, value: unknown): void {
480
+ const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
481
+ // Defensive about the payload, not about the name: `value` is typed
482
+ // `unknown` by the protocol, so a server can put anything there, and a
483
+ // malformed announcement must not take the run down with it.
484
+ const keys = Array.isArray(payload.keys)
485
+ ? payload.keys.filter((key): key is string => typeof key === "string")
486
+ : [];
487
+ if (keys.length === 0) {
488
+ return;
489
+ }
490
+ for (const key of keys) {
491
+ ledger.invalidated.add(key);
492
+ }
493
+ this.#host.element.dispatchEvent(
494
+ new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
495
+ detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
496
+ bubbles: true,
497
+ composed: true,
498
+ }),
499
+ );
500
+ }
501
+ }