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

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 (46) hide show
  1. package/README.md +16 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +34 -11
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +385 -119
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +4 -1
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  20. package/dist/runtime/adapter/tool-approval.js +11 -12
  21. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +176 -45
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/types.d.ts +67 -1
  26. package/dist/runtime/types.d.ts.map +1 -1
  27. package/dist/useAgUiRuntime.d.ts.map +1 -1
  28. package/dist/useAgUiRuntime.js +15 -5
  29. package/dist/useAgUiRuntime.js.map +1 -1
  30. package/package.json +9 -9
  31. package/src/index.ts +1 -0
  32. package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
  33. package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
  34. package/src/runtime/adapter/conversions.ts +87 -17
  35. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  36. package/src/runtime/adapter/run-aggregator.ts +595 -139
  37. package/src/runtime/adapter/subscriber.ts +10 -1
  38. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  39. package/src/runtime/adapter/tool-approval.ts +17 -12
  40. package/src/runtime/event-parser.ts +216 -36
  41. package/src/runtime/types.ts +96 -12
  42. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  43. package/src/useAgUiRuntime.queue.test.tsx +54 -0
  44. package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
  45. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  46. package/src/useAgUiRuntime.ts +15 -3
@@ -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;
@@ -41,6 +87,7 @@ type ToolCallState = {
41
87
  toolCallId: string;
42
88
  toolCallName: string;
43
89
  argsText: string;
90
+ argsTextScanner?: JSONContainerScanner | undefined;
44
91
  parsedArgs: Record<string, unknown> | undefined;
45
92
  result: unknown;
46
93
  isError: boolean | undefined;
@@ -50,6 +97,81 @@ type ToolCallState = {
50
97
  mcpAppServerId?: string;
51
98
  modelContent?: ToolModelContentPart[];
52
99
  snapshotResultApplied: boolean;
100
+ subagentRunId?: string;
101
+ };
102
+
103
+ type JSONContainerScanner = {
104
+ state: "pending" | "open" | "complete" | "invalid";
105
+ stack: ("{" | "[")[];
106
+ inString: boolean;
107
+ escaped: boolean;
108
+ };
109
+
110
+ const createJSONContainerScanner = (): JSONContainerScanner => ({
111
+ state: "pending",
112
+ stack: [],
113
+ inString: false,
114
+ escaped: false,
115
+ });
116
+
117
+ const isJSONWhitespace = (char: string): boolean =>
118
+ char === " " || char === "\t" || char === "\n" || char === "\r";
119
+
120
+ const scanJSONContainerDelta = (
121
+ scanner: JSONContainerScanner,
122
+ delta: string,
123
+ ): boolean => {
124
+ let completed = false;
125
+
126
+ for (const char of delta) {
127
+ if (scanner.state === "invalid") continue;
128
+ if (scanner.state === "complete") {
129
+ if (isJSONWhitespace(char)) continue;
130
+ scanner.state = "invalid";
131
+ continue;
132
+ }
133
+ if (scanner.state === "pending") {
134
+ if (isJSONWhitespace(char)) continue;
135
+ if (char !== "{" && char !== "[") {
136
+ scanner.state = "invalid";
137
+ continue;
138
+ }
139
+ scanner.state = "open";
140
+ scanner.stack.push(char);
141
+ continue;
142
+ }
143
+ if (scanner.inString) {
144
+ if (scanner.escaped) {
145
+ scanner.escaped = false;
146
+ } else if (char === "\\") {
147
+ scanner.escaped = true;
148
+ } else if (char === '"') {
149
+ scanner.inString = false;
150
+ }
151
+ continue;
152
+ }
153
+ if (char === '"') {
154
+ scanner.inString = true;
155
+ continue;
156
+ }
157
+ if (char === "{" || char === "[") {
158
+ scanner.stack.push(char);
159
+ continue;
160
+ }
161
+ if (char === "}" || char === "]") {
162
+ const expected = char === "}" ? "{" : "[";
163
+ if (scanner.stack.pop() !== expected) {
164
+ scanner.state = "invalid";
165
+ continue;
166
+ }
167
+ if (scanner.stack.length === 0) {
168
+ scanner.state = "complete";
169
+ completed = true;
170
+ }
171
+ }
172
+ }
173
+
174
+ return completed && scanner.state === "complete";
53
175
  };
54
176
 
55
177
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
@@ -91,7 +213,7 @@ export class RunAggregator {
91
213
  private status: ChatModelRunResult["status"] | undefined;
92
214
  private interrupts: AgUiInterrupt[] | undefined;
93
215
  private readonly textParts = new Map<string, { buffer: string }>();
94
- private activeTextMessageId: string | undefined;
216
+ private readonly activeTextMessageIdByScope = new Map<string, string>();
95
217
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
96
218
  private readonly reasoningSignatures = new Map<string, string>();
97
219
  private readonly reasoningSignatureIds = new Map<string, string>();
@@ -109,18 +231,23 @@ export class RunAggregator {
109
231
  private readonly loggedDroppedOpaqueIds = new Set<string>();
110
232
  private readonly reasoningMessageIds = new Map<string, string>();
111
233
  private readonly anonymousReasoningKeys = new Set<string>();
112
- private activeReasoningKey: string | undefined;
234
+ private readonly activeReasoningKeyByScope = new Map<string, string>();
235
+ private readonly subagentRuns = new Map<string, SubagentRunState>();
236
+ // The nesting graph only moves when a run is announced or a tool call is
237
+ // created, which is rare next to emit(), so it is derived once per change
238
+ // rather than per streamed delta.
239
+ private nestingMemo:
240
+ | {
241
+ subagentsByParentToolCallId: Map<string, string[]>;
242
+ reachable: Set<string>;
243
+ }
244
+ | undefined;
113
245
  private reasoningPartCounter = 0;
114
246
  private readonly toolCalls = new Map<string, ToolCallState>();
115
247
  private readonly a2uiBuckets = new Map<string, A2uiState>();
116
248
  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
- )[] = [];
249
+ private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
250
+ private readonly partOrder: PartOrderEntry[] = [];
124
251
  private textPartCounter = 0;
125
252
  private serverMessageIdReported = false;
126
253
  private reportedServerMessageId: string | undefined;
@@ -166,14 +293,18 @@ export class RunAggregator {
166
293
  }
167
294
 
168
295
  this.interrupts = undefined;
296
+ // A call nested inside a subagent message is answerable the same way
297
+ // a root call is: getPendingToolCalls and addToolResult walk
298
+ // ToolCallMessagePart.messages, so any unresolved call keeps the run
299
+ // resumable.
169
300
  const hasUnresolvedToolCalls = Array.from(this.toolCalls.values()).some(
170
301
  (tc) => tc.result === undefined,
171
302
  );
172
303
 
173
- this.status =
174
- event.outcome?.type === "success" || !hasUnresolvedToolCalls
175
- ? { type: "complete", reason: "unknown" }
176
- : { type: "requires-action", reason: "tool-calls" };
304
+ this.status = hasUnresolvedToolCalls
305
+ ? { type: "requires-action", reason: "tool-calls" }
306
+ : { type: "complete", reason: "unknown" };
307
+ this.closeOpenSubagentRuns(this.status);
177
308
  this.emit();
178
309
  break;
179
310
  }
@@ -183,48 +314,61 @@ export class RunAggregator {
183
314
  reason: "error",
184
315
  ...(event.message !== undefined ? { error: event.message } : {}),
185
316
  };
317
+ this.closeOpenSubagentRuns(this.status);
186
318
  this.emit();
187
319
  break;
188
320
  }
