@assistant-ui/react-ag-ui 0.0.60 → 0.0.61

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 (45) hide show
  1. package/dist/agUiExtras.d.ts +1 -3
  2. package/dist/agUiExtras.d.ts.map +1 -1
  3. package/dist/hooks.d.ts +3 -5
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/index.d.ts +7 -5
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +152 -144
  8. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  9. package/dist/runtime/AgUiThreadRuntimeCore.js +141 -23
  10. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  11. package/dist/runtime/adapter/conversions.d.ts +51 -53
  12. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  13. package/dist/runtime/adapter/conversions.js +65 -21
  14. package/dist/runtime/adapter/conversions.js.map +1 -1
  15. package/dist/runtime/adapter/run-aggregator.d.ts +89 -88
  16. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  17. package/dist/runtime/adapter/run-aggregator.js +35 -2
  18. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  19. package/dist/runtime/adapter/subscriber.d.ts +107 -108
  20. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  21. package/dist/runtime/adapter/tool-approval.d.ts +3 -4
  22. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.d.ts +3 -5
  24. package/dist/runtime/event-parser.d.ts.map +1 -1
  25. package/dist/runtime/interrupt-internals.d.ts +1 -3
  26. package/dist/runtime/interrupt-internals.d.ts.map +1 -1
  27. package/dist/runtime/logger.d.ts +2 -4
  28. package/dist/runtime/logger.d.ts.map +1 -1
  29. package/dist/runtime/mcp-tool-result.d.ts +4 -6
  30. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  31. package/dist/runtime/types.d.ts +237 -238
  32. package/dist/runtime/types.d.ts.map +1 -1
  33. package/dist/useAgUiRuntime.d.ts +13 -15
  34. package/dist/useAgUiRuntime.d.ts.map +1 -1
  35. package/dist/useAgUiRuntime.js +9 -2
  36. package/dist/useAgUiRuntime.js.map +1 -1
  37. package/package.json +6 -6
  38. package/src/runtime/AgUiThreadRuntimeCore.ts +239 -32
  39. package/src/runtime/adapter/conversions.content.test.ts +56 -0
  40. package/src/runtime/adapter/conversions.ts +112 -44
  41. package/src/runtime/adapter/run-aggregator.ts +53 -2
  42. package/src/useAgUiRuntime.history.test.tsx +93 -2
  43. package/src/useAgUiRuntime.thread-switch.test.tsx +84 -4
  44. package/src/useAgUiRuntime.ts +12 -3
  45. package/src/useAgUiRuntime.voice.test.tsx +402 -0
@@ -20,12 +20,14 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
20
20
  import {
21
21
  AG_UI_METADATA_NAMESPACE,
22
22
  A2UI_SURFACE_ACTIVITY_TYPE,
23
+ MCP_APPS_ACTIVITY_TYPE,
23
24
  type AgUiCustomMetadata,
24
25
  type AgUiOpaqueReasoning,
25
26
  } from "./run-aggregator";
26
27
  import {
27
28
  applyA2uiOperations,
28
29
  convertSurfaceToUISpec,
30
+ surfaceToOperations,
29
31
  type A2uiState,
30
32
  type A2uiSurfaceState,
31
33
  } from "@assistant-ui/react-generative-ui/a2ui";
