@artooi/ag-ui-web-component 0.39.0 → 0.41.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 (58) hide show
  1. package/CHANGELOG.md +208 -1
  2. package/README.md +116 -36
  3. package/dist/ag-ui-web-component.bundle.js +255 -101
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +3 -2
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +7 -3
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +29 -8
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +11 -4
  12. package/dist/core/attachment.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +14 -2
  14. package/dist/core/client_seed.d.ts.map +1 -1
  15. package/dist/core/tool_outcome.d.ts +4 -3
  16. package/dist/core/tool_outcome.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +19 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.js +1078 -664
  20. package/dist/index.js.map +4 -4
  21. package/dist/tools/tool_catalog.d.ts.map +1 -1
  22. package/dist/ui/composer/composer_attachments.d.ts +6 -1
  23. package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
  24. package/dist/ui/composer/composer_voice.d.ts +3 -0
  25. package/dist/ui/composer/composer_voice.d.ts.map +1 -1
  26. package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
  27. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
  28. package/dist/ui/history/conversation_history.d.ts +30 -4
  29. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  30. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  31. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  32. package/dist/ui/placement/panel_placement.d.ts +1 -1
  33. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  34. package/dist/ui/styles.d.ts +1 -1
  35. package/dist/ui/styles.d.ts.map +1 -1
  36. package/dist/ui/transcript/transcript.d.ts +3 -2
  37. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  38. package/dist/ui/ui_strings.d.ts +2 -0
  39. package/dist/ui/ui_strings.d.ts.map +1 -1
  40. package/package.json +3 -3
  41. package/src/constants.ts +3 -2
  42. package/src/core/ag_ui_chat.ts +217 -64
  43. package/src/core/agui_client.ts +155 -125
  44. package/src/core/attachment.ts +13 -5
  45. package/src/core/client_seed.ts +14 -2
  46. package/src/core/tool_outcome.ts +4 -3
  47. package/src/core/utils.ts +23 -0
  48. package/src/tools/tool_catalog.ts +63 -12
  49. package/src/ui/composer/composer_attachments.ts +64 -41
  50. package/src/ui/composer/composer_voice.ts +5 -0
  51. package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
  52. package/src/ui/history/conversation_history.ts +149 -38
  53. package/src/ui/placement/launcher_drag.ts +104 -89
  54. package/src/ui/placement/panel_placement.ts +27 -3
  55. package/src/ui/styles.ts +137 -14
  56. package/src/ui/transcript/transcript.ts +10 -5
  57. package/src/ui/ui_strings.ts +3 -0
  58. package/src/version.ts +1 -1
@@ -5,7 +5,14 @@ import {
5
5
  type RunAgentParameters,
6
6
  randomUUID,
7
7
  } from "@ag-ui/client";
8
- import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
8
+ import {
9
+ type Context,
10
+ contentToText,
11
+ type Interrupt,
12
+ type Message,
13
+ type ResumeEntry,
14
+ type Tool,
15
+ } from "@ag-ui/core";
9
16
  import { MAX_TOOL_ROUNDS, TOOL_OUTCOME } from "../constants.js";
10
17
  import type { AttachmentRef } from "./attachment.js";
11
18
  import type { ToolOutcome } from "./tool_outcome.js";
@@ -91,11 +98,17 @@ export interface AgUiClientHandlers {
91
98
  * `TOOL_CALL_RESULT`). Frontend tools don't emit this — the client supplies
92
99
  * their result itself — so this is the channel for server-executed output.
93
100
  *
94
- * `outcome` is the event's optional `outcome` field, forwarded raw. It is
95
- * `unknown` rather than {@link ToolOutcome} because it comes off a
96
- * `passthrough` zod schema: the protocol does not validate it, so neither can
97
- * this signature honestly claim to. Read it with `toolStatusFromOutcome`,
98
- * which treats `undefined` and anything unrecognised as a success.
101
+ * `outcome` is the `outcome` key of the event's `metadata`, forwarded raw.
102
+ * It is `unknown` rather than {@link ToolOutcome} because metadata is open by
103
+ * key: the protocol declares the object and validates nothing inside it, so
104
+ * neither can this signature honestly claim to. Read it with
105
+ * `toolStatusFromOutcome`, which treats `undefined` and anything unrecognised
106
+ * as a success.
107
+ *
108
+ * Only the metadata is read. A server written against `@ag-ui/client` 0.x
109
+ * states the outcome at the top level of the event, and 1.0 strips that key
110
+ * before any subscriber runs, so from such a server this is `undefined` and
111
+ * the card settles as done.
99
112
  *
