@assistant-ui/react-a2a 0.2.4 → 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 +10 -10
- 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,773 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { A2AClient, A2AError } from "./A2AClient";
|
|
3
|
+
import type { A2AMessage, A2AStreamEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
// --- Fetch mock helpers ---
|
|
6
|
+
|
|
7
|
+
function mockFetchResponse(body: unknown, ok = true, status = 200): Response {
|
|
8
|
+
return {
|
|
9
|
+
ok,
|
|
10
|
+
status,
|
|
11
|
+
statusText: ok ? "OK" : "Error",
|
|
12
|
+
headers: new Headers(),
|
|
13
|
+
json: vi.fn().mockResolvedValue(body),
|
|
14
|
+
} as unknown as Response;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mockSSEResponse(lines: string[]): Response {
|
|
18
|
+
const text = lines.join("\n");
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
const chunks = [encoder.encode(text)];
|
|
21
|
+
let index = 0;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
status: 200,
|
|
26
|
+
statusText: "OK",
|
|
27
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
28
|
+
body: {
|
|
29
|
+
getReader: () => ({
|
|
30
|
+
read: vi.fn().mockImplementation(() => {
|
|
31
|
+
if (index < chunks.length) {
|
|
32
|
+
return Promise.resolve({
|
|
33
|
+
done: false,
|
|
34
|
+
value: chunks[index++],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
38
|
+
}),
|
|
39
|
+
releaseLock: vi.fn(),
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
} as unknown as Response;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const userMessage: A2AMessage = {
|
|
46
|
+
messageId: "msg-1",
|
|
47
|
+
role: "user",
|
|
48
|
+
parts: [{ text: "Hello" }],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("A2AClient", () => {
|
|
52
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
53
|
+
let client: A2AClient;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
fetchMock = vi.fn();
|
|
57
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
58
|
+
client = new A2AClient({ baseUrl: "https://agent.test" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.unstubAllGlobals();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- Headers ---
|
|
66
|
+
|
|
67
|
+
describe("headers", () => {
|
|
68
|
+
it("sends A2A-Version header", async () => {
|
|
69
|
+
fetchMock.mockResolvedValue(
|
|
70
|
+
mockFetchResponse({
|
|
71
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await client.sendMessage(userMessage);
|
|
76
|
+
|
|
77
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
78
|
+
expect(init.headers["A2A-Version"]).toBe("1.0");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("sends application/a2a+json content type for POST", async () => {
|
|
82
|
+
fetchMock.mockResolvedValue(
|
|
83
|
+
mockFetchResponse({
|
|
84
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await client.sendMessage(userMessage);
|
|
89
|
+
|
|
90
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
91
|
+
expect(init.headers["Content-Type"]).toBe("application/a2a+json");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not send Content-Type for GET requests", async () => {
|
|
95
|
+
fetchMock.mockResolvedValue(
|
|
96
|
+
mockFetchResponse({
|
|
97
|
+
name: "Test",
|
|
98
|
+
description: "desc",
|
|
99
|
+
version: "1.0",
|
|
100
|
+
supportedInterfaces: [],
|
|
101
|
+
capabilities: {},
|
|
102
|
+
defaultInputModes: [],
|
|
103
|
+
defaultOutputModes: [],
|
|
104
|
+
skills: [],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await client.getAgentCard();
|
|
109
|
+
|
|
110
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
111
|
+
expect(init.headers["Content-Type"]).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("sends A2A-Extensions header when extensions configured", async () => {
|
|
115
|
+
const extClient = new A2AClient({
|
|
116
|
+
baseUrl: "https://agent.test",
|
|
117
|
+
extensions: ["urn:ext:one", "urn:ext:two"],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
fetchMock.mockResolvedValue(
|
|
121
|
+
mockFetchResponse({
|
|
122
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await extClient.sendMessage(userMessage);
|
|
127
|
+
|
|
128
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
129
|
+
expect(init.headers["A2A-Extensions"]).toBe("urn:ext:one, urn:ext:two");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("supports dynamic headers via function", async () => {
|
|
133
|
+
const dynamicClient = new A2AClient({
|
|
134
|
+
baseUrl: "https://agent.test",
|
|
135
|
+
headers: () => ({ Authorization: "Bearer tok123" }),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
fetchMock.mockResolvedValue(
|
|
139
|
+
mockFetchResponse({
|
|
140
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await dynamicClient.sendMessage(userMessage);
|
|
145
|
+
|
|
146
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
147
|
+
expect(init.headers["Authorization"]).toBe("Bearer tok123");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- Tenant ---
|
|
152
|
+
|
|
153
|
+
describe("tenant", () => {
|
|
154
|
+
it("prepends tenant to URL path", async () => {
|
|
155
|
+
const tenantClient = new A2AClient({
|
|
156
|
+
baseUrl: "https://agent.test",
|
|
157
|
+
tenant: "my-org",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
fetchMock.mockResolvedValue(
|
|
161
|
+
mockFetchResponse({
|
|
162
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await tenantClient.sendMessage(userMessage);
|
|
167
|
+
|
|
168
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
169
|
+
expect(url).toBe("https://agent.test/my-org/message:send");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- sendMessage ---
|
|
174
|
+
|
|
175
|
+
describe("sendMessage", () => {
|
|
176
|
+
it("sends POST to /message:send with wire-format role", async () => {
|
|
177
|
+
fetchMock.mockResolvedValue(
|
|
178
|
+
mockFetchResponse({
|
|
179
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await client.sendMessage(userMessage);
|
|
184
|
+
|
|
185
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
186
|
+
expect(url).toBe("https://agent.test/message:send");
|
|
187
|
+
expect(init.method).toBe("POST");
|
|
188
|
+
|
|
189
|
+
const body = JSON.parse(init.body);
|
|
190
|
+
expect(body.message.role).toBe("ROLE_USER");
|
|
191
|
+
expect(body.message.messageId).toBe("msg-1");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("unwraps task from SendMessageResponse", async () => {
|
|
195
|
+
fetchMock.mockResolvedValue(
|
|
196
|
+
mockFetchResponse({
|
|
197
|
+
task: {
|
|
198
|
+
id: "t1",
|
|
199
|
+
status: { state: "TASK_STATE_COMPLETED" },
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const result = await client.sendMessage(userMessage);
|
|
205
|
+
expect((result as any).id).toBe("t1");
|
|
206
|
+
expect((result as any).status.state).toBe("completed");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("unwraps message from SendMessageResponse", async () => {
|
|
210
|
+
fetchMock.mockResolvedValue(
|
|
211
|
+
mockFetchResponse({
|
|
212
|
+
message: {
|
|
213
|
+
messageId: "m2",
|
|
214
|
+
role: "ROLE_AGENT",
|
|
215
|
+
parts: [{ text: "Hi" }],
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const result = await client.sendMessage(userMessage);
|
|
221
|
+
expect((result as any).messageId).toBe("m2");
|
|
222
|
+
expect((result as any).role).toBe("agent");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("includes metadata in request body", async () => {
|
|
226
|
+
fetchMock.mockResolvedValue(
|
|
227
|
+
mockFetchResponse({
|
|
228
|
+
task: { id: "t1", status: { state: "completed" } },
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
await client.sendMessage(userMessage, undefined, { foo: "bar" });
|
|
233
|
+
|
|
234
|
+
const body = JSON.parse(fetchMock.mock.calls[0]![1].body);
|
|
235
|
+
expect(body.metadata).toEqual({ foo: "bar" });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// --- Enum normalization ---
|
|
240
|
+
|
|
241
|
+
describe("enum normalization", () => {
|
|
242
|
+
it("normalizes TASK_STATE_* to lowercase", async () => {
|
|
243
|
+
fetchMock.mockResolvedValue(
|
|
244
|
+
mockFetchResponse({
|
|
245
|
+
task: {
|
|
246
|
+
id: "t1",
|
|
247
|
+
status: { state: "TASK_STATE_WORKING" },
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
253
|
+
expect(result.id).toBe("t1");
|
|
254
|
+
expect(result.status.state).toBe("working");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("normalizes ROLE_AGENT to lowercase", async () => {
|
|
258
|
+
fetchMock.mockResolvedValue(
|
|
259
|
+
mockFetchResponse({
|
|
260
|
+
message: {
|
|
261
|
+
messageId: "m1",
|
|
262
|
+
role: "ROLE_AGENT",
|
|
263
|
+
parts: [{ text: "Hi" }],
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
269
|
+
expect(result.role).toBe("agent");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("passes through already-lowercase values", async () => {
|
|
273
|
+
fetchMock.mockResolvedValue(
|
|
274
|
+
mockFetchResponse({
|
|
275
|
+
task: { id: "t1", status: { state: "working" } },
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
280
|
+
expect(result.status.state).toBe("working");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("normalizes snake_case keys to camelCase", async () => {
|
|
284
|
+
fetchMock.mockResolvedValue(
|
|
285
|
+
mockFetchResponse({
|
|
286
|
+
task: {
|
|
287
|
+
id: "t1",
|
|
288
|
+
context_id: "ctx-1",
|
|
289
|
+
status: { state: "completed" },
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
295
|
+
expect(result.contextId).toBe("ctx-1");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("preserves metadata keys without camelCase conversion", async () => {
|
|
299
|
+
fetchMock.mockResolvedValue(
|
|
300
|
+
mockFetchResponse({
|
|
301
|
+
task: {
|
|
302
|
+
id: "t1",
|
|
303
|
+
status: { state: "completed" },
|
|
304
|
+
metadata: {
|
|
305
|
+
my_custom_key: "value1",
|
|
306
|
+
another_snake_key: { nested_key: 42 },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
313
|
+
// metadata keys should NOT be camelCased
|
|
314
|
+
expect(result.metadata.my_custom_key).toBe("value1");
|
|
315
|
+
expect(result.metadata.another_snake_key.nested_key).toBe(42);
|
|
316
|
+
expect(result.metadata.myCustomKey).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("does not normalize enum-like values inside metadata", async () => {
|
|
320
|
+
fetchMock.mockResolvedValue(
|
|
321
|
+
mockFetchResponse({
|
|
322
|
+
task: {
|
|
323
|
+
id: "t1",
|
|
324
|
+
status: { state: "completed" },
|
|
325
|
+
metadata: {
|
|
326
|
+
state: "TASK_STATE_WORKING",
|
|
327
|
+
role: "ROLE_AGENT",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
334
|
+
// metadata values should NOT be enum-normalized
|
|
335
|
+
expect(result.metadata.state).toBe("TASK_STATE_WORKING");
|
|
336
|
+
expect(result.metadata.role).toBe("ROLE_AGENT");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("preserves data field values in parts", async () => {
|
|
340
|
+
fetchMock.mockResolvedValue(
|
|
341
|
+
mockFetchResponse({
|
|
342
|
+
message: {
|
|
343
|
+
message_id: "m1",
|
|
344
|
+
role: "ROLE_AGENT",
|
|
345
|
+
parts: [
|
|
346
|
+
{
|
|
347
|
+
data: {
|
|
348
|
+
snake_case_key: "preserved",
|
|
349
|
+
state: "TASK_STATE_SHOULD_NOT_CHANGE",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const result = (await client.sendMessage(userMessage)) as any;
|
|
358
|
+
const data = result.parts[0].data;
|
|
359
|
+
expect(data.snake_case_key).toBe("preserved");
|
|
360
|
+
expect(data.state).toBe("TASK_STATE_SHOULD_NOT_CHANGE");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// --- Error handling ---
|
|
365
|
+
|
|
366
|
+
describe("error handling", () => {
|
|
367
|
+
it("throws A2AError with structured error body", async () => {
|
|
368
|
+
fetchMock.mockResolvedValue({
|
|
369
|
+
ok: false,
|
|
370
|
+
status: 404,
|
|
371
|
+
statusText: "Not Found",
|
|
372
|
+
headers: new Headers(),
|
|
373
|
+
json: vi.fn().mockResolvedValue({
|
|
374
|
+
error: {
|
|
375
|
+
code: 404,
|
|
376
|
+
status: "NOT_FOUND",
|
|
377
|
+
message: "Task not found",
|
|
378
|
+
details: [{ reason: "TASK_NOT_FOUND" }],
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await expect(client.getTask("missing")).rejects.toThrow(A2AError);
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await client.getTask("missing");
|
|
387
|
+
} catch (e) {
|
|
388
|
+
const err = e as A2AError;
|
|
389
|
+
expect(err.code).toBe(404);
|
|
390
|
+
expect(err.status).toBe("NOT_FOUND");
|
|
391
|
+
expect(err.message).toBe("Task not found");
|
|
392
|
+
expect(err.details).toEqual([{ reason: "TASK_NOT_FOUND" }]);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("throws A2AError with generic error when no body", async () => {
|
|
397
|
+
fetchMock.mockResolvedValue({
|
|
398
|
+
ok: false,
|
|
399
|
+
status: 500,
|
|
400
|
+
statusText: "Internal Server Error",
|
|
401
|
+
headers: new Headers(),
|
|
402
|
+
json: vi.fn().mockRejectedValue(new Error("no body")),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await expect(client.getTask("t1")).rejects.toThrow(A2AError);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// --- getTask ---
|
|
410
|
+
|
|
411
|
+
describe("getTask", () => {
|
|
412
|
+
it("sends GET to /tasks/{id}", async () => {
|
|
413
|
+
fetchMock.mockResolvedValue(
|
|
414
|
+
mockFetchResponse({ id: "t1", status: { state: "completed" } }),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
await client.getTask("t1");
|
|
418
|
+
|
|
419
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
420
|
+
expect(url).toBe("https://agent.test/tasks/t1");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("includes history_length query param", async () => {
|
|
424
|
+
fetchMock.mockResolvedValue(
|
|
425
|
+
mockFetchResponse({ id: "t1", status: { state: "completed" } }),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
await client.getTask("t1", 5);
|
|
429
|
+
|
|
430
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
431
|
+
expect(url).toBe("https://agent.test/tasks/t1?history_length=5");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// --- listTasks ---
|
|
436
|
+
|
|
437
|
+
describe("listTasks", () => {
|
|
438
|
+
it("sends GET to /tasks with query params", async () => {
|
|
439
|
+
fetchMock.mockResolvedValue(
|
|
440
|
+
mockFetchResponse({
|
|
441
|
+
tasks: [],
|
|
442
|
+
nextPageToken: "",
|
|
443
|
+
pageSize: 50,
|
|
444
|
+
totalSize: 0,
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await client.listTasks({
|
|
449
|
+
contextId: "ctx-1",
|
|
450
|
+
status: "working",
|
|
451
|
+
pageSize: 10,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
455
|
+
expect(url).toContain("/tasks?");
|
|
456
|
+
expect(url).toContain("context_id=ctx-1");
|
|
457
|
+
expect(url).toContain("status=TASK_STATE_WORKING");
|
|
458
|
+
expect(url).toContain("page_size=10");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// --- cancelTask ---
|
|
463
|
+
|
|
464
|
+
describe("cancelTask", () => {
|
|
465
|
+
it("sends POST to /tasks/{id}:cancel", async () => {
|
|
466
|
+
fetchMock.mockResolvedValue(
|
|
467
|
+
mockFetchResponse({ id: "t1", status: { state: "canceled" } }),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
await client.cancelTask("t1");
|
|
471
|
+
|
|
472
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
473
|
+
expect(url).toBe("https://agent.test/tasks/t1:cancel");
|
|
474
|
+
expect(init.method).toBe("POST");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("includes metadata in cancel request", async () => {
|
|
478
|
+
fetchMock.mockResolvedValue(
|
|
479
|
+
mockFetchResponse({ id: "t1", status: { state: "canceled" } }),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await client.cancelTask("t1", { reason: "user requested" });
|
|
483
|
+
|
|
484
|
+
const body = JSON.parse(fetchMock.mock.calls[0]![1].body);
|
|
485
|
+
expect(body.metadata).toEqual({ reason: "user requested" });
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// --- getAgentCard ---
|
|
490
|
+
|
|
491
|
+
describe("getAgentCard", () => {
|
|
492
|
+
it("fetches from /.well-known/agent-card.json", async () => {
|
|
493
|
+
fetchMock.mockResolvedValue(
|
|
494
|
+
mockFetchResponse({
|
|
495
|
+
name: "Test Agent",
|
|
496
|
+
description: "A test",
|
|
497
|
+
version: "1.0",
|
|
498
|
+
supported_interfaces: [
|
|
499
|
+
{
|
|
500
|
+
url: "https://agent.test",
|
|
501
|
+
protocol_binding: "HTTP+JSON",
|
|
502
|
+
protocol_version: "1.0",
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
capabilities: { streaming: true },
|
|
506
|
+
default_input_modes: ["text"],
|
|
507
|
+
default_output_modes: ["text"],
|
|
508
|
+
skills: [],
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const card = await client.getAgentCard();
|
|
513
|
+
|
|
514
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
515
|
+
expect(url).toBe("https://agent.test/.well-known/agent-card.json");
|
|
516
|
+
expect(card.name).toBe("Test Agent");
|
|
517
|
+
expect(card.capabilities.streaming).toBe(true);
|
|
518
|
+
// snake_case keys should be normalized
|
|
519
|
+
expect(card.supportedInterfaces).toHaveLength(1);
|
|
520
|
+
expect(card.supportedInterfaces[0]!.protocolBinding).toBe("HTTP+JSON");
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// --- SSE streaming ---
|
|
525
|
+
|
|
526
|
+
describe("streamMessage", () => {
|
|
527
|
+
it("parses SSE status update events", async () => {
|
|
528
|
+
const sseData = JSON.stringify({
|
|
529
|
+
status_update: {
|
|
530
|
+
task_id: "t1",
|
|
531
|
+
context_id: "ctx-1",
|
|
532
|
+
status: {
|
|
533
|
+
state: "TASK_STATE_WORKING",
|
|
534
|
+
message: {
|
|
535
|
+
message_id: "s1",
|
|
536
|
+
role: "ROLE_AGENT",
|
|
537
|
+
parts: [{ text: "Thinking..." }],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
fetchMock.mockResolvedValue(
|
|
544
|
+
mockSSEResponse([`data: ${sseData}`, "", ""]),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const events: A2AStreamEvent[] = [];
|
|
548
|
+
for await (const event of client.streamMessage(userMessage)) {
|
|
549
|
+
events.push(event);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
expect(events).toHaveLength(1);
|
|
553
|
+
expect(events[0]!.type).toBe("statusUpdate");
|
|
554
|
+
const evt = events[0] as Extract<
|
|
555
|
+
A2AStreamEvent,
|
|
556
|
+
{ type: "statusUpdate" }
|
|
557
|
+
>;
|
|
558
|
+
expect(evt.event.taskId).toBe("t1");
|
|
559
|
+
expect(evt.event.status.state).toBe("working");
|
|
560
|
+
expect(evt.event.status.message?.role).toBe("agent");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("parses SSE artifact update events", async () => {
|
|
564
|
+
const sseData = JSON.stringify({
|
|
565
|
+
artifact_update: {
|
|
566
|
+
task_id: "t1",
|
|
567
|
+
context_id: "ctx-1",
|
|
568
|
+
artifact: {
|
|
569
|
+
artifact_id: "a1",
|
|
570
|
+
name: "Code",
|
|
571
|
+
parts: [{ text: "print('hello')" }],
|
|
572
|
+
},
|
|
573
|
+
last_chunk: true,
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
fetchMock.mockResolvedValue(
|
|
578
|
+
mockSSEResponse([`data: ${sseData}`, "", ""]),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const events: A2AStreamEvent[] = [];
|
|
582
|
+
for await (const event of client.streamMessage(userMessage)) {
|
|
583
|
+
events.push(event);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
expect(events).toHaveLength(1);
|
|
587
|
+
expect(events[0]!.type).toBe("artifactUpdate");
|
|
588
|
+
const evt = events[0] as Extract<
|
|
589
|
+
A2AStreamEvent,
|
|
590
|
+
{ type: "artifactUpdate" }
|
|
591
|
+
>;
|
|
592
|
+
expect(evt.event.artifact.artifactId).toBe("a1");
|
|
593
|
+
expect(evt.event.lastChunk).toBe(true);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("unwraps JSON-RPC envelope in SSE", async () => {
|
|
597
|
+
const inner = {
|
|
598
|
+
status_update: {
|
|
599
|
+
task_id: "t1",
|
|
600
|
+
context_id: "ctx-1",
|
|
601
|
+
status: { state: "TASK_STATE_COMPLETED" },
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
const sseData = JSON.stringify({
|
|
605
|
+
jsonrpc: "2.0",
|
|
606
|
+
id: 1,
|
|
607
|
+
result: inner,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
fetchMock.mockResolvedValue(
|
|
611
|
+
mockSSEResponse([`data: ${sseData}`, "", ""]),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const events: A2AStreamEvent[] = [];
|
|
615
|
+
for await (const event of client.streamMessage(userMessage)) {
|
|
616
|
+
events.push(event);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
expect(events).toHaveLength(1);
|
|
620
|
+
expect(events[0]!.type).toBe("statusUpdate");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("parses multiple SSE events", async () => {
|
|
624
|
+
const ev1 = JSON.stringify({
|
|
625
|
+
status_update: {
|
|
626
|
+
task_id: "t1",
|
|
627
|
+
context_id: "ctx-1",
|
|
628
|
+
status: { state: "TASK_STATE_WORKING" },
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
const ev2 = JSON.stringify({
|
|
632
|
+
status_update: {
|
|
633
|
+
task_id: "t1",
|
|
634
|
+
context_id: "ctx-1",
|
|
635
|
+
status: { state: "TASK_STATE_COMPLETED" },
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
fetchMock.mockResolvedValue(
|
|
640
|
+
mockSSEResponse([`data: ${ev1}`, "", `data: ${ev2}`, "", ""]),
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
const events: A2AStreamEvent[] = [];
|
|
644
|
+
for await (const event of client.streamMessage(userMessage)) {
|
|
645
|
+
events.push(event);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
expect(events).toHaveLength(2);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("skips malformed SSE events", async () => {
|
|
652
|
+
fetchMock.mockResolvedValue(
|
|
653
|
+
mockSSEResponse(["data: {invalid json}", "", ""]),
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const events: A2AStreamEvent[] = [];
|
|
657
|
+
for await (const event of client.streamMessage(userMessage)) {
|
|
658
|
+
events.push(event);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
expect(events).toHaveLength(0);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// --- Push notification configs ---
|
|
666
|
+
|
|
667
|
+
describe("push notification configs", () => {
|
|
668
|
+
it("createTaskPushNotificationConfig sends POST", async () => {
|
|
669
|
+
fetchMock.mockResolvedValue(
|
|
670
|
+
mockFetchResponse({
|
|
671
|
+
id: "pnc-1",
|
|
672
|
+
taskId: "t1",
|
|
673
|
+
url: "https://hook.test/notify",
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
await client.createTaskPushNotificationConfig({
|
|
678
|
+
taskId: "t1",
|
|
679
|
+
url: "https://hook.test/notify",
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
683
|
+
expect(url).toBe("https://agent.test/tasks/t1/pushNotificationConfigs");
|
|
684
|
+
expect(init.method).toBe("POST");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("getTaskPushNotificationConfig sends GET", async () => {
|
|
688
|
+
fetchMock.mockResolvedValue(
|
|
689
|
+
mockFetchResponse({
|
|
690
|
+
id: "pnc-1",
|
|
691
|
+
taskId: "t1",
|
|
692
|
+
url: "https://hook.test",
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
await client.getTaskPushNotificationConfig("t1", "pnc-1");
|
|
697
|
+
|
|
698
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
699
|
+
expect(url).toBe(
|
|
700
|
+
"https://agent.test/tasks/t1/pushNotificationConfigs/pnc-1",
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("listTaskPushNotificationConfigs with pagination", async () => {
|
|
705
|
+
fetchMock.mockResolvedValue(
|
|
706
|
+
mockFetchResponse({
|
|
707
|
+
configs: [],
|
|
708
|
+
nextPageToken: "tok2",
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
await client.listTaskPushNotificationConfigs("t1", {
|
|
713
|
+
pageSize: 5,
|
|
714
|
+
pageToken: "tok1",
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
718
|
+
expect(url).toContain("/tasks/t1/pushNotificationConfigs?");
|
|
719
|
+
expect(url).toContain("page_size=5");
|
|
720
|
+
expect(url).toContain("page_token=tok1");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("listTaskPushNotificationConfigs without pagination", async () => {
|
|
724
|
+
fetchMock.mockResolvedValue(mockFetchResponse({ configs: [] }));
|
|
725
|
+
|
|
726
|
+
await client.listTaskPushNotificationConfigs("t1");
|
|
727
|
+
|
|
728
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
729
|
+
expect(url).toBe("https://agent.test/tasks/t1/pushNotificationConfigs");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("deleteTaskPushNotificationConfig sends DELETE", async () => {
|
|
733
|
+
fetchMock.mockResolvedValue({
|
|
734
|
+
ok: true,
|
|
735
|
+
status: 204,
|
|
736
|
+
statusText: "No Content",
|
|
737
|
+
headers: new Headers(),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await client.deleteTaskPushNotificationConfig("t1", "pnc-1");
|
|
741
|
+
|
|
742
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
743
|
+
expect(url).toBe(
|
|
744
|
+
"https://agent.test/tasks/t1/pushNotificationConfigs/pnc-1",
|
|
745
|
+
);
|
|
746
|
+
expect(init.method).toBe("DELETE");
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// --- getExtendedAgentCard ---
|
|
751
|
+
|
|
752
|
+
describe("getExtendedAgentCard", () => {
|
|
753
|
+
it("fetches from /extendedAgentCard", async () => {
|
|
754
|
+
fetchMock.mockResolvedValue(
|
|
755
|
+
mockFetchResponse({
|
|
756
|
+
name: "Extended",
|
|
757
|
+
description: "desc",
|
|
758
|
+
version: "1.0",
|
|
759
|
+
supportedInterfaces: [],
|
|
760
|
+
capabilities: {},
|
|
761
|
+
defaultInputModes: [],
|
|
762
|
+
defaultOutputModes: [],
|
|
763
|
+
skills: [],
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
await client.getExtendedAgentCard();
|
|
768
|
+
|
|
769
|
+
const [url] = fetchMock.mock.calls[0]!;
|
|
770
|
+
expect(url).toBe("https://agent.test/extendedAgentCard");
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
});
|