@assistant-ui/react-a2a 0.2.35 → 0.2.37
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 +45 -48
- package/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AClient.js +43 -7
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +100 -87
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +218 -20
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/a2aExtras.d.ts +2 -5
- package/dist/a2aExtras.d.ts.map +1 -1
- package/dist/conversions.d.ts +26 -26
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +24 -2
- package/dist/conversions.js.map +1 -1
- package/dist/hooks.d.ts +3 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -0
- package/dist/types.d.ts +276 -279
- package/dist/types.d.ts.map +1 -1
- package/dist/useA2ARuntime.d.ts +3 -6
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +8 -0
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +12 -10
- package/src/A2AClient.test.ts +41 -0
- package/src/A2AClient.ts +50 -6
- package/src/A2AThreadRuntimeCore.test.ts +1134 -10
- package/src/A2AThreadRuntimeCore.ts +372 -21
- package/src/conversions.test.ts +116 -0
- package/src/conversions.ts +48 -1
- package/src/useA2ARuntime.test.tsx +336 -1
- package/src/useA2ARuntime.ts +14 -0
- package/src/useA2ARuntime.voice.test.tsx +370 -0
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyA2uiOperations,
|
|
4
|
+
convertSurfaceToUISpec,
|
|
5
|
+
} from "@assistant-ui/react-generative-ui/a2ui";
|
|
2
6
|
import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
|
|
3
7
|
import type { A2AClient } from "./A2AClient";
|
|
4
8
|
import type {
|
|
5
9
|
A2AAgentCard,
|
|
6
10
|
A2AMessage,
|
|
11
|
+
A2APart,
|
|
7
12
|
A2AStreamEvent,
|
|
8
13
|
A2ATask,
|
|
9
14
|
} from "./types";
|
|
10
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
AppendMessage,
|
|
17
|
+
ExportedMessageRepository,
|
|
18
|
+
ThreadMessage,
|
|
19
|
+
} from "@assistant-ui/core";
|
|
11
20
|
|
|
12
21
|
// --- Mock client factory ---
|
|
13
22
|
|
|
@@ -96,7 +105,11 @@ function createBranchedHistory() {
|
|
|
96
105
|
return { user, firstAssistant, secondAssistant, history };
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
function statusUpdateEvent(
|
|
108
|
+
function statusUpdateEvent(
|
|
109
|
+
state: string,
|
|
110
|
+
text?: string,
|
|
111
|
+
parts?: A2APart[],
|
|
112
|
+
): A2AStreamEvent {
|
|
100
113
|
return {
|
|
101
114
|
type: "statusUpdate",
|
|
102
115
|
event: {
|
|
@@ -104,13 +117,15 @@ function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
|
|
|
104
117
|
contextId: "ctx-1",
|
|
105
118
|
status: {
|
|
106
119
|
state: state as any,
|
|
107
|
-
...(text
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
...(text !== undefined || parts !== undefined
|
|
121
|
+
? {
|
|
122
|
+
message: {
|
|
123
|
+
messageId: "s1",
|
|
124
|
+
role: "agent" as const,
|
|
125
|
+
parts: parts ?? [{ text }],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
114
129
|
},
|
|
115
130
|
},
|
|
116
131
|
};
|
|
@@ -118,7 +133,7 @@ function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
|
|
|
118
133
|
|
|
119
134
|
function artifactUpdateEvent(
|
|
120
135
|
artifactId: string,
|
|
121
|
-
parts:
|
|
136
|
+
parts: A2APart[],
|
|
122
137
|
opts: { append?: boolean; lastChunk?: boolean } = {},
|
|
123
138
|
): A2AStreamEvent {
|
|
124
139
|
return {
|
|
@@ -133,6 +148,12 @@ function artifactUpdateEvent(
|
|
|
133
148
|
};
|
|
134
149
|
}
|
|
135
150
|
|
|
151
|
+
const toolInteraction = {
|
|
152
|
+
type: "action" as const,
|
|
153
|
+
occurredAt: 1_700_000_000_000,
|
|
154
|
+
payload: { value: "selected" },
|
|
155
|
+
};
|
|
156
|
+
|
|
136
157
|
describe("A2AThreadRuntimeCore", () => {
|
|
137
158
|
let notifyUpdate: ReturnType<typeof vi.fn>;
|
|
138
159
|
|
|
@@ -269,6 +290,147 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
269
290
|
});
|
|
270
291
|
|
|
271
292
|
describe("history loading", () => {
|
|
293
|
+
it("loads persisted history without waiting for agent-card discovery", async () => {
|
|
294
|
+
const restored = createHistoryMessage(
|
|
295
|
+
"restored",
|
|
296
|
+
"user",
|
|
297
|
+
"Saved history",
|
|
298
|
+
);
|
|
299
|
+
const history = {
|
|
300
|
+
load: vi.fn().mockResolvedValue({
|
|
301
|
+
headId: restored.id,
|
|
302
|
+
messages: [{ parentId: null, message: restored }],
|
|
303
|
+
}),
|
|
304
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
305
|
+
};
|
|
306
|
+
const getAgentCard = vi.fn(() => new Promise<A2AAgentCard>(() => {}));
|
|
307
|
+
const core = createCore({ getAgentCard }, { history });
|
|
308
|
+
|
|
309
|
+
const result = await Promise.race([
|
|
310
|
+
core.__internal_load().then(() => "loaded"),
|
|
311
|
+
new Promise<"timed out">((resolve) => {
|
|
312
|
+
setTimeout(() => resolve("timed out"), 0);
|
|
313
|
+
}),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
expect(result).toBe("loaded");
|
|
317
|
+
expect(getAgentCard).toHaveBeenCalledOnce();
|
|
318
|
+
expect(core.getMessages()).toEqual([restored]);
|
|
319
|
+
expect(core.isLoading).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("keeps initial history loading across same-thread message resyncs", async () => {
|
|
323
|
+
let resolve!: (repo: ExportedMessageRepository) => void;
|
|
324
|
+
const pending = new Promise<ExportedMessageRepository>((res) => {
|
|
325
|
+
resolve = res;
|
|
326
|
+
});
|
|
327
|
+
const core = createCore(
|
|
328
|
+
{},
|
|
329
|
+
{ history: { load: () => pending, append: async () => {} } },
|
|
330
|
+
);
|
|
331
|
+
const loading = core.__internal_load();
|
|
332
|
+
core.applyExternalMessages([]);
|
|
333
|
+
const wasLoading = core.isLoading;
|
|
334
|
+
const restored = createHistoryMessage(
|
|
335
|
+
"restored",
|
|
336
|
+
"user",
|
|
337
|
+
"Saved history",
|
|
338
|
+
);
|
|
339
|
+
resolve({
|
|
340
|
+
headId: restored.id,
|
|
341
|
+
messages: [{ parentId: null, message: restored }],
|
|
342
|
+
});
|
|
343
|
+
await loading;
|
|
344
|
+
expect(wasLoading).toBe(true);
|
|
345
|
+
expect(core.getMessages()).toEqual([restored]);
|
|
346
|
+
expect(core.isLoading).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it.each(["resolve", "reject"])(
|
|
350
|
+
"ignores an initial history load that later %s after a thread switch",
|
|
351
|
+
async (outcome) => {
|
|
352
|
+
let resolve!: (repo: ExportedMessageRepository) => void;
|
|
353
|
+
let reject!: (error: Error) => void;
|
|
354
|
+
const pending = new Promise<ExportedMessageRepository>((res, rej) => {
|
|
355
|
+
resolve = res;
|
|
356
|
+
reject = rej;
|
|
357
|
+
});
|
|
358
|
+
const onError = vi.fn();
|
|
359
|
+
const history = {
|
|
360
|
+
load: vi.fn(() => pending),
|
|
361
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
362
|
+
};
|
|
363
|
+
const core = createCore({}, { history, onError });
|
|
364
|
+
const loading = core.__internal_load();
|
|
365
|
+
const replacement = createHistoryMessage(
|
|
366
|
+
"selected",
|
|
367
|
+
"user",
|
|
368
|
+
"Selected thread",
|
|
369
|
+
);
|
|
370
|
+
core.applyExternalMessages([replacement]);
|
|
371
|
+
core.resetContext();
|
|
372
|
+
|
|
373
|
+
if (outcome === "resolve") {
|
|
374
|
+
const previous = createHistoryMessage(
|
|
375
|
+
"previous",
|
|
376
|
+
"user",
|
|
377
|
+
"Previous thread",
|
|
378
|
+
);
|
|
379
|
+
resolve({
|
|
380
|
+
headId: previous.id,
|
|
381
|
+
messages: [{ parentId: null, message: previous }],
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
reject(new Error("Old history failed"));
|
|
385
|
+
}
|
|
386
|
+
await loading;
|
|
387
|
+
|
|
388
|
+
expect(core.getMessages()).toEqual([replacement]);
|
|
389
|
+
expect(core.isLoading).toBe(false);
|
|
390
|
+
expect(onError).not.toHaveBeenCalled();
|
|
391
|
+
await core.__internal_load();
|
|
392
|
+
expect(history.load).toHaveBeenCalledTimes(1);
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
it("ends initial loading immediately when switching threads", async () => {
|
|
397
|
+
let resolve!: (repo: ExportedMessageRepository) => void;
|
|
398
|
+
const pending = new Promise<ExportedMessageRepository>((res) => {
|
|
399
|
+
resolve = res;
|
|
400
|
+
});
|
|
401
|
+
const core = createCore(
|
|
402
|
+
{},
|
|
403
|
+
{ history: { load: () => pending, append: async () => {} } },
|
|
404
|
+
);
|
|
405
|
+
const loading = core.__internal_load();
|
|
406
|
+
expect(core.isLoading).toBe(true);
|
|
407
|
+
core.applyExternalMessages([]);
|
|
408
|
+
core.resetContext();
|
|
409
|
+
const updatesAfterReplacement = notifyUpdate.mock.calls.length;
|
|
410
|
+
expect(core.isLoading).toBe(false);
|
|
411
|
+
resolve({ messages: [] });
|
|
412
|
+
await loading;
|
|
413
|
+
expect(notifyUpdate).toHaveBeenCalledTimes(updatesAfterReplacement);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("does not import old history when agent-card discovery settles after a thread switch", async () => {
|
|
417
|
+
let resolveCard!: (card: A2AAgentCard) => void;
|
|
418
|
+
const card = new Promise<A2AAgentCard>((resolve) => {
|
|
419
|
+
resolveCard = resolve;
|
|
420
|
+
});
|
|
421
|
+
const { history } = createBranchedHistory();
|
|
422
|
+
const core = createCore({ getAgentCard: () => card }, { history });
|
|
423
|
+
const loading = core.__internal_load();
|
|
424
|
+
await Promise.resolve();
|
|
425
|
+
core.applyExternalMessages([]);
|
|
426
|
+
core.resetContext();
|
|
427
|
+
resolveCard({ name: "Agent" } as A2AAgentCard);
|
|
428
|
+
await loading;
|
|
429
|
+
expect(core.getMessages()).toEqual([]);
|
|
430
|
+
expect(core.getAgentCard()?.name).toBe("Agent");
|
|
431
|
+
expect(core.isLoading).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
272
434
|
it("preserves sibling branches and selects the persisted head", async () => {
|
|
273
435
|
const { user, firstAssistant, secondAssistant, history } =
|
|
274
436
|
createBranchedHistory();
|
|
@@ -791,6 +953,476 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
791
953
|
expect(wasRunningDuringStream).toBe(true);
|
|
792
954
|
expect(core.isRunning()).toBe(false);
|
|
793
955
|
});
|
|
956
|
+
|
|
957
|
+
it("rebuilds an A2UI surface across status updates with replayable operations", async () => {
|
|
958
|
+
const core = createCore({
|
|
959
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
960
|
+
yield statusUpdateEvent("working", undefined, [
|
|
961
|
+
{
|
|
962
|
+
data: [
|
|
963
|
+
{
|
|
964
|
+
version: "v0.9",
|
|
965
|
+
createSurface: { surfaceId: "summary" },
|
|
966
|
+
},
|
|
967
|
+
],
|
|
968
|
+
},
|
|
969
|
+
]);
|
|
970
|
+
yield statusUpdateEvent("completed", undefined, [
|
|
971
|
+
{
|
|
972
|
+
data: [
|
|
973
|
+
{
|
|
974
|
+
version: "v0.9",
|
|
975
|
+
updateComponents: {
|
|
976
|
+
surfaceId: "summary",
|
|
977
|
+
components: [
|
|
978
|
+
{
|
|
979
|
+
id: "root",
|
|
980
|
+
component: "Text",
|
|
981
|
+
text: { path: "/summary" },
|
|
982
|
+
},
|
|
983
|
+
],
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
version: "v0.9",
|
|
988
|
+
updateDataModel: {
|
|
989
|
+
surfaceId: "summary",
|
|
990
|
+
contents: { summary: "Ready" },
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
},
|
|
995
|
+
]);
|
|
996
|
+
}),
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1000
|
+
|
|
1001
|
+
const assistant = core.getMessages()[1]!;
|
|
1002
|
+
expect(assistant.content).toHaveLength(1);
|
|
1003
|
+
const part = assistant.content[0]!;
|
|
1004
|
+
if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
|
|
1005
|
+
expect(part).toMatchObject({
|
|
1006
|
+
toolCallId: "a2ui:summary",
|
|
1007
|
+
toolName: "present",
|
|
1008
|
+
result: {},
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const artifact = part.artifact as { a2ui: unknown };
|
|
1012
|
+
const replayed = applyA2uiOperations(new Map(), artifact.a2ui);
|
|
1013
|
+
const surface = replayed.state.get("summary");
|
|
1014
|
+
expect(replayed.warnings).toEqual([]);
|
|
1015
|
+
expect(surface).toBeDefined();
|
|
1016
|
+
expect(convertSurfaceToUISpec(surface!).spec).toEqual(part.args);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("keeps a recorded A2UI interaction through later stream updates and writes it when settled", async () => {
|
|
1020
|
+
let release!: () => void;
|
|
1021
|
+
const continueStream = new Promise<void>((resolve) => {
|
|
1022
|
+
release = resolve;
|
|
1023
|
+
});
|
|
1024
|
+
const history = {
|
|
1025
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
1026
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
1027
|
+
};
|
|
1028
|
+
const core = createCore(
|
|
1029
|
+
{
|
|
1030
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1031
|
+
yield statusUpdateEvent("working", undefined, [
|
|
1032
|
+
{
|
|
1033
|
+
data: [
|
|
1034
|
+
{
|
|
1035
|
+
version: "v0.9",
|
|
1036
|
+
createSurface: { surfaceId: "summary" },
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
version: "v0.9",
|
|
1040
|
+
updateComponents: {
|
|
1041
|
+
surfaceId: "summary",
|
|
1042
|
+
components: [
|
|
1043
|
+
{
|
|
1044
|
+
id: "root",
|
|
1045
|
+
component: "Text",
|
|
1046
|
+
text: "Ready",
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
},
|
|
1053
|
+
]);
|
|
1054
|
+
await continueStream;
|
|
1055
|
+
yield statusUpdateEvent("working", "Status update");
|
|
1056
|
+
yield artifactUpdateEvent("a1", [
|
|
1057
|
+
{
|
|
1058
|
+
data: [
|
|
1059
|
+
{
|
|
1060
|
+
version: "v0.9",
|
|
1061
|
+
updateDataModel: {
|
|
1062
|
+
surfaceId: "summary",
|
|
1063
|
+
contents: { summary: "Updated" },
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
],
|
|
1067
|
+
},
|
|
1068
|
+
]);
|
|
1069
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1070
|
+
}),
|
|
1071
|
+
},
|
|
1072
|
+
{ history },
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
const run = core.append(createUserAppendMessage("Go"));
|
|
1076
|
+
await vi.waitFor(() => {
|
|
1077
|
+
const part = core.getMessages()[1]?.content[0];
|
|
1078
|
+
expect(part).toMatchObject({
|
|
1079
|
+
type: "tool-call",
|
|
1080
|
+
toolCallId: "a2ui:summary",
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
const assistantId = core.getMessages()[1]!.id;
|
|
1085
|
+
await core.recordToolInteraction({
|
|
1086
|
+
messageId: assistantId,
|
|
1087
|
+
toolCallId: "a2ui:summary",
|
|
1088
|
+
interaction: toolInteraction,
|
|
1089
|
+
});
|
|
1090
|
+
expect(core.getMessages()[1]!.content[0]).toMatchObject({
|
|
1091
|
+
unstable_interactions: { entries: [toolInteraction] },
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
release();
|
|
1095
|
+
await run;
|
|
1096
|
+
|
|
1097
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
|
|
1098
|
+
const persisted = history.append.mock.calls[1]![0].message;
|
|
1099
|
+
const part = persisted.content.find(
|
|
1100
|
+
(content: { type: string; toolCallId?: string }) =>
|
|
1101
|
+
content.type === "tool-call" && content.toolCallId === "a2ui:summary",
|
|
1102
|
+
);
|
|
1103
|
+
expect(part).toMatchObject({
|
|
1104
|
+
unstable_interactions: { entries: [toolInteraction] },
|
|
1105
|
+
});
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("rebuilds A2UI state from a task snapshot in full-state order", async () => {
|
|
1109
|
+
const core = createCore({
|
|
1110
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1111
|
+
yield statusUpdateEvent("working", undefined, [
|
|
1112
|
+
{
|
|
1113
|
+
data: [
|
|
1114
|
+
{
|
|
1115
|
+
version: "v0.9",
|
|
1116
|
+
createSurface: { surfaceId: "obsolete" },
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
version: "v0.9",
|
|
1120
|
+
updateComponents: {
|
|
1121
|
+
surfaceId: "obsolete",
|
|
1122
|
+
components: [
|
|
1123
|
+
{ id: "root", component: "Text", text: "Obsolete" },
|
|
1124
|
+
],
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
},
|
|
1129
|
+
]);
|
|
1130
|
+
yield {
|
|
1131
|
+
type: "artifactUpdate",
|
|
1132
|
+
event: {
|
|
1133
|
+
taskId: "t1",
|
|
1134
|
+
contextId: "ctx-1",
|
|
1135
|
+
artifact: {
|
|
1136
|
+
artifactId: "a1",
|
|
1137
|
+
parts: [
|
|
1138
|
+
{
|
|
1139
|
+
data: [
|
|
1140
|
+
{
|
|
1141
|
+
version: "v0.9",
|
|
1142
|
+
updateDataModel: {
|
|
1143
|
+
surfaceId: "obsolete",
|
|
1144
|
+
contents: { summary: "Stale artifact event" },
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
],
|
|
1148
|
+
},
|
|
1149
|
+
],
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
} satisfies A2AStreamEvent;
|
|
1153
|
+
yield {
|
|
1154
|
+
type: "task",
|
|
1155
|
+
task: {
|
|
1156
|
+
id: "t1",
|
|
1157
|
+
contextId: "ctx-1",
|
|
1158
|
+
status: {
|
|
1159
|
+
state: "completed",
|
|
1160
|
+
message: {
|
|
1161
|
+
messageId: "s1",
|
|
1162
|
+
role: "agent",
|
|
1163
|
+
parts: [
|
|
1164
|
+
{
|
|
1165
|
+
data: [
|
|
1166
|
+
{
|
|
1167
|
+
version: "v0.9",
|
|
1168
|
+
updateDataModel: {
|
|
1169
|
+
surfaceId: "summary",
|
|
1170
|
+
path: "/summary",
|
|
1171
|
+
contents: "From task status",
|
|
1172
|
+
},
|
|
1173
|
+
},
|
|
1174
|
+
],
|
|
1175
|
+
},
|
|
1176
|
+
],
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
history: [
|
|
1180
|
+
{
|
|
1181
|
+
messageId: "h1",
|
|
1182
|
+
role: "agent",
|
|
1183
|
+
parts: [
|
|
1184
|
+
{
|
|
1185
|
+
data: [
|
|
1186
|
+
{
|
|
1187
|
+
version: "v0.9",
|
|
1188
|
+
createSurface: { surfaceId: "summary" },
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
version: "v0.9",
|
|
1192
|
+
updateComponents: {
|
|
1193
|
+
surfaceId: "summary",
|
|
1194
|
+
components: [
|
|
1195
|
+
{
|
|
1196
|
+
id: "root",
|
|
1197
|
+
component: "Text",
|
|
1198
|
+
text: { path: "/summary" },
|
|
1199
|
+
},
|
|
1200
|
+
],
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
],
|
|
1204
|
+
},
|
|
1205
|
+
],
|
|
1206
|
+
},
|
|
1207
|
+
],
|
|
1208
|
+
artifacts: [
|
|
1209
|
+
{
|
|
1210
|
+
artifactId: "a1",
|
|
1211
|
+
parts: [
|
|
1212
|
+
{
|
|
1213
|
+
data: [
|
|
1214
|
+
{
|
|
1215
|
+
version: "v0.9",
|
|
1216
|
+
updateDataModel: {
|
|
1217
|
+
surfaceId: "summary",
|
|
1218
|
+
path: "/summary",
|
|
1219
|
+
contents: "From task artifact",
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
],
|
|
1223
|
+
},
|
|
1224
|
+
],
|
|
1225
|
+
},
|
|
1226
|
+
],
|
|
1227
|
+
},
|
|
1228
|
+
} satisfies A2AStreamEvent;
|
|
1229
|
+
}),
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1233
|
+
|
|
1234
|
+
expect(core.getMessages()[1]!.content).toHaveLength(1);
|
|
1235
|
+
const part = core.getMessages()[1]!.content[0]!;
|
|
1236
|
+
if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
|
|
1237
|
+
const replayed = applyA2uiOperations(
|
|
1238
|
+
new Map(),
|
|
1239
|
+
(part.artifact as { a2ui: unknown }).a2ui,
|
|
1240
|
+
);
|
|
1241
|
+
expect(replayed.state.get("summary")?.dataModel).toEqual({
|
|
1242
|
+
summary: "From task status",
|
|
1243
|
+
});
|
|
1244
|
+
expect(replayed.state.has("obsolete")).toBe(false);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it("keeps an A2UI surface when a partial task snapshot repeats an artifact", async () => {
|
|
1248
|
+
const core = createCore({
|
|
1249
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1250
|
+
yield artifactUpdateEvent("a1", [
|
|
1251
|
+
{
|
|
1252
|
+
data: [
|
|
1253
|
+
{
|
|
1254
|
+
version: "v0.9",
|
|
1255
|
+
createSurface: { surfaceId: "summary" },
|
|
1256
|
+
},
|
|
1257
|
+
],
|
|
1258
|
+
},
|
|
1259
|
+
]);
|
|
1260
|
+
yield statusUpdateEvent("working", undefined, [
|
|
1261
|
+
{
|
|
1262
|
+
data: [
|
|
1263
|
+
{
|
|
1264
|
+
version: "v0.9",
|
|
1265
|
+
updateComponents: {
|
|
1266
|
+
surfaceId: "summary",
|
|
1267
|
+
components: [
|
|
1268
|
+
{ id: "root", component: "Text", text: "Ready" },
|
|
1269
|
+
],
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
version: "v0.9",
|
|
1274
|
+
updateDataModel: {
|
|
1275
|
+
surfaceId: "summary",
|
|
1276
|
+
contents: { summary: "Ready" },
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
],
|
|
1280
|
+
},
|
|
1281
|
+
]);
|
|
1282
|
+
yield {
|
|
1283
|
+
type: "task",
|
|
1284
|
+
task: {
|
|
1285
|
+
id: "t1",
|
|
1286
|
+
contextId: "ctx-1",
|
|
1287
|
+
status: { state: "completed" },
|
|
1288
|
+
artifacts: [
|
|
1289
|
+
{
|
|
1290
|
+
artifactId: "a1",
|
|
1291
|
+
parts: [
|
|
1292
|
+
{
|
|
1293
|
+
data: [
|
|
1294
|
+
{
|
|
1295
|
+
version: "v0.9",
|
|
1296
|
+
createSurface: { surfaceId: "summary" },
|
|
1297
|
+
},
|
|
1298
|
+
],
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
},
|
|
1302
|
+
],
|
|
1303
|
+
},
|
|
1304
|
+
} satisfies A2AStreamEvent;
|
|
1305
|
+
}),
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1309
|
+
|
|
1310
|
+
expect(core.getMessages()[1]!.content).toHaveLength(1);
|
|
1311
|
+
const part = core.getMessages()[1]!.content[0]!;
|
|
1312
|
+
if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
|
|
1313
|
+
const replayed = applyA2uiOperations(
|
|
1314
|
+
new Map(),
|
|
1315
|
+
(part.artifact as { a2ui: unknown }).a2ui,
|
|
1316
|
+
);
|
|
1317
|
+
expect(replayed.state.get("summary")?.dataModel).toEqual({
|
|
1318
|
+
summary: "Ready",
|
|
1319
|
+
});
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it("does not replay A2UI history already applied when a partial task snapshot carries history", async () => {
|
|
1323
|
+
const created = {
|
|
1324
|
+
messageId: "agent-1",
|
|
1325
|
+
role: "agent" as const,
|
|
1326
|
+
parts: [
|
|
1327
|
+
{
|
|
1328
|
+
data: [
|
|
1329
|
+
{ version: "v0.9", createSurface: { surfaceId: "summary" } },
|
|
1330
|
+
],
|
|
1331
|
+
},
|
|
1332
|
+
],
|
|
1333
|
+
};
|
|
1334
|
+
const core = createCore({
|
|
1335
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1336
|
+
yield { type: "message", message: created } satisfies A2AStreamEvent;
|
|
1337
|
+
yield statusUpdateEvent("working", undefined, [
|
|
1338
|
+
{
|
|
1339
|
+
data: [
|
|
1340
|
+
{
|
|
1341
|
+
version: "v0.9",
|
|
1342
|
+
updateComponents: {
|
|
1343
|
+
surfaceId: "summary",
|
|
1344
|
+
components: [
|
|
1345
|
+
{ id: "root", component: "Text", text: "Ready" },
|
|
1346
|
+
],
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
version: "v0.9",
|
|
1351
|
+
updateDataModel: {
|
|
1352
|
+
surfaceId: "summary",
|
|
1353
|
+
contents: { summary: "Ready" },
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
},
|
|
1358
|
+
]);
|
|
1359
|
+
yield {
|
|
1360
|
+
type: "task",
|
|
1361
|
+
task: {
|
|
1362
|
+
id: "t1",
|
|
1363
|
+
contextId: "ctx-1",
|
|
1364
|
+
status: { state: "completed" },
|
|
1365
|
+
history: [created],
|
|
1366
|
+
},
|
|
1367
|
+
} satisfies A2AStreamEvent;
|
|
1368
|
+
}),
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1372
|
+
|
|
1373
|
+
const part = core
|
|
1374
|
+
.getMessages()[1]!
|
|
1375
|
+
.content.find((candidate) => candidate.type === "tool-call");
|
|
1376
|
+
if (part?.type !== "tool-call")
|
|
1377
|
+
throw new Error("expected A2UI tool call");
|
|
1378
|
+
const replayed = applyA2uiOperations(
|
|
1379
|
+
new Map(),
|
|
1380
|
+
(part.artifact as { a2ui: unknown }).a2ui,
|
|
1381
|
+
);
|
|
1382
|
+
expect(replayed.state.get("summary")?.dataModel).toEqual({
|
|
1383
|
+
summary: "Ready",
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("removes a deleted A2UI surface", async () => {
|
|
1388
|
+
const core = createCore({
|
|
1389
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1390
|
+
yield statusUpdateEvent("working", undefined, [
|
|
1391
|
+
{
|
|
1392
|
+
data: [
|
|
1393
|
+
{
|
|
1394
|
+
version: "v0.9",
|
|
1395
|
+
createSurface: { surfaceId: "summary" },
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
version: "v0.9",
|
|
1399
|
+
updateComponents: {
|
|
1400
|
+
surfaceId: "summary",
|
|
1401
|
+
components: [
|
|
1402
|
+
{ id: "root", component: "Text", text: "Ready" },
|
|
1403
|
+
],
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
],
|
|
1407
|
+
},
|
|
1408
|
+
]);
|
|
1409
|
+
yield statusUpdateEvent("completed", undefined, [
|
|
1410
|
+
{
|
|
1411
|
+
data: [
|
|
1412
|
+
{
|
|
1413
|
+
version: "v0.9",
|
|
1414
|
+
deleteSurface: { surfaceId: "summary" },
|
|
1415
|
+
},
|
|
1416
|
+
],
|
|
1417
|
+
},
|
|
1418
|
+
]);
|
|
1419
|
+
}),
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1423
|
+
|
|
1424
|
+
expect(core.getMessages()[1]!.content).toEqual([]);
|
|
1425
|
+
});
|
|
794
1426
|
});
|
|
795
1427
|
|
|
796
1428
|
// --- Sync (non-streaming) fallback ---
|
|
@@ -1060,6 +1692,62 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1060
1692
|
expect(artifacts[0]!.parts[1]!.text).toBe("part2");
|
|
1061
1693
|
});
|
|
1062
1694
|
|
|
1695
|
+
it("stores merged task artifacts on the assistant message", async () => {
|
|
1696
|
+
const core = createCore({
|
|
1697
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1698
|
+
yield artifactUpdateEvent("a1", [{ text: "part1" }]);
|
|
1699
|
+
yield artifactUpdateEvent("a1", [{ text: "part2" }], {
|
|
1700
|
+
append: true,
|
|
1701
|
+
});
|
|
1702
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1703
|
+
}),
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1707
|
+
|
|
1708
|
+
expect(core.getMessages()[1]!.metadata.custom.a2a).toEqual({
|
|
1709
|
+
artifacts: [
|
|
1710
|
+
{
|
|
1711
|
+
artifactId: "a1",
|
|
1712
|
+
name: "a1",
|
|
1713
|
+
parts: [{ text: "part1" }, { text: "part2" }],
|
|
1714
|
+
},
|
|
1715
|
+
],
|
|
1716
|
+
});
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it("stores artifact files on the assistant message without their inline bytes", async () => {
|
|
1720
|
+
const core = createCore({
|
|
1721
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1722
|
+
yield artifactUpdateEvent("a1", [
|
|
1723
|
+
{
|
|
1724
|
+
raw: "JVBERi0xLjQK",
|
|
1725
|
+
mediaType: "application/pdf",
|
|
1726
|
+
filename: "report.pdf",
|
|
1727
|
+
},
|
|
1728
|
+
{ url: "https://example.com/chart.png", mediaType: "image/png" },
|
|
1729
|
+
]);
|
|
1730
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1731
|
+
}),
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
await core.append(createUserAppendMessage("Go"));
|
|
1735
|
+
|
|
1736
|
+
expect(core.getArtifacts()[0]!.parts[0]!.raw).toBe("JVBERi0xLjQK");
|
|
1737
|
+
expect(core.getMessages()[1]!.metadata.custom.a2a).toEqual({
|
|
1738
|
+
artifacts: [
|
|
1739
|
+
{
|
|
1740
|
+
artifactId: "a1",
|
|
1741
|
+
name: "a1",
|
|
1742
|
+
parts: [
|
|
1743
|
+
{ mediaType: "application/pdf", filename: "report.pdf" },
|
|
1744
|
+
{ url: "https://example.com/chart.png", mediaType: "image/png" },
|
|
1745
|
+
],
|
|
1746
|
+
},
|
|
1747
|
+
],
|
|
1748
|
+
});
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1063
1751
|
it("replaces artifact when append=false", async () => {
|
|
1064
1752
|
const core = createCore({
|
|
1065
1753
|
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
@@ -1215,6 +1903,312 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1215
1903
|
const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
|
|
1216
1904
|
expect(secondCallMsg.taskId).toBe("t1");
|
|
1217
1905
|
});
|
|
1906
|
+
|
|
1907
|
+
it("persists a paused assistant message and updates it when it settles", async () => {
|
|
1908
|
+
let release!: () => void;
|
|
1909
|
+
const settled = new Promise<void>((resolve) => {
|
|
1910
|
+
release = resolve;
|
|
1911
|
+
});
|
|
1912
|
+
const stored = new Map<string, ThreadMessage>();
|
|
1913
|
+
const history = {
|
|
1914
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
1915
|
+
append: vi.fn(async (item: { message: ThreadMessage }) => {
|
|
1916
|
+
stored.set(item.message.id, item.message);
|
|
1917
|
+
}),
|
|
1918
|
+
update: vi.fn(async (item: { message: ThreadMessage }) => {
|
|
1919
|
+
stored.set(item.message.id, item.message);
|
|
1920
|
+
}),
|
|
1921
|
+
};
|
|
1922
|
+
const core = createCore(
|
|
1923
|
+
{
|
|
1924
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1925
|
+
yield statusUpdateEvent("input_required", "What should I do?");
|
|
1926
|
+
await settled;
|
|
1927
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1928
|
+
}),
|
|
1929
|
+
},
|
|
1930
|
+
{ history },
|
|
1931
|
+
);
|
|
1932
|
+
|
|
1933
|
+
const run = core.append(createUserAppendMessage("Start"));
|
|
1934
|
+
|
|
1935
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
|
|
1936
|
+
const paused = history.append.mock.calls[1]![0].message;
|
|
1937
|
+
expect(paused.status).toEqual({
|
|
1938
|
+
type: "requires-action",
|
|
1939
|
+
reason: "interrupt",
|
|
1940
|
+
});
|
|
1941
|
+
expect(paused.content).toEqual([
|
|
1942
|
+
{ type: "text", text: "What should I do?" },
|
|
1943
|
+
]);
|
|
1944
|
+
|
|
1945
|
+
release();
|
|
1946
|
+
await run;
|
|
1947
|
+
|
|
1948
|
+
await vi.waitFor(() => expect(history.update).toHaveBeenCalledOnce());
|
|
1949
|
+
expect(stored.get(paused.id)).toMatchObject({
|
|
1950
|
+
content: [{ type: "text", text: "Done" }],
|
|
1951
|
+
status: { type: "complete", reason: "stop" },
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
it("appends only the settled assistant message without history updates", async () => {
|
|
1956
|
+
let release!: () => void;
|
|
1957
|
+
const settled = new Promise<void>((resolve) => {
|
|
1958
|
+
release = resolve;
|
|
1959
|
+
});
|
|
1960
|
+
const stored = new Map<string, ThreadMessage>();
|
|
1961
|
+
const history = {
|
|
1962
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
1963
|
+
append: vi.fn(async (item: { message: ThreadMessage }) => {
|
|
1964
|
+
stored.set(item.message.id, item.message);
|
|
1965
|
+
}),
|
|
1966
|
+
};
|
|
1967
|
+
const core = createCore(
|
|
1968
|
+
{
|
|
1969
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1970
|
+
yield statusUpdateEvent("input_required", "What should I do?");
|
|
1971
|
+
await settled;
|
|
1972
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1973
|
+
}),
|
|
1974
|
+
},
|
|
1975
|
+
{ history },
|
|
1976
|
+
);
|
|
1977
|
+
|
|
1978
|
+
const run = core.append(createUserAppendMessage("Start"));
|
|
1979
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledOnce());
|
|
1980
|
+
release();
|
|
1981
|
+
await run;
|
|
1982
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
|
|
1983
|
+
const assistant = core.getMessages()[1]!;
|
|
1984
|
+
expect(stored.get(assistant.id)).toMatchObject({
|
|
1985
|
+
content: [{ type: "text", text: "Done" }],
|
|
1986
|
+
status: { type: "complete", reason: "stop" },
|
|
1987
|
+
});
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it("retries a rejected final history update", async () => {
|
|
1991
|
+
let releaseSettlement!: () => void;
|
|
1992
|
+
const settlement = new Promise<void>((resolve) => {
|
|
1993
|
+
releaseSettlement = resolve;
|
|
1994
|
+
});
|
|
1995
|
+
let releaseRetry!: () => void;
|
|
1996
|
+
const retry = new Promise<void>((resolve) => {
|
|
1997
|
+
releaseRetry = resolve;
|
|
1998
|
+
});
|
|
1999
|
+
const error = new Error("history update failed");
|
|
2000
|
+
const stored = new Map<string, ThreadMessage>();
|
|
2001
|
+
const consoleError = vi
|
|
2002
|
+
.spyOn(console, "error")
|
|
2003
|
+
.mockImplementation(() => {});
|
|
2004
|
+
const history = {
|
|
2005
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
2006
|
+
append: vi.fn(async (item: { message: ThreadMessage }) => {
|
|
2007
|
+
stored.set(item.message.id, item.message);
|
|
2008
|
+
}),
|
|
2009
|
+
update: vi
|
|
2010
|
+
.fn()
|
|
2011
|
+
.mockRejectedValueOnce(error)
|
|
2012
|
+
.mockImplementation(async (item: { message: ThreadMessage }) => {
|
|
2013
|
+
stored.set(item.message.id, item.message);
|
|
2014
|
+
}),
|
|
2015
|
+
};
|
|
2016
|
+
const core = createCore(
|
|
2017
|
+
{
|
|
2018
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
2019
|
+
yield statusUpdateEvent("input_required", "What should I do?");
|
|
2020
|
+
await settlement;
|
|
2021
|
+
yield statusUpdateEvent("completed", "Done");
|
|
2022
|
+
await retry;
|
|
2023
|
+
yield statusUpdateEvent("completed", "Done");
|
|
2024
|
+
}),
|
|
2025
|
+
},
|
|
2026
|
+
{ history },
|
|
2027
|
+
);
|
|
2028
|
+
|
|
2029
|
+
const run = core.append(createUserAppendMessage("Start"));
|
|
2030
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
|
|
2031
|
+
releaseSettlement();
|
|
2032
|
+
await vi.waitFor(() => {
|
|
2033
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
2034
|
+
"[react-a2a] failed to update history entry",
|
|
2035
|
+
error,
|
|
2036
|
+
);
|
|
2037
|
+
});
|
|
2038
|
+
releaseRetry();
|
|
2039
|
+
await run;
|
|
2040
|
+
|
|
2041
|
+
await vi.waitFor(() => expect(history.update).toHaveBeenCalledTimes(2));
|
|
2042
|
+
const assistant = core.getMessages()[1]!;
|
|
2043
|
+
expect(stored.get(assistant.id)).toMatchObject({
|
|
2044
|
+
content: [{ type: "text", text: "Done" }],
|
|
2045
|
+
status: { type: "complete", reason: "stop" },
|
|
2046
|
+
});
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
it("retries a rejected final history append", async () => {
|
|
2050
|
+
let releaseSettlement!: () => void;
|
|
2051
|
+
const settlement = new Promise<void>((resolve) => {
|
|
2052
|
+
releaseSettlement = resolve;
|
|
2053
|
+
});
|
|
2054
|
+
let releaseRetry!: () => void;
|
|
2055
|
+
const retry = new Promise<void>((resolve) => {
|
|
2056
|
+
releaseRetry = resolve;
|
|
2057
|
+
});
|
|
2058
|
+
const error = new Error("history append failed");
|
|
2059
|
+
const stored = new Map<string, ThreadMessage>();
|
|
2060
|
+
const consoleError = vi
|
|
2061
|
+
.spyOn(console, "error")
|
|
2062
|
+
.mockImplementation(() => {});
|
|
2063
|
+
let assistantAppendAttempts = 0;
|
|
2064
|
+
const history = {
|
|
2065
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
2066
|
+
append: vi.fn(async (item: { message: ThreadMessage }) => {
|
|
2067
|
+
if (
|
|
2068
|
+
item.message.role === "assistant" &&
|
|
2069
|
+
assistantAppendAttempts++ === 0
|
|
2070
|
+
) {
|
|
2071
|
+
throw error;
|
|
2072
|
+
}
|
|
2073
|
+
stored.set(item.message.id, item.message);
|
|
2074
|
+
}),
|
|
2075
|
+
};
|
|
2076
|
+
const core = createCore(
|
|
2077
|
+
{
|
|
2078
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
2079
|
+
yield statusUpdateEvent("input_required", "What should I do?");
|
|
2080
|
+
await settlement;
|
|
2081
|
+
yield statusUpdateEvent("completed", "Done");
|
|
2082
|
+
await retry;
|
|
2083
|
+
yield statusUpdateEvent("completed", "Done");
|
|
2084
|
+
}),
|
|
2085
|
+
},
|
|
2086
|
+
{ history },
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
const run = core.append(createUserAppendMessage("Start"));
|
|
2090
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledOnce());
|
|
2091
|
+
releaseSettlement();
|
|
2092
|
+
await vi.waitFor(() => {
|
|
2093
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
2094
|
+
"[react-a2a] failed to append history entry",
|
|
2095
|
+
error,
|
|
2096
|
+
);
|
|
2097
|
+
});
|
|
2098
|
+
releaseRetry();
|
|
2099
|
+
await run;
|
|
2100
|
+
|
|
2101
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(3));
|
|
2102
|
+
const assistant = core.getMessages()[1]!;
|
|
2103
|
+
expect(stored.get(assistant.id)).toMatchObject({
|
|
2104
|
+
content: [{ type: "text", text: "Done" }],
|
|
2105
|
+
status: { type: "complete", reason: "stop" },
|
|
2106
|
+
});
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
describe("tool interactions", () => {
|
|
2111
|
+
it("updates a settled assistant message in history", async () => {
|
|
2112
|
+
const history = {
|
|
2113
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
2114
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
2115
|
+
update: vi.fn().mockResolvedValue(undefined),
|
|
2116
|
+
};
|
|
2117
|
+
const core = createCore(
|
|
2118
|
+
{
|
|
2119
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
2120
|
+
yield statusUpdateEvent("completed", undefined, [
|
|
2121
|
+
{
|
|
2122
|
+
data: [
|
|
2123
|
+
{
|
|
2124
|
+
version: "v0.9",
|
|
2125
|
+
createSurface: { surfaceId: "summary" },
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
version: "v0.9",
|
|
2129
|
+
updateComponents: {
|
|
2130
|
+
surfaceId: "summary",
|
|
2131
|
+
components: [
|
|
2132
|
+
{
|
|
2133
|
+
id: "root",
|
|
2134
|
+
component: "Text",
|
|
2135
|
+
text: "Ready",
|
|
2136
|
+
},
|
|
2137
|
+
],
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2140
|
+
],
|
|
2141
|
+
},
|
|
2142
|
+
]);
|
|
2143
|
+
}),
|
|
2144
|
+
},
|
|
2145
|
+
{ history },
|
|
2146
|
+
);
|
|
2147
|
+
|
|
2148
|
+
await core.append(createUserAppendMessage("Go"));
|
|
2149
|
+
await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
|
|
2150
|
+
|
|
2151
|
+
await core.recordToolInteraction({
|
|
2152
|
+
messageId: core.getMessages()[1]!.id,
|
|
2153
|
+
toolCallId: "a2ui:summary",
|
|
2154
|
+
interaction: toolInteraction,
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
await vi.waitFor(() => expect(history.update).toHaveBeenCalledOnce());
|
|
2158
|
+
expect(history.update.mock.calls[0]![0].parentId).toBe(
|
|
2159
|
+
core.getMessages()[0]!.id,
|
|
2160
|
+
);
|
|
2161
|
+
expect(history.update.mock.calls[0]![0].message.content).toContainEqual(
|
|
2162
|
+
expect.objectContaining({
|
|
2163
|
+
toolCallId: "a2ui:summary",
|
|
2164
|
+
unstable_interactions: { entries: [toolInteraction] },
|
|
2165
|
+
}),
|
|
2166
|
+
);
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
it("rejects an unknown message or tool call", async () => {
|
|
2170
|
+
const core = createCore();
|
|
2171
|
+
core.applyExternalMessages([
|
|
2172
|
+
{
|
|
2173
|
+
id: "assistant",
|
|
2174
|
+
role: "assistant",
|
|
2175
|
+
createdAt: new Date(),
|
|
2176
|
+
status: { type: "complete", reason: "stop" },
|
|
2177
|
+
content: [
|
|
2178
|
+
{
|
|
2179
|
+
type: "tool-call",
|
|
2180
|
+
toolCallId: "known",
|
|
2181
|
+
toolName: "present",
|
|
2182
|
+
args: {},
|
|
2183
|
+
argsText: "{}",
|
|
2184
|
+
result: {},
|
|
2185
|
+
},
|
|
2186
|
+
],
|
|
2187
|
+
metadata: {
|
|
2188
|
+
unstable_state: null,
|
|
2189
|
+
unstable_annotations: [],
|
|
2190
|
+
unstable_data: [],
|
|
2191
|
+
steps: [],
|
|
2192
|
+
custom: {},
|
|
2193
|
+
},
|
|
2194
|
+
} as ThreadMessage,
|
|
2195
|
+
]);
|
|
2196
|
+
|
|
2197
|
+
await expect(
|
|
2198
|
+
core.recordToolInteraction({
|
|
2199
|
+
messageId: "missing",
|
|
2200
|
+
toolCallId: "known",
|
|
2201
|
+
interaction: toolInteraction,
|
|
2202
|
+
}),
|
|
2203
|
+
).rejects.toThrow("non-existing message");
|
|
2204
|
+
await expect(
|
|
2205
|
+
core.recordToolInteraction({
|
|
2206
|
+
messageId: "assistant",
|
|
2207
|
+
toolCallId: "missing",
|
|
2208
|
+
interaction: toolInteraction,
|
|
2209
|
+
}),
|
|
2210
|
+
).rejects.toThrow("non-existing tool call");
|
|
2211
|
+
});
|
|
1218
2212
|
});
|
|
1219
2213
|
|
|
1220
2214
|
// --- Task snapshot ---
|
|
@@ -1402,6 +2396,136 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1402
2396
|
expect(cancelTask).not.toHaveBeenCalled();
|
|
1403
2397
|
});
|
|
1404
2398
|
|
|
2399
|
+
it("ignores a cancellation response that resolves after a newer task snapshot", async () => {
|
|
2400
|
+
let resolveCancel!: (task: A2ATask) => void;
|
|
2401
|
+
const cancelTask = vi.fn().mockReturnValue(
|
|
2402
|
+
new Promise<A2ATask>((resolve) => {
|
|
2403
|
+
resolveCancel = resolve;
|
|
2404
|
+
}),
|
|
2405
|
+
);
|
|
2406
|
+
const streamMessage = vi.fn().mockImplementation(async function* (
|
|
2407
|
+
_msg: any,
|
|
2408
|
+
_cfg: any,
|
|
2409
|
+
_meta: any,
|
|
2410
|
+
signal: AbortSignal,
|
|
2411
|
+
) {
|
|
2412
|
+
if (streamMessage.mock.calls.length === 1) {
|
|
2413
|
+
yield statusUpdateEvent("working");
|
|
2414
|
+
await new Promise<void>((resolve) => {
|
|
2415
|
+
if (signal.aborted) resolve();
|
|
2416
|
+
else
|
|
2417
|
+
signal.addEventListener("abort", () => resolve(), {
|
|
2418
|
+
once: true,
|
|
2419
|
+
});
|
|
2420
|
+
});
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
yield statusUpdateEvent("working", "Second answer");
|
|
2424
|
+
});
|
|
2425
|
+
const core = createCore({ cancelTask, streamMessage });
|
|
2426
|
+
|
|
2427
|
+
const firstRun = core.append(createUserAppendMessage("First"));
|
|
2428
|
+
await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
|
|
2429
|
+
|
|
2430
|
+
const cancelPromise = core.cancel();
|
|
2431
|
+
await firstRun;
|
|
2432
|
+
|
|
2433
|
+
await core.append(createUserAppendMessage("Second"));
|
|
2434
|
+
expect(core.getTask()?.status.state).toBe("working");
|
|
2435
|
+
|
|
2436
|
+
resolveCancel({ id: "t1", status: { state: "canceled" } });
|
|
2437
|
+
await cancelPromise;
|
|
2438
|
+
|
|
2439
|
+
expect(cancelTask).toHaveBeenCalledExactlyOnceWith("t1");
|
|
2440
|
+
expect(core.getTask()).toMatchObject({
|
|
2441
|
+
id: "t1",
|
|
2442
|
+
status: { state: "working" },
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
it("ignores a cancellation response that resolves after a newer run starts", async () => {
|
|
2447
|
+
let resolveCancel!: (task: A2ATask) => void;
|
|
2448
|
+
const cancelTask = vi.fn().mockReturnValue(
|
|
2449
|
+
new Promise<A2ATask>((resolve) => {
|
|
2450
|
+
resolveCancel = resolve;
|
|
2451
|
+
}),
|
|
2452
|
+
);
|
|
2453
|
+
// The follow-up run never emits, so it leaves the non-terminal task
|
|
2454
|
+
// object from the first run in place: only the run itself is newer.
|
|
2455
|
+
const streamMessage = vi.fn().mockImplementation(async function* (
|
|
2456
|
+
_msg: any,
|
|
2457
|
+
_cfg: any,
|
|
2458
|
+
_meta: any,
|
|
2459
|
+
signal: AbortSignal,
|
|
2460
|
+
) {
|
|
2461
|
+
if (streamMessage.mock.calls.length === 1) {
|
|
2462
|
+
yield statusUpdateEvent("working");
|
|
2463
|
+
}
|
|
2464
|
+
await new Promise<void>((resolve) => {
|
|
2465
|
+
if (signal.aborted) resolve();
|
|
2466
|
+
else
|
|
2467
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
2468
|
+
});
|
|
2469
|
+
});
|
|
2470
|
+
const core = createCore({ cancelTask, streamMessage });
|
|
2471
|
+
|
|
2472
|
+
const firstRun = core.append(createUserAppendMessage("First"));
|
|
2473
|
+
await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
|
|
2474
|
+
|
|
2475
|
+
const cancelPromise = core.cancel();
|
|
2476
|
+
await firstRun;
|
|
2477
|
+
|
|
2478
|
+
void core.append(createUserAppendMessage("Second"));
|
|
2479
|
+
await vi.waitFor(() => expect(streamMessage).toHaveBeenCalledTimes(2));
|
|
2480
|
+
expect(core.isRunning()).toBe(true);
|
|
2481
|
+
|
|
2482
|
+
resolveCancel({ id: "t1", status: { state: "canceled" } });
|
|
2483
|
+
await cancelPromise;
|
|
2484
|
+
|
|
2485
|
+
expect(core.getTask()?.status.state).toBe("working");
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
it("still cancels the server task when onCancel clears the thread", async () => {
|
|
2489
|
+
let resolveCancel!: (task: A2ATask) => void;
|
|
2490
|
+
const cancelTask = vi.fn().mockReturnValue(
|
|
2491
|
+
new Promise<A2ATask>((resolve) => {
|
|
2492
|
+
resolveCancel = resolve;
|
|
2493
|
+
}),
|
|
2494
|
+
);
|
|
2495
|
+
const streamMessage = vi.fn().mockImplementation(async function* (
|
|
2496
|
+
_msg: any,
|
|
2497
|
+
_cfg: any,
|
|
2498
|
+
_meta: any,
|
|
2499
|
+
signal: AbortSignal,
|
|
2500
|
+
) {
|
|
2501
|
+
yield statusUpdateEvent("working");
|
|
2502
|
+
await new Promise<void>((resolve) => {
|
|
2503
|
+
if (signal.aborted) resolve();
|
|
2504
|
+
else
|
|
2505
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
2506
|
+
});
|
|
2507
|
+
});
|
|
2508
|
+
let core!: A2AThreadRuntimeCore;
|
|
2509
|
+
core = createCore(
|
|
2510
|
+
{ cancelTask, streamMessage },
|
|
2511
|
+
{ onCancel: () => core.applyExternalMessages([]) },
|
|
2512
|
+
);
|
|
2513
|
+
|
|
2514
|
+
const runPromise = core.append(createUserAppendMessage("Go"));
|
|
2515
|
+
await vi.waitFor(() => expect(core.getTask()?.id).toBe("t1"));
|
|
2516
|
+
|
|
2517
|
+
const cancelPromise = core.cancel();
|
|
2518
|
+
await runPromise;
|
|
2519
|
+
|
|
2520
|
+
expect(cancelTask).toHaveBeenCalledExactlyOnceWith("t1");
|
|
2521
|
+
|
|
2522
|
+
resolveCancel({ id: "t1", status: { state: "canceled" } });
|
|
2523
|
+
await cancelPromise;
|
|
2524
|
+
|
|
2525
|
+
expect(core.getTask()).toBeUndefined();
|
|
2526
|
+
expect(core.getMessages()).toEqual([]);
|
|
2527
|
+
});
|
|
2528
|
+
|
|
1405
2529
|
it.each(["throws", "rejects"] as const)(
|
|
1406
2530
|
"isolates onCancel callbacks that %s",
|
|
1407
2531
|
async (failureMode) => {
|