@@ -104,6 +106,7 @@ type ToolCallPart = {
104
106
  argsText?: string;
105
107
  args?: ReadonlyJSONObject;
106
108
  result?: unknown;
109
+ artifact?: unknown;
107
110
  isError?: boolean;
108
111
  modelContent?: readonly ToolModelContentPart[];
109
112
  unstable_toolMessageId?: string;
@@ -241,7 +244,9 @@ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
241
244
  // Build an AG-UI multimodal source from a data URL, raw base64 payload, or an
242
245
  // http(s) URL. A `url` source may omit the mime type; a `data` source always
243
246
  // resolves one (falling back to application/octet-stream). An explicit
244
- // `sourceType: "url"` on the part forces the url leg for non-http references.
247
+ // `sourceType: "url"` on the part forces the url leg for non-http references,
248
+ // and a data URL that is not base64 takes it too, since a `data` source
249
+ // carries base64 bytes.
245
250
  function buildInputSource(
246
251
  value: string,
247
252
  declaredMimeType: string | undefined,
@@ -250,7 +255,8 @@ function buildInputSource(
250
255
  const source = resolveFilePartSource({
251
256
  data: value,
252
257
  mimeType: declaredMimeType ?? "application/octet-stream",
253
- sourceType,
258
+ sourceType:
259
+ /^data:/i.test(value) && !parseDataUrl(value) ? "url" : sourceType,
254
260
  });
255
261
  if (source.kind === "url") {
256
262
  return declaredMimeType !== undefined
@@ -718,6 +724,7 @@ function attachA2uiSurfaces(
718
724
  args: spec as unknown as ReadonlyJSONObject,
719
725
  argsText: JSON.stringify(spec),
720
726
  result: {},
727
+ artifact: { a2ui: surfaceToOperations(surface) },
721
728
  });
722
729
  }
723
730
 
@@ -737,9 +744,9 @@ function attachA2uiSurfaces(
737
744
  // A tool call that arrives without a `parentMessageId` has no record to join, so
738
745
  // the client opens one keyed by the call id. An assistant record whose own id is
739
746
  // a call it carries is that container: an id the client invented for the call
740
- // rather than a boundary an agent drew. The prose the run answers with lands in
741
- // a record of its own, and nothing on the wire binds the two to the run that
742
- // produced them.
747
+ // rather than a boundary an agent drew. The prose of the turn lands in records
748
+ // of its own on either side of it, and nothing on the wire binds them to the
749
+ // run that produced them.
743
750
  function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
744
751
  if (message.role !== "assistant" || !Array.isArray(message.content))
745
752
  return false;
@@ -759,23 +766,37 @@ function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
759
766
  return carriesOwnId;
760
767
  }
761
768
 
762
- function carriesText(message: CoreThreadMessageLike): boolean {
769
+ function carriesPart(message: CoreThreadMessageLike, type: string): boolean {
763
770
  return (
764
771
  Array.isArray(message.content) &&
765
- message.content.some((part) => isObject(part) && part.type === "text")
772
+ message.content.some((part) => isObject(part) && part.type === type)
773
+ );
774
+ }
775
+
776
+ function carriesText(message: CoreThreadMessageLike): boolean {
777
+ return carriesPart(message, "text");
778
+ }
779
+
780
+ // A container joins the assistant record ahead of it when that record holds
781
+ // prose or calls of the turn. A released reasoning record holds neither: its
782
+ // part leaves the export under the record's own id, so a message merged under
783
+ // that id would put two records with one id on the wire.
784
+ function opensTurn(message: CoreThreadMessageLike): boolean {
785
+ return (
786
+ message.role === "assistant" &&
787
+ (carriesText(message) || carriesPart(message, "tool-call"))
766
788
  );
767
789
  }
768
790
 
769
- // What the run put after the container folds onto it: another container for a
770
- // parallel call, or the parts of the answer. A record carrying a tool call an
771
- // agent addressed is a boundary of its own and keeps its message.
772
- function foldsOntoToolCallContainer(message: CoreThreadMessageLike): boolean {
791
+ // The parts of the answer fold onto the container ahead of them. A record
792
+ // carrying a tool call an agent addressed is a boundary of its own and keeps
793
+ // its message.
794
+ function answersToolCallContainer(message: CoreThreadMessageLike): boolean {
773
795
  if (message.role !== "assistant" || !Array.isArray(message.content))
774
796
  return false;
775
797
  if (message.content.length === 0) return false;
776
- return (
777
- isSyntheticToolCallContainer(message) ||
778
- !message.content.some((part) => isObject(part) && part.type === "tool-call")
798
+ return !message.content.some(
799
+ (part) => isObject(part) && part.type === "tool-call",
779
800
  );
780
801
  }
781
802
 
@@ -789,51 +810,53 @@ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
789
810
  return isObject(namespaced) ? namespaced : {};
790
811
  }
791
812
 
792
- function foldOntoToolCallContainer(
793
- container: CoreThreadMessageLike,
813
+ function foldTurnRecords(
814
+ previous: CoreThreadMessageLike,
794
815
  message: CoreThreadMessageLike,
795
816
  ): CoreThreadMessageLike {
796
817
  const interrupts = [
797
- ...(readPersistedInterrupts(container.metadata) ?? []),
818
+ ...(readPersistedInterrupts(previous.metadata) ?? []),
798
819
  ...(readPersistedInterrupts(message.metadata) ?? []),
799
820
  ];
800
- // An entry that rode the folded record sat ahead of content that now follows
801
- // the container's calls, so it is replayed after the merged record and its
821
+ // An entry that rode a record behind the turn's calls sat ahead of content
822
+ // that now follows them, so it is replayed after the merged record and its
802
823
  // tool results rather than ahead of a message that no longer starts there.
824
+ // One that rode the container opening the turn sat ahead of every call and
825
+ // stays ahead of the record.
826
+ const trailsCalls = carriesPart(previous, "tool-call");
803
827
  const opaqueReasoning = [
804
- ...readOpaqueReasoning(container.metadata),
805
- ...readOpaqueReasoning(message.metadata).map((entry) => ({
806
- ...entry,
807
- after: true,
808
- })),
828
+ ...readOpaqueReasoning(previous.metadata),
829
+ ...readOpaqueReasoning(message.metadata).map((entry) =>
830
+ trailsCalls ? { ...entry, after: true } : entry,
831
+ ),
809
832
  ];
810
833
  const namespaced = {
811
- ...readNamespacedMetadata(container.metadata),
834
+ ...readNamespacedMetadata(previous.metadata),
812
835
  ...readNamespacedMetadata(message.metadata),
813
836
  ...(interrupts.length > 0 ? { interrupts } : {}),
814
837
  ...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
815
838
  } satisfies AgUiCustomMetadata & Record<string, unknown>;
816
839
  const custom = {
817
- ...readCustomMetadata(container.metadata),
840
+ ...readCustomMetadata(previous.metadata),
818
841
  ...readCustomMetadata(message.metadata),
819
842
  ...(Object.keys(namespaced).length > 0
820
843
  ? { [AG_UI_METADATA_NAMESPACE]: namespaced }
821
844
  : {}),
822
845
  };
823
846
  const metadata = {
824
- ...(isObject(container.metadata) ? container.metadata : {}),
847
+ ...(isObject(previous.metadata) ? previous.metadata : {}),
825
848
  ...(isObject(message.metadata) ? message.metadata : {}),
826
849
  ...(Object.keys(custom).length > 0 ? { custom } : {}),
827
850
  };
828
851
  return {
829
- ...container,
852
+ ...previous,
830
853
  ...message,
831
- // The container's id names a call rather than the turn, so the record the
854
+ // A container's id names a call rather than the turn, so the record the
832
855
  // agent addressed its prose to wins: that is the id the next run has to
833
856
  // carry for the agent to recognize the message it wrote.
834
- id: carriesText(message) ? message.id : container.id,
857
+ id: carriesText(message) ? message.id : previous.id,
835
858
  content: [
836
- ...(Array.isArray(container.content) ? container.content : []),
859
+ ...(Array.isArray(previous.content) ? previous.content : []),
837
860
  ...(Array.isArray(message.content) ? message.content : []),
838
861
  ],
839
862
  ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
@@ -856,6 +879,10 @@ export function fromAgUiMessages(
856
879
  const converted: CoreThreadMessageLike[] = [];
857
880
  const a2uiBuckets = new Map<string, A2uiState>();
858
881
  const a2uiBucketOwnerIndices = new Map<string, number>();
882
+ const activityParts = new Map<
883
+ string,
884
+ { ownerIndex: number; part: { type: "data"; name: string; data: unknown } }
885
+ >();
859
886
  // A zero-data-retention run carries its payload in encryptedValue with no
860
887
  // readable content, so the record has no part to become and rides on the
861
888
  // neighbouring message instead of being dropped. The anchor is the index the
@@ -1004,15 +1031,9 @@ export function fromAgUiMessages(
1004
1031
  }
1005
1032
 
1006
1033
  if (role === "activity") {
1007
- // Only a2ui-surface activity messages have an assistant-part equivalent
1008
- // to rehydrate; other activity types still have no surface to repaint.
1009
1034
  const activityType = getString(rawMessage, "activityType");
1010
- if (activityType !== A2UI_SURFACE_ACTIVITY_TYPE) continue;
1011
- const activityContent = isObject(rawMessage.content)
1012
- ? (rawMessage.content as Record<string, unknown>)
1013
- : null;
1014
- const operations = activityContent?.["a2ui_operations"];
1015
- if (!Array.isArray(operations)) continue;
1035
+ if (activityType === undefined || activityType === MCP_APPS_ACTIVITY_TYPE)
1036
+ continue;
1016
1037
 
1017
1038
  // A surface belongs to the turn that painted it, and held reasoning is a
1018
1039
  // nearer antecedent than the previous turn's assistant record, so it is
@@ -1030,6 +1051,48 @@ export function fromAgUiMessages(
1030
1051
  if (ownerIndex === -1) continue;
1031
1052
 
1032
1053
  const owner = converted[ownerIndex]!;
1054
+ if (activityType !== A2UI_SURFACE_ACTIVITY_TYPE) {
1055
+ const bucketKey =
1056
+ getString(rawMessage, "id") ?? `agui-activity:${activityType}`;
1057
+ const part = {
1058
+ type: "data" as const,
1059
+ name: `agui-activity/${activityType}`,
1060
+ data: rawMessage.content,
1061
+ };
1062
+ const existing = activityParts.get(bucketKey);
1063
+ const existingOwner = existing && converted[existing.ownerIndex];
1064
+ const existingIndex =
1065
+ existingOwner && Array.isArray(existingOwner.content)
1066
+ ? existingOwner.content.indexOf(existing.part)
1067
+ : -1;
1068
+ if (existing && existingOwner && existingIndex !== -1) {
1069
+ const content = [...(existingOwner.content as unknown[])];
1070
+ content[existingIndex] = part;
1071
+ converted[existing.ownerIndex] = {
1072
+ ...existingOwner,
1073
+ content: content as typeof existingOwner.content,
1074
+ };
1075
+ activityParts.set(bucketKey, {
1076
+ ownerIndex: existing.ownerIndex,
1077
+ part,
1078
+ });
1079
+ continue;
1080
+ }
1081
+
1082
+ const content = Array.isArray(owner.content) ? owner.content : [];
1083
+ activityParts.set(bucketKey, { ownerIndex, part });
1084
+ converted[ownerIndex] = {
1085
+ ...owner,
1086
+ content: [...content, part],
1087
+ };
1088
+ continue;
1089
+ }
1090
+
1091
+ const activityContent = isObject(rawMessage.content)
1092
+ ? (rawMessage.content as Record<string, unknown>)
1093
+ : null;
1094
+ const operations = activityContent?.["a2ui_operations"];
1095
+ if (!Array.isArray(operations)) continue;
1033
1096
  const bucketKey = getString(rawMessage, "id") ?? "a2ui:anonymous";
1034
1097
  const { state } = applyA2uiOperations(new Map(), operations);
1035
1098
  a2uiBuckets.delete(bucketKey);
@@ -1134,17 +1197,22 @@ export function fromAgUiMessages(
1134
1197
  ]);
1135
1198
  }
1136
1199
 
1137
- // A turn that called a tool before answering sits on the wire as a container
1138
- // and the records that follow it, which the live run renders as one message.
1200
+ // A turn that called a tool without addressing a record sits on the wire as
1201
+ // a container beside the records around it, which the live run renders as
1202
+ // one message: the container joins the assistant record ahead of it, and
1203
+ // what follows a container joins it until the turn carries text, where a
1204
+ // further text record opens a message of its own as it does live.
1139
1205
  const folded: CoreThreadMessageLike[] = [];
1140
1206
  for (const message of converted) {
1141
1207
  const previous = folded[folded.length - 1];
1142
1208
  if (
1143
1209
  previous !== undefined &&
1144
- isSyntheticToolCallContainer(previous) &&
1145
- foldsOntoToolCallContainer(message)
1210
+ (isSyntheticToolCallContainer(message)
1211
+ ? opensTurn(previous)
1212
+ : isSyntheticToolCallContainer(previous) &&
1213
+ answersToolCallContainer(message))
1146
1214
  ) {
1147
- folded[folded.length - 1] = foldOntoToolCallContainer(previous, message);
1215
+ folded[folded.length - 1] = foldTurnRecords(previous, message);
1148
1216
  continue;
1149
1217
  }
1150
1218
  folded.push(message);
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  applyA2uiOperations,
15
15
  convertSurfaceToUISpec,
16
+ surfaceToOperations,
16
17
  type A2uiState,
17
18
  type A2uiSurfaceState,
18
19
  } from "@assistant-ui/react-generative-ui/a2ui";
@@ -49,7 +50,7 @@ type PartOrderEntry =
49
50
  | { kind: "text"; key: string; subagentRunId?: string }
50
51
  | { kind: "reasoning"; key: string; subagentRunId?: string }
51
52
  | { kind: "tool-call"; toolCallId: string }
52
- | { kind: "data"; name: string; value: unknown };
53
+ | { kind: "data"; name: string; value: unknown; subagentRunId?: string };
53
54
 
54
55
  type BuildContext = {
55
56
  subagentsByParentToolCallId: Map<string, string[]>;
@@ -95,6 +96,7 @@ type ToolCallState = {
95
96
  toolMessageId?: string;
96
97
  mcpAppResourceUri?: string;
97
98
  mcpAppServerId?: string;
99
+ artifact?: unknown;
98
100
  modelContent?: ToolModelContentPart[];
99
101
  snapshotResultApplied: boolean;
100
102
  subagentRunId?: string;
@@ -246,6 +248,10 @@ export class RunAggregator {
246
248
  private readonly toolCalls = new Map<string, ToolCallState>();
247
249
  private readonly a2uiBuckets = new Map<string, A2uiState>();
248
250
  private readonly a2uiToolCallIds = new Set<string>();
251
+ private readonly activityParts = new Map<
252
+ string,
253
+ { kind: "data"; name: string; value: unknown; subagentRunId?: string }
254
+ >();
249
255
  private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
250
256
  private readonly partOrder: PartOrderEntry[] = [];
251
257
  private textPartCounter = 0;
@@ -319,6 +325,9 @@ export class RunAggregator {
319
325
  break;
320
326
  }
321
327
  case "RUN_CANCELLED": {
328
+ // A cancel can land after RUN_FINISHED while the response is still
329
+ // closing, and the answer it finished is already persisted as complete.
330
+ if (this.status?.type === "complete") break;
322
331
  this.status = { type: "incomplete", reason: "cancelled" };
323
332
  this.closeOpenSubagentRuns(this.status);
324
333
  this.emit();
@@ -503,7 +512,10 @@ export class RunAggregator {
503
512
  this.handleA2uiActivitySnapshot(event);
504
513
  break;
505
514
  }
506
- if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
515
+ if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) {
516
+ this.handleActivitySnapshot(event);
517
+ break;
518
+ }
507
519
  const activityScope = this.scopeOf(event);
508
520
  const toolCallId = event.content.toolCallId;
509
521
  const fallbackId =
@@ -644,6 +656,42 @@ export class RunAggregator {
644
656
  this.emit();
645
657
  }
646
658
 
659
+ private handleActivitySnapshot(
660
+ event: Extract<AgUiEvent, { type: "ACTIVITY_SNAPSHOT" }>,
661
+ ): void {
662
+ const scope = this.scopeOf(event);
663
+ const key = this.partKey(
664
+ scope,
665
+ event.messageId !== undefined
666
+ ? `message:${event.messageId}`
667
+ : `type:${event.activityType}`,
668
+ );
669
+ const existing = this.activityParts.get(key);
670
+ if (existing) {
671
+ if (event.replace === false) return;
672
+ existing.name = `agui-activity/${event.activityType}`;
673
+ existing.value = event.content;
674
+ } else {
675
+ const part =
676
+ scope === ROOT_SCOPE
677
+ ? {
678
+ kind: "data" as const,
679
+ name: `agui-activity/${event.activityType}`,
680
+ value: event.content,
681
+ }
682
+ : {
683
+ kind: "data" as const,
684
+ name: `agui-activity/${event.activityType}`,
685
+ value: event.content,
686
+ subagentRunId: scope,
687
+ };
688
+ this.activityParts.set(key, part);
689
+ this.partOrder.push(part);
690
+ this.activeTextMessageIdByScope.delete(scope);
691
+ }
692
+ this.emit();
693
+ }
694
+
647
695
  private synthesizeA2uiToolCalls(): void {
648
696
  const surfaces = new Map<string, A2uiSurfaceState>();
649
697
 
@@ -671,6 +719,7 @@ export class RunAggregator {
671
719
  parsedArgs: spec,
672
720
  result: {},
673
721
  isError: undefined,
722
+ artifact: { a2ui: surfaceToOperations(surface) },
674
723
  snapshotResultApplied: false,
675
724
  };
676
725
  if (!this.toolCalls.has(toolCallId)) {
@@ -729,6 +778,7 @@ export class RunAggregator {
729
778
  this.toolCalls.clear();
730
779
  this.a2uiBuckets.clear();
731
780
  this.a2uiToolCallIds.clear();
781
+ this.activityParts.clear();
732
782
  this.lastResolvedToolCallIdByScope.clear();
733
783
  this.partOrder.length = 0;
734
784
  this.textPartCounter = 0;
@@ -1097,6 +1147,7 @@ export class RunAggregator {
1097
1147
  argsText: entry.argsText,
1098
1148
  ...(approval ? { approval } : {}),
1099
1149
  ...(entry.result !== undefined ? { result: entry.result } : {}),
1150
+ ...(entry.artifact !== undefined ? { artifact: entry.artifact } : {}),
1100
1151
  ...(entry.modelContent !== undefined
1101
1152
  ? { modelContent: entry.modelContent }
1102
1153
  : {}),
@@ -1,8 +1,11 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, it, expect, vi, afterEach } from "vitest";
4
- import { cleanup, renderHook, waitFor } from "@testing-library/react";
5
- import type { ThreadHistoryAdapter } from "@assistant-ui/core";
4
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
5
+ import type {
6
+ ThreadAssistantMessage,
7
+ ThreadHistoryAdapter,
8
+ } from "@assistant-ui/core";
6
9
  import type { HttpAgent } from "@ag-ui/client";
7
10
  import { useAgUiRuntime } from "./useAgUiRuntime";
8
11
 
@@ -73,4 +76,92 @@ describe("useAgUiRuntime history", () => {
73
76
  rerender();
74
77
  expect(history.load).toHaveBeenCalledOnce();
75
78
  });
79
+
80
+ it("records a settled tool interaction through the message part runtime", async () => {
81
+ const assistant: ThreadAssistantMessage = {
82
+ id: "assistant-1",
83
+ role: "assistant",
84
+ createdAt: new Date(0),
85
+ status: { type: "complete", reason: "unknown" },
86
+ content: [
87
+ {
88
+ type: "tool-call",
89
+ toolCallId: "call-1",
90
+ toolName: "present",
91
+ args: {},
92
+ argsText: "{}",
93
+ result: {},
94
+ },
95
+ ],
96
+ metadata: {
97
+ unstable_state: null,
98
+ unstable_annotations: [],
99
+ unstable_data: [],
100
+ steps: [],
101
+ custom: {},
102
+ },
103
+ };
104
+ const update = vi.fn(async () => {});
105
+ const history: ThreadHistoryAdapter = {
106
+ load: vi.fn().mockResolvedValue({
107
+ headId: assistant.id,
108
+ messages: [{ parentId: null, message: assistant }],
109
+ }),
110
+ append: vi.fn().mockResolvedValue(undefined),
111
+ update,
112
+ };
113
+ const { result } = renderHook(() =>
114
+ useAgUiRuntime({ agent, adapters: { history } }),
115
+ );
116
+
117
+ await waitFor(() =>
118
+ expect(
119
+ result.current.thread.getState().messages.map((message) => message.id),
120
+ ).toEqual([assistant.id]),
121
+ );
122
+ const part = result.current.thread
123
+ .getMessageById(assistant.id)
124
+ .getMessagePartByToolCallId("call-1");
125
+ const recordInteraction = part.unstable_recordInteraction;
126
+ expect(recordInteraction).toBeDefined();
127
+
128
+ await act(async () => {
129
+ await recordInteraction!({
130
+ type: "action",
131
+ payload: { action: "confirm" },
132
+ });
133
+ });
134
+
135
+ expect(part.getState()).toMatchObject({
136
+ unstable_interactions: {
137
+ entries: [
138
+ {
139
+ type: "action",
140
+ payload: { action: "confirm" },
141
+ },
142
+ ],
143
+ },
144
+ });
145
+ expect(update).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ parentId: null,
148
+ message: expect.objectContaining({
149
+ id: assistant.id,
150
+ content: [
151
+ expect.objectContaining({
152
+ toolCallId: "call-1",
153
+ unstable_interactions: expect.objectContaining({
154
+ entries: [
155
+ expect.objectContaining({
156
+ type: "action",
157
+ payload: { action: "confirm" },
158
+ }),
159
+ ],
160
+ }),
161
+ }),
162
+ ],
163
+ }),
164
+ }),
165
+ );
166
+ });
76
167
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { act, cleanup, renderHook } from "@testing-library/react";
5
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
6
6
  import type { HttpAgent } from "@ag-ui/client";
7
7
  import type { ThreadMessage } from "@assistant-ui/core";
8
8
  import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
@@ -15,12 +15,16 @@ type ThreadLoad = Awaited<
15
15
 
16
16
  function deferred<T>() {
17
17
  let resolve!: (value: T) => void;
18
- const promise = new Promise<T>((res) => {
18
+ let reject!: (reason?: unknown) => void;
19
+ const promise = new Promise<T>((res, rej) => {
19
20
  resolve = res;
21
+ reject = rej;
20
22
  });
21
- return { promise, resolve };
23
+ return { promise, resolve, reject };
22
24
  }
23
25
 
26
+ type Subscriber = Record<string, ((payload: any) => void) | undefined>;
27
+
24
28
  function message(id: string): ThreadMessage {
25
29
  return {
26
30
  id,
@@ -35,15 +39,17 @@ function message(id: string): ThreadMessage {
35
39
  function renderRuntime(
36
40
  load: (id: string) => Promise<ThreadLoad>,
37
41
  create: () => Promise<void> = async () => {},
42
+ agentOverride?: HttpAgent,
38
43
  ) {
39
44
  const agent = {
40
45
  runAgent: vi.fn(),
41
46
  abortRun: vi.fn(),
42
47
  } as unknown as HttpAgent;
48
+ const runtimeAgent = agentOverride ?? agent;
43
49
  return renderHook(() => {
44
50
  const [threadId, setThreadId] = useState("initial");
45
51
  return useAgUiRuntime({
46
- agent,
52
+ agent: runtimeAgent,
47
53
  adapters: {
48
54
  threadList: {
49
55
  threadId,
@@ -112,6 +118,80 @@ describe("useAgUiRuntime thread switching", () => {
112
118
  },
113
119
  );
114
120
 
121
+ it.each(["resolve", "reject"] as const)(
122
+ "keeps a new thread empty after an active run when creation %s",
123
+ async (outcome) => {
124
+ const load = deferred<ThreadLoad>();
125
+ const create = deferred<void>();
126
+ const run = deferred<void>();
127
+ let subscriber!: Subscriber;
128
+ const runAgent = vi.fn(async (_input: unknown, next: Subscriber) => {
129
+ subscriber = next;
130
+ await run.promise;
131
+ });
132
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
133
+ const { result } = renderRuntime(
134
+ () => load.promise,
135
+ () => create.promise,
136
+ agent,
137
+ );
138
+
139
+ let switchA!: Promise<void>;
140
+ act(() => {
141
+ switchA = result.current.threads.switchToThread("thread-a");
142
+ });
143
+ await act(async () => {
144
+ load.resolve({ messages: [message("thread-a")] });
145
+ await switchA;
146
+ });
147
+ expect(
148
+ result.current.thread.export().messages.map((m) => m.message.id),
149
+ ).toEqual(["thread-a"]);
150
+
151
+ act(() => {
152
+ void result.current.thread.append("still running");
153
+ });
154
+ await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
155
+
156
+ let switchNew!: Promise<void>;
157
+ act(() => {
158
+ switchNew = result.current.threads.switchToNewThread();
159
+ });
160
+ expect(result.current.thread.export().messages).toEqual([]);
161
+ await waitFor(() =>
162
+ expect(result.current.threads.getState().mainThreadId).toBe(
163
+ "thread-new",
164
+ ),
165
+ );
166
+
167
+ await act(async () => {
168
+ if (outcome === "resolve") {
169
+ create.resolve();
170
+ await switchNew;
171
+ } else {
172
+ create.reject(new Error("create failed"));
173
+ await expect(switchNew).rejects.toThrow("create failed");
174
+ }
175
+ });
176
+
177
+ act(() => {
178
+ subscriber.onMessagesSnapshotEvent?.({
179
+ event: {
180
+ type: "MESSAGES_SNAPSHOT",
181
+ messages: [{ id: "late", role: "assistant", content: "too late" }],
182
+ },
183
+ });
184
+ });
185
+ expect(result.current.thread.export().messages).toEqual([]);
186
+
187
+ await act(async () => {
188
+ run.resolve();
189
+ await runAgent.mock.results[0]!.value;
190
+ });
191
+ expect(agent.abortRun).toHaveBeenCalledOnce();
192
+ },
193
+ );
194
+
115
195
  it("ignores a load superseded by creating a new thread", async () => {
116
196
  const resume = vi
117
197
  .spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")