@assistant-ui/react-ag-ui 0.0.56 → 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 (42) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
  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 +54 -25
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +419 -123
  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 +12 -9
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +10 -10
  29. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  30. package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
  31. package/src/runtime/adapter/conversions.ts +128 -36
  32. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  33. package/src/runtime/adapter/run-aggregator.ts +670 -148
  34. package/src/runtime/adapter/subscriber.ts +10 -1
  35. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  36. package/src/runtime/adapter/tool-approval.ts +17 -12
  37. package/src/runtime/event-parser.ts +216 -36
  38. package/src/runtime/types.ts +70 -12
  39. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  40. package/src/useAgUiRuntime.history.test.tsx +76 -0
  41. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  42. package/src/useAgUiRuntime.ts +12 -9
@@ -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;
@@ -28,6 +74,9 @@ export type AgUiOpaqueReasoning = {
28
74
  };
29
75
 
30
76
  export type AgUiCustomMetadata = {
77
+ /** Wire role restored on export for messages the internal model cannot
78
+ * represent (a developer record rides as a system message). */
79
+ role?: "developer";
31
80
  interrupts?: AgUiInterrupt[];
32
81
  opaqueReasoning?: AgUiOpaqueReasoning[];
33
82
  };
@@ -47,6 +96,7 @@ type ToolCallState = {
47
96
  mcpAppServerId?: string;
48
97
  modelContent?: ToolModelContentPart[];
49
98
  snapshotResultApplied: boolean;
99
+ subagentRunId?: string;
50
100
  };
51
101
 
52
102
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
@@ -63,6 +113,7 @@ export type RunAggregatorOptions = {
63
113
  logger: Logger;
64
114
  emit: Emit;
65
115
  onServerMessageId?: (messageId: string) => void;
116
+ onTextMessageStart?: (messageId: string) => void;
66
117
  };
67
118
 