100
113
  * Added as a third parameter rather than as a new callback, so an
101
114
  * implementation written against the two-parameter form still satisfies this
@@ -300,29 +313,6 @@ export class AgUiClient {
300
313
  * set would miss entirely.
301
314
  */
302
315
  readonly #closedMessageIds = new Set<string>();
303
- /**
304
- * How each tool call ended, keyed by call id, for the transcript this client
305
- * persists.
306
- *
307
- * A side table rather than a field written onto the message, because neither
308
- * producer of a tool message will carry it. `@ag-ui/client` builds the
309
- * server-side one by destructuring five named fields off the event, so an
310
- * `outcome` beside them is dropped before the message exists; and writing it
311
- * back onto that message afterwards would put it in `agent.messages`, which is
312
- * what the *next* request sends to the server. This keeps the annotation on
313
- * the copy handed to the store and off the wire.
314
- *
315
- * Per client, not per run: `saveMessages` rewrites the whole transcript on
316
- * every persist, so an outcome recorded in round one has to still be here in
317
- * round five or the earlier card silently reverts to a green one. Bounded by
318
- * the number of tool calls in the conversation, which the transcript beside it
319
- * already is.
320
- *
321
- * Values are `unknown` because a restored seed fills it too (see
322
- * {@link #adoptSeededOutcomes}), and a store is not trusted to hold only the
323
- * words this class writes: whatever it held is written back as it was.
324
- */
325
- readonly #outcomes = new Map<string, unknown>();
326
316
  readonly #connectionLostMessage: string;
327
317
  readonly #unfinishedMessage: string;
328
318
  readonly #declinedMessage: string;
@@ -350,7 +340,7 @@ export class AgUiClient {
350
340
  // the agent zero times, which would look exactly like a broken endpoint.
351
341
  const rounds = config.maxToolRounds ?? MAX_TOOL_ROUNDS;
352
342
  this.#maxToolRounds = rounds >= 1 ? Math.floor(rounds) : MAX_TOOL_ROUNDS;
353
- this.#adoptSeededOutcomes();
343
+ this.#moveSeededKeysIntoMetadata();
354
344
  const onStateChanged = config.onStateChanged;
355
345
  if (onStateChanged !== undefined) {
356
346
  // The agent applies STATE_SNAPSHOT / STATE_DELTA itself; subscribing is
@@ -392,18 +382,24 @@ export class AgUiClient {
392
382
  * tools (bounded by {@link AgUiClientConfig.maxToolRounds}, which defaults to
393
383
  * {@link MAX_TOOL_ROUNDS}).
394
384
  *
395
- * `attachments` ride on the user message as a non-standard field so the
396
- * default store round-trips them for history replay; see
385
+ * `attachments` ride in the user message's `metadata`, under
386
+ * `attachments`, which is how the server learns which files the agent can
387
+ * read and how the default store round-trips them for history replay; see
397
388
  * {@link messageAttachments}.
398
389
  */
399
390
  async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
400
- // Cast at the AG-UI boundary: `attachments` is a web-component augmentation
401
- // the strict `Message` union doesn't declare, but `addMessage` /
402
- // `structuredClone` preserve it verbatim.
403
- const message = { id: randomUUID(), role: "user", content } as Message;
404
- if (attachments.length > 0) {
405
- (message as { attachments?: readonly AttachmentRef[] }).attachments = attachments;
406
- }
391
+ // In `metadata` because it is the one field on a message `@ag-ui/client`
392
+ // declares open by key. 1.0 strips every undeclared key from the input it
393
+ // sends, so refs at the top level of the message -- where every release
394
+ // before 1.0 put them -- are removed with a console warning and the server
395
+ // is never told a file was attached. No metadata at all without refs: an
396
+ // empty object would put a field on every plain message that says nothing.
397
+ const message: Message = {
398
+ id: randomUUID(),
399
+ role: "user",
400
+ content,
401
+ ...(attachments.length > 0 ? { metadata: { attachments } } : {}),
402
+ };
407
403
  this.#agent.addMessage(message);
408
404
  this.#persist();
409
405
  await this.#run();
@@ -504,38 +500,31 @@ export class AgUiClient {
504
500
  }
505
501
 
506
502
  /**
507
- * Move the outcome annotations a restored history carries off the agent's
508
- * messages and into {@link #outcomes}.
503
+ * Move the keys a history stored by an earlier release carries at the top
504
+ * level of its messages into their `metadata`, where this release writes them.
509
505
  *
510
- * A restore seeds the agent from the stored copy, and the stored copy is the
511
- * annotated one {@link #persist} wrote. Left on `agent.messages`, the
512
- * annotation went out on the very next request -- the one place it was
513
- * promised never to go. Moved rather than dropped: the store keeps the whole
514
- * transcript as one list, so the next save has to write each annotation back
515
- * or a declined card turns green on the reload after.
506
+ * Earlier releases wrote a tool message's `outcome` and a user message's
507
+ * `attachments` beside the protocol's own fields. `@ag-ui/client` 1.0 strips
508
+ * every undeclared key from the input it sends, so a restored conversation
509
+ * sent as it was stored would lose both on its first request: the server
510
+ * would no longer be told about any file attached before the upgrade, and
511
+ * each strip would print a warning. Moved rather than dropped, so the next
512
+ * save writes the conversation in the shape this release reads first and a
513
+ * reload after it still replays a declined card as declined.
516
514
  *
517
- * `ag_ui_chat_restored_outcome_wire.test.ts` holds the move, off the body of a
518
- * real request. The early return only spares the copy for a seed with nothing
519
- * to move, and the role check is held by the type checker: only a tool message
520
- * has the `toolCallId` the annotation is keyed by.
515
+ * Merged into the metadata a message already has, with a key already there
516
+ * winning: a message carrying both was last written by the newer writer.
517
+ * `ag_ui_chat_restored_outcome_wire.test.ts` reads the result off the body of
518
+ * a real request. The check before `setMessages` only spares a copy when
519
+ * nothing moves, which is every restore of a store this release wrote;
520
+ * nothing observable depends on it.
521
521
  */
522
- #adoptSeededOutcomes(): void {
522
+ #moveSeededKeysIntoMetadata(): void {
523
523
  const seeded = this.#agent.messages;
524
- if (!seeded.some((message) => "outcome" in message)) {
525
- return;
524
+ const moved = seeded.map(withTopLevelKeysInMetadata);
525
+ if (moved.some((message, index) => message !== seeded[index])) {
526
+ this.#agent.setMessages(moved);
526
527
  }
527
- this.#agent.setMessages(
528
- seeded.map((message) => {
529
- if (message.role !== "tool") {
530
- return message;
531
- }
532
- // An unannotated message records `undefined`, which `#persist` already
533
- // reads as "nothing to write back".
534
- const { outcome, ...wire } = message as typeof message & { outcome?: unknown };
535
- this.#outcomes.set(message.toolCallId, outcome);
536
- return wire;
537
- }),
538
- );
539
528
  }
540
529
 
541
530
  /**
@@ -553,17 +542,21 @@ export class AgUiClient {
553
542
  * approval runs or denies them server-side, and a result here would answer
554
543
  * them twice.
555
544
  *
556
- * The labels go to {@link #outcomes}, never onto the messages, because these
557
- * messages are the request.
545
+ * The outcome rides in the result's `metadata`, where the client folds a
546
+ * server's own, so the next request carries it and a reload replays it. A
547
+ * server that does not read it ignores it: `metadata` is a declared field.
558
548
  */
559
549
  #answerUnansweredCalls(resuming: ReadonlySet<string>): void {
560
550
  const messages = this.#agent.messages;
561
551
  const answered = answerUnansweredCalls(
562
552
  messages,
563
- (toolCallId) => {
564
- this.#outcomes.set(toolCallId, TOOL_OUTCOME.INTERRUPTED);
565
- return { id: randomUUID(), role: "tool", content: this.#unfinishedMessage, toolCallId };
566
- },
553
+ (toolCallId) => ({
554
+ id: randomUUID(),
555
+ role: "tool",
556
+ content: this.#unfinishedMessage,
557
+ toolCallId,
558
+ metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
559
+ }),
567
560
  resuming,
568
561
  );
569
562
  if (answered !== messages) {
@@ -594,12 +587,12 @@ export class AgUiClient {
594
587
  for (const interrupt of interrupts) {
595
588
  const toolCallId = interrupt.toolCallId;
596
589
  if (toolCallId !== undefined && responses[interrupt.id]?.status === "cancelled") {
597
- this.#outcomes.set(toolCallId, TOOL_OUTCOME.DENIED);
598
590
  this.#agent.addMessage({
599
591
  id: randomUUID(),
600
592
  role: "tool",
601
593
  content: this.#declinedMessage,
602
594
  toolCallId,
595
+ metadata: { outcome: TOOL_OUTCOME.DENIED },
603
596
  });
604
597
  }
605
598
  }
@@ -607,35 +600,32 @@ export class AgUiClient {
607
600
  }
608
601
 
609
602
  /**
610
- * Hand the transcript to the host's store, annotated with what {@link #outcomes}
611
- * knows about how each tool call ended.
603
+ * Hand the transcript to the host's store.
612
604
  *
613
- * Every persist in this class goes through here, because the store keeps only
614
- * the most recent list: annotating one call site would mean the next
615
- * unannotated save quietly threw the annotations away.
605
+ * The list as the agent holds it, with nothing added: how each tool call
606
+ * ended rides in its result's `metadata`, which the client folds in from a
607
+ * server's `TOOL_CALL_RESULT` and this class writes for the results it makes
608
+ * itself, so the store sees exactly what the next request sends.
616
609
  */
617
610
  #persist(): void {
618
- const messages = this.#agent.messages;
619
- if (this.#outcomes.size === 0) {
620
- this.#onPersist(messages);
621
- return;
622
- }
623
- // A copy, and only of the messages that gain something. `agent.messages` is
624
- // the list the next `runAgent` sends back to the server, so writing an extra
625
- // field into it would put a client-side annotation on the wire; a store is
626
- // allowed to hold more than the protocol does.
627
- this.#onPersist(
628
- messages.map((message) => {
629
- if (message.role !== "tool") {
630
- return message;
631
- }
632
- const outcome = this.#outcomes.get(message.toolCallId);
633
- // Cast at the AG-UI boundary, as the `attachments` augmentation on a
634
- // user message already does: `Message` does not declare the field, and
635
- // the default store round-trips it through `JSON.stringify` verbatim.
636
- return outcome === undefined ? message : ({ ...message, outcome } as Message);
637
- }),
638
- );
611
+ this.#onPersist(this.#agent.messages);
612
+ }
613
+
614
+ /**
615
+ * The history in the form a save writes it, which is now {@link messages}
616
+ * itself.
617
+ *
618
+ * It differed while this client kept how each tool call ended in a side table
619
+ * and annotated a copy for the store, because `@ag-ui/client` 0.x kept nothing
620
+ * but five named fields of a result. 1.0 folds a result's `metadata` onto the
621
+ * message it appends, so the outcome is on the message and there is no copy
622
+ * left to make.
623
+ *
624
+ * @deprecated Read {@link messages}. Kept so a caller written against the
625
+ * getter keeps compiling and gets the same list.
626
+ */
627
+ get annotatedMessages(): readonly Message[] {
628
+ return this.messages;
639
629
  }
640
630
 
641
631
  async #runLoop(): Promise<void> {
@@ -722,19 +712,18 @@ export class AgUiClient {
722
712
  // next mount. Stop here rather than re-running into a dead context.
723
713
  return;
724
714
  }
725
- // Recorded before the persist below, so the very first save of this
726
- // message already carries how it ended. A frontend tool's refusal or
727
- // failure never touches the wire's `outcome` field -- no server states
728
- // it, because no server ran the call -- so this side table is the only
729
- // record there is, and a reload reads a card off it.
730
- if (result.outcome !== undefined) {
731
- this.#outcomes.set(call.id, result.outcome);
732
- }
715
+ // A frontend tool's refusal or failure is stated by no server, because
716
+ // no server ran the call, so the client writes it where the client
717
+ // folds a server's: the result's `metadata`. Written with the message,
718
+ // so the very first save of it already carries how it ended, and a
719
+ // reload reads the card off it. A success writes no metadata at all,
720
+ // as a server's success states none.
733
721
  this.#agent.addMessage({
734
722
  id: randomUUID(),
735
723
  role: "tool",
736
724
  content: result.content,
737
725
  toolCallId: call.id,
726
+ ...(result.outcome === undefined ? {} : { metadata: { outcome: result.outcome } }),
738
727
  });
