@assistant-ui/react-ag-ui 0.1.4 → 0.1.6

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.
@@ -175,20 +175,110 @@ export const isPlainObject = (
175
175
  ): value is Record<string, unknown> =>
176
176
  !!value && typeof value === "object" && !Array.isArray(value);
177
177
 
178
+ // One assistant message worth of parts. A run that moves on to another
179
+ // message keeps the previous buffer, so later events naming it still land.
180
+ type MessageBuffer = {
181
+ id: string | undefined;
182
+ textParts: Map<string, { buffer: string }>;
183
+ openTextFrames: Set<string>;
184
+ activeTextMessageIdByScope: Map<string, string>;
185
+ reasoningParts: Map<string, { buffer: string; timing?: MessagePartTiming }>;
186
+ reasoningSignatures: Map<string, string>;
187
+ reasoningSignatureIds: Map<string, string>;
188
+ hiddenSignatures: Map<string, string>;
189
+ hiddenSignatureAnchors: Map<string, number>;
190
+ hiddenBlockAnchors: Map<string, number>;
191
+ hiddenReasoningIds: Set<string>;
192
+ hiddenActiveReasoningId: string | undefined;
193
+ hasEmittedOpaqueReasoning: boolean;
194
+ loggedDroppedOpaqueIds: Set<string>;
195
+ reasoningMessageIds: Map<string, string>;
196
+ activeReasoningKeyByScope: Map<string, string>;
197
+ subagentRuns: Map<string, SubagentRunState>;
198
+ // The nesting graph only moves when a run is announced or a tool call is
199
+ // created, which is rare next to emit(), so it is derived once per change
200
+ // rather than per streamed delta.
201
+ nestingMemo:
202
+ | {
203
+ subagentsByParentToolCallId: Map<string, string[]>;
204
+ reachable: Set<string>;
205
+ }
206
+ | undefined;
207
+ toolCalls: Map<string, ToolCallState>;
208
+ a2uiBuckets: Map<string, A2uiState>;
209
+ a2uiToolCallIds: Set<string>;
210
+ activityParts: Map<
211
+ string,
212
+ { kind: "data"; name: string; value: unknown; subagentRunId?: string }
213
+ >;
214
+ lastResolvedToolCallIdByScope: Map<string, string>;
215
+ partOrder: PartOrderEntry[];
216
+ lastReasoningBeforeToolCall: PartOrderEntry | undefined;
217
+ textPartCounter: number;
218
+ reportedServerMessageId: string | undefined;
219
+ };
220
+
221
+ const createMessageBuffer = (id?: string): MessageBuffer => ({
222
+ id,
223
+ textParts: new Map(),
224
+ openTextFrames: new Set(),
225
+ activeTextMessageIdByScope: new Map(),
226
+ reasoningParts: new Map(),
227
+ reasoningSignatures: new Map(),
228
+ reasoningSignatureIds: new Map(),
229
+ hiddenSignatures: new Map(),
230
+ hiddenSignatureAnchors: new Map(),
231
+ hiddenBlockAnchors: new Map(),
232
+ hiddenReasoningIds: new Set(),
233
+ hiddenActiveReasoningId: undefined,
234
+ hasEmittedOpaqueReasoning: false,
235
+ loggedDroppedOpaqueIds: new Set(),
236
+ reasoningMessageIds: new Map(),
237
+ activeReasoningKeyByScope: new Map(),
238
+ subagentRuns: new Map(),
239
+ nestingMemo: undefined,
240
+ toolCalls: new Map(),
241
+ a2uiBuckets: new Map(),
242
+ a2uiToolCallIds: new Set(),
243
+ activityParts: new Map(),
244
+ lastResolvedToolCallIdByScope: new Map(),
245
+ partOrder: [],
246
+ lastReasoningBeforeToolCall: undefined,
247
+ textPartCounter: 0,
248
+ reportedServerMessageId: undefined,
249
+ });
250
+
251
+ export type RetainedMessageState = {
252
+ messageId: string;
253
+ /** A root text frame or a sub-agent run is still open. */
254
+ streaming: boolean;
255
+ openToolCallIds: string[];
256
+ };
257
+
178
258
  export type RunAggregatorOptions = {
179
259
  showThinking: boolean;
180
260
  joinStrategy?: JoinStrategy | undefined;
181
261
  logger: Logger;
182
262
  emit: Emit;
263
+ onRetainedMessage?: (
264
+ update: ChatModelRunResult,
265
+ state: RetainedMessageState,
266
+ ) => void;
267
+ canRetainMessage?: (messageId: string) => boolean;
183
268
  onServerMessageId?: (messageId: string) => void;
184
- onTextMessageStart?: (messageId: string, openToolCallIds: string[]) => void;
269
+ /** Returns the id the outgoing message lives under, when it may keep receiving events. */
270
+ onTextMessageStart?: (
271
+ messageId: string,
272
+ openToolCallIds: string[],
273
+ ) => string | void;
185
274
  };
186
275
 
187
276
  /**
188
277
  * Collects AG-UI events into assistant-ui run snapshots that can be yielded from a ChatModelAdapter.
189
278
  *
190
- * The aggregator keeps a single assistant message worth of parts. Each incoming event updates the parts and
191
- * emits a fresh snapshot through the provided `emit` callback. `CUSTOM` events
279
+ * Each incoming event updates the parts of the assistant message it names and
280
+ * emits a fresh snapshot: through `emit` for the message the run streams into,
281
+ * and through `onRetainedMessage` for one it moved on from. `CUSTOM` events
192
282
  * become canonical data parts; integration plumbing names such as
193
283
  * `on_interrupt`, `PredictState`, `Exit`, `hook_error`, `state_update_error`,
194
284
  * `system:*`, and `MultiAgentHandoff` are forwarded the same way, so apps only
@@ -200,54 +290,21 @@ export class RunAggregator {
200
290
  private readonly joinStrategy: JoinStrategy | undefined;
201
291
  private readonly logger: Logger;
202
292
  private readonly onServerMessageId: ((messageId: string) => void) | undefined;
203
- private readonly onTextMessageStart:
204
- | ((messageId: string, openToolCallIds: string[]) => void)
205
- | undefined;
293
+ private readonly onTextMessageStart: RunAggregatorOptions["onTextMessageStart"];
294
+ private readonly onRetainedMessage: RunAggregatorOptions["onRetainedMessage"];
295
+ private readonly canRetainMessage: RunAggregatorOptions["canRetainMessage"];
206
296
 
207
297
  private status: ChatModelRunResult["status"] | undefined;
208
298
  private interrupts: AgUiInterrupt[] | undefined;
209
299
  private pendingToolCallIds: string[] | undefined;
210
- private readonly textParts = new Map<string, { buffer: string }>();
211
300
  private readonly pendingSnapshotText = new Map<string, string>();
212
- private readonly activeTextMessageIdByScope = new Map<string, string>();
213
- private readonly reasoningParts = new Map<
214
- string,
215
- { buffer: string; timing?: MessagePartTiming }
216
- >();
217
- private readonly reasoningSignatures = new Map<string, string>();
218
- private readonly reasoningSignatureIds = new Map<string, string>();
219
- private readonly hiddenSignatures = new Map<string, string>();
220
- private readonly hiddenSignatureAnchors = new Map<string, number>();
221
- private readonly hiddenBlockAnchors = new Map<string, number>();
222
- private readonly hiddenReasoningIds = new Set<string>();
223
- private hiddenActiveReasoningId: string | undefined;
224
- private hasEmittedOpaqueReasoning = false;
225
- private readonly loggedDroppedOpaqueIds = new Set<string>();
226
- private readonly reasoningMessageIds = new Map<string, string>();
227
- private readonly activeReasoningKeyByScope = new Map<string, string>();
228
- private readonly subagentRuns = new Map<string, SubagentRunState>();
229
- // The nesting graph only moves when a run is announced or a tool call is
230
- // created, which is rare next to emit(), so it is derived once per change
231
- // rather than per streamed delta.
232
- private nestingMemo:
233
- | {
234
- subagentsByParentToolCallId: Map<string, string[]>;
235
- reachable: Set<string>;
236
- }
237
- | undefined;
238
- private readonly toolCalls = new Map<string, ToolCallState>();
239
- private readonly a2uiBuckets = new Map<string, A2uiState>();
240
- private readonly a2uiToolCallIds = new Set<string>();
241
- private readonly activityParts = new Map<
242
- string,
243
- { kind: "data"; name: string; value: unknown; subagentRunId?: string }
244
- >();
245
- private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
246
- private readonly partOrder: PartOrderEntry[] = [];
247
- private lastReasoningBeforeToolCall: PartOrderEntry | undefined;
248
- private textPartCounter = 0;
301
+ private current: MessageBuffer = createMessageBuffer();
302
+ // The buffer the event being handled routes to; equal to `current` except
303
+ // while an event naming a moved-off message is applied.
304
+ private buf: MessageBuffer = this.current;
305
+ private readonly retained = new Map<string, MessageBuffer>();
306
+ private unannounced: MessageBuffer | undefined;
249
307
  private serverMessageIdReported = false;
250
- private reportedServerMessageId: string | undefined;
251
308
  private lastTextMessageId: string | undefined;
252
309
 
253
310
  private streamStartTime: number | undefined;
@@ -261,31 +318,229 @@ export class RunAggregator {
261
318
  this.logger = options.logger;
262
319
  this.onServerMessageId = options.onServerMessageId;
263
320
  this.onTextMessageStart = options.onTextMessageStart;
321
+ this.onRetainedMessage = options.onRetainedMessage;
322
+ this.canRetainMessage = options.canRetainMessage;
264
323
  }
265
324
 
266
325
  hasToolCall(toolCallId: string): boolean {
267
- return this.toolCalls.has(toolCallId);
326
+ return this.buffers().some((buffer) => buffer.toolCalls.has(toolCallId));
327
+ }
328
+
329
+ isRetained(messageId: string): boolean {
330
+ return this.retained.has(messageId);
268
331
  }
269
332
 
270
- rebaseMessage(messageId: string): void {
271
- const snapshotText = this.pendingSnapshotText.get(messageId);
272
- this.resetMessageParts();
333
+ /**
334
+ * Makes `messageId` the message the run streams into. The previous message
335
+ * keeps its parts under `previousId`, and returns true when it does; call
336
+ * `announceRetained` to publish its state once the caller can apply it.
337
+ */
338
+ rebaseMessage(messageId: string, previousId?: string): boolean {
339
+ const retained = this.openMessage(messageId, previousId);
273
340
  this.lastTextMessageId = messageId;
274
- this.reportedServerMessageId = messageId;
275
- if (snapshotText !== undefined) {
276
- this.pendingSnapshotText.set(messageId, snapshotText);
277
- }
341
+ this.current.reportedServerMessageId = messageId;
278
342
  this.startTextMessage(ROOT_SCOPE, messageId);
343
+ if (retained) this.unannounced = retained;
344
+ return retained !== undefined;
345
+ }
346
+
347
+ announceRetained(): void {
348
+ const buffer = this.unannounced;
349
+ this.unannounced = undefined;
350
+ if (buffer && this.retained.get(buffer.id!) === buffer)
351
+ this.withBuffer(buffer, () => this.emit());
279
352
  }
