@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,279 @@
1
+ import { randomUUID } from "@ag-ui/client";
2
+ import {
3
+ type ClientConversationStore,
4
+ SessionStorageStore,
5
+ writeStoredItem,
6
+ } from "./conversation_store.js";
7
+
8
+ /**
9
+ * Storage namespaces already spoken for in this document.
10
+ *
11
+ * Per document rather than per origin, and released on disconnect, because the
12
+ * question it answers is "is another element on this page using these keys right
13
+ * now" — not "has anything ever used them". A registry that never released would
14
+ * turn every remount, and every framework re-render that moves the node, into a
15
+ * false collision that costs the element its own conversation.
16
+ *
17
+ * Module-level on purpose, and the one piece of shared mutable state the
18
+ * repository's `CLAUDE.md` exempts from its rule against it: the question is
19
+ * about the *other* live elements, which no instance field can answer. See the
20
+ * three conditions recorded there before adding anything beside it.
21
+ */
22
+ const CLAIMED_NAMESPACES = new Set<string>();
23
+
24
+ /** What the storage scope needs from the element that owns it. */
25
+ export interface StorageScopeHost {
26
+ /** The element's `id`, the namespace it prefers. */
27
+ readonly id: () => string;
28
+ /** The element's `endpoint`, the namespace it falls back to. */
29
+ readonly endpoint: () => string;
30
+ }
31
+
32
+ /**
33
+ * Which storage keys are this element's: the namespace it claims in the
34
+ * document, the key a layout preference is stored under, and the built-in
35
+ * conversation store scoped to it.
36
+ *
37
+ * Owned one-to-one by an `<ag-ui-chat>`. The one thing it shares is the record
38
+ * of claimed namespaces above, which is what keeps two elements apart.
39
+ */
40
+ export class StorageScope {
41
+ readonly #host: StorageScopeHost;
42
+ // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
43
+ // size), so two instances on one origin don't clobber each other. Empty ⇒ the
44
+ // pre-namespacing global keys (back-compat). Resolved on connect; the
45
+ // conversation adds `user-key` on top of it, see conversationNamespace.
46
+ #storageNs = "";
47
+ // The entry this element put in CLAIMED_NAMESPACES, to take back out on
48
+ // disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
49
+ // the claim to an element that mounted first).
50
+ #claimedNs: string | null = null;
51
+ // The fallback namespace minted when the preferred one was already claimed,
52
+ // with the preferred value it was minted for — so the element keeps it across
53
+ // remounts, but re-resolves if the host answers the warning with an `id`.
54
+ #generatedNs = "";
55
+ #generatedFor = "";
56
+ // The `sessionStorage`-backed store, which the element may therefore re-scope
57
+ // on a principal change. `null` when the host injected a store of its own
58
+ // kind, whose keying the element does not know and must not guess at.
59
+ #builtinStore: SessionStorageStore | null = null;
60
+
61
+ constructor(host: StorageScopeHost) {
62
+ this.#host = host;
63
+ }
64
+
65
+ /**
66
+ * Resolve the per-instance storage namespace, before any key is read or
67
+ * written, so this instance doesn't share collapsed/theme/thread state with
68
+ * another on the same origin.
69
+ */
70
+ claim(): void {
71
+ this.#storageNs = this.#claimNamespace();
72
+ }
73
+
74
+ /**
75
+ * Give the namespace back. A disconnect is not necessarily a farewell — a
76
+ * DOM move and a framework re-render both look like one — and an element
77
+ * that could not reclaim its own namespace on the way back in would lose its
78
+ * conversation to a false collision.
79
+ */
80
+ release(): void {
81
+ if (this.#claimedNs !== null) {
82
+ CLAIMED_NAMESPACES.delete(this.#claimedNs);
83
+ this.#claimedNs = null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The conversation store's namespace: this element's, scoped to the principal
89
+ * `key` names.
90
+ *
91
+ * Only the conversation is principal-scoped. The panel's own collapsed / size
92
+ * / theme preferences stay on the element's own namespace, because they are
93
+ * this element's UI state rather than anyone's data — they carry no word of
94
+ * what was said — and because they are read once while connecting, so
95
+ * re-scoping them under a live element would rearrange the panel around a
96
+ * user who had only just signed in.
97
+ */
98
+ conversationNamespace(key: string): string {
99
+ return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
100
+ }
101
+
102
+ /**
103
+ * Namespace the built-in default store, and hand back the store to use. A
104
+ * host-injected store is used verbatim; the built-in one is remembered
105
+ * either way, because it is the element's own store and a later `user-key`
106
+ * change may move it to another namespace.
107
+ */
108
+ scopeStore(store: ClientConversationStore, key: string): ClientConversationStore {
109
+ if (!(store instanceof SessionStorageStore)) {
110
+ return store;
111
+ }
112
+ const namespace = this.conversationNamespace(key);
113
+ this.#builtinStore = namespace === "" ? store : new SessionStorageStore(namespace);
114
+ return this.#builtinStore;
115
+ }
116
+
117
+ /**
118
+ * Rebuild the built-in store under `namespace`, or `null` for a store of the
119
+ * host's own kind, which holds its data somewhere the element cannot see and
120
+ * has to scope itself.
121
+ */
122
+ rescopeStore(namespace: string): SessionStorageStore | null {
123
+ if (this.#builtinStore === null) {
124
+ return null;
125
+ }
126
+ this.#builtinStore = new SessionStorageStore(namespace);
127
+ return this.#builtinStore;
128
+ }
129
+
130
+ /** This instance's namespaced form of an origin-scoped storage key. */
131
+ key(base: string): string {
132
+ return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
133
+ }
134
+
135
+ /**
136
+ * Read a namespaced origin-scoped value, falling back once to the legacy
137
+ * pre-namespacing global key (left in place) so an existing collapsed/theme
138
+ * preference survives the upgrade.
139
+ */
140
+ readScopedItem(base: string): string | null {
141
+ const scoped = sessionStorage.getItem(this.key(base));
142
+ if (scoped !== null || this.#storageNs === "") {
143
+ return scoped;
144
+ }
145
+ return sessionStorage.getItem(base);
146
+ }
147
+
148
+ /**
149
+ * Read a layout preference: where the widget sits, how big it is, which
150
+ * theme it wears.
151
+ *
152
+ * These live in `localStorage` rather than beside the transcript, because a
153
+ * layout preference is not a conversation. The transcript is deliberately
154
+ * per-tab -- two tabs are two conversations, and closing the tab ends it --
155
+ * and everything else inherited that scoping without earning it. A user who
156
+ * dragged the panel clear of their own UI did it again in the next tab, and
157
+ * again after every restart.
158
+ *
159
+ * Whether the widget is *currently open* stays per-tab with the transcript.
160
+ * It is a statement about this tab rather than a preference: carrying it
161
+ * across would pop the panel open on every new tab because it was opened
162
+ * once, somewhere else.
163
+ *
164
+ * Falls back to the session value it used to be written to, so an existing
165
+ * position survives the upgrade rather than resetting once.
166
+ */
167
+ readPreference(base: string): string | null {
168
+ try {
169
+ const stored = localStorage.getItem(this.key(base));
170
+ if (stored !== null) {
171
+ return stored;
172
+ }
173
+ } catch {
174
+ // Fall through to the per-tab copy below.
175
+ }
176
+ return this.readScopedItem(base);
177
+ }
178
+
179
+ /**
180
+ * Persist a layout preference as durably as this browser allows: to
181
+ * `localStorage` so it outlives the tab, and to the per-tab store as well.
182
+ *
183
+ * The second write is not redundancy for its own sake. A privacy mode can
184
+ * deny `localStorage` while allowing `sessionStorage`, and losing the
185
+ * durable copy should degrade to the per-tab behaviour this replaced rather
186
+ * than to no persistence at all. The read above prefers the durable copy, so
187
+ * a tab that has both cannot be shadowed by its own stale one.
188
+ *
189
+ * Neither write is worth an exception. Losing where the panel sat is not
190
+ * worth a warning either -- unlike the transcript, which says so once,
191
+ * because losing that loses the conversation on the next reload.
192
+ */
193
+ writePreference(base: string, value: string): void {
194
+ const key = this.key(base);
195
+ try {
196
+ localStorage.setItem(key, value);
197
+ } catch {
198
+ // Quota, or a store that denies writes.
199
+ }
200
+ writeStoredItem(key, value);
201
+ }
202
+
203
+ /**
204
+ * Drop a layout preference from both stores.
205
+ *
206
+ * The mirror of {@link writePreference}, and it has to clear both for the
207
+ * same reason that writes both: leaving either copy behind means the value
208
+ * comes back on the next read.
209
+ */
210
+ clearPreference(base: string): void {
211
+ const key = this.key(base);
212
+ try {
213
+ localStorage.removeItem(key);
214
+ } catch {
215
+ // A store that denies access; the per-tab copy below still goes.
216
+ }
217
+ try {
218
+ sessionStorage.removeItem(key);
219
+ } catch {
220
+ // Nothing left to do: the value was never persisted in the first place.
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Claim this element's storage namespace: its `id`, else its `endpoint`.
226
+ *
227
+ * The endpoint fallback exists so a lone widget restores its conversation
228
+ * across reloads with nothing asked of the page author. It stops working the
229
+ * moment there are two of them — a docked support panel and an inline page
230
+ * assistant against one agent mount, neither carrying an `id`, which nothing
231
+ * requires — because both resolve to the same string and then share a thread
232
+ * pointer, a drawer index and every message key. Whichever mounts second
233
+ * adopts the first's active thread and rehydrates its transcript into its own
234
+ * panel: one conversation's content inside another, on the same page.
235
+ *
236
+ * So the namespace is claimed by the first element to mount under it, and a
237
+ * second is given one of its own plus a warning naming the fix. The first
238
+ * element keeps the endpoint namespace, which is what leaves the ordinary
239
+ * single-element case exactly as it was.
240
+ *
241
+ * The generated namespace is random rather than derived from mount order.
242
+ * That costs the second element its history across reloads — the warning says
243
+ * so, and an `id` fixes it — which is the honest trade against an order-based
244
+ * name that would silently hand a stored conversation to whichever element
245
+ * happened to mount second on the next load.
246
+ */
247
+ #claimNamespace(): string {
248
+ const id = this.#host.id();
249
+ const preferred = id !== "" ? id : this.#host.endpoint();
250
+ // Nothing to key on. The pre-namespacing global keys, as before: an element
251
+ // with neither an id nor an endpoint cannot send anything, so what it would
252
+ // be claiming is an empty conversation.
253
+ if (preferred === "") {
254
+ return "";
255
+ }
256
+ // Already lost this claim once. Keep the fallback rather than drifting back
257
+ // onto a namespace the other element may since have released, which would
258
+ // swap this panel's conversation for that one's.
259
+ if (this.#generatedFor === preferred) {
260
+ return this.#generatedNs;
261
+ }
262
+ if (!CLAIMED_NAMESPACES.has(preferred)) {
263
+ CLAIMED_NAMESPACES.add(preferred);
264
+ this.#claimedNs = preferred;
265
+ return preferred;
266
+ }
267
+ this.#generatedFor = preferred;
268
+ this.#generatedNs = `${preferred}~${randomUUID()}`;
269
+ console.warn(
270
+ `<ag-ui-chat>: another element on this page already stores its ` +
271
+ `conversation under "${preferred}", so this one has been given a ` +
272
+ "throwaway namespace of its own — the two would otherwise share a " +
273
+ "thread pointer, a history drawer and every message. Give each " +
274
+ "<ag-ui-chat> its own id to keep them apart and let this one restore " +
275
+ "its conversation across reloads.",
276
+ );
277
+ return this.#generatedNs;
278
+ }
279
+ }
@@ -21,9 +21,8 @@ export type ToolOutcome = (typeof TOOL_OUTCOME)[keyof typeof TOOL_OUTCOME];
21
21
  * expected to be successes, but because the alternative is worse in the
22
22
  * direction that matters: a card claiming a call failed when it did not is a
23
23
  * lie the user acts on, while a card claiming success has at least the result
24
- * text under it for them to read. `interrupted` is the concrete case today --
25
- * pydantic-ai emits it, this vocabulary does not carry it, and a future release
26
- * may add more. Forward compatibility is the point of the open field.
24
+ * text under it for them to read. A future release may add words, and forward
25
+ * compatibility is the point of the open field.
27
26
  */
28
27
  export function toolStatusFromOutcome(outcome: unknown): SettledStatus {
29
28
  if (outcome === TOOL_OUTCOME.FAILED) {
@@ -32,5 +31,8 @@ export function toolStatusFromOutcome(outcome: unknown): SettledStatus {
32
31
  if (outcome === TOOL_OUTCOME.DENIED) {
33
32
  return TOOL_CALL_STATUS.DECLINED;
34
33
  }
34
+ if (outcome === TOOL_OUTCOME.INTERRUPTED) {
35
+ return TOOL_CALL_STATUS.INTERRUPTED;
36
+ }
35
37
  return TOOL_CALL_STATUS.DONE;
36
38
  }
package/src/core/utils.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Non-exported-from-index helpers shared by the core modules.
2
2
 
3
3
  import { randomUUID } from "@ag-ui/client";
4
+ import type { Message } from "@ag-ui/core";
4
5
  import type { ClientConversationStore } from "./conversation_store.js";
5
6
 
6
7
  /**
@@ -96,3 +97,111 @@ export function warnOnCrossOriginCredentials(
96
97
  "silence this notice. Reported once per origin.",
97
98
  );
98
99
  }
100
+
101
+ /**
102
+ * The tokens of a comma-separated attribute value, trimmed, with empty ones
103
+ * dropped -- so `"scroll, drag,"` is `["scroll", "drag"]`.
104
+ *
105
+ * Shared by every attribute that names a set of opt-ins, so each one reads a
106
+ * stray space or a trailing comma the same way.
107
+ */
108
+ export function commaTokens(value: string): string[] {
109
+ return value
110
+ .split(",")
111
+ .map((token) => token.trim())
112
+ .filter((token) => token !== "");
113
+ }
114
+
115
+ /**
116
+ * `messages` with a result for every tool call that has none, each placed at the
117
+ * end of the round that made the call.
118
+ *
119
+ * **Why every call.** Several model providers reject a request carrying a tool
120
+ * call with no result, and a conversation that sends one cannot continue. A run
121
+ * leaves calls unanswered in more ways than any one path can list: Stop while
122
+ * the stream is still arriving, a round ending on `RUN_ERROR`, a name no tool
123
+ * here owns, a server that never streamed a result, a page reloaded mid-run.
124
+ * Answering whatever is unanswered, rather than each of those where it happens,
125
+ * is what keeps a path nobody listed from sending one.
126
+ *
127
+ * **Where each result goes.** At the end of its round: the assistant turns
128
+ * carrying calls, then the results that follow them. That is the shape the run
129
+ * loop writes for a round it executed, and a provider reads a result only in the
130
+ * turn straight after the calls; appended at the end of history, it would sit
131
+ * behind a user turn or a reply the conversation has moved on to. A call with a
132
+ * result after a *later* turn is left alone, since it is answered.
133
+ *
134
+ * `answer` builds the one message for a call id, so the caller decides what is
135
+ * true to say and where the label for a reload goes. Calls in `except` are about
136
+ * to be answered by something else -- a resumed approval, or a navigation
137
+ * result -- and must not be answered twice.
138
+ *
139
+ * Returns `messages` itself when nothing is owed.
140
+ *
141
+ * Every condition deciding a round's end is one branch arc, which a coverage
142
+ * gate reports as covered with any part of it deleted. Each is held by a test in
143
+ * `ag_ui_chat_unanswered_tool_calls.test.ts`: a result continuing the round by
144
+ * "answers it after the round's own results"; a turn without calls closing it by
145
+ * "sends a not-finished result for a call that arrived before it"; a turn with
146
+ * calls after a result closing it by "answers each round's open call before the
147
+ * next round starts"; the answered check by "sends the decline Stop gave the
148
+ * open approval"; and the exclusion by "leaves the call it resumes for the
149
+ * server to answer".
150
+ */
151
+ export function answerUnansweredCalls(
152
+ messages: readonly Message[],
153
+ answer: (toolCallId: string) => Message,
154
+ except: ReadonlySet<string> = new Set(),
155
+ ): readonly Message[] {
156
+ const answered = new Set<string>(except);
157
+ for (const message of messages) {
158
+ if (message.role === "tool") {
159
+ answered.add(message.toolCallId);
160
+ }
161
+ }
162
+ const result: Message[] = [];
163
+ let owed: string[] = [];
164
+ let afterResult = false;
165
+ for (const message of messages) {
166
+ const calls = message.role === "assistant" ? toolCallIds(message.toolCalls) : [];
167
+ const continuesRound = message.role === "tool" || (calls.length > 0 && !afterResult);
168
+ if (!continuesRound) {
169
+ result.push(...owed.map(answer));
170
+ owed = [];
171
+ }
172
+ result.push(message);
173
+ afterResult = message.role === "tool";
174
+ for (const id of calls) {
175
+ if (!answered.has(id)) {
176
+ // Marked as it is owed, so a store holding the same id twice gets one
177
+ // answer rather than two, which a provider rejects just the same.
178
+ answered.add(id);
179
+ owed.push(id);
180
+ }
181
+ }
182
+ }
183
+ if (result.length + owed.length === messages.length) {
184
+ return messages;
185
+ }
186
+ result.push(...owed.map(answer));
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * The ids of the tool calls an assistant turn carries, skipping anything without
192
+ * one.
193
+ *
194
+ * The field is read off the wire or out of a store, and neither is held to its
195
+ * declared type: a server dumping Python models sends `null` for no calls, and a
196
+ * stored conversation may be hand-edited or written by an older version. A throw
197
+ * here would cost the whole request, so a shapeless entry is passed over.
198
+ */
199
+ function toolCallIds(value: unknown): string[] {
200
+ // `Object()` boxes a `null` or a primitive entry into something with no `id`,
201
+ // so one read covers every shape without a branch per shape.
202
+ return Array.isArray(value)
203
+ ? value
204
+ .map((call: unknown) => (Object(call) as { id?: unknown }).id)
205
+ .filter((id): id is string => typeof id === "string")
206
+ : [];
207
+ }
@@ -0,0 +1,166 @@
1
+ import type { SkillsMenu } from "../ui/composer/skills_menu.js";
2
+ import { fillUiString } from "../ui/fill_ui_string.js";
3
+ import type { UiStrings } from "../ui/ui_strings.js";
4
+ import { fillTemplate } from "./fill_template.js";
5
+ import { parseSkills } from "./parse_skills.js";
6
+ import type { Skill } from "./skill.js";
7
+
8
+ /** What the skill catalog needs from the element that owns it. */
9
+ export interface SkillCatalogHost {
10
+ /** The custom element, whose `data-skills-url` names the backend catalog. */
11
+ readonly element: HTMLElement;
12
+ /** The chips row and the slash palette the merged catalog is shown in. */
13
+ readonly menu: SkillsMenu;
14
+ /** The composer a picked skill's prompt is written into. */
15
+ readonly input: HTMLTextAreaElement;
16
+ /** The hint above the composer, saying what a skill still needs. */
17
+ readonly hint: HTMLElement;
18
+ /** The resolved string table. */
19
+ readonly strings: () => UiStrings;
20
+ /** The host's `skillContext`, read at the moment of the pick. */
21
+ readonly context: () => Record<string, unknown>;
22
+ /** Read an opt-in flag attribute. */
23
+ readonly flag: (name: string) => boolean;
24
+ /** Parse a JSON-valued attribute, warning when it will not parse. */
25
+ readonly readJsonAttribute: (name: string) => unknown;
26
+ /** The fetch options every request the element makes carries. */
27
+ readonly fetchInit: (url: string) => RequestInit | undefined;
28
+ /** The element's public `sendMessage`. */
29
+ readonly send: (content: string) => void;
30
+ /** Send whatever the composer holds, as the Send button does. */
31
+ readonly submit: () => void;
32
+ /** Resize the composer to what it now holds. */
33
+ readonly autoGrow: () => void;
34
+ }
35
+
36
+ /**
37
+ * The element's skills: the three catalogs a skill can come from, merged into
38
+ * the chips and the slash palette, and what picking one does to the composer.
39
+ *
40
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
41
+ * instance.
42
+ */
43
+ export class SkillCatalog {
44
+ readonly #host: SkillCatalogHost;
45
+ // Skill catalog by source; merged backend → embed → client (later wins).
46
+ #backendSkills: readonly Skill[] = [];
47
+ #embedSkills: readonly Skill[] = [];
48
+ #clientSkills: readonly Skill[] = [];
49
+
50
+ constructor(host: SkillCatalogHost) {
51
+ this.#host = host;
52
+ }
53
+
54
+ /** The replacement behind `AgUiChat.setSkills`, whose doc is the contract. */
55
+ setClientSkills(skills: readonly Skill[]): void {
56
+ this.#clientSkills = skills;
57
+ this.#recompute();
58
+ }
59
+
60
+ /**
61
+ * Wire the skill surfaces: opt-in flags and the embedded catalog. The backend
62
+ * catalog is fetched from the element's startup, a microtask later, so it
63
+ * carries the host's transport configuration.
64
+ */
65
+ init(): void {
66
+ this.#host.menu.enableChips(this.#host.flag("data-prompt-chips"));
67
+ this.#host.menu.enableSlash(this.#host.flag("data-slash-commands"));
68
+ this.#embedSkills = this.#readEmbedded();
69
+ this.#recompute();
70
+ }
71
+
72
+ /** Fetch the backend skills catalog from `data-skills-url`, if set. */
73
+ async fetch(): Promise<void> {
74
+ const url = this.#host.element.getAttribute("data-skills-url");
75
+ if (url === null) {
76
+ return;
77
+ }
78
+ try {
79
+ const response = await fetch(url, this.#host.fetchInit(url));
80
+ this.#backendSkills = parseSkills(await response.json());
81
+ this.#recompute();
82
+ } catch {
83
+ // Network/parse failure: skills just stay as the embedded/client set.
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Act on a picked skill.
89
+ *
90
+ * A skill with no `prompt` is server-resolved: picking it sends the bare
91
+ * `/name` token for the agent to expand, so the wording never reaches the
92
+ * browser. Prefer that shape — a skill often states a project's internal
93
+ * workflow most plainly, and a catalog endpoint is a plain GET.
94
+ *
95
+ * A skill carrying a `prompt` has the client fill its `{placeholder}`s from
96
+ * the page instead, which is right for placeholders only the page can supply.
97
+ *
98
+ * Either way a pick sends; `sendImmediately: false` opts into pre-filling the
99
+ * composer instead.
100
+ */
101
+ apply(skill: Skill): void {
102
+ const { input, hint } = this.#host;
103
+ if (skill.prompt === undefined) {
104
+ hint.hidden = true;
105
+ this.#host.send(`/${skill.name}`);
106
+ return;
107
+ }
108
+ const { text, missing } = fillTemplate(skill.prompt, this.#host.context());
109
+ if (missing.length > 0) {
110
+ // Hand the user something to work with rather than only a refusal. The
111
+ // partially-filled template goes into the composer with its unresolved
112
+ // `{placeholder}`s intact and the first one selected, so the next
113
+ // keystroke replaces it. Blocking with a hint alone left whatever the
114
+ // user had typed to open the palette — a lone "/" — sitting there, which
115
+ // says nothing about what the skill wanted or how to give it.
116
+ hint.textContent = fillUiString(this.#host.strings().skillNeeds, {
117
+ title: skill.title,
118
+ fields: missing.join(", "),
119
+ });
120
+ hint.hidden = false;
121
+ input.value = text;
122
+ this.#host.autoGrow();
123
+ input.focus();
124
+ this.#selectFirstPlaceholder(text);
125
+ return;
126
+ }
127
+ hint.hidden = true;
128
+ input.value = text;
129
+ this.#host.autoGrow();
130
+ if (skill.sendImmediately === false) {
131
+ input.focus();
132
+ return;
133
+ }
134
+ this.#host.submit();
135
+ }
136
+
137
+ /** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
138
+ #readEmbedded(): readonly Skill[] {
139
+ // `parseSkills` drops anything that is not a well-formed skill, `null`
140
+ // included, so the absent and unparseable cases need no branch here.
141
+ return parseSkills(this.#host.readJsonAttribute("data-skills"));
142
+ }
143
+
144
+ /** Merge the three sources (backend → embed → client; later wins by name). */
145
+ #recompute(): void {
146
+ const merged = new Map<string, Skill>();
147
+ for (const skill of [...this.#backendSkills, ...this.#embedSkills, ...this.#clientSkills]) {
148
+ merged.set(skill.name, skill);
149
+ }
150
+ this.#host.menu.setSkills([...merged.values()]);
151
+ }
152
+
153
+ /**
154
+ * Put the caret on the first unresolved placeholder, selected.
155
+ *
156
+ * Typing then replaces it, which is the shortest path from "this skill needs
157
+ * a topic" to a sendable prompt.
158
+ */
159
+ #selectFirstPlaceholder(text: string): void {
160
+ // The first surviving brace *is* the first unresolved placeholder — a
161
+ // resolved one was substituted away — so this needs no search through the
162
+ // missing keys and no not-found branch to defend.
163
+ const start = text.indexOf("{");
164
+ this.#host.input.setSelectionRange(start, text.indexOf("}", start) + 1);
165
+ }
166
+ }
@@ -0,0 +1,19 @@
1
+ import { LOAD_CAPABILITY_TOOL } from "../constants.js";
2
+ import type { AgUiToolCall } from "../core/agui_client.js";
3
+
4
+ /**
5
+ * The skill name a `load_capability` call activated, or `null` when the call is
6
+ * something else.
7
+ *
8
+ * Every deferred capability loads through this one tool, so the id is a skill
9
+ * name only when the project wired agent skills; another project's capability
10
+ * id surfaces here too. Acceptable for a muted notice, and better than a
11
+ * parallel signal — the id is exactly what the model selected.
12
+ */
13
+ export function skillNameFrom(call: AgUiToolCall): string | null {
14
+ if (call.name !== LOAD_CAPABILITY_TOOL) {
15
+ return null;
16
+ }
17
+ const id = (call.args as { id?: unknown } | null | undefined)?.id;
18
+ return typeof id === "string" && id !== "" ? id : null;
19
+ }