739
728
  this.#persist();
740
729
  executed = true;
@@ -748,7 +737,6 @@ export class AgUiClient {
748
737
  #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
749
738
  const h = this.#handlers;
750
739
  const closed = this.#closedMessageIds;
751
- const outcomes = this.#outcomes;
752
740
  // Read at event time, not captured now: the flag flips mid-run, and the
753
741
  // subscriber is built before the run that a later `cancel()` stops.
754
742
  const cancelled = (): boolean => this.#cancelled;
@@ -790,20 +778,22 @@ export class AgUiClient {
790
778
  h.onToolCall(call);
791
779
  },
792
780
  onToolCallResultEvent({ event }) {
793
- // Bracket access because the field is not declared: `TOOL_CALL_RESULT`
794
- // extends a `passthrough` schema, so an unknown key survives parsing and
795
- // arrives here typed only by the catch-all index signature. That is the
796
- // whole mechanism the outcome rides -- no schema change in `@ag-ui/core`
797
- // is needed for a server to state one.
798
- const outcome = event["outcome"];
799
- // Recorded even when it is a word this client does not recognise, and
800
- // even when it says "success": the store is a record of what the server
801
- // said, and re-reading it through the same mapping as the live path is
802
- // what keeps a reload agreeing with what the user watched happen.
803
- if (typeof outcome === "string") {
804
- outcomes.set(event.toolCallId, outcome);
805
- }
806
- h.onToolResult(event.toolCallId, event.content, outcome);
781
+ // In the event's `metadata`, the one place on it `@ag-ui/client` 1.0
782
+ // declares open by key. Its enforcement stage strips every undeclared
783
+ // key before a subscriber runs, so an `outcome` stated at the top level
784
+ // of the event -- where servers wrote it for 0.x -- never gets here.
785
+ //
786
+ // Nothing is recorded for the transcript: after this returns, the
787
+ // client builds the tool message and folds the event's metadata onto
788
+ // it, so the outcome is already on the message a save writes, word for
789
+ // word as the server said it. A reload reads it back through the same
790
+ // mapping as the live path, which is what keeps the two agreeing.
791
+ const outcome: unknown = event.metadata?.["outcome"];
792
+ // Content may arrive as parts since the protocol's 1.0, so a tool can
793
+ // return an image beside its text. A card shows text, so the text parts
794
+ // are joined and the rest left to the host store, which keeps the
795
+ // message whole; a plain string passes through unchanged.
796
+ h.onToolResult(event.toolCallId, contentToText(event.content), outcome);
807
797
  },
808
798
  onActivitySnapshotEvent({ event, messages }) {
809
799
  // A snapshot for an id already in the list is a replacement, not a new
@@ -864,9 +854,11 @@ export class AgUiClient {
864
854
  }
865
855
  pendingDeltas.clear();
866
856
  },
867
- // `@ag-ui/client` maps the deprecated THINKING_* events onto these
868
- // REASONING_* callbacks, so the reasoning family alone covers both
869
- // protocol versions.
857
+ // THINKING_* left the protocol in its 1.0, but `@ag-ui/client` still
858
+ // converts a server's THINKING_* events onto these REASONING_* callbacks,
859
+ // with a console warning, for a deprecation window it sets. Nothing in the
860
+ // family emits THINKING_*: pydantic-ai has sent REASONING_* at every
861
+ // protocol version django-ag-ui admits.
870
862
  onReasoningStartEvent() {
871
863
  h.onReasoningStart();
872
864
  },
@@ -946,3 +938,41 @@ function isAbortError(error: unknown): boolean {
946
938
  error.name === "AbortError" || (error instanceof TypeError && /abort/i.test(error.message))
947
939
  );
948
940
  }
