@assistant-ui/react-a2a 0.2.41 → 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) {
@@ -257,7 +284,7 @@ export class A2AThreadRuntimeCore {
257
284
  };
258
285
  });
259
286
  if (touched) {
260
- this.notifyUpdate();
287
+ this.publishUpdate();
261
288
  this.persistAssistantHistory(messageId);
262
289
  }
263
290
  }
@@ -291,7 +318,7 @@ export class A2AThreadRuntimeCore {
291
318
  this._agentCardRetryAfter = 0;
292
319
  this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
293
320
  this._agentCardDiscoveryFailed = false;
294
- this.notifyUpdate();
321
+ this.publishUpdate();
295
322
  },
296
323
  () => {
297
324
  this._agentCardDiscoveryFailed = true;
@@ -357,10 +384,10 @@ export class A2AThreadRuntimeCore {
357
384
  .finally(() => {
358
385
  if (generation !== this._historyLoadGeneration) return;
359
386
  this._isLoading = false;
360
- this.notifyUpdate();
387
+ this.publishUpdate();
361
388
  });
362
389
 
363
- this.notifyUpdate();
390
+ this.publishUpdate();
364
391
  return this._loadPromise;
365
392
  }
366
393
 
@@ -380,7 +407,7 @@ export class A2AThreadRuntimeCore {
380
407
  : this.session.headId;
381
408
  this.session.addOrUpdateMessage(parentId, threadMessage);
382
409
  this.session.switchToBranch(threadMessage.id);
383
- this.notifyUpdate();
410
+ this.publishUpdate();
384
411
  this.recordHistoryEntry(parentId, threadMessage);
385
412
 
386
413
  if (!startRun) return;
@@ -391,7 +418,7 @@ export class A2AThreadRuntimeCore {
391
418
  const parentId = this.session.headId;
392
419
  this.session.addOrUpdateMessage(parentId, message);
393
420
  this.session.switchToBranch(message.id);
394
- this.notifyUpdate();
421
+ this.publishUpdate();
395
422
  this.recordHistoryEntry(parentId, message);
396
423
  }
397
424
 
@@ -422,7 +449,7 @@ export class A2AThreadRuntimeCore {
422
449
  // onCancel callback synchronously, which may clear the thread and with it
423
450
  // the task this cancellation is for, or start a new run.
424
451
  const task = this.currentTask;
425
- const generation = this.runGeneration;
452
+ const lease = this.runLeases.current();
426
453
 
427
454
  // Abort locally first so the stream stops immediately
428
455
  this.abortController.abort();
@@ -433,11 +460,11 @@ export class A2AThreadRuntimeCore {
433
460
  const updated = await this.client.cancelTask(task.id);
434
461
  // Only apply the response while nothing newer exists. A newer snapshot
435
462
  // or a cleared thread replaces the task object; a follow-up run that
436
- // 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
437
464
  // that case out.
438
- if (this.currentTask === task && this.runGeneration === generation) {
465
+ if (this.currentTask === task && lease.isCurrent()) {
439
466
  this.currentTask = updated;
440
- this.notifyUpdate();
467
+ this.publishUpdate();
441
468
  }
442
469
  } catch {
443
470
  // Server cancel failed; local abort already handled
@@ -471,7 +498,7 @@ export class A2AThreadRuntimeCore {
471
498
  this.currentArtifacts = [];
472
499
  this.a2uiState = new Map();
473
500
  this.a2uiMessageIds.clear();
474
- this.notifyUpdate();
501
+ this.publishUpdate();
475
502
  }
476
503
 
477
504
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
@@ -510,11 +537,17 @@ export class A2AThreadRuntimeCore {
510
537
  // --- Run logic ---
511
538
 
512
539
  private async startRun(userThreadMessage: ThreadMessage): Promise<void> {
513
- this.runGeneration++;
514
-
515
- // Cancel any in-progress run before starting a new one
516
- if (this.abortController) {
517
- 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;
518
551
  this.abortController = null;
519
552
  }
520
553
 
@@ -708,7 +741,7 @@ export class A2AThreadRuntimeCore {
708
741
  const status = taskStateToMessageStatus(event.status.state);
709
742
  this.updateAssistantStatus(assistantId, status);
710
743
 
711
- this.notifyUpdate();
744
+ this.publishUpdate();
712
745
  }
713
746
 
714
747
  private handleArtifactUpdate(
@@ -757,7 +790,7 @@ export class A2AThreadRuntimeCore {
757
790
  );
758
791
  }
759
792
 
760
- this.notifyUpdate();
793
+ this.publishUpdate();
761
794
  }
762
795
 
763
796
  private handleMessage(assistantId: string, message: A2AMessage) {
@@ -766,7 +799,7 @@ export class A2AThreadRuntimeCore {
766
799
  this.applyA2uiMessage(message);
767
800
  const content = a2aMessageToContent(message);
768
801
  this.updateAssistantContent(assistantId, content);
769
- this.notifyUpdate();
802
+ this.publishUpdate();
770
803
  }
771
804
 
772
805
  private handleTaskSnapshot(assistantId: string, task: A2ATask) {
@@ -833,7 +866,7 @@ export class A2AThreadRuntimeCore {
833
866
  const status = taskStateToMessageStatus(task.status.state);
834
867
  this.updateAssistantStatus(assistantId, status);
835
868
 
836
- this.notifyUpdate();
869
+ this.publishUpdate();
837
870
  }
838
871
 
839
872
  // --- Message helpers ---
@@ -856,7 +889,7 @@ export class A2AThreadRuntimeCore {
856
889
  };
857
890
  this.session.addOrUpdateMessage(parentId, assistant);
858
891
  this.session.switchToBranch(id);
859
- this.notifyUpdate();
892
+ this.publishUpdate();
860
893
  return id;
861
894
  }
862
895
 
@@ -890,16 +923,11 @@ export class A2AThreadRuntimeCore {
890
923
  private a2uiSurfaceParts(): ThreadAssistantMessagePart[] {
891
924
  const parts: ThreadAssistantMessagePart[] = [];
892
925
  for (const [surfaceId, surface] of this.a2uiState) {
893
- const { spec } = convertSurfaceToUISpec(surface);
894
- if (!spec) continue;
926
+ const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
927
+ if (!toolCall) continue;
895
928
  parts.push({
896
929
  type: "tool-call",
897
- toolCallId: `a2ui:${surfaceId}`,
898
- toolName: "present",
899
- args: spec as unknown as ReadonlyJSONObject,
900
- argsText: JSON.stringify(spec),
901
- result: {},
902
- artifact: { a2ui: surfaceToOperations(surface) },
930
+ ...toolCall,
903
931
  });
904
932
  }
905
933
  return parts;
@@ -959,7 +987,7 @@ export class A2AThreadRuntimeCore {
959
987
  content: this.withA2uiSurfaces(message.content),
960
988
  };
961
989
  });
962
- if (touched) this.notifyUpdate();
990
+ if (touched) this.publishUpdate();
963
991
  }
964
992
 
965
993
  private updateAssistantArtifacts(messageId: string) {
@@ -981,7 +1009,7 @@ export class A2AThreadRuntimeCore {
981
1009
  },
982
1010
  };
983
1011
  });
984
- if (touched) this.notifyUpdate();
1012
+ if (touched) this.publishUpdate();
985
1013
  }
986
1014
 
987
1015
  private updateAssistantStatus(messageId: string, status: MessageStatus) {
@@ -990,7 +1018,7 @@ export class A2AThreadRuntimeCore {
990
1018
  return { ...message, status };
991
1019
  });
992
1020
  if (touched) {
993
- this.notifyUpdate();
1021
+ this.publishUpdate();
994
1022
  if (this.isPersistableAssistantStatus(status)) {
995
1023
  this.persistAssistantHistory(messageId);
996
1024
  }
@@ -1015,7 +1043,7 @@ export class A2AThreadRuntimeCore {
1015
1043
 
1016
1044
  private setRunning(running: boolean) {
1017
1045
  this.isRunningFlag = running;
1018
- this.notifyUpdate();
1046
+ this.publishUpdate();
1019
1047
  }
1020
1048
 
1021
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