@assistant-ui/react-ag-ui 0.0.56 → 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.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { InputContent, RunAgentParameters } from "@ag-ui/client";
4
+ import { generateId } from "@assistant-ui/core";
4
5
  import type {
5
6
  ThreadMessageLike as CoreThreadMessageLike,
6
7
  PartProviderMetadata,
@@ -9,8 +10,8 @@ import type {
9
10
  } from "@assistant-ui/core";
10
11
  import {
11
12
  getAutoStatus,
12
- httpUrlPattern,
13
13
  parseDataUrl,
14
+ resolveFilePartSource,
14
15
  } from "@assistant-ui/core/internal";
15
16
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
16
17
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
@@ -177,13 +178,6 @@ function parseJSONText(value: string): unknown {
177
178
  }
178
179
  }
179
180
 
180
- function generateId(): string {
181
- return (
182
- (globalThis.crypto as { randomUUID?: () => string })?.randomUUID?.() ??
183
- Math.random().toString(36).slice(2)
184
- );
185
- }
186
-
187
181
  function normalizeToolCall(part: ToolCallPart): {
188
182
  id: string;
189
183
  call: AgUiToolCall;
@@ -242,17 +236,20 @@ function buildInputSource(
242
236
  declaredMimeType: string | undefined,
243
237
  sourceType?: string,
244
238
  ): InputContentSource {
245
- if (sourceType === "url" || httpUrlPattern.test(value)) {
239
+ const source = resolveFilePartSource({
240
+ data: value,
241
+ mimeType: declaredMimeType ?? "application/octet-stream",
242
+ sourceType,
243
+ });
244
+ if (source.kind === "url") {
246
245
  return declaredMimeType !== undefined
247
- ? { type: "url", value, mimeType: declaredMimeType }
248
- : { type: "url", value };
246
+ ? { type: "url", value: source.url, mimeType: declaredMimeType }
247
+ : { type: "url", value: source.url };
249
248
  }
250
- const parsed = parseDataUrl(value);
251
249
  return {
252
250
  type: "data",
253
- value: parsed?.data ?? value,
254
- mimeType:
255
- parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
251
+ value: source.data,
252
+ mimeType: source.mimeType,
256
253
  };
257
254
  }
258
255
 
@@ -662,7 +659,7 @@ function toAssistantSnapshotMessage(
662
659
  }
663
660
 
664
661
  function toUserOrSystemSnapshotMessage(
665
- role: "user" | "system",
662
+ role: "user" | "system" | "developer",
666
663
  rawMessage: Record<string, unknown>,
667
664
  ): CoreThreadMessageLike {
668
665
  const messageName = getString(rawMessage, "name");
@@ -670,10 +667,23 @@ function toUserOrSystemSnapshotMessage(
670
667
  role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
671
668
  return {
672
669
  id: getString(rawMessage, "id") ?? generateId(),
673
- role,
670
+ // The internal message model has no developer role; it rides as a system
671
+ // message with its wire role kept in metadata so the export restores it.
672
+ role: role === "developer" ? "system" : role,
674
673
  content: extractText(rawMessage.content),
675
674
  ...(messageName !== undefined ? { name: messageName } : {}),
676
675
  ...(attachments.length > 0 ? { attachments } : {}),
676
+ ...(role === "developer"
677
+ ? {
678
+ metadata: {
679
+ custom: {
680
+ [AG_UI_METADATA_NAMESPACE]: {
681
+ role: "developer",
682
+ } satisfies AgUiCustomMetadata,
683
+ },
684
+ },
685
+ }
686
+ : {}),
677
687
  };
678
688
  }
679
689
 
@@ -939,7 +949,7 @@ export function fromAgUiMessages(
939
949
  continue;
940
950
  }
941
951
 
942
- if (role === "user" || role === "system") {
952
+ if (role === "user" || role === "system" || role === "developer") {
943
953
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
944
954
  }
945
955
  }
@@ -1137,9 +1147,21 @@ export function toAgUiMessages(
1137
1147
  }
1138
1148
 
1139
1149
  if (message.role === "system" || message.role === "developer") {
1150
+ const custom = isObject(message.metadata)
1151
+ ? message.metadata.custom
1152
+ : undefined;
1153
+ const namespaced = isObject(custom)
1154
+ ? custom[AG_UI_METADATA_NAMESPACE]
1155
+ : undefined;
1156
+ const wireRole =
1157
+ message.role === "system" &&
1158
+ isObject(namespaced) &&
1159
+ namespaced.role === "developer"
1160
+ ? ("developer" as const)
1161
+ : message.role;
1140
1162
  converted.push({
1141
1163
  id: message.id,
1142
- role: message.role,
1164
+ role: wireRole,
1143
1165
  content: extractText(message.content),
1144
1166
  ...(message.name ? { name: message.name } : {}),
1145
1167
  });
@@ -28,6 +28,9 @@ export type AgUiOpaqueReasoning = {
28
28
  };
29
29
 
30
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";
31
34
  interrupts?: AgUiInterrupt[];
32
35
  opaqueReasoning?: AgUiOpaqueReasoning[];
33
36
  };
@@ -63,6 +66,7 @@ export type RunAggregatorOptions = {
63
66
  logger: Logger;
64
67
  emit: Emit;
65
68
  onServerMessageId?: (messageId: string) => void;
69
+ onTextMessageStart?: (messageId: string) => void;
66
70
  };
67
71
 
68
72
  /**
@@ -80,16 +84,29 @@ export class RunAggregator {
80
84
  private readonly showThinking: boolean;
81
85
  private readonly logger: Logger;
82
86
  private readonly onServerMessageId: ((messageId: string) => void) | undefined;
87
+ private readonly onTextMessageStart:
88
+ | ((messageId: string) => void)
89
+ | undefined;
83
90
 
84
91
  private status: ChatModelRunResult["status"] | undefined;
85
92
  private interrupts: AgUiInterrupt[] | undefined;
86
- private readonly textParts = new Map<
87
- string,
88
- { buffer: string; touched: boolean }
89
- >();
93
+ private readonly textParts = new Map<string, { buffer: string }>();
90
94
  private activeTextMessageId: string | undefined;
91
95
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
92
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>();
93
110
  private readonly reasoningMessageIds = new Map<string, string>();
94
111
  private readonly anonymousReasoningKeys = new Set<string>();
95
112
  private activeReasoningKey: string | undefined;
@@ -106,6 +123,8 @@ export class RunAggregator {
106
123
  )[] = [];
107
124
  private textPartCounter = 0;
108
125
  private serverMessageIdReported = false;
126
+ private reportedServerMessageId: string | undefined;
127
+ private lastTextMessageId: string | undefined;
109
128
 
110
129
  private streamStartTime: number | undefined;
111
130
  private firstTokenTime: number | undefined;
@@ -116,6 +135,7 @@ export class RunAggregator {
116
135
  this.showThinking = options.showThinking;
117
136
  this.logger = options.logger;
118
137
  this.onServerMessageId = options.onServerMessageId;
138
+ this.onTextMessageStart = options.onTextMessageStart;
119
139
  }
120
140
 
121
141
  hasToolCall(toolCallId: string): boolean {
@@ -125,22 +145,11 @@ export class RunAggregator {
125
145
  handle(event: AgUiEvent): void {
126
146
  switch (event.type) {
127
147
  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;
148
+ this.resetMessageParts();
142
149
  this.interrupts = undefined;
143
150
  this.serverMessageIdReported = false;
151
+ this.lastTextMessageId = undefined;
152
+ this.reportedServerMessageId = undefined;
144
153
  this.streamStartTime = Date.now();
145
154
  this.firstTokenTime = undefined;
146
155
  this.totalChunks = 0;
@@ -184,18 +193,19 @@ export class RunAggregator {
184
193
  }
185
194
 
186
195
  case "TEXT_MESSAGE_START": {
196
+ this.beginDistinctTextMessage(event.messageId);
187
197
  this.reportServerMessageId(event.messageId);
188
- const id = this.startTextMessage(event.messageId);
189
- if (id) {
190
- this.markTextPartTouched(id);
191
- }
198
+ this.startTextMessage(event.messageId);
199
+ if (event.messageId) this.lastTextMessageId = event.messageId;
192
200
  this.emit();
193
201
  break;
194
202
  }
195
203
  case "TEXT_MESSAGE_CONTENT":
196
204
  case "TEXT_MESSAGE_CHUNK": {
197
205
  const incomingId = "messageId" in event ? event.messageId : undefined;
206
+ this.beginDistinctTextMessage(incomingId);
198
207
  this.reportServerMessageId(incomingId);
208
+ if (incomingId) this.lastTextMessageId = incomingId;
199
209
  if (!event.delta) break;
200
210
  this.recordFirstToken();
201
211
  const id = this.resolveTextMessageId(incomingId);
@@ -238,13 +248,31 @@ export class RunAggregator {
238
248
  // entityId names any message, not necessarily a reasoning one, so an
239
249
  // unmatched id may only claim a block that has no id to contradict it.
240
250
  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;
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;
246
258
  if (key !== undefined) {
247
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
+ );
248
276
  this.emit();
249
277
  }
250
278
  }
@@ -436,8 +464,13 @@ export class RunAggregator {
436
464
  }
437
465
 
438
466
  private reportServerMessageId(messageId: string | undefined): void {
439
- if (this.serverMessageIdReported || !messageId) return;
467
+ if (!messageId) return;
468
+ if (this.lastTextMessageId === undefined) {
469
+ this.lastTextMessageId = messageId;
470
+ }
471
+ if (this.serverMessageIdReported) return;
440
472
  this.serverMessageIdReported = true;
473
+ this.reportedServerMessageId = messageId;
441
474
  this.onServerMessageId?.(messageId);
442
475
  }
443
476
 
@@ -445,6 +478,45 @@ export class RunAggregator {
445
478
  this.textParts.clear();
446
479
  }
447
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
+
448
520
  private generateTextKey(): string {
449
521
  this.textPartCounter += 1;
450
522
  return `text-${this.textPartCounter}`;
@@ -476,7 +548,7 @@ export class RunAggregator {
476
548
 
477
549
  private ensureTextPart(id: string): void {
478
550
  if (!this.textParts.has(id)) {
479
- this.textParts.set(id, { buffer: "", touched: false });
551
+ this.textParts.set(id, { buffer: "" });
480
552
  if (
481
553
  !this.partOrder.some((part) => part.kind === "text" && part.key === id)
482
554
  ) {
@@ -485,18 +557,11 @@ export class RunAggregator {
485
557
  }
486
558
  }
487
559
 
488
- private markTextPartTouched(id: string): void {
489
- const entry = this.textParts.get(id);
490
- if (!entry) return;
491
- entry.touched = true;
492
- }
493
-
494
560
  private appendText(id: string, delta: string): void {
495
561
  this.ensureTextPart(id);
496
562
  const entry = this.textParts.get(id);
497
563
  if (!entry) return;
498
564
  entry.buffer += delta;
499
- entry.touched = true;
500
565
  }
501
566
 
502
567
  private startToolCall(
@@ -640,7 +705,22 @@ export class RunAggregator {
640
705
  new Set(this.toolCalls.keys()),
641
706
  );
642
707
 
643
- for (const part of this.partOrder) {
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
+ };
721
+
722
+ for (const [index, part] of this.partOrder.entries()) {
723
+ currentIndex = index;
644
724
  if (part.kind === "reasoning") {
645
725
  if (this.showThinking) {
646
726
  const buffer = this.reasoningParts.get(part.key) ?? "";
@@ -651,13 +731,22 @@ export class RunAggregator {
651
731
  ...(reasoningId !== undefined ? { reasoningId } : {}),
652
732
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
653
733
  };
654
- snapshot.push({
734
+ pushSnapshotPart({
655
735
  type: "reasoning",
656
736
  text: buffer,
657
737
  ...(Object.keys(meta).length > 0
658
738
  ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
659
739
  : {}),
660
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
+ }
661
750
  }
662
751
  }
663
752
  continue;
@@ -665,14 +754,14 @@ export class RunAggregator {
665
754
 
666
755
  if (part.kind === "text") {
667
756
  const entry = this.textParts.get(part.key);
668
- if (entry?.touched) {
669
- 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);
670
759
  }
671
760
  continue;
672
761
  }
673
762
 
674
763
  if (part.kind === "data") {
675
- snapshot.push({
764
+ pushSnapshotPart({
676
765
  type: "data",
677
766
  name: part.name,
678
767
  data: part.value,
@@ -712,17 +801,57 @@ export class RunAggregator {
712
801
  ? { unstable_toolMessageId: entry.toolMessageId }
713
802
  : {}),
714
803
  } as ToolCallMessagePart & { unstable_toolMessageId?: string };
715
- snapshot.push(toolPart);
804
+ pushSnapshotPart(toolPart);
716
805
  }
717
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;
718
844
  const timing = this.getTiming();
719
845
  const metadata = {
720
846
  ...(timing ? { timing } : {}),
721
- ...(this.interrupts
847
+ ...(this.interrupts || includeOpaque
722
848
  ? {
723
849
  custom: {
724
850
  [AG_UI_METADATA_NAMESPACE]: {
725
- interrupts: this.interrupts,
851
+ ...(this.interrupts ? { interrupts: this.interrupts } : {}),
852
+ ...(includeOpaque
853
+ ? { opaqueReasoning: publishableOpaqueReasoning }
854
+ : {}),
726
855
  } satisfies AgUiCustomMetadata,
727
856
  },
728
857
  }
@@ -778,7 +907,18 @@ export class RunAggregator {
778
907
  }
779
908
 
780
909
  private handleReasoningStart(messageId?: string, isMessageId = false): void {
781
- 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
+ }
782
922
  // A reasoning block acts as a boundary: anonymous text arriving after it
783
923
  // should be a new part, not appended to any pre-reasoning text.
784
924
  this.activeTextMessageId = undefined;
@@ -804,7 +944,15 @@ export class RunAggregator {
804
944
  messageId?: string,
805
945
  isMessageId = false,
806
946
  ): void {
807
- 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
+ }
808
956
  if (!this.activeReasoningKey) {
809
957
  // Content arrived without a preceding START — create the slot lazily.
810
958
  this.handleReasoningStart(messageId, isMessageId);
@@ -816,7 +964,10 @@ export class RunAggregator {
816
964
  }
817
965
 
818
966
  private handleReasoningEnd(): void {
819
- if (!this.showThinking) return;
967
+ if (!this.showThinking) {
968
+ this.hiddenActiveReasoning = "none";
969
+ return;
970
+ }
820
971
  this.activeReasoningKey = undefined;
821
972
  this.emit();
822
973
  }
@@ -0,0 +1,76 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { cleanup, renderHook, waitFor } from "@testing-library/react";
5
+ import type { ThreadHistoryAdapter } from "@assistant-ui/core";
6
+ import type { HttpAgent } from "@ag-ui/client";
7
+ import { useAgUiRuntime } from "./useAgUiRuntime";
8
+
9
+ const agent = { runAgent: vi.fn(), abortRun: vi.fn() } as unknown as HttpAgent;
10
+
11
+ function createHistory(): ThreadHistoryAdapter {
12
+ return {
13
+ load: vi.fn().mockResolvedValue({
14
+ headId: "restored",
15
+ messages: [
16
+ {
17
+ parentId: null,
18
+ message: {
19
+ id: "restored",
20
+ role: "user" as const,
21
+ content: [{ type: "text" as const, text: "hello" }],
22
+ createdAt: new Date(0),
23
+ metadata: { custom: {} },
24
+ },
25
+ },
26
+ ],
27
+ }),
28
+ append: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+
32
+ afterEach(() => {
33
+ cleanup();
34
+ });
35
+
36
+ describe("useAgUiRuntime history", () => {
37
+ it("loads a history adapter that arrives on a later render", async () => {
38
+ const history = createHistory();
39
+ const { result, rerender } = renderHook(
40
+ ({ history }: { history?: ThreadHistoryAdapter }) =>
41
+ useAgUiRuntime({ agent, adapters: history ? { history } : {} }),
42
+ { initialProps: {} },
43
+ );
44
+
45
+ await waitFor(() =>
46
+ expect(result.current.thread.getState().isLoading).toBe(false),
47
+ );
48
+ expect(history.load).not.toHaveBeenCalled();
49
+ expect(result.current.thread.getState().messages).toEqual([]);
50
+
51
+ rerender({ history });
52
+
53
+ await waitFor(() =>
54
+ expect(
55
+ result.current.thread.getState().messages.map((m) => m.id),
56
+ ).toEqual(["restored"]),
57
+ );
58
+ expect(history.load).toHaveBeenCalledOnce();
59
+ });
60
+
61
+ it("loads once when the adapter is present from the first render", async () => {
62
+ const history = createHistory();
63
+ const { result, rerender } = renderHook(() =>
64
+ useAgUiRuntime({ agent, adapters: { history } }),
65
+ );
66
+
67
+ await waitFor(() =>
68
+ expect(
69
+ result.current.thread.getState().messages.map((m) => m.id),
70
+ ).toEqual(["restored"]),
71
+ );
72
+ rerender();
73
+ rerender();
74
+ expect(history.load).toHaveBeenCalledOnce();
75
+ });
76
+ });
@@ -81,14 +81,16 @@ export function useAgUiRuntime(
81
81
  }
82
82
 
83
83
  const core = coreRef.current;
84
- core.updateOptions({
85
- agent: options.agent,
86
- logger,
87
- showThinking: options.showThinking ?? true,
88
- autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
89
- ...(options.onError && { onError: options.onError }),
90
- ...(options.onCancel && { onCancel: options.onCancel }),
91
- ...(historyAdapter && { history: historyAdapter }),
84
+ useEffect(() => {
85
+ core.updateOptions({
86
+ agent: options.agent,
87
+ logger,
88
+ showThinking: options.showThinking ?? true,
89
+ autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
90
+ ...(options.onError && { onError: options.onError }),
91
+ ...(options.onCancel && { onCancel: options.onCancel }),
92
+ ...(historyAdapter && { history: historyAdapter }),
93
+ });
92
94
  });
93
95
 
94
96
  const [toolStatuses, setToolStatuses] = useState<
@@ -228,7 +230,7 @@ export function useAgUiRuntime(
228
230
  isLoading: core.isLoading,
229
231
  messageRepository: core.getMessageRepository(),
230
232
  state: core.getState(),
231
- isRunning,
233
+ isRunning: core.isRunning(),
232
234
  extras: agUiExtras.provide({
233
235
  interrupts:
234
236
  core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,