@artooi/ag-ui-web-component 0.27.0 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +247 -1
  2. package/README.md +186 -6
  3. package/dist/ag-ui-web-component.bundle.js +50 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +55 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +8 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +43 -1
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/create_http_agent.d.ts +13 -0
  12. package/dist/core/create_http_agent.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts +23 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +28 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +564 -94
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/is_destructive.d.ts +8 -2
  20. package/dist/tools/is_destructive.d.ts.map +1 -1
  21. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  22. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  23. package/dist/ui/render_markdown.d.ts +23 -5
  24. package/dist/ui/render_markdown.d.ts.map +1 -1
  25. package/dist/ui/resize_handle.d.ts +5 -1
  26. package/dist/ui/resize_handle.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +13 -7
  28. package/dist/ui/ui_strings.d.ts.map +1 -1
  29. package/dist/ui/voice_input.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/core/ag_ui_chat.ts +431 -41
  32. package/src/core/agui_client.ts +18 -1
  33. package/src/core/conversation_store.ts +128 -42
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +35 -2
  36. package/src/core/utils.ts +58 -0
  37. package/src/tools/is_destructive.ts +8 -2
  38. package/src/tools/parse_tool_catalog.ts +18 -6
  39. package/src/ui/render_markdown.ts +111 -21
  40. package/src/ui/resize_handle.ts +32 -2
  41. package/src/ui/ui_strings.ts +19 -8
  42. package/src/ui/voice_input.ts +43 -0
  43. package/src/version.ts +1 -1
