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

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 (47) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +25 -15
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +257 -210
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +13 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +77 -39
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +23 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +155 -41
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +9 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts +56 -0
  20. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
  21. package/dist/runtime/adapter/tool-approval.js +244 -0
  22. package/dist/runtime/adapter/tool-approval.js.map +1 -0
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/event-parser.js +2 -1
  25. package/dist/runtime/event-parser.js.map +1 -1
  26. package/dist/runtime/interrupt-internals.d.ts +8 -0
  27. package/dist/runtime/interrupt-internals.d.ts.map +1 -0
  28. package/dist/runtime/interrupt-internals.js +25 -0
  29. package/dist/runtime/interrupt-internals.js.map +1 -0
  30. package/dist/useAgUiRuntime.js +14 -9
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  35. package/src/runtime/AgUiThreadRuntimeCore.ts +436 -272
  36. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  37. package/src/runtime/adapter/conversions.ts +123 -57
  38. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  39. package/src/runtime/adapter/run-aggregator.ts +234 -47
  40. package/src/runtime/adapter/subscriber.ts +14 -0
  41. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  42. package/src/runtime/adapter/tool-approval.ts +366 -0
  43. package/src/runtime/event-parser.ts +10 -1
  44. package/src/runtime/interrupt-internals.ts +35 -0
  45. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  46. package/src/useAgUiRuntime.history.test.tsx +76 -0
  47. package/src/useAgUiRuntime.ts +15 -9
@@ -15,6 +15,7 @@ import {
15
15
  type A2uiSurfaceState,
16
16
  } from "@assistant-ui/react-generative-ui/a2ui";
17
17
  import { readMcpAppResourceUri } from "../mcp-tool-result";
18
+ import { projectAgUiToolApprovals } from "./tool-approval";
18
19
  import type { AgUiEvent, AgUiInterrupt } from "../types";
19
20
  import type { Logger } from "../logger";
20
21
 
@@ -27,6 +28,9 @@ export type AgUiOpaqueReasoning = {
27
28
  };
28
29
 
29
30
  export type AgUiCustomMetadata = {
31
+ /** Wire role restored on export for messages the internal model cannot
32
+ * represent (a developer record rides as a system message). */
33
+ role?: "developer";
30
34
  interrupts?: AgUiInterrupt[];
31
35
  opaqueReasoning?: AgUiOpaqueReasoning[];
32
36
  };
@@ -62,29 +66,47 @@ export type RunAggregatorOptions = {
62
66
  logger: Logger;
63
67
  emit: Emit;
64
68
  onServerMessageId?: (messageId: string) => void;
69
+ onTextMessageStart?: (messageId: string) => void;
65
70
  };
66
71
 
67
72
  /**
68
73
  * Collects AG-UI events into assistant-ui run snapshots that can be yielded from a ChatModelAdapter.
69
74
  *
70
75
  * The aggregator keeps a single assistant message worth of parts. Each incoming event updates the parts and
71
- * emits a fresh snapshot through the provided `emit` callback.
76
+ * emits a fresh snapshot through the provided `emit` callback. `CUSTOM` events
77
+ * become canonical data parts; integration plumbing names such as
78
+ * `on_interrupt`, `PredictState`, `Exit`, `hook_error`, `state_update_error`,
79
+ * `system:*`, and `MultiAgentHandoff` are forwarded the same way, so apps only
80
+ * need data renderers for names they own.
72
81
  */
