@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.
- package/dist/A2AClient.d.ts +3 -4
- package/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +9 -3
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +56 -10
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/a2aExtras.d.ts +1 -2
- package/dist/a2aExtras.d.ts.map +1 -1
- package/dist/conversions.d.ts +8 -9
- package/dist/conversions.d.ts.map +1 -1
- package/dist/hooks.d.ts +3 -4
- package/dist/hooks.d.ts.map +1 -1
- package/dist/types.d.ts +39 -40
- package/dist/types.d.ts.map +1 -1
- package/dist/useA2ARuntime.d.ts +1 -2
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/A2AThreadRuntimeCore.test.ts +394 -2
- package/src/A2AThreadRuntimeCore.ts +83 -16
- package/src/useA2ARuntime.test.tsx +112 -6
|
@@ -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 {
|
|
5
|
-
|
|
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
|
-
|
|
201
|
-
this.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
349
|
+
if (task?.id) {
|
|
291
350
|
try {
|
|
292
|
-
const updated = await this.client.cancelTask(
|
|
293
|
-
|
|
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 {
|