@assistant-ui/react-ag-ui 0.0.57 → 0.0.58

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 (40) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +3 -4
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +29 -43
  5. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +34 -11
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +326 -119
  12. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  13. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  14. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  15. package/dist/runtime/adapter/subscriber.js +4 -1
  16. package/dist/runtime/adapter/subscriber.js.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.js +11 -12
  20. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  21. package/dist/runtime/event-parser.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.js +176 -45
  23. package/dist/runtime/event-parser.js.map +1 -1
  24. package/dist/runtime/types.d.ts +42 -1
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +1 -0
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +9 -9
  29. package/src/runtime/AgUiThreadRuntimeCore.ts +40 -60
  30. package/src/runtime/adapter/conversions.ts +87 -17
  31. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  32. package/src/runtime/adapter/run-aggregator.ts +510 -139
  33. package/src/runtime/adapter/subscriber.ts +10 -1
  34. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  35. package/src/runtime/adapter/tool-approval.ts +17 -12
  36. package/src/runtime/event-parser.ts +216 -36
  37. package/src/runtime/types.ts +70 -12
  38. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  39. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  40. package/src/useAgUiRuntime.ts +1 -0
@@ -3,8 +3,10 @@
3
3
  import {
4
4
  isMcpAppUri,
5
5
  type ChatModelRunResult,
6
+ type MessageStatus,
6
7
  type MessageTiming,
7
8
  type ThreadAssistantMessagePart,
9
+ type ThreadMessage,
8
10
  type ToolCallMessagePart,
9
11
  type ToolModelContentPart,
10
12
  } from "@assistant-ui/core";
@@ -21,6 +23,50 @@ import type { Logger } from "../logger";
21
23
 
22
24
  export const AG_UI_METADATA_NAMESPACE = "agui";
23
25
 