73
82
  export class RunAggregator {
74
83
  private readonly emitUpdate: Emit;
75
84
  private readonly showThinking: boolean;
76
85
  private readonly logger: Logger;
77
86
  private readonly onServerMessageId: ((messageId: string) => void) | undefined;
87
+ private readonly onTextMessageStart:
88
+ | ((messageId: string) => void)
89
+ | undefined;
78
90
 
79
91
  private status: ChatModelRunResult["status"] | undefined;
80
92
  private interrupts: AgUiInterrupt[] | undefined;
81
- private readonly textParts = new Map<
82
- string,
83
- { buffer: string; touched: boolean }
84
- >();
93
+ private readonly textParts = new Map<string, { buffer: string }>();
85
94
  private activeTextMessageId: string | undefined;
86
95
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
87
96
  private readonly reasoningSignatures = new Map<string, string>();
97
+ private readonly reasoningSignatureIds = new Map<string, string>();
98
+ // Signatures captured while thinking is hidden have no block to live on;
99
+ // they are transport state that must survive to the next run input. The
100
+ // claim rules mirror the visible path: an id-carrying block, or an open
101
+ // anonymous one.
102
+ private readonly hiddenSignatures = new Map<string, string>();
103
+ private readonly hiddenSignatureAnchors = new Map<string, number>();
104
+ private readonly hiddenBlockAnchors = new Map<string, number>();
105
+ private hiddenAnonymousAnchor: number | undefined;
106
+ private readonly hiddenReasoningIds = new Set<string>();
107
+ private hiddenActiveReasoning: "none" | "anonymous" | "identified" = "none";
108
+ private hasEmittedOpaqueReasoning = false;
109
+ private readonly loggedDroppedOpaqueIds = new Set<string>();
88
110
  private readonly reasoningMessageIds = new Map<string, string>();
89
111
  private readonly anonymousReasoningKeys = new Set<string>();
90
112
  private activeReasoningKey: string | undefined;
@@ -97,9 +119,12 @@ export class RunAggregator {
97
119
  | { kind: "text"; key: string }
98
120
  | { kind: "reasoning"; key: string }
99
121
  | { kind: "tool-call"; toolCallId: string }
122
+ | { kind: "data"; name: string; value: unknown }
100
123
  )[] = [];
101
124
  private textPartCounter = 0;
102
125
  private serverMessageIdReported = false;
126
+ private reportedServerMessageId: string | undefined;
127
+ private lastTextMessageId: string | undefined;
103
128
 
104
129
  private streamStartTime: number | undefined;
105
130
  private firstTokenTime: number | undefined;
@@ -110,6 +135,7 @@ export class RunAggregator {
110
135
  this.showThinking = options.showThinking;
111
136
  this.logger = options.logger;
112
137
  this.onServerMessageId = options.onServerMessageId;
138
+ this.onTextMessageStart = options.onTextMessageStart;
113
139
  }
114
140
 
115
141
  hasToolCall(toolCallId: string): boolean {
@@ -119,22 +145,11 @@ export class RunAggregator {
119
145
  handle(event: AgUiEvent): void {
120
146
  switch (event.type) {
121
147
  case "RUN_STARTED": {
122
- this.clearTextParts();
123
- this.reasoningParts.clear();
124
- this.reasoningSignatures.clear();
125
- this.reasoningMessageIds.clear();
126
- this.anonymousReasoningKeys.clear();
127
- this.activeReasoningKey = undefined;
128
- this.reasoningPartCounter = 0;
129
- this.toolCalls.clear();
130
- this.a2uiBuckets.clear();
131
- this.a2uiToolCallIds.clear();
132
- this.lastResolvedToolCallId = undefined;
133
- this.partOrder.length = 0;
134
- this.textPartCounter = 0;
135
- this.activeTextMessageId = undefined;
148
+ this.resetMessageParts();
136
149
  this.interrupts = undefined;
137
150
  this.serverMessageIdReported = false;
151
+ this.lastTextMessageId = undefined;
152
+ this.reportedServerMessageId = undefined;
138
153
  this.streamStartTime = Date.now();
139
154
  this.firstTokenTime = undefined;
140
155
  this.totalChunks = 0;
@@ -178,18 +193,19 @@ export class RunAggregator {
178
193
  }
179
194
 
180
195
  case "TEXT_MESSAGE_START": {
196
+ this.beginDistinctTextMessage(event.messageId);
181
197
  this.reportServerMessageId(event.messageId);
182
- const id = this.startTextMessage(event.messageId);
183
- if (id) {
184
- this.markTextPartTouched(id);
185
- }
198
+ this.startTextMessage(event.messageId);
199
+ if (event.messageId) this.lastTextMessageId = event.messageId;
186
200
  this.emit();
187
201
  break;
188
202
  }
189
203
  case "TEXT_MESSAGE_CONTENT":
190
204
  case "TEXT_MESSAGE_CHUNK": {
191
205
  const incomingId = "messageId" in event ? event.messageId : undefined;
206
+ this.beginDistinctTextMessage(incomingId);
192
207
  this.reportServerMessageId(incomingId);
208
+ if (incomingId) this.lastTextMessageId = incomingId;
193
209
  if (!event.delta) break;
194
210
  this.recordFirstToken();
195
211
  const id = this.resolveTextMessageId(incomingId);
@@ -207,6 +223,17 @@ export class RunAggregator {
207
223
  break;
208
224
  }
209
225
 
226
+ case "CUSTOM": {
227
+ this.activeTextMessageId = undefined;
228
+ this.partOrder.push({
229
+ kind: "data",
230
+ name: event.name,
231
+ value: event.value,
232
+ });
233
+ this.emit();
234
+ break;
235
+ }
236
+
210
237
  case "THINKING_START":
211
238
  case "THINKING_TEXT_MESSAGE_START":
212
239
  case "REASONING_START":
@@ -221,13 +248,31 @@ export class RunAggregator {
221
248
  // entityId names any message, not necessarily a reasoning one, so an
222
249
  // unmatched id may only claim a block that has no id to contradict it.
223
250
  const active = this.activeReasoningKey;
224
- const key = this.reasoningParts.has(event.entityId)
225
- ? event.entityId
226
- : active !== undefined && this.anonymousReasoningKeys.has(active)
227
- ? active
228
- : undefined;
251
+ const key = this.showThinking
252
+ ? this.reasoningParts.has(event.entityId)
253
+ ? event.entityId
254
+ : active !== undefined && this.anonymousReasoningKeys.has(active)
255
+ ? active
256
+ : undefined
257
+ : undefined;
229
258
  if (key !== undefined) {
230
259
  this.reasoningSignatures.set(key, event.encryptedValue);
260
+ this.reasoningSignatureIds.set(key, event.entityId);
261
+ this.emit();
262
+ } else if (
263
+ !this.showThinking &&
264
+ event.entityId.trim().length > 0 &&
265
+ event.encryptedValue.trim().length > 0 &&
266
+ (this.hiddenReasoningIds.has(event.entityId) ||
267
+ this.hiddenActiveReasoning === "anonymous")
268
+ ) {
269
+ this.hiddenSignatures.set(event.entityId, event.encryptedValue);
270
+ this.hiddenSignatureAnchors.set(
271
+ event.entityId,
272
+ this.hiddenBlockAnchors.get(event.entityId) ??
273
+ this.hiddenAnonymousAnchor ??
274
+ this.partOrder.length,
275
+ );
231
276
  this.emit();
232
277
  }
233
278
  }
@@ -419,8 +464,13 @@ export class RunAggregator {
419
464
  }
420
465
 
421
466
  private reportServerMessageId(messageId: string | undefined): void {
422
- if (this.serverMessageIdReported || !messageId) return;
467
+ if (!messageId) return;
468
+ if (this.lastTextMessageId === undefined) {
469
+ this.lastTextMessageId = messageId;
470
+ }
471
+ if (this.serverMessageIdReported) return;
423
472
  this.serverMessageIdReported = true;
473
+ this.reportedServerMessageId = messageId;
424
474
  this.onServerMessageId?.(messageId);
425
475
  }
426
476
 
@@ -428,6 +478,45 @@ export class RunAggregator {
428
478
  this.textParts.clear();
429
479
  }
430
480
 
481
+ private resetMessageParts(): void {
482
+ this.clearTextParts();
483
+ this.reasoningParts.clear();
484
+ this.reasoningSignatures.clear();
485
+ this.reasoningSignatureIds.clear();
486
+ this.reasoningMessageIds.clear();
487
+ this.hiddenSignatures.clear();
488
+ this.hiddenSignatureAnchors.clear();
489
+ this.hiddenBlockAnchors.clear();
490
+ this.hiddenAnonymousAnchor = undefined;
491
+ this.hiddenReasoningIds.clear();
492
+ this.hiddenActiveReasoning = "none";
493
+ this.loggedDroppedOpaqueIds.clear();
494
+ this.anonymousReasoningKeys.clear();
495
+ this.activeReasoningKey = undefined;
496
+ this.reasoningPartCounter = 0;
497
+ this.toolCalls.clear();
498
+ this.a2uiBuckets.clear();
499
+ this.a2uiToolCallIds.clear();
500
+ this.lastResolvedToolCallId = undefined;
501
+ this.partOrder.length = 0;
502
+ this.textPartCounter = 0;
503
+ this.activeTextMessageId = undefined;
504
+ this.reportedServerMessageId = undefined;
505
+ }
506
+
507
+ private beginDistinctTextMessage(messageId: string | undefined): void {
508
+ if (
509
+ !messageId ||
510
+ this.lastTextMessageId === undefined ||
511
+ this.lastTextMessageId === messageId ||
512
+ !this.onTextMessageStart
513
+ ) {
514
+ return;
515
+ }
516
+ this.resetMessageParts();
517
+ this.onTextMessageStart(messageId);
518
+ }
519
+
431
520
  private generateTextKey(): string {
432
521
  this.textPartCounter += 1;
433
522
  return `text-${this.textPartCounter}`;
@@ -459,7 +548,7 @@ export class RunAggregator {
459
548
 
460
549
  private ensureTextPart(id: string): void {
461
550
  if (!this.textParts.has(id)) {
462
- this.textParts.set(id, { buffer: "", touched: false });
551
+ this.textParts.set(id, { buffer: "" });
463
552
  if (
464
553
  !this.partOrder.some((part) => part.kind === "text" && part.key === id)
465
554
  ) {
@@ -468,18 +557,11 @@ export class RunAggregator {
468
557
  }
469
558
  }
470
559
 
471
- private markTextPartTouched(id: string): void {
472
- const entry = this.textParts.get(id);
473
- if (!entry) return;
474
- entry.touched = true;
475
- }
476
-
477
560
  private appendText(id: string, delta: string): void {
478
561
  this.ensureTextPart(id);
479
562
  const entry = this.textParts.get(id);
480
563
  if (!entry) return;
481
564
  entry.buffer += delta;
482
- entry.touched = true;
483
565
  }
484
566
 
485
567
  private startToolCall(
@@ -614,8 +696,31 @@ export class RunAggregator {
614
696
 
615
697
  private emit(): void {
616
698
  const snapshot: ThreadAssistantMessagePart[] = [];
699
+ // A run that ended incomplete can no longer be resumed, so a gate left over
700
+ // from an earlier interrupt outcome is unanswerable and must not stay
701
+ // projected. The interrupts themselves are kept on the message, since the
702
+ // bespoke hooks read that payload.
703
+ const approvals = projectAgUiToolApprovals(
704
+ this.status?.type === "requires-action" ? this.interrupts : undefined,
705
+ new Set(this.toolCalls.keys()),
706
+ );
707
+
708
+ const opaqueCandidates: (AgUiOpaqueReasoning & { anchor: number })[] =
709
+ Array.from(this.hiddenSignatures, ([id, encryptedValue]) => ({
710
+ id,
711
+ encryptedValue,
712
+ anchor: this.hiddenSignatureAnchors.get(id)!,
713
+ }));
714
+
715
+ let currentIndex = -1;
716
+ let lastMaterializedIndex = -1;
717
+ const pushSnapshotPart = (part: (typeof snapshot)[number]) => {
718
+ snapshot.push(part);
719
+ lastMaterializedIndex = currentIndex;
720
+ };
617
721
 
618
- for (const part of this.partOrder) {
722
+ for (const [index, part] of this.partOrder.entries()) {
723
+ currentIndex = index;
619
724
  if (part.kind === "reasoning") {
620
725
  if (this.showThinking) {
621
726
  const buffer = this.reasoningParts.get(part.key) ?? "";
@@ -626,13 +731,22 @@ export class RunAggregator {
626
731
  ...(reasoningId !== undefined ? { reasoningId } : {}),
627
732
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
628
733
  };
629
- snapshot.push({
734
+ pushSnapshotPart({
630
735
  type: "reasoning",
631
736
  text: buffer,
632
737
  ...(Object.keys(meta).length > 0
633
738
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
634
739
  : {}),
635
740
  } as const);
741
+ } else {
742
+ // A retracted empty block still carries transport state: without
743
+ // a part to live on, its signature rides the message metadata,
744
+ // matching the shape a snapshot reload produces.
745
+ const encryptedValue = this.reasoningSignatures.get(part.key);
746
+ const id = this.reasoningSignatureIds.get(part.key);
747
+ if (id?.trim() && encryptedValue?.trim()) {
748
+ opaqueCandidates.push({ id, encryptedValue, anchor: index + 1 });
749
+ }
636
750
  }
637
751
  }
638
752
  continue;
@@ -640,20 +754,31 @@ export class RunAggregator {
640
754
 
641
755
  if (part.kind === "text") {
642
756
  const entry = this.textParts.get(part.key);
643
- if (entry?.touched) {
644
- snapshot.push({ type: "text", text: entry.buffer } as const);
757
+ if (entry && entry.buffer.trim().length > 0) {
758
+ pushSnapshotPart({ type: "text", text: entry.buffer } as const);
645
759
  }
646
760
  continue;
647
761
  }
648
762
 
763
+ if (part.kind === "data") {
764
+ pushSnapshotPart({
765
+ type: "data",
766
+ name: part.name,
767
+ data: part.value,
768
+ });
769
+ continue;
770
+ }
771
+
649
772
  const entry = this.toolCalls.get(part.toolCallId);
650
773
  if (!entry) continue;
774
+ const approval = approvals.get(entry.toolCallId);
651
775
  const toolPart: ToolCallMessagePart = {
652
776
  type: "tool-call",
653
777
  toolCallId: entry.toolCallId,
654
778
  toolName: entry.toolCallName,
655
779
  args: (entry.parsedArgs ?? {}) as any,
656
780
  argsText: entry.argsText,
781
+ ...(approval ? { approval } : {}),
657
782
  ...(entry.result !== undefined ? { result: entry.result } : {}),
658
783
  ...(entry.modelContent !== undefined
659
784
  ? { modelContent: entry.modelContent }
@@ -676,17 +801,57 @@ export class RunAggregator {
676
801
  ? { unstable_toolMessageId: entry.toolMessageId }
677
802
  : {}),
678
803
  } as ToolCallMessagePart & { unstable_toolMessageId?: string };
679
- snapshot.push(toolPart);
804
+ pushSnapshotPart(toolPart);
680
805
  }
681
806
 
807
+ // An anonymous claim promotes the signature's entityId to a wire message
808
+ // id; when that id actually names a text message or the adopted assistant
809
+ // message id (which can also arrive via TOOL_CALL_START.parentMessageId),
810
+ // replaying it as its own record would put two wire records under one id,
811
+ // so such entries are dropped instead — the same outcome the visible
812
+ // path's claim guard produces for a signature that is "not for this
813
+ // block". Entries with no materialized part after their anchor trail the
814
+ // assistant record, matching the snapshot path's anchor/after
815
+ // bookkeeping.
816
+ const toolMessageIds = new Set<string>();
817
+ for (const call of this.toolCalls.values()) {
818
+ if (call.toolMessageId) toolMessageIds.add(call.toolMessageId);
819
+ }
820
+ opaqueCandidates.sort((a, b) => a.anchor - b.anchor);
821
+ const publishableOpaqueReasoning = opaqueCandidates
822
+ .filter((entry) => {
823
+ const collides =
824
+ this.textParts.has(entry.id) ||
825
+ entry.id === this.reportedServerMessageId ||
826
+ toolMessageIds.has(entry.id);
827
+ if (collides && !this.loggedDroppedOpaqueIds.has(entry.id)) {
828
+ this.loggedDroppedOpaqueIds.add(entry.id);
829
+ this.logger.debug?.(
830
+ "[agui] aggregator dropped opaque reasoning signature: id collides with a message id",
831
+ entry.id,
832
+ );
833
+ }
834
+ return !collides;
835
+ })
836
+ .map(({ anchor, ...entry }) =>
837
+ lastMaterializedIndex < anchor ? { ...entry, after: true } : entry,
838
+ );
839
+ if (publishableOpaqueReasoning.length > 0)
840
+ this.hasEmittedOpaqueReasoning = true;
841
+ // Once an opaque entry has been published this run, the key keeps being
842
+ // emitted (empty when withdrawn) so the namespace merge can retract it.
843
+ const includeOpaque = this.hasEmittedOpaqueReasoning;
682
844
  const timing = this.getTiming();
683
845
  const metadata = {
684
846
  ...(timing ? { timing } : {}),
685
- ...(this.interrupts
847
+ ...(this.interrupts || includeOpaque
686
848
  ? {
687
849
  custom: {
688
850
  [AG_UI_METADATA_NAMESPACE]: {
689
- interrupts: this.interrupts,
851
+ ...(this.interrupts ? { interrupts: this.interrupts } : {}),
852
+ ...(includeOpaque
853
+ ? { opaqueReasoning: publishableOpaqueReasoning }
854
+ : {}),
690
855
  } satisfies AgUiCustomMetadata,
691
856
  },
692
857
  }
@@ -742,7 +907,18 @@ export class RunAggregator {
742
907
  }
743
908
 
744
909
  private handleReasoningStart(messageId?: string, isMessageId = false): void {
745
- if (!this.showThinking) return;
910
+ if (!this.showThinking) {
911
+ const anchor = this.partOrder.length;
912
+ if (messageId === undefined) {
913
+ this.hiddenActiveReasoning = "anonymous";
914
+ this.hiddenAnonymousAnchor = anchor;
915
+ } else {
916
+ this.hiddenReasoningIds.add(messageId);
917
+ this.hiddenBlockAnchors.set(messageId, anchor);
918
+ this.hiddenActiveReasoning = "identified";
919
+ }
920
+ return;
921
+ }
746
922
  // A reasoning block acts as a boundary: anonymous text arriving after it
747
923
  // should be a new part, not appended to any pre-reasoning text.
748
924
  this.activeTextMessageId = undefined;
@@ -768,7 +944,15 @@ export class RunAggregator {
768
944
  messageId?: string,
769
945
  isMessageId = false,
770
946
  ): void {
771
- if (!this.showThinking || !delta) return;
947
+ if (!delta) return;
948
+ if (!this.showThinking) {
949
+ // Content without a preceding START still names the block (the visible
950
+ // path opens it lazily); register it so a signature can claim it.
951
+ if (this.hiddenActiveReasoning === "none") {
952
+ this.handleReasoningStart(messageId, isMessageId);
953
+ }
954
+ return;
955
+ }
772
956
  if (!this.activeReasoningKey) {
773
957
  // Content arrived without a preceding START — create the slot lazily.
774
958
  this.handleReasoningStart(messageId, isMessageId);
@@ -780,7 +964,10 @@ export class RunAggregator {
780
964
  }
781
965
 
782
966
  private handleReasoningEnd(): void {
783
- if (!this.showThinking) return;
967
+ if (!this.showThinking) {
968
+ this.hiddenActiveReasoning = "none";
969
+ return;
970
+ }
784
971
  this.activeReasoningKey = undefined;
785
972
  this.emit();
786
973
  }
@@ -35,6 +35,7 @@ type Subscriber = {
35
35
  onCustomEvent?: (payload: { event: unknown }) => void;
36
36
  onRawEvent?: (payload: { event: unknown }) => void;
37
37
  onRunFinishedEvent?: (payload: { event: unknown }) => void;
38
+ onRunErrorEvent?: (payload: { event: unknown }) => void;
38
39
  onRunFinalized?: () => void;
39
40
  onRunFailed?: (payload: { error: Error }) => void;
40
41
  };
@@ -146,11 +147,24 @@ export const createAgUiSubscriber = (
146
147
  onCustomEvent: ({ event }) => dispatchIfValid(event, "CUSTOM"),
147
148
  onRawEvent: ({ event }) => dispatchIfValid(event, "RAW"),
148
149
  onRunFinishedEvent: ({ event }) => {
150
+ if (runFinishedDispatched) return;
149
151
  const parsed = ensureEvent(event, "RUN_FINISHED", logger);
150
152
  if (!parsed) return;
151
153
  runFinishedDispatched = true;
152
154
  dispatch(parsed);
153
155
  },
156
+ onRunErrorEvent: ({ event }) => {
157
+ const parsed = ensureEvent(event, "RUN_ERROR", logger);
158
+ if (!parsed || parsed.type !== "RUN_ERROR") return;
159
+ runFinishedDispatched = true;
160
+ dispatch(parsed);
161
+
162
+ const error = Object.assign(
163
+ new Error(parsed.message ?? "Run failed"),
164
+ parsed.code === undefined ? {} : { code: parsed.code },
165
+ );
166
+ onRunFailed?.(error);
167
+ },
154
168
  onRunFinalized: () => {
155
169
  if (runFinishedDispatched) return;
156
170
  dispatch({ type: "RUN_FINISHED", runId });