@@ -97,7 +97,14 @@ export interface AgUiClientHandlers {
97
97
  onActivityChanged(messageId: string, activityType: string, content: unknown): void;
98
98
  /** Fired when a reasoning model starts emitting its chain-of-thought. */
99
99
  onReasoningStart(): void;
100
- /** Fired on every reasoning token; ``buffer`` is the full reasoning text so far. */
100
+ /**
101
+ * Fired on every reasoning token, and once more when the block ends.
102
+ *
103
+ * ``buffer`` is the text accumulated *before* the token that triggered the
104
+ * call, which is what the protocol client passes -- so the stream trails by
105
+ * one delta and the final call, at the end of the block, is what completes
106
+ * it. Render the buffer wholesale rather than appending it.
107
+ */
101
108
  onReasoningDelta(buffer: string): void;
102
109
  /** Fired when the reasoning block ends (before the answer text streams). */
103
110
  onReasoningEnd(): void;
@@ -497,6 +504,16 @@ export class AgUiClient {
497
504
  onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
498
505
  h.onReasoningDelta(reasoningMessageBuffer);
499
506
  },
507
+ // The delta callback reports the buffer as it stood *before* the announced
508
+ // delta was appended, so on its own it always trails the stream by one and
509
+ // renders nothing at all for a block that arrives as a single delta. The
510
+ // answer text is spared that because its own end event carries the whole
511
+ // message; this is the reasoning counterpart, and it has to be
512
+ // REASONING_MESSAGE_END rather than REASONING_END, because only the former
513
+ // carries a buffer.
514
+ onReasoningMessageEndEvent({ reasoningMessageBuffer }) {
515
+ h.onReasoningDelta(reasoningMessageBuffer);
516
+ },
500
517
  onReasoningEndEvent() {
501
518
  h.onReasoningEnd();
502
519
  },
@@ -102,6 +102,49 @@ const TITLE_LIMIT = 60;
102
102
  const PREVIEW_LIMIT = 100;
103
103
  const DEFAULT_TITLE = "New conversation";
104
104
 
105
+ // One warning per page, not one per write. The condition is origin-wide and
106
+ // persistent — a full quota stays full — so a message per persisted turn (or,
107
+ // on the resize path, per keystroke) would bury the one that matters.
108
+ let writeFailureReported = false;
109
+
110
+ /**
111
+ * `sessionStorage.setItem` that survives a store which refuses to write.
112
+ *
113
+ * `setItem` throws on an exhausted quota (a long conversation, or one turn
114
+ * carrying a large tool result) and in privacy modes that deny storage
115
+ * altogether. Every write here is a *durability* concern — surviving a reload —
116
+ * and none of them is worth an exception, because of where they are called
117
+ * from: the element persists the transcript from inside the run loop, so an
118
+ * unguarded throw escapes as a run error and tells the user the agent failed
119
+ * when nothing but the browser's storage did. On the cancel path it escapes as
120
+ * an unhandled rejection instead.
121
+ *
122
+ * So a failed write loses the reload, never the conversation on screen, and
123
+ * says so once. Recovery is in the user's hands already: deleting the oversized
124
+ * thread from the history drawer is a `removeItem`, which frees the quota.
125
+ */
126
+ export function writeStoredItem(key: string, value: string): void {
127
+ try {
128
+ sessionStorage.setItem(key, value);
129
+ } catch {
130
+ if (writeFailureReported) {
131
+ return;
132
+ }
133
+ writeFailureReported = true;
134
+ console.warn(
135
+ "<ag-ui-chat>: the browser refused a sessionStorage write — the quota is " +
136
+ "full, or storage is disabled for this context. The conversation " +
137
+ "continues, but it will not survive a page reload. Deleting a long " +
138
+ "conversation from the history drawer frees the quota.",
139
+ );
140
+ }
141
+ }
142
+
143
+ /** The storage-key root for a namespace; `""` is the pre-namespacing global root. */
144
+ function rootFor(namespace: string): string {
145
+ return namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
146
+ }
147
+
105
148
  /** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
106
149
  interface StoredThread {
107
150
  threadId: string;
@@ -122,15 +165,59 @@ interface StoredThread {
122
165
  * An optional `namespace` scopes every key to one element, so two
123
166
  * `<ag-ui-chat>` instances on the same origin keep separate active-thread
124
167
  * pointers and drawer indexes instead of clobbering each other. The default
125
- * empty namespace keeps the origin-global keys; see {@link #migrateLegacyKeys}.
168
+ * empty namespace keeps the origin-global keys, which a namespaced store adopts
169
+ * on construction; see {@link SessionStorageStore.adopt}.
126
170
  */
127
171
  export class SessionStorageStore implements ClientConversationStore {
128
172
  readonly #root: string;
129
173
 
130
174
  constructor(namespace = "") {
131
- this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
175
+ this.#root = rootFor(namespace);
132
176
  if (namespace !== "") {
133
- this.#migrateLegacyKeys();
177
+ // One-time move of the pre-namespacing global keys, so an existing
178
+ // conversation isn't orphaned by the upgrade. See {@link adopt}.
179
+ SessionStorageStore.adopt("", namespace);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Move every key a store owns out of `from`'s namespace and into `to`'s.
185
+ *
186
+ * Two callers, one move. The constructor adopts the pre-namespacing global
187
+ * keys (`from` = `""`); `<ag-ui-chat>` adopts an element-scoped conversation
188
+ * into a principal-scoped one the first time a `user-key` arrives, which is a
189
+ * host naming the user who was already there rather than a handover.
190
+ *
191
+ * Only this store's own suffixes move — the element's `collapsed` / `size` /
192
+ * `theme` keys share the global root and are deliberately left where they
193
+ * are. A value already present at the destination wins: the destination is
194
+ * the durable record and the source is the stray this move exists to clear.
195
+ */
196
+ static adopt(from: string, to: string): void {
197
+ const fromRoot = `${rootFor(from)}:`;
198
+ const toRoot = `${rootFor(to)}:`;
199
+ for (const [key, suffix] of ownedKeys(fromRoot)) {
200
+ const value = sessionStorage.getItem(key);
201
+ const destination = toRoot + suffix;
202
+ if (value !== null && sessionStorage.getItem(destination) === null) {
203
+ writeStoredItem(destination, value);
204
+ }
205
+ sessionStorage.removeItem(key);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Forget everything a store holds for `namespace`.
211
+ *
212
+ * The logout primitive: `<ag-ui-chat>` calls it when its `user-key` changes,
213
+ * and a host driving its own store can call it from its own sign-out path.
214
+ * Deliberately narrow — it removes only keys under this exact namespace whose
215
+ * suffix parses as one this store writes, so it can never reach another
216
+ * element's conversation or the host's own `sessionStorage` entries.
217
+ */
218
+ static purge(namespace: string): void {
219
+ for (const [key] of ownedKeys(`${rootFor(namespace)}:`)) {
220
+ sessionStorage.removeItem(key);
134
221
  }
135
222
  }
136
223
 
@@ -140,8 +227,8 @@ export class SessionStorageStore implements ClientConversationStore {
140
227
 
141
228
  newThread(): string {
142
229
  const id = randomUUID();
143
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), id);
144
- sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
230
+ writeStoredItem(this.#key(THREAD_SUFFIX), id);
231
+ writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
145
232
  return id;
146
233
  }
147
234
 
@@ -157,7 +244,7 @@ export class SessionStorageStore implements ClientConversationStore {
157
244
  }
158
245
 
159
246
  saveMessages(threadId: string, messages: readonly Message[]): void {
160
- sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
247
+ writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
161
248
  sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
162
249
  this.#touchThread(threadId, messages);
163
250
  }
@@ -172,7 +259,7 @@ export class SessionStorageStore implements ClientConversationStore {
172
259
  sessionStorage.removeItem(key);
173
260
  return;
174
261
  }
175
- sessionStorage.setItem(key, JSON.stringify(checkpoint));
262
+ writeStoredItem(key, JSON.stringify(checkpoint));
176
263
  }
177
264
 
178
265
  clear(threadId: string): void {
@@ -196,7 +283,7 @@ export class SessionStorageStore implements ClientConversationStore {
196
283
  }
197
284
 
198
285
  setActiveThread(threadId: string): void {
199
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
286
+ writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
200
287
  }
201
288
 
202
289
  renameThread(threadId: string, title: string): void {
@@ -244,7 +331,7 @@ export class SessionStorageStore implements ClientConversationStore {
244
331
  sessionStorage.removeItem(key);
245
332
  return;
246
333
  }
247
- sessionStorage.setItem(key, JSON.stringify(threads));
334
+ writeStoredItem(key, JSON.stringify(threads));
248
335
  }
249
336
 
250
337
  /** This store's fully-qualified key for a suffix (namespaced when set). */
@@ -252,37 +339,6 @@ export class SessionStorageStore implements ClientConversationStore {
252
339
  return `${this.#root}:${suffix}`;
253
340
  }
254
341
 
255
- /**
256
- * One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
257
- * namespace, so an existing conversation isn't orphaned. Only this store's own
258
- * keys move — the element's `collapsed` / `theme` keys are left alone. The
259
- * first namespaced instance to mount adopts the data; a second namespace
260
- * finds it gone and starts fresh.
261
- */
262
- #migrateLegacyKeys(): void {
263
- const legacyRoot = `${KEY_ROOT}:`;
264
- const moves: Array<readonly [string, string]> = [];
265
- for (let i = 0; i < sessionStorage.length; i += 1) {
266
- const key = sessionStorage.key(i);
267
- if (key === null || !key.startsWith(legacyRoot)) {
268
- continue;
269
- }
270
- const suffix = key.slice(legacyRoot.length);
271
- if (isOwnedSuffix(suffix)) {
272
- moves.push([key, this.#key(suffix)]);
273
- }
274
- }
275
- // Collected first, mutated second — writing while iterating by index skips
276
- // entries as the key list shifts.
277
- for (const [from, to] of moves) {
278
- const value = sessionStorage.getItem(from);
279
- if (value !== null && sessionStorage.getItem(to) === null) {
280
- sessionStorage.setItem(to, value);
281
- }
282
- sessionStorage.removeItem(from);
283
- }
284
- }
285
-
286
342
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
287
343
  #readJson<T>(key: string): T | null {
288
344
  const raw = sessionStorage.getItem(key);
@@ -297,13 +353,43 @@ export class SessionStorageStore implements ClientConversationStore {
297
353
  }
298
354
  }
299
355
 
300
- /** Whether a legacy key suffix belongs to the store (vs the element's own keys). */
356
+ /**
357
+ * Every `sessionStorage` key under `root` that this store wrote, as
358
+ * `[key, suffix]`.
359
+ *
360
+ * Collected into an array before the caller mutates anything: `sessionStorage`
361
+ * is enumerated by index, and removing an entry mid-loop shifts the ones after
362
+ * it out from under the cursor.
363
+ *
364
+ * The suffix test is what makes {@link SessionStorageStore.purge} safe to point
365
+ * at a namespace. It matters most for the global root, which the element's own
366
+ * `collapsed` / `size` / `theme` keys share — but it also means a namespace
367
+ * whose name happens to be a prefix of another cannot reach into it, since the
368
+ * remainder would have to parse as one of these suffixes.
369
+ */
370
+ function ownedKeys(root: string): Array<readonly [string, string]> {
371
+ const found: Array<readonly [string, string]> = [];
372
+ for (let index = 0; index < sessionStorage.length; index += 1) {
373
+ const key = sessionStorage.key(index);
374
+ if (key === null || !key.startsWith(root)) {
375
+ continue;
376
+ }
377
+ const suffix = key.slice(root.length);
378
+ if (isOwnedSuffix(suffix)) {
379
+ found.push([key, suffix]);
380
+ }
381
+ }
382
+ return found;
383
+ }
384
+
385
+ /** Whether a key suffix belongs to the store (vs the element's own keys). */
301
386
  function isOwnedSuffix(suffix: string): boolean {
302
387
  return (
303
388
  suffix === THREAD_SUFFIX ||
304
389
  suffix === THREADS_SUFFIX ||
305
390
  suffix.startsWith(MESSAGES_SUFFIX) ||
306
- suffix.startsWith(CHECKPOINT_SUFFIX)
391
+ suffix.startsWith(CHECKPOINT_SUFFIX) ||
392
+ suffix.startsWith(MINTED_SUFFIX)
307
393
  );
308
394
  }
309
395
 
@@ -1,6 +1,6 @@
1
1
  import { type AbstractAgent, HttpAgent } from "@ag-ui/client";
2
2
  import type { Message } from "@ag-ui/core";
3
- import { withCredentials } from "./utils.js";
3
+ import { warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
4
4
 
5
5
  /** Config for {@link createHttpAgent}. */
6
6
  export interface HttpAgentOptions {
@@ -32,6 +32,19 @@ export interface HttpAgentOptions {
32
32
  * server streams `STATE_SNAPSHOT` / `STATE_DELTA`.
33
33
  */
34
34
  initialState?: Readonly<Record<string, unknown>>;
35
+ /**
36
+ * Origins, besides the document's own, this agent may carry the host's
37
+ * credentials to.
38
+ *
39
+ * Running the agent on another subdomain is a normal deployment and stays
40
+ * supported, so a cross-origin endpoint is not refused — it is *announced*,
41
+ * once per origin, on the console. Listing an origin here says the
42
+ * destination was chosen deliberately and silences the notice for it.
43
+ *
44
+ * Entries are compared as serialized origins (`https://agent.example.com`,
45
+ * scheme and port included), which is what `URL.origin` produces.
46
+ */
47
+ trustedOrigins?: readonly string[];
35
48
  }
36
49
 
37
50
  /**
@@ -42,9 +55,11 @@ export interface HttpAgentOptions {
42
55
  * {@link AbstractAgent}.
43
56
  */
44
57
  export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
58
+ const staticHeaders = options.headers ?? {};
59
+ const warned = new Set<string>();
45
60
  return new HttpAgent({
46
61
  url: options.endpoint,
47
- headers: options.headers ?? {},
62
+ headers: staticHeaders,
48
63
  initialState: { ...(options.initialState ?? {}) },
49
64
  // HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
50
65
  // rebinding the global `fetch` to the agent instance — "Illegal invocation"
@@ -53,6 +68,13 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
53
68
  // own config having no seam for either.
54
69
  fetch: (url, init) => {
55
70
  const fresh = options.getHeaders?.();
71
+ // Only the names the *host* supplied. `HttpAgent` adds `Content-Type` and
72
+ // `Accept` to every request and neither is a credential, so reporting the
73
+ // outgoing header set wholesale would cry wolf on every plain request.
74
+ const credentialNames = [
75
+ ...new Set([...Object.keys(staticHeaders), ...Object.keys(fresh ?? {})]),
76
+ ].sort();
77
+ warnOnCrossOriginCredentials(url, credentialNames, options.trustedOrigins ?? [], warned);
56
78
  if (fresh === undefined) {
57
79
  return fetch(url, withCredentials(init, options.credentials));
58
80
  }
@@ -40,25 +40,50 @@ type CredentialsProvider = () => RequestCredentials | undefined;
40
40
  * the fallback when a request fails. Rename and delete apply optimistically via
41
41
  * a local overlay, so the drawer reflects them before the fire-and-forget
42
42
  * round-trip lands.
43
+ *
44
+ * Pass `cacheMessages: false` to keep message bodies out of the browser
45
+ * entirely; see the constructor.
43
46
  */
44
47
  export class RemoteConversationStore implements ClientConversationStore {
45
48
  readonly #url: string;
46
49
  readonly #headers: HeadersProvider;
47
50
  readonly #local: ClientConversationStore;
48
51
  readonly #credentials: CredentialsProvider;
52
+ readonly #cacheMessages: boolean;
49
53
  readonly #dropped = new Set<string>();
50
54
  readonly #renamed = new Map<string, string>();
51
55
 
56
+ /**
57
+ * @param cacheMessages Whether to mirror message bodies into the local store.
58
+ *
59
+ * `true` (the default, and the behaviour this class has always had) keeps a
60
+ * local copy of every turn, so the transcript still replays when the thread
61
+ * endpoint is unreachable. `false` is for the deployment that chose a
62
+ * server-backed store precisely so transcripts do not sit in the browser:
63
+ * regulated content, a shared workstation, an operator who has to be able to
64
+ * say where the conversation lives. It is not the same as passing a local
65
+ * store that does nothing — the local store also owns the active thread id,
66
+ * the navigation checkpoint and the "nothing sent here yet" marker, all of
67
+ * which must keep working — so the opt-out is scoped to the bodies alone.
68
+ *
69
+ * The cost is deliberate and worth stating: with no local copy there is
70
+ * nothing to fall back to, so a failed request shows an empty transcript
71
+ * rather than a stale one, and the drawer's offline list loses its previews
72
+ * (a preview is an excerpt of a message, which is the very thing being kept
73
+ * off the client).
74
+ */
52
75
  constructor(
53
76
  url: string,
54
77
  headers: HeadersProvider = () => ({}),
55
78
  local: ClientConversationStore = new SessionStorageStore(),
56
79
  credentials: CredentialsProvider = () => undefined,
80
+ cacheMessages = true,
57
81
  ) {
58
82
  this.#url = url.endsWith("/") ? url : `${url}/`;
59
83
  this.#headers = headers;
60
84
  this.#local = local;
61
85
  this.#credentials = credentials;
86
+ this.#cacheMessages = cacheMessages;
62
87
  }
63
88
 
64
89
  threadId(): string {
@@ -84,8 +109,16 @@ export class RemoteConversationStore implements ClientConversationStore {
84
109
  }
85
110
 
86
111
  saveMessages(threadId: string, messages: readonly Message[]): void {
87
- // The agent run persists server-side; keep a local cache for offline replay.
88
- this.#local.saveMessages(threadId, messages);
112
+ // The agent run persists server-side; keep a local cache for offline replay
113
+ // unless the host asked for the bodies to stay off the client.
114
+ //
115
+ // The empty list is not a way of saying "nothing happened". A save is what
116
+ // retires the local store's minted marker, and `loadMessages` skips the
117
+ // server for a thread that store still calls unsent — so dropping the call
118
+ // entirely would leave every thread permanently unsent and its history
119
+ // unreachable after a reload. Saving an empty list records that the thread
120
+ // is real without recording a word of what was said in it.
121
+ this.#local.saveMessages(threadId, this.#cacheMessages ? messages : []);
89
122
  }
90
123
 
91
124
  loadCheckpoint(threadId: string): NavigationCheckpoint | null {
package/src/core/utils.ts CHANGED
@@ -38,3 +38,61 @@ export function mintThread(store: ClientConversationStore): string {
38
38
  store.setActiveThread(id);
39
39
  return id;
40
40
  }
41
+
42
+ /**
43
+ * Announce host credentials about to leave the document's origin.
44
+ *
45
+ * `endpoint` and its six sibling URL attributes are plain HTML, and a page that
46
+ * interpolates one from a query parameter or from tenant-authored
47
+ * configuration has handed an attacker the destination. The browser preflights
48
+ * the custom header, any server willing to answer `Access-Control-Allow-Headers`
49
+ * receives it, and the token leaves on the element's very first request —
50
+ * before the user has done anything. Nothing else in this package compares a
51
+ * configured URL against an expected origin, so without this the delivery is
52
+ * silent, which is the only part of that sequence worth changing.
53
+ *
54
+ * A warning rather than a refusal because a cross-origin agent is a documented
55
+ * deployment: refusing would break working installations to defend against a
56
+ * page that is already interpolating untrusted data into its own markup. What
57
+ * it removes is the silence.
58
+ *
59
+ * `warned` is supplied by the caller rather than held here, per this package's
60
+ * rule against shared mutable state: two elements on one page must each get
61
+ * their own notice, and the set lives exactly as long as its owner.
62
+ *
63
+ * Every configured URL goes through this, not the agent endpoint alone. The
64
+ * tool catalog, the skills list, the thread and attachment endpoints and the
65
+ * upload target are all named by the same kind of host attribute and all carry
66
+ * the same headers, so covering one of them and not the rest would report the
67
+ * least interesting of the seven.
68
+ */
69
+ export function warnOnCrossOriginCredentials(
70
+ url: string | URL,
71
+ credentialNames: readonly string[],
72
+ trustedOrigins: readonly string[],
73
+ warned: Set<string>,
74
+ ): void {
75
+ if (credentialNames.length === 0) {
76
+ return;
77
+ }
78
+ // Resolved against the document, so a relative endpoint — the ordinary case —
79
+ // lands on this origin and says nothing.
80
+ const destination = new URL(String(url), location.href).origin;
81
+ if (
82
+ destination === location.origin ||
83
+ trustedOrigins.includes(destination) ||
84
+ warned.has(destination)
85
+ ) {
86
+ return;
87
+ }
88
+ warned.add(destination);
89
+ console.warn(
90
+ `<ag-ui-chat>: sending host credentials (${credentialNames.join(", ")}) to ` +
91
+ `${destination}, which is not this page's origin (${location.origin}). Those headers ` +
92
+ "are the page's own authentication, and whichever server answers the browser's " +
93
+ "preflight receives them — so a URL attribute built from a query parameter or from " +
94
+ "tenant-authored configuration is a channel for the token to leave on. If this " +
95
+ "destination is deliberate, name it in `trustedOrigins` to confirm it and " +
96
+ "silence this notice. Reported once per origin.",
97
+ );
98
+ }
@@ -3,8 +3,14 @@ import { X_DESTRUCTIVE_KEY } from "../constants.js";
3
3
  /**
4
4
  * Whether a tool's JSON-Schema `parameters` marks it destructive.
5
5
  *
6
- * Reads the `x-destructive` extension stamped by the server (`django-ag-ui`'s
7
- * `build_input_schema`) or by a host declaring a tool directly.
6
+ * Reads the `x-destructive` extension off a schema the **host** declared —
7
+ * a tool passed to `registerTool`, or one of the built-ins. A server-side
8
+ * tool's schema never reaches the browser: tool definitions travel
9
+ * client-to-server on `RunAgentInput.tools`, and the only channel coming the
10
+ * other way is the tool catalog (`data-tools-url`), which carries labels, not
11
+ * schemas. So a server tool marked destructive there is not gated here, and
12
+ * must be gated server-side instead — the confirmation this flag drives is a
13
+ * property of tools the browser itself executes.
8
14
  */
9
15
  export function isDestructive(parameters: Record<string, unknown>): boolean {
10
16
  return parameters[X_DESTRUCTIVE_KEY] === true;
@@ -7,17 +7,24 @@ export interface ToolCatalogEntry {
7
7
  readonly name: string;
8
8
  /** A friendly card label for the tool. */
9
9
  readonly summary: string;
10
- /** Optional longer blurb (e.g. for a future tooltip). */
10
+ /** Optional longer blurb (e.g. for a tooltip). */
11
11
  readonly description?: string;
12
12
  }
13
13
 
14
14
  /**
15
- * Parse a fetched tool catalog into a `name → summary` map, skipping any entry
15
+ * Parse a fetched tool catalog into a `name → entry` map, skipping any entry
16
16
  * that isn't a `{ name: string, summary: string }` object. Tolerant by design:
17
- * a malformed payload yields an empty map rather than throwing.
17
+ * a malformed payload yields an empty map rather than throwing, and an
18
+ * optional field of the wrong type costs that field rather than the entry.
19
+ *
20
+ * Whole entries rather than bare summaries, even though the element itself
21
+ * only labels cards with `summary`: the map is what a caller gets, so
22
+ * narrowing it here would put `description` on the wire with nowhere to
23
+ * arrive, and no consumer could recover it without changing this signature
24
+ * first.
18
25
  */
19
- export function parseToolCatalog(data: unknown): Record<string, string> {
20
- const out: Record<string, string> = {};
26
+ export function parseToolCatalog(data: unknown): Record<string, ToolCatalogEntry> {
27
+ const out: Record<string, ToolCatalogEntry> = {};
21
28
  if (!Array.isArray(data)) {
22
29
  return out;
23
30
  }
@@ -28,8 +35,13 @@ export function parseToolCatalog(data: unknown): Record<string, string> {
28
35
  const record = entry as Record<string, unknown>;
29
36
  const name = record["name"];
30
37
  const summary = record["summary"];
38
+ const description = record["description"];
31
39
  if (typeof name === "string" && typeof summary === "string") {
32
- out[name] = summary;
40
+ // Built conditionally rather than with an `undefined` field:
41
+ // `exactOptionalPropertyTypes` makes "absent" and "present as
42
+ // undefined" different types, and only the former is the wire shape.
43
+ out[name] =
44
+ typeof description === "string" ? { name, summary, description } : { name, summary };
33
45
  }
34
46
  }
35
47
  return out;