68
119
  /**
@@ -80,32 +131,52 @@ export class RunAggregator {
80
131
  private readonly showThinking: boolean;
81
132
  private readonly logger: Logger;
82
133
  private readonly onServerMessageId: ((messageId: string) => void) | undefined;
134
+ private readonly onTextMessageStart:
135
+ | ((messageId: string) => void)
136
+ | undefined;
83
137
 
84
138
  private status: ChatModelRunResult["status"] | undefined;
85
139
  private interrupts: AgUiInterrupt[] | undefined;
86
- private readonly textParts = new Map<
87
- string,
88
- { buffer: string; touched: boolean }
89
- >();
90
- private activeTextMessageId: string | undefined;
140
+ private readonly textParts = new Map<string, { buffer: string }>();
141
+ private readonly activeTextMessageIdByScope = new Map<string, string>();
91
142
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
92
143
  private readonly reasoningSignatures = new Map<string, string>();
144
+ private readonly reasoningSignatureIds = new Map<string, string>();
145
+ // Signatures captured while thinking is hidden have no block to live on;
146
+ // they are transport state that must survive to the next run input. The
147
+ // claim rules mirror the visible path: an id-carrying block, or an open
148
+ // anonymous one.
149
+ private readonly hiddenSignatures = new Map<string, string>();
150
+ private readonly hiddenSignatureAnchors = new Map<string, number>();
151
+ private readonly hiddenBlockAnchors = new Map<string, number>();
152
+ private hiddenAnonymousAnchor: number | undefined;
153
+ private readonly hiddenReasoningIds = new Set<string>();
154
+ private hiddenActiveReasoning: "none" | "anonymous" | "identified" = "none";
155
+ private hasEmittedOpaqueReasoning = false;
156
+ private readonly loggedDroppedOpaqueIds = new Set<string>();
93
157
  private readonly reasoningMessageIds = new Map<string, string>();
94
158
  private readonly anonymousReasoningKeys = new Set<string>();
95
- 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;
96
170
  private reasoningPartCounter = 0;
97
171
  private readonly toolCalls = new Map<string, ToolCallState>();
98
172
  private readonly a2uiBuckets = new Map<string, A2uiState>();
99
173
  private readonly a2uiToolCallIds = new Set<string>();
100
- private lastResolvedToolCallId: string | undefined;
101
- private readonly partOrder: (
102
- | { kind: "text"; key: string }
103
- | { kind: "reasoning"; key: string }
104
- | { kind: "tool-call"; toolCallId: string }
105
- | { kind: "data"; name: string; value: unknown }
106
- )[] = [];
174
+ private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
175
+ private readonly partOrder: PartOrderEntry[] = [];
107
176
  private textPartCounter = 0;
108
177
  private serverMessageIdReported = false;
178
+ private reportedServerMessageId: string | undefined;
179
+ private lastTextMessageId: string | undefined;
109
180
 
110
181
  private streamStartTime: number | undefined;
111
182
  private firstTokenTime: number | undefined;
@@ -116,6 +187,7 @@ export class RunAggregator {
116
187
  this.showThinking = options.showThinking;
117
188
  this.logger = options.logger;
118
189
  this.onServerMessageId = options.onServerMessageId;
190
+ this.onTextMessageStart = options.onTextMessageStart;
119
191
  }
120
192
 
121
193
  hasToolCall(toolCallId: string): boolean {
@@ -125,22 +197,11 @@ export class RunAggregator {
125
197
  handle(event: AgUiEvent): void {
126
198
  switch (event.type) {
127
199
  case "RUN_STARTED": {
128
- this.clearTextParts();
129
- this.reasoningParts.clear();
130
- this.reasoningSignatures.clear();
131
- this.reasoningMessageIds.clear();
132
- this.anonymousReasoningKeys.clear();
133
- this.activeReasoningKey = undefined;
134
- this.reasoningPartCounter = 0;
135
- this.toolCalls.clear();
136
- this.a2uiBuckets.clear();
137
- this.a2uiToolCallIds.clear();
138
- this.lastResolvedToolCallId = undefined;
139
- this.partOrder.length = 0;
140
- this.textPartCounter = 0;
141
- this.activeTextMessageId = undefined;
200
+ this.resetMessageParts();
142
201
  this.interrupts = undefined;
143
202
  this.serverMessageIdReported = false;
203
+ this.lastTextMessageId = undefined;
204
+ this.reportedServerMessageId = undefined;
144
205
  this.streamStartTime = Date.now();
145
206
  this.firstTokenTime = undefined;
146
207
  this.totalChunks = 0;
@@ -157,14 +218,18 @@ export class RunAggregator {
157
218
  }
158
219
 
159
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.
160
225
  const hasUnresolvedToolCalls = Array.from(this.toolCalls.values()).some(
161
226
  (tc) => tc.result === undefined,
162
227
  );
163
228
 
164
- this.status =
165
- event.outcome?.type === "success" || !hasUnresolvedToolCalls
166
- ? { type: "complete", reason: "unknown" }
167
- : { 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);
168
233
  this.emit();
169
234
  break;
170
235
  }
@@ -174,47 +239,61 @@ export class RunAggregator {
174
239
  reason: "error",
175
240
  ...(event.message !== undefined ? { error: event.message } : {}),
176
241
  };
242
+ this.closeOpenSubagentRuns(this.status);
177
243
  this.emit();
178
244
  break;
179
245
  }
180
246
  case "RUN_CANCELLED": {
181
247
  this.status = { type: "incomplete", reason: "cancelled" };
248
+ this.closeOpenSubagentRuns(this.status);
182
249
  this.emit();
183
250
  break;
184
251
  }
185
252
 
186
253
  case "TEXT_MESSAGE_START": {
187
- this.reportServerMessageId(event.messageId);
188
- const id = this.startTextMessage(event.messageId);
189
- if (id) {
190
- this.markTextPartTouched(id);
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;
191
259
  }
260
+ this.startTextMessage(scope, event.messageId);
192
261
  this.emit();
193
262
  break;
194
263
  }
195
264
  case "TEXT_MESSAGE_CONTENT":
196
265
  case "TEXT_MESSAGE_CHUNK": {
197
266
  const incomingId = "messageId" in event ? event.messageId : undefined;
198
- this.reportServerMessageId(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
+ }
199
273
  if (!event.delta) break;
200
274
  this.recordFirstToken();
201
- const id = this.resolveTextMessageId(incomingId);
275
+ const id = this.resolveTextMessageId(scope, incomingId);
202
276
  this.appendText(id, event.delta);
203
277
  this.totalChunks++;
204
278
  this.emit();
205
279
  break;
206
280
  }
207
281
  case "TEXT_MESSAGE_END": {
208
- this.reportServerMessageId(event.messageId);
209
- if (event.messageId && this.activeTextMessageId === event.messageId) {
210
- 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);
211
290
  }
212
291
  this.emit();
213
292
  break;
214
293
  }
215
294
 
216
295
  case "CUSTOM": {
217
- this.activeTextMessageId = undefined;
296
+ this.activeTextMessageIdByScope.delete(ROOT_SCOPE);
218
297
  this.partOrder.push({
219
298
  kind: "data",
220
299
  name: event.name,
@@ -229,33 +308,58 @@ export class RunAggregator {
229
308
  case "REASONING_START":
230
309
  case "REASONING_MESSAGE_START":
231
310
  this.handleReasoningStart(
311
+ this.scopeOf("subagentRunId" in event ? event : {}),
232
312
  "messageId" in event ? event.messageId : undefined,
233
313
  event.type === "REASONING_MESSAGE_START",
234
314
  );
235
315
  break;
236
316
  case "REASONING_ENCRYPTED_VALUE":
237
317
  if (event.subtype === "message") {
318
+ const scope = this.scopeOf(event);
238
319
  // entityId names any message, not necessarily a reasoning one, so an
239
320
  // unmatched id may only claim a block that has no id to contradict it.
240
- const active = this.activeReasoningKey;
241
- const key = this.reasoningParts.has(event.entityId)
242
- ? event.entityId
243
- : active !== undefined && this.anonymousReasoningKeys.has(active)
244
- ? active
245
- : undefined;
321
+ const active = this.activeReasoningKeyByScope.get(scope);
322
+ const entityKey = this.partKey(scope, event.entityId);
323
+ const key = this.showThinking
324
+ ? this.reasoningParts.has(entityKey)
325
+ ? entityKey
326
+ : active !== undefined && this.anonymousReasoningKeys.has(active)
327
+ ? active
328
+ : undefined
329
+ : undefined;
246
330
  if (key !== undefined) {
247
331
  this.reasoningSignatures.set(key, event.encryptedValue);
332
+ this.reasoningSignatureIds.set(key, event.entityId);
333
+ this.emit();
334
+ } else if (
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 &&
339
+ event.entityId.trim().length > 0 &&
340
+ event.encryptedValue.trim().length > 0 &&
341
+ (this.hiddenReasoningIds.has(event.entityId) ||
342
+ this.hiddenActiveReasoning === "anonymous")
343
+ ) {
344
+ this.hiddenSignatures.set(event.entityId, event.encryptedValue);
345
+ this.hiddenSignatureAnchors.set(
346
+ event.entityId,
347
+ this.hiddenBlockAnchors.get(event.entityId) ??
348
+ this.hiddenAnonymousAnchor ??
349
+ this.partOrder.length,
350
+ );
248
351
  this.emit();
249
352
  }
250
353
  }
251
354
  break;
252
355
  case "THINKING_TEXT_MESSAGE_CONTENT":
253
- this.handleReasoningContent(event.delta);
356
+ this.handleReasoningContent(ROOT_SCOPE, event.delta);
254
357
  this.totalChunks++;
255
358
  this.recordFirstToken();
256
359
  break;
257
360
  case "REASONING_MESSAGE_CONTENT":
258
361
  this.handleReasoningContent(
362
+ this.scopeOf(event),
259
363
  event.delta,
260
364
  "messageId" in event ? event.messageId : undefined,
261
365
  true,
@@ -265,14 +369,20 @@ export class RunAggregator {
265
369
  break;
266
370
  case "THINKING_TEXT_MESSAGE_END":
267
371
  case "THINKING_END":
372
+ this.handleReasoningEnd(ROOT_SCOPE);
373
+ break;
268
374
  case "REASONING_MESSAGE_END":
269
375
  case "REASONING_END":
270
- this.handleReasoningEnd();
376
+ this.handleReasoningEnd(this.scopeOf(event));
271
377
  break;
272
378
 
273
379
  case "TOOL_CALL_START": {
274
- this.reportServerMessageId(event.parentMessageId);
380
+ const scope = this.scopeOf(event);
381
+ if (scope === ROOT_SCOPE) {
382
+ this.reportServerMessageId(event.parentMessageId);
383
+ }
275
384
  this.startToolCall(
385
+ scope,
276
386
  event.toolCallId,
277
387
  event.toolCallName,
278
388
  event.parentMessageId,
@@ -282,7 +392,10 @@ export class RunAggregator {
282
392
  }
283
393
  case "TOOL_CALL_ARGS":
284
394
  case "TOOL_CALL_CHUNK": {
285
- if (event.type === "TOOL_CALL_CHUNK") {
395
+ if (
396
+ event.type === "TOOL_CALL_CHUNK" &&
397
+ this.scopeOf(event) === ROOT_SCOPE
398
+ ) {
286
399
  this.reportServerMessageId(event.parentMessageId);
287
400
  }
288
401
  if (!event.delta) break;
@@ -296,6 +409,7 @@ export class RunAggregator {
296
409
  }
297
410
  case "TOOL_CALL_RESULT": {
298
411
  this.finishToolCall(
412
+ this.scopeOf(event),
299
413
  event.toolCallId,
300
414
  event.content ?? "",
301
415
  typeof event.mcpResult?.isError === "boolean"
@@ -315,12 +429,15 @@ export class RunAggregator {
315
429
  break;
316
430
  }
317
431
  if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
432
+ const activityScope = this.scopeOf(event);
318
433
  const toolCallId = event.content.toolCallId;
434
+ const fallbackId =
435
+ this.lastResolvedToolCallIdByScope.get(activityScope);
319
436
  const entry =
320
437
  typeof toolCallId === "string"
321
438
  ? this.toolCalls.get(toolCallId)
322
- : this.lastResolvedToolCallId
323
- ? this.toolCalls.get(this.lastResolvedToolCallId)
439
+ : fallbackId
440
+ ? this.toolCalls.get(fallbackId)
324
441
  : undefined;
325
442
  const resourceUri = event.content.resourceUri;
326
443
  if (
@@ -363,6 +480,70 @@ export class RunAggregator {
363
480
  break;
364
481
  }
365
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
+
366
547
  default: {
367
548
  this.logger.debug?.("[agui] aggregator ignored event", event);
368
549
  }
@@ -420,12 +601,14 @@ export class RunAggregator {
420
601
  if (!this.toolCalls.has(toolCallId)) {
421
602
  this.partOrder.push({ kind: "tool-call", toolCallId });
422
603
  }
604
+ this.nestingMemo = undefined;
423
605
  this.toolCalls.set(toolCallId, entry);
424
606
  this.a2uiToolCallIds.add(toolCallId);
425
607
  }
426
608
 
427
609
  for (const toolCallId of this.a2uiToolCallIds) {
428
610
  if (activeToolCallIds.has(toolCallId)) continue;
611
+ this.nestingMemo = undefined;
429
612
  this.toolCalls.delete(toolCallId);
430
613
  const partIndex = this.partOrder.findIndex(
431
614
  (part) => part.kind === "tool-call" && part.toolCallId === toolCallId,
@@ -436,8 +619,13 @@ export class RunAggregator {
436
619
  }
437
620
 
438
621
  private reportServerMessageId(messageId: string | undefined): void {
439
- if (this.serverMessageIdReported || !messageId) return;
622
+ if (!messageId) return;
623
+ if (this.lastTextMessageId === undefined) {
624
+ this.lastTextMessageId = messageId;
625
+ }
626
+ if (this.serverMessageIdReported) return;
440
627
  this.serverMessageIdReported = true;
628
+ this.reportedServerMessageId = messageId;
441
629
  this.onServerMessageId?.(messageId);
442
630
  }
443
631
 
@@ -445,75 +633,136 @@ export class RunAggregator {
445
633
  this.textParts.clear();
446
634
  }
447
635
 
636
+ private resetMessageParts(): void {
637
+ this.clearTextParts();
638
+ this.reasoningParts.clear();
639
+ this.reasoningSignatures.clear();
640
+ this.reasoningSignatureIds.clear();
641
+ this.reasoningMessageIds.clear();
642
+ this.hiddenSignatures.clear();
643
+ this.hiddenSignatureAnchors.clear();
644
+ this.hiddenBlockAnchors.clear();
645
+ this.hiddenAnonymousAnchor = undefined;
646
+ this.hiddenReasoningIds.clear();
647
+ this.hiddenActiveReasoning = "none";
648
+ this.loggedDroppedOpaqueIds.clear();
649
+ this.anonymousReasoningKeys.clear();
650
+ this.activeReasoningKeyByScope.clear();
651
+ this.nestingMemo = undefined;
652
+ this.subagentRuns.clear();
653
+ this.reasoningPartCounter = 0;
654
+ this.toolCalls.clear();
655
+ this.a2uiBuckets.clear();
656
+ this.a2uiToolCallIds.clear();
657
+ this.lastResolvedToolCallIdByScope.clear();
658
+ this.partOrder.length = 0;
659
+ this.textPartCounter = 0;
660
+ this.activeTextMessageIdByScope.clear();
661
+ this.reportedServerMessageId = undefined;
662
+ }
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
+
683
+ private beginDistinctTextMessage(messageId: string | undefined): void {
684
+ if (
685
+ !messageId ||
686
+ this.lastTextMessageId === undefined ||
687
+ this.lastTextMessageId === messageId ||
688
+ !this.onTextMessageStart
689
+ ) {
690
+ return;
691
+ }
692
+ this.resetMessageParts();
693
+ this.onTextMessageStart(messageId);
694
+ }
695
+
448
696
  private generateTextKey(): string {
449
697
  this.textPartCounter += 1;
450
698
  return `text-${this.textPartCounter}`;
451
699
  }
452
700
 
453
- private startTextMessage(messageId?: string): string {
701
+ private startTextMessage(scope: string, messageId?: string): string {
454
702
  const id = messageId ?? this.generateTextKey();
455
- this.ensureTextPart(id);
456
- this.activeTextMessageId = id;
457
- return id;
703
+ const key = this.ensureTextPart(scope, id);
704
+ this.activeTextMessageIdByScope.set(scope, key);
705
+ return key;
458
706
  }
459
707
 
460
- private resolveTextMessageId(messageId?: string): string {
708
+ private resolveTextMessageId(scope: string, messageId?: string): string {
461
709
  if (messageId) {
462
- this.ensureTextPart(messageId);
463
- this.activeTextMessageId = messageId;
464
- return messageId;
710
+ const key = this.ensureTextPart(scope, messageId);
711
+ this.activeTextMessageIdByScope.set(scope, key);
712
+ return key;
465
713
  }
466
714
 
467
- if (this.activeTextMessageId) {
468
- return this.activeTextMessageId;
715
+ const active = this.activeTextMessageIdByScope.get(scope);
716
+ if (active) {
717
+ return active;
469
718
  }
470
719
 
471
- const generated = this.generateTextKey();
472
- this.ensureTextPart(generated);
473
- this.activeTextMessageId = generated;
474
- return generated;
720
+ const key = this.ensureTextPart(scope, this.generateTextKey());
721
+ this.activeTextMessageIdByScope.set(scope, key);
722
+ return key;
475
723
  }
476
724
 
477
- private ensureTextPart(id: string): void {
478
- if (!this.textParts.has(id)) {
479
- this.textParts.set(id, { buffer: "", touched: false });
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: "" });
480
729
  if (
481
- !this.partOrder.some((part) => part.kind === "text" && part.key === id)
730
+ !this.partOrder.some((part) => part.kind === "text" && part.key === key)
482
731
  ) {
483
- 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
+ );
484
737
  }
485
738
  }
486
- }
487
-
488
- private markTextPartTouched(id: string): void {
489
- const entry = this.textParts.get(id);
490
- if (!entry) return;
491
- entry.touched = true;
739
+ return key;
492
740
  }
493
741
 
494
742
  private appendText(id: string, delta: string): void {
495
- this.ensureTextPart(id);
496
743
  const entry = this.textParts.get(id);
497
744
  if (!entry) return;
498
745
  entry.buffer += delta;
499
- entry.touched = true;
500
746
  }
501
747
 
502
748
  private startToolCall(
749
+ scope: string,
503
750
  id: string | undefined,
504
751
  name?: string,
505
752
  parentMessageId?: string,
506
753
  ) {
507
754
  if (!id) return;
508
755
  // A new tool call acts as a boundary: any anonymous text that arrives
509
- // after it should be a new part, not appended to the pre-tool text.
510
- 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);
511
760
  if (
512
761
  !this.partOrder.some(
513
762
  (part) => part.kind === "tool-call" && part.toolCallId === id,
514
763
  )
515
764
  ) {
516
- this.insertToolPart(id, parentMessageId);
765
+ this.insertToolPart(scope, id, parentMessageId);
517
766
  }
518
767
  const state: ToolCallState = {
519
768
  toolCallId: id,
@@ -527,16 +776,21 @@ export class RunAggregator {
527
776
  if (parentMessageId) {
528
777
  state.parentMessageId = parentMessageId;
529
778
  }
779
+ if (scope !== ROOT_SCOPE) {
780
+ state.subagentRunId = scope;
781
+ }
782
+ this.nestingMemo = undefined;
530
783
  this.toolCalls.set(id, state);
531
784
  }
532
785
 
533
786
  // The message and tool-call channels are unordered on the wire, so anchor a
534
787
  // tool call under its parentMessageId text part instead of appending it.
535
- private insertToolPart(id: string, parentMessageId?: string) {
788
+ private insertToolPart(scope: string, id: string, parentMessageId?: string) {
536
789
  const entry = { kind: "tool-call", toolCallId: id } as const;
537
790
  if (parentMessageId) {
791
+ const parentKey = this.partKey(scope, parentMessageId);
538
792
  const parentIndex = this.partOrder.findIndex(
539
- (part) => part.kind === "text" && part.key === parentMessageId,
793
+ (part) => part.kind === "text" && part.key === parentKey,
540
794
  );
541
795
  if (parentIndex !== -1) {
542
796
  let insertAt = parentIndex + 1;
@@ -576,6 +830,7 @@ export class RunAggregator {
576
830
  }
577
831
 
578
832
  private finishToolCall(
833
+ scope: string,
579
834
  id: string,
580
835
  content: string,
581
836
  isError?: boolean,
@@ -593,7 +848,9 @@ export class RunAggregator {
593
848
  result: undefined,
594
849
  isError: undefined,
595
850
  snapshotResultApplied: false,
851
+ ...(scope !== ROOT_SCOPE ? { subagentRunId: scope } : {}),
596
852
  };
853
+ this.nestingMemo = undefined;
597
854
  this.toolCalls.set(id, entry);
598
855
  }
599
856
  if (
@@ -626,25 +883,173 @@ export class RunAggregator {
626
883
  if (toolMessageId) {
627
884
  entry.toolMessageId = toolMessageId;
628
885
  }
629
- this.lastResolvedToolCallId = id;
886
+ this.lastResolvedToolCallIdByScope.set(scope, id);
630
887
  }
631
888
 
632
- private emit(): void {
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
+ }
913
+
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
+ }
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[] {
633
989
  const snapshot: ThreadAssistantMessagePart[] = [];
634
- // A run that ended incomplete can no longer be resumed, so a gate left over
635
- // from an earlier interrupt outcome is unanswerable and must not stay
636
- // projected. The interrupts themselves are kept on the message, since the
637
- // bespoke hooks read that payload.
638
- const approvals = projectAgUiToolApprovals(
639
- this.status?.type === "requires-action" ? this.interrupts : undefined,
640
- new Set(this.toolCalls.keys()),
641
- );
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;
642
1046
 
643
- for (const part of this.partOrder) {
644
1047
  if (part.kind === "reasoning") {
645
1048
  if (this.showThinking) {
646
1049
  const buffer = this.reasoningParts.get(part.key) ?? "";
647
- if (buffer.length > 0 || this.activeReasoningKey === part.key) {
1050
+ const isActive =
1051
+ this.activeReasoningKeyByScope.get(rawScope) === part.key;
1052
+ if (buffer.length > 0 || isActive) {
648
1053
  const encryptedValue = this.reasoningSignatures.get(part.key);
649
1054
  const reasoningId = this.reasoningMessageIds.get(part.key);
650
1055
  const meta = {
@@ -658,6 +1063,20 @@ export class RunAggregator {
658
1063
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
659
1064
  : {}),
660
1065
  } as const);
1066
+ materialized();
1067
+ } else if (isRoot && ctx.root) {
1068
+ // A retracted empty block still carries transport state: without
1069
+ // a part to live on, its signature rides the message metadata,
1070
+ // matching the shape a snapshot reload produces.
1071
+ const encryptedValue = this.reasoningSignatures.get(part.key);
1072
+ const id = this.reasoningSignatureIds.get(part.key);
1073
+ if (id?.trim() && encryptedValue?.trim()) {
1074
+ ctx.root.opaqueCandidates.push({
1075
+ id,
1076
+ encryptedValue,
1077
+ anchor: index + 1,
1078
+ });
1079
+ }
661
1080
  }
662
1081
  }
663
1082
  continue;
@@ -665,8 +1084,9 @@ export class RunAggregator {
665
1084
 
666
1085
  if (part.kind === "text") {
667
1086
  const entry = this.textParts.get(part.key);
668
- if (entry?.touched) {
1087
+ if (entry && entry.buffer.trim().length > 0) {
669
1088
  snapshot.push({ type: "text", text: entry.buffer } as const);
1089
+ materialized();
670
1090
  }
671
1091
  continue;
672
1092
  }
@@ -677,52 +1097,116 @@ export class RunAggregator {
677
1097
  name: part.name,
678
1098
  data: part.value,
679
1099
  });
1100
+ materialized();
680
1101
  continue;
681
1102
  }
1103
+ }
682
1104
 
683
- const entry = this.toolCalls.get(part.toolCallId);
684
- if (!entry) continue;
685
- const approval = approvals.get(entry.toolCallId);
686
- const toolPart: ToolCallMessagePart = {
687
- type: "tool-call",
688
- toolCallId: entry.toolCallId,
689
- toolName: entry.toolCallName,
690
- args: (entry.parsedArgs ?? {}) as any,
691
- argsText: entry.argsText,
692
- ...(approval ? { approval } : {}),
693
- ...(entry.result !== undefined ? { result: entry.result } : {}),
694
- ...(entry.modelContent !== undefined
695
- ? { modelContent: entry.modelContent }
696
- : {}),
697
- ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
698
- ...(entry.mcpAppResourceUri
699
- ? {
700
- mcp: {
701
- app: {
702
- resourceUri: entry.mcpAppResourceUri,
703
- ...(entry.mcpAppServerId
704
- ? { serverId: entry.mcpAppServerId }
705
- : {}),
706
- },
707
- },
708
- }
709
- : {}),
710
- ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
711
- ...(entry.toolMessageId
712
- ? { unstable_toolMessageId: entry.toolMessageId }
713
- : {}),
714
- } as ToolCallMessagePart & { unstable_toolMessageId?: string };
715
- snapshot.push(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 }]);
716
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;
717
1161
 
1162
+ // An anonymous claim promotes the signature's entityId to a wire message
1163
+ // id; when that id actually names a text message or the adopted assistant
1164
+ // message id (which can also arrive via TOOL_CALL_START.parentMessageId),
1165
+ // replaying it as its own record would put two wire records under one id,
1166
+ // so such entries are dropped instead — the same outcome the visible
1167
+ // path's claim guard produces for a signature that is "not for this
1168
+ // block". Entries with no materialized part after their anchor trail the
1169
+ // assistant record, matching the snapshot path's anchor/after
1170
+ // bookkeeping.
1171
+ const toolMessageIds = new Set<string>();
1172
+ for (const call of this.toolCalls.values()) {
1173
+ if (call.toolMessageId) toolMessageIds.add(call.toolMessageId);
1174
+ }
1175
+ opaqueCandidates.sort((a, b) => a.anchor - b.anchor);
1176
+ const publishableOpaqueReasoning = opaqueCandidates
1177
+ .filter((entry) => {
1178
+ const collides =
1179
+ this.textParts.has(entry.id) ||
1180
+ entry.id === this.reportedServerMessageId ||
1181
+ toolMessageIds.has(entry.id);
1182
+ if (collides && !this.loggedDroppedOpaqueIds.has(entry.id)) {
1183
+ this.loggedDroppedOpaqueIds.add(entry.id);
1184
+ this.logger.debug?.(
1185
+ "[agui] aggregator dropped opaque reasoning signature: id collides with a message id",
1186
+ entry.id,
1187
+ );
1188
+ }
1189
+ return !collides;
1190
+ })
1191
+ .map(({ anchor, ...entry }) =>
1192
+ lastMaterializedIndex < anchor ? { ...entry, after: true } : entry,
1193
+ );
1194
+ if (publishableOpaqueReasoning.length > 0)
1195
+ this.hasEmittedOpaqueReasoning = true;
1196
+ // Once an opaque entry has been published this run, the key keeps being
1197
+ // emitted (empty when withdrawn) so the namespace merge can retract it.
1198
+ const includeOpaque = this.hasEmittedOpaqueReasoning;
718
1199
  const timing = this.getTiming();
719
1200
  const metadata = {
720
1201
  ...(timing ? { timing } : {}),
721
- ...(this.interrupts
1202
+ ...(this.interrupts || includeOpaque
722
1203
  ? {
723
1204
  custom: {
724
1205
  [AG_UI_METADATA_NAMESPACE]: {
725
- interrupts: this.interrupts,
1206
+ ...(this.interrupts ? { interrupts: this.interrupts } : {}),
1207
+ ...(includeOpaque
1208
+ ? { opaqueReasoning: publishableOpaqueReasoning }
1209
+ : {}),
726
1210
  } satisfies AgUiCustomMetadata,
727
1211
  },
728
1212
  }
@@ -777,12 +1261,33 @@ export class RunAggregator {
777
1261
  };
778
1262
  }
779
1263
 
780
- private handleReasoningStart(messageId?: string, isMessageId = false): void {
781
- if (!this.showThinking) return;
1264
+ private handleReasoningStart(
1265
+ scope: string,
1266
+ messageId?: string,
1267
+ isMessageId = false,
1268
+ ): void {
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;
1273
+ const anchor = this.partOrder.length;
1274
+ if (messageId === undefined) {
1275
+ this.hiddenActiveReasoning = "anonymous";
1276
+ this.hiddenAnonymousAnchor = anchor;
1277
+ } else {
1278
+ this.hiddenReasoningIds.add(messageId);
1279
+ this.hiddenBlockAnchors.set(messageId, anchor);
1280
+ this.hiddenActiveReasoning = "identified";
1281
+ }
1282
+ return;
1283
+ }
782
1284
  // A reasoning block acts as a boundary: anonymous text arriving after it
783
- // should be a new part, not appended to any pre-reasoning text.
784
- this.activeTextMessageId = undefined;
785
- 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
+ );
786
1291
  // Two different questions: which id may be replayed as a ReasoningMessage.id
787
1292
  // (only the message-scoped aliases carry one), and which block an unmatched
788
1293
  // signature may claim (any block opened without an id at all).
@@ -793,31 +1298,48 @@ export class RunAggregator {
793
1298
  }
794
1299
  if (!this.reasoningParts.has(key)) {
795
1300
  this.reasoningParts.set(key, "");
796
- 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
+ );
797
1306
  }
798
- this.activeReasoningKey = key;
1307
+ this.activeReasoningKeyByScope.set(scope, key);
799
1308
  this.emit();
800
1309
  }
801
1310
 
802
1311
  private handleReasoningContent(
1312
+ scope: string,
803
1313
  delta: string,
804
1314
  messageId?: string,
805
1315
  isMessageId = false,
806
1316
  ): void {
807
- if (!this.showThinking || !delta) return;
808
- if (!this.activeReasoningKey) {
1317
+ if (!delta) return;
1318
+ if (!this.showThinking) {
1319
+ // Content without a preceding START still names the block (the visible
1320
+ // path opens it lazily); register it so a signature can claim it.
1321
+ if (scope !== ROOT_SCOPE) return;
1322
+ if (this.hiddenActiveReasoning === "none") {
1323
+ this.handleReasoningStart(scope, messageId, isMessageId);
1324
+ }
1325
+ return;
1326
+ }
1327
+ if (!this.activeReasoningKeyByScope.has(scope)) {
809
1328
  // Content arrived without a preceding START — create the slot lazily.
810
- this.handleReasoningStart(messageId, isMessageId);
1329
+ this.handleReasoningStart(scope, messageId, isMessageId);
811
1330
  }
812
- const key = this.activeReasoningKey;
1331
+ const key = this.activeReasoningKeyByScope.get(scope);
813
1332
  if (!key) return;
814
1333
  this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
815
1334
  this.emit();
816
1335
  }
817
1336
 
818
- private handleReasoningEnd(): void {
819
- if (!this.showThinking) return;
820
- this.activeReasoningKey = undefined;
1337
+ private handleReasoningEnd(scope: string): void {
1338
+ if (!this.showThinking) {
1339
+ if (scope === ROOT_SCOPE) this.hiddenActiveReasoning = "none";
1340
+ return;
1341
+ }
1342
+ this.activeReasoningKeyByScope.delete(scope);
821
1343
  this.emit();
822
1344
  }
823
1345
  }