941
+
942
+ /**
943
+ * `message` with the keys an earlier release wrote at its top level -- a tool
944
+ * message's `outcome`, a user message's `attachments` -- moved into its
945
+ * `metadata`, or `message` itself when it carries neither.
946
+ *
947
+ * Both keys move whatever the role: no message the protocol defines declares
948
+ * either, so a top-level one can only be this component's own. A key already in
949
+ * the metadata wins over the one being moved, and a `metadata` that is not an
950
+ * object is replaced rather than spread -- a store is not trusted to hold the
951
+ * shape anything here wrote, and a spread string would scatter into numbered
952
+ * keys.
953
+ *
954
+ * Held by "carries history stored by an earlier release into metadata" in
955
+ * `agui_client.test.ts`, which seeds a message with only `attachments`, one
956
+ * with only `outcome`, one whose metadata already has the key, and one whose
957
+ * metadata is a string: deleting either half of the early return, or the
958
+ * object check, fails it.
959
+ */
960
+ function withTopLevelKeysInMetadata(message: Message): Message {
961
+ const { outcome, attachments, ...wire } = message as Message & {
962
+ outcome?: unknown;
963
+ attachments?: unknown;
964
+ };
965
+ if (outcome === undefined && attachments === undefined) {
966
+ return message;
967
+ }
968
+ const existing = (wire as { metadata?: unknown }).metadata;
969
+ return {
970
+ ...wire,
971
+ metadata: {
972
+ ...(outcome === undefined ? {} : { outcome }),
973
+ ...(attachments === undefined ? {} : { attachments }),
974
+ // `null` passes the check and spreads as nothing, which is what it means.
975
+ ...(typeof existing === "object" ? existing : {}),
976
+ },
977
+ } as Message;
978
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Message } from "@ag-ui/core";
2
+ import { metadataOrTopLevel } from "./utils.js";
2
3
 
