@assistant-ui/react-a2a 0.2.40 → 0.2.42

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.
@@ -17,14 +17,13 @@ import {
17
17
  appendToolInteraction,
18
18
  createMessageRepositorySession,
19
19
  invokeUserCallback,
20
+ RunLeases,
20
21
  } from "@assistant-ui/core/internal";
21
- import type { ReadonlyJSONObject } from "assistant-stream/utils";
22
22
  import {
23
23
  applyA2uiOperations,
24
- convertSurfaceToUISpec,
25
- surfaceToOperations,
24
+ surfaceToPresentToolCall,
26
25
  type A2uiState,
27
- } from "@assistant-ui/react-generative-ui/a2ui";
26
+ } from "@assistant-ui/generative-ui/a2ui";
28
27
  import type { A2AClient } from "./A2AClient";
29
28
  import type {
30
29
  A2AArtifact,
@@ -64,7 +63,6 @@ export type A2AThreadRuntimeCoreOptions = {
64
63
  onCancel?: (() => void) | undefined;
65
64
  onArtifactComplete?: ((artifact: A2AArtifact) => void) | undefined;
66
65
  history?: ThreadHistoryAdapter | undefined;
67
- notifyUpdate: () => void;
68
66
  };
69
67
 
70
68
  const FALLBACK_USER_STATUS = {
@@ -97,13 +95,23 @@ export class A2AThreadRuntimeCore {
97
95
  private onCancel: (() => void) | undefined;
98
96
  private onArtifactComplete: ((artifact: A2AArtifact) => void) | undefined;
99
97
  private history: ThreadHistoryAdapter | undefined;
100
- private readonly notifyUpdate: () => void;
98
+ private readonly listeners = new Set<() => void>();
99
+ private snapshot:
100
+ | {
101
+ isLoading: boolean;
102
+ isRunning: boolean;
103
+ messageRepository: ExportedMessageRepository;
104
+ task: A2ATask | undefined;
105
+ artifacts: readonly A2AArtifact[];
106
+ agentCard: A2AAgentCard | undefined;
107
+ }
108
+ | undefined;
101
109
 
102
110
  private runtime: AssistantRuntime | undefined;
103
111
  private readonly session = createMessageRepositorySession();
104
112
  private isRunningFlag = false;
105
113
  private abortController: AbortController | null = null;
106
- private runGeneration = 0;
114
+ private readonly runLeases = new RunLeases();
107
115
  private pendingError: Error | null = null;
108
116
 
109
117
  // A2A-specific state
@@ -137,15 +145,32 @@ export class A2AThreadRuntimeCore {
137
145
  this.onCancel = options.onCancel;
138
146
  this.onArtifactComplete = options.onArtifactComplete;
139
147
  this.history = options.history;
140
- this.notifyUpdate = options.notifyUpdate;
141
148
  }
142
149
 
143
- updateOptions(options: Omit<A2AThreadRuntimeCoreOptions, "notifyUpdate">) {
150
+ subscribe = (listener: () => void): (() => void) => {
151
+ this.listeners.add(listener);
152
+ return () => this.listeners.delete(listener);
153
+ };
154
+
155
+ getSnapshot = () =>
156
+ (this.snapshot ??= {
157
+ isLoading: this._isLoading,
158
+ isRunning: this.isRunningFlag,
159
+ messageRepository: this.session.export(),
160
+ task: this.currentTask,
161
+ artifacts: this.currentArtifacts,
162
+ agentCard: this.agentCardValue,
163
+ });
164
+
165
+ private publishUpdate(): void {
166
+ this.snapshot = undefined;
167
+ for (const listener of this.listeners) listener();
168
+ }
169
+
170
+ updateOptions(options: A2AThreadRuntimeCoreOptions) {
144
171
  this.client = options.client;
145
- // The hook re-applies options on every render, including renders caused
146
- // by this core's own notifyUpdate. The option only seeds the context: a
147
- // re-render with the same value must not clobber a server-assigned
148
- // contextId learned from the stream.
172
+ // The option only seeds the context; a re-render with the same value must
173
+ // not clobber a server-assigned contextId learned from the stream.
149
174
  if (options.contextId !== this.lastOptionsContextId) {
150
175
  this.contextId = options.contextId;
151
176
  this.lastOptionsContextId = options.contextId;
@@ -172,6 +197,7 @@ export class A2AThreadRuntimeCore {
172
197
  * switches, deletes, and cancel resyncs, which must keep the live context. */
173
198
  resetContext(): void {
174
199
  this._historyLoadGeneration++;
200
+ const wasLoading = this._isLoading;
175
201
  this._isLoading = false;
176
202
  // Restore the seed before aborting: an onCancel callback that starts a
177
203
  // new run must not pick up the old thread's context, and its controller
@@ -184,6 +210,7 @@ export class A2AThreadRuntimeCore {
184
210
  this.abortController = null;
185
211
  }
186
212
  }
213
+ if (wasLoading) this.publishUpdate();
187
214
  }
188
215
 
189
216
  attachRuntime(runtime: AssistantRuntime) {
@@ -193,9 +220,12 @@ export class A2AThreadRuntimeCore {
193
220
  detachRuntime() {
194
221
  this.runtime = undefined;
195
222
  // Abort in-flight requests on unmount
196
- if (this.abortController) {
197
- this.abortController.abort();
198
- this.abortController = null;
223
+ const controller = this.abortController;
224
+ if (controller) {
225
+ controller.abort();
226
+ if (this.abortController === controller) {
227
+ this.abortController = null;
228
+ }
199
229
  }
200
230
  }
201
231
 
@@ -254,7 +284,7 @@ export class A2AThreadRuntimeCore {
254
284
  };
255
285
  });
256
286
  if (touched) {
257
- this.notifyUpdate();
287
+ this.publishUpdate();
258
288
  this.persistAssistantHistory(messageId);
259
289
  }
260
290
  }
@@ -288,7 +318,7 @@ export class A2AThreadRuntimeCore {
288
318
  this._agentCardRetryAfter = 0;
289
319
  this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
290
320
  this._agentCardDiscoveryFailed = false;
291
- this.notifyUpdate();
321
+ this.publishUpdate();
292
322
  },
293
323
  () => {
294
324
  this._agentCardDiscoveryFailed = true;
@@ -354,10 +384,10 @@ export class A2AThreadRuntimeCore {
354
384
  .finally(() => {
355
385
  if (generation !== this._historyLoadGeneration) return;
356
386
  this._isLoading = false;
357
- this.notifyUpdate();
387
+ this.publishUpdate();
358
388
  });
359
389
 
360
- this.notifyUpdate();
390
+ this.publishUpdate();
361
391
  return this._loadPromise;
362
392
  }
363
393
 
@@ -377,7 +407,7 @@ export class A2AThreadRuntimeCore {
377
407
  : this.session.headId;
378
408
  this.session.addOrUpdateMessage(parentId, threadMessage);
379
409
  this.session.switchToBranch(threadMessage.id);
380
- this.notifyUpdate();
410
+ this.publishUpdate();
381
411
  this.recordHistoryEntry(parentId, threadMessage);
382
412
 
383
413
  if (!startRun) return;
@@ -388,7 +418,7 @@ export class A2AThreadRuntimeCore {
388
418
  const parentId = this.session.headId;
389
419
  this.session.addOrUpdateMessage(parentId, message);
390
420
  this.session.switchToBranch(message.id);
391
- this.notifyUpdate();
421
+ this.publishUpdate();
392
422
  this.recordHistoryEntry(parentId, message);
393
423
  }
394
424
 
@@ -419,7 +449,7 @@ export class A2AThreadRuntimeCore {
419
449
  // onCancel callback synchronously, which may clear the thread and with it
420
450
  // the task this cancellation is for, or start a new run.
421
451
  const task = this.currentTask;
422
- const generation = this.runGeneration;
452
+ const lease = this.runLeases.current();
423
453
 
424
454
  // Abort locally first so the stream stops immediately
425
455
  this.abortController.abort();
@@ -430,11 +460,11 @@ export class A2AThreadRuntimeCore {
430
460
  const updated = await this.client.cancelTask(task.id);
431
461
  // Only apply the response while nothing newer exists. A newer snapshot
432
462
  // or a cleared thread replaces the task object; a follow-up run that
433
- // has not emitted yet keeps it, so the run generation is what rules
463
+ // has not emitted yet keeps it, so the run lease is what rules
434
464
  // that case out.
435
- if (this.currentTask === task && this.runGeneration === generation) {
465
+ if (this.currentTask === task && lease.isCurrent()) {
436
466
  this.currentTask = updated;
437
- this.notifyUpdate();
467
+ this.publishUpdate();
438
468
  }
439
469
  } catch {
440
470
  // Server cancel failed; local abort already handled
@@ -468,7 +498,7 @@ export class A2AThreadRuntimeCore {
468
498
  this.currentArtifacts = [];
469
499
  this.a2uiState = new Map();
470
500
  this.a2uiMessageIds.clear();
471
- this.notifyUpdate();
501
+ this.publishUpdate();
472
502
  }
473
503
 
474
504
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
@@ -507,11 +537,17 @@ export class A2AThreadRuntimeCore {
507
537
  // --- Run logic ---
508
538
 
509
539
  private async startRun(userThreadMessage: ThreadMessage): Promise<void> {
510
- this.runGeneration++;
511
-
512
- // Cancel any in-progress run before starting a new one
513
- if (this.abortController) {
514
- this.abortController.abort();
540
+ this.runLeases.begin();
541
+
542
+ // Cancel any in-progress run before starting a new one. Its abort runs
543
+ // onCancel synchronously, and a run that callback starts keeps the thread.
544
+ // A listener that throws before finishRun leaves `previous` installed, so
545
+ // only a different, non-null controller counts as a replacement run.
546
+ const previous = this.abortController;
547
+ if (previous) {
548
+ previous.abort();
549
+ if (this.abortController !== previous && this.abortController !== null)
550
+ return;
515
551
  this.abortController = null;
516
552
  }
517
553
 
@@ -705,7 +741,7 @@ export class A2AThreadRuntimeCore {
705
741
  const status = taskStateToMessageStatus(event.status.state);
706
742
  this.updateAssistantStatus(assistantId, status);
707
743
 
708
- this.notifyUpdate();
744
+ this.publishUpdate();
709
745
  }
710
746
 
711
747
  private handleArtifactUpdate(
@@ -754,7 +790,7 @@ export class A2AThreadRuntimeCore {
754
790
  );
755
791
  }
756
792
 
757
- this.notifyUpdate();
793
+ this.publishUpdate();
758
794
  }
759
795
 
760
796
  private handleMessage(assistantId: string, message: A2AMessage) {
@@ -763,7 +799,7 @@ export class A2AThreadRuntimeCore {
763
799
  this.applyA2uiMessage(message);
764
800
  const content = a2aMessageToContent(message);
765
801
  this.updateAssistantContent(assistantId, content);
766
- this.notifyUpdate();
802
+ this.publishUpdate();
767
803
  }
768
804
 
769
805
  private handleTaskSnapshot(assistantId: string, task: A2ATask) {
@@ -830,7 +866,7 @@ export class A2AThreadRuntimeCore {
830
866
  const status = taskStateToMessageStatus(task.status.state);
831
867
  this.updateAssistantStatus(assistantId, status);
832
868
 
833
- this.notifyUpdate();
869
+ this.publishUpdate();
834
870
  }
835
871
 
836
872
  // --- Message helpers ---
@@ -853,7 +889,7 @@ export class A2AThreadRuntimeCore {
853
889
  };
854
890
  this.session.addOrUpdateMessage(parentId, assistant);
855
891
  this.session.switchToBranch(id);
856
- this.notifyUpdate();
892
+ this.publishUpdate();
857
893
  return id;
858
894
  }
859
895
 
@@ -887,16 +923,11 @@ export class A2AThreadRuntimeCore {
887
923
  private a2uiSurfaceParts(): ThreadAssistantMessagePart[] {
888
924
  const parts: ThreadAssistantMessagePart[] = [];
889
925
  for (const [surfaceId, surface] of this.a2uiState) {
890
- const { spec } = convertSurfaceToUISpec(surface);
891
- if (!spec) continue;
926
+ const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
927
+ if (!toolCall) continue;
892
928
  parts.push({
893
929
  type: "tool-call",
894
- toolCallId: `a2ui:${surfaceId}`,
895
- toolName: "present",
896
- args: spec as unknown as ReadonlyJSONObject,
897
- argsText: JSON.stringify(spec),
898
- result: {},
899
- artifact: { a2ui: surfaceToOperations(surface) },
930
+ ...toolCall,
900
931
  });
901
932
  }
902
933
  return parts;
@@ -956,7 +987,7 @@ export class A2AThreadRuntimeCore {
956
987
  content: this.withA2uiSurfaces(message.content),
957
988
  };
958
989
  });
959
- if (touched) this.notifyUpdate();
990
+ if (touched) this.publishUpdate();
960
991
  }
961
992
 
962
993
  private updateAssistantArtifacts(messageId: string) {
@@ -978,7 +1009,7 @@ export class A2AThreadRuntimeCore {
978
1009
  },
979
1010
  };
980
1011
  });
981
- if (touched) this.notifyUpdate();
1012
+ if (touched) this.publishUpdate();
982
1013
  }
983
1014
 
984
1015
  private updateAssistantStatus(messageId: string, status: MessageStatus) {
@@ -987,7 +1018,7 @@ export class A2AThreadRuntimeCore {
987
1018
  return { ...message, status };
988
1019
  });
989
1020
  if (touched) {
990
- this.notifyUpdate();
1021
+ this.publishUpdate();
991
1022
  if (this.isPersistableAssistantStatus(status)) {
992
1023
  this.persistAssistantHistory(messageId);
993
1024
  }
@@ -1012,7 +1043,7 @@ export class A2AThreadRuntimeCore {
1012
1043
 
1013
1044
  private setRunning(running: boolean) {
1014
1045
  this.isRunningFlag = running;
1015
- this.notifyUpdate();
1046
+ this.publishUpdate();
1016
1047
  }
1017
1048
 
1018
1049
  private finishRun(controller: AbortController | null) {
@@ -475,6 +475,19 @@ describe("isInterruptedTaskState", () => {
475
475
  });
476
476
 
477
477
  describe("contentPartsToA2AParts", () => {
478
+ it.each(["application/pdf", ""])(
479
+ "unwraps a media-less data URL while retaining the adapter's %j MIME fallback",
480
+ (mimeType) => {
481
+ expect(
482
+ contentPartsToA2AParts([
483
+ { type: "file", data: "data:;base64,SGVsbG8=", mimeType },
484
+ ]),
485
+ ).toEqual([
486
+ { raw: "SGVsbG8=", mediaType: mimeType || "application/octet-stream" },
487
+ ]);
488
+ },
489
+ );
490
+
478
491
  it("converts text parts", () => {
479
492
  const result = contentPartsToA2AParts([{ type: "text", text: "hi" }]);
480
493
  expect(result).toEqual([{ text: "hi" }]);
@@ -525,6 +538,14 @@ describe("contentPartsToA2AParts", () => {
525
538
  expect(result).toEqual([{ raw: "aGVsbG8=", mediaType: "image/png" }]);
526
539
  });
527
540
 
541
+ it("sniffs media-less image data URLs before sending their raw bytes", () => {
542
+ expect(
543
+ contentPartsToA2AParts([
544
+ { type: "image", image: "data:;base64,iVBORw0KGgo=" },
545
+ ]),
546
+ ).toEqual([{ raw: "iVBORw0KGgo=", mediaType: "image/png" }]);
547
+ });
548
+
528
549
  it("propagates image filenames", () => {
529
550
  const result = contentPartsToA2AParts([
530
551
  {
@@ -8,6 +8,7 @@ import type {
8
8
  import {
9
9
  parseDataUrl,
10
10
  resolveFilePartSource,
11
+ resolveImageMediaType,
11
12
  } from "@assistant-ui/core/internal";
12
13
  import type { A2AMessage, A2APart, A2ATaskState } from "./types";
13
14
 
@@ -176,7 +177,7 @@ export function contentPartsToA2AParts(
176
177
  if (parsed) {
177
178
  return {
178
179
  raw: parsed.data,
179
- mediaType: parsed.mimeType,
180
+ mediaType: resolveImageMediaType(part.image, fallbackMimeType),
180
181
  ...(part.filename && { filename: part.filename }),
181
182
  };
182
183
  }
@@ -140,6 +140,42 @@ describe("useA2ARuntime", () => {
140
140
  append: vi.fn().mockResolvedValue(undefined),
141
141
  });
142
142
 
143
+ it("re-renders when the core publishes loaded history", async () => {
144
+ const { client, getAgentCard } = createMockClient();
145
+ getAgentCard.mockImplementation(() => new Promise(() => {}));
146
+ let resolveHistory!: (repo: ExportedMessageRepository) => void;
147
+ const pendingHistory = new Promise<ExportedMessageRepository>((resolve) => {
148
+ resolveHistory = resolve;
149
+ });
150
+ const history = {
151
+ load: vi.fn(() => pendingHistory),
152
+ append: async () => {},
153
+ };
154
+ const render = vi.fn();
155
+ const { result } = renderHook(() => {
156
+ render();
157
+ return useA2ARuntime({ client, adapters: { history } });
158
+ });
159
+
160
+ await waitFor(() => expect(history.load).toHaveBeenCalledOnce());
161
+ const rendersBeforeHistory = render.mock.calls.length;
162
+
163
+ await act(async () => {
164
+ resolveHistory({
165
+ headId: "restored",
166
+ messages: [
167
+ { parentId: null, message: createThreadMessage("restored") },
168
+ ],
169
+ });
170
+ await pendingHistory;
171
+ });
172
+
173
+ expect(render.mock.calls.length).toBeGreaterThan(rendersBeforeHistory);
174
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
175
+ "restored",
176
+ ]);
177
+ });
178
+
143
179
  it("loads a history adapter that arrives on a later render", async () => {
144
180
  const { client } = createMockClient();
145
181
  const history = createHistory();
@@ -466,6 +502,74 @@ describe("useA2ARuntime", () => {
466
502
  expect(result.current.thread.export().messages).toEqual([]);
467
503
  });
468
504
 
505
+ it.each(["existing", "new"] as const)(
506
+ "drops a send started by onCancel while switching to the %s thread",
507
+ async (destination) => {
508
+ const { client, streamMessage } = createMockClient(true);
509
+ let resolveSwitch!: () => void;
510
+ const pendingSwitch = new Promise<void>((resolve) => {
511
+ resolveSwitch = resolve;
512
+ });
513
+ let result!: { current: ReturnType<typeof useA2ARuntime> };
514
+ let cancelled = false;
515
+ ({ result } = renderHook(() => {
516
+ const [threadId, setThreadId] = useState("initial");
517
+ return useA2ARuntime({
518
+ client,
519
+ onCancel: () => {
520
+ if (cancelled) return;
521
+ cancelled = true;
522
+ void result.current.thread.append("started by onCancel");
523
+ },
524
+ adapters: {
525
+ threadList: {
526
+ threadId,
527
+ onSwitchToThread: async (id) => {
528
+ setThreadId(id);
529
+ await pendingSwitch;
530
+ return { messages: [createThreadMessage("loaded")] };
531
+ },
532
+ onSwitchToNewThread: async () => {
533
+ setThreadId("thread-new");
534
+ await pendingSwitch;
535
+ },
536
+ },
537
+ },
538
+ });
539
+ }));
540
+
541
+ act(() => {
542
+ void result.current.thread.append("old prompt");
543
+ });
544
+ await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
545
+
546
+ let switching!: Promise<void>;
547
+ act(() => {
548
+ switching =
549
+ destination === "existing"
550
+ ? result.current.threads.switchToThread("thread-a")
551
+ : result.current.threads.switchToNewThread();
552
+ });
553
+ act(() => {
554
+ void result.current.thread.append("during switch");
555
+ });
556
+ await act(async () => {
557
+ await new Promise((resolve) => setTimeout(resolve, 0));
558
+ });
559
+ await act(async () => {
560
+ resolveSwitch();
561
+ await switching;
562
+ });
563
+
564
+ expect(cancelled).toBe(true);
565
+ expect(streamMessage).toHaveBeenCalledOnce();
566
+ expect(result.current.thread.getState().isRunning).toBe(false);
567
+ expect(
568
+ result.current.thread.getState().messages.map((m) => m.id),
569
+ ).toEqual(destination === "existing" ? ["loaded"] : []);
570
+ },
571
+ );
572
+
469
573
  it("leaves a new thread empty after an active run and failed creation", async () => {
470
574
  const { client, streamMessage } = createMockClient(true);
471
575
  let rejectNew!: (error: Error) => void;
@@ -7,6 +7,7 @@ import {
7
7
  useMemo,
8
8
  useRef,
9
9
  useState,
10
+ useSyncExternalStore,
10
11
  } from "react";
11
12
  import {
12
13
  useExternalStoreRuntime,
@@ -43,8 +44,6 @@ const serializeManagedClientOptions = (
43
44
  };
44
45
 
45
46
  export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
46
- const [_version, setVersion] = useState(0);
47
- const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);
48
47
  const runtimeAdapters = useRuntimeAdapters();
49
48
  const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;
50
49
  const threadListAdapter = options.adapters?.threadList;
@@ -118,7 +117,6 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
118
117
  new A2AThreadRuntimeCore({
119
118
  ...coreOptionsRef.current,
120
119
  client,
121
- notifyUpdate,
122
120
  });
123
121
  const [pinnedCore, setPinnedCore] = useState(() => ({
124
122
  client,
@@ -130,6 +128,11 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
130
128
  setPinnedCore(currentCore);
131
129
  }
132
130
  const core = currentCore.core;
131
+ const snapshot = useSyncExternalStore(
132
+ core.subscribe,
133
+ core.getSnapshot,
134
+ core.getSnapshot,
135
+ );
133
136
 
134
137
  useEffect(() => {
135
138
  core.updateOptions(coreOptions);
@@ -137,41 +140,58 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
137
140
 
138
141
  // Thread list
139
142
  const threadSwitchGenerationRef = useRef(0);
143
+ const switchingGenerationRef = useRef<number | null>(null);
140
144
  const threadList = useMemo(() => {
141
145
  if (!threadListAdapter) return undefined;
142
146
 
143
147
  const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;
144
148
 
149
+ const releaseSwitch = (generation: number) => {
150
+ if (switchingGenerationRef.current === generation) {
151
+ switchingGenerationRef.current = null;
152
+ }
153
+ };
154
+
145
155
  return {
146
156
  threadId: threadListAdapter.threadId,
147
157
  onSwitchToNewThread: onSwitchToNewThread
148
158
  ? async () => {
149
159
  const generation = ++threadSwitchGenerationRef.current;
150
- // Clear before the thread id flips, or the old messages leak
151
- // into the new thread as a sibling branch.
152
- core.applyExternalMessages([]);
153
- core.resetContext();
154
- await onSwitchToNewThread();
155
- if (generation !== threadSwitchGenerationRef.current) return;
156
- // Apply first so the abort inside resetContext finds an already
157
- // cleared repository and cannot persist the old thread's partial
158
- // assistant message.
159
- core.applyExternalMessages([]);
160
- core.resetContext();
160
+ switchingGenerationRef.current = generation;
161
+ try {
162
+ // Clear before the thread id flips, or the old messages leak
163
+ // into the new thread as a sibling branch.
164
+ core.applyExternalMessages([]);
165
+ core.resetContext();
166
+ await onSwitchToNewThread();
167
+ if (generation !== threadSwitchGenerationRef.current) return;
168
+ // Apply first so the abort inside resetContext finds an already
169
+ // cleared repository and cannot persist the old thread's partial
170
+ // assistant message.
171
+ core.applyExternalMessages([]);
172
+ core.resetContext();
173
+ } finally {
174
+ releaseSwitch(generation);
175
+ }
161
176
  }
162
177
  : undefined,
163
178
  onSwitchToThread: onSwitchToThread
164
179
  ? async (threadId: string) => {
165
180
  const generation = ++threadSwitchGenerationRef.current;
166
- // Clear before the thread id flips, or the old messages leak
167
- // into the new thread as a sibling branch.
168
- core.applyExternalMessages([]);
169
- core.resetContext();
170
- const result = await onSwitchToThread(threadId);
171
- if (generation !== threadSwitchGenerationRef.current) return;
172
- core.applyExternalMessages([]);
173
- core.applyExternalMessages(result.messages);
174
- core.resetContext();
181
+ switchingGenerationRef.current = generation;
182
+ try {
183
+ // Clear before the thread id flips, or the old messages leak
184
+ // into the new thread as a sibling branch.
185
+ core.applyExternalMessages([]);
186
+ core.resetContext();
187
+ const result = await onSwitchToThread(threadId);
188
+ if (generation !== threadSwitchGenerationRef.current) return;
189
+ core.applyExternalMessages([]);
190
+ core.applyExternalMessages(result.messages);
191
+ core.resetContext();
192
+ } finally {
193
+ releaseSwitch(generation);
194
+ }
175
195
  }
176
196
  : undefined,
177
197
  };
@@ -194,24 +214,31 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
194
214
  // Build store adapter
195
215
  const shared = useExternalStoreSharedOptions(options);
196
216
  const store = useMemo(() => {
197
- void _version;
198
-
199
217
  return {
200
218
  ...shared,
201
- isLoading: core.isLoading,
202
- messageRepository: core.getMessageRepository(),
203
- isRunning: core.isRunning(),
219
+ isLoading: snapshot.isLoading,
220
+ messageRepository: snapshot.messageRepository,
221
+ isRunning: snapshot.isRunning,
204
222
  unstable_persistsHistory: true,
205
223
  extras: a2aExtras.provide({
206
- task: core.getTask(),
207
- artifacts: core.getArtifacts(),
208
- agentCard: core.getAgentCard(),
224
+ task: snapshot.task,
225
+ artifacts: snapshot.artifacts,
226
+ agentCard: snapshot.agentCard,
209
227
  }),
210
- onNew: (message: AppendMessage) => core.append(message),
228
+ onNew: (message: AppendMessage) =>
229
+ switchingGenerationRef.current === null
230
+ ? core.append(message)
231
+ : Promise.resolve(),
211
232
  onVoiceTranscript: (message: ThreadMessage) =>
212
233
  core.appendVoiceTranscript(message),
213
- onEdit: (message: AppendMessage) => core.edit(message),
214
- onReload: (parentId: string | null) => core.reload(parentId),
234
+ onEdit: (message: AppendMessage) =>
235
+ switchingGenerationRef.current === null
236
+ ? core.edit(message)
237
+ : Promise.resolve(),
238
+ onReload: (parentId: string | null) =>
239
+ switchingGenerationRef.current === null
240
+ ? core.reload(parentId)
241
+ : Promise.resolve(),
215
242
  onCancel: () => core.cancel(),
216
243
  unstable_onRecordToolInteraction: (options) =>
217
244
  core.recordToolInteraction(options),
@@ -221,7 +248,7 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
221
248
  core.applyExternalMessages(messages),
222
249
  adapters: adapterAdapters,
223
250
  } satisfies ExternalStoreAdapter<ThreadMessage>;
224
- }, [adapterAdapters, core, _version, shared]);
251
+ }, [adapterAdapters, core, snapshot, shared]);
225
252
 
226
253
  const runtime = useExternalStoreRuntime(store);
227
254