@assistant-ui/react-ag-ui 0.1.0 → 0.1.2

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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/agUiExtras.js +1 -2
  3. package/dist/hooks.js +1 -2
  4. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  5. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  6. package/dist/runtime/AgUiThreadController.js +565 -89
  7. package/dist/runtime/AgUiThreadController.js.map +1 -1
  8. package/dist/runtime/adapter/conversions.d.ts +9 -1
  9. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  10. package/dist/runtime/adapter/conversions.js +194 -12
  11. package/dist/runtime/adapter/conversions.js.map +1 -1
  12. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  13. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  14. package/dist/runtime/adapter/replay-content.js +185 -0
  15. package/dist/runtime/adapter/replay-content.js.map +1 -0
  16. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  17. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.js +35 -14
  19. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  20. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  21. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  22. package/dist/runtime/adapter/subscriber.js +2 -10
  23. package/dist/runtime/adapter/subscriber.js.map +1 -1
  24. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  25. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  26. package/dist/runtime/adapter/tool-approval.js +23 -6
  27. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  28. package/dist/runtime/agUiThreadState.d.ts +1 -1
  29. package/dist/runtime/agUiThreadState.d.ts.map +1 -1
  30. package/dist/runtime/agUiThreadState.js +3 -4
  31. package/dist/runtime/agUiThreadState.js.map +1 -1
  32. package/dist/runtime/event-parser.d.ts.map +1 -1
  33. package/dist/runtime/event-parser.js +2 -2
  34. package/dist/runtime/event-parser.js.map +1 -1
  35. package/dist/runtime/interrupt-internals.js +1 -2
  36. package/dist/runtime/logger.js +1 -2
  37. package/dist/runtime/mcp-tool-result.js +1 -2
  38. package/dist/runtime/types.d.ts +2 -0
  39. package/dist/runtime/types.d.ts.map +1 -1
  40. package/dist/runtime/types.js +3 -3
  41. package/dist/runtime/types.js.map +1 -1
  42. package/dist/useAgUiRuntime.js +1 -2
  43. package/package.json +6 -6
  44. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  45. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  46. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  47. package/src/runtime/AgUiThreadController.replay.test.ts +3939 -0
  48. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
  49. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  50. package/src/runtime/AgUiThreadController.test.ts +119 -0
  51. package/src/runtime/AgUiThreadController.ts +1184 -136
  52. package/src/runtime/adapter/conversions.content.test.ts +242 -1
  53. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  54. package/src/runtime/adapter/conversions.ts +349 -28
  55. package/src/runtime/adapter/replay-content.test.ts +235 -0
  56. package/src/runtime/adapter/replay-content.ts +306 -0
  57. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  58. package/src/runtime/adapter/run-aggregator.ts +52 -9
  59. package/src/runtime/adapter/subscriber.test.ts +1 -1
  60. package/src/runtime/adapter/subscriber.ts +1 -6
  61. package/src/runtime/adapter/tool-approval.ts +41 -3
  62. package/src/runtime/agUiThreadState.test.ts +21 -2
  63. package/src/runtime/agUiThreadState.ts +12 -3
  64. package/src/runtime/event-parser.ts +3 -0
  65. package/src/runtime/types.ts +5 -0
