@assistant-ui/react-ag-ui 0.1.7 → 0.1.9

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.
@@ -0,0 +1,126 @@
1
+ import type { HttpAgent } from "@ag-ui/client";
2
+ import { expect, it, vi } from "vitest";
3
+ import { AgUiThreadController } from "./AgUiThreadController";
4
+ import type { AgUiSubscriber } from "./adapter/subscriber";
5
+ import { makeLogger } from "./logger";
6
+
7
+ it("notifies subscribers synchronously when a delta callback cancels the run", async () => {
8
+ vi.useFakeTimers();
9
+ let subscriber!: AgUiSubscriber;
10
+ let finishRun!: () => void;
11
+ const agent = {
12
+ runAgent: (_input: unknown, current: AgUiSubscriber) => {
13
+ subscriber = current;
14
+ return new Promise<void>((resolve) => {
15
+ finishRun = resolve;
16
+ });
17
+ },
18
+ abortRun: vi.fn(),
19
+ } as unknown as HttpAgent;
20
+ const runningStates: boolean[] = [];
21
+ let runningStatesAtCancelReturn: boolean[] = [];
22
+ const error = vi.fn(() => {
23
+ void controller.cancel();
24
+ runningStatesAtCancelReturn = [...runningStates];
25
+ });
26
+ const controller = new AgUiThreadController({
27
+ agent,
28
+ logger: makeLogger({ error }),
29
+ showThinking: true,
30
+ });
31
+ const pending = controller.reload(null);
32
+
33
+ try {
34
+ subscriber.onTextMessageStartEvent?.({
35
+ event: { type: "TEXT_MESSAGE_START", messageId: "answer" },
36
+ });
37
+ controller.subscribe(() => {
38
+ runningStates.push(controller.getSnapshot().isRunning);
39
+ });
40
+ subscriber.onTextMessageContentEvent?.({
41
+ event: {
42
+ type: "TEXT_MESSAGE_CONTENT",
43
+ messageId: "answer",
44
+ delta: "partial",
45
+ },
46
+ });
47
+ expect(runningStates).toEqual([]);
48
+ expect(controller.getSnapshot().isRunning).toBe(true);
49
+
50
+ subscriber.onStateDeltaEvent?.({
51
+ event: {
52
+ type: "STATE_DELTA",
53
+ delta: [{ op: "replace", path: "/missing", value: 1 }],
54
+ },
55
+ });
56
+
57
+ expect(error).toHaveBeenCalledOnce();
58
+ expect(runningStatesAtCancelReturn.length).toBeGreaterThan(0);
59
+ expect(runningStatesAtCancelReturn.at(-1)).toBe(false);
60
+ expect(controller.getSnapshot().isRunning).toBe(false);
61
+ vi.runAllTimers();
62
+ expect(runningStates).toEqual(runningStatesAtCancelReturn);
63
+ } finally {
64
+ finishRun();
65
+ await pending;
66
+ vi.useRealTimers();
67
+ }
68
+ });
69
+
70
+ it("notifies once for a text delta burst while the stream stays open", async () => {
71
+ vi.useFakeTimers();
72
+ let subscriber!: AgUiSubscriber;
73
+ let finishRun!: () => void;
74
+ const agent = {
75
+ runAgent: (_input: unknown, current: AgUiSubscriber) => {
76
+ subscriber = current;
77
+ return new Promise<void>((resolve) => {
78
+ finishRun = resolve;
79
+ });
80
+ },
81
+ abortRun: vi.fn(),
82
+ } as unknown as HttpAgent;
83
+ const controller = new AgUiThreadController({
84
+ agent,
85
+ logger: makeLogger(),
86
+ showThinking: true,
87
+ });
88
+ const pending = controller.reload(null);
89
+
90
+ try {
91
+ subscriber.onTextMessageStartEvent?.({
92
+ event: { type: "TEXT_MESSAGE_START", messageId: "answer" },
93
+ });
94
+ const listener = vi.fn(() => controller.getSnapshot());
95
+ controller.subscribe(listener);
96
+ for (const delta of ["hello", " ", "world"]) {
97
+ subscriber.onTextMessageContentEvent?.({
98
+ event: { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta },
99
+ });
100
+ }
101
+
102
+ expect(listener).not.toHaveBeenCalled();
103
+ vi.runAllTimers();
104
+ expect(listener).toHaveBeenCalledOnce();
105
+ expect(listener.mock.results[0]?.value).toMatchObject({
106
+ isRunning: true,
107
+ messageRepository: {
108
+ messages: [
109
+ {
110
+ message: {
111
+ role: "assistant",
112
+ content: [{ type: "text", text: "hello world" }],
113
+ status: { type: "running" },
114
+ },
115
+ },
116
+ ],
117
+ },
118
+ });
119
+ vi.runAllTimers();
120
+ expect(listener).toHaveBeenCalledOnce();
121
+ } finally {
122
+ finishRun();
123
+ await pending;
124
+ vi.useRealTimers();
125
+ }
126
+ });
@@ -218,6 +218,28 @@ const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
218
218
  type ResumeStream = (
219
219
  options: ChatModelRunOptions,
220
220
  ) => AsyncGenerator<ChatModelRunResult, void, unknown>;
