@assistant-ui/react-ag-ui 0.0.64 → 0.0.66

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 (35) hide show
  1. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -3
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  3. package/dist/runtime/AgUiThreadRuntimeCore.js +193 -39
  4. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  5. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  6. package/dist/runtime/adapter/conversions.js +19 -29
  7. package/dist/runtime/adapter/conversions.js.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.d.ts +4 -16
  9. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.js +100 -23
  11. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  12. package/dist/runtime/event-parser.d.ts.map +1 -1
  13. package/dist/runtime/event-parser.js +16 -8
  14. package/dist/runtime/event-parser.js.map +1 -1
  15. package/dist/runtime/types.d.ts +30 -5
  16. package/dist/runtime/types.d.ts.map +1 -1
  17. package/dist/runtime/types.js +8 -0
  18. package/dist/runtime/types.js.map +1 -0
  19. package/dist/useAgUiRuntime.d.ts.map +1 -1
  20. package/dist/useAgUiRuntime.js +80 -33
  21. package/dist/useAgUiRuntime.js.map +1 -1
  22. package/package.json +7 -6
  23. package/src/runtime/AgUiThreadRuntimeCore.test.ts +380 -6
  24. package/src/runtime/AgUiThreadRuntimeCore.ts +314 -70
  25. package/src/runtime/adapter/conversions.content.test.ts +41 -0
  26. package/src/runtime/adapter/conversions.nested-tool-calls.test.ts +107 -0
  27. package/src/runtime/adapter/conversions.ts +37 -64
  28. package/src/runtime/adapter/run-aggregator.reasoning.test.ts +143 -0
  29. package/src/runtime/adapter/run-aggregator.ts +163 -40
  30. package/src/runtime/event-parser.ts +18 -4
  31. package/src/runtime/types.ts +43 -11
  32. package/src/useAgUiRuntime.external-store.test.tsx +51 -0
  33. package/src/useAgUiRuntime.queue.test.tsx +63 -2
  34. package/src/useAgUiRuntime.thread-switch.test.tsx +587 -3
  35. package/src/useAgUiRuntime.ts +106 -44
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  isMcpAppUri,
5
5
  type ChatModelRunResult,
6
+ type MessagePartTiming,
6
7
  type MessageStatus,
7
8
  type MessageTiming,
8
9
  type ThreadAssistantMessagePart,
@@ -12,19 +13,24 @@ import {
12
13
  } from "@assistant-ui/core";
13
14
  import {
14
15
  applyA2uiOperations,
15
- convertSurfaceToUISpec,
16
- surfaceToOperations,
16
+ surfaceToPresentToolCall,
17
17
  type A2uiState,
18
18
  type A2uiSurfaceState,
19
- } from "@assistant-ui/react-generative-ui/a2ui";
19
+ } from "@assistant-ui/generative-ui/a2ui";
20
20
  import jsonpatch, { type Operation } from "fast-json-patch";
21
21
  import { readMcpAppResourceUri } from "../mcp-tool-result";
22
22
  import { projectAgUiToolApprovals } from "./tool-approval";
23
- import type { AgUiEvent, AgUiInterrupt } from "../types";
23
+ import {
24
+ AG_UI_METADATA_NAMESPACE,
25
+ A2UI_SURFACE_ACTIVITY_TYPE,
26
+ MCP_APPS_ACTIVITY_TYPE,
27
+ type AgUiCustomMetadata,
28
+ type AgUiEvent,
29
+ type AgUiInterrupt,
30
+ type AgUiOpaqueReasoning,
31
+ } from "../types";
24
32
  import type { Logger } from "../logger";
25
33
 
26
- export const AG_UI_METADATA_NAMESPACE = "agui";
27
-
28
34
  const ROOT_SCOPE = "";
29
35
 
30
36
  // Defensive guard against a malformed or cyclic parentToolCallId /
@@ -69,20 +75,6 @@ type BuildContext = {
69
75
  };