189
321
  case "RUN_CANCELLED": {
190
322
  this.status = { type: "incomplete", reason: "cancelled" };
323
+ this.closeOpenSubagentRuns(this.status);
191
324
  this.emit();
192
325
  break;
193
326
  }
194
327
 
195
328
  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;
329
+ const scope = this.scopeOf(event);
330
+ if (scope === ROOT_SCOPE) {
331
+ this.beginDistinctTextMessage(event.messageId);
332
+ this.reportServerMessageId(event.messageId);
333
+ if (event.messageId) this.lastTextMessageId = event.messageId;
334
+ }
335
+ this.startTextMessage(scope, event.messageId);
200
336
  this.emit();
201
337
  break;
202
338
  }
203
339
  case "TEXT_MESSAGE_CONTENT":
204
340
  case "TEXT_MESSAGE_CHUNK": {
205
341
  const incomingId = "messageId" in event ? event.messageId : undefined;
206
- this.beginDistinctTextMessage(incomingId);
207
- this.reportServerMessageId(incomingId);
208
- if (incomingId) this.lastTextMessageId = incomingId;
342
+ const scope = this.scopeOf(event);
343
+ if (scope === ROOT_SCOPE) {
344
+ this.beginDistinctTextMessage(incomingId);
345
+ this.reportServerMessageId(incomingId);
346
+ if (incomingId) this.lastTextMessageId = incomingId;
347
+ }
209
348
  if (!event.delta) break;
210
349
  this.recordFirstToken();
211
- const id = this.resolveTextMessageId(incomingId);
350
+ const id = this.resolveTextMessageId(scope, incomingId);
212
351
  this.appendText(id, event.delta);
213
352
  this.totalChunks++;
214
353
  this.emit();
215
354
  break;
216
355
  }