26
+ const ROOT_SCOPE = "";
27
+
28
+ // Defensive guard against a malformed or cyclic parentToolCallId /
29
+ // parentSubagentRunId chain, not a realistic nesting depth.
30
+ const MAX_SUBAGENT_DEPTH = 16;
31
+
32
+ type SubagentRunState = {
33
+ subagentRunId: string;
34
+ name: string;
35
+ description?: string;
36
+ parentSubagentRunId?: string;
37
+ parentToolCallId?: string;
38
+ parentMessageId?: string;
39
+ createdAt: Date;
40
+ status: MessageStatus;
41
+ result?: unknown;
42
+ interruptIds?: string[];
43
+ errorCode?: string;
44
+ };
45
+
46
+ type ToolApproval = NonNullable<ToolCallMessagePart["approval"]>;
47
+
48
+ type PartOrderEntry =
49
+ | { kind: "text"; key: string; subagentRunId?: string }
50
+ | { kind: "reasoning"; key: string; subagentRunId?: string }
51
+ | { kind: "tool-call"; toolCallId: string }
52
+ | { kind: "data"; name: string; value: unknown };
53
+
54
+ type BuildContext = {
55
+ subagentsByParentToolCallId: Map<string, string[]>;
56
+ reachable: Set<string>;
57
+ approvals: ReadonlyMap<string, ToolApproval>;
58
+ // partOrder bucketed by the scope each part renders in, so one emit walks
59
+ // every part once however many subagents the run spawned.
60
+ partsByScope: Map<string, { index: number; part: PartOrderEntry }[]>;
61
+ // Present only for the root walk: the pass that builds the snapshot is also
62
+ // the one that decides where a retracted reasoning signature anchors, so the
63
+ // two can never disagree about what materialized.
64
+ root?: {
65
+ opaqueCandidates: (AgUiOpaqueReasoning & { anchor: number })[];
66
+ lastMaterializedIndex: number;
67
+ };
68
+ };
69
+
24
70
  export type AgUiOpaqueReasoning = {
25
71
  id: string;
26
72
  encryptedValue: string;
@@ -50,6 +96,7 @@ type ToolCallState = {
50
96
  mcpAppServerId?: string;
51
97
  modelContent?: ToolModelContentPart[];
52
98
  snapshotResultApplied: boolean;
99
+ subagentRunId?: string;
53
100
  };
54
101
 
55
102
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
@@ -91,7 +138,7 @@ export class RunAggregator {
91
138
  private status: ChatModelRunResult["status"] | undefined;
92
139
  private interrupts: AgUiInterrupt[] | undefined;
93
140
  private readonly textParts = new Map<string, { buffer: string }>();
94
- private activeTextMessageId: string | undefined;
141
+ private readonly activeTextMessageIdByScope = new Map<string, string>();
95
142
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
96
143
  private readonly reasoningSignatures = new Map<string, string>();
97
144
  private readonly reasoningSignatureIds = new Map<string, string>();
@@ -109,18 +156,23 @@ export class RunAggregator {
109
156
  private readonly loggedDroppedOpaqueIds = new Set<string>();
110
157
  private readonly reasoningMessageIds = new Map<string, string>();
111
158
  private readonly anonymousReasoningKeys = new Set<string>();
112
- private activeReasoningKey: string | undefined;
159
+ private readonly activeReasoningKeyByScope = new Map<string, string>();
160
+ private readonly subagentRuns = new Map<string, SubagentRunState>();
161
+ // The nesting graph only moves when a run is announced or a tool call is
162
+ // created, which is rare next to emit(), so it is derived once per change
163
+ // rather than per streamed delta.
164
+ private nestingMemo:
165
+ | {
166
+ subagentsByParentToolCallId: Map<string, string[]>;
167
+ reachable: Set<string>;
168
+ }
169
+ | undefined;
113
170
  private reasoningPartCounter = 0;
114
171
  private readonly toolCalls = new Map<string, ToolCallState>();
115
172
  private readonly a2uiBuckets = new Map<string, A2uiState>();
116
173
  private readonly a2uiToolCallIds = new Set<string>();
117
- private lastResolvedToolCallId: string | undefined;
118
- private readonly partOrder: (
119
- | { kind: "text"; key: string }
120
- | { kind: "reasoning"; key: string }
121
- | { kind: "tool-call"; toolCallId: string }
122
- | { kind: "data"; name: string; value: unknown }
123
- )[] = [];
174
+ private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
175
+ private readonly partOrder: PartOrderEntry[] = [];
124
176
  private textPartCounter = 0;
125
177
  private serverMessageIdReported = false;
126
178
  private reportedServerMessageId: string | undefined;
@@ -166,14 +218,18 @@ export class RunAggregator {
166
218
  }
167
219
 
168
220
  this.interrupts = undefined;
221
+ // A call nested inside a subagent message is answerable the same way
222
+ // a root call is: getPendingToolCalls and addToolResult walk
223
+ // ToolCallMessagePart.messages, so any unresolved call keeps the run
224
+ // resumable.
169
225
  const hasUnresolvedToolCalls = Array.from(this.toolCalls.values()).some(
170
226
  (tc) => tc.result === undefined,
171
227
  );
172
228
 
173
- this.status =
174
- event.outcome?.type === "success" || !hasUnresolvedToolCalls
175
- ? { type: "complete", reason: "unknown" }
176
- : { type: "requires-action", reason: "tool-calls" };
229
+ this.status = hasUnresolvedToolCalls
230
+ ? { type: "requires-action", reason: "tool-calls" }
231
+ : { type: "complete", reason: "unknown" };
232
+ this.closeOpenSubagentRuns(this.status);
177
233
  this.emit();
178
234
  break;
179
235
  }
@@ -183,48 +239,61 @@ export class RunAggregator {
183
239
  reason: "error",
184
240
  ...(event.message !== undefined ? { error: event.message } : {}),
185
241
  };
242
+ this.closeOpenSubagentRuns(this.status);
186
243
  this.emit();
187
244
  break;
188
245
  }
189
246
  case "RUN_CANCELLED": {
190
247
  this.status = { type: "incomplete", reason: "cancelled" };
248
+ this.closeOpenSubagentRuns(this.status);
191
249
  this.emit();
192
250
  break;
193
251
  }
194
252
 
195
253
  case "TEXT_MESSAGE_START": {
196
- this.beginDistinctTextMessage(event.messageId);
197
- this.reportServerMessageId(event.messageId);
198
- this.startTextMessage(event.messageId);
199
- if (event.messageId) this.lastTextMessageId = event.messageId;
254
+ const scope = this.scopeOf(event);
255
+ if (scope === ROOT_SCOPE) {
256
+ this.beginDistinctTextMessage(event.messageId);
257
+ this.reportServerMessageId(event.messageId);
258
+ if (event.messageId) this.lastTextMessageId = event.messageId;
259
+ }
260
+ this.startTextMessage(scope, event.messageId);
200
261
  this.emit();
201
262
  break;
202
263
  }
203
264
  case "TEXT_MESSAGE_CONTENT":
204
265
  case "TEXT_MESSAGE_CHUNK": {
205
266
  const incomingId = "messageId" in event ? event.messageId : undefined;
206
- this.beginDistinctTextMessage(incomingId);
207
- this.reportServerMessageId(incomingId);
208
- if (incomingId) this.lastTextMessageId = incomingId;
267
+ const scope = this.scopeOf(event);
268
+ if (scope === ROOT_SCOPE) {
269
+ this.beginDistinctTextMessage(incomingId);
270
+ this.reportServerMessageId(incomingId);
271
+ if (incomingId) this.lastTextMessageId = incomingId;
272
+ }
209
273
  if (!event.delta) break;
210
274
  this.recordFirstToken();
211
- const id = this.resolveTextMessageId(incomingId);
275
+ const id = this.resolveTextMessageId(scope, incomingId);
212
276
  this.appendText(id, event.delta);
213
277
  this.totalChunks++;
214
278
  this.emit();
215
279
  break;
216
280
  }
217
281
  case "TEXT_MESSAGE_END": {
218
- this.reportServerMessageId(event.messageId);
219
- if (event.messageId && this.activeTextMessageId === event.messageId) {
220
- this.activeTextMessageId = undefined;
282
+ const scope = this.scopeOf(event);
283
+ if (scope === ROOT_SCOPE) this.reportServerMessageId(event.messageId);
284
+ if (
285
+ event.messageId &&
286
+ this.activeTextMessageIdByScope.get(scope) ===
287
+ this.partKey(scope, event.messageId)
288
+ ) {
289
+ this.activeTextMessageIdByScope.delete(scope);
221
290
  }
222
291
  this.emit();
223
292
  break;
224
293
  }
225
294
 
226
295
  case "CUSTOM": {
227
- this.activeTextMessageId = undefined;
296
+ this.activeTextMessageIdByScope.delete(ROOT_SCOPE);
228
297
  this.partOrder.push({
229
298
  kind: "data",
230
299
  name: event.name,
@@ -239,18 +308,21 @@ export class RunAggregator {
239
308
  case "REASONING_START":
240
309
  case "REASONING_MESSAGE_START":
241
310
  this.handleReasoningStart(
311
+ this.scopeOf("subagentRunId" in event ? event : {}),
242
312
  "messageId" in event ? event.messageId : undefined,
243
313
  event.type === "REASONING_MESSAGE_START",
244
314
  );
245
315
  break;
246
316
  case "REASONING_ENCRYPTED_VALUE":
247
317
  if (event.subtype === "message") {
318
+ const scope = this.scopeOf(event);
248
319
  // entityId names any message, not necessarily a reasoning one, so an
249
320
  // unmatched id may only claim a block that has no id to contradict it.
250
- const active = this.activeReasoningKey;
321
+ const active = this.activeReasoningKeyByScope.get(scope);
322
+ const entityKey = this.partKey(scope, event.entityId);
251
323
  const key = this.showThinking
252
- ? this.reasoningParts.has(event.entityId)
253
- ? event.entityId
324
+ ? this.reasoningParts.has(entityKey)
325
+ ? entityKey
254
326
  : active !== undefined && this.anonymousReasoningKeys.has(active)
255
327
  ? active
256
328
  : undefined
@@ -261,6 +333,9 @@ export class RunAggregator {
261
333
  this.emit();
262
334
  } else if (
263
335
  !this.showThinking &&
336
+ // Hidden-reasoning bookkeeping stays root-only for this task — a
337
+ // subagent's opaque/hidden reasoning signatures are not preserved.
338
+ scope === ROOT_SCOPE &&
264
339
  event.entityId.trim().length > 0 &&
265
340
  event.encryptedValue.trim().length > 0 &&
266
341
  (this.hiddenReasoningIds.has(event.entityId) ||
@@ -278,12 +353,13 @@ export class RunAggregator {
278
353
  }
279
354
  break;
280
355
  case "THINKING_TEXT_MESSAGE_CONTENT":
281
- this.handleReasoningContent(event.delta);
356
+ this.handleReasoningContent(ROOT_SCOPE, event.delta);
282
357
  this.totalChunks++;
283
358
  this.recordFirstToken();
284
359
  break;
285
360
  case "REASONING_MESSAGE_CONTENT":
286
361
  this.handleReasoningContent(
362
+ this.scopeOf(event),
287
363
  event.delta,
288
364
  "messageId" in event ? event.messageId : undefined,
289
365
  true,
@@ -293,14 +369,20 @@ export class RunAggregator {
293
369
  break;
294
370
  case "THINKING_TEXT_MESSAGE_END":
295
371
  case "THINKING_END":
372
+ this.handleReasoningEnd(ROOT_SCOPE);
373
+ break;
296
374
  case "REASONING_MESSAGE_END":
297
375
  case "REASONING_END":
298
- this.handleReasoningEnd();
376
+ this.handleReasoningEnd(this.scopeOf(event));
299
377
  break;
300
378
 
301
379
  case "TOOL_CALL_START": {
302
- this.reportServerMessageId(event.parentMessageId);
380
+ const scope = this.scopeOf(event);
381
+ if (scope === ROOT_SCOPE) {
382
+ this.reportServerMessageId(event.parentMessageId);
383
+ }
303
384
  this.startToolCall(
385
+ scope,
304
386
  event.toolCallId,
305
387
  event.toolCallName,
306
388
  event.parentMessageId,
@@ -310,7 +392,10 @@ export class RunAggregator {
310
392
  }
311
393
  case "TOOL_CALL_ARGS":
312
394
  case "TOOL_CALL_CHUNK": {
313
- if (event.type === "TOOL_CALL_CHUNK") {
395
+ if (
396
+ event.type === "TOOL_CALL_CHUNK" &&
397
+ this.scopeOf(event) === ROOT_SCOPE
398
+ ) {
314
399
  this.reportServerMessageId(event.parentMessageId);
315
400
  }
316
401
  if (!event.delta) break;
@@ -324,6 +409,7 @@ export class RunAggregator {
324
409
  }
325
410
  case "TOOL_CALL_RESULT": {
326
411
  this.finishToolCall(
412
+ this.scopeOf(event),
327
413
  event.toolCallId,
328
414
  event.content ?? "",
329
415
  typeof event.mcpResult?.isError === "boolean"
@@ -343,12 +429,15 @@ export class RunAggregator {
343
429
  break;
344
430
  }
345
431
  if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
432
+ const activityScope = this.scopeOf(event);
346
433
  const toolCallId = event.content.toolCallId;
434
+ const fallbackId =
435
+ this.lastResolvedToolCallIdByScope.get(activityScope);
347
436
  const entry =
348
437
  typeof toolCallId === "string"
349
438
  ? this.toolCalls.get(toolCallId)
350
- : this.lastResolvedToolCallId
351
- ? this.toolCalls.get(this.lastResolvedToolCallId)
439
+ : fallbackId
440
+ ? this.toolCalls.get(fallbackId)
352
441
  : undefined;
353
442
  const resourceUri = event.content.resourceUri;
354
443
  if (
@@ -391,6 +480,70 @@ export class RunAggregator {
391
480
  break;
392
481
  }
393
482
 
483
+ case "SUBAGENT_STARTED": {
484
+ // A suspended subagent is re-announced with the same id on resume, so
485
+ // a repeat is a continuation: it may refresh the descriptive fields
486
+ // and reopen the status, but re-parenting an established run would
487
+ // move its already-rendered output to a different tool call.
488
+ const existing = this.subagentRuns.get(event.subagentRunId);
489
+ if (existing) {
490
+ existing.name = event.name;
491
+ if (event.description !== undefined)
492
+ existing.description = event.description;
493
+ existing.status = { type: "running" };
494
+ this.emit();
495
+ break;
496
+ }
497
+ this.nestingMemo = undefined;
498
+ this.subagentRuns.set(event.subagentRunId, {
499
+ subagentRunId: event.subagentRunId,
500
+ name: event.name,
501
+ ...(event.description !== undefined
502
+ ? { description: event.description }
503
+ : {}),
504
+ ...(event.parentSubagentRunId !== undefined
505
+ ? { parentSubagentRunId: event.parentSubagentRunId }
506
+ : {}),
507
+ ...(event.parentToolCallId !== undefined
508
+ ? { parentToolCallId: event.parentToolCallId }
509
+ : {}),
510
+ ...(event.parentMessageId !== undefined
511
+ ? { parentMessageId: event.parentMessageId }
512
+ : {}),
513
+ createdAt: new Date(),
514
+ status: { type: "running" },
515
+ });
516
+ this.emit();
517
+ break;
518
+ }
519
+ case "SUBAGENT_FINISHED": {
520
+ const run = this.subagentRuns.get(event.subagentRunId);
521
+ if (run) {
522
+ run.status =
523
+ event.outcome?.type === "suspended"
524
+ ? { type: "requires-action", reason: "interrupt" }
525
+ : { type: "complete", reason: "unknown" };
526
+ if (event.result !== undefined) run.result = event.result;
527
+ if (event.outcome?.type === "suspended" && event.outcome.interruptIds)
528
+ run.interruptIds = event.outcome.interruptIds;
529
+ }
530
+ this.emit();
531
+ break;
532
+ }
533
+ case "SUBAGENT_ERROR": {
534
+ const run = this.subagentRuns.get(event.subagentRunId);
535
+ if (run) {
536
+ run.status = {
537
+ type: "incomplete",
538
+ reason: "error",
539
+ ...(event.message !== undefined ? { error: event.message } : {}),
540
+ };
541
+ if (event.code !== undefined) run.errorCode = event.code;
542
+ }
543
+ this.emit();
544
+ break;
545
+ }
546
+
394
547
  default: {
395
548
  this.logger.debug?.("[agui] aggregator ignored event", event);
396
549
  }
@@ -448,12 +601,14 @@ export class RunAggregator {
448
601
  if (!this.toolCalls.has(toolCallId)) {
449
602
  this.partOrder.push({ kind: "tool-call", toolCallId });
450
603
  }
604
+ this.nestingMemo = undefined;
451
605
  this.toolCalls.set(toolCallId, entry);
452
606
  this.a2uiToolCallIds.add(toolCallId);
453
607
  }
454
608
 
455
609
  for (const toolCallId of this.a2uiToolCallIds) {
456
610
  if (activeToolCallIds.has(toolCallId)) continue;
611
+ this.nestingMemo = undefined;
457
612
  this.toolCalls.delete(toolCallId);
458
613
  const partIndex = this.partOrder.findIndex(
459
614
  (part) => part.kind === "tool-call" && part.toolCallId === toolCallId,
@@ -492,18 +647,39 @@ export class RunAggregator {
492
647
  this.hiddenActiveReasoning = "none";
493
648
  this.loggedDroppedOpaqueIds.clear();
494
649
  this.anonymousReasoningKeys.clear();
495
- this.activeReasoningKey = undefined;
650
+ this.activeReasoningKeyByScope.clear();
651
+ this.nestingMemo = undefined;
652
+ this.subagentRuns.clear();
496
653
  this.reasoningPartCounter = 0;
497
654
  this.toolCalls.clear();
498
655
  this.a2uiBuckets.clear();
499
656
  this.a2uiToolCallIds.clear();
500
- this.lastResolvedToolCallId = undefined;
657
+ this.lastResolvedToolCallIdByScope.clear();
501
658
  this.partOrder.length = 0;
502
659
  this.textPartCounter = 0;
503
- this.activeTextMessageId = undefined;
660
+ this.activeTextMessageIdByScope.clear();
504
661
  this.reportedServerMessageId = undefined;
505
662
  }
506
663
 
664
+ // A subagent only ever reports its own terminal, so a run that ends while
665
+ // one is still streaming would leave that nested message spinning forever.
666
+ private closeOpenSubagentRuns(status: MessageStatus): void {
667
+ for (const run of this.subagentRuns.values()) {
668
+ if (run.status.type === "running") run.status = status;
669
+ }
670
+ }
671
+
672
+ private scopeOf(event: { subagentRunId?: string }): string {
673
+ return event.subagentRunId ?? ROOT_SCOPE;
674
+ }
675
+
676
+ // Nothing in the AG-UI schema makes a messageId unique across subagent runs,
677
+ // so the accumulators key on the scope too. Root keys stay bare, which is
678
+ // what the wire-id collision checks against reported message ids compare on.
679
+ private partKey(scope: string, id: string): string {
680
+ return scope === ROOT_SCOPE ? id : `${scope}\u0000${id}`;
681
+ }
682
+
507
683
  private beginDistinctTextMessage(messageId: string | undefined): void {
508
684
  if (
509
685
  !messageId ||
@@ -522,63 +698,71 @@ export class RunAggregator {
522
698
  return `text-${this.textPartCounter}`;
523
699
  }
524
700
 
525
- private startTextMessage(messageId?: string): string {
701
+ private startTextMessage(scope: string, messageId?: string): string {
526
702
  const id = messageId ?? this.generateTextKey();
527
- this.ensureTextPart(id);
528
- this.activeTextMessageId = id;
529
- return id;
703
+ const key = this.ensureTextPart(scope, id);
704
+ this.activeTextMessageIdByScope.set(scope, key);
705
+ return key;
530
706
  }
531
707
 
532
- private resolveTextMessageId(messageId?: string): string {
708
+ private resolveTextMessageId(scope: string, messageId?: string): string {
533
709
  if (messageId) {
534
- this.ensureTextPart(messageId);
535
- this.activeTextMessageId = messageId;
536
- return messageId;
710
+ const key = this.ensureTextPart(scope, messageId);
711
+ this.activeTextMessageIdByScope.set(scope, key);
712
+ return key;
537
713
  }
538
714
 
539
- if (this.activeTextMessageId) {
540
- return this.activeTextMessageId;
715
+ const active = this.activeTextMessageIdByScope.get(scope);
716
+ if (active) {
717
+ return active;
541
718
  }
542
719
 
543
- const generated = this.generateTextKey();
544
- this.ensureTextPart(generated);
545
- this.activeTextMessageId = generated;
546
- return generated;
720
+ const key = this.ensureTextPart(scope, this.generateTextKey());
721
+ this.activeTextMessageIdByScope.set(scope, key);
722
+ return key;
547
723
  }
548
724
 
549
- private ensureTextPart(id: string): void {
550
- if (!this.textParts.has(id)) {
551
- this.textParts.set(id, { buffer: "" });
725
+ private ensureTextPart(scope: string, id: string): string {
726
+ const key = this.partKey(scope, id);
727
+ if (!this.textParts.has(key)) {
728
+ this.textParts.set(key, { buffer: "" });
552
729
  if (
553
- !this.partOrder.some((part) => part.kind === "text" && part.key === id)
730
+ !this.partOrder.some((part) => part.kind === "text" && part.key === key)
554
731
  ) {
555
- this.partOrder.push({ kind: "text", key: id });
732
+ this.partOrder.push(
733
+ scope === ROOT_SCOPE
734
+ ? { kind: "text", key }
735
+ : { kind: "text", key, subagentRunId: scope },
736
+ );
556
737
  }
557
738
  }
739
+ return key;
558
740
  }
559
741
 
560
742
  private appendText(id: string, delta: string): void {
561
- this.ensureTextPart(id);
562
743
  const entry = this.textParts.get(id);
563
744
  if (!entry) return;
564
745
  entry.buffer += delta;
565
746
  }
566
747
 
567
748
  private startToolCall(
749
+ scope: string,
568
750
  id: string | undefined,
569
751
  name?: string,
570
752
  parentMessageId?: string,
571
753
  ) {
572
754
  if (!id) return;
573
755
  // A new tool call acts as a boundary: any anonymous text that arrives
574
- // after it should be a new part, not appended to the pre-tool text.
575
- this.activeTextMessageId = undefined;
756
+ // after it should be a new part, not appended to the pre-tool text —
757
+ // scoped, so a subagent's tool call doesn't break the root run's text
758
+ // and vice versa.
759
+ this.activeTextMessageIdByScope.delete(scope);
576
760
  if (
577
761
  !this.partOrder.some(
578
762
  (part) => part.kind === "tool-call" && part.toolCallId === id,
579
763
  )
580
764
  ) {
581
- this.insertToolPart(id, parentMessageId);
765
+ this.insertToolPart(scope, id, parentMessageId);
582
766
  }
583
767
  const state: ToolCallState = {
584
768
  toolCallId: id,
@@ -592,16 +776,21 @@ export class RunAggregator {
592
776
  if (parentMessageId) {
593
777
  state.parentMessageId = parentMessageId;
594
778
  }
779
+ if (scope !== ROOT_SCOPE) {
780
+ state.subagentRunId = scope;
781
+ }
782
+ this.nestingMemo = undefined;
595
783
  this.toolCalls.set(id, state);
596
784
  }
597
785
 
598
786
  // The message and tool-call channels are unordered on the wire, so anchor a
599
787
  // tool call under its parentMessageId text part instead of appending it.
600
- private insertToolPart(id: string, parentMessageId?: string) {
788
+ private insertToolPart(scope: string, id: string, parentMessageId?: string) {
601
789
  const entry = { kind: "tool-call", toolCallId: id } as const;
602
790
  if (parentMessageId) {
791
+ const parentKey = this.partKey(scope, parentMessageId);
603
792
  const parentIndex = this.partOrder.findIndex(
604
- (part) => part.kind === "text" && part.key === parentMessageId,
793
+ (part) => part.kind === "text" && part.key === parentKey,
605
794
  );
606
795
  if (parentIndex !== -1) {
607
796
  let insertAt = parentIndex + 1;
@@ -641,6 +830,7 @@ export class RunAggregator {
641
830
  }
642
831
 
643
832
  private finishToolCall(
833
+ scope: string,
644
834
  id: string,
645
835
  content: string,
646
836
  isError?: boolean,
@@ -658,7 +848,9 @@ export class RunAggregator {
658
848
  result: undefined,
659
849
  isError: undefined,
660
850
  snapshotResultApplied: false,
851
+ ...(scope !== ROOT_SCOPE ? { subagentRunId: scope } : {}),
661
852
  };
853
+ this.nestingMemo = undefined;
662
854
  this.toolCalls.set(id, entry);
663
855
  }
664
856
  if (
@@ -691,61 +883,199 @@ export class RunAggregator {
691
883
  if (toolMessageId) {
692
884
  entry.toolMessageId = toolMessageId;
693
885
  }
694
- this.lastResolvedToolCallId = id;
886
+ this.lastResolvedToolCallIdByScope.set(scope, id);
695
887
  }
696
888
 
697
- private emit(): void {
698
- const snapshot: ThreadAssistantMessagePart[] = [];
699
- // A run that ended incomplete can no longer be resumed, so a gate left over
700
- // from an earlier interrupt outcome is unanswerable and must not stay
701
- // projected. The interrupts themselves are kept on the message, since the
702
- // bespoke hooks read that payload.
703
- const approvals = projectAgUiToolApprovals(
704
- this.status?.type === "requires-action" ? this.interrupts : undefined,
705
- new Set(this.toolCalls.keys()),
706
- );
889
+ private nesting(): {
890
+ subagentsByParentToolCallId: Map<string, string[]>;
891
+ reachable: Set<string>;
892
+ } {
893
+ if (!this.nestingMemo) {
894
+ const subagentsByParentToolCallId = this.subagentsByParentToolCallId();
895
+ this.nestingMemo = {
896
+ subagentsByParentToolCallId,
897
+ reachable: this.reachableSubagentRunIds(subagentsByParentToolCallId),
898
+ };
899
+ }
900
+ return this.nestingMemo;
901
+ }
902
+
903
+ private subagentsByParentToolCallId(): Map<string, string[]> {
904
+ const map = new Map<string, string[]>();
905
+ for (const run of this.subagentRuns.values()) {
906
+ if (!run.parentToolCallId) continue;
907
+ const list = map.get(run.parentToolCallId) ?? [];
908
+ list.push(run.subagentRunId);
909
+ map.set(run.parentToolCallId, list);
910
+ }
911
+ return map;
912
+ }
707
913
 
708
- const opaqueCandidates: (AgUiOpaqueReasoning & { anchor: number })[] =
709
- Array.from(this.hiddenSignatures, ([id, encryptedValue]) => ({
710
- id,
711
- encryptedValue,
712
- anchor: this.hiddenSignatureAnchors.get(id)!,
713
- }));
714
-
715
- let currentIndex = -1;
716
- let lastMaterializedIndex = -1;
717
- const pushSnapshotPart = (part: (typeof snapshot)[number]) => {
718
- snapshot.push(part);
719
- lastMaterializedIndex = currentIndex;
914
+ // A subagent nests under its spawning tool call only when that call is itself
915
+ // reachable from the root scope. A run with no parentToolCallId, one naming a
916
+ // call this run never saw, or one in a cycle has nowhere to hang; the visited
917
+ // set also keeps a malformed chain to one visit per run instead of branching
918
+ // at every level.
919
+ private reachableSubagentRunIds(
920
+ subagentsByParentToolCallId: Map<string, string[]>,
921
+ ): Set<string> {
922
+ const reachable = new Set<string>();
923
+ const walk = (scope: string, depth: number): void => {
924
+ if (depth > MAX_SUBAGENT_DEPTH) return;
925
+ for (const entry of this.toolCalls.values()) {
926
+ if ((entry.subagentRunId ?? ROOT_SCOPE) !== scope) continue;
927
+ for (const id of subagentsByParentToolCallId.get(entry.toolCallId) ??
928
+ []) {
929
+ if (reachable.has(id)) continue;
930
+ reachable.add(id);
931
+ walk(id, depth + 1);
932
+ }
933
+ }
720
934
  };
935
+ walk(ROOT_SCOPE, 0);
936
+ return reachable;
937
+ }
938
+
939
+ // Nested messages rather than a discovery hook (the shape react-langchain
940
+ // uses): ToolCallMessagePart.messages is the contract core consumers already
941
+ // walk, and SUBAGENT_STARTED.parentToolCallId names the spawning call
942
+ // outright, so the join needs no namespace scheme of its own.
943
+ private materializeSubagentMessage(
944
+ subagentRunId: string,
945
+ depth: number,
946
+ ctx: BuildContext,
947
+ ): ThreadMessage | undefined {
948
+ const run = this.subagentRuns.get(subagentRunId);
949
+ if (!run || depth > MAX_SUBAGENT_DEPTH) return undefined;
950
+ const content = this.buildParts(subagentRunId, depth + 1, ctx);
951
+ return {
952
+ id: run.subagentRunId,
953
+ role: "assistant",
954
+ createdAt: run.createdAt,
955
+ status: run.status,
956
+ content,
957
+ metadata: {
958
+ unstable_state: null,
959
+ unstable_annotations: [],
960
+ unstable_data: [],
961
+ steps: [],
962
+ custom: {
963
+ [AG_UI_METADATA_NAMESPACE]: {
964
+ name: run.name,
965
+ ...(run.description !== undefined
966
+ ? { description: run.description }
967
+ : {}),
968
+ ...(run.parentSubagentRunId !== undefined
969
+ ? { parentSubagentRunId: run.parentSubagentRunId }
970
+ : {}),
971
+ ...(run.result !== undefined ? { result: run.result } : {}),
972
+ ...(run.interruptIds !== undefined
973
+ ? { interruptIds: run.interruptIds }
974
+ : {}),
975
+ ...(run.errorCode !== undefined
976
+ ? { errorCode: run.errorCode }
977
+ : {}),
978
+ },
979
+ },
980
+ },
981
+ } as ThreadMessage;
982
+ }
983
+
984
+ private buildParts(
985
+ scope: string,
986
+ depth: number,
987
+ ctx: BuildContext,
988
+ ): ThreadAssistantMessagePart[] {
989
+ const snapshot: ThreadAssistantMessagePart[] = [];
990
+ const isRoot = scope === ROOT_SCOPE;
991
+
992
+ for (const { index, part } of ctx.partsByScope.get(scope) ?? []) {
993
+ const materialized = () => {
994
+ if (isRoot && ctx.root) ctx.root.lastMaterializedIndex = index;
995
+ };
996
+
997
+ if (part.kind === "tool-call") {
998
+ const entry = this.toolCalls.get(part.toolCallId);
999
+ if (!entry) continue;
1000
+ const approval = ctx.approvals.get(entry.toolCallId);
1001
+ const nestedMessages = (
1002
+ ctx.subagentsByParentToolCallId.get(entry.toolCallId) ?? []
1003
+ )
1004
+ .filter((id) => ctx.reachable.has(id))
1005
+ .map((id) => this.materializeSubagentMessage(id, depth, ctx))
1006
+ .filter((m): m is ThreadMessage => m !== undefined);
1007
+ const toolPart: ToolCallMessagePart = {
1008
+ type: "tool-call",
1009
+ toolCallId: entry.toolCallId,
1010
+ toolName: entry.toolCallName,
1011
+ args: (entry.parsedArgs ?? {}) as any,
1012
+ argsText: entry.argsText,
1013
+ ...(approval ? { approval } : {}),
1014
+ ...(entry.result !== undefined ? { result: entry.result } : {}),
1015
+ ...(entry.modelContent !== undefined
1016
+ ? { modelContent: entry.modelContent }
1017
+ : {}),
1018
+ ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
1019
+ ...(entry.mcpAppResourceUri
1020
+ ? {
1021
+ mcp: {
1022
+ app: {
1023
+ resourceUri: entry.mcpAppResourceUri,
1024
+ ...(entry.mcpAppServerId
1025
+ ? { serverId: entry.mcpAppServerId }
1026
+ : {}),
1027
+ },
1028
+ },
1029
+ }
1030
+ : {}),
1031
+ ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
1032
+ ...(entry.toolMessageId
1033
+ ? { unstable_toolMessageId: entry.toolMessageId }
1034
+ : {}),
1035
+ ...(nestedMessages.length > 0 ? { messages: nestedMessages } : {}),
1036
+ } as ToolCallMessagePart & { unstable_toolMessageId?: string };
1037
+ snapshot.push(toolPart);
1038
+ materialized();
1039
+ continue;
1040
+ }
1041
+
1042
+ const rawScope =
1043
+ "subagentRunId" in part
1044
+ ? (part.subagentRunId ?? ROOT_SCOPE)
1045
+ : ROOT_SCOPE;
721
1046
 
722
- for (const [index, part] of this.partOrder.entries()) {
723
- currentIndex = index;
724
1047
  if (part.kind === "reasoning") {
725
1048
  if (this.showThinking) {
726
1049
  const buffer = this.reasoningParts.get(part.key) ?? "";
727
- if (buffer.length > 0 || this.activeReasoningKey === part.key) {
1050
+ const isActive =
1051
+ this.activeReasoningKeyByScope.get(rawScope) === part.key;
1052
+ if (buffer.length > 0 || isActive) {
728
1053
  const encryptedValue = this.reasoningSignatures.get(part.key);
729
1054
  const reasoningId = this.reasoningMessageIds.get(part.key);
730
1055
  const meta = {
731
1056
  ...(reasoningId !== undefined ? { reasoningId } : {}),
732
1057
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
733
1058
  };
734
- pushSnapshotPart({
1059
+ snapshot.push({
735
1060
  type: "reasoning",
736
1061
  text: buffer,
737
1062
  ...(Object.keys(meta).length > 0
738
1063
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
739
1064
  : {}),
740
1065
  } as const);
741
- } else {
1066
+ materialized();
1067
+ } else if (isRoot && ctx.root) {
742
1068
  // A retracted empty block still carries transport state: without
743
1069
  // a part to live on, its signature rides the message metadata,
744
1070
  // matching the shape a snapshot reload produces.
745
1071
  const encryptedValue = this.reasoningSignatures.get(part.key);
746
1072
  const id = this.reasoningSignatureIds.get(part.key);
747
1073
  if (id?.trim() && encryptedValue?.trim()) {
748
- opaqueCandidates.push({ id, encryptedValue, anchor: index + 1 });
1074
+ ctx.root.opaqueCandidates.push({
1075
+ id,
1076
+ encryptedValue,
1077
+ anchor: index + 1,
1078
+ });
749
1079
  }
750
1080
  }
751
1081
  }
@@ -755,54 +1085,79 @@ export class RunAggregator {
755
1085
  if (part.kind === "text") {
756
1086
  const entry = this.textParts.get(part.key);
757
1087
  if (entry && entry.buffer.trim().length > 0) {
758
- pushSnapshotPart({ type: "text", text: entry.buffer } as const);
1088
+ snapshot.push({ type: "text", text: entry.buffer } as const);
1089
+ materialized();
759
1090
  }
760
1091
  continue;
761
1092
  }
762
1093
 
763
1094
  if (part.kind === "data") {
764
- pushSnapshotPart({
1095
+ snapshot.push({
765
1096
  type: "data",
766
1097
  name: part.name,
767
1098
  data: part.value,
768
1099
  });
1100
+ materialized();
769
1101
  continue;
770
1102
  }
1103
+ }
771
1104
 
772
- const entry = this.toolCalls.get(part.toolCallId);
773
- if (!entry) continue;
774
- const approval = approvals.get(entry.toolCallId);
775
- const toolPart: ToolCallMessagePart = {
776
- type: "tool-call",
777
- toolCallId: entry.toolCallId,
778
- toolName: entry.toolCallName,
779
- args: (entry.parsedArgs ?? {}) as any,
780
- argsText: entry.argsText,
781
- ...(approval ? { approval } : {}),
782
- ...(entry.result !== undefined ? { result: entry.result } : {}),
783
- ...(entry.modelContent !== undefined
784
- ? { modelContent: entry.modelContent }
785
- : {}),
786
- ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
787
- ...(entry.mcpAppResourceUri
788
- ? {
789
- mcp: {
790
- app: {
791
- resourceUri: entry.mcpAppResourceUri,
792
- ...(entry.mcpAppServerId
793
- ? { serverId: entry.mcpAppServerId }
794
- : {}),
795
- },
796
- },
797
- }
798
- : {}),
799
- ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
800
- ...(entry.toolMessageId
801
- ? { unstable_toolMessageId: entry.toolMessageId }
802
- : {}),
803
- } as ToolCallMessagePart & { unstable_toolMessageId?: string };
804
- pushSnapshotPart(toolPart);
1105
+ return snapshot;
1106
+ }
1107
+
1108
+ private emit(): void {
1109
+ const { subagentsByParentToolCallId, reachable } = this.nesting();
1110
+ const root = {
1111
+ opaqueCandidates: Array.from(
1112
+ this.hiddenSignatures,
1113
+ ([id, encryptedValue]) => ({
1114
+ id,
1115
+ encryptedValue,
1116
+ anchor: this.hiddenSignatureAnchors.get(id)!,
1117
+ }),
1118
+ ) as (AgUiOpaqueReasoning & { anchor: number })[],
1119
+ lastMaterializedIndex: -1,
1120
+ };
1121
+ // A run that ended incomplete can no longer be resumed, so a gate left over
1122
+ // from an earlier interrupt outcome is unanswerable and must not stay
1123
+ // projected. The interrupts themselves are kept on the message, since the
1124
+ // bespoke hooks read that payload. Bound ids cover every call the run
1125
+ // produced, root or nested: the approval seams walk
1126
+ // ToolCallMessagePart.messages, so a gate naming a subagent-scoped call is
1127
+ // answerable and projects onto the nested part.
1128
+ const partsByScope = new Map<
1129
+ string,
1130
+ { index: number; part: PartOrderEntry }[]
1131
+ >();
1132
+ for (const [index, part] of this.partOrder.entries()) {
1133
+ const rawScope =
1134
+ part.kind === "tool-call"
1135
+ ? (this.toolCalls.get(part.toolCallId)?.subagentRunId ?? ROOT_SCOPE)
1136
+ : "subagentRunId" in part
1137
+ ? (part.subagentRunId ?? ROOT_SCOPE)
1138
+ : ROOT_SCOPE;
1139
+ const bucketScope =
1140
+ rawScope === ROOT_SCOPE || reachable.has(rawScope)
1141
+ ? rawScope
1142
+ : ROOT_SCOPE;
1143
+ const bucket = partsByScope.get(bucketScope);
1144
+ if (bucket) bucket.push({ index, part });
1145
+ else partsByScope.set(bucketScope, [{ index, part }]);
805
1146
  }
1147
+ const ctx: BuildContext = {
1148
+ subagentsByParentToolCallId,
1149
+ reachable,
1150
+ partsByScope,
1151
+ approvals: projectAgUiToolApprovals(
1152
+ this.status?.type === "requires-action" ? this.interrupts : undefined,
1153
+ new Set(
1154
+ Array.from(this.toolCalls.values()).map((entry) => entry.toolCallId),
1155
+ ),
1156
+ ),
1157
+ root,
1158
+ };
1159
+ const snapshot = this.buildParts(ROOT_SCOPE, 0, ctx);
1160
+ const { opaqueCandidates, lastMaterializedIndex } = root;
806
1161
 
807
1162
  // An anonymous claim promotes the signature's entityId to a wire message
808
1163
  // id; when that id actually names a text message or the adopted assistant
@@ -906,8 +1261,15 @@ export class RunAggregator {
906
1261
  };
907
1262
  }
908
1263
 
909
- private handleReasoningStart(messageId?: string, isMessageId = false): void {
1264
+ private handleReasoningStart(
1265
+ scope: string,
1266
+ messageId?: string,
1267
+ isMessageId = false,
1268
+ ): void {
910
1269
  if (!this.showThinking) {
1270
+ // Hidden-reasoning bookkeeping stays root-only for this task — a
1271
+ // subagent's opaque/hidden reasoning signatures are not preserved.
1272
+ if (scope !== ROOT_SCOPE) return;
911
1273
  const anchor = this.partOrder.length;
912
1274
  if (messageId === undefined) {
913
1275
  this.hiddenActiveReasoning = "anonymous";
@@ -920,9 +1282,12 @@ export class RunAggregator {
920
1282
  return;
921
1283
  }
922
1284
  // A reasoning block acts as a boundary: anonymous text arriving after it
923
- // should be a new part, not appended to any pre-reasoning text.
924
- this.activeTextMessageId = undefined;
925
- const key = messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`;
1285
+ // should be a new part, not appended to any pre-reasoning text — scoped.
1286
+ this.activeTextMessageIdByScope.delete(scope);
1287
+ const key = this.partKey(
1288
+ scope,
1289
+ messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`,
1290
+ );
926
1291
  // Two different questions: which id may be replayed as a ReasoningMessage.id
927
1292
  // (only the message-scoped aliases carry one), and which block an unmatched
928
1293
  // signature may claim (any block opened without an id at all).
@@ -933,13 +1298,18 @@ export class RunAggregator {
933
1298
  }
934
1299
  if (!this.reasoningParts.has(key)) {
935
1300
  this.reasoningParts.set(key, "");
936
- this.partOrder.push({ kind: "reasoning", key });
1301
+ this.partOrder.push(
1302
+ scope === ROOT_SCOPE
1303
+ ? { kind: "reasoning", key }
1304
+ : { kind: "reasoning", key, subagentRunId: scope },
1305
+ );
937
1306
  }
938
- this.activeReasoningKey = key;
1307
+ this.activeReasoningKeyByScope.set(scope, key);
939
1308
  this.emit();
940
1309
  }
941
1310
 
942
1311
  private handleReasoningContent(
1312
+ scope: string,
943
1313
  delta: string,
944
1314
  messageId?: string,
945
1315
  isMessageId = false,
@@ -948,27 +1318,28 @@ export class RunAggregator {
948
1318
  if (!this.showThinking) {
949
1319
  // Content without a preceding START still names the block (the visible
950
1320
  // path opens it lazily); register it so a signature can claim it.
1321
+ if (scope !== ROOT_SCOPE) return;
951
1322
  if (this.hiddenActiveReasoning === "none") {
952
- this.handleReasoningStart(messageId, isMessageId);
1323
+ this.handleReasoningStart(scope, messageId, isMessageId);
953
1324
  }
954
1325
  return;
955
1326
  }
956
- if (!this.activeReasoningKey) {
1327
+ if (!this.activeReasoningKeyByScope.has(scope)) {
957
1328
  // Content arrived without a preceding START — create the slot lazily.
958
- this.handleReasoningStart(messageId, isMessageId);
1329
+ this.handleReasoningStart(scope, messageId, isMessageId);
959
1330
  }
960
- const key = this.activeReasoningKey;
1331
+ const key = this.activeReasoningKeyByScope.get(scope);
961
1332
  if (!key) return;
962
1333
  this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
963
1334
  this.emit();
964
1335
  }
965
1336
 
966
- private handleReasoningEnd(): void {
1337
+ private handleReasoningEnd(scope: string): void {
967
1338
  if (!this.showThinking) {
968
- this.hiddenActiveReasoning = "none";
1339
+ if (scope === ROOT_SCOPE) this.hiddenActiveReasoning = "none";
969
1340
  return;
970
1341
  }
971
- this.activeReasoningKey = undefined;
1342
+ this.activeReasoningKeyByScope.delete(scope);
972
1343
  this.emit();
973
1344
  }
974
1345
  }