3
4
  /**
4
5
  * A durable, lightweight reference to one uploaded file — what an upload
@@ -24,17 +25,24 @@ export interface AttachmentRef {
24
25
  /**
25
26
  * The attachment refs a user message carries.
26
27
  *
27
- * Refs ride on the message as a non-standard `attachments` field, which the
28
- * default store round-trips and `@ag-ui/client` preserves through `addMessage`
29
- * / `structuredClone`, so a restored conversation re-renders its chips. The
30
- * server's strict `RunAgentInput` validation ignores the unknown field.
28
+ * `AgUiClient.send` writes them into the message's `metadata`, under
29
+ * `attachments`. That is the one place they survive the trip: `@ag-ui/client`
30
+ * 1.0 strips every key its schemas do not declare from the outgoing input, so
31
+ * refs written at the top level of the message never reach the server, while
32
+ * `metadata` is declared open by key and sent as it is. The server reads them
33
+ * from there to tell the agent which files it can read, and the default store
34
+ * round-trips them, so a restored conversation re-renders its chips.
35
+ *
36
+ * A conversation stored by an earlier release has them at the top level, so
37
+ * that is read when the metadata has none. An older server reads only the top
38
+ * level, so against one the agent is never told about a file at all.
31
39
  *
32
40
  * Storage is untrusted — hand-edited, truncated, or corrupted — so malformed
33
41
  * entries are dropped here; a shapeless one would throw in `iconFor` and abort
34
42
  * the whole history replay.
35
43
  */