217
356
  case "TEXT_MESSAGE_END": {
218
- this.reportServerMessageId(event.messageId);
219
- if (event.messageId && this.activeTextMessageId === event.messageId) {
220
- this.activeTextMessageId = undefined;
357
+ const scope = this.scopeOf(event);
358
+ if (scope === ROOT_SCOPE) this.reportServerMessageId(event.messageId);
359
+ if (
360
+ event.messageId &&
361
+ this.activeTextMessageIdByScope.get(scope) ===
362
+ this.partKey(scope, event.messageId)
363
+ ) {
364
+ this.activeTextMessageIdByScope.delete(scope);
221
365
  }
222
366
  this.emit();
223
367
  break;
224
368
  }
225
369
 
226
370
  case "CUSTOM": {
227
- this.activeTextMessageId = undefined;
371
+ this.activeTextMessageIdByScope.delete(ROOT_SCOPE);
228
372
  this.partOrder.push({
229
373
  kind: "data",
230
374
  name: event.name,
@@ -239,18 +383,21 @@ export class RunAggregator {
239
383
  case "REASONING_START":
240
384
  case "REASONING_MESSAGE_START":
241
385
  this.handleReasoningStart(
386
+ this.scopeOf("subagentRunId" in event ? event : {}),
242
387
  "messageId" in event ? event.messageId : undefined,
243
388
  event.type === "REASONING_MESSAGE_START",
244
389
  );
245
390
  break;
246
391
  case "REASONING_ENCRYPTED_VALUE":
247
392
  if (event.subtype === "message") {
393
+ const scope = this.scopeOf(event);
248
394
  // entityId names any message, not necessarily a reasoning one, so an
249
395
  // unmatched id may only claim a block that has no id to contradict it.
250
- const active = this.activeReasoningKey;
396
+ const active = this.activeReasoningKeyByScope.get(scope);
397
+ const entityKey = this.partKey(scope, event.entityId);
251
398
  const key = this.showThinking
252
- ? this.reasoningParts.has(event.entityId)
253
- ? event.entityId
399
+ ? this.reasoningParts.has(entityKey)
400
+ ? entityKey
254
401
  : active !== undefined && this.anonymousReasoningKeys.has(active)
255
402
  ? active
256
403
  : undefined
@@ -261,6 +408,9 @@ export class RunAggregator {
261
408
  this.emit();
262
409
  } else if (
263
410
  !this.showThinking &&
411
+ // Hidden-reasoning bookkeeping stays root-only for this task — a
412
+ // subagent's opaque/hidden reasoning signatures are not preserved.
413
+ scope === ROOT_SCOPE &&
264
414
  event.entityId.trim().length > 0 &&
265
415
  event.encryptedValue.trim().length > 0 &&
266
416
  (this.hiddenReasoningIds.has(event.entityId) ||
@@ -278,12 +428,13 @@ export class RunAggregator {
278
428
  }
279
429
  break;
280
430
  case "THINKING_TEXT_MESSAGE_CONTENT":
281
- this.handleReasoningContent(event.delta);
431
+ this.handleReasoningContent(ROOT_SCOPE, event.delta);
282
432
  this.totalChunks++;
283
433
  this.recordFirstToken();
284
434
  break;
285
435
  case "REASONING_MESSAGE_CONTENT":
286
436
  this.handleReasoningContent(
437
+ this.scopeOf(event),
287
438
  event.delta,
288
439
  "messageId" in event ? event.messageId : undefined,
289
440
  true,
@@ -293,14 +444,20 @@ export class RunAggregator {
293
444
  break;
294
445
  case "THINKING_TEXT_MESSAGE_END":
295
446
  case "THINKING_END":
447
+ this.handleReasoningEnd(ROOT_SCOPE);
448
+ break;
296
449
  case "REASONING_MESSAGE_END":
297
450
  case "REASONING_END":
298
- this.handleReasoningEnd();
451
+ this.handleReasoningEnd(this.scopeOf(event));
299
452
  break;
300
453
 
301
454
  case "TOOL_CALL_START": {
302
- this.reportServerMessageId(event.parentMessageId);
455
+ const scope = this.scopeOf(event);
456
+ if (scope === ROOT_SCOPE) {
457
+ this.reportServerMessageId(event.parentMessageId);
458
+ }
303
459
  this.startToolCall(
460
+ scope,
304
461
  event.toolCallId,
305
462
  event.toolCallName,
306
463
  event.parentMessageId,
@@ -310,7 +467,10 @@ export class RunAggregator {
310
467
  }
311
468
  case "TOOL_CALL_ARGS":
312
469
  case "TOOL_CALL_CHUNK": {
313
- if (event.type === "TOOL_CALL_CHUNK") {
470
+ if (
471
+ event.type === "TOOL_CALL_CHUNK" &&
472
+ this.scopeOf(event) === ROOT_SCOPE
473
+ ) {
314
474
  this.reportServerMessageId(event.parentMessageId);
315
475
  }
316
476
  if (!event.delta) break;
@@ -324,6 +484,7 @@ export class RunAggregator {
324
484
  }
325
485
  case "TOOL_CALL_RESULT": {
326
486
  this.finishToolCall(
487
+ this.scopeOf(event),
327
488
  event.toolCallId,
328
489
  event.content ?? "",
329
490
  typeof event.mcpResult?.isError === "boolean"
@@ -343,12 +504,15 @@ export class RunAggregator {
343
504
  break;
344
505
  }
345
506
  if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
507
+ const activityScope = this.scopeOf(event);
346
508
  const toolCallId = event.content.toolCallId;
509
+ const fallbackId =
510
+ this.lastResolvedToolCallIdByScope.get(activityScope);
347
511
  const entry =
348
512
  typeof toolCallId === "string"
349
513
  ? this.toolCalls.get(toolCallId)
350
- : this.lastResolvedToolCallId
351
- ? this.toolCalls.get(this.lastResolvedToolCallId)
514
+ : fallbackId
515
+ ? this.toolCalls.get(fallbackId)
352
516
  : undefined;
353
517
  const resourceUri = event.content.resourceUri;
354
518
  if (
@@ -391,6 +555,70 @@ export class RunAggregator {
391
555
  break;
392
556
  }
393
557
 
558
+ case "SUBAGENT_STARTED": {
559
+ // A suspended subagent is re-announced with the same id on resume, so
560
+ // a repeat is a continuation: it may refresh the descriptive fields
561
+ // and reopen the status, but re-parenting an established run would
562
+ // move its already-rendered output to a different tool call.
563
+ const existing = this.subagentRuns.get(event.subagentRunId);
564
+ if (existing) {
565
+ existing.name = event.name;
566
+ if (event.description !== undefined)
567
+ existing.description = event.description;
568
+ existing.status = { type: "running" };
569
+ this.emit();
570
+ break;
571
+ }
572
+ this.nestingMemo = undefined;
573
+ this.subagentRuns.set(event.subagentRunId, {
574
+ subagentRunId: event.subagentRunId,
575
+ name: event.name,
576
+ ...(event.description !== undefined
577
+ ? { description: event.description }
578
+ : {}),
579
+ ...(event.parentSubagentRunId !== undefined
580
+ ? { parentSubagentRunId: event.parentSubagentRunId }
581
+ : {}),
582
+ ...(event.parentToolCallId !== undefined
583
+ ? { parentToolCallId: event.parentToolCallId }
584
+ : {}),
585
+ ...(event.parentMessageId !== undefined
586
+ ? { parentMessageId: event.parentMessageId }
587
+ : {}),
588
+ createdAt: new Date(),
589
+ status: { type: "running" },
590
+ });
591
+ this.emit();
592
+ break;
593
+ }
594
+ case "SUBAGENT_FINISHED": {
595
+ const run = this.subagentRuns.get(event.subagentRunId);
596
+ if (run) {
597
+ run.status =
598
+ event.outcome?.type === "suspended"
599
+ ? { type: "requires-action", reason: "interrupt" }
600
+ : { type: "complete", reason: "unknown" };
601
+ if (event.result !== undefined) run.result = event.result;
602
+ if (event.outcome?.type === "suspended" && event.outcome.interruptIds)
603
+ run.interruptIds = event.outcome.interruptIds;
604
+ }
605
+ this.emit();
606
+ break;
607
+ }
608
+ case "SUBAGENT_ERROR": {
609
+ const run = this.subagentRuns.get(event.subagentRunId);
610
+ if (run) {
611
+ run.status = {
612
+ type: "incomplete",
613
+ reason: "error",
614
+ ...(event.message !== undefined ? { error: event.message } : {}),
615
+ };
616
+ if (event.code !== undefined) run.errorCode = event.code;
617
+ }
618
+ this.emit();
619
+ break;
620
+ }
621
+
394
622
  default: {
395
623
  this.logger.debug?.("[agui] aggregator ignored event", event);
396
624
  }
@@ -448,12 +676,14 @@ export class RunAggregator {
448
676
  if (!this.toolCalls.has(toolCallId)) {
449
677
  this.partOrder.push({ kind: "tool-call", toolCallId });
450
678
  }
679
+ this.nestingMemo = undefined;
451
680
  this.toolCalls.set(toolCallId, entry);
452
681
  this.a2uiToolCallIds.add(toolCallId);
453
682
  }
454
683
 
455
684
  for (const toolCallId of this.a2uiToolCallIds) {
456
685
  if (activeToolCallIds.has(toolCallId)) continue;
686
+ this.nestingMemo = undefined;
457
687
  this.toolCalls.delete(toolCallId);
458
688
  const partIndex = this.partOrder.findIndex(
459
689
  (part) => part.kind === "tool-call" && part.toolCallId === toolCallId,
@@ -492,18 +722,39 @@ export class RunAggregator {
492
722
  this.hiddenActiveReasoning = "none";
493
723
  this.loggedDroppedOpaqueIds.clear();
494
724
  this.anonymousReasoningKeys.clear();
495
- this.activeReasoningKey = undefined;
725
+ this.activeReasoningKeyByScope.clear();
726
+ this.nestingMemo = undefined;
727
+ this.subagentRuns.clear();
496
728
  this.reasoningPartCounter = 0;
497
729
  this.toolCalls.clear();
498
730
  this.a2uiBuckets.clear();
499
731
  this.a2uiToolCallIds.clear();
500
- this.lastResolvedToolCallId = undefined;
732
+ this.lastResolvedToolCallIdByScope.clear();
501
733
  this.partOrder.length = 0;
502
734
  this.textPartCounter = 0;
503
- this.activeTextMessageId = undefined;
735
+ this.activeTextMessageIdByScope.clear();
504
736
  this.reportedServerMessageId = undefined;
505
737
  }
506
738
 
739
+ // A subagent only ever reports its own terminal, so a run that ends while
740
+ // one is still streaming would leave that nested message spinning forever.
741
+ private closeOpenSubagentRuns(status: MessageStatus): void {
742
+ for (const run of this.subagentRuns.values()) {
743
+ if (run.status.type === "running") run.status = status;
744
+ }
745
+ }
746
+
747
+ private scopeOf(event: { subagentRunId?: string }): string {
748
+ return event.subagentRunId ?? ROOT_SCOPE;
749
+ }
750
+
751
+ // Nothing in the AG-UI schema makes a messageId unique across subagent runs,
752
+ // so the accumulators key on the scope too. Root keys stay bare, which is
753
+ // what the wire-id collision checks against reported message ids compare on.
754
+ private partKey(scope: string, id: string): string {
755
+ return scope === ROOT_SCOPE ? id : `${scope}\u0000${id}`;
756
+ }
757
+
507
758
  private beginDistinctTextMessage(messageId: string | undefined): void {
508
759
  if (
509
760
  !messageId ||
@@ -522,63 +773,71 @@ export class RunAggregator {
522
773
  return `text-${this.textPartCounter}`;
523
774
  }
524
775
 
525
- private startTextMessage(messageId?: string): string {
776
+ private startTextMessage(scope: string, messageId?: string): string {
526
777
  const id = messageId ?? this.generateTextKey();
527
- this.ensureTextPart(id);
528
- this.activeTextMessageId = id;
529
- return id;
778
+ const key = this.ensureTextPart(scope, id);
779
+ this.activeTextMessageIdByScope.set(scope, key);
780
+ return key;
530
781
  }
531
782
 
532
- private resolveTextMessageId(messageId?: string): string {
783
+ private resolveTextMessageId(scope: string, messageId?: string): string {
533
784
  if (messageId) {
534
- this.ensureTextPart(messageId);
535
- this.activeTextMessageId = messageId;
536
- return messageId;
785
+ const key = this.ensureTextPart(scope, messageId);
786
+ this.activeTextMessageIdByScope.set(scope, key);
787
+ return key;
537
788
  }
538
789
 
539
- if (this.activeTextMessageId) {
540
- return this.activeTextMessageId;
790
+ const active = this.activeTextMessageIdByScope.get(scope);
791
+ if (active) {
792
+ return active;
541
793
  }
542
794
 
543
- const generated = this.generateTextKey();
544
- this.ensureTextPart(generated);
545
- this.activeTextMessageId = generated;
546
- return generated;
795
+ const key = this.ensureTextPart(scope, this.generateTextKey());
796
+ this.activeTextMessageIdByScope.set(scope, key);
797
+ return key;
547
798
  }
548
799
 
549
- private ensureTextPart(id: string): void {
550
- if (!this.textParts.has(id)) {
551
- this.textParts.set(id, { buffer: "" });
800
+ private ensureTextPart(scope: string, id: string): string {
801
+ const key = this.partKey(scope, id);
802
+ if (!this.textParts.has(key)) {
803
+ this.textParts.set(key, { buffer: "" });
552
804
  if (
553
- !this.partOrder.some((part) => part.kind === "text" && part.key === id)
805
+ !this.partOrder.some((part) => part.kind === "text" && part.key === key)
554
806
  ) {
555
- this.partOrder.push({ kind: "text", key: id });
807
+ this.partOrder.push(
808
+ scope === ROOT_SCOPE
809
+ ? { kind: "text", key }
810
+ : { kind: "text", key, subagentRunId: scope },
811
+ );
556
812
  }
557
813
  }
814
+ return key;
558
815
  }
559
816
 
560
817
  private appendText(id: string, delta: string): void {
561
- this.ensureTextPart(id);
562
818
  const entry = this.textParts.get(id);
563
819
  if (!entry) return;
564
820
  entry.buffer += delta;
565
821
  }
566
822
 
567
823
  private startToolCall(
824
+ scope: string,
568
825
  id: string | undefined,
569
826
  name?: string,
570
827
  parentMessageId?: string,
571
828
  ) {
572
829
  if (!id) return;
573
830
  // 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;
831
+ // after it should be a new part, not appended to the pre-tool text —
832
+ // scoped, so a subagent's tool call doesn't break the root run's text
833
+ // and vice versa.
834
+ this.activeTextMessageIdByScope.delete(scope);
576
835
  if (
577
836
  !this.partOrder.some(
578
837
  (part) => part.kind === "tool-call" && part.toolCallId === id,
579
838
  )
580
839
  ) {
581
- this.insertToolPart(id, parentMessageId);
840
+ this.insertToolPart(scope, id, parentMessageId);
582
841
  }
583
842
  const state: ToolCallState = {
584
843
  toolCallId: id,
@@ -592,16 +851,21 @@ export class RunAggregator {
592
851
  if (parentMessageId) {
593
852
  state.parentMessageId = parentMessageId;
594
853
  }
854
+ if (scope !== ROOT_SCOPE) {
855
+ state.subagentRunId = scope;
856
+ }
857
+ this.nestingMemo = undefined;
595
858
  this.toolCalls.set(id, state);
596
859
  }
597
860
 
598
861
  // The message and tool-call channels are unordered on the wire, so anchor a
599
862
  // tool call under its parentMessageId text part instead of appending it.
600
- private insertToolPart(id: string, parentMessageId?: string) {
863
+ private insertToolPart(scope: string, id: string, parentMessageId?: string) {
601
864
  const entry = { kind: "tool-call", toolCallId: id } as const;
602
865
  if (parentMessageId) {
866
+ const parentKey = this.partKey(scope, parentMessageId);
603
867
  const parentIndex = this.partOrder.findIndex(
604
- (part) => part.kind === "text" && part.key === parentMessageId,
868
+ (part) => part.kind === "text" && part.key === parentKey,
605
869
  );
606
870
  if (parentIndex !== -1) {
607
871
  let insertAt = parentIndex + 1;
@@ -627,7 +891,17 @@ export class RunAggregator {
627
891
  private appendToolArgs(id: string | undefined, delta: string) {
628
892
  const entry = id ? this.toolCalls.get(id) : undefined;
629
893
  if (!entry) return;
894
+ if (!entry.argsTextScanner) {
895
+ entry.argsTextScanner = createJSONContainerScanner();
896
+ }
630
897
  entry.argsText += delta;
898
+ const shouldParse = scanJSONContainerDelta(entry.argsTextScanner, delta);
899
+ if (!shouldParse) {
900
+ if (entry.argsTextScanner.state === "invalid") {
901
+ entry.parsedArgs = undefined;
902
+ }
903
+ return;
904
+ }
631
905
  try {
632
906
  const parsed = JSON.parse(entry.argsText);
633
907
  if (parsed && typeof parsed === "object") {
@@ -641,6 +915,7 @@ export class RunAggregator {
641
915
  }
642
916
 
643
917
  private finishToolCall(
918
+ scope: string,
644
919
  id: string,
645
920
  content: string,
646
921
  isError?: boolean,
@@ -658,7 +933,9 @@ export class RunAggregator {
658
933
  result: undefined,
659
934
  isError: undefined,
660
935
  snapshotResultApplied: false,
936
+ ...(scope !== ROOT_SCOPE ? { subagentRunId: scope } : {}),
661
937
  };
938
+ this.nestingMemo = undefined;
662
939
  this.toolCalls.set(id, entry);
663
940
  }
664
941
  if (
@@ -691,61 +968,199 @@ export class RunAggregator {
691
968
  if (toolMessageId) {
692
969
  entry.toolMessageId = toolMessageId;
693
970
  }
694
- this.lastResolvedToolCallId = id;
971
+ this.lastResolvedToolCallIdByScope.set(scope, id);
695
972
  }
696
973
 
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
- );
974
+ private nesting(): {
975
+ subagentsByParentToolCallId: Map<string, string[]>;
976
+ reachable: Set<string>;
977
+ } {
978
+ if (!this.nestingMemo) {
979
+ const subagentsByParentToolCallId = this.subagentsByParentToolCallId();
980
+ this.nestingMemo = {
981
+ subagentsByParentToolCallId,
982
+ reachable: this.reachableSubagentRunIds(subagentsByParentToolCallId),
983
+ };
984
+ }
985
+ return this.nestingMemo;
986
+ }
707
987
 
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;
988
+ private subagentsByParentToolCallId(): Map<string, string[]> {
989
+ const map = new Map<string, string[]>();
990
+ for (const run of this.subagentRuns.values()) {
991
+ if (!run.parentToolCallId) continue;
992
+ const list = map.get(run.parentToolCallId) ?? [];
993
+ list.push(run.subagentRunId);
994
+ map.set(run.parentToolCallId, list);
995
+ }
996
+ return map;
997
+ }
998
+
999
+ // A subagent nests under its spawning tool call only when that call is itself
1000
+ // reachable from the root scope. A run with no parentToolCallId, one naming a
1001
+ // call this run never saw, or one in a cycle has nowhere to hang; the visited
1002
+ // set also keeps a malformed chain to one visit per run instead of branching
1003
+ // at every level.
1004
+ private reachableSubagentRunIds(
1005
+ subagentsByParentToolCallId: Map<string, string[]>,
1006
+ ): Set<string> {
1007
+ const reachable = new Set<string>();
1008
+ const walk = (scope: string, depth: number): void => {
1009
+ if (depth > MAX_SUBAGENT_DEPTH) return;
1010
+ for (const entry of this.toolCalls.values()) {
1011
+ if ((entry.subagentRunId ?? ROOT_SCOPE) !== scope) continue;
1012
+ for (const id of subagentsByParentToolCallId.get(entry.toolCallId) ??
1013
+ []) {
1014
+ if (reachable.has(id)) continue;
1015
+ reachable.add(id);
1016
+ walk(id, depth + 1);
1017
+ }
1018
+ }
720
1019
  };
1020
+ walk(ROOT_SCOPE, 0);
1021
+ return reachable;
1022
+ }
1023
+
1024
+ // Nested messages rather than a discovery hook (the shape react-langchain
1025
+ // uses): ToolCallMessagePart.messages is the contract core consumers already
1026
+ // walk, and SUBAGENT_STARTED.parentToolCallId names the spawning call
1027
+ // outright, so the join needs no namespace scheme of its own.
1028
+ private materializeSubagentMessage(
1029
+ subagentRunId: string,
1030
+ depth: number,
1031
+ ctx: BuildContext,
1032
+ ): ThreadMessage | undefined {
1033
+ const run = this.subagentRuns.get(subagentRunId);
1034
+ if (!run || depth > MAX_SUBAGENT_DEPTH) return undefined;
1035
+ const content = this.buildParts(subagentRunId, depth + 1, ctx);
1036
+ return {
1037
+ id: run.subagentRunId,
1038
+ role: "assistant",
1039
+ createdAt: run.createdAt,
1040
+ status: run.status,
1041
+ content,
1042
+ metadata: {
1043
+ unstable_state: null,
1044
+ unstable_annotations: [],
1045
+ unstable_data: [],
1046
+ steps: [],
1047
+ custom: {
1048
+ [AG_UI_METADATA_NAMESPACE]: {
1049
+ name: run.name,
1050
+ ...(run.description !== undefined
1051
+ ? { description: run.description }
1052
+ : {}),
1053
+ ...(run.parentSubagentRunId !== undefined
1054
+ ? { parentSubagentRunId: run.parentSubagentRunId }
1055
+ : {}),
1056
+ ...(run.result !== undefined ? { result: run.result } : {}),
1057
+ ...(run.interruptIds !== undefined
1058
+ ? { interruptIds: run.interruptIds }
1059
+ : {}),
1060
+ ...(run.errorCode !== undefined
1061
+ ? { errorCode: run.errorCode }
1062
+ : {}),
1063
+ },
1064
+ },
1065
+ },
1066
+ } as ThreadMessage;
1067
+ }
1068
+
1069
+ private buildParts(
1070
+ scope: string,
1071
+ depth: number,
1072
+ ctx: BuildContext,
1073
+ ): ThreadAssistantMessagePart[] {
1074
+ const snapshot: ThreadAssistantMessagePart[] = [];
1075
+ const isRoot = scope === ROOT_SCOPE;
1076
+
1077
+ for (const { index, part } of ctx.partsByScope.get(scope) ?? []) {
1078
+ const materialized = () => {
1079
+ if (isRoot && ctx.root) ctx.root.lastMaterializedIndex = index;
1080
+ };
1081
+
1082
+ if (part.kind === "tool-call") {
1083
+ const entry = this.toolCalls.get(part.toolCallId);
1084
+ if (!entry) continue;
1085
+ const approval = ctx.approvals.get(entry.toolCallId);
1086
+ const nestedMessages = (
1087
+ ctx.subagentsByParentToolCallId.get(entry.toolCallId) ?? []
1088
+ )
1089
+ .filter((id) => ctx.reachable.has(id))
1090
+ .map((id) => this.materializeSubagentMessage(id, depth, ctx))
1091
+ .filter((m): m is ThreadMessage => m !== undefined);
1092
+ const toolPart: ToolCallMessagePart = {
1093
+ type: "tool-call",
1094
+ toolCallId: entry.toolCallId,
1095
+ toolName: entry.toolCallName,
1096
+ args: (entry.parsedArgs ?? {}) as any,
1097
+ argsText: entry.argsText,
1098
+ ...(approval ? { approval } : {}),
1099
+ ...(entry.result !== undefined ? { result: entry.result } : {}),
1100
+ ...(entry.modelContent !== undefined
1101
+ ? { modelContent: entry.modelContent }
1102
+ : {}),
1103
+ ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
1104
+ ...(entry.mcpAppResourceUri
1105
+ ? {
1106
+ mcp: {
1107
+ app: {
1108
+ resourceUri: entry.mcpAppResourceUri,
1109
+ ...(entry.mcpAppServerId
1110
+ ? { serverId: entry.mcpAppServerId }
1111
+ : {}),
1112
+ },
1113
+ },
1114
+ }
1115
+ : {}),
1116
+ ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
1117
+ ...(entry.toolMessageId
1118
+ ? { unstable_toolMessageId: entry.toolMessageId }
1119
+ : {}),
1120
+ ...(nestedMessages.length > 0 ? { messages: nestedMessages } : {}),
1121
+ } as ToolCallMessagePart & { unstable_toolMessageId?: string };
1122
+ snapshot.push(toolPart);
1123
+ materialized();
1124
+ continue;
1125
+ }
1126
+
1127
+ const rawScope =
1128
+ "subagentRunId" in part
1129
+ ? (part.subagentRunId ?? ROOT_SCOPE)
1130
+ : ROOT_SCOPE;
721
1131
 
722
- for (const [index, part] of this.partOrder.entries()) {
723
- currentIndex = index;
724
1132
  if (part.kind === "reasoning") {
725
1133
  if (this.showThinking) {
726
1134
  const buffer = this.reasoningParts.get(part.key) ?? "";
727
- if (buffer.length > 0 || this.activeReasoningKey === part.key) {
1135
+ const isActive =
1136
+ this.activeReasoningKeyByScope.get(rawScope) === part.key;
1137
+ if (buffer.length > 0 || isActive) {
728
1138
  const encryptedValue = this.reasoningSignatures.get(part.key);
729
1139
  const reasoningId = this.reasoningMessageIds.get(part.key);
730
1140
  const meta = {
731
1141
  ...(reasoningId !== undefined ? { reasoningId } : {}),
732
1142
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
733
1143
  };
734
- pushSnapshotPart({
1144
+ snapshot.push({
735
1145
  type: "reasoning",
736
1146
  text: buffer,
737
1147
  ...(Object.keys(meta).length > 0
738
1148
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
739
1149
  : {}),
740
1150
  } as const);
741
- } else {
1151
+ materialized();
1152
+ } else if (isRoot && ctx.root) {
742
1153
  // A retracted empty block still carries transport state: without
743
1154
  // a part to live on, its signature rides the message metadata,
744
1155
  // matching the shape a snapshot reload produces.
745
1156
  const encryptedValue = this.reasoningSignatures.get(part.key);
746
1157
  const id = this.reasoningSignatureIds.get(part.key);
747
1158
  if (id?.trim() && encryptedValue?.trim()) {
748
- opaqueCandidates.push({ id, encryptedValue, anchor: index + 1 });
1159
+ ctx.root.opaqueCandidates.push({
1160
+ id,
1161
+ encryptedValue,
1162
+ anchor: index + 1,
1163
+ });
749
1164
  }
750
1165
  }
751
1166
  }
@@ -755,54 +1170,79 @@ export class RunAggregator {
755
1170
  if (part.kind === "text") {
756
1171
  const entry = this.textParts.get(part.key);
757
1172
  if (entry && entry.buffer.trim().length > 0) {
758
- pushSnapshotPart({ type: "text", text: entry.buffer } as const);
1173
+ snapshot.push({ type: "text", text: entry.buffer } as const);
1174
+ materialized();
759
1175
  }
760
1176
  continue;
761
1177
  }
762
1178
 
763
1179
  if (part.kind === "data") {
764
- pushSnapshotPart({
1180
+ snapshot.push({
765
1181
  type: "data",
766
1182
  name: part.name,
767
1183
  data: part.value,
768
1184
  });
1185
+ materialized();
769
1186
  continue;
770
1187
  }
1188
+ }
771
1189
 
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);
1190
+ return snapshot;
1191
+ }
1192
+
1193
+ private emit(): void {
1194
+ const { subagentsByParentToolCallId, reachable } = this.nesting();
1195
+ const root = {
1196
+ opaqueCandidates: Array.from(
1197
+ this.hiddenSignatures,
1198
+ ([id, encryptedValue]) => ({
1199
+ id,
1200
+ encryptedValue,
1201
+ anchor: this.hiddenSignatureAnchors.get(id)!,
1202
+ }),
1203
+ ) as (AgUiOpaqueReasoning & { anchor: number })[],
1204
+ lastMaterializedIndex: -1,
1205
+ };
1206
+ // A run that ended incomplete can no longer be resumed, so a gate left over
1207
+ // from an earlier interrupt outcome is unanswerable and must not stay
1208
+ // projected. The interrupts themselves are kept on the message, since the
1209
+ // bespoke hooks read that payload. Bound ids cover every call the run
1210
+ // produced, root or nested: the approval seams walk
1211
+ // ToolCallMessagePart.messages, so a gate naming a subagent-scoped call is
1212
+ // answerable and projects onto the nested part.
1213
+ const partsByScope = new Map<
1214
+ string,
1215
+ { index: number; part: PartOrderEntry }[]
1216
+ >();
1217
+ for (const [index, part] of this.partOrder.entries()) {
1218
+ const rawScope =
1219
+ part.kind === "tool-call"
1220
+ ? (this.toolCalls.get(part.toolCallId)?.subagentRunId ?? ROOT_SCOPE)
1221
+ : "subagentRunId" in part
1222
+ ? (part.subagentRunId ?? ROOT_SCOPE)
1223
+ : ROOT_SCOPE;
1224
+ const bucketScope =
1225
+ rawScope === ROOT_SCOPE || reachable.has(rawScope)
1226
+ ? rawScope
1227
+ : ROOT_SCOPE;
1228
+ const bucket = partsByScope.get(bucketScope);
1229
+ if (bucket) bucket.push({ index, part });
1230
+ else partsByScope.set(bucketScope, [{ index, part }]);
805
1231
  }
1232
+ const ctx: BuildContext = {
1233
+ subagentsByParentToolCallId,
1234
+ reachable,
1235
+ partsByScope,
1236
+ approvals: projectAgUiToolApprovals(
1237
+ this.status?.type === "requires-action" ? this.interrupts : undefined,
1238
+ new Set(
1239
+ Array.from(this.toolCalls.values()).map((entry) => entry.toolCallId),
1240
+ ),
1241
+ ),
1242
+ root,
1243
+ };
1244
+ const snapshot = this.buildParts(ROOT_SCOPE, 0, ctx);
1245
+ const { opaqueCandidates, lastMaterializedIndex } = root;
806
1246
 
807
1247
  // An anonymous claim promotes the signature's entityId to a wire message
808
1248
  // id; when that id actually names a text message or the adopted assistant
@@ -906,8 +1346,15 @@ export class RunAggregator {
906
1346
  };
907
1347
  }
908
1348
 
909
- private handleReasoningStart(messageId?: string, isMessageId = false): void {
1349
+ private handleReasoningStart(
1350
+ scope: string,
1351
+ messageId?: string,
1352
+ isMessageId = false,
1353
+ ): void {
910
1354
  if (!this.showThinking) {
1355
+ // Hidden-reasoning bookkeeping stays root-only for this task — a
1356
+ // subagent's opaque/hidden reasoning signatures are not preserved.
1357
+ if (scope !== ROOT_SCOPE) return;
911
1358
  const anchor = this.partOrder.length;
912
1359
  if (messageId === undefined) {
913
1360
  this.hiddenActiveReasoning = "anonymous";
@@ -920,9 +1367,12 @@ export class RunAggregator {
920
1367
  return;
921
1368
  }
922
1369
  // 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}`;
1370
+ // should be a new part, not appended to any pre-reasoning text — scoped.
1371
+ this.activeTextMessageIdByScope.delete(scope);
1372
+ const key = this.partKey(
1373
+ scope,
1374
+ messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`,
1375
+ );
926
1376
  // Two different questions: which id may be replayed as a ReasoningMessage.id
927
1377
  // (only the message-scoped aliases carry one), and which block an unmatched
928
1378
  // signature may claim (any block opened without an id at all).
@@ -933,13 +1383,18 @@ export class RunAggregator {
933
1383
  }
934
1384
  if (!this.reasoningParts.has(key)) {
935
1385
  this.reasoningParts.set(key, "");
936
- this.partOrder.push({ kind: "reasoning", key });
1386
+ this.partOrder.push(
1387
+ scope === ROOT_SCOPE
1388
+ ? { kind: "reasoning", key }
1389
+ : { kind: "reasoning", key, subagentRunId: scope },
1390
+ );
937
1391
  }
938
- this.activeReasoningKey = key;
1392
+ this.activeReasoningKeyByScope.set(scope, key);
939
1393
  this.emit();
940
1394
  }
941
1395
 
942
1396
  private handleReasoningContent(
1397
+ scope: string,
943
1398
  delta: string,
944
1399
  messageId?: string,
945
1400
  isMessageId = false,
@@ -948,27 +1403,28 @@ export class RunAggregator {
948
1403
  if (!this.showThinking) {
949
1404
  // Content without a preceding START still names the block (the visible
950
1405
  // path opens it lazily); register it so a signature can claim it.
1406
+ if (scope !== ROOT_SCOPE) return;
951
1407
  if (this.hiddenActiveReasoning === "none") {
952
- this.handleReasoningStart(messageId, isMessageId);
1408
+ this.handleReasoningStart(scope, messageId, isMessageId);
953
1409
  }
954
1410
  return;
955
1411
  }
956
- if (!this.activeReasoningKey) {
1412
+ if (!this.activeReasoningKeyByScope.has(scope)) {
957
1413
  // Content arrived without a preceding START — create the slot lazily.
958
- this.handleReasoningStart(messageId, isMessageId);
1414
+ this.handleReasoningStart(scope, messageId, isMessageId);
959
1415
  }
960
- const key = this.activeReasoningKey;
1416
+ const key = this.activeReasoningKeyByScope.get(scope);
961
1417
  if (!key) return;
962
1418
  this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
963
1419
  this.emit();
964
1420
  }
965
1421
 
966
- private handleReasoningEnd(): void {
1422
+ private handleReasoningEnd(scope: string): void {
967
1423
  if (!this.showThinking) {
968
- this.hiddenActiveReasoning = "none";
1424
+ if (scope === ROOT_SCOPE) this.hiddenActiveReasoning = "none";
969
1425
  return;
970
1426
  }
971
- this.activeReasoningKey = undefined;
1427
+ this.activeReasoningKeyByScope.delete(scope);
972
1428
  this.emit();
973
1429
  }
974
1430
  }