@artooi/ag-ui-web-component 0.26.1 → 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 +291 -1
  2. package/README.md +191 -8
  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 +61 -3
  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 +58 -3
  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 +29 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +42 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +602 -104
  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 +444 -45
  32. package/src/core/agui_client.ts +43 -2
  33. package/src/core/conversation_store.ts +146 -49
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +45 -3
  36. package/src/core/utils.ts +83 -1
  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;
@@ -408,6 +415,9 @@ export class AgUiClient {
408
415
  #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
409
416
  const h = this.#handlers;
410
417
  const closed = this.#closedMessageIds;
418
+ // Read at event time, not captured now: the flag flips mid-run, and the
419
+ // subscriber is built before the run that a later `cancel()` stops.
420
+ const cancelled = (): boolean => this.#cancelled;
411
421
  // Charts whose patch has been dispatched but not yet applied. Scoped to the
412
422
  // subscriber, so it cannot outlive the run that created it.
413
423
  const pendingDeltas = new Set<string>();
@@ -494,6 +504,16 @@ export class AgUiClient {
494
504
  onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
495
505
  h.onReasoningDelta(reasoningMessageBuffer);
496
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
+ },
497
517
  onReasoningEndEvent() {
498
518
  h.onReasoningEnd();
499
519
  },
@@ -510,6 +530,15 @@ export class AgUiClient {
510
530
  onRunErrorEvent({ event }) {
511
531
  runState.terminal = true;
512
532
  runState.errored = true;
533
+ // Cancelling aborts the response mid-read, and the browser's own words
534
+ // for that can arrive here as a RUN_ERROR — Chrome's is
535
+ // "BodyStreamBuffer was aborted". The run is over either way, but a
536
+ // deliberate stop is not a failure, and reporting it would raise a
537
+ // warning bubble above the stopped note saying the same thing twice.
538
+ // The promise route in `#run` reports the cancellation.
539
+ if (cancelled()) {
540
+ return;
541
+ }
513
542
  h.onError(event.message);
514
543
  },
515
544
  onRunFinalized() {
@@ -532,7 +561,19 @@ interface RunState {
532
561
  * Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
533
562
  * with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
534
563
  * `AbortError` instead of filtering it.
564
+ *
565
+ * Aborting a fetch whose body is mid-read does not always surface as an
566
+ * `AbortError`. Chrome raises `TypeError: BodyStreamBuffer was aborted`, which
567
+ * is the same event wearing a different name, so a message naming the abort is
568
+ * read as one too. Narrow on purpose: only a `TypeError`, and only when it says
569
+ * so — a genuine type error carries no such word, and misreading one as a
570
+ * cancellation would hide a real failure behind a stopped note.
535
571
  */
536
572
  function isAbortError(error: unknown): boolean {
537
- return error instanceof Error && error.name === "AbortError";
573
+ if (!(error instanceof Error)) {
574
+ return false;
575
+ }
576
+ return (
577
+ error.name === "AbortError" || (error instanceof TypeError && /abort/i.test(error.message))
578
+ );
538
579
  }
@@ -36,12 +36,24 @@ export interface ThreadMeta {
36
36
  * is a small local hint a server store can derive from history and no-op.
37
37
  *
38
38
  * Thread enumeration backs the chat-history drawer; deleting a thread reuses
39
- * {@link clear} and "new chat" reuses {@link threadId} after clearing the
40
- * active thread.
39
+ * {@link clear}, and "new chat" is {@link newThread} which leaves the
40
+ * conversation it moves off of intact, for the drawer to offer back.
41
41
  */
42
42
  export interface ClientConversationStore {
43
43
  /** The active conversation id, generated and persisted on first read. */
44
44
  threadId(): string;
45
+ /**
46
+ * Start a fresh conversation, make it active, and return its id.
47
+ *
48
+ * Existing threads are left where they are: "new chat" adds one, and
49
+ * {@link clear} is the only method that takes one away.
50
+ *
51
+ * Optional, so a store written before this method existed still works. The
52
+ * caller then mints the id itself and hands it to {@link setActiveThread},
53
+ * which loses only the store's own record that the thread is new (see
54
+ * {@link isUnsent}).
55
+ */
56
+ newThread?(): string;
45
57
  /** Load the persisted message history, or `null` when none exists. */
46
58
  loadMessages(threadId: string): Promise<readonly Message[] | null>;
47
59
  /** Persist the message history (and refresh the thread's drawer metadata). */
@@ -90,6 +102,49 @@ const TITLE_LIMIT = 60;
90
102
  const PREVIEW_LIMIT = 100;
91
103
  const DEFAULT_TITLE = "New conversation";
92
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
+
93
148
  /** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
94
149
  interface StoredThread {
95
150
  threadId: string;
@@ -110,27 +165,70 @@ interface StoredThread {
110
165
  * An optional `namespace` scopes every key to one element, so two
111
166
  * `<ag-ui-chat>` instances on the same origin keep separate active-thread
112
167
  * pointers and drawer indexes instead of clobbering each other. The default
113
- * 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}.
114
170
  */
115
171
  export class SessionStorageStore implements ClientConversationStore {
116
172
  readonly #root: string;
117
173
 
118
174
  constructor(namespace = "") {
119
- this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
175
+ this.#root = rootFor(namespace);
120
176
  if (namespace !== "") {
121
- 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);
122
180
  }
123
181
  }
124
182
 
125
- threadId(): string {
126
- const key = this.#key(THREAD_SUFFIX);
127
- const existing = sessionStorage.getItem(key);
128
- if (existing !== null) {
129
- return existing;
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);
130
221
  }
222
+ }
223
+
224
+ threadId(): string {
225
+ return sessionStorage.getItem(this.#key(THREAD_SUFFIX)) ?? this.newThread();
226
+ }
227
+
228
+ newThread(): string {
131
229
  const id = randomUUID();
132
- sessionStorage.setItem(key, id);
133
- sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
230
+ writeStoredItem(this.#key(THREAD_SUFFIX), id);
231
+ writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
134
232
  return id;
135
233
  }
136
234
 
@@ -146,7 +244,7 @@ export class SessionStorageStore implements ClientConversationStore {
146
244
  }
147
245
 
148
246
  saveMessages(threadId: string, messages: readonly Message[]): void {
149
- sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
247
+ writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
150
248
  sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
151
249
  this.#touchThread(threadId, messages);
152
250
  }
@@ -161,7 +259,7 @@ export class SessionStorageStore implements ClientConversationStore {
161
259
  sessionStorage.removeItem(key);
162
260
  return;
163
261
  }
164
- sessionStorage.setItem(key, JSON.stringify(checkpoint));
262
+ writeStoredItem(key, JSON.stringify(checkpoint));
165
263
  }
166
264
 
167
265
  clear(threadId: string): void {
@@ -185,7 +283,7 @@ export class SessionStorageStore implements ClientConversationStore {
185
283
  }
186
284
 
187
285
  setActiveThread(threadId: string): void {
188
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
286
+ writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
189
287
  }
190
288
 
191
289
  renameThread(threadId: string, title: string): void {
@@ -233,7 +331,7 @@ export class SessionStorageStore implements ClientConversationStore {
233
331
  sessionStorage.removeItem(key);
234
332
  return;
235
333
  }
236
- sessionStorage.setItem(key, JSON.stringify(threads));
334
+ writeStoredItem(key, JSON.stringify(threads));
237
335
  }
238
336
 
239
337
  /** This store's fully-qualified key for a suffix (namespaced when set). */
@@ -241,37 +339,6 @@ export class SessionStorageStore implements ClientConversationStore {
241
339
  return `${this.#root}:${suffix}`;
242
340
  }
243
341
 
244
- /**
245
- * One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
246
- * namespace, so an existing conversation isn't orphaned. Only this store's own
247
- * keys move — the element's `collapsed` / `theme` keys are left alone. The
248
- * first namespaced instance to mount adopts the data; a second namespace
249
- * finds it gone and starts fresh.
250
- */
251
- #migrateLegacyKeys(): void {
252
- const legacyRoot = `${KEY_ROOT}:`;
253
- const moves: Array<readonly [string, string]> = [];
254
- for (let i = 0; i < sessionStorage.length; i += 1) {
255
- const key = sessionStorage.key(i);
256
- if (key === null || !key.startsWith(legacyRoot)) {
257
- continue;
258
- }
259
- const suffix = key.slice(legacyRoot.length);
260
- if (isOwnedSuffix(suffix)) {
261
- moves.push([key, this.#key(suffix)]);
262
- }
263
- }
264
- // Collected first, mutated second — writing while iterating by index skips
265
- // entries as the key list shifts.
266
- for (const [from, to] of moves) {
267
- const value = sessionStorage.getItem(from);
268
- if (value !== null && sessionStorage.getItem(to) === null) {
269
- sessionStorage.setItem(to, value);
270
- }
271
- sessionStorage.removeItem(from);
272
- }
273
- }
274
-
275
342
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
276
343
  #readJson<T>(key: string): T | null {
277
344
  const raw = sessionStorage.getItem(key);
@@ -286,13 +353,43 @@ export class SessionStorageStore implements ClientConversationStore {
286
353
  }
287
354
  }
288
355
 
289
- /** 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). */
290
386
  function isOwnedSuffix(suffix: string): boolean {
291
387
  return (
292
388
  suffix === THREAD_SUFFIX ||
293
389
  suffix === THREADS_SUFFIX ||
294
390
  suffix.startsWith(MESSAGES_SUFFIX) ||
295
- suffix.startsWith(CHECKPOINT_SUFFIX)
391
+ suffix.startsWith(CHECKPOINT_SUFFIX) ||
392
+ suffix.startsWith(MINTED_SUFFIX)
296
393
  );
297
394
  }
298
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
  }
@@ -5,7 +5,7 @@ import {
5
5
  SessionStorageStore,
6
6
  type ThreadMeta,
7
7
  } from "./conversation_store.js";
8
- import { withCredentials } from "./utils.js";
8
+ import { mintThread, withCredentials } from "./utils.js";
9
9
 
10
10
  /** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
11
11
  interface ServerThreadRow {
@@ -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 {
@@ -69,14 +94,31 @@ export class RemoteConversationStore implements ClientConversationStore {
69
94
  this.#local.setActiveThread(threadId);
70
95
  }
71
96
 
97
+ /**
98
+ * Delegated to the local store, which owns the active id — and deliberately
99
+ * silent on the wire: the server learns of a thread when its first message is
100
+ * persisted, so an abandoned new chat costs no round-trip and leaves no row.
101
+ */
102
+ newThread(): string {
103
+ return mintThread(this.#local);
104
+ }
105
+
72
106
  /** Delegated, so wrapping a store does not lose what it knows about its own ids. */
73
107
  isUnsent(threadId: string): boolean {
74
108
  return this.#local.isUnsent?.(threadId) === true;
75
109
  }
76
110
 
77
111
  saveMessages(threadId: string, messages: readonly Message[]): void {
78
- // The agent run persists server-side; keep a local cache for offline replay.
79
- 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 : []);
80
122
  }
81
123
 
82
124
  loadCheckpoint(threadId: string): NavigationCheckpoint | null {
package/src/core/utils.ts CHANGED
@@ -1,4 +1,7 @@
1
- // Non-exported-from-index helpers shared by the core transport modules.
1
+ // Non-exported-from-index helpers shared by the core modules.
2
+
3
+ import { randomUUID } from "@ag-ui/client";
4
+ import type { ClientConversationStore } from "./conversation_store.js";
2
5
 
3
6
  /**
4
7
  * Overlay a `credentials` mode onto a fetch `init`, or hand the `init` back
@@ -14,3 +17,82 @@ export function withCredentials(
14
17
  ): RequestInit | undefined {
15
18
  return credentials === undefined ? init : { ...init, credentials };
16
19
  }
20
+
21
+ /**
22
+ * Start a new conversation in `store` and return its id.
23
+ *
24
+ * `newThread` is optional on the interface, so a store that predates it is
25
+ * driven the only other way the interface allows: mint an id here and make it
26
+ * active. That path loses the store's own note that the thread is new, so a
27
+ * remote store would go on to ask the server for a conversation that cannot
28
+ * exist yet — which is why every store in this package implements the method.
29
+ *
30
+ * What neither path does is clear the thread being left behind. Starting a
31
+ * conversation is not a reason to destroy the previous one.
32
+ */
33
+ export function mintThread(store: ClientConversationStore): string {
34
+ if (store.newThread !== undefined) {
35
+ return store.newThread();
36
+ }
37
+ const id = randomUUID();
38
+ store.setActiveThread(id);
39
+ return id;
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;