@@ -22,6 +22,7 @@ import {
22
22
  import jsonpatch, { type Operation } from "fast-json-patch";
23
23
  import { readMcpAppResourceUri, toolContentText } from "../mcp-tool-result";
24
24
  import { projectAgUiToolApprovals } from "./tool-approval";
25
+ import { withAgUiPartMessageMetadata } from "./conversions";
25
26
  import {
26
27
  AG_UI_METADATA_NAMESPACE,
27
28
  A2UI_SURFACE_ACTIVITY_TYPE,
@@ -30,15 +31,12 @@ import {
30
31
  type AgUiEvent,
31
32
  type AgUiInterrupt,
32
33
  type AgUiOpaqueReasoning,
34
+ MAX_SUBAGENT_DEPTH,
33
35
  } from "../types";
34
36
  import type { Logger } from "../logger";
35
37
 
36
38
  const ROOT_SCOPE = "";
37
39
 
38
- // Defensive guard against a malformed or cyclic parentToolCallId /
39
- // parentSubagentRunId chain, not a realistic nesting depth.
40
- const MAX_SUBAGENT_DEPTH = 16;
41
-
42
40
  type SubagentRunState = {
43
41
  subagentRunId: string;
44
42
  name: string;
@@ -93,6 +91,7 @@ type ToolCallState = {
93
91
  mcpAppServerId?: string;
94
92
  artifact?: unknown;
95
93
  modelContent?: ToolModelContentPart[];
94
+ resultMetadata?: Record<string, unknown>;
96
95
  snapshotResultApplied: boolean;
97
96
  subagentRunId?: string;
98
97
  };
@@ -209,6 +208,7 @@ export class RunAggregator {
209
208
  private interrupts: AgUiInterrupt[] | undefined;
210
209
  private pendingToolCallIds: string[] | undefined;
211
210
  private readonly textParts = new Map<string, { buffer: string }>();
211
+ private readonly pendingSnapshotText = new Map<string, string>();
212
212
  private readonly activeTextMessageIdByScope = new Map<string, string>();
213
213
  private readonly reasoningParts = new Map<
214
214
  string,
@@ -282,6 +282,20 @@ export class RunAggregator {
282
282
  this.emit();
283
283
  break;
284
284
  }
285
+ case "MESSAGES_SNAPSHOT": {
286
+ this.pendingSnapshotText.clear();
287
+ for (const message of event.messages) {
288
+ if (
289
+ message.role !== "assistant" ||
290
+ typeof message.content !== "string"
291
+ )
292
+ continue;
293
+ const part = this.textParts.get(message.id);
294
+ if (part) part.buffer = message.content;
295
+ else this.pendingSnapshotText.set(message.id, message.content);
296
+ }
297
+ break;
298
+ }
285
299
  case "RUN_FINISHED": {
286
300
  if (event.outcome?.type === "cancelled") {
287
301
  this.interrupts = undefined;
@@ -368,7 +382,7 @@ export class RunAggregator {
368
382
  const incomingId = event.messageId;
369
383
  const scope = this.scopeOf(event);
370
384
  if (scope === ROOT_SCOPE) {
371
- this.beginDistinctTextMessage(incomingId);
385
+ this.beginDistinctTextMessage(incomingId, true);
372
386
  this.reportServerMessageId(incomingId);
373
387
  if (incomingId) this.lastTextMessageId = incomingId;
374
388
  }
@@ -494,6 +508,7 @@ export class RunAggregator {
494
508
  : undefined,
495
509
  event.messageId,
496
510
  event.mcpResult,
511
+ event.metadata,
497
512
  );
498
513
  this.emit();
499
514
  break;
@@ -793,6 +808,7 @@ export class RunAggregator {
793
808
 
794
809
  private resetMessageParts(): void {
795
810
  this.clearTextParts();
811
+ this.pendingSnapshotText.clear();
796
812
  this.reasoningParts.clear();
797
813
  this.reasoningSignatures.clear();
798
814
  this.reasoningSignatureIds.clear();
@@ -837,7 +853,10 @@ export class RunAggregator {
837
853
  return scope === ROOT_SCOPE ? id : `${scope}\u0000${id}`;
838
854
  }
839
855
 
840
- private beginDistinctTextMessage(messageId: string | undefined): void {
856
+ private beginDistinctTextMessage(
857
+ messageId: string | undefined,
858
+ preserveSnapshotText = false,
859
+ ): void {
841
860
  if (
842
861
  this.joinStrategy === "concat-content" ||
843
862
  !messageId ||
@@ -854,7 +873,13 @@ export class RunAggregator {
854
873
  const openToolCallIds = Array.from(this.toolCalls.values())
855
874
  .filter((call) => call.result === undefined)
856
875
  .map((call) => call.toolCallId);
876
+ const snapshotText = preserveSnapshotText
877
+ ? this.pendingSnapshotText.get(messageId)
878
+ : undefined;
857
879
  this.resetMessageParts();
880
+ if (snapshotText !== undefined) {
881
+ this.pendingSnapshotText.set(messageId, snapshotText);
882
+ }
858
883
  this.onTextMessageStart(messageId, openToolCallIds);
859
884
  this.restoreReasoning(carried);
860
885
  }
@@ -946,15 +971,21 @@ export class RunAggregator {
946
971
  private startTextMessage(scope: string, messageId?: string): string {
947
972
  const id = messageId ?? this.generateTextKey();
948
973
  const key = this.ensureTextPart(scope, id);
974
+ if (scope === ROOT_SCOPE && messageId) {
975
+ const snapshotText = this.pendingSnapshotText.get(messageId);
976
+ const part = this.textParts.get(key);
977
+ if (snapshotText !== undefined && part) {
978
+ part.buffer = snapshotText;
979
+ this.pendingSnapshotText.delete(messageId);
980
+ }
981
+ }
949
982
  this.activeTextMessageIdByScope.set(scope, key);
950
983
  return key;
951
984
  }
952
985
 
953
986
  private resolveTextMessageId(scope: string, messageId?: string): string {
954
987
  if (messageId) {
955
- const key = this.ensureTextPart(scope, messageId);
956
- this.activeTextMessageIdByScope.set(scope, key);
957
- return key;
988
+ return this.startTextMessage(scope, messageId);
958
989
  }
959
990
 
960
991
  const active = this.activeTextMessageIdByScope.get(scope);
@@ -1098,6 +1129,7 @@ export class RunAggregator {
1098
1129
  isError?: boolean,
1099
1130
  toolMessageId?: string,
1100
1131
  mcpResult?: Record<string, unknown>,
1132
+ metadata?: Record<string, unknown>,
1101
1133
  ) {
1102
1134
  if (!id) return;
1103
1135
  const modelText = toolContentText(content);
@@ -1149,6 +1181,9 @@ export class RunAggregator {
1149
1181
  if (toolMessageId) {
1150
1182
  entry.toolMessageId = toolMessageId;
1151
1183
  }
1184
+ if (metadata !== undefined) {
1185
+ entry.resultMetadata = { ...entry.resultMetadata, ...metadata };
1186
+ }
1152
1187
  this.lastResolvedToolCallIdByScope.set(scope, id);
1153
1188
  }
1154
1189
 
@@ -1299,6 +1334,14 @@ export class RunAggregator {
1299
1334
  ...(entry.toolMessageId
1300
1335
  ? { unstable_toolMessageId: entry.toolMessageId }
1301
1336
  : {}),
1337
+ ...(entry.resultMetadata !== undefined
1338
+ ? {
1339
+ providerMetadata: withAgUiPartMessageMetadata(
1340
+ undefined,
1341
+ entry.resultMetadata,
1342
+ ),
1343
+ }
1344
+ : {}),
1302
1345
  ...(nestedMessages.length > 0 ? { messages: nestedMessages } : {}),
1303
1346
  } as ToolCallMessagePart & { unstable_toolMessageId?: string };
1304
1347
  snapshot.push(toolPart);
@@ -4,7 +4,7 @@ import { createAgUiSubscriber } from "./subscriber";
4
4
  const createSubscriber = () => {
5
5
  const dispatch = vi.fn();
6
6
  const logger = { debug: vi.fn(), error: vi.fn() };
7
- const subscriber = createAgUiSubscriber({ dispatch, runId: "run-1", logger });
7
+ const subscriber = createAgUiSubscriber({ dispatch, logger });
8
8
  return { subscriber, dispatch, logger };
9
9
  };
10
10
 
@@ -60,7 +60,6 @@ const ensureEvent = (
60
60
 
61
61
  type SubscriberOptions = {
62
62
  dispatch: Dispatch;
63
- runId: string;
64
63
  logger?: Logger;
65
64
  onRunFailed?: (error: Error) => void;
66
65
  };
@@ -68,7 +67,7 @@ type SubscriberOptions = {
68
67
  export const createAgUiSubscriber = (
69
68
  options: SubscriberOptions,
70
69
  ): Subscriber => {
71
- const { dispatch, runId, logger, onRunFailed } = options;
70
+ const { dispatch, logger, onRunFailed } = options;
72
71
  let runFinishedDispatched = false;
73
72
  const dispatchIfValid = (raw: unknown, type: AgUiEvent["type"]) => {
74
73
  const event = ensureEvent(raw, type, logger);
@@ -140,10 +139,6 @@ export const createAgUiSubscriber = (
140
139
  logger?.debug?.("[agui] step boundary ignored", event),
141
140
  onStepFinishedEvent: ({ event }) =>
142
141
  logger?.debug?.("[agui] step boundary ignored", event),
143
- onRunFinalized: () => {
144
- if (runFinishedDispatched) return;
145
- dispatch({ type: "RUN_FINISHED", runId });
146
- },
147
142
  onRunFailed: ({ error }) => {
148
143
  if (runFinishedDispatched) return;
149
144
  runFinishedDispatched = true;
@@ -228,6 +228,40 @@ export const projectAgUiToolApprovals = (
228
228
  );
229
229
  };
230
230
 
231
+ /** A recorded decision authorizes one exact call and gate, not a reused id. */
232
+ export const preserveToolApprovalDecision = (
233
+ previous: ToolCallMessagePart | undefined,
234
+ next: ToolCallMessagePart,
235
+ ): ToolApproval | undefined => {
236
+ const prior = previous?.approval;
237
+ let approval = next.approval;
238
+ if (!approval && prior) {
239
+ const {
240
+ approved: _approved,
241
+ reason: _reason,
242
+ resolution: _resolution,
243
+ ...pending
244
+ } = prior;
245
+ approval = pending;
246
+ }
247
+ if (!approval) return undefined;
248
+ // AG-UI may omit argument events for a no-argument call; both forms mean {}.
249
+ if (
250
+ !previous ||
251
+ prior?.id !== approval.id ||
252
+ previous.toolCallId !== next.toolCallId ||
253
+ previous.toolName !== next.toolName ||
254
+ (previous.argsText || "{}") !== (next.argsText || "{}")
255
+ )
256
+ return approval;
257
+ return {
258
+ ...approval,
259
+ ...(prior.approved !== undefined ? { approved: prior.approved } : {}),
260
+ ...(prior.reason !== undefined ? { reason: prior.reason } : {}),
261
+ ...(prior.resolution !== undefined ? { resolution: prior.resolution } : {}),
262
+ };
263
+ };
264
+
231
265
  /**
232
266
  * Records a decision on the gated tool call. The decision is held on the part
233
267
  * until every gate in the batch is answered, because AG-UI resumes a run with
@@ -266,16 +300,20 @@ export const buildToolApprovalResume = (
266
300
  content: readonly ThreadAssistantMessagePart[],
267
301
  interrupts: readonly AgUiInterrupt[],
268
302
  ): AgUiResumeEntry[] | null => {
269
- const open = new Set(interrupts.map((interrupt) => interrupt.id));
303
+ const calls = Array.from(iterateToolCallParts(content));
304
+ const gates = projectAgUiToolApprovals(
305
+ interrupts,
306
+ new Set(calls.map((part) => part.toolCallId)),
307
+ );
270
308
  // Decisions accumulate in a `Map` rather than in an object literal, because an
271
309
  // interrupt id of `__proto__` would otherwise write to the prototype chain
272
310
  // instead of recording a response, and `constructor` or `toString` would
273
311
  // report themselves as answered while undecided.
274
312
  const responses = new Map<string, { status: "resolved"; payload: unknown }>();
275
- for (const part of iterateToolCallParts(content)) {
313
+ for (const part of calls) {
276
314
  const approval = part.approval;
277
315
  if (!approval || approval.approved === undefined) continue;
278
- if (!open.has(approval.id)) continue;
316
+ if (gates.get(part.toolCallId)?.id !== approval.id) continue;
279
317
  responses.set(approval.id, {
280
318
  status: "resolved",
281
319
  payload: {
@@ -115,7 +115,10 @@ describe("reduceAgUiThreadState", () => {
115
115
  owner,
116
116
  messageId: "assistant",
117
117
  });
118
- expect(withResume.pendingResume).toEqual({ owner, messageId: "assistant" });
118
+ expect(withResume.pendingResume).toEqual({
119
+ owner,
120
+ messageIds: ["assistant"],
121
+ });
119
122
  expect(
120
123
  reduceAgUiThreadState(withResume, {
121
124
  type: "resumeDeferred",
@@ -123,6 +126,22 @@ describe("reduceAgUiThreadState", () => {
123
126
  messageId: "assistant",
124
127
  }),
125
128
  ).toBe(withResume);
129
+ const withTwoOwners = reduceAgUiThreadState(withResume, {
130
+ type: "resumeDeferred",
131
+ owner,
132
+ messageId: "assistant-next",
133
+ });
134
+ expect(withTwoOwners.pendingResume).toEqual({
135
+ owner,
136
+ messageIds: ["assistant", "assistant-next"],
137
+ });
138
+ expect(
139
+ reduceAgUiThreadState(withTwoOwners, {
140
+ type: "resumeDeferred",
141
+ owner: new AbortController(),
142
+ messageId: "new-run",
143
+ }).pendingResume?.messageIds,
144
+ ).toEqual(["new-run"]);
126
145
  const withOwner = reduceAgUiThreadState(withResume, {
127
146
  type: "a2uiResumeDeferred",
128
147
  owner,
@@ -189,7 +208,7 @@ describe("reduceAgUiThreadState", () => {
189
208
  });
190
209
  expect(cleared.pendingA2uiResumeOwner).toBeNull();
191
210
  expect(cleared.pendingA2uiAction).toBeUndefined();
192
- expect(cleared.pendingResume).toEqual({ owner, messageId: "assistant" });
211
+ expect(cleared.pendingResume).toEqual({ owner, messageIds: ["assistant"] });
193
212
  expect(
194
213
  reduceAgUiThreadState(cleared, { type: "a2uiContinuationsCleared" }),
195
214
  ).toBe(cleared);
@@ -8,7 +8,10 @@ export type AgUiThreadState = {
8
8
  historyMutationGeneration: number;
9
9
  stateSnapshot: ReadonlyJSONValue | undefined;
10
10
  lastRunConfig: RunConfig | undefined;
11
- pendingResume: { owner: AbortController; messageId: string } | null;
11
+ pendingResume: {
12
+ owner: AbortController;
13
+ messageIds: readonly string[];
14
+ } | null;
12
15
  pendingA2uiResumeOwner: AbortController | null;
13
16
  pendingA2uiAction: Record<string, unknown> | undefined;
14
17
  };
@@ -78,11 +81,17 @@ export const reduceAgUiThreadState = (
78
81
  : { ...state, lastRunConfig: action.runConfig };
79
82
  case "resumeDeferred":
80
83
  return state.pendingResume?.owner === action.owner &&
81
- state.pendingResume.messageId === action.messageId
84
+ state.pendingResume.messageIds.includes(action.messageId)
82
85
  ? state
83
86
  : {
84
87
  ...state,
85
- pendingResume: { owner: action.owner, messageId: action.messageId },
88
+ pendingResume: {
89
+ owner: action.owner,
90
+ messageIds:
91
+ state.pendingResume?.owner === action.owner
92
+ ? [...state.pendingResume.messageIds, action.messageId]
93
+ : [action.messageId],
94
+ },
86
95
  };
87
96
  case "resumeCleared":
88
97
  return state.pendingResume === null
@@ -344,6 +344,9 @@ export const parseAgUiEvent = (
344
344
  messageId: getString("messageId"),
345
345
  role: payload.role === "tool" ? "tool" : undefined,
346
346
  mcpResult: parseMcpToolCallResult(payload, content),
347
+ metadata: isPlainObject(payload.metadata)
348
+ ? payload.metadata
349
+ : undefined,
347
350
  subagentRunId: getString("subagentRunId"),
348
351
  },
349
352
  );
@@ -26,6 +26,10 @@ export const AG_UI_METADATA_NAMESPACE = "agui";
26
26
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
27
27
  export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
28
28
 
29
+ // Defensive guard against a malformed or cyclic parentToolCallId /
30
+ // parentSubagentRunId chain, not a realistic nesting depth.
31
+ export const MAX_SUBAGENT_DEPTH = 16;
32
+
29
33
  export type AgUiOpaqueReasoning = {
30
34
  id: string;
31
35
  encryptedValue: string;
@@ -278,6 +282,7 @@ export type AgUiEvent =
278
282
  content: string | ContentPart[];
279
283
  role?: "tool";
280
284
  mcpResult?: McpToolCallResult;
285
+ metadata?: Record<string, unknown>;
281
286
  subagentRunId?: string;
282
287
  }
283
288
  | {