221
+ /** A run already in flight on the agent, joined through `agent.connectAgent()`. */
222
+ type ResumeSource = ResumeStream | "connect";
223
+
224
+ // connectAgent() swallows the base class's not-implemented error and resolves
225
+ // with no events, so only an agent that overrides connect() can rejoin a run.
226
+ // AbstractAgent extends nothing, so the prototype owning connect() is the base
227
+ // exactly when its own prototype is Object.prototype. Checking ownership instead
228
+ // of function identity holds when the app loads its own copy of @ag-ui/client.
229
+ const implementsConnect = (agent: AbstractAgent): boolean => {
230
+ if (typeof agent.connectAgent !== "function") return false;
231
+ if (Object.prototype.hasOwnProperty.call(agent, "connect")) return true;
232
+ for (
233
+ let proto: object | null = Object.getPrototypeOf(agent);
234
+ proto !== null;
235
+ proto = Object.getPrototypeOf(proto)
236
+ ) {
237
+ if (Object.prototype.hasOwnProperty.call(proto, "connect")) {
238
+ return Object.getPrototypeOf(proto) !== Object.prototype;
239
+ }
240
+ }
241
+ return false;
242
+ };
221
243
  type ResumeRunConfig = {
222
244
  parentId: string | null;
223
245
  sourceId: string | null;
@@ -240,6 +262,7 @@ type CoreOptions = {
240
262
  logger: Logger;
241
263
  showThinking: boolean;
242
264
  joinStrategy?: UseAgUiRuntimeOptions["joinStrategy"];
265
+ joinAdjacentAssistantMessages?: boolean | undefined;
243
266
  getRunContext?: UseAgUiRuntimeOptions["getRunContext"];
244
267
  resumeTranscript?: AgUiResumeTranscript | undefined;
245
268
  autoCancelPendingToolCalls?: boolean | undefined;
@@ -377,6 +400,7 @@ export class AgUiThreadController {
377
400
  private agent: AbstractAgent;
378
401
  private logger: Logger;
379
402
  private showThinking: boolean;
403
+ private joinAdjacentAssistantMessages: boolean;
380
404
  private joinStrategy: UseAgUiRuntimeOptions["joinStrategy"];
381
405
  private getRunContext: UseAgUiRuntimeOptions["getRunContext"];
382
406
  private resumeTranscript: AgUiResumeTranscript;
@@ -384,6 +408,7 @@ export class AgUiThreadController {
384
408
  private onError: ((error: Error) => void) | undefined;
385
409
  private onCancel: (() => void) | undefined;
386
410
  private readonly listeners = new Set<() => void>();
411
+ private pendingUpdateNotification: ReturnType<typeof setTimeout> | undefined;
387
412
  private snapshot:
388
413
  | {
389
414
  isLoading: boolean;
@@ -445,6 +470,8 @@ export class AgUiThreadController {
445
470
  }
446
471
  this.logger = options.logger;
447
472
  this.showThinking = options.showThinking;
473
+ this.joinAdjacentAssistantMessages =
474
+ options.joinAdjacentAssistantMessages ?? false;
448
475
  this.joinStrategy = options.joinStrategy;
449
476
  this.getRunContext = options.getRunContext;
450
477
  this.resumeTranscript = options.resumeTranscript ?? "full";
@@ -472,8 +499,20 @@ export class AgUiThreadController {
472
499
  this.state = reduceAgUiThreadState(this.state, action);
473
500
  }
474
501
 
475
- private publishUpdate(): void {
502
+ private publishUpdate(deferNotification = false): void {
476
503
  this.snapshot = undefined;
504
+ if (deferNotification) {
505
+ if (this.pendingUpdateNotification !== undefined) return;
506
+ this.pendingUpdateNotification = setTimeout(() => {
507
+ this.pendingUpdateNotification = undefined;
508
+ for (const listener of this.listeners) listener();
509
+ }, 0);
510
+ return;
511
+ }
512
+ if (this.pendingUpdateNotification !== undefined) {
513
+ clearTimeout(this.pendingUpdateNotification);
514
+ this.pendingUpdateNotification = undefined;
515
+ }
477
516
  for (const listener of this.listeners) listener();
478
517
  }
479
518
 
@@ -492,6 +531,8 @@ export class AgUiThreadController {
492
531
  }
493
532
  this.logger = options.logger;
494
533
  this.showThinking = options.showThinking;
534
+ this.joinAdjacentAssistantMessages =
535
+ options.joinAdjacentAssistantMessages ?? false;
495
536
  this.joinStrategy = options.joinStrategy;
496
537
  this.getRunContext = options.getRunContext;
497
538
  this.resumeTranscript = options.resumeTranscript ?? "full";
@@ -517,9 +558,22 @@ export class AgUiThreadController {
517
558
 
518
559
  detachRuntime() {
519
560
  this.runtime = undefined;
520
- void this.cancel().catch((error) => {
561
+ try {
562
+ this.leave();
563
+ } catch (error) {
521
564
  this.logger.error("[agui] failed to cancel run during teardown", error);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Stops following the active run because the thread is being left (a thread
570
+ * switch or teardown), as opposed to cancel(), which the user asked for.
571
+ */
572
+ leave(): void {
573
+ this.dispatch({
574
+ type: "historyMutationAdvanced",
522
575
  });
576
+ this.abortActiveRun(true);
523
577
  }
524
578
 
525
579
  getMessages(): readonly ThreadMessage[] {
@@ -582,12 +636,11 @@ export class AgUiThreadController {
582
636
  .then(async (repo) => {
583
637
  if (repo?.unstable_resume) {
584
638
  const parentId = repo.headId ?? this.session.headId;
585
- const resumeStream = this.history?.resume?.bind(this.history);
586
639
  await this.startRun(
587
640
  parentId,
588
641
  this.state.lastRunConfig,
589
642
  undefined,
590
- resumeStream,
643
+ this.getResumeSource(),
591
644
  );
592
645
  }
593
646
  })
@@ -739,16 +792,26 @@ export class AgUiThreadController {
739
792
  // synchronously, so a callback that starts another run must keep the
740
793
  // controller it installed, and a throw from a subclass's abortRun would
741
794
  // otherwise strand the thread as running.
742
- private abortActiveRun(): void {
795
+ private abortActiveRun(leaveRunning = false): void {
743
796
  const controller = this.abortController;
744
797
  if (!controller) return;
745
798
  try {
746
- (this.activeRunAgent ?? this.agent).abortRun();
799
+ this.stopAgent(this.activeRunAgent ?? this.agent, leaveRunning);
747
800
  } finally {
748
801
  controller.abort();
749
802
  }
750
803
  }
751
804
 
805
+ private stopAgent(agent: AbstractAgent, leaveRunning: boolean): void {
806
+ if (leaveRunning && implementsConnect(agent)) {
807
+ void agent.detachActiveRun().catch((error: unknown) => {
808
+ this.logger.error?.("[agui] agent detachActiveRun failed", error);
809
+ });
810
+ return;
811
+ }
812
+ agent.abortRun();
813
+ }
814
+
752
815
  // Starting a run supersedes the active one, and reports whether the caller
753
816
  // still owns the thread afterwards: the local abort runs onCancel
754
817
  // synchronously, so a callback that starts its own run keeps it. abortRun is a
@@ -785,11 +848,11 @@ export class AgUiThreadController {
785
848
  async resumeInFlightRun(messages: readonly ThreadMessage[]): Promise<void> {
786
849
  const historyImport = this.waitForHistoryImport();
787
850
  if (historyImport && !(await historyImport)) return;
788
- // Without a resume stream startRun would re-run the agent from scratch.
789
- const resumeStream = this.history?.resume?.bind(this.history);
790
- if (!resumeStream) {
851
+ // Without a resume source startRun would re-run the agent from scratch.
852
+ const resumeSource = this.getResumeSource();
853
+ if (!resumeSource) {
791
854
  const error = new Error(
792
- "[agui] unstable_resume requires a ThreadHistoryAdapter with a resume() method; skipping resume after thread switch",
855
+ "[agui] unstable_resume requires a ThreadHistoryAdapter with a resume() method or an agent that implements connect(); skipping resume after thread switch",
793
856
  );
794
857
  this.logger.error?.(error.message);
795
858
  invokeRuntimeCallback("onError", this.onError, error);
@@ -801,13 +864,19 @@ export class AgUiThreadController {
801
864
  parentId,
802
865
  this.state.lastRunConfig,
803
866
  undefined,
804
- resumeStream,
867
+ resumeSource,
805
868
  );
806
869
  } catch {
807
870
  // startRun already reported via onError; don't reject the switch.
808
871
  }
809
872
  }
810
873
 
874
+ private getResumeSource(): ResumeSource | undefined {
875
+ const resumeStream = this.history?.resume?.bind(this.history);
876
+ if (resumeStream) return resumeStream;
877
+ return implementsConnect(this.agent) ? "connect" : undefined;
878
+ }
879
+
811
880
  private assertNoPendingInterrupts(): void {
812
881
  if (!this.getPendingInterrupts()) return;
813
882
  throw new Error(
@@ -1664,7 +1733,7 @@ export class AgUiThreadController {
1664
1733
  this.activeRunAgent = null;
1665
1734
  this.setRunning(false);
1666
1735
  try {
1667
- (activeRunAgent ?? this.agent).abortRun();
1736
+ this.stopAgent(activeRunAgent ?? this.agent, true);
1668
1737
  } catch (error) {
1669
1738
  this.logger.error?.("[agui] agent abortRun failed", error);
1670
1739
  } finally {
@@ -1679,10 +1748,12 @@ export class AgUiThreadController {
1679
1748
  parentId: string | null,
1680
1749
  runConfig?: RunConfig,
1681
1750
  resume?: ResumeDispatch,
1682
- resumeStream?: ResumeStream,
1751
+ resumeSource?: ResumeSource,
1683
1752
  replayExistingMessages = false,
1684
1753
  ): Promise<void> {
1685
1754
  if (this.isThreadSwitching?.()) return;
1755
+ const resumeStream =
1756
+ typeof resumeSource === "function" ? resumeSource : undefined;
1686
1757
  // A default AG-UI run supersedes the active run; the hook's opt-in message
1687
1758
  // queue serializes sends instead. append supersedes earlier, before it links
1688
1759
  // its message; this covers the entry points that start a run without one.
@@ -1778,6 +1849,7 @@ export class AgUiThreadController {
1778
1849
  let frameOwnerIds = new Set<string>();
1779
1850
  let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1780
1851
  let settlingRun = false;
1852
+ let deferUpdateNotifications = false;
1781
1853
  let latestSnapshot: ThreadAssistantMessage | undefined;
1782
1854
  let snapshotToolCallIds = new Set<string>();
1783
1855
  const snapshotResultIds = new Set<string>();
@@ -1813,7 +1885,11 @@ export class AgUiThreadController {
1813
1885
  this.session.hasMessage(assistantParentId)
1814
1886
  ? assistantParentId
1815
1887
  : repositoryHeadId;
1816
- const created = this.insertAssistantPlaceholder(parentId, cached);
1888
+ const created = this.insertAssistantPlaceholder(
1889
+ parentId,
1890
+ cached,
1891
+ deferUpdateNotifications,
1892
+ );
1817
1893
  assistantMessageId = created;
1818
1894
  this.markPendingAssistantHistory(created, parentId);
1819
1895
  return created;
@@ -2061,6 +2137,7 @@ export class AgUiThreadController {
2061
2137
  },
2062
2138
  replayContexts.get(ownerId),
2063
2139
  interrupt ? new Set<string>() : restrictedPendingToolCallIds,
2140
+ deferUpdateNotifications,
2064
2141
  );
2065
2142
  }
2066
2143
  const { content: _content, ...controlUpdate } = update;
@@ -2078,6 +2155,7 @@ export class AgUiThreadController {
2078
2155
  primaryUpdate,
2079
2156
  replayContexts.get(messageId),
2080
2157
  restrictedPendingToolCallIds,
2158
+ deferUpdateNotifications,
2081
2159
  );
2082
2160
  if (messageId === frameMessageId && resolved !== assistantMessageId) {
2083
2161
  assistantMessageId = resolved;
@@ -2151,7 +2229,11 @@ export class AgUiThreadController {
2151
2229
  const previousId = assistantMessageId;
2152
2230
  assistantMessageId = undefined;
2153
2231
  if (!adoptsReplay && this.session.tryGetMessage(previousId)) {
2154
- const created = this.insertAssistantPlaceholder(previousId);
2232
+ const created = this.insertAssistantPlaceholder(
2233
+ previousId,
2234
+ undefined,
2235
+ deferUpdateNotifications,
2236
+ );
2155
2237
  assistantMessageId = created;
2156
2238
  this.markPendingAssistantHistory(created, previousId);
2157
2239
  }
@@ -2165,7 +2247,11 @@ export class AgUiThreadController {
2165
2247
  replayEnded = true;
2166
2248
  const placeholder = ensureAssistant(true);
2167
2249
  if (placeholder === serverId) return;
2168
- const reassigned = this.reassignAssistantId(placeholder, serverId);
2250
+ const reassigned = this.reassignAssistantId(
2251
+ placeholder,
2252
+ serverId,
2253
+ deferUpdateNotifications,
2254
+ );
2169
2255
  // A collision drops the placeholder before revealing the existing
2170
2256
  // server message as the current head. Only messages introduced during
2171
2257
  // this run can replace the placeholder; regeneration must not rewrite
@@ -2238,13 +2324,19 @@ export class AgUiThreadController {
2238
2324
  (settlingRun && heldMessages.has(messageId));
2239
2325
  if (held) heldMessages.set(messageId, new Set(unresolved));
2240
2326
  else heldMessages.delete(messageId);
2241
- this.updateAssistantMessage(messageId, {
2242
- ...update,
2243
- ...(content !== undefined ? { content } : {}),
2244
- status: held
2245
- ? { type: "running" }
2246
- : { type: "complete", reason: "unknown" },
2247
- });
2327
+ this.updateAssistantMessage(
2328
+ messageId,
2329
+ {
2330
+ ...update,
2331
+ ...(content !== undefined ? { content } : {}),
2332
+ status: held
2333
+ ? { type: "running" }
2334
+ : { type: "complete", reason: "unknown" },
2335
+ },
2336
+ undefined,
2337
+ undefined,
2338
+ deferUpdateNotifications,
2339
+ );
2248
2340
  const current = this.session.tryGetMessage(messageId)?.message;
2249
2341
  if (current?.role === "assistant")
2250
2342
  retainedSnapshots.set(messageId, { ...snapshot, message: current });
@@ -2262,7 +2354,13 @@ export class AgUiThreadController {
2262
2354
  !runStartMessageIds.has(head.id) &&
2263
2355
  !aggregator.isRetained(head.id)
2264
2356
  )
2265
- this.updateAssistantMessage(head.id, { status: { type: "running" } });
2357
+ this.updateAssistantMessage(
2358
+ head.id,
2359
+ { status: { type: "running" } },
2360
+ undefined,
2361
+ undefined,
2362
+ deferUpdateNotifications,
2363
+ );
2266
2364
  };
2267
2365
 
2268
2366
  const abortController = new AbortController();
@@ -2477,6 +2575,13 @@ export class AgUiThreadController {
2477
2575
  if (settlingRun) persistRunAssistants(true);
2478
2576
  const beforeSnapshot =
2479
2577
  event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
2578
+ const previousDeferredNotifications = deferUpdateNotifications;
2579
+ deferUpdateNotifications =
2580
+ event.type === "TEXT_MESSAGE_CONTENT" ||
2581
+ event.type === "REASONING_MESSAGE_CONTENT" ||
2582
+ event.type === "TOOL_CALL_ARGS" ||
2583
+ event.type === "ACTIVITY_DELTA" ||
2584
+ event.type === "STATE_DELTA";
2480
2585
  try {
2481
2586
  const nextAssistantMessageId = this.handleEvent(
2482
2587
  aggregator,
@@ -2569,6 +2674,7 @@ export class AgUiThreadController {
2569
2674
  }
2570
2675
  if (settlingRun) persistRunAssistants(false);
2571
2676
  } finally {
2677
+ deferUpdateNotifications = previousDeferredNotifications;
2572
2678
  settlingRun = false;
2573
2679
  }
2574
2680
  };
@@ -2638,9 +2744,13 @@ export class AgUiThreadController {
2638
2744
  } catch {
2639
2745
  // ignore
2640
2746
  }
2641
- const runAgent: RunAgentWithRunOptions =
2642
- runAgentInstance.runAgent.bind(runAgentInstance);
2643
- await runAgent(input, subscriber, { signal: abortSignal });
2747
+ if (resumeSource === "connect") {
2748
+ await runAgentInstance.connectAgent(input, subscriber);
2749
+ } else {
2750
+ const runAgent: RunAgentWithRunOptions =
2751
+ runAgentInstance.runAgent.bind(runAgentInstance);
2752
+ await runAgent(input, subscriber, { signal: abortSignal });
2753
+ }
2644
2754
  }
2645
2755
  } catch (error) {
2646
2756
  // HttpAgent rethrows a failure it already passed to the subscriber's
@@ -2962,6 +3072,7 @@ export class AgUiThreadController {
2962
3072
  private insertAssistantPlaceholder(
2963
3073
  parentId: string | null,
2964
3074
  id: string = generateOptimisticId(),
3075
+ deferNotification = false,
2965
3076
  ): string {
2966
3077
  const assistant: ThreadAssistantMessage = {
2967
3078
  id,
@@ -2980,11 +3091,15 @@ export class AgUiThreadController {
2980
3091
  };
2981
3092
  this.session.addOrUpdateMessage(parentId ?? this.session.headId, assistant);
2982
3093
  this.session.switchToBranch(id);
2983
- this.publishUpdate();
3094
+ this.publishUpdate(deferNotification);
2984
3095
  return id;
2985
3096
  }
2986
3097
 
2987
- private reassignAssistantId(oldId: string, newId: string): boolean {
3098
+ private reassignAssistantId(
3099
+ oldId: string,
3100
+ newId: string,
3101
+ deferNotification = false,
3102
+ ): boolean {
2988
3103
  if (oldId === newId) return true;
2989
3104
  const oldItem = this.session.tryGetMessage(oldId);
2990
3105
  if (!oldItem) return false;
@@ -3045,7 +3160,7 @@ export class AgUiThreadController {
3045
3160
  );
3046
3161
  }
3047
3162
 
3048
- this.publishUpdate();
3163
+ this.publishUpdate(deferNotification);
3049
3164
  return !collidesWithExisting;
3050
3165
  }
3051
3166
 
@@ -3054,6 +3169,7 @@ export class AgUiThreadController {
3054
3169
  update: ChatModelRunResult,
3055
3170
  replay?: ReplayContext,
3056
3171
  pendingToolCallIds?: ReadonlySet<string>,
3172
+ deferNotification = false,
3057
3173
  ): string {
3058
3174
  let latestStatus: MessageStatus | undefined;
3059
3175
  const touched = this.session.updateMessage(messageId, (message) => {
@@ -3177,10 +3293,10 @@ export class AgUiThreadController {
3177
3293
  latestStatus !== undefined && latestStatus.type !== "running";
3178
3294
  if (isSettled && isOptimisticId(messageId)) {
3179
3295
  const stableId = generateId();
3180
- this.reassignAssistantId(messageId, stableId);
3296
+ this.reassignAssistantId(messageId, stableId, deferNotification);
3181
3297
  resolvedMessageId = stableId;
3182
3298
  } else {
3183
- this.publishUpdate();
3299
+ this.publishUpdate(deferNotification);
3184
3300
  }
3185
3301
  if (this.isPersistableStatus(latestStatus)) {
3186
3302
  this.persistAssistantHistory(resolvedMessageId);
@@ -3546,7 +3662,7 @@ export class AgUiThreadController {
3546
3662
  type: "stateSnapshot",
3547
3663
  snapshot: result.newDocument as ReadonlyJSONValue,
3548
3664
  });
3549
- this.updateActiveAssistantState(activeAssistantId);
3665
+ this.updateActiveAssistantState(activeAssistantId, true);
3550
3666
  } catch (error) {
3551
3667
  this.logger.error?.("[agui] failed to apply state delta", error);
3552
3668
  }
@@ -3658,7 +3774,10 @@ export class AgUiThreadController {
3658
3774
  return activeAssistantId;
3659
3775
  }
3660
3776
 
3661
- private updateActiveAssistantState(messageId: string | undefined): void {
3777
+ private updateActiveAssistantState(
3778
+ messageId: string | undefined,
3779
+ deferNotification = false,
3780
+ ): void {
3662
3781
  const current =
3663
3782
  messageId === undefined
3664
3783
  ? undefined
@@ -3680,7 +3799,7 @@ export class AgUiThreadController {
3680
3799
  };
3681
3800
  });
3682
3801
  }
3683
- this.publishUpdate();
3802
+ this.publishUpdate(deferNotification);
3684
3803
  }
3685
3804
 
3686
3805
  private applyCrossRunToolResult(
@@ -3860,7 +3979,10 @@ export class AgUiThreadController {
3860
3979
  activeMessage?.role === "assistant" ? activeMessage : undefined;
3861
3980
  const normalized = fromAgUiSnapshotMessages(
3862
3981
  rawMessages,
3863
- { showThinking: this.showThinking },
3982
+ {
3983
+ showThinking: this.showThinking,
3984
+ joinAdjacentAssistantMessages: this.joinAdjacentAssistantMessages,
3985
+ },
3864
3986
  this.subagentParentToolCallIds,
3865
3987
  );
3866
3988
  const converted: ThreadMessage[] = [];