70
76
  };
71
77
 
72
- export type AgUiOpaqueReasoning = {
73
- id: string;
74
- encryptedValue: string;
75
- after?: boolean;
76
- };
77
-
78
- export type AgUiCustomMetadata = {
79
- /** Wire role restored on export for messages the internal model cannot
80
- * represent (a developer record rides as a system message). */
81
- role?: "developer";
82
- interrupts?: AgUiInterrupt[];
83
- opaqueReasoning?: AgUiOpaqueReasoning[];
84
- };
85
-
86
78
  type Emit = (update: ChatModelRunResult) => void;
87
79
 
88
80
  type ToolCallState = {
@@ -177,10 +169,6 @@ const scanJSONContainerDelta = (
177
169
  return completed && scanner.state === "complete";
178
170
  };
179
171
 
180
- export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
181
-
182
- export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
183
-
184
172
  export const isPlainObject = (
185
173
  value: unknown,
186
174
  ): value is Record<string, unknown> =>
@@ -217,7 +205,10 @@ export class RunAggregator {
217
205
  private interrupts: AgUiInterrupt[] | undefined;
218
206
  private readonly textParts = new Map<string, { buffer: string }>();
219
207
  private readonly activeTextMessageIdByScope = new Map<string, string>();
220
- private readonly reasoningParts = new Map<string, string>(); // key → buffer
208
+ private readonly reasoningParts = new Map<
209
+ string,
210
+ { buffer: string; timing?: MessagePartTiming }
211
+ >();
221
212
  private readonly reasoningSignatures = new Map<string, string>();
222
213
  private readonly reasoningSignatureIds = new Map<string, string>();
223
214
  // Signatures captured while thinking is hidden have no block to live on;
@@ -255,6 +246,7 @@ export class RunAggregator {
255
246
  >();
256
247
  private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
257
248
  private readonly partOrder: PartOrderEntry[] = [];
249
+ private lastReasoningBeforeToolCall: PartOrderEntry | undefined;
258
250
  private textPartCounter = 0;
259
251
  private serverMessageIdReported = false;
260
252
  private reportedServerMessageId: string | undefined;
@@ -396,6 +388,7 @@ export class RunAggregator {
396
388
  this.scopeOf("subagentRunId" in event ? event : {}),
397
389
  "messageId" in event ? event.messageId : undefined,
398
390
  event.type === "REASONING_MESSAGE_START",
391
+ event.timestamp,
399
392
  );
400
393
  break;
401
394
  case "REASONING_ENCRYPTED_VALUE":
@@ -454,11 +447,11 @@ export class RunAggregator {
454
447
  break;
455
448
  case "THINKING_TEXT_MESSAGE_END":
456
449
  case "THINKING_END":
457
- this.handleReasoningEnd(ROOT_SCOPE);
450
+ this.handleReasoningEnd(ROOT_SCOPE, event.timestamp);
458
451
  break;
459
452
  case "REASONING_MESSAGE_END":
460
453
  case "REASONING_END":
461
- this.handleReasoningEnd(this.scopeOf(event));
454
+ this.handleReasoningEnd(this.scopeOf(event), event.timestamp);
462
455
  break;
463
456
 
464
457
  case "TOOL_CALL_START": {
@@ -743,23 +736,27 @@ export class RunAggregator {
743
736
 
744
737
  const activeToolCallIds = new Set<string>();
745
738
  for (const [surfaceId, surface] of surfaces) {
746
- const toolCallId = `a2ui:${surfaceId}`;
747
- const { spec, warnings } = convertSurfaceToUISpec(surface);
739
+ const { toolCall, warnings } = surfaceToPresentToolCall(
740
+ surfaceId,
741
+ surface,
742
+ );
748
743
  for (const warning of warnings) {
749
744
  this.logger.debug("[agui] a2ui surface conversion warning", warning);
750
745
  }
751
- if (!spec) continue;
746
+ if (!toolCall) continue;
747
+
748
+ const { toolCallId } = toolCall;
752
749
 
753
750
  activeToolCallIds.add(toolCallId);
754
751
 
755
752
  const entry: ToolCallState = {
756
- toolCallId,
757
- toolCallName: "present",
758
- argsText: JSON.stringify(spec),
759
- parsedArgs: spec,
760
- result: {},
753
+ toolCallId: toolCall.toolCallId,
754
+ toolCallName: toolCall.toolName,
755
+ argsText: toolCall.argsText,
756
+ parsedArgs: toolCall.args,
757
+ result: toolCall.result,
761
758
  isError: undefined,
762
- artifact: { a2ui: surfaceToOperations(surface) },
759
+ artifact: toolCall.artifact,
763
760
  snapshotResultApplied: false,
764
761
  };
765
762
  if (!this.toolCalls.has(toolCallId)) {
@@ -821,6 +818,7 @@ export class RunAggregator {
821
818
  this.activityParts.clear();
822
819
  this.lastResolvedToolCallIdByScope.clear();
823
820
  this.partOrder.length = 0;
821
+ this.lastReasoningBeforeToolCall = undefined;
824
822
  this.textPartCounter = 0;
825
823
  this.activeTextMessageIdByScope.clear();
826
824
  this.reportedServerMessageId = undefined;
@@ -854,8 +852,103 @@ export class RunAggregator {
854
852
  ) {
855
853
  return;
856
854
  }
855
+ const carried = this.takeTrailingReasoning();
856
+ if (carried.parts.length > 0 || carried.hiddenSignatures.length > 0) {
857
+ this.emit();
858
+ }
859
+ const reasoningPartCounter = this.reasoningPartCounter;
857
860
  this.resetMessageParts();
858
861
  this.onTextMessageStart(messageId);
862
+ this.reasoningPartCounter = reasoningPartCounter;
863
+ this.restoreReasoning(carried);
864
+ }
865
+
866
+ private takeTrailingReasoning() {
867
+ const parts: {
868
+ key: string;
869
+ entry: { buffer: string; timing?: MessagePartTiming };
870
+ signature: string | undefined;
871
+ signatureId: string | undefined;
872
+ reasoningId: string | undefined;
873
+ anonymous: boolean;
874
+ }[] = [];
875
+ let last = this.partOrder.at(-1);
876
+ while (
877
+ last?.kind === "reasoning" &&
878
+ last.subagentRunId === undefined &&
879
+ last !== this.lastReasoningBeforeToolCall
880
+ ) {
881
+ this.partOrder.pop();
882
+ parts.unshift({
883
+ key: last.key,
884
+ entry: this.reasoningParts.get(last.key) ?? { buffer: "" },
885
+ signature: this.reasoningSignatures.get(last.key),
886
+ signatureId: this.reasoningSignatureIds.get(last.key),
887
+ reasoningId: this.reasoningMessageIds.get(last.key),
888
+ anonymous: this.anonymousReasoningKeys.has(last.key),
889
+ });
890
+ last = this.partOrder.at(-1);
891
+ }
892
+ const cut = this.partOrder.length;
893
+ const hiddenSignatures = Array.from(this.hiddenSignatures).filter(
894
+ ([id]) => this.hiddenSignatureAnchors.get(id)! >= cut,
895
+ );
896
+ for (const [id] of hiddenSignatures) {
897
+ this.hiddenSignatures.delete(id);
898
+ this.hiddenSignatureAnchors.delete(id);
899
+ }
900
+ const hiddenBlockIds = Array.from(this.hiddenBlockAnchors)
901
+ .filter(([, anchor]) => anchor >= cut)
902
+ .map(([id]) => id);
903
+ const hiddenAnonymous =
904
+ this.hiddenAnonymousAnchor !== undefined &&
905
+ this.hiddenAnonymousAnchor >= cut;
906
+ const activeCarried =
907
+ (this.hiddenActiveReasoning === "identified" &&
908
+ hiddenBlockIds.length > 0) ||
909
+ (this.hiddenActiveReasoning === "anonymous" && hiddenAnonymous);
910
+ return {
911
+ parts,
912
+ activeKey: this.activeReasoningKeyByScope.get(ROOT_SCOPE),
913
+ hiddenSignatures,
914
+ hiddenBlockIds,
915
+ hiddenAnonymous,
916
+ hiddenActiveReasoning: activeCarried
917
+ ? this.hiddenActiveReasoning
918
+ : ("none" as const),
919
+ };
920
+ }
921
+
922
+ private restoreReasoning(
923
+ carried: ReturnType<RunAggregator["takeTrailingReasoning"]>,
924
+ ): void {
925
+ for (const part of carried.parts) {
926
+ this.partOrder.push({ kind: "reasoning", key: part.key });
927
+ this.reasoningParts.set(part.key, part.entry);
928
+ if (part.signature !== undefined)
929
+ this.reasoningSignatures.set(part.key, part.signature);
930
+ if (part.signatureId !== undefined)
931
+ this.reasoningSignatureIds.set(part.key, part.signatureId);
932
+ if (part.reasoningId !== undefined)
933
+ this.reasoningMessageIds.set(part.key, part.reasoningId);
934
+ if (part.anonymous) this.anonymousReasoningKeys.add(part.key);
935
+ }
936
+ if (
937
+ carried.activeKey !== undefined &&
938
+ this.reasoningParts.has(carried.activeKey)
939
+ ) {
940
+ this.activeReasoningKeyByScope.set(ROOT_SCOPE, carried.activeKey);
941
+ }
942
+ for (const [id, encryptedValue] of carried.hiddenSignatures) {
943
+ this.hiddenSignatures.set(id, encryptedValue);
944
+ this.hiddenSignatureAnchors.set(id, 0);
945
+ }
946
+ for (const id of carried.hiddenBlockIds) {
947
+ this.hiddenReasoningIds.add(id);
948
+ this.hiddenBlockAnchors.set(id, 0);
949
+ }
950
+ if (carried.hiddenAnonymous) this.hiddenAnonymousAnchor = 0;
951
+ this.hiddenActiveReasoning = carried.hiddenActiveReasoning;
859
952
  }
860
953
 
861
954
  private generateTextKey(): string {
@@ -927,6 +1020,13 @@ export class RunAggregator {
927
1020
  (part) => part.kind === "tool-call" && part.toolCallId === id,
928
1021
  )
929
1022
  ) {
1023
+ // Tool insertion can precede existing reasoning in display order.
1024
+ if (scope === ROOT_SCOPE) {
1025
+ this.lastReasoningBeforeToolCall = this.partOrder.findLast(
1026
+ (part) =>
1027
+ part.kind === "reasoning" && part.subagentRunId === undefined,
1028
+ );
1029
+ }
930
1030
  this.insertToolPart(scope, id, parentMessageId);
931
1031
  }
932
1032
  const state: ToolCallState = {
@@ -1222,7 +1322,8 @@ export class RunAggregator {
1222
1322
 
1223
1323
  if (part.kind === "reasoning") {
1224
1324
  if (this.showThinking) {
1225
- const buffer = this.reasoningParts.get(part.key) ?? "";
1325
+ const entry = this.reasoningParts.get(part.key);
1326
+ const buffer = entry?.buffer ?? "";
1226
1327
  const isActive =
1227
1328
  this.activeReasoningKeyByScope.get(rawScope) === part.key;
1228
1329
  if (buffer.length > 0 || isActive) {
@@ -1235,6 +1336,7 @@ export class RunAggregator {
1235
1336
  snapshot.push({
1236
1337
  type: "reasoning",
1237
1338
  text: buffer,
1339
+ ...(entry?.timing ? { timing: entry.timing } : {}),
1238
1340
  ...(Object.keys(meta).length > 0
1239
1341
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
1240
1342
  : {}),
@@ -1441,6 +1543,7 @@ export class RunAggregator {
1441
1543
  scope: string,
1442
1544
  messageId?: string,
1443
1545
  isMessageId = false,
1546
+ timestamp?: number,
1444
1547
  ): void {
1445
1548
  if (!this.showThinking) {
1446
1549
  // Hidden-reasoning bookkeeping stays root-only for this task — a
@@ -1473,13 +1576,23 @@ export class RunAggregator {
1473
1576
  this.reasoningMessageIds.set(key, messageId);
1474
1577
  }
1475
1578
  if (!this.reasoningParts.has(key)) {
1476
- this.reasoningParts.set(key, "");
1579
+ this.reasoningParts.set(key, { buffer: "" });
1477
1580
  this.partOrder.push(
1478
1581
  scope === ROOT_SCOPE
1479
1582
  ? { kind: "reasoning", key }
1480
1583
  : { kind: "reasoning", key, subagentRunId: scope },
1481
1584
  );
1482
1585
  }
1586
+ const entry = this.reasoningParts.get(key);
1587
+ if (entry?.timing) {
1588
+ entry.timing = { startedAt: entry.timing.startedAt };
1589
+ } else if (
1590
+ entry &&
1591
+ typeof timestamp === "number" &&
1592
+ Number.isFinite(timestamp)
1593
+ ) {
1594
+ entry.timing = { startedAt: timestamp };
1595
+ }
1483
1596
  this.activeReasoningKeyByScope.set(scope, key);
1484
1597
  this.emit();
1485
1598
  }
@@ -1506,15 +1619,25 @@ export class RunAggregator {
1506
1619
  }
1507
1620
  const key = this.activeReasoningKeyByScope.get(scope);
1508
1621
  if (!key) return;
1509
- this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
1622
+ const entry = this.reasoningParts.get(key);
1623
+ if (entry) entry.buffer += delta;
1510
1624
  this.emit();
1511
1625
  }
1512
1626
 
1513
- private handleReasoningEnd(scope: string): void {
1627
+ private handleReasoningEnd(scope: string, timestamp?: number): void {
1514
1628
  if (!this.showThinking) {
1515
1629
  if (scope === ROOT_SCOPE) this.hiddenActiveReasoning = "none";
1516
1630
  return;
1517
1631
  }
1632
+ const key = this.activeReasoningKeyByScope.get(scope);
1633
+ const entry = key ? this.reasoningParts.get(key) : undefined;
1634
+ if (
1635
+ entry?.timing &&
1636
+ typeof timestamp === "number" &&
1637
+ Number.isFinite(timestamp)
1638
+ ) {
1639
+ entry.timing = { ...entry.timing, completedAt: timestamp };
1640
+ }
1518
1641
  this.activeReasoningKeyByScope.delete(scope);
1519
1642
  this.emit();
1520
1643
  }
@@ -146,6 +146,10 @@ export const parseAgUiEvent = (
146
146
 
147
147
  const getString = (key: string) =>
148
148
  isString(payload[key]) ? (payload[key] as string) : undefined;
149
+ const timestamp =
150
+ typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp)
151
+ ? payload.timestamp
152
+ : undefined;
149
153
 
150
154
  switch (typeValue) {
151
155
  case "RUN_STARTED": {
@@ -219,24 +223,31 @@ export const parseAgUiEvent = (
219
223
  case "THINKING_START":
220
224
  return withOptional(
221
225
  { type: "THINKING_START" as const },
222
- { title: getString("title") },
226
+ { title: getString("title"), timestamp },
223
227
  );
224
228
  case "THINKING_TEXT_MESSAGE_START":
225
- return { type: "THINKING_TEXT_MESSAGE_START" };
229
+ return withOptional(
230
+ { type: "THINKING_TEXT_MESSAGE_START" as const },
231
+ { timestamp },
232
+ );
226
233
  case "THINKING_TEXT_MESSAGE_CONTENT": {
227
234
  const delta = getString("delta") ?? "";
228
235
  return { type: "THINKING_TEXT_MESSAGE_CONTENT", delta };
229
236
  }
230
237
  case "THINKING_TEXT_MESSAGE_END":
231
- return { type: "THINKING_TEXT_MESSAGE_END" };
238
+ return withOptional(
239
+ { type: "THINKING_TEXT_MESSAGE_END" as const },
240
+ { timestamp },
241
+ );
232
242
  case "THINKING_END":
233
- return { type: "THINKING_END" };
243
+ return withOptional({ type: "THINKING_END" as const }, { timestamp });
234
244
  case "REASONING_START":
235
245
  return withOptional(
236
246
  { type: "REASONING_START" as const },
237
247
  {
238
248
  messageId: getString("messageId"),
239
249
  subagentRunId: getString("subagentRunId"),
250
+ timestamp,
240
251
  },
241
252
  );
242
253
  case "REASONING_MESSAGE_START":
@@ -245,6 +256,7 @@ export const parseAgUiEvent = (
245
256
  {
246
257
  messageId: getString("messageId"),
247
258
  subagentRunId: getString("subagentRunId"),
259
+ timestamp,
248
260
  },
249
261
  );
250
262
  case "REASONING_MESSAGE_CONTENT": {
@@ -263,6 +275,7 @@ export const parseAgUiEvent = (
263
275
  {
264
276
  messageId: getString("messageId"),
265
277
  subagentRunId: getString("subagentRunId"),
278
+ timestamp,
266
279
  },
267
280
  );
268
281
  case "REASONING_ENCRYPTED_VALUE": {
@@ -292,6 +305,7 @@ export const parseAgUiEvent = (
292
305
  {
293
306
  messageId: getString("messageId"),
294
307
  subagentRunId: getString("subagentRunId"),
308
+ timestamp,
295
309
  },
296
310
  );
297
311
  case "TOOL_CALL_START": {
@@ -15,11 +15,29 @@ import type { Logger } from "./logger";
15
15
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
16
16
  import type { McpToolCallResult } from "./mcp-tool-result";
17
17
 
18
+ export const AG_UI_METADATA_NAMESPACE = "agui";
19
+ export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
20
+ export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
21
+
22
+ export type AgUiOpaqueReasoning = {
23
+ id: string;
24
+ encryptedValue: string;
25
+ after?: boolean;
26
+ };
27
+
28
+ export type AgUiCustomMetadata = {
29
+ /** Wire role restored on export for messages the internal model cannot
30
+ * represent (a developer record rides as a system message). */
31
+ role?: "developer";
32
+ interrupts?: AgUiInterrupt[];
33
+ opaqueReasoning?: AgUiOpaqueReasoning[];
34
+ };
35
+
18
36
  /**
19
- * @experimental This API is still under active development and might change without notice.
20
- *
21
37
  * Same as ExternalStoreThreadListAdapter, except `onSwitchToThread` returns
22
38
  * the messages (and optional state) to hydrate the thread with.
39
+ *
40
+ * @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release.
23
41
  */
24
42
  type SwitchToThreadResult = {
25
43
  messages: readonly ThreadMessage[];
@@ -28,6 +46,8 @@ type SwitchToThreadResult = {
28
46
  * Set when the thread has a run in flight. The runtime resumes the run
29
47
  * after hydrating, the same way `ThreadHistoryAdapter.load()` does when it
30
48
  * returns `unstable_resume: true`.
49
+ *
50
+ * @deprecated Experimental since 2025-03-31. Not scheduled for removal; the API may change in any release.
31
51
  */
32
52
  unstable_resume?: boolean;
33
53
  };
@@ -50,9 +70,7 @@ export type UseAgUiRuntimeAdapters = {
50
70
  voice?: RealtimeVoiceAdapter;
51
71
  feedback?: FeedbackAdapter;
52
72
  history?: ThreadHistoryAdapter;
53
- /**
54
- * @experimental This API is still under active development and might change without notice.
55
- */
73
+ /** @deprecated Experimental since 2026-01-28. Not scheduled for removal; the API may change in any release. */
56
74
  threadList?: UseAgUiThreadListAdapter;
57
75
  };
58
76
 
@@ -102,6 +120,8 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
102
120
  * settles, exposing it on `composer.queue` for `ComposerPrimitive.Queue`.
103
121
  * The runtime owns the queue lifecycle because flushing needs the agent's
104
122
  * send path and the run's own busy and idle edges.
123
+ *
124
+ * @deprecated Experimental since 2026-06-04. Not scheduled for removal; the API may change in any release.
105
125
  */
106
126
  unstable_enableMessageQueue?: boolean | undefined;
107
127
  onError?: (e: Error) => void;
@@ -180,16 +200,22 @@ export type AgUiEvent =
180
200
  delta: string;
181
201
  subagentRunId?: string;
182
202
  }
183
- | { type: "THINKING_START"; title?: string }
184
- | { type: "THINKING_TEXT_MESSAGE_START" }
203
+ | { type: "THINKING_START"; title?: string; timestamp?: number }
204
+ | { type: "THINKING_TEXT_MESSAGE_START"; timestamp?: number }
185
205
  | { type: "THINKING_TEXT_MESSAGE_CONTENT"; delta: string }
186
- | { type: "THINKING_TEXT_MESSAGE_END" }
187
- | { type: "THINKING_END" }
188
- | { type: "REASONING_START"; messageId?: string; subagentRunId?: string }
206
+ | { type: "THINKING_TEXT_MESSAGE_END"; timestamp?: number }
207
+ | { type: "THINKING_END"; timestamp?: number }
208
+ | {
209
+ type: "REASONING_START";
210
+ messageId?: string;
211
+ subagentRunId?: string;
212
+ timestamp?: number;
213
+ }
189
214
  | {
190
215
  type: "REASONING_MESSAGE_START";
191
216
  messageId?: string;
192
217
  subagentRunId?: string;
218
+ timestamp?: number;
193
219
  }
194
220
  | {
195
221
  type: "REASONING_MESSAGE_CONTENT";
@@ -201,6 +227,7 @@ export type AgUiEvent =
201
227
  type: "REASONING_MESSAGE_END";
202
228
  messageId?: string;
203
229
  subagentRunId?: string;
230
+ timestamp?: number;
204
231
  }
205
232
  | {
206
233
  type: "REASONING_ENCRYPTED_VALUE";
@@ -209,7 +236,12 @@ export type AgUiEvent =
209
236
  encryptedValue: string;
210
237
  subagentRunId?: string;
211
238
  }
212
- | { type: "REASONING_END"; messageId?: string; subagentRunId?: string }
239
+ | {
240
+ type: "REASONING_END";
241
+ messageId?: string;
242
+ subagentRunId?: string;
243
+ timestamp?: number;
244
+ }
213
245
  | {
214
246
  type: "TOOL_CALL_START";
215
247
  toolCallId: string;
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, renderHook } from "@testing-library/react";
4
+ import type { HttpAgent } from "@ag-ui/client";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { useAgUiRuntime } from "./useAgUiRuntime";
7
+
8
+ afterEach(cleanup);
9
+
10
+ it("exposes the deprecated interrupt methods on the runtime facade", () => {
11
+ const agent = {
12
+ runAgent: vi.fn(),
13
+ abortRun: vi.fn(),
14
+ } as unknown as HttpAgent;
15
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
16
+
17
+ expect(Object.hasOwn(result.current, "unstable_getPendingInterrupts")).toBe(
18
+ true,
19
+ );
20
+ expect(
21
+ Object.hasOwn(result.current, "unstable_submitInterruptResponses"),
22
+ ).toBe(true);
23
+ expect(result.current.unstable_getPendingInterrupts()).toEqual([]);
24
+ expect(result.current.unstable_submitInterruptResponses).toEqual(
25
+ expect.any(Function),
26
+ );
27
+ });
28
+
29
+ it("re-renders when the core publishes a state change", () => {
30
+ const agent = {
31
+ runAgent: vi.fn(),
32
+ abortRun: vi.fn(),
33
+ } as unknown as HttpAgent;
34
+ let renders = 0;
35
+ const { result } = renderHook(() => {
36
+ renders++;
37
+ return useAgUiRuntime({ agent });
38
+ });
39
+ const initialRenders = renders;
40
+
41
+ act(() => {
42
+ result.current.thread.append({
43
+ role: "user",
44
+ content: [{ type: "text", text: "hello" }],
45
+ startRun: false,
46
+ });
47
+ });
48
+
49
+ expect(renders).toBeGreaterThan(initialRenders);
50
+ expect(result.current.thread.getState().messages).toHaveLength(1);
51
+ });
@@ -96,11 +96,67 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
96
96
  );
97
97
  });
98
98
 
99
+ it("holds queued sends while sending is disabled", async () => {
100
+ const { agent, runAgent, release } = gatedAgent();
101
+
102
+ const { result, rerender } = renderHook(
103
+ ({ isSendDisabled }) =>
104
+ useAgUiRuntime({
105
+ agent,
106
+ unstable_enableMessageQueue: true,
107
+ isSendDisabled,
108
+ }),
109
+ { initialProps: { isSendDisabled: false } },
110
+ );
111
+ mount(result.current);
112
+
113
+ await act(async () => {
114
+ await result.current.thread.append({
115
+ role: "user",
116
+ content: [{ type: "text", text: "first" }],
117
+ });
118
+ });
119
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
120
+
121
+ await act(async () => {
122
+ await result.current.thread.append({
123
+ role: "user",
124
+ content: [{ type: "text", text: "second" }],
125
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
126
+ });
127
+ });
128
+ await waitFor(() =>
129
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
130
+ );
131
+ rerender({ isSendDisabled: true });
132
+
133
+ await act(async () => {
134
+ release();
135
+ });
136
+ await waitFor(() =>
137
+ expect(result.current.thread.getState().isRunning).toBe(false),
138
+ );
139
+ expect(runAgent).toHaveBeenCalledTimes(1);
140
+ expect(screen.getByTestId("queued").textContent).toBe("second");
141
+
142
+ rerender({ isSendDisabled: false });
143
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
144
+ await waitFor(() =>
145
+ expect(screen.getByTestId("queued").textContent).toBe(""),
146
+ );
147
+ });
148
+
99
149
  it("preserves queued sends when the active run is cancelled", async () => {
100
150
  const { agent, runAgent, release } = gatedAgent();
101
151
 
102
- const { result } = renderHook(() =>
103
- useAgUiRuntime({ agent, unstable_enableMessageQueue: true }),
152
+ const { result, rerender } = renderHook(
153
+ ({ isSendDisabled }) =>
154
+ useAgUiRuntime({
155
+ agent,
156
+ unstable_enableMessageQueue: true,
157
+ isSendDisabled,
158
+ }),
159
+ { initialProps: { isSendDisabled: false } },
104
160
  );
105
161
  mount(result.current);
106
162
 
@@ -135,6 +191,11 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
135
191
  expect(screen.getByTestId("queued").textContent).toBe("second"),
136
192
  );
137
193
 
194
+ rerender({ isSendDisabled: true });
195
+ rerender({ isSendDisabled: false });
196
+ expect(runAgent).toHaveBeenCalledTimes(1);
197
+ expect(screen.getByTestId("queued").textContent).toBe("second");
198
+
138
199
  await act(async () => {
139
200
  await result.current.thread.append({
140
201
  role: "user",