280
353
 
281
354
  handle(event: AgUiEvent): void {
355
+ const owner = this.ownerOf(event);
356
+ if (owner === this.current) this.apply(event);
357
+ else this.withBuffer(owner, () => this.apply(event));
358
+ }
359
+
360
+ private buffers(): MessageBuffer[] {
361
+ return [this.current, ...this.retained.values()];
362
+ }
363
+
364
+ private withBuffer(buffer: MessageBuffer, fn: () => void): void {
365
+ const previous = this.buf;
366
+ this.buf = buffer;
367
+ try {
368
+ fn();
369
+ } finally {
370
+ this.buf = previous;
371
+ }
372
+ }
373
+
374
+ // Starts a fresh current message (or resumes a retained one) and keeps the
375
+ // outgoing one addressable by id, so events that still name it route back.
376
+ private openMessage(
377
+ messageId: string,
378
+ previousId: string | undefined,
379
+ ): MessageBuffer | undefined {
380
+ const outgoing = this.current;
381
+ const next = this.retained.get(messageId) ?? createMessageBuffer(messageId);
382
+ this.retained.delete(messageId);
383
+ let retained: MessageBuffer | undefined;
384
+ if (previousId !== undefined && previousId !== messageId) {
385
+ outgoing.id = previousId;
386
+ this.retained.set(previousId, outgoing);
387
+ retained = outgoing;
388
+ }
389
+ this.current = next;
390
+ this.buf = next;
391
+ return retained;
392
+ }
393
+
394
+ private ownerForMessage(messageId: string | undefined): MessageBuffer {
395
+ if (!messageId || messageId === this.current.id) return this.current;
396
+ const retained = this.retained.get(messageId);
397
+ if (retained) return retained;
398
+ // A snapshot can deliver a message before the events that finish it, as
399
+ // a reconnect replay does.
400
+ if (this.canRetainMessage?.(messageId)) {
401
+ const buffer = createMessageBuffer(messageId);
402
+ this.retained.set(messageId, buffer);
403
+ if (this.pendingSnapshotText.has(messageId))
404
+ this.withBuffer(buffer, () =>
405
+ this.startTextMessage(ROOT_SCOPE, messageId),
406
+ );
407
+ return buffer;
408
+ }
409
+ return this.current;
410
+ }
411
+
412
+ private ownerForToolCall(toolCallId: unknown): MessageBuffer | undefined {
413
+ if (typeof toolCallId !== "string") return undefined;
414
+ return this.buffers().find((buffer) => buffer.toolCalls.has(toolCallId));
415
+ }
416
+
417
+ private ownerForSubagent(subagentRunId: string): MessageBuffer {
418
+ return (
419
+ this.buffers().find((buffer) => buffer.subagentRuns.has(subagentRunId)) ??
420
+ this.current
421
+ );
422
+ }
423
+
424
+ private ownerForActivity(event: {
425
+ messageId?: string;
426
+ subagentRunId?: string;
427
+ }): MessageBuffer {
428
+ if (event.subagentRunId !== undefined)
429
+ return this.ownerForSubagent(event.subagentRunId);
430
+ if (event.messageId === undefined) return this.current;
431
+ const key = `message:${event.messageId}`;
432
+ return (
433
+ this.buffers().find((buffer) => buffer.activityParts.has(key)) ??
434
+ this.current
435
+ );
436
+ }
437
+
438
+ private ownerOf(event: AgUiEvent): MessageBuffer {
439
+ if (this.joinStrategy === "concat-content") return this.current;
440
+ switch (event.type) {
441
+ case "TEXT_MESSAGE_START":
442
+ case "TEXT_MESSAGE_CONTENT":
443
+ case "TEXT_MESSAGE_END":
444
+ return event.subagentRunId !== undefined
445
+ ? this.ownerForSubagent(event.subagentRunId)
446
+ : this.ownerForMessage(event.messageId);
447
+ case "TOOL_CALL_START":
448
+ return event.subagentRunId !== undefined
449
+ ? this.ownerForSubagent(event.subagentRunId)
450
+ : this.ownerForMessage(event.parentMessageId);
451
+ case "TOOL_CALL_ARGS":
452
+ case "TOOL_CALL_END":
453
+ case "TOOL_CALL_RESULT":
454
+ return (
455
+ this.ownerForToolCall(event.toolCallId) ??
456
+ (event.subagentRunId !== undefined
457
+ ? this.ownerForSubagent(event.subagentRunId)
458
+ : this.current)
459
+ );
460
+ case "SUBAGENT_STARTED": {
461
+ const existing = this.buffers().find((buffer) =>
462
+ buffer.subagentRuns.has(event.subagentRunId),
463
+ );
464
+ if (existing) return existing;
465
+ if (event.parentToolCallId !== undefined) {
466
+ const owner = this.ownerForToolCall(event.parentToolCallId);
467
+ if (owner) return owner;
468
+ }
469
+ if (event.parentSubagentRunId !== undefined)
470
+ return this.ownerForSubagent(event.parentSubagentRunId);
471
+ return this.ownerForMessage(event.parentMessageId);
472
+ }
473
+ case "SUBAGENT_FINISHED":
474
+ case "SUBAGENT_ERROR":
475
+ return this.ownerForSubagent(event.subagentRunId);
476
+ case "ACTIVITY_SNAPSHOT":
477
+ if (event.activityType === MCP_APPS_ACTIVITY_TYPE) {
478
+ const owner = this.ownerForToolCall(event.content.toolCallId);
479
+ if (owner) return owner;
480
+ }
481
+ if (event.activityType === A2UI_SURFACE_ACTIVITY_TYPE)
482
+ return this.current;
483
+ return this.ownerForActivity(event);
484
+ case "ACTIVITY_DELTA":
485
+ return this.ownerForActivity(event);
486
+ case "REASONING_START":
487
+ case "REASONING_MESSAGE_START":
488
+ case "REASONING_MESSAGE_CONTENT":
489
+ case "REASONING_MESSAGE_END":
490
+ case "REASONING_END":
491
+ case "REASONING_ENCRYPTED_VALUE": {
492
+ if (event.subagentRunId !== undefined)
493
+ return this.ownerForSubagent(event.subagentRunId);
494
+ const id =
495
+ event.type === "REASONING_ENCRYPTED_VALUE"
496
+ ? event.entityId
497
+ : event.messageId;
498
+ return (
499
+ this.buffers().find(
500
+ (buffer) =>
501
+ buffer.reasoningParts.has(id) ||
502
+ buffer.hiddenReasoningIds.has(id),
503
+ ) ?? this.current
504
+ );
505
+ }
506
+ default:
507
+ return this.current;
508
+ }
509
+ }
510
+
511
+ private retainedState(buffer: MessageBuffer): RetainedMessageState {
512
+ const openToolCallIds = Array.from(buffer.toolCalls.values())
513
+ .filter(
514
+ (call) =>
515
+ call.result === undefined &&
516
+ !buffer.a2uiToolCallIds.has(call.toolCallId),
517
+ )
518
+ .map((call) => call.toolCallId);
519
+ return {
520
+ messageId: buffer.id!,
521
+ openToolCallIds,
522
+ streaming:
523
+ buffer.openTextFrames.size > 0 ||
524
+ Array.from(buffer.subagentRuns.values()).some(
525
+ (run) => run.status.type === "running",
526
+ ),
527
+ };
528
+ }
529
+
530
+ private apply(event: AgUiEvent): void {
282
531
  switch (event.type) {
283
532
  case "RUN_STARTED": {
284
- this.resetMessageParts();
533
+ this.retained.clear();
534
+ this.unannounced = undefined;
535
+ const { hasEmittedOpaqueReasoning } = this.current;
536
+ this.current = createMessageBuffer();
537
+ // A resumed message must still retract what the previous run published.
538
+ this.current.hasEmittedOpaqueReasoning = hasEmittedOpaqueReasoning;
539
+ this.buf = this.current;
540
+ this.pendingSnapshotText.clear();
285
541
  this.interrupts = undefined;
286
542
  this.serverMessageIdReported = false;
287
543
  this.lastTextMessageId = undefined;
288
- this.reportedServerMessageId = undefined;
289
544
  this.streamStartTime = Date.now();
290
545
  this.firstTokenTime = undefined;
291
546
  this.totalChunks = 0;
@@ -295,15 +550,33 @@ export class RunAggregator {
295
550
  }
296
551
  case "MESSAGES_SNAPSHOT": {
297
552
  this.pendingSnapshotText.clear();
553
+ const buffers = this.buffers();
298
554
  for (const message of event.messages) {
299
- if (
300
- message.role !== "assistant" ||
301
- typeof message.content !== "string"
302
- )
303
- continue;
304
- const part = this.textParts.get(message.id);
305
- if (part) part.buffer = message.content;
306
- else this.pendingSnapshotText.set(message.id, message.content);
555
+ if (message?.role !== "assistant") continue;
556
+ if (typeof message.content === "string") {
557
+ const part = buffers
558
+ .map((buffer) => buffer.textParts.get(message.id))
559
+ .find((entry) => entry !== undefined);
560
+ if (part) part.buffer = message.content;
561
+ else this.pendingSnapshotText.set(message.id, message.content);
562
+ }
563
+ // A call the stream opened without args takes the record's, so the
564
+ // message it stays on matches what a reload would show.
565
+ for (const call of Array.isArray(message.toolCalls)
566
+ ? message.toolCalls
567
+ : []) {
568
+ const args = call?.function?.arguments;
569
+ if (typeof args !== "string" || args.length === 0) continue;
570
+ for (const buffer of buffers) {
571
+ const entry = buffer.toolCalls.get(call.id);
572
+ if (entry === undefined || entry.argsText !== "") continue;
573
+ entry.argsText = args;
574
+ entry.argsTextScanner = createJSONContainerScanner();
575
+ scanJSONContainerDelta(entry.argsTextScanner, args);
576
+ const parsed = tryParseJSON(args);
577
+ entry.parsedArgs = isPlainObject(parsed) ? parsed : undefined;
578
+ }
579
+ }
307
580
  }
308
581
  break;
309
582
  }
@@ -334,10 +607,10 @@ export class RunAggregator {
334
607
  const hasUnresolvedToolCalls =
335
608
  pendingToolCallIds && pendingToolCallIds.length > 0
336
609
  ? pendingToolCallIds.some((id) => {
337
- const call = this.toolCalls.get(id);
610
+ const call = this.buf.toolCalls.get(id);
338
611
  return call !== undefined && call.result === undefined;
339
612
  })
340
- : Array.from(this.toolCalls.values()).some(
613
+ : Array.from(this.buf.toolCalls.values()).some(
341
614
  (tc) => tc.result === undefined,
342
615
  );
343
616
 
@@ -380,11 +653,13 @@ export class RunAggregator {
380
653
 
381
654
  case "TEXT_MESSAGE_START": {
382
655
  const scope = this.scopeOf(event);
383
- if (scope === ROOT_SCOPE) {
656
+ if (scope === ROOT_SCOPE && this.buf === this.current) {
384
657
  this.beginDistinctTextMessage(event.messageId);
385
658
  this.reportServerMessageId(event.messageId);
386
659
  if (event.messageId) this.lastTextMessageId = event.messageId;
387
660
  }
661
+ if (scope === ROOT_SCOPE && event.messageId)
662
+ this.buf.openTextFrames.add(event.messageId);
388
663
  this.startTextMessage(scope, event.messageId);
389
664
  this.emit();
390
665
  break;
@@ -392,11 +667,13 @@ export class RunAggregator {
392
667
  case "TEXT_MESSAGE_CONTENT": {
393
668
  const incomingId = event.messageId;
394
669
  const scope = this.scopeOf(event);
395
- if (scope === ROOT_SCOPE) {
670
+ if (scope === ROOT_SCOPE && this.buf === this.current) {
396
671
  this.beginDistinctTextMessage(incomingId, true);
397
672
  this.reportServerMessageId(incomingId);
398
673
  if (incomingId) this.lastTextMessageId = incomingId;
399
674
  }
675
+ if (scope === ROOT_SCOPE && incomingId)
676
+ this.buf.openTextFrames.add(incomingId);
400
677
  if (!event.delta) break;
401
678
  this.recordFirstToken();
402
679
  const id = this.resolveTextMessageId(scope, incomingId);
@@ -407,21 +684,25 @@ export class RunAggregator {
407
684
  }
408
685
  case "TEXT_MESSAGE_END": {
409
686
  const scope = this.scopeOf(event);
410
- if (scope === ROOT_SCOPE) this.reportServerMessageId(event.messageId);
687
+ if (scope === ROOT_SCOPE && event.messageId) {
688
+ if (this.buf === this.current)
689
+ this.reportServerMessageId(event.messageId);
690
+ this.buf.openTextFrames.delete(event.messageId);
691
+ }
411
692
  if (
412
693
  event.messageId &&
413
- this.activeTextMessageIdByScope.get(scope) ===
694
+ this.buf.activeTextMessageIdByScope.get(scope) ===
414
695
  this.partKey(scope, event.messageId)
415
696
  ) {
416
- this.activeTextMessageIdByScope.delete(scope);
697
+ this.buf.activeTextMessageIdByScope.delete(scope);
417
698
  }
418
699
  this.emit();
419
700
  break;
420
701
  }
421
702
 
422
703
  case "CUSTOM": {
423
- this.activeTextMessageIdByScope.delete(ROOT_SCOPE);
424
- this.partOrder.push({
704
+ this.buf.activeTextMessageIdByScope.delete(ROOT_SCOPE);
705
+ this.buf.partOrder.push({
425
706
  kind: "data",
426
707
  name: event.name,
427
708
  value: event.value,
@@ -444,25 +725,25 @@ export class RunAggregator {
444
725
  const scope = this.scopeOf(event);
445
726
  const entityKey = this.partKey(scope, event.entityId);
446
727
  const key =
447
- this.showThinking && this.reasoningParts.has(entityKey)
728
+ this.showThinking && this.buf.reasoningParts.has(entityKey)
448
729
  ? entityKey
449
730
  : undefined;
450
731
  if (key !== undefined) {
451
- this.reasoningSignatures.set(key, event.encryptedValue);
452
- this.reasoningSignatureIds.set(key, event.entityId);
732
+ this.buf.reasoningSignatures.set(key, event.encryptedValue);
733
+ this.buf.reasoningSignatureIds.set(key, event.entityId);
453
734
  this.emit();
454
735
  } else if (
455
736
  !this.showThinking &&
456
737
  scope === ROOT_SCOPE &&
457
738
  event.entityId.trim().length > 0 &&
458
739
  event.encryptedValue.trim().length > 0 &&
459
- this.hiddenReasoningIds.has(event.entityId)
740
+ this.buf.hiddenReasoningIds.has(event.entityId)
460
741
  ) {
461
- this.hiddenSignatures.set(event.entityId, event.encryptedValue);
462
- this.hiddenSignatureAnchors.set(
742
+ this.buf.hiddenSignatures.set(event.entityId, event.encryptedValue);
743
+ this.buf.hiddenSignatureAnchors.set(
463
744
  event.entityId,
464
- this.hiddenBlockAnchors.get(event.entityId) ??
465
- this.partOrder.length,
745
+ this.buf.hiddenBlockAnchors.get(event.entityId) ??
746
+ this.buf.partOrder.length,
466
747
  );
467
748
  this.emit();
468
749
  }
@@ -485,7 +766,7 @@ export class RunAggregator {
485
766
 
486
767
  case "TOOL_CALL_START": {
487
768
  const scope = this.scopeOf(event);
488
- if (scope === ROOT_SCOPE) {
769
+ if (scope === ROOT_SCOPE && this.buf === this.current) {
489
770
  this.reportServerMessageId(event.parentMessageId);
490
771
  }
491
772
  this.startToolCall(
@@ -536,12 +817,12 @@ export class RunAggregator {
536
817
  const activityScope = this.scopeOf(event);
537
818
  const toolCallId = event.content.toolCallId;
538
819
  const fallbackId =
539
- this.lastResolvedToolCallIdByScope.get(activityScope);
820
+ this.buf.lastResolvedToolCallIdByScope.get(activityScope);
540
821
  const entry =
541
822
  typeof toolCallId === "string"
542
- ? this.toolCalls.get(toolCallId)
823
+ ? this.buf.toolCalls.get(toolCallId)
543
824
  : fallbackId
544
- ? this.toolCalls.get(fallbackId)
825
+ ? this.buf.toolCalls.get(fallbackId)
545
826
  : undefined;
546
827
  const resourceUri = event.content.resourceUri;
547
828
  if (
@@ -589,7 +870,7 @@ export class RunAggregator {
589
870
  // a repeat is a continuation: it may refresh the descriptive fields
590
871
  // and reopen the status, but re-parenting an established run would
591
872
  // move its already-rendered output to a different tool call.
592
- const existing = this.subagentRuns.get(event.subagentRunId);
873
+ const existing = this.buf.subagentRuns.get(event.subagentRunId);
593
874
  if (existing) {
594
875
  existing.name = event.name;
595
876
  if (event.description !== undefined)
@@ -598,8 +879,8 @@ export class RunAggregator {
598
879
  this.emit();
599
880
  break;
600
881
  }
601
- this.nestingMemo = undefined;
602
- this.subagentRuns.set(event.subagentRunId, {
882
+ this.buf.nestingMemo = undefined;
883
+ this.buf.subagentRuns.set(event.subagentRunId, {
603
884
  subagentRunId: event.subagentRunId,
604
885
  name: event.name,
605
886
  ...(event.description !== undefined
@@ -621,7 +902,7 @@ export class RunAggregator {
621
902
  break;
622
903
  }
623
904
  case "SUBAGENT_FINISHED": {
624
- const run = this.subagentRuns.get(event.subagentRunId);
905
+ const run = this.buf.subagentRuns.get(event.subagentRunId);
625
906
  if (run) {
626
907
  run.status =
627
908
  event.outcome?.type === "suspended"
@@ -635,7 +916,7 @@ export class RunAggregator {
635
916
  break;
636
917
  }
637
918
  case "SUBAGENT_ERROR": {
638
- const run = this.subagentRuns.get(event.subagentRunId);
919
+ const run = this.buf.subagentRuns.get(event.subagentRunId);
639
920
  if (run) {
640
921
  run.status = {
641
922
  type: "incomplete",
@@ -663,7 +944,7 @@ export class RunAggregator {
663
944
  event: Extract<AgUiEvent, { type: "ACTIVITY_DELTA" }>,
664
945
  ): void {
665
946
  const scope = this.scopeOf(event);
666
- const existing = this.activityParts.get(
947
+ const existing = this.buf.activityParts.get(
667
948
  this.partKey(scope, `message:${event.messageId}`),
668
949
  );
669
950
  if (!existing) {
@@ -700,14 +981,14 @@ export class RunAggregator {
700
981
  if (!Array.isArray(operations)) return;
701
982
 
702
983
  const messageId = event.messageId ?? "a2ui:anonymous";
703
- if (event.replace === false && this.a2uiBuckets.has(messageId)) return;
984
+ if (event.replace === false && this.buf.a2uiBuckets.has(messageId)) return;
704
985
 
705
986
  const { state, warnings } = applyA2uiOperations(new Map(), operations);
706
987
  for (const warning of warnings) {
707
988
  this.logger.debug("[agui] a2ui operation warning", warning);
708
989
  }
709
- this.a2uiBuckets.delete(messageId);
710
- this.a2uiBuckets.set(messageId, state);
990
+ this.buf.a2uiBuckets.delete(messageId);
991
+ this.buf.a2uiBuckets.set(messageId, state);
711
992
  this.synthesizeA2uiToolCalls();
712
993
  this.emit();
713
994
  }
@@ -722,7 +1003,7 @@ export class RunAggregator {
722
1003
  ? `message:${event.messageId}`
723
1004
  : `type:${event.activityType}`,
724
1005
  );
725
- const existing = this.activityParts.get(key);
1006
+ const existing = this.buf.activityParts.get(key);
726
1007
  if (existing) {
727
1008
  if (event.replace === false) return;
728
1009
  existing.name = `agui-activity/${event.activityType}`;
@@ -741,9 +1022,9 @@ export class RunAggregator {
741
1022
  value: event.content,
742
1023
  subagentRunId: scope,
743
1024
  };
744
- this.activityParts.set(key, part);
745
- this.partOrder.push(part);
746
- this.activeTextMessageIdByScope.delete(scope);
1025
+ this.buf.activityParts.set(key, part);
1026
+ this.buf.partOrder.push(part);
1027
+ this.buf.activeTextMessageIdByScope.delete(scope);
747
1028
  }
748
1029
  this.emit();
749
1030
  }
@@ -751,7 +1032,7 @@ export class RunAggregator {
751
1032
  private synthesizeA2uiToolCalls(): void {
752
1033
  const surfaces = new Map<string, A2uiSurfaceState>();
753
1034
 
754
- for (const bucket of this.a2uiBuckets.values()) {
1035
+ for (const bucket of this.buf.a2uiBuckets.values()) {
755
1036
  for (const [surfaceId, surface] of bucket) {
756
1037
  surfaces.set(surfaceId, surface);
757
1038
  }
@@ -782,23 +1063,23 @@ export class RunAggregator {
782
1063
  artifact: toolCall.artifact,
783
1064
  snapshotResultApplied: false,
784
1065
  };
785
- if (!this.toolCalls.has(toolCallId)) {
786
- this.partOrder.push({ kind: "tool-call", toolCallId });
1066
+ if (!this.buf.toolCalls.has(toolCallId)) {
1067
+ this.buf.partOrder.push({ kind: "tool-call", toolCallId });
787
1068
  }
788
- this.nestingMemo = undefined;
789
- this.toolCalls.set(toolCallId, entry);
790
- this.a2uiToolCallIds.add(toolCallId);
1069
+ this.buf.nestingMemo = undefined;
1070
+ this.buf.toolCalls.set(toolCallId, entry);
1071
+ this.buf.a2uiToolCallIds.add(toolCallId);
791
1072
  }
792
1073
 
793
- for (const toolCallId of this.a2uiToolCallIds) {
1074
+ for (const toolCallId of this.buf.a2uiToolCallIds) {
794
1075
  if (activeToolCallIds.has(toolCallId)) continue;
795
- this.nestingMemo = undefined;
796
- this.toolCalls.delete(toolCallId);
797
- const partIndex = this.partOrder.findIndex(
1076
+ this.buf.nestingMemo = undefined;
1077
+ this.buf.toolCalls.delete(toolCallId);
1078
+ const partIndex = this.buf.partOrder.findIndex(
798
1079
  (part) => part.kind === "tool-call" && part.toolCallId === toolCallId,
799
1080
  );
800
- if (partIndex !== -1) this.partOrder.splice(partIndex, 1);
801
- this.a2uiToolCallIds.delete(toolCallId);
1081
+ if (partIndex !== -1) this.buf.partOrder.splice(partIndex, 1);
1082
+ this.buf.a2uiToolCallIds.delete(toolCallId);
802
1083
  }
803
1084
  }
804
1085
 
@@ -809,47 +1090,24 @@ export class RunAggregator {
809
1090
  }
810
1091
  if (this.serverMessageIdReported) return;
811
1092
  this.serverMessageIdReported = true;
812
- this.reportedServerMessageId = messageId;
1093
+ this.buf.reportedServerMessageId = messageId;
813
1094
  this.onServerMessageId?.(messageId);
814
1095
  }
815
1096
 
816
- private clearTextParts(): void {
817
- this.textParts.clear();
818
- }
819
-
820
- private resetMessageParts(): void {
821
- this.clearTextParts();
822
- this.pendingSnapshotText.clear();
823
- this.reasoningParts.clear();
824
- this.reasoningSignatures.clear();
825
- this.reasoningSignatureIds.clear();
826
- this.reasoningMessageIds.clear();
827
- this.hiddenSignatures.clear();
828
- this.hiddenSignatureAnchors.clear();
829
- this.hiddenBlockAnchors.clear();
830
- this.hiddenReasoningIds.clear();
831
- this.hiddenActiveReasoningId = undefined;
832
- this.loggedDroppedOpaqueIds.clear();
833
- this.activeReasoningKeyByScope.clear();
834
- this.nestingMemo = undefined;
835
- this.subagentRuns.clear();
836
- this.toolCalls.clear();
837
- this.a2uiBuckets.clear();
838
- this.a2uiToolCallIds.clear();
839
- this.activityParts.clear();
840
- this.lastResolvedToolCallIdByScope.clear();
841
- this.partOrder.length = 0;
842
- this.lastReasoningBeforeToolCall = undefined;
843
- this.textPartCounter = 0;
844
- this.activeTextMessageIdByScope.clear();
845
- this.reportedServerMessageId = undefined;
846
- }
847
-
848
1097
  // A subagent only ever reports its own terminal, so a run that ends while
849
1098
  // one is still streaming would leave that nested message spinning forever.
1099
+ // Moved-off messages publish first, so the current emit that follows sees
1100
+ // them settled.
850
1101
  private closeOpenSubagentRuns(status: MessageStatus): void {
851
- for (const run of this.subagentRuns.values()) {
852
- if (run.status.type === "running") run.status = status;
1102
+ for (const buffer of this.buffers()) {
1103
+ let closed = false;
1104
+ for (const run of buffer.subagentRuns.values()) {
1105
+ if (run.status.type !== "running") continue;
1106
+ run.status = status;
1107
+ closed = true;
1108
+ }
1109
+ if (closed && buffer !== this.current)
1110
+ this.withBuffer(buffer, () => this.emit());
853
1111
  }
854
1112
  }
855
1113
 
@@ -881,17 +1139,13 @@ export class RunAggregator {
881
1139
  if (carried.parts.length > 0 || carried.hiddenSignatures.length > 0) {
882
1140
  this.emit();
883
1141
  }
884
- const openToolCallIds = Array.from(this.toolCalls.values())
1142
+ const openToolCallIds = Array.from(this.buf.toolCalls.values())
885
1143
  .filter((call) => call.result === undefined)
886
1144
  .map((call) => call.toolCallId);
887
- const snapshotText = preserveSnapshotText
888
- ? this.pendingSnapshotText.get(messageId)
889
- : undefined;
890
- this.resetMessageParts();
891
- if (snapshotText !== undefined) {
892
- this.pendingSnapshotText.set(messageId, snapshotText);
893
- }
894
- this.onTextMessageStart(messageId, openToolCallIds);
1145
+ if (!preserveSnapshotText) this.pendingSnapshotText.delete(messageId);
1146
+ const outgoingId =
1147
+ this.onTextMessageStart(messageId, openToolCallIds) || undefined;
1148
+ this.openMessage(messageId, outgoingId);
895
1149
  this.restoreReasoning(carried);
896
1150
  }
897
1151
 
@@ -903,37 +1157,37 @@ export class RunAggregator {
903
1157
  signatureId: string | undefined;
904
1158
  reasoningId: string | undefined;
905
1159
  }[] = [];
906
- let last = this.partOrder.at(-1);
1160
+ let last = this.buf.partOrder.at(-1);
907
1161
  while (
908
1162
  last?.kind === "reasoning" &&
909
1163
  last.subagentRunId === undefined &&
910
- last !== this.lastReasoningBeforeToolCall
1164
+ last !== this.buf.lastReasoningBeforeToolCall
911
1165
  ) {
912
- this.partOrder.pop();
1166
+ this.buf.partOrder.pop();
913
1167
  parts.unshift({
914
1168
  key: last.key,
915
- entry: this.reasoningParts.get(last.key) ?? { buffer: "" },
916
- signature: this.reasoningSignatures.get(last.key),
917
- signatureId: this.reasoningSignatureIds.get(last.key),
918
- reasoningId: this.reasoningMessageIds.get(last.key),
1169
+ entry: this.buf.reasoningParts.get(last.key) ?? { buffer: "" },
1170
+ signature: this.buf.reasoningSignatures.get(last.key),
1171
+ signatureId: this.buf.reasoningSignatureIds.get(last.key),
1172
+ reasoningId: this.buf.reasoningMessageIds.get(last.key),
919
1173
  });
920
- last = this.partOrder.at(-1);
1174
+ last = this.buf.partOrder.at(-1);
921
1175
  }
922
- const cut = this.partOrder.length;
923
- const hiddenSignatures = Array.from(this.hiddenSignatures).filter(
924
- ([id]) => this.hiddenSignatureAnchors.get(id)! >= cut,
1176
+ const cut = this.buf.partOrder.length;
1177
+ const hiddenSignatures = Array.from(this.buf.hiddenSignatures).filter(
1178
+ ([id]) => this.buf.hiddenSignatureAnchors.get(id)! >= cut,
925
1179
  );
926
1180
  for (const [id] of hiddenSignatures) {
927
- this.hiddenSignatures.delete(id);
928
- this.hiddenSignatureAnchors.delete(id);
1181
+ this.buf.hiddenSignatures.delete(id);
1182
+ this.buf.hiddenSignatureAnchors.delete(id);
929
1183
  }
930
- const hiddenBlockIds = Array.from(this.hiddenBlockAnchors)
1184
+ const hiddenBlockIds = Array.from(this.buf.hiddenBlockAnchors)
931
1185
  .filter(([, anchor]) => anchor >= cut)
932
1186
  .map(([id]) => id);
933
- const hiddenActiveReasoningId = this.hiddenActiveReasoningId;
1187
+ const hiddenActiveReasoningId = this.buf.hiddenActiveReasoningId;
934
1188
  return {
935
1189
  parts,
936
- activeKey: this.activeReasoningKeyByScope.get(ROOT_SCOPE),
1190
+ activeKey: this.buf.activeReasoningKeyByScope.get(ROOT_SCOPE),
937
1191
  hiddenSignatures,
938
1192
  hiddenBlockIds,
939
1193
  hiddenActiveReasoningId:
@@ -948,35 +1202,35 @@ export class RunAggregator {
948
1202
  carried: ReturnType<RunAggregator["takeTrailingReasoning"]>,
949
1203
  ): void {
950
1204
  for (const part of carried.parts) {
951
- this.partOrder.push({ kind: "reasoning", key: part.key });
952
- this.reasoningParts.set(part.key, part.entry);
1205
+ this.buf.partOrder.push({ kind: "reasoning", key: part.key });
1206
+ this.buf.reasoningParts.set(part.key, part.entry);
953
1207
  if (part.signature !== undefined)
954
- this.reasoningSignatures.set(part.key, part.signature);
1208
+ this.buf.reasoningSignatures.set(part.key, part.signature);
955
1209
  if (part.signatureId !== undefined)
956
- this.reasoningSignatureIds.set(part.key, part.signatureId);
1210
+ this.buf.reasoningSignatureIds.set(part.key, part.signatureId);
957
1211
  if (part.reasoningId !== undefined)
958
- this.reasoningMessageIds.set(part.key, part.reasoningId);
1212
+ this.buf.reasoningMessageIds.set(part.key, part.reasoningId);
959
1213
  }
960
1214
  if (
961
1215
  carried.activeKey !== undefined &&
962
- this.reasoningParts.has(carried.activeKey)
1216
+ this.buf.reasoningParts.has(carried.activeKey)
963
1217
  ) {
964
- this.activeReasoningKeyByScope.set(ROOT_SCOPE, carried.activeKey);
1218
+ this.buf.activeReasoningKeyByScope.set(ROOT_SCOPE, carried.activeKey);
965
1219
  }
966
1220
  for (const [id, encryptedValue] of carried.hiddenSignatures) {
967
- this.hiddenSignatures.set(id, encryptedValue);
968
- this.hiddenSignatureAnchors.set(id, 0);
1221
+ this.buf.hiddenSignatures.set(id, encryptedValue);
1222
+ this.buf.hiddenSignatureAnchors.set(id, 0);
969
1223
  }
970
1224
  for (const id of carried.hiddenBlockIds) {
971
- this.hiddenReasoningIds.add(id);
972
- this.hiddenBlockAnchors.set(id, 0);
1225
+ this.buf.hiddenReasoningIds.add(id);
1226
+ this.buf.hiddenBlockAnchors.set(id, 0);
973
1227
  }
974
- this.hiddenActiveReasoningId = carried.hiddenActiveReasoningId;
1228
+ this.buf.hiddenActiveReasoningId = carried.hiddenActiveReasoningId;
975
1229
  }
976
1230
 
977
1231
  private generateTextKey(): string {
978
- this.textPartCounter += 1;
979
- return `text-${this.textPartCounter}`;
1232
+ this.buf.textPartCounter += 1;
1233
+ return `text-${this.buf.textPartCounter}`;
980
1234
  }
981
1235
 
982
1236
  private startTextMessage(scope: string, messageId?: string): string {
@@ -984,13 +1238,13 @@ export class RunAggregator {
984
1238
  const key = this.ensureTextPart(scope, id);
985
1239
  if (scope === ROOT_SCOPE && messageId) {
986
1240
  const snapshotText = this.pendingSnapshotText.get(messageId);
987
- const part = this.textParts.get(key);
1241
+ const part = this.buf.textParts.get(key);
988
1242
  if (snapshotText !== undefined && part) {
989
1243
  part.buffer = snapshotText;
990
1244
  this.pendingSnapshotText.delete(messageId);
991
1245
  }
992
1246
  }
993
- this.activeTextMessageIdByScope.set(scope, key);
1247
+ this.buf.activeTextMessageIdByScope.set(scope, key);
994
1248
  return key;
995
1249
  }
996
1250
 
@@ -999,24 +1253,26 @@ export class RunAggregator {
999
1253
  return this.startTextMessage(scope, messageId);
1000
1254
  }
1001
1255
 
1002
- const active = this.activeTextMessageIdByScope.get(scope);
1256
+ const active = this.buf.activeTextMessageIdByScope.get(scope);
1003
1257
  if (active) {
1004
1258
  return active;
1005
1259
  }
1006
1260
 
1007
1261
  const key = this.ensureTextPart(scope, this.generateTextKey());
1008
- this.activeTextMessageIdByScope.set(scope, key);
1262
+ this.buf.activeTextMessageIdByScope.set(scope, key);
1009
1263
  return key;
1010
1264
  }
1011
1265
 
1012
1266
  private ensureTextPart(scope: string, id: string): string {
1013
1267
  const key = this.partKey(scope, id);
1014
- if (!this.textParts.has(key)) {
1015
- this.textParts.set(key, { buffer: "" });
1268
+ if (!this.buf.textParts.has(key)) {
1269
+ this.buf.textParts.set(key, { buffer: "" });
1016
1270
  if (
1017
- !this.partOrder.some((part) => part.kind === "text" && part.key === key)
1271
+ !this.buf.partOrder.some(
1272
+ (part) => part.kind === "text" && part.key === key,
1273
+ )
1018
1274
  ) {
1019
- this.partOrder.push(
1275
+ this.buf.partOrder.push(
1020
1276
  scope === ROOT_SCOPE
1021
1277
  ? { kind: "text", key }
1022
1278
  : { kind: "text", key, subagentRunId: scope },
@@ -1027,7 +1283,7 @@ export class RunAggregator {
1027
1283
  }
1028
1284
 
1029
1285
  private appendText(id: string, delta: string): void {
1030
- const entry = this.textParts.get(id);
1286
+ const entry = this.buf.textParts.get(id);
1031
1287
  if (!entry) return;
1032
1288
  entry.buffer += delta;
1033
1289
  }
@@ -1043,15 +1299,15 @@ export class RunAggregator {
1043
1299
  // after it should be a new part, not appended to the pre-tool text —
1044
1300
  // scoped, so a subagent's tool call doesn't break the root run's text
1045
1301
  // and vice versa.
1046
- this.activeTextMessageIdByScope.delete(scope);
1302
+ this.buf.activeTextMessageIdByScope.delete(scope);
1047
1303
  if (
1048
- !this.partOrder.some(
1304
+ !this.buf.partOrder.some(
1049
1305
  (part) => part.kind === "tool-call" && part.toolCallId === id,
1050
1306
  )
1051
1307
  ) {
1052
1308
  // Tool insertion can precede existing reasoning in display order.
1053
1309
  if (scope === ROOT_SCOPE) {
1054
- this.lastReasoningBeforeToolCall = this.partOrder.findLast(
1310
+ this.buf.lastReasoningBeforeToolCall = this.buf.partOrder.findLast(
1055
1311
  (part) =>
1056
1312
  part.kind === "reasoning" && part.subagentRunId === undefined,
1057
1313
  );
@@ -1073,8 +1329,8 @@ export class RunAggregator {
1073
1329
  if (scope !== ROOT_SCOPE) {
1074
1330
  state.subagentRunId = scope;
1075
1331
  }
1076
- this.nestingMemo = undefined;
1077
- this.toolCalls.set(id, state);
1332
+ this.buf.nestingMemo = undefined;
1333
+ this.buf.toolCalls.set(id, state);
1078
1334
  }
1079
1335
 
1080
1336
  // The message and tool-call channels are unordered on the wire, so anchor a
@@ -1083,16 +1339,16 @@ export class RunAggregator {
1083
1339
  const entry = { kind: "tool-call", toolCallId: id } as const;
1084
1340
  if (parentMessageId) {
1085
1341
  const parentKey = this.partKey(scope, parentMessageId);
1086
- const parentIndex = this.partOrder.findIndex(
1342
+ const parentIndex = this.buf.partOrder.findIndex(
1087
1343
  (part) => part.kind === "text" && part.key === parentKey,
1088
1344
  );
1089
1345
  if (parentIndex !== -1) {
1090
1346
  let insertAt = parentIndex + 1;
1091
- while (insertAt < this.partOrder.length) {
1092
- const part = this.partOrder[insertAt];
1347
+ while (insertAt < this.buf.partOrder.length) {
1348
+ const part = this.buf.partOrder[insertAt];
1093
1349
  if (
1094
1350
  part?.kind === "tool-call" &&
1095
- this.toolCalls.get(part.toolCallId)?.parentMessageId ===
1351
+ this.buf.toolCalls.get(part.toolCallId)?.parentMessageId ===
1096
1352
  parentMessageId
1097
1353
  ) {
1098
1354
  insertAt += 1;
@@ -1100,15 +1356,15 @@ export class RunAggregator {
1100
1356
  }
1101
1357
  break;
1102
1358
  }
1103
- this.partOrder.splice(insertAt, 0, entry);
1359
+ this.buf.partOrder.splice(insertAt, 0, entry);
1104
1360
  return;
1105
1361
  }
1106
1362
  }
1107
- this.partOrder.push(entry);
1363
+ this.buf.partOrder.push(entry);
1108
1364
  }
1109
1365
 
1110
1366
  private appendToolArgs(id: string | undefined, delta: string) {
1111
- const entry = id ? this.toolCalls.get(id) : undefined;
1367
+ const entry = id ? this.buf.toolCalls.get(id) : undefined;
1112
1368
  if (!entry) return;
1113
1369
  if (!entry.argsTextScanner) {
1114
1370
  entry.argsTextScanner = createJSONContainerScanner();
@@ -1144,7 +1400,7 @@ export class RunAggregator {
1144
1400
  ) {
1145
1401
  if (!id) return;
1146
1402
  const modelText = toolContentText(content);
1147
- let entry = this.toolCalls.get(id);
1403
+ let entry = this.buf.toolCalls.get(id);
1148
1404
  if (!entry) {
1149
1405
  entry = {
1150
1406
  toolCallId: id,
@@ -1156,15 +1412,15 @@ export class RunAggregator {
1156
1412
  snapshotResultApplied: false,
1157
1413
  ...(scope !== ROOT_SCOPE ? { subagentRunId: scope } : {}),
1158
1414
  };
1159
- this.nestingMemo = undefined;
1160
- this.toolCalls.set(id, entry);
1415
+ this.buf.nestingMemo = undefined;
1416
+ this.buf.toolCalls.set(id, entry);
1161
1417
  }
1162
1418
  if (
1163
- !this.partOrder.some(
1419
+ !this.buf.partOrder.some(
1164
1420
  (part) => part.kind === "tool-call" && part.toolCallId === id,
1165
1421
  )
1166
1422
  ) {
1167
- this.partOrder.push({ kind: "tool-call", toolCallId: id });
1423
+ this.buf.partOrder.push({ kind: "tool-call", toolCallId: id });
1168
1424
  }
1169
1425
  if (mcpResult !== undefined && !entry.snapshotResultApplied) {
1170
1426
  entry.result = mcpResult;
@@ -1195,26 +1451,26 @@ export class RunAggregator {
1195
1451
  if (metadata !== undefined) {
1196
1452
  entry.resultMetadata = { ...entry.resultMetadata, ...metadata };
1197
1453
  }
1198
- this.lastResolvedToolCallIdByScope.set(scope, id);
1454
+ this.buf.lastResolvedToolCallIdByScope.set(scope, id);
1199
1455
  }
1200
1456
 
1201
1457
  private nesting(): {
1202
1458
  subagentsByParentToolCallId: Map<string, string[]>;
1203
1459
  reachable: Set<string>;
1204
1460
  } {
1205
- if (!this.nestingMemo) {
1461
+ if (!this.buf.nestingMemo) {
1206
1462
  const subagentsByParentToolCallId = this.subagentsByParentToolCallId();
1207
- this.nestingMemo = {
1463
+ this.buf.nestingMemo = {
1208
1464
  subagentsByParentToolCallId,
1209
1465
  reachable: this.reachableSubagentRunIds(subagentsByParentToolCallId),
1210
1466
  };
1211
1467
  }
1212
- return this.nestingMemo;
1468
+ return this.buf.nestingMemo;
1213
1469
  }
1214
1470
 
1215
1471
  private subagentsByParentToolCallId(): Map<string, string[]> {
1216
1472
  const map = new Map<string, string[]>();
1217
- for (const run of this.subagentRuns.values()) {
1473
+ for (const run of this.buf.subagentRuns.values()) {
1218
1474
  if (!run.parentToolCallId) continue;
1219
1475
  const list = map.get(run.parentToolCallId) ?? [];
1220
1476
  list.push(run.subagentRunId);
@@ -1234,7 +1490,7 @@ export class RunAggregator {
1234
1490
  const reachable = new Set<string>();
1235
1491
  const walk = (scope: string, depth: number): void => {
1236
1492
  if (depth > MAX_SUBAGENT_DEPTH) return;
1237
- for (const entry of this.toolCalls.values()) {
1493
+ for (const entry of this.buf.toolCalls.values()) {
1238
1494
  if ((entry.subagentRunId ?? ROOT_SCOPE) !== scope) continue;
1239
1495
  for (const id of subagentsByParentToolCallId.get(entry.toolCallId) ??
1240
1496
  []) {
@@ -1257,7 +1513,7 @@ export class RunAggregator {
1257
1513
  depth: number,
1258
1514
  ctx: BuildContext,
1259
1515
  ): ThreadMessage | undefined {
1260
- const run = this.subagentRuns.get(subagentRunId);
1516
+ const run = this.buf.subagentRuns.get(subagentRunId);
1261
1517
  if (!run || depth > MAX_SUBAGENT_DEPTH) return undefined;
1262
1518
  const content = this.buildParts(subagentRunId, depth + 1, ctx);
1263
1519
  return {
@@ -1307,7 +1563,7 @@ export class RunAggregator {
1307
1563
  };
1308
1564
 
1309
1565
  if (part.kind === "tool-call") {
1310
- const entry = this.toolCalls.get(part.toolCallId);
1566
+ const entry = this.buf.toolCalls.get(part.toolCallId);
1311
1567
  if (!entry) continue;
1312
1568
  const approval = ctx.approvals.get(entry.toolCallId);
1313
1569
  const nestedMessages = (
@@ -1367,13 +1623,13 @@ export class RunAggregator {
1367
1623
 
1368
1624
  if (part.kind === "reasoning") {
1369
1625
  if (this.showThinking) {
1370
- const entry = this.reasoningParts.get(part.key);
1626
+ const entry = this.buf.reasoningParts.get(part.key);
1371
1627
  const buffer = entry?.buffer ?? "";
1372
1628
  const isActive =
1373
- this.activeReasoningKeyByScope.get(rawScope) === part.key;
1629
+ this.buf.activeReasoningKeyByScope.get(rawScope) === part.key;
1374
1630
  if (buffer.length > 0 || isActive) {
1375
- const encryptedValue = this.reasoningSignatures.get(part.key);
1376
- const reasoningId = this.reasoningMessageIds.get(part.key);
1631
+ const encryptedValue = this.buf.reasoningSignatures.get(part.key);
1632
+ const reasoningId = this.buf.reasoningMessageIds.get(part.key);
1377
1633
  const meta = {
1378
1634
  ...(reasoningId !== undefined ? { reasoningId } : {}),
1379
1635
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
@@ -1391,8 +1647,8 @@ export class RunAggregator {
1391
1647
  // A retracted empty block still carries transport state: without
1392
1648
  // a part to live on, its signature rides the message metadata,
1393
1649
  // matching the shape a snapshot reload produces.
1394
- const encryptedValue = this.reasoningSignatures.get(part.key);
1395
- const id = this.reasoningSignatureIds.get(part.key);
1650
+ const encryptedValue = this.buf.reasoningSignatures.get(part.key);
1651
+ const id = this.buf.reasoningSignatureIds.get(part.key);
1396
1652
  if (id?.trim() && encryptedValue?.trim()) {
1397
1653
  ctx.root.opaqueCandidates.push({
1398
1654
  id,
@@ -1406,7 +1662,7 @@ export class RunAggregator {
1406
1662
  }
1407
1663
 
1408
1664
  if (part.kind === "text") {
1409
- const entry = this.textParts.get(part.key);
1665
+ const entry = this.buf.textParts.get(part.key);
1410
1666
  if (entry && entry.buffer.trim().length > 0) {
1411
1667
  snapshot.push({ type: "text", text: entry.buffer } as const);
1412
1668
  materialized();
@@ -1432,11 +1688,11 @@ export class RunAggregator {
1432
1688
  const { subagentsByParentToolCallId, reachable } = this.nesting();
1433
1689
  const root = {
1434
1690
  opaqueCandidates: Array.from(
1435
- this.hiddenSignatures,
1691
+ this.buf.hiddenSignatures,
1436
1692
  ([id, encryptedValue]) => ({
1437
1693
  id,
1438
1694
  encryptedValue,
1439
- anchor: this.hiddenSignatureAnchors.get(id)!,
1695
+ anchor: this.buf.hiddenSignatureAnchors.get(id)!,
1440
1696
  }),
1441
1697
  ) as (AgUiOpaqueReasoning & { anchor: number })[],
1442
1698
  lastMaterializedIndex: -1,
@@ -1452,10 +1708,11 @@ export class RunAggregator {
1452
1708
  string,
1453
1709
  { index: number; part: PartOrderEntry }[]
1454
1710
  >();
1455
- for (const [index, part] of this.partOrder.entries()) {
1711
+ for (const [index, part] of this.buf.partOrder.entries()) {
1456
1712
  const rawScope =
1457
1713
  part.kind === "tool-call"
1458
- ? (this.toolCalls.get(part.toolCallId)?.subagentRunId ?? ROOT_SCOPE)
1714
+ ? (this.buf.toolCalls.get(part.toolCallId)?.subagentRunId ??
1715
+ ROOT_SCOPE)
1459
1716
  : "subagentRunId" in part
1460
1717
  ? (part.subagentRunId ?? ROOT_SCOPE)
1461
1718
  : ROOT_SCOPE;
@@ -1472,9 +1729,13 @@ export class RunAggregator {
1472
1729
  reachable,
1473
1730
  partsByScope,
1474
1731
  approvals: projectAgUiToolApprovals(
1475
- this.status?.type === "requires-action" ? this.interrupts : undefined,
1732
+ this.buf === this.current && this.status?.type === "requires-action"
1733
+ ? this.interrupts
1734
+ : undefined,
1476
1735
  new Set(
1477
- Array.from(this.toolCalls.values()).map((entry) => entry.toolCallId),
1736
+ Array.from(this.buf.toolCalls.values()).map(
1737
+ (entry) => entry.toolCallId,
1738
+ ),
1478
1739
  ),
1479
1740
  ),
1480
1741
  root,
@@ -1483,18 +1744,18 @@ export class RunAggregator {
1483
1744
  const { opaqueCandidates, lastMaterializedIndex } = root;
1484
1745
 
1485
1746
  const toolMessageIds = new Set<string>();
1486
- for (const call of this.toolCalls.values()) {
1747
+ for (const call of this.buf.toolCalls.values()) {
1487
1748
  if (call.toolMessageId) toolMessageIds.add(call.toolMessageId);
1488
1749
  }
1489
1750
  opaqueCandidates.sort((a, b) => a.anchor - b.anchor);
1490
1751
  const publishableOpaqueReasoning = opaqueCandidates
1491
1752
  .filter((entry) => {
1492
1753
  const collides =
1493
- this.textParts.has(entry.id) ||
1494
- entry.id === this.reportedServerMessageId ||
1754
+ this.buf.textParts.has(entry.id) ||
1755
+ entry.id === this.buf.reportedServerMessageId ||
1495
1756
  toolMessageIds.has(entry.id);
1496
- if (collides && !this.loggedDroppedOpaqueIds.has(entry.id)) {
1497
- this.loggedDroppedOpaqueIds.add(entry.id);
1757
+ if (collides && !this.buf.loggedDroppedOpaqueIds.has(entry.id)) {
1758
+ this.buf.loggedDroppedOpaqueIds.add(entry.id);
1498
1759
  this.logger.debug?.(
1499
1760
  "[agui] aggregator dropped opaque reasoning signature: id collides with a message id",
1500
1761
  entry.id,
@@ -1506,10 +1767,30 @@ export class RunAggregator {
1506
1767
  lastMaterializedIndex < anchor ? { ...entry, after: true } : entry,
1507
1768
  );
1508
1769
  if (publishableOpaqueReasoning.length > 0)
1509
- this.hasEmittedOpaqueReasoning = true;
1770
+ this.buf.hasEmittedOpaqueReasoning = true;
1510
1771
  // Once an opaque entry has been published this run, the key keeps being
1511
1772
  // emitted (empty when withdrawn) so the namespace merge can retract it.
1512
- const includeOpaque = this.hasEmittedOpaqueReasoning;
1773
+ const includeOpaque = this.buf.hasEmittedOpaqueReasoning;
1774
+ if (this.buf !== this.current) {
1775
+ this.onRetainedMessage?.(
1776
+ {
1777
+ content: snapshot,
1778
+ ...(includeOpaque
1779
+ ? {
1780
+ metadata: {
1781
+ custom: {
1782
+ [AG_UI_METADATA_NAMESPACE]: {
1783
+ opaqueReasoning: publishableOpaqueReasoning,
1784
+ } satisfies AgUiCustomMetadata,
1785
+ },
1786
+ },
1787
+ }
1788
+ : {}),
1789
+ },
1790
+ this.retainedState(this.buf),
1791
+ );
1792
+ return;
1793
+ }
1513
1794
  const timing = this.getTiming();
1514
1795
  const metadata = {
1515
1796
  ...(timing ? { timing } : {}),
@@ -1560,7 +1841,7 @@ export class RunAggregator {
1560
1841
  const tokenCount =
1561
1842
  this.totalChunks > 0
1562
1843
  ? Math.ceil(
1563
- Array.from(this.textParts.values()).reduce(
1844
+ Array.from(this.buf.textParts.values()).reduce(
1564
1845
  (sum, p) => sum + p.buffer.length,
1565
1846
  0,
1566
1847
  ) / 4,
@@ -1580,7 +1861,7 @@ export class RunAggregator {
1580
1861
  ...(tokenCount !== undefined ? { tokenCount } : {}),
1581
1862
  ...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
1582
1863
  totalChunks: this.totalChunks,
1583
- toolCallCount: this.toolCalls.size,
1864
+ toolCallCount: this.buf.toolCalls.size,
1584
1865
  };
1585
1866
  }
1586
1867
 
@@ -1592,28 +1873,28 @@ export class RunAggregator {
1592
1873
  ): void {
1593
1874
  if (!this.showThinking) {
1594
1875
  if (scope !== ROOT_SCOPE) return;
1595
- const anchor = this.partOrder.length;
1596
- this.hiddenReasoningIds.add(messageId);
1597
- this.hiddenBlockAnchors.set(messageId, anchor);
1598
- this.hiddenActiveReasoningId = messageId;
1876
+ const anchor = this.buf.partOrder.length;
1877
+ this.buf.hiddenReasoningIds.add(messageId);
1878
+ this.buf.hiddenBlockAnchors.set(messageId, anchor);
1879
+ this.buf.hiddenActiveReasoningId = messageId;
1599
1880
  return;
1600
1881
  }
1601
1882
  // A reasoning block acts as a boundary: anonymous text arriving after it
1602
1883
  // should be a new part, not appended to any pre-reasoning text — scoped.
1603
- this.activeTextMessageIdByScope.delete(scope);
1884
+ this.buf.activeTextMessageIdByScope.delete(scope);
1604
1885
  const key = this.partKey(scope, messageId);
1605
1886
  if (isMessageId) {
1606
- this.reasoningMessageIds.set(key, messageId);
1887
+ this.buf.reasoningMessageIds.set(key, messageId);
1607
1888
  }
1608
- if (!this.reasoningParts.has(key)) {
1609
- this.reasoningParts.set(key, { buffer: "" });
1610
- this.partOrder.push(
1889
+ if (!this.buf.reasoningParts.has(key)) {
1890
+ this.buf.reasoningParts.set(key, { buffer: "" });
1891
+ this.buf.partOrder.push(
1611
1892
  scope === ROOT_SCOPE
1612
1893
  ? { kind: "reasoning", key }
1613
1894
  : { kind: "reasoning", key, subagentRunId: scope },
1614
1895
  );
1615
1896
  }
1616
- const entry = this.reasoningParts.get(key);
1897
+ const entry = this.buf.reasoningParts.get(key);
1617
1898
  if (entry?.timing) {
1618
1899
  entry.timing = { startedAt: entry.timing.startedAt };
1619
1900
  } else if (
@@ -1623,7 +1904,7 @@ export class RunAggregator {
1623
1904
  ) {
1624
1905
  entry.timing = { startedAt: timestamp };
1625
1906
  }
1626
- this.activeReasoningKeyByScope.set(scope, key);
1907
+ this.buf.activeReasoningKeyByScope.set(scope, key);
1627
1908
  this.emit();
1628
1909
  }
1629
1910
 
@@ -1638,29 +1919,29 @@ export class RunAggregator {
1638
1919
  // Content without a preceding START still names the block (the visible
1639
1920
  // path opens it lazily); register it so a signature can claim it.
1640
1921
  if (scope !== ROOT_SCOPE) return;
1641
- if (this.hiddenActiveReasoningId === undefined) {
1922
+ if (this.buf.hiddenActiveReasoningId === undefined) {
1642
1923
  this.handleReasoningStart(scope, messageId, isMessageId);
1643
1924
  }
1644
1925
  return;
1645
1926
  }
1646
- if (!this.activeReasoningKeyByScope.has(scope)) {
1927
+ if (!this.buf.activeReasoningKeyByScope.has(scope)) {
1647
1928
  // Content arrived without a preceding START — create the slot lazily.
1648
1929
  this.handleReasoningStart(scope, messageId, isMessageId);
1649
1930
  }
1650
- const key = this.activeReasoningKeyByScope.get(scope);
1931
+ const key = this.buf.activeReasoningKeyByScope.get(scope);
1651
1932
  if (!key) return;
1652
- const entry = this.reasoningParts.get(key);
1933
+ const entry = this.buf.reasoningParts.get(key);
1653
1934
  if (entry) entry.buffer += delta;
1654
1935
  this.emit();
1655
1936
  }
1656
1937
 
1657
1938
  private handleReasoningEnd(scope: string, timestamp?: number): void {
1658
1939
  if (!this.showThinking) {
1659
- if (scope === ROOT_SCOPE) this.hiddenActiveReasoningId = undefined;
1940
+ if (scope === ROOT_SCOPE) this.buf.hiddenActiveReasoningId = undefined;
1660
1941
  return;
1661
1942
  }
1662
- const key = this.activeReasoningKeyByScope.get(scope);
1663
- const entry = key ? this.reasoningParts.get(key) : undefined;
1943
+ const key = this.buf.activeReasoningKeyByScope.get(scope);
1944
+ const entry = key ? this.buf.reasoningParts.get(key) : undefined;
1664
1945
  if (
1665
1946
  entry?.timing &&
1666
1947
  typeof timestamp === "number" &&
@@ -1668,7 +1949,7 @@ export class RunAggregator {
1668
1949
  ) {
1669
1950
  entry.timing = { ...entry.timing, completedAt: timestamp };
1670
1951
  }
1671
- this.activeReasoningKeyByScope.delete(scope);
1952
+ this.buf.activeReasoningKeyByScope.delete(scope);
1672
1953
  this.emit();
1673
1954
  }
1674
1955
  }