@assistant-ui/react-a2a 0.2.5 → 0.2.7
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/README.md +47 -1
- package/dist/A2AClient.d.ts +43 -0
- package/dist/A2AClient.d.ts.map +1 -0
- package/dist/A2AClient.js +358 -0
- package/dist/A2AClient.js.map +1 -0
- package/dist/A2AThreadRuntimeCore.d.ts +75 -0
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -0
- package/dist/A2AThreadRuntimeCore.js +483 -0
- package/dist/A2AThreadRuntimeCore.js.map +1 -0
- package/dist/conversions.d.ts +14 -0
- package/dist/conversions.d.ts.map +1 -0
- package/dist/conversions.js +92 -0
- package/dist/conversions.js.map +1 -0
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +228 -84
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -9
- package/dist/types.js.map +1 -1
- package/dist/useA2ARuntime.d.ts +35 -48
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +126 -172
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +9 -9
- package/src/A2AClient.test.ts +773 -0
- package/src/A2AClient.ts +519 -0
- package/src/A2AThreadRuntimeCore.test.ts +692 -0
- package/src/A2AThreadRuntimeCore.ts +633 -0
- package/src/conversions.test.ts +276 -0
- package/src/conversions.ts +115 -0
- package/src/index.ts +66 -6
- package/src/types.ts +276 -95
- package/src/useA2ARuntime.ts +204 -296
- package/dist/A2AMessageAccumulator.d.ts +0 -16
- package/dist/A2AMessageAccumulator.d.ts.map +0 -1
- package/dist/A2AMessageAccumulator.js +0 -29
- package/dist/A2AMessageAccumulator.js.map +0 -1
- package/dist/appendA2AChunk.d.ts +0 -3
- package/dist/appendA2AChunk.d.ts.map +0 -1
- package/dist/appendA2AChunk.js +0 -110
- package/dist/appendA2AChunk.js.map +0 -1
- package/dist/convertA2AMessages.d.ts +0 -64
- package/dist/convertA2AMessages.d.ts.map +0 -1
- package/dist/convertA2AMessages.js +0 -90
- package/dist/convertA2AMessages.js.map +0 -1
- package/dist/testUtils.d.ts +0 -4
- package/dist/testUtils.d.ts.map +0 -1
- package/dist/testUtils.js +0 -6
- package/dist/testUtils.js.map +0 -1
- package/dist/useA2AMessages.d.ts +0 -25
- package/dist/useA2AMessages.d.ts.map +0 -1
- package/dist/useA2AMessages.js +0 -122
- package/dist/useA2AMessages.js.map +0 -1
- package/src/A2AMessageAccumulator.ts +0 -48
- package/src/appendA2AChunk.ts +0 -121
- package/src/convertA2AMessages.ts +0 -108
- package/src/testUtils.ts +0 -11
- package/src/useA2AMessages.ts +0 -180
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
|
|
3
|
+
import type { A2AClient } from "./A2AClient";
|
|
4
|
+
import type { A2AMessage, A2AStreamEvent, A2ATask } from "./types";
|
|
5
|
+
import type { AppendMessage, ThreadMessage } from "@assistant-ui/core";
|
|
6
|
+
|
|
7
|
+
// --- Mock client factory ---
|
|
8
|
+
|
|
9
|
+
function createMockClient(overrides: Partial<A2AClient> = {}): A2AClient {
|
|
10
|
+
return {
|
|
11
|
+
getAgentCard: vi.fn().mockRejectedValue(new Error("not found")),
|
|
12
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
13
|
+
id: "t1",
|
|
14
|
+
status: { state: "completed" },
|
|
15
|
+
} satisfies A2ATask),
|
|
16
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
17
|
+
// default: empty stream
|
|
18
|
+
}),
|
|
19
|
+
getTask: vi.fn(),
|
|
20
|
+
listTasks: vi.fn(),
|
|
21
|
+
cancelTask: vi.fn().mockResolvedValue({
|
|
22
|
+
id: "t1",
|
|
23
|
+
status: { state: "canceled" },
|
|
24
|
+
}),
|
|
25
|
+
subscribeToTask: vi.fn(),
|
|
26
|
+
getExtendedAgentCard: vi.fn(),
|
|
27
|
+
createTaskPushNotificationConfig: vi.fn(),
|
|
28
|
+
getTaskPushNotificationConfig: vi.fn(),
|
|
29
|
+
listTaskPushNotificationConfigs: vi.fn(),
|
|
30
|
+
deleteTaskPushNotificationConfig: vi.fn(),
|
|
31
|
+
...overrides,
|
|
32
|
+
} as unknown as A2AClient;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createUserAppendMessage(text: string): AppendMessage {
|
|
36
|
+
return {
|
|
37
|
+
parentId: null,
|
|
38
|
+
role: "user",
|
|
39
|
+
content: [{ type: "text", text }],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
|
|
44
|
+
return {
|
|
45
|
+
type: "statusUpdate",
|
|
46
|
+
event: {
|
|
47
|
+
taskId: "t1",
|
|
48
|
+
contextId: "ctx-1",
|
|
49
|
+
status: {
|
|
50
|
+
state: state as any,
|
|
51
|
+
...(text && {
|
|
52
|
+
message: {
|
|
53
|
+
messageId: "s1",
|
|
54
|
+
role: "agent" as const,
|
|
55
|
+
parts: [{ text }],
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function artifactUpdateEvent(
|
|
64
|
+
artifactId: string,
|
|
65
|
+
parts: { text: string }[],
|
|
66
|
+
opts: { append?: boolean; lastChunk?: boolean } = {},
|
|
67
|
+
): A2AStreamEvent {
|
|
68
|
+
return {
|
|
69
|
+
type: "artifactUpdate",
|
|
70
|
+
event: {
|
|
71
|
+
taskId: "t1",
|
|
72
|
+
contextId: "ctx-1",
|
|
73
|
+
artifact: { artifactId, name: artifactId, parts },
|
|
74
|
+
append: opts.append,
|
|
75
|
+
lastChunk: opts.lastChunk,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("A2AThreadRuntimeCore", () => {
|
|
81
|
+
let notifyUpdate: ReturnType<typeof vi.fn>;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
notifyUpdate = vi.fn();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function createCore(
|
|
88
|
+
clientOverrides: Partial<A2AClient> = {},
|
|
89
|
+
coreOverrides: Record<string, unknown> = {},
|
|
90
|
+
) {
|
|
91
|
+
return new A2AThreadRuntimeCore({
|
|
92
|
+
client: createMockClient(clientOverrides),
|
|
93
|
+
notifyUpdate,
|
|
94
|
+
...coreOverrides,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Basic state ---
|
|
99
|
+
|
|
100
|
+
describe("initial state", () => {
|
|
101
|
+
it("starts with no messages", () => {
|
|
102
|
+
const core = createCore();
|
|
103
|
+
expect(core.getMessages()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("starts not running", () => {
|
|
107
|
+
const core = createCore();
|
|
108
|
+
expect(core.isRunning()).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("starts with no task", () => {
|
|
112
|
+
const core = createCore();
|
|
113
|
+
expect(core.getTask()).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("starts with no artifacts", () => {
|
|
117
|
+
const core = createCore();
|
|
118
|
+
expect(core.getArtifacts()).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- Edit & Reload ---
|
|
123
|
+
|
|
124
|
+
describe("edit", () => {
|
|
125
|
+
it("delegates to append", async () => {
|
|
126
|
+
const core = createCore({
|
|
127
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
128
|
+
yield statusUpdateEvent("completed", "Edited response");
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await core.edit(createUserAppendMessage("Edited"));
|
|
133
|
+
|
|
134
|
+
const messages = core.getMessages();
|
|
135
|
+
expect(messages).toHaveLength(2);
|
|
136
|
+
expect(messages[0]!.role).toBe("user");
|
|
137
|
+
expect(messages[1]!.role).toBe("assistant");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("reload", () => {
|
|
142
|
+
it("resets to parent and re-runs", async () => {
|
|
143
|
+
let runCount = 0;
|
|
144
|
+
const core = createCore({
|
|
145
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
146
|
+
runCount++;
|
|
147
|
+
yield statusUpdateEvent("completed", `Run ${runCount}`);
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await core.append(createUserAppendMessage("Hello"));
|
|
152
|
+
expect(core.getMessages()).toHaveLength(2);
|
|
153
|
+
|
|
154
|
+
const userId = core.getMessages()[0]!.id;
|
|
155
|
+
await core.reload(userId);
|
|
156
|
+
|
|
157
|
+
// After reload: user message + new assistant message
|
|
158
|
+
expect(core.getMessages()).toHaveLength(2);
|
|
159
|
+
const assistant = core.getMessages()[1]!;
|
|
160
|
+
expect(assistant.content).toEqual([{ type: "text", text: "Run 2" }]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// --- Streaming run ---
|
|
165
|
+
|
|
166
|
+
describe("streaming run", () => {
|
|
167
|
+
it("processes status update events into messages", async () => {
|
|
168
|
+
const events: A2AStreamEvent[] = [
|
|
169
|
+
statusUpdateEvent("working", "Thinking..."),
|
|
170
|
+
statusUpdateEvent("completed", "Done!"),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const core = createCore({
|
|
174
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
175
|
+
for (const e of events) yield e;
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await core.append(createUserAppendMessage("Hello"));
|
|
180
|
+
|
|
181
|
+
const messages = core.getMessages();
|
|
182
|
+
expect(messages).toHaveLength(2); // user + assistant
|
|
183
|
+
expect(messages[0]!.role).toBe("user");
|
|
184
|
+
expect(messages[1]!.role).toBe("assistant");
|
|
185
|
+
|
|
186
|
+
const assistant = messages[1]!;
|
|
187
|
+
// Final content should be "Done!"
|
|
188
|
+
expect(assistant.content).toEqual([{ type: "text", text: "Done!" }]);
|
|
189
|
+
expect(assistant.status).toEqual({
|
|
190
|
+
type: "complete",
|
|
191
|
+
reason: "stop",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("tracks task state from status updates", async () => {
|
|
196
|
+
const core = createCore({
|
|
197
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
198
|
+
yield statusUpdateEvent("working", "...");
|
|
199
|
+
yield statusUpdateEvent("completed", "Done");
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await core.append(createUserAppendMessage("Go"));
|
|
204
|
+
|
|
205
|
+
const task = core.getTask();
|
|
206
|
+
expect(task).toBeDefined();
|
|
207
|
+
expect(task!.id).toBe("t1");
|
|
208
|
+
expect(task!.status.state).toBe("completed");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("tracks context ID from events", async () => {
|
|
212
|
+
const core = createCore({
|
|
213
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
214
|
+
yield statusUpdateEvent("completed", "Done");
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await core.append(createUserAppendMessage("Go"));
|
|
219
|
+
|
|
220
|
+
const task = core.getTask();
|
|
221
|
+
expect(task!.contextId).toBe("ctx-1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("is running during stream and not after", async () => {
|
|
225
|
+
let wasRunningDuringStream = false;
|
|
226
|
+
const core = createCore({
|
|
227
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
228
|
+
// Capture isRunning state mid-stream
|
|
229
|
+
wasRunningDuringStream = core.isRunning();
|
|
230
|
+
yield statusUpdateEvent("completed", "Done");
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await core.append(createUserAppendMessage("Go"));
|
|
235
|
+
|
|
236
|
+
expect(wasRunningDuringStream).toBe(true);
|
|
237
|
+
expect(core.isRunning()).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// --- Sync (non-streaming) fallback ---
|
|
242
|
+
|
|
243
|
+
describe("sync fallback", () => {
|
|
244
|
+
it("uses sendMessage when streaming is false in agent card", async () => {
|
|
245
|
+
const sendMessage = vi.fn().mockResolvedValue({
|
|
246
|
+
id: "t1",
|
|
247
|
+
status: {
|
|
248
|
+
state: "completed",
|
|
249
|
+
message: {
|
|
250
|
+
messageId: "s1",
|
|
251
|
+
role: "agent",
|
|
252
|
+
parts: [{ text: "Sync response" }],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
} satisfies A2ATask);
|
|
256
|
+
|
|
257
|
+
const streamMessage = vi.fn();
|
|
258
|
+
|
|
259
|
+
const core = createCore({ sendMessage, streamMessage });
|
|
260
|
+
// Simulate agent card with streaming: false
|
|
261
|
+
(core as any).agentCardValue = {
|
|
262
|
+
capabilities: { streaming: false },
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
await core.append(createUserAppendMessage("Hello"));
|
|
266
|
+
|
|
267
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(streamMessage).not.toHaveBeenCalled();
|
|
269
|
+
|
|
270
|
+
const messages = core.getMessages();
|
|
271
|
+
const assistant = messages[1]!;
|
|
272
|
+
expect(assistant.content).toEqual([
|
|
273
|
+
{ type: "text", text: "Sync response" },
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("handles Message-only response (stateless agent)", async () => {
|
|
278
|
+
const sendMessage = vi.fn().mockResolvedValue({
|
|
279
|
+
messageId: "m2",
|
|
280
|
+
role: "agent",
|
|
281
|
+
parts: [{ text: "Quick answer" }],
|
|
282
|
+
} satisfies A2AMessage);
|
|
283
|
+
|
|
284
|
+
const core = createCore({ sendMessage });
|
|
285
|
+
(core as any).agentCardValue = {
|
|
286
|
+
capabilities: { streaming: false },
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
await core.append(createUserAppendMessage("Hello"));
|
|
290
|
+
|
|
291
|
+
const messages = core.getMessages();
|
|
292
|
+
expect(messages[1]!.content).toEqual([
|
|
293
|
+
{ type: "text", text: "Quick answer" },
|
|
294
|
+
]);
|
|
295
|
+
expect(messages[1]!.status).toEqual({
|
|
296
|
+
type: "complete",
|
|
297
|
+
reason: "stop",
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// --- Artifact handling ---
|
|
303
|
+
|
|
304
|
+
describe("artifacts", () => {
|
|
305
|
+
it("accumulates artifacts from artifact update events", async () => {
|
|
306
|
+
const core = createCore({
|
|
307
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
308
|
+
yield artifactUpdateEvent("a1", [{ text: "code1" }]);
|
|
309
|
+
yield artifactUpdateEvent("a2", [{ text: "code2" }]);
|
|
310
|
+
yield statusUpdateEvent("completed", "Done");
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await core.append(createUserAppendMessage("Go"));
|
|
315
|
+
|
|
316
|
+
const artifacts = core.getArtifacts();
|
|
317
|
+
expect(artifacts).toHaveLength(2);
|
|
318
|
+
expect(artifacts[0]!.artifactId).toBe("a1");
|
|
319
|
+
expect(artifacts[1]!.artifactId).toBe("a2");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("appends parts to existing artifact when append=true", async () => {
|
|
323
|
+
const core = createCore({
|
|
324
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
325
|
+
yield artifactUpdateEvent("a1", [{ text: "part1" }]);
|
|
326
|
+
yield artifactUpdateEvent("a1", [{ text: "part2" }], {
|
|
327
|
+
append: true,
|
|
328
|
+
});
|
|
329
|
+
yield statusUpdateEvent("completed", "Done");
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await core.append(createUserAppendMessage("Go"));
|
|
334
|
+
|
|
335
|
+
const artifacts = core.getArtifacts();
|
|
336
|
+
expect(artifacts).toHaveLength(1);
|
|
337
|
+
expect(artifacts[0]!.parts).toHaveLength(2);
|
|
338
|
+
expect(artifacts[0]!.parts[0]!.text).toBe("part1");
|
|
339
|
+
expect(artifacts[0]!.parts[1]!.text).toBe("part2");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("replaces artifact when append=false", async () => {
|
|
343
|
+
const core = createCore({
|
|
344
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
345
|
+
yield artifactUpdateEvent("a1", [{ text: "old" }]);
|
|
346
|
+
yield artifactUpdateEvent("a1", [{ text: "new" }]);
|
|
347
|
+
yield statusUpdateEvent("completed", "Done");
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await core.append(createUserAppendMessage("Go"));
|
|
352
|
+
|
|
353
|
+
const artifacts = core.getArtifacts();
|
|
354
|
+
expect(artifacts).toHaveLength(1);
|
|
355
|
+
expect(artifacts[0]!.parts).toHaveLength(1);
|
|
356
|
+
expect(artifacts[0]!.parts[0]!.text).toBe("new");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("calls onArtifactComplete when lastChunk=true", async () => {
|
|
360
|
+
const onArtifactComplete = vi.fn();
|
|
361
|
+
|
|
362
|
+
const core = new A2AThreadRuntimeCore({
|
|
363
|
+
client: createMockClient({
|
|
364
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
365
|
+
yield artifactUpdateEvent("a1", [{ text: "code" }], {
|
|
366
|
+
lastChunk: true,
|
|
367
|
+
});
|
|
368
|
+
yield statusUpdateEvent("completed", "Done");
|
|
369
|
+
}),
|
|
370
|
+
}),
|
|
371
|
+
onArtifactComplete,
|
|
372
|
+
notifyUpdate,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await core.append(createUserAppendMessage("Go"));
|
|
376
|
+
|
|
377
|
+
expect(onArtifactComplete).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(onArtifactComplete.mock.calls[0]![0].artifactId).toBe("a1");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("resets artifacts on new run", async () => {
|
|
382
|
+
let runCount = 0;
|
|
383
|
+
|
|
384
|
+
const core = createCore({
|
|
385
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
386
|
+
runCount++;
|
|
387
|
+
if (runCount === 1) {
|
|
388
|
+
yield artifactUpdateEvent("a1", [{ text: "first" }]);
|
|
389
|
+
yield statusUpdateEvent("completed", "Done");
|
|
390
|
+
} else {
|
|
391
|
+
yield statusUpdateEvent("completed", "Done");
|
|
392
|
+
}
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await core.append(createUserAppendMessage("First"));
|
|
397
|
+
expect(core.getArtifacts()).toHaveLength(1);
|
|
398
|
+
|
|
399
|
+
await core.append(createUserAppendMessage("Second"));
|
|
400
|
+
expect(core.getArtifacts()).toHaveLength(0);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// --- Task lifecycle ---
|
|
405
|
+
|
|
406
|
+
describe("task lifecycle", () => {
|
|
407
|
+
it("clears task after terminal state for new message", async () => {
|
|
408
|
+
let runCount = 0;
|
|
409
|
+
|
|
410
|
+
const streamMessage = vi.fn().mockImplementation(async function* () {
|
|
411
|
+
runCount++;
|
|
412
|
+
yield statusUpdateEvent("completed", `Run ${runCount}`);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const core = createCore({ streamMessage });
|
|
416
|
+
|
|
417
|
+
await core.append(createUserAppendMessage("First"));
|
|
418
|
+
expect(core.getTask()!.status.state).toBe("completed");
|
|
419
|
+
|
|
420
|
+
await core.append(createUserAppendMessage("Second"));
|
|
421
|
+
|
|
422
|
+
// Verify the second call didn't include the old taskId
|
|
423
|
+
const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
|
|
424
|
+
expect(secondCallMsg.taskId).toBeUndefined();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("keeps taskId for non-terminal states (input_required)", async () => {
|
|
428
|
+
let runCount = 0;
|
|
429
|
+
|
|
430
|
+
const streamMessage = vi.fn().mockImplementation(async function* () {
|
|
431
|
+
runCount++;
|
|
432
|
+
if (runCount === 1) {
|
|
433
|
+
yield statusUpdateEvent("input_required", "Need more info");
|
|
434
|
+
} else {
|
|
435
|
+
yield statusUpdateEvent("completed", "Done");
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const core = createCore({ streamMessage });
|
|
440
|
+
|
|
441
|
+
await core.append(createUserAppendMessage("Start"));
|
|
442
|
+
expect(core.getTask()!.status.state).toBe("input_required");
|
|
443
|
+
|
|
444
|
+
await core.append(createUserAppendMessage("More info"));
|
|
445
|
+
|
|
446
|
+
// Second call should include the taskId
|
|
447
|
+
const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
|
|
448
|
+
expect(secondCallMsg.taskId).toBe("t1");
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// --- Task snapshot ---
|
|
453
|
+
|
|
454
|
+
describe("task snapshot", () => {
|
|
455
|
+
it("handles full task snapshot from stream", async () => {
|
|
456
|
+
const taskSnapshot: A2ATask = {
|
|
457
|
+
id: "t1",
|
|
458
|
+
contextId: "ctx-1",
|
|
459
|
+
status: {
|
|
460
|
+
state: "completed",
|
|
461
|
+
message: {
|
|
462
|
+
messageId: "s1",
|
|
463
|
+
role: "agent",
|
|
464
|
+
parts: [{ text: "Full snapshot" }],
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
artifacts: [
|
|
468
|
+
{
|
|
469
|
+
artifactId: "a1",
|
|
470
|
+
parts: [{ text: "artifact content" }],
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const core = createCore({
|
|
476
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
477
|
+
yield { type: "task", task: taskSnapshot } as A2AStreamEvent;
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await core.append(createUserAppendMessage("Go"));
|
|
482
|
+
|
|
483
|
+
expect(core.getTask()).toEqual(taskSnapshot);
|
|
484
|
+
expect(core.getArtifacts()).toHaveLength(1);
|
|
485
|
+
|
|
486
|
+
const assistant = core.getMessages()[1]!;
|
|
487
|
+
expect(assistant.content).toEqual([
|
|
488
|
+
{ type: "text", text: "Full snapshot" },
|
|
489
|
+
]);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// --- Message event ---
|
|
494
|
+
|
|
495
|
+
describe("message event", () => {
|
|
496
|
+
it("handles standalone agent message event", async () => {
|
|
497
|
+
const core = createCore({
|
|
498
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
499
|
+
yield {
|
|
500
|
+
type: "message",
|
|
501
|
+
message: {
|
|
502
|
+
messageId: "m2",
|
|
503
|
+
role: "agent",
|
|
504
|
+
parts: [{ text: "Direct message" }],
|
|
505
|
+
},
|
|
506
|
+
} as A2AStreamEvent;
|
|
507
|
+
}),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await core.append(createUserAppendMessage("Go"));
|
|
511
|
+
|
|
512
|
+
const assistant = core.getMessages()[1]!;
|
|
513
|
+
expect(assistant.content).toEqual([
|
|
514
|
+
{ type: "text", text: "Direct message" },
|
|
515
|
+
]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("ignores user-role message events", async () => {
|
|
519
|
+
const core = createCore({
|
|
520
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
521
|
+
yield {
|
|
522
|
+
type: "message",
|
|
523
|
+
message: {
|
|
524
|
+
messageId: "m2",
|
|
525
|
+
role: "user",
|
|
526
|
+
parts: [{ text: "Echo" }],
|
|
527
|
+
},
|
|
528
|
+
} as A2AStreamEvent;
|
|
529
|
+
}),
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await core.append(createUserAppendMessage("Go"));
|
|
533
|
+
|
|
534
|
+
const assistant = core.getMessages()[1]!;
|
|
535
|
+
expect(assistant.content).toEqual([]); // No content from user message
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// --- Cancel ---
|
|
540
|
+
|
|
541
|
+
describe("cancel", () => {
|
|
542
|
+
it("updates task from server cancel response", async () => {
|
|
543
|
+
const cancelTask = vi.fn().mockResolvedValue({
|
|
544
|
+
id: "t1",
|
|
545
|
+
status: { state: "canceled" },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const core = createCore({ cancelTask });
|
|
549
|
+
|
|
550
|
+
// Manually set task state to simulate a running task
|
|
551
|
+
(core as any).currentTask = {
|
|
552
|
+
id: "t1",
|
|
553
|
+
status: { state: "working" },
|
|
554
|
+
};
|
|
555
|
+
(core as any).abortController = new AbortController();
|
|
556
|
+
|
|
557
|
+
await core.cancel();
|
|
558
|
+
|
|
559
|
+
expect(cancelTask).toHaveBeenCalledWith("t1");
|
|
560
|
+
expect(core.getTask()!.status.state).toBe("canceled");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("does nothing when no abort controller", async () => {
|
|
564
|
+
const cancelTask = vi.fn();
|
|
565
|
+
const core = createCore({ cancelTask });
|
|
566
|
+
|
|
567
|
+
await core.cancel();
|
|
568
|
+
|
|
569
|
+
expect(cancelTask).not.toHaveBeenCalled();
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// --- Error handling ---
|
|
574
|
+
|
|
575
|
+
describe("error handling", () => {
|
|
576
|
+
it("sets error status and re-throws on stream failure", async () => {
|
|
577
|
+
const onError = vi.fn();
|
|
578
|
+
|
|
579
|
+
const core = new A2AThreadRuntimeCore({
|
|
580
|
+
client: createMockClient({
|
|
581
|
+
streamMessage: vi.fn().mockImplementation(() => ({
|
|
582
|
+
async next() {
|
|
583
|
+
throw new Error("Network error");
|
|
584
|
+
},
|
|
585
|
+
[Symbol.asyncIterator]() {
|
|
586
|
+
return this;
|
|
587
|
+
},
|
|
588
|
+
})),
|
|
589
|
+
}),
|
|
590
|
+
onError,
|
|
591
|
+
notifyUpdate,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
|
|
595
|
+
"Network error",
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
599
|
+
expect(onError.mock.calls[0]![0].message).toBe("Network error");
|
|
600
|
+
|
|
601
|
+
const assistant = core.getMessages()[1]!;
|
|
602
|
+
expect(assistant.status).toEqual({
|
|
603
|
+
type: "incomplete",
|
|
604
|
+
reason: "error",
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("marks complete when stream ends without terminal status", async () => {
|
|
609
|
+
const core = createCore({
|
|
610
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
611
|
+
// Stream ends without any events
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await core.append(createUserAppendMessage("Go"));
|
|
616
|
+
|
|
617
|
+
const assistant = core.getMessages()[1]!;
|
|
618
|
+
expect(assistant.status).toEqual({
|
|
619
|
+
type: "complete",
|
|
620
|
+
reason: "stop",
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// --- Concurrent run protection ---
|
|
626
|
+
|
|
627
|
+
describe("concurrent runs", () => {
|
|
628
|
+
it("aborts previous run when new message is sent", async () => {
|
|
629
|
+
let streamCount = 0;
|
|
630
|
+
const abortedSignals: boolean[] = [];
|
|
631
|
+
|
|
632
|
+
const core = createCore({
|
|
633
|
+
streamMessage: vi.fn().mockImplementation(async function* (
|
|
634
|
+
_msg: any,
|
|
635
|
+
_cfg: any,
|
|
636
|
+
_meta: any,
|
|
637
|
+
signal: AbortSignal,
|
|
638
|
+
) {
|
|
639
|
+
streamCount++;
|
|
640
|
+
abortedSignals.push(signal.aborted);
|
|
641
|
+
|
|
642
|
+
if (streamCount === 1) {
|
|
643
|
+
// First stream: hang until aborted
|
|
644
|
+
await new Promise((resolve) => {
|
|
645
|
+
signal.addEventListener("abort", resolve, { once: true });
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
yield statusUpdateEvent("completed", "Second run done");
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Start first run (don't await)
|
|
655
|
+
const first = core.append(createUserAppendMessage("First"));
|
|
656
|
+
|
|
657
|
+
// Small delay to let stream start
|
|
658
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
659
|
+
|
|
660
|
+
// Start second run - should abort first
|
|
661
|
+
await core.append(createUserAppendMessage("Second"));
|
|
662
|
+
|
|
663
|
+
await first;
|
|
664
|
+
|
|
665
|
+
// Second run should have completed
|
|
666
|
+
expect(core.isRunning()).toBe(false);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// --- applyExternalMessages ---
|
|
671
|
+
|
|
672
|
+
describe("applyExternalMessages", () => {
|
|
673
|
+
it("replaces all messages", () => {
|
|
674
|
+
const core = createCore();
|
|
675
|
+
|
|
676
|
+
const msgs: ThreadMessage[] = [
|
|
677
|
+
{
|
|
678
|
+
id: "ext-1",
|
|
679
|
+
role: "user",
|
|
680
|
+
createdAt: new Date(),
|
|
681
|
+
content: [{ type: "text", text: "External" }],
|
|
682
|
+
status: { type: "complete", reason: "stop" },
|
|
683
|
+
} as ThreadMessage,
|
|
684
|
+
];
|
|
685
|
+
|
|
686
|
+
core.applyExternalMessages(msgs);
|
|
687
|
+
|
|
688
|
+
expect(core.getMessages()).toHaveLength(1);
|
|
689
|
+
expect(core.getMessages()[0]!.id).toBe("ext-1");
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
});
|