36
44
  export function messageAttachments(message: Message): readonly AttachmentRef[] {
37
- const refs = (message as { attachments?: unknown }).attachments;
45
+ const refs = metadataOrTopLevel(message, "attachments");
38
46
  return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
39
47
  }
40
48
 
@@ -10,6 +10,18 @@ export interface ClientSeed {
10
10
  readonly endpoint: string;
11
11
  /** The history the agent starts from, and sends with its first run. */
12
12
  readonly initialMessages: readonly Message[];
13
- /** Whether the client writes its messages to the conversation store. */
14
- readonly persist: boolean;
13
+ /**
14
+ * The conversation this client's own history continues, which every save
15
+ * writes ahead of it.
16
+ *
17
+ * Empty for the conversation's own client, whose history is the whole
18
+ * conversation. A continuation's history is only the turn it adds and its
19
+ * answer -- the endpoint supplies the rest from its snapshot -- and a store
20
+ * keeps one list per thread, so a save of that history alone would replace
21
+ * the conversation with its last exchange, and not saving it lost the
22
+ * exchange on the next reload.
23
+ */
24
+ readonly follows: readonly Message[];
25
+ /** Handed the whole conversation each time the client saves it, as written. */
26
+ readonly onSaved?: (conversation: readonly Message[]) => void;
15
27
  }
