@assistant-ui/react-a2a 0.2.34 → 0.2.36

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,8 +1,17 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
3
3
  import type { A2AClient } from "./A2AClient";
4
- import type { A2AMessage, A2AStreamEvent, A2ATask } from "./types";
5
- import type { AppendMessage, ThreadMessage } from "@assistant-ui/core";
4
+ import type {
5
+ A2AAgentCard,
6
+ A2AMessage,
7
+ A2AStreamEvent,
8
+ A2ATask,
9
+ } from "./types";
10
+ import type {
11
+ AppendMessage,
12
+ ExportedMessageRepository,
13
+ ThreadMessage,
14
+ } from "@assistant-ui/core";
6
15
 
7
16
  // --- Mock client factory ---
8
17
 
@@ -264,6 +273,118 @@ describe("A2AThreadRuntimeCore", () => {
264
273
  });
265
274
 
266
275
  describe("history loading", () => {
276
+ it("keeps initial history loading across same-thread message resyncs", async () => {
277
+ let resolve!: (repo: ExportedMessageRepository) => void;
278
+ const pending = new Promise<ExportedMessageRepository>((res) => {
279
+ resolve = res;
280
+ });
281
+ const core = createCore(
282
+ {},
283
+ { history: { load: () => pending, append: async () => {} } },
284
+ );
285
+ const loading = core.__internal_load();
286
+ core.applyExternalMessages([]);
287
+ const wasLoading = core.isLoading;
288
+ const restored = createHistoryMessage(
289
+ "restored",
290
+ "user",
291
+ "Saved history",
292
+ );
293
+ resolve({
294
+ headId: restored.id,
295
+ messages: [{ parentId: null, message: restored }],
296
+ });
297
+ await loading;
298
+ expect(wasLoading).toBe(true);
299
+ expect(core.getMessages()).toEqual([restored]);
300
+ expect(core.isLoading).toBe(false);
301
+ });
302
+
303
+ it.each(["resolve", "reject"])(
304
+ "ignores an initial history load that later %s after a thread switch",
305
+ async (outcome) => {
306
+ let resolve!: (repo: ExportedMessageRepository) => void;
307
+ let reject!: (error: Error) => void;
308
+ const pending = new Promise<ExportedMessageRepository>((res, rej) => {
309
+ resolve = res;
310
+ reject = rej;
311
+ });
312
+ const onError = vi.fn();
313
+ const history = {
314
+ load: vi.fn(() => pending),
315
+ append: vi.fn().mockResolvedValue(undefined),
316
+ };
317
+ const core = createCore({}, { history, onError });
318
+ const loading = core.__internal_load();
319
+ const replacement = createHistoryMessage(
320
+ "selected",
321
+ "user",
322
+ "Selected thread",
323
+ );
324
+ core.applyExternalMessages([replacement]);
325
+ core.resetContext();
326
+
327
+ if (outcome === "resolve") {
328
+ const previous = createHistoryMessage(
329
+ "previous",
330
+ "user",
331
+ "Previous thread",
332
+ );
333
+ resolve({
334
+ headId: previous.id,
335
+ messages: [{ parentId: null, message: previous }],
336
+ });
337
+ } else {
338
+ reject(new Error("Old history failed"));
339
+ }
340
+ await loading;
341
+
342
+ expect(core.getMessages()).toEqual([replacement]);
343
+ expect(core.isLoading).toBe(false);
344
+ expect(onError).not.toHaveBeenCalled();
345
+ await core.__internal_load();
346
+ expect(history.load).toHaveBeenCalledTimes(1);
347
+ },
348
+ );
349
+
350
+ it("ends initial loading immediately when switching threads", async () => {
351
+ let resolve!: (repo: ExportedMessageRepository) => void;
352
+ const pending = new Promise<ExportedMessageRepository>((res) => {
353
+ resolve = res;
354
+ });
355
+ const core = createCore(
356
+ {},
357
+ { history: { load: () => pending, append: async () => {} } },
358
+ );
359
+ const loading = core.__internal_load();
360
+ expect(core.isLoading).toBe(true);
361
+ core.applyExternalMessages([]);
362
+ core.resetContext();
363
+ const updatesAfterReplacement = notifyUpdate.mock.calls.length;
364
+ expect(core.isLoading).toBe(false);
365
+ resolve({ messages: [] });
366
+ await loading;
367
+ expect(notifyUpdate).toHaveBeenCalledTimes(updatesAfterReplacement);
368
+ });
369
+
370
+ it("does not import old history when agent-card discovery settles after a thread switch", async () => {
371
+ let resolveCard!: (card: A2AAgentCard) => void;
372
+ const card = new Promise<A2AAgentCard>((resolve) => {
373
+ resolveCard = resolve;
374
+ });
375
+ const { history } = createBranchedHistory();
376
+ const core = createCore({ getAgentCard: () => card }, { history });
377
+ const loading = core.__internal_load();
378
+ await Promise.resolve();
379
+ core.applyExternalMessages([]);
380
+ core.resetContext();
381
+ resolveCard({ name: "Agent" } as A2AAgentCard);
382
+ await loading;
383
+ expect(core.getMessages()).toEqual([]);
384
+ expect(core.getAgentCard()?.name).toBe("Agent");
385
+ expect(core.isLoading).toBe(false);
386
+ });
387
+
267
388
  it("preserves sibling branches and selects the persisted head", async () => {
268
389
  const { user, firstAssistant, secondAssistant, history } =
269
390
  createBranchedHistory();
@@ -791,6 +912,147 @@ describe("A2AThreadRuntimeCore", () => {
791
912
  // --- Sync (non-streaming) fallback ---
792
913
 
793
914
  describe("sync fallback", () => {
915
+ it("retries agent card discovery after a transient failure", async () => {
916
+ let now = 1_000;
917
+ vi.spyOn(Date, "now").mockImplementation(() => now);
918
+ let resolveRecovery!: (card: A2AAgentCard) => void;
919
+ const transportOrder: string[] = [];
920
+ const getAgentCard = vi
921
+ .fn()
922
+ .mockRejectedValueOnce(new Error("temporary failure"))
923
+ .mockImplementationOnce(
924
+ () =>
925
+ new Promise<A2AAgentCard>((resolve) => {
926
+ resolveRecovery = resolve;
927
+ }),
928
+ );
929
+ const sendMessage = vi.fn().mockImplementation(async () => {
930
+ transportOrder.push("sync");
931
+ return {
932
+ id: "t1",
933
+ status: { state: "completed" },
934
+ } satisfies A2ATask;
935
+ });
936
+ const streamMessage = vi.fn().mockImplementation(async function* () {
937
+ transportOrder.push("stream");
938
+ yield statusUpdateEvent("completed");
939
+ });
940
+ const core = createCore({ getAgentCard, sendMessage, streamMessage });
941
+
942
+ await core.append(createUserAppendMessage("First"));
943
+ now += 5_000;
944
+ await core.append(createUserAppendMessage("Second"));
945
+ resolveRecovery({
946
+ name: "Agent",
947
+ capabilities: { streaming: false },
948
+ } as A2AAgentCard);
949
+ await vi.waitFor(() => expect(core.getAgentCard()).toBeDefined());
950
+ await core.append(createUserAppendMessage("Third"));
951
+
952
+ expect(getAgentCard).toHaveBeenCalledTimes(2);
953
+ expect(streamMessage).toHaveBeenCalledTimes(2);
954
+ expect(sendMessage).toHaveBeenCalledOnce();
955
+ expect(transportOrder).toEqual(["stream", "stream", "sync"]);
956
+ });
957
+
958
+ it("does not repeat failed discovery during the retry delay", async () => {
959
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
960
+ const getAgentCard = vi.fn().mockRejectedValue(new Error("unavailable"));
961
+ const streamMessage = vi.fn().mockImplementation(async function* () {
962
+ yield statusUpdateEvent("completed");
963
+ });
964
+ const core = createCore({ getAgentCard, streamMessage });
965
+
966
+ await core.append(createUserAppendMessage("First"));
967
+ await core.append(createUserAppendMessage("Second"));
968
+
969
+ expect(getAgentCard).toHaveBeenCalledOnce();
970
+ expect(streamMessage).toHaveBeenCalledTimes(2);
971
+ });
972
+
973
+ it("backs off persistent failures without blocking later sends", async () => {
974
+ let now = 1_000;
975
+ vi.spyOn(Date, "now").mockImplementation(() => now);
976
+ let rejectSecond!: (error: Error) => void;
977
+ const getAgentCard = vi
978
+ .fn()
979
+ .mockRejectedValueOnce(new Error("first failure"))
980
+ .mockImplementationOnce(
981
+ () =>
982
+ new Promise<A2AAgentCard>((_resolve, reject) => {
983
+ rejectSecond = reject;
984
+ }),
985
+ )
986
+ .mockRejectedValue(new Error("still unavailable"));
987
+ const streamMessage = vi.fn().mockImplementation(async function* () {
988
+ yield statusUpdateEvent("completed");
989
+ });
990
+ const core = createCore({ getAgentCard, streamMessage });
991
+
992
+ await core.append(createUserAppendMessage("First"));
993
+ now = 6_000;
994
+ await core.append(createUserAppendMessage("Second"));
995
+ expect(streamMessage).toHaveBeenCalledTimes(2);
996
+ rejectSecond(new Error("second failure"));
997
+ await vi.waitFor(() => expect(getAgentCard).toHaveBeenCalledTimes(2));
998
+
999
+ now = 15_999;
1000
+ await core.append(createUserAppendMessage("Third"));
1001
+ expect(getAgentCard).toHaveBeenCalledTimes(2);
1002
+
1003
+ now = 16_000;
1004
+ await core.append(createUserAppendMessage("Fourth"));
1005
+ expect(getAgentCard).toHaveBeenCalledTimes(3);
1006
+ expect(streamMessage).toHaveBeenCalledTimes(4);
1007
+ });
1008
+
1009
+ it("waits for agent capabilities before choosing the first send method", async () => {
1010
+ let resolveAgentCard!: (value: A2AAgentCard) => void;
1011
+ const getAgentCard = vi.fn(
1012
+ () =>
1013
+ new Promise<A2AAgentCard>((resolve) => {
1014
+ resolveAgentCard = resolve;
1015
+ }),
1016
+ );
1017
+ const sendMessage = vi.fn().mockResolvedValue({
1018
+ id: "t1",
1019
+ status: { state: "completed" },
1020
+ } satisfies A2ATask);
1021
+ const streamMessage = vi.fn();
1022
+ const core = createCore({ getAgentCard, sendMessage, streamMessage });
1023
+
1024
+ const run = core.append(createUserAppendMessage("Hello"));
1025
+ expect(sendMessage).not.toHaveBeenCalled();
1026
+ expect(streamMessage).not.toHaveBeenCalled();
1027
+
1028
+ resolveAgentCard({
1029
+ name: "Agent",
1030
+ capabilities: { streaming: false },
1031
+ } as A2AAgentCard);
1032
+ await run;
1033
+
1034
+ expect(getAgentCard).toHaveBeenCalledOnce();
1035
+ expect(sendMessage).toHaveBeenCalledOnce();
1036
+ expect(streamMessage).not.toHaveBeenCalled();
1037
+ });
1038
+
1039
+ it("stops waiting for agent capabilities when the run is cancelled", async () => {
1040
+ const getAgentCard = vi.fn(() => new Promise<A2AAgentCard>(() => {}));
1041
+ const sendMessage = vi.fn();
1042
+ const streamMessage = vi.fn();
1043
+ const core = createCore({ getAgentCard, sendMessage, streamMessage });
1044
+
1045
+ const run = core.append(createUserAppendMessage("Hello"));
1046
+ expect(core.isRunning()).toBe(true);
1047
+
1048
+ await core.cancel();
1049
+ await run;
1050
+
1051
+ expect(core.isRunning()).toBe(false);
1052
+ expect(sendMessage).not.toHaveBeenCalled();
1053
+ expect(streamMessage).not.toHaveBeenCalled();
1054
+ });
1055
+
794
1056
  it("uses sendMessage when streaming is false in agent card", async () => {
795
1057
  const sendMessage = vi.fn().mockResolvedValue({
796
1058
  id: "t1",
@@ -1256,6 +1518,136 @@ describe("A2AThreadRuntimeCore", () => {
1256
1518
  expect(cancelTask).not.toHaveBeenCalled();
1257
1519
  });
1258
1520
 
1521
+ it("ignores a cancellation response that resolves after a newer task snapshot", async () => {
1522
+ let resolveCancel!: (task: A2ATask) => void;
1523
+ const cancelTask = vi.fn().mockReturnValue(
1524
+ new Promise<A2ATask>((resolve) => {
1525
+ resolveCancel = resolve;
1526
+ }),
1527
+ );
1528
+ const streamMessage = vi.fn().mockImplementation(async function* (
1529
+ _msg: any,
1530
+ _cfg: any,
1531
+ _meta: any,
1532
+ signal: AbortSignal,
1533
+ ) {
1534
+ if (streamMessage.mock.calls.length === 1) {
1535
+ yield statusUpdateEvent("working");
1536
+ await new Promise<void>((resolve) => {
1537
+ if (signal.aborted) resolve();
1538
+ else
1539
+ signal.addEventListener("abort", () => resolve(), {
1540
+ once: true,
1541
+ });
1542
+ });
1543
+ return;
1544
+ }
1545
+ yield statusUpdateEvent("working", "Second answer");
1546
+ });
1547
+ const core = createCore({ cancelTask, streamMessage });
1548
+
1549
+ const firstRun = core.append(createUserAppendMessage("First"));
1550
+ await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
1551
+
1552
+ const cancelPromise = core.cancel();
1553
+ await firstRun;
1554
+
1555
+ await core.append(createUserAppendMessage("Second"));
1556
+ expect(core.getTask()?.status.state).toBe("working");
1557
+
1558
+ resolveCancel({ id: "t1", status: { state: "canceled" } });
1559
+ await cancelPromise;
1560
+
1561
+ expect(cancelTask).toHaveBeenCalledExactlyOnceWith("t1");
1562
+ expect(core.getTask()).toMatchObject({
1563
+ id: "t1",
1564
+ status: { state: "working" },
1565
+ });
1566
+ });
1567
+
1568
+ it("ignores a cancellation response that resolves after a newer run starts", async () => {
1569
+ let resolveCancel!: (task: A2ATask) => void;
1570
+ const cancelTask = vi.fn().mockReturnValue(
1571
+ new Promise<A2ATask>((resolve) => {
1572
+ resolveCancel = resolve;
1573
+ }),
1574
+ );
1575
+ // The follow-up run never emits, so it leaves the non-terminal task
1576
+ // object from the first run in place: only the run itself is newer.
1577
+ const streamMessage = vi.fn().mockImplementation(async function* (
1578
+ _msg: any,
1579
+ _cfg: any,
1580
+ _meta: any,
1581
+ signal: AbortSignal,
1582
+ ) {
1583
+ if (streamMessage.mock.calls.length === 1) {
1584
+ yield statusUpdateEvent("working");
1585
+ }
1586
+ await new Promise<void>((resolve) => {
1587
+ if (signal.aborted) resolve();
1588
+ else
1589
+ signal.addEventListener("abort", () => resolve(), { once: true });
1590
+ });
1591
+ });
1592
+ const core = createCore({ cancelTask, streamMessage });
1593
+
1594
+ const firstRun = core.append(createUserAppendMessage("First"));
1595
+ await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
1596
+
1597
+ const cancelPromise = core.cancel();
1598
+ await firstRun;
1599
+
1600
+ void core.append(createUserAppendMessage("Second"));
1601
+ await vi.waitFor(() => expect(streamMessage).toHaveBeenCalledTimes(2));
1602
+ expect(core.isRunning()).toBe(true);
1603
+
1604
+ resolveCancel({ id: "t1", status: { state: "canceled" } });
1605
+ await cancelPromise;
1606
+
1607
+ expect(core.getTask()?.status.state).toBe("working");
1608
+ });
1609
+
1610
+ it("still cancels the server task when onCancel clears the thread", async () => {
1611
+ let resolveCancel!: (task: A2ATask) => void;
1612
+ const cancelTask = vi.fn().mockReturnValue(
1613
+ new Promise<A2ATask>((resolve) => {
1614
+ resolveCancel = resolve;
1615
+ }),
1616
+ );
1617
+ const streamMessage = vi.fn().mockImplementation(async function* (
1618
+ _msg: any,
1619
+ _cfg: any,
1620
+ _meta: any,
1621
+ signal: AbortSignal,
1622
+ ) {
1623
+ yield statusUpdateEvent("working");
1624
+ await new Promise<void>((resolve) => {
1625
+ if (signal.aborted) resolve();
1626
+ else
1627
+ signal.addEventListener("abort", () => resolve(), { once: true });
1628
+ });
1629
+ });
1630
+ let core!: A2AThreadRuntimeCore;
1631
+ core = createCore(
1632
+ { cancelTask, streamMessage },
1633
+ { onCancel: () => core.applyExternalMessages([]) },
1634
+ );
1635
+
1636
+ const runPromise = core.append(createUserAppendMessage("Go"));
1637
+ await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
1638
+
1639
+ const cancelPromise = core.cancel();
1640
+ await runPromise;
1641
+
1642
+ expect(cancelTask).toHaveBeenCalledExactlyOnceWith("t1");
1643
+
1644
+ resolveCancel({ id: "t1", status: { state: "canceled" } });
1645
+ await cancelPromise;
1646
+
1647
+ expect(core.getTask()).toBeUndefined();
1648
+ expect(core.getMessages()).toEqual([]);
1649
+ });
1650
+
1259
1651
  it.each(["throws", "rejects"] as const)(
1260
1652
  "isolates onCancel callbacks that %s",
1261
1653
  async (failureMode) => {
@@ -25,6 +25,7 @@ import type {
25
25
  A2ATaskArtifactUpdateEvent,
26
26
  A2ATaskStatusUpdateEvent,
27
27
  } from "./types";
28
+
28
29
  import {
29
30
  a2aMessageToContent,
30
31
  isTerminalTaskState,
@@ -32,6 +33,9 @@ import {
32
33
  taskStateToMessageStatus,
33
34
  } from "./conversions";
34
35
 
36
+ const INITIAL_AGENT_CARD_RETRY_DELAY_MS = 5_000;
37
+ const MAX_AGENT_CARD_RETRY_DELAY_MS = 5 * 60_000;
38
+
35
39
  export type A2AThreadRuntimeCoreOptions = {
36
40
  client: A2AClient;
37
41
  contextId?: string | undefined;
@@ -79,6 +83,7 @@ export class A2AThreadRuntimeCore {
79
83
  private readonly session = createMessageRepositorySession();
80
84
  private isRunningFlag = false;
81
85
  private abortController: AbortController | null = null;
86
+ private runGeneration = 0;
82
87
  private pendingError: Error | null = null;
83
88
 
84
89
  // A2A-specific state
@@ -91,8 +96,12 @@ export class A2AThreadRuntimeCore {
91
96
  private readonly recordedHistoryIds = new Set<string>();
92
97
  private _isLoading = false;
93
98
  private _loadPromise: Promise<void> | undefined;
99
+ private _historyLoadGeneration = 0;
94
100
  private _loadRequested = false;
95
101
  private _agentCardPromise: Promise<void> | undefined;
102
+ private _agentCardRetryAfter = 0;
103
+ private _agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
104
+ private _agentCardDiscoveryFailed = false;
96
105
 
97
106
  private lastOptionsContextId: string | undefined;
98
107
 
@@ -139,6 +148,8 @@ export class A2AThreadRuntimeCore {
139
148
  /** Thread-boundary reset: applyExternalMessages alone also serves branch
140
149
  * switches, deletes, and cancel resyncs, which must keep the live context. */
141
150
  resetContext(): void {
151
+ this._historyLoadGeneration++;
152
+ this._isLoading = false;
142
153
  // Restore the seed before aborting: an onCancel callback that starts a
143
154
  // new run must not pick up the old thread's context, and its controller
144
155
  // must not be discarded.
@@ -197,31 +208,72 @@ export class A2AThreadRuntimeCore {
197
208
  return this._isLoading;
198
209
  }
199
210
 
200
- __internal_load(): Promise<void> {
201
- this._loadRequested = true;
202
- this._agentCardPromise ??= this.client
203
- .getAgentCard()
204
- .then((agentCard) => {
211
+ private loadAgentCard(): Promise<void> {
212
+ if (Date.now() < this._agentCardRetryAfter) return Promise.resolve();
213
+
214
+ this._agentCardPromise ??= this.client.getAgentCard().then(
215
+ (agentCard) => {
205
216
  this.agentCardValue = agentCard;
217
+ this._agentCardRetryAfter = 0;
218
+ this._agentCardRetryDelay = INITIAL_AGENT_CARD_RETRY_DELAY_MS;
219
+ this._agentCardDiscoveryFailed = false;
206
220
  this.notifyUpdate();
207
- })
208
- .catch(() => undefined);
221
+ },
222
+ () => {
223
+ this._agentCardDiscoveryFailed = true;
224
+ this._agentCardRetryAfter = Date.now() + this._agentCardRetryDelay;
225
+ this._agentCardRetryDelay = Math.min(
226
+ this._agentCardRetryDelay * 2,
227
+ MAX_AGENT_CARD_RETRY_DELAY_MS,
228
+ );
229
+ this._agentCardPromise = undefined;
230
+ },
231
+ );
232
+ return this._agentCardPromise;
233
+ }
234
+
235
+ private async waitForAgentCard(signal: AbortSignal): Promise<boolean> {
236
+ const shouldWait = !this._agentCardDiscoveryFailed;
237
+ const load = this.loadAgentCard();
238
+ if (signal.aborted) return false;
239
+ if (!shouldWait) return true;
240
+
241
+ let onAbort!: () => void;
242
+ const abort = new Promise<void>((resolve) => {
243
+ onAbort = resolve;
244
+ signal.addEventListener("abort", onAbort, { once: true });
245
+ });
246
+
247
+ try {
248
+ await Promise.race([load, abort]);
249
+ } finally {
250
+ signal.removeEventListener("abort", onAbort);
251
+ }
252
+ return !signal.aborted;
253
+ }
254
+
255
+ __internal_load(): Promise<void> {
256
+ this._loadRequested = true;
257
+ const agentCardPromise = this.loadAgentCard();
209
258
 
210
259
  if (this._loadPromise) return this._loadPromise;
211
- if (!this.history) return this._agentCardPromise;
260
+ if (!this.history) return agentCardPromise;
212
261
 
213
262
  this._isLoading = true;
214
263
 
264
+ const generation = this._historyLoadGeneration;
215
265
  const historyPromise = this.history.load();
216
266
 
217
- this._loadPromise = Promise.all([historyPromise, this._agentCardPromise])
267
+ this._loadPromise = Promise.all([historyPromise, agentCardPromise])
218
268
  .then(([repo]) => {
269
+ if (generation !== this._historyLoadGeneration) return;
219
270
  if (repo) {
220
271
  this.session.applyExternalMessageRepository(repo);
221
272
  this.finalizeExternalApply();
222
273
  }
223
274
  })
224
275
  .catch((error) => {
276
+ if (generation !== this._historyLoadGeneration) return;
225
277
  invokeRuntimeCallback(
226
278
  "onError",
227
279
  this.onError,
@@ -229,6 +281,7 @@ export class A2AThreadRuntimeCore {
229
281
  );
230
282
  })
231
283
  .finally(() => {
284
+ if (generation !== this._historyLoadGeneration) return;
232
285
  this._isLoading = false;
233
286
  this.notifyUpdate();
234
287
  });
@@ -283,14 +336,26 @@ export class A2AThreadRuntimeCore {
283
336
  async cancel(): Promise<void> {
284
337
  if (!this.abortController) return;
285
338
 
339
+ // Read the server target before aborting: the abort listener runs the
340
+ // onCancel callback synchronously, which may clear the thread and with it
341
+ // the task this cancellation is for, or start a new run.
342
+ const task = this.currentTask;
343
+ const generation = this.runGeneration;
344
+
286
345
  // Abort locally first so the stream stops immediately
287
346
  this.abortController.abort();
288
347
 
289
348
  // Then try to cancel the task on the server
290
- if (this.currentTask?.id) {
349
+ if (task?.id) {
291
350
  try {
292
- const updated = await this.client.cancelTask(this.currentTask.id);
293
- this.currentTask = updated;
351
+ const updated = await this.client.cancelTask(task.id);
352
+ // Only apply the response while nothing newer exists. A newer snapshot
353
+ // or a cleared thread replaces the task object; a follow-up run that
354
+ // has not emitted yet keeps it, so the run generation is what rules
355
+ // that case out.
356
+ if (this.currentTask === task && this.runGeneration === generation) {
357
+ this.currentTask = updated;
358
+ }
294
359
  } catch {
295
360
  // Server cancel failed; local abort already handled
296
361
  }
@@ -360,6 +425,8 @@ export class A2AThreadRuntimeCore {
360
425
  // --- Run logic ---
361
426
 
362
427
  private async startRun(userThreadMessage: ThreadMessage): Promise<void> {
428
+ this.runGeneration++;
429
+
363
430
  // Cancel any in-progress run before starting a new one
364
431
  if (this.abortController) {
365
432
  this.abortController.abort();
@@ -407,11 +474,11 @@ export class A2AThreadRuntimeCore {
407
474
 
408
475
  this.setRunning(true);
409
476
 
410
- // Check if agent supports streaming; fall back to sync sendMessage if not
411
- const supportsStreaming =
412
- this.agentCardValue?.capabilities?.streaming !== false;
413
-
414
477
  try {
478
+ if (!(await this.waitForAgentCard(abortController.signal))) return;
479
+
480
+ const supportsStreaming =
481
+ this.agentCardValue?.capabilities?.streaming !== false;
415
482
  if (supportsStreaming) {
416
483
  await this.runStreaming(a2aMessage, assistantId, abortController);
417
484
  } else {