@@ -13,9 +13,10 @@ export type ToolOutcome = (typeof TOOL_OUTCOME)[keyof typeof TOOL_OUTCOME];
13
13
  * The card status a wire outcome settles into.
14
14
  *
15
15
  * Takes `unknown` rather than {@link ToolOutcome} on purpose: both callers read
16
- * this off a boundary the type system does not police -- a `passthrough` field
17
- * on an AG-UI event, and a JSON blob out of the conversation store -- so the
18
- * narrowing belongs here, once, instead of at each of them.
16
+ * this off a boundary the type system does not police -- a key in an AG-UI
17
+ * event's `metadata`, which the protocol declares open and does not validate,
18
+ * and a JSON blob out of the conversation store -- so the narrowing belongs
19
+ * here, once, instead of at each of them.
19
20
  *
20
21
  * **Everything unrecognised maps to `DONE`.** Not because unknown values are
21
22
  * expected to be successes, but because the alternative is worse in the
package/src/core/utils.ts CHANGED
@@ -205,3 +205,26 @@ function toolCallIds(value: unknown): string[] {
205
205
  .filter((id): id is string => typeof id === "string")
206
206
  : [];
207
207
  }
208
+
209
+ /**
210
+ * A key a message carries in its `metadata`, or the same key at the message's
211
+ * top level when its metadata has none.
212
+ *
213
+ * Two keys are read this way, a tool message's `outcome` and a user message's
214
+ * `attachments`, and both for the same reason. `@ag-ui/client` 1.0 strips every
215
+ * key its schemas do not declare, off an inbound event and off the outgoing
216
+ * input, and `metadata` is the one place it declares open by key, so that is
217
+ * where this component writes them. A conversation stored by an earlier release
218
+ * has them at the top level instead, and a reload has to replay it as it
219
+ * settled.
220
+ *
221
+ * Metadata first, so a message carrying both says what the newer writer said.
222
+ * The result is `unknown` because it comes out of a store, which is not trusted
223
+ * to hold the shape anything here wrote; the caller narrows it. `Object()` boxes
224
+ * an absent, `null` or primitive `metadata` into an object with neither key,
225
+ * which reads as no metadata without a branch per shape.
226
+ */
227
+ export function metadataOrTopLevel(message: Message, key: "outcome" | "attachments"): unknown {
228
+ const metadata = Object((message as { metadata?: unknown }).metadata) as Record<string, unknown>;
229
+ return metadata[key] ?? (message as unknown as Record<string, unknown>)[key];
230
+ }
@@ -264,13 +264,13 @@ export class ToolCatalog {
264
264
  },
265
265
  required: ["question"],
266
266
  },
267
- handler: (args) => this.#askUser(args),
267
+ handler: (args, callId) => this.#askUser(args, callId),
268
268
  },
269
269
  ];
270
270
  }
271
271
 
272
272
  /** Render the `ask_user` question card and resolve with the user's answer. */
273
- async #askUser(args: Record<string, unknown>): Promise<string> {
273
+ async #askUser(args: Record<string, unknown>, callId: string | undefined): Promise<string> {
274
274
  const question = typeof args["question"] === "string" ? args["question"] : "";
275
275
  const request: QuestionRequest = { question };
276
276
  const rawOptions = args["options"];
@@ -284,20 +284,71 @@ export class ToolCatalog {
284
284
  // it with an empty answer (the run is then cancelled).
285
285
  const signal = this.#host.decision.open();
286
286
  this.#host.hidePending();
287
- // A host-supplied renderer takes full control of the UI; otherwise the
288
- // built-in inline card renders into the current answer group.
287
+ // The built-in inline card renders into the current answer group.
288
+ const builtIn = (): Promise<string> =>
289
+ requestQuestion(this.#host.ensureGroup(), request, {
290
+ signal,
291
+ strings: this.#host.strings(),
292
+ });
293
+ // A host-supplied renderer takes full control of the UI.
289
294
  const renderer = this.#host.askUserRenderer();
290
- const answer =
291
- renderer !== null
292
- ? // Called on the element, as `this.askUserRenderer(...)` always was.
293
- await renderer.call(this.#host.element, request, { signal })
294
- : await requestQuestion(this.#host.ensureGroup(), request, {
295
- signal,
296
- strings: this.#host.strings(),
297
- });
295
+ let answer: string;
296
+ if (renderer === null) {
297
+ answer = await builtIn();
298
+ } else {
299
+ // Awaited here rather than inside a helper, so an answering renderer
300
+ // takes exactly as many turns to be heard as it always did.
301
+ try {
302
+ // Called on the element, as `this.askUserRenderer(...)` always was.
303
+ answer = await renderer.call(this.#host.element, request, { signal });
304
+ } catch (error) {
305
+ answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
306
+ }
307
+ }
298
308
  this.#host.decision.close();
299
309
  this.#host.updateEmptyState();
300
310
  this.#host.follow();
301
311
  return answer;
302
312
  }
313
+
314
+ /**
315
+ * Answer an `ask_user` call whose host renderer threw or rejected instead of
316
+ * answering: put the question to the built-in card.
317
+ *
318
+ * The same answer `approvalRenderer` gets for the same failure, and for the
319
+ * same reason: a renderer is presentation, not a policy. It decides how the
320
+ * question looks, never whether the agent's question is put to the user.
321
+ * Uncaught, the throw escaped the handler, so the call's card settled as an
322
+ * error quoting the host's message, that message went on to the agent as the
323
+ * tool result -- a detail of the host's page, never written for the model --
324
+ * and the pending decision was never closed, so the next Stop, in whatever
325
+ * round, aborted the signal of a wait that had already ended rather than
326
+ * finding nothing open. The built-in card still asks,
327
+ * and the run carries on as if no renderer had been set. Reported the way a
328
+ * failed `render` is, and for the same reason: survived is not the same as
329
+ * findable.
330
+ *
331
+ * Except when the wait was already abandoned. A renderer honouring its signal
332
+ * rejects once a Stop fires it, which is the signal working rather than the
333
+ * renderer failing, and a card drawn then would ask about a run the user just
334
+ * ended. So it resolves with the empty answer the built-in card resolves with
335
+ * on the same abort, and says nothing.
336
+ */
337
+ #afterRendererFailed(
338
+ error: unknown,
339
+ signal: AbortSignal,
340
+ callId: string | undefined,
341
+ builtIn: () => Promise<string>,
342
+ ): Promise<string> {
343
+ if (signal.aborted) {
344
+ return Promise.resolve("");
345
+ }
346
+ // Named by call rather than by question: the model can ask the same thing
347
+ // twice in one turn, and the id is the one thing that tells them apart.
348
+ console.warn(
349
+ `ag-ui-chat: askUserRenderer failed for tool call ${callId}, so the built-in question card asks instead`,
350
+ error,
351
+ );
352
+ return builtIn();
353
+ }
303
354
  }