@assistant-ui/react-a2a 0.2.29 → 0.2.30
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.map +1 -1
- package/dist/A2AClient.js +75 -6
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +19 -3
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/conversions.js +2 -2
- package/dist/conversions.js.map +1 -1
- package/package.json +5 -5
- package/src/A2AClient.test.ts +223 -9
- package/src/A2AClient.ts +155 -6
- package/src/A2AThreadRuntimeCore.test.ts +135 -10
- package/src/A2AThreadRuntimeCore.ts +34 -4
- package/src/conversions.test.ts +16 -0
- package/src/conversions.ts +2 -2
- package/src/useA2ARuntime.test.tsx +20 -6
package/dist/conversions.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversions.js","names":[],"sources":["../src/conversions.ts"],"sourcesContent":["\"use client\";\n\nimport type { MessageStatus, ThreadAssistantMessage } from \"@assistant-ui/core\";\nimport { httpUrlPattern, parseDataUrl } from \"@assistant-ui/core/internal\";\nimport type { A2AMessage, A2APart, A2ATaskState } from \"./types\";\n\nfunction isImageMediaType(mediaType?: string): boolean {\n return !!mediaType && mediaType.startsWith(\"image/\");\n}\n\nexport function a2aPartToContent(\n part: A2APart,\n): ThreadAssistantMessage[\"content\"][number] {\n if (part.text !== undefined) {\n return { type: \"text\", text: part.text };\n }\n if (part.url !== undefined) {\n if (isImageMediaType(part.mediaType)) {\n return {\n type: \"image\",\n image: part.url,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n type: \"file\",\n data: part.url,\n mimeType: part.mediaType ?? \"application/octet-stream\",\n sourceType: \"url\",\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (part.raw !== undefined) {\n if (isImageMediaType(part.mediaType)) {\n return {\n type: \"image\",\n image: `data:${part.mediaType};base64,${part.raw}`,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n type: \"file\",\n data: part.raw,\n mimeType: part.mediaType ?? \"application/octet-stream\",\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (part.data !== undefined) {\n return { type: \"text\", text: JSON.stringify(part.data, null, 2) };\n }\n return { type: \"text\", text: \"\" };\n}\n\nexport function a2aPartsToContent(\n parts: A2APart[],\n): ThreadAssistantMessage[\"content\"] {\n return parts.map(a2aPartToContent);\n}\n\nconst TERMINAL_STATES = new Set<A2ATaskState>([\n \"completed\",\n \"failed\",\n \"canceled\",\n \"rejected\",\n]);\n\nconst INTERRUPTED_STATES = new Set<A2ATaskState>([\n \"input_required\",\n \"auth_required\",\n]);\n\nexport function isTerminalTaskState(state: A2ATaskState): boolean {\n return TERMINAL_STATES.has(state);\n}\n\nexport function isInterruptedTaskState(state: A2ATaskState): boolean {\n return INTERRUPTED_STATES.has(state);\n}\n\nexport function taskStateToMessageStatus(state: A2ATaskState): MessageStatus {\n switch (state) {\n case \"submitted\":\n case \"working\":\n return { type: \"running\" };\n case \"completed\":\n return { type: \"complete\", reason: \"stop\" };\n case \"failed\":\n case \"rejected\":\n return { type: \"incomplete\", reason: \"error\" };\n case \"canceled\":\n return { type: \"incomplete\", reason: \"cancelled\" };\n case \"input_required\":\n case \"auth_required\":\n return { type: \"requires-action\", reason: \"interrupt\" };\n default:\n return { type: \"running\" };\n }\n}\n\nexport function contentPartsToA2AParts(\n content: ReadonlyArray<{\n type: string;\n text?: string | undefined;\n image?: string | undefined;\n data?: unknown;\n mimeType?: string | undefined;\n filename?: string | undefined;\n sourceType?: \"url\" | \"id\" | undefined;\n audio?: { data: string; format: string } | undefined;\n }>,\n fallbackMimeType?: string,\n): A2APart[] {\n return content\n .map((part): A2APart | null => {\n switch (part.type) {\n case \"text\":\n return { text: part.text ?? \"\" };\n case \"image\": {\n if (!part.image) return null;\n const parsed = parseDataUrl(part.image);\n if (parsed) {\n return {\n raw: parsed.data,\n mediaType: parsed.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n url: part.image,\n ...(fallbackMimeType && { mediaType: fallbackMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n case \"file\": {\n if (typeof part.data !== \"string\" || !part.data) return null;\n const declaredMimeType = part.mimeType || fallbackMimeType;\n if (part.sourceType === \"url\" || httpUrlPattern.test(part.data)) {\n return {\n url: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n const parsed = parseDataUrl(part.data);\n if (parsed) {\n return {\n raw: parsed.data,\n mediaType: parsed.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (/^data:/i.test(part.data)) {\n return {\n url: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n raw: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n case \"audio\": {\n if (!part.audio) return null;\n return {\n raw: parseDataUrl(part.audio.data)?.data ?? part.audio.data,\n mediaType: `audio/${part.audio.format}`,\n };\n }\n case \"data\": {\n if (part.data === undefined) return null;\n return { data: part.data };\n }\n default:\n return null;\n }\n })\n .filter((p): p is A2APart => p !== null);\n}\n\nexport function a2aMessageToContent(\n message: A2AMessage,\n): ThreadAssistantMessage[\"content\"] {\n return a2aPartsToContent(message
|
|
1
|
+
{"version":3,"file":"conversions.js","names":[],"sources":["../src/conversions.ts"],"sourcesContent":["\"use client\";\n\nimport type { MessageStatus, ThreadAssistantMessage } from \"@assistant-ui/core\";\nimport { httpUrlPattern, parseDataUrl } from \"@assistant-ui/core/internal\";\nimport type { A2AMessage, A2APart, A2ATaskState } from \"./types\";\n\nfunction isImageMediaType(mediaType?: string): boolean {\n return !!mediaType && mediaType.startsWith(\"image/\");\n}\n\nexport function a2aPartToContent(\n part: A2APart,\n): ThreadAssistantMessage[\"content\"][number] {\n if (part.text !== undefined) {\n return { type: \"text\", text: part.text };\n }\n if (part.url !== undefined) {\n if (isImageMediaType(part.mediaType)) {\n return {\n type: \"image\",\n image: part.url,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n type: \"file\",\n data: part.url,\n mimeType: part.mediaType ?? \"application/octet-stream\",\n sourceType: \"url\",\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (part.raw !== undefined) {\n if (isImageMediaType(part.mediaType)) {\n return {\n type: \"image\",\n image: `data:${part.mediaType};base64,${part.raw}`,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n type: \"file\",\n data: part.raw,\n mimeType: part.mediaType ?? \"application/octet-stream\",\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (part.data !== undefined) {\n return { type: \"text\", text: JSON.stringify(part.data, null, 2) };\n }\n return { type: \"text\", text: \"\" };\n}\n\nexport function a2aPartsToContent(\n parts: A2APart[],\n): ThreadAssistantMessage[\"content\"] {\n return (Array.isArray(parts) ? parts : []).map(a2aPartToContent);\n}\n\nconst TERMINAL_STATES = new Set<A2ATaskState>([\n \"completed\",\n \"failed\",\n \"canceled\",\n \"rejected\",\n]);\n\nconst INTERRUPTED_STATES = new Set<A2ATaskState>([\n \"input_required\",\n \"auth_required\",\n]);\n\nexport function isTerminalTaskState(state: A2ATaskState): boolean {\n return TERMINAL_STATES.has(state);\n}\n\nexport function isInterruptedTaskState(state: A2ATaskState): boolean {\n return INTERRUPTED_STATES.has(state);\n}\n\nexport function taskStateToMessageStatus(state: A2ATaskState): MessageStatus {\n switch (state) {\n case \"submitted\":\n case \"working\":\n return { type: \"running\" };\n case \"completed\":\n return { type: \"complete\", reason: \"stop\" };\n case \"failed\":\n case \"rejected\":\n return { type: \"incomplete\", reason: \"error\" };\n case \"canceled\":\n return { type: \"incomplete\", reason: \"cancelled\" };\n case \"input_required\":\n case \"auth_required\":\n return { type: \"requires-action\", reason: \"interrupt\" };\n default:\n return { type: \"running\" };\n }\n}\n\nexport function contentPartsToA2AParts(\n content: ReadonlyArray<{\n type: string;\n text?: string | undefined;\n image?: string | undefined;\n data?: unknown;\n mimeType?: string | undefined;\n filename?: string | undefined;\n sourceType?: \"url\" | \"id\" | undefined;\n audio?: { data: string; format: string } | undefined;\n }>,\n fallbackMimeType?: string,\n): A2APart[] {\n return content\n .map((part): A2APart | null => {\n switch (part.type) {\n case \"text\":\n return { text: part.text ?? \"\" };\n case \"image\": {\n if (!part.image) return null;\n const parsed = parseDataUrl(part.image);\n if (parsed) {\n return {\n raw: parsed.data,\n mediaType: parsed.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n url: part.image,\n ...(fallbackMimeType && { mediaType: fallbackMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n case \"file\": {\n if (typeof part.data !== \"string\" || !part.data) return null;\n const declaredMimeType = part.mimeType || fallbackMimeType;\n if (part.sourceType === \"url\" || httpUrlPattern.test(part.data)) {\n return {\n url: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n const parsed = parseDataUrl(part.data);\n if (parsed) {\n return {\n raw: parsed.data,\n mediaType: parsed.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n }\n if (/^data:/i.test(part.data)) {\n return {\n url: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n return {\n raw: part.data,\n ...(declaredMimeType && { mediaType: declaredMimeType }),\n ...(part.filename && { filename: part.filename }),\n };\n }\n case \"audio\": {\n if (!part.audio) return null;\n return {\n raw: parseDataUrl(part.audio.data)?.data ?? part.audio.data,\n mediaType: `audio/${part.audio.format}`,\n };\n }\n case \"data\": {\n if (part.data === undefined) return null;\n return { data: part.data };\n }\n default:\n return null;\n }\n })\n .filter((p): p is A2APart => p !== null);\n}\n\nexport function a2aMessageToContent(\n message: A2AMessage,\n): ThreadAssistantMessage[\"content\"] {\n return a2aPartsToContent(message?.parts ?? []);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-a2a",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.30",
|
|
4
4
|
"description": "A2A (Agent-to-Agent) v1.0 protocol adapter for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"a2a",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
],
|
|
30
30
|
"sideEffects": false,
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@assistant-ui/core": "^0.3.
|
|
33
|
-
"@assistant-ui/store": "^0.3.
|
|
34
|
-
"assistant-stream": "^0.3.
|
|
32
|
+
"@assistant-ui/core": "^0.3.14",
|
|
33
|
+
"@assistant-ui/store": "^0.3.10",
|
|
34
|
+
"assistant-stream": "^0.3.38"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@types/react": "*",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"react": "^19.2.8",
|
|
51
51
|
"react-dom": "^19.2.8",
|
|
52
52
|
"vitest": "^4.1.10",
|
|
53
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
53
|
+
"@assistant-ui/x-buildutils": "0.0.23"
|
|
54
54
|
},
|
|
55
55
|
"publishConfig": {
|
|
56
56
|
"access": "public",
|
package/src/A2AClient.test.ts
CHANGED
|
@@ -734,6 +734,14 @@ describe("A2AClient", () => {
|
|
|
734
734
|
const [url] = fetchMock.mock.calls[0]!;
|
|
735
735
|
expect(url).toBe("https://agent.test/tasks/t1?history_length=5");
|
|
736
736
|
});
|
|
737
|
+
|
|
738
|
+
it("rejects malformed successful responses", async () => {
|
|
739
|
+
fetchMock.mockResolvedValue(mockFetchResponse({}));
|
|
740
|
+
|
|
741
|
+
await expect(client.getTask("t1")).rejects.toThrow(
|
|
742
|
+
"Invalid A2A tasks:get response: expected a valid task payload.",
|
|
743
|
+
);
|
|
744
|
+
});
|
|
737
745
|
});
|
|
738
746
|
|
|
739
747
|
// --- listTasks ---
|
|
@@ -761,6 +769,65 @@ describe("A2AClient", () => {
|
|
|
761
769
|
expect(url).toContain("status=TASK_STATE_WORKING");
|
|
762
770
|
expect(url).toContain("page_size=10");
|
|
763
771
|
});
|
|
772
|
+
|
|
773
|
+
it("rejects malformed successful responses", async () => {
|
|
774
|
+
fetchMock.mockResolvedValue(mockFetchResponse({ tasks: 42 }));
|
|
775
|
+
|
|
776
|
+
await expect(client.listTasks()).rejects.toThrow(
|
|
777
|
+
"Invalid A2A tasks:list response: expected a valid task list payload.",
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("normalizes empty responses", async () => {
|
|
782
|
+
fetchMock.mockResolvedValue(mockFetchResponse({}));
|
|
783
|
+
|
|
784
|
+
await expect(client.listTasks()).resolves.toEqual({
|
|
785
|
+
tasks: [],
|
|
786
|
+
nextPageToken: "",
|
|
787
|
+
pageSize: 0,
|
|
788
|
+
totalSize: 0,
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("rejects malformed tasks in successful responses", async () => {
|
|
793
|
+
fetchMock.mockResolvedValue(
|
|
794
|
+
mockFetchResponse({
|
|
795
|
+
tasks: [{}],
|
|
796
|
+
nextPageToken: "",
|
|
797
|
+
pageSize: 1,
|
|
798
|
+
totalSize: 1,
|
|
799
|
+
}),
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
await expect(client.listTasks()).rejects.toThrow(
|
|
803
|
+
"Invalid A2A tasks:list response: expected a valid task list payload.",
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("normalizes omitted pagination defaults", async () => {
|
|
808
|
+
const tasks = [{ id: "t1", status: { state: "completed" } }];
|
|
809
|
+
fetchMock.mockResolvedValue(mockFetchResponse({ tasks }));
|
|
810
|
+
|
|
811
|
+
await expect(client.listTasks()).resolves.toEqual({
|
|
812
|
+
tasks,
|
|
813
|
+
nextPageToken: "",
|
|
814
|
+
pageSize: 0,
|
|
815
|
+
totalSize: 0,
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("rejects malformed pagination fields", async () => {
|
|
820
|
+
fetchMock.mockResolvedValue(
|
|
821
|
+
mockFetchResponse({
|
|
822
|
+
tasks: [],
|
|
823
|
+
nextPageToken: 42,
|
|
824
|
+
}),
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
await expect(client.listTasks()).rejects.toThrow(
|
|
828
|
+
"Invalid A2A tasks:list response: expected a valid task list payload.",
|
|
829
|
+
);
|
|
830
|
+
});
|
|
764
831
|
});
|
|
765
832
|
|
|
766
833
|
// --- cancelTask ---
|
|
@@ -788,6 +855,14 @@ describe("A2AClient", () => {
|
|
|
788
855
|
const body = JSON.parse(fetchMock.mock.calls[0]![1].body);
|
|
789
856
|
expect(body.metadata).toEqual({ reason: "user requested" });
|
|
790
857
|
});
|
|
858
|
+
|
|
859
|
+
it("rejects malformed successful responses", async () => {
|
|
860
|
+
fetchMock.mockResolvedValue(mockFetchResponse({}));
|
|
861
|
+
|
|
862
|
+
await expect(client.cancelTask("t1")).rejects.toThrow(
|
|
863
|
+
"Invalid A2A tasks:cancel response: expected a valid task payload.",
|
|
864
|
+
);
|
|
865
|
+
});
|
|
791
866
|
});
|
|
792
867
|
|
|
793
868
|
// --- getAgentCard ---
|
|
@@ -1221,12 +1296,13 @@ describe("A2AClient", () => {
|
|
|
1221
1296
|
expect(events).toHaveLength(1);
|
|
1222
1297
|
});
|
|
1223
1298
|
|
|
1224
|
-
it("rejects successful responses that are not event streams", async () => {
|
|
1299
|
+
it("rejects and cancels successful responses that are not event streams", async () => {
|
|
1300
|
+
const cancel = vi.fn();
|
|
1301
|
+
const body = new ReadableStream<Uint8Array>({ cancel });
|
|
1225
1302
|
fetchMock.mockResolvedValue(
|
|
1226
|
-
|
|
1227
|
-
"
|
|
1228
|
-
|
|
1229
|
-
),
|
|
1303
|
+
new Response(body, {
|
|
1304
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1305
|
+
}),
|
|
1230
1306
|
);
|
|
1231
1307
|
|
|
1232
1308
|
const consumeStream = async () => {
|
|
@@ -1238,10 +1314,13 @@ describe("A2AClient", () => {
|
|
|
1238
1314
|
await expect(consumeStream()).rejects.toThrow(
|
|
1239
1315
|
'Expected A2A stream response Content-Type "text/event-stream", received "text/html; charset=utf-8"',
|
|
1240
1316
|
);
|
|
1317
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
1241
1318
|
});
|
|
1242
1319
|
|
|
1243
1320
|
it("rejects task subscriptions without a content type", async () => {
|
|
1244
|
-
fetchMock.mockResolvedValue(
|
|
1321
|
+
fetchMock.mockResolvedValue(
|
|
1322
|
+
new Response(new ReadableStream<Uint8Array>()),
|
|
1323
|
+
);
|
|
1245
1324
|
|
|
1246
1325
|
const consumeStream = async () => {
|
|
1247
1326
|
for await (const event of client.subscribeToTask("t1")) {
|
|
@@ -1322,9 +1401,34 @@ describe("A2AClient", () => {
|
|
|
1322
1401
|
expect((evt.event.status.message as any)?.content).toBeUndefined();
|
|
1323
1402
|
});
|
|
1324
1403
|
|
|
1325
|
-
it("skips malformed SSE events", async () => {
|
|
1404
|
+
it("skips malformed and unrecognized SSE events", async () => {
|
|
1405
|
+
const first = JSON.stringify({
|
|
1406
|
+
status_update: {
|
|
1407
|
+
task_id: "t1",
|
|
1408
|
+
context_id: "ctx-1",
|
|
1409
|
+
status: { state: "TASK_STATE_WORKING" },
|
|
1410
|
+
},
|
|
1411
|
+
});
|
|
1412
|
+
const second = JSON.stringify({
|
|
1413
|
+
status_update: {
|
|
1414
|
+
task_id: "t1",
|
|
1415
|
+
context_id: "ctx-1",
|
|
1416
|
+
status: { state: "TASK_STATE_COMPLETED" },
|
|
1417
|
+
},
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1326
1420
|
fetchMock.mockResolvedValue(
|
|
1327
|
-
mockSSEResponse([
|
|
1421
|
+
mockSSEResponse([
|
|
1422
|
+
`data: ${first}`,
|
|
1423
|
+
"",
|
|
1424
|
+
"data: {invalid json}",
|
|
1425
|
+
"",
|
|
1426
|
+
"data: {}",
|
|
1427
|
+
"",
|
|
1428
|
+
`data: ${second}`,
|
|
1429
|
+
"",
|
|
1430
|
+
"",
|
|
1431
|
+
]),
|
|
1328
1432
|
);
|
|
1329
1433
|
|
|
1330
1434
|
const events: A2AStreamEvent[] = [];
|
|
@@ -1332,7 +1436,11 @@ describe("A2AClient", () => {
|
|
|
1332
1436
|
events.push(event);
|
|
1333
1437
|
}
|
|
1334
1438
|
|
|
1335
|
-
expect(events).toHaveLength(
|
|
1439
|
+
expect(events).toHaveLength(2);
|
|
1440
|
+
expect(events.map((event) => event.type)).toEqual([
|
|
1441
|
+
"statusUpdate",
|
|
1442
|
+
"statusUpdate",
|
|
1443
|
+
]);
|
|
1336
1444
|
});
|
|
1337
1445
|
});
|
|
1338
1446
|
|
|
@@ -1403,6 +1511,112 @@ describe("A2AClient", () => {
|
|
|
1403
1511
|
expect(url).toBe("https://agent.test/tasks/t1/pushNotificationConfigs");
|
|
1404
1512
|
});
|
|
1405
1513
|
|
|
1514
|
+
it.each([
|
|
1515
|
+
[
|
|
1516
|
+
"pushNotificationConfigs:create",
|
|
1517
|
+
() =>
|
|
1518
|
+
client.createTaskPushNotificationConfig({
|
|
1519
|
+
taskId: "t1",
|
|
1520
|
+
url: "https://hook.test",
|
|
1521
|
+
}),
|
|
1522
|
+
],
|
|
1523
|
+
[
|
|
1524
|
+
"pushNotificationConfigs:get",
|
|
1525
|
+
() => client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1526
|
+
],
|
|
1527
|
+
])("rejects malformed %s responses", async (operation, request) => {
|
|
1528
|
+
fetchMock.mockResolvedValue(mockFetchResponse({}));
|
|
1529
|
+
|
|
1530
|
+
await expect(request()).rejects.toThrow(
|
|
1531
|
+
`Invalid A2A ${operation} response`,
|
|
1532
|
+
);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it.each([
|
|
1536
|
+
{ configs: {} },
|
|
1537
|
+
{ configs: [{}] },
|
|
1538
|
+
{ configs: [], nextPageToken: 42 },
|
|
1539
|
+
])("rejects malformed list responses", async (response) => {
|
|
1540
|
+
fetchMock.mockResolvedValue(mockFetchResponse(response));
|
|
1541
|
+
|
|
1542
|
+
await expect(
|
|
1543
|
+
client.listTaskPushNotificationConfigs("t1"),
|
|
1544
|
+
).rejects.toThrow("Invalid A2A pushNotificationConfigs:list response");
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
it.each([{}, { configs: null, nextPageToken: null }])(
|
|
1548
|
+
"normalizes omitted ProtoJSON list defaults",
|
|
1549
|
+
async (response) => {
|
|
1550
|
+
fetchMock.mockResolvedValue(mockFetchResponse(response));
|
|
1551
|
+
|
|
1552
|
+
await expect(
|
|
1553
|
+
client.listTaskPushNotificationConfigs("t1"),
|
|
1554
|
+
).resolves.toEqual({
|
|
1555
|
+
configs: [],
|
|
1556
|
+
});
|
|
1557
|
+
},
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
it("normalizes an omitted authentication scheme", async () => {
|
|
1561
|
+
fetchMock.mockResolvedValue(
|
|
1562
|
+
mockFetchResponse({
|
|
1563
|
+
url: "https://hook.test",
|
|
1564
|
+
authentication: { credentials: "secret" },
|
|
1565
|
+
}),
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
await expect(
|
|
1569
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1570
|
+
).resolves.toEqual({
|
|
1571
|
+
url: "https://hook.test",
|
|
1572
|
+
authentication: { scheme: "", credentials: "secret" },
|
|
1573
|
+
});
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it("rejects malformed nested authentication responses", async () => {
|
|
1577
|
+
fetchMock.mockResolvedValue(
|
|
1578
|
+
mockFetchResponse({
|
|
1579
|
+
url: "https://hook.test",
|
|
1580
|
+
authentication: { scheme: 42 },
|
|
1581
|
+
}),
|
|
1582
|
+
);
|
|
1583
|
+
|
|
1584
|
+
await expect(
|
|
1585
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1586
|
+
).rejects.toThrow("Invalid A2A pushNotificationConfigs:get response");
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
it("accepts valid nested authentication responses", async () => {
|
|
1590
|
+
const config = {
|
|
1591
|
+
url: "https://hook.test",
|
|
1592
|
+
authentication: { scheme: "Bearer", credentials: "secret" },
|
|
1593
|
+
};
|
|
1594
|
+
fetchMock.mockResolvedValue(mockFetchResponse(config));
|
|
1595
|
+
|
|
1596
|
+
await expect(
|
|
1597
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1598
|
+
).resolves.toEqual(config);
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it("normalizes nullable optional config fields", async () => {
|
|
1602
|
+
fetchMock.mockResolvedValue(
|
|
1603
|
+
mockFetchResponse({
|
|
1604
|
+
tenant: null,
|
|
1605
|
+
id: null,
|
|
1606
|
+
taskId: null,
|
|
1607
|
+
url: "https://hook.test",
|
|
1608
|
+
token: null,
|
|
1609
|
+
authentication: null,
|
|
1610
|
+
}),
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
await expect(
|
|
1614
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1615
|
+
).resolves.toEqual({
|
|
1616
|
+
url: "https://hook.test",
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1406
1620
|
it("deleteTaskPushNotificationConfig sends DELETE", async () => {
|
|
1407
1621
|
fetchMock.mockResolvedValue({
|
|
1408
1622
|
ok: true,
|
package/src/A2AClient.ts
CHANGED
|
@@ -272,6 +272,142 @@ const parseSendMessageResponse = (value: unknown): A2ATask | A2AMessage => {
|
|
|
272
272
|
);
|
|
273
273
|
};
|
|
274
274
|
|
|
275
|
+
const parseTaskResponse = (
|
|
276
|
+
value: unknown,
|
|
277
|
+
operation: "tasks:get" | "tasks:cancel",
|
|
278
|
+
): A2ATask => {
|
|
279
|
+
if (isTask(value)) return value;
|
|
280
|
+
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Invalid A2A ${operation} response: expected a valid task payload.`,
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const isNonNegativeInteger = (value: unknown): value is number =>
|
|
287
|
+
typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
288
|
+
|
|
289
|
+
const invalidListTasksResponse = (): never => {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"Invalid A2A tasks:list response: expected a valid task list payload.",
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const parseListTasksResponse = (value: unknown): A2AListTasksResponse => {
|
|
296
|
+
if (!isRecord(value)) return invalidListTasksResponse();
|
|
297
|
+
|
|
298
|
+
const tasks = value.tasks ?? [];
|
|
299
|
+
if (!Array.isArray(tasks) || !tasks.every(isTask)) {
|
|
300
|
+
return invalidListTasksResponse();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { nextPageToken, pageSize, totalSize } = value;
|
|
304
|
+
if (
|
|
305
|
+
(nextPageToken != null && typeof nextPageToken !== "string") ||
|
|
306
|
+
(pageSize != null && !isNonNegativeInteger(pageSize)) ||
|
|
307
|
+
(totalSize != null && !isNonNegativeInteger(totalSize))
|
|
308
|
+
) {
|
|
309
|
+
return invalidListTasksResponse();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
...value,
|
|
314
|
+
tasks,
|
|
315
|
+
nextPageToken: nextPageToken ?? "",
|
|
316
|
+
pageSize: pageSize ?? 0,
|
|
317
|
+
totalSize: totalSize ?? 0,
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const invalidPushNotificationConfigResponse =
|
|
322
|
+
(
|
|
323
|
+
operation: "pushNotificationConfigs:create" | "pushNotificationConfigs:get",
|
|
324
|
+
) =>
|
|
325
|
+
(): never => {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Invalid A2A ${operation} response: expected a valid push notification config payload.`,
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const invalidListPushNotificationConfigsResponse = (): never => {
|
|
332
|
+
throw new Error(
|
|
333
|
+
"Invalid A2A pushNotificationConfigs:list response: expected a valid push notification config list payload.",
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const parseOptionalString = (
|
|
338
|
+
value: unknown,
|
|
339
|
+
invalid: () => never,
|
|
340
|
+
): string | undefined =>
|
|
341
|
+
value == null ? undefined : typeof value === "string" ? value : invalid();
|
|
342
|
+
|
|
343
|
+
const parseTaskPushNotificationConfigResponse = (
|
|
344
|
+
value: unknown,
|
|
345
|
+
invalid: () => never,
|
|
346
|
+
): A2ATaskPushNotificationConfig => {
|
|
347
|
+
if (
|
|
348
|
+
!isRecord(value) ||
|
|
349
|
+
typeof value.url !== "string" ||
|
|
350
|
+
value.url.length === 0
|
|
351
|
+
) {
|
|
352
|
+
return invalid();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { tenant, id, taskId, url, token, authentication, ...extra } = value;
|
|
356
|
+
let normalizedAuthentication: A2ATaskPushNotificationConfig["authentication"];
|
|
357
|
+
if (authentication != null) {
|
|
358
|
+
if (!isRecord(authentication)) return invalid();
|
|
359
|
+
const { scheme, credentials, ...authenticationExtra } = authentication;
|
|
360
|
+
normalizedAuthentication = {
|
|
361
|
+
...authenticationExtra,
|
|
362
|
+
scheme: parseOptionalString(scheme, invalid) ?? "",
|
|
363
|
+
...(credentials == null
|
|
364
|
+
? {}
|
|
365
|
+
: { credentials: parseOptionalString(credentials, invalid) }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
...extra,
|
|
371
|
+
...(tenant == null ? {} : { tenant: parseOptionalString(tenant, invalid) }),
|
|
372
|
+
...(id == null ? {} : { id: parseOptionalString(id, invalid) }),
|
|
373
|
+
...(taskId == null ? {} : { taskId: parseOptionalString(taskId, invalid) }),
|
|
374
|
+
url,
|
|
375
|
+
...(token == null ? {} : { token: parseOptionalString(token, invalid) }),
|
|
376
|
+
...(normalizedAuthentication === undefined
|
|
377
|
+
? {}
|
|
378
|
+
: { authentication: normalizedAuthentication }),
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const parseListTaskPushNotificationConfigsResponse = (
|
|
383
|
+
value: unknown,
|
|
384
|
+
): A2AListTaskPushNotificationConfigsResponse => {
|
|
385
|
+
if (!isRecord(value)) return invalidListPushNotificationConfigsResponse();
|
|
386
|
+
|
|
387
|
+
const { configs: rawConfigs, nextPageToken, ...extra } = value;
|
|
388
|
+
if (rawConfigs != null && !Array.isArray(rawConfigs)) {
|
|
389
|
+
return invalidListPushNotificationConfigsResponse();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
...extra,
|
|
394
|
+
configs: (rawConfigs ?? []).map((config) =>
|
|
395
|
+
parseTaskPushNotificationConfigResponse(
|
|
396
|
+
config,
|
|
397
|
+
invalidListPushNotificationConfigsResponse,
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
...(nextPageToken == null
|
|
401
|
+
? {}
|
|
402
|
+
: {
|
|
403
|
+
nextPageToken: parseOptionalString(
|
|
404
|
+
nextPageToken,
|
|
405
|
+
invalidListPushNotificationConfigsResponse,
|
|
406
|
+
),
|
|
407
|
+
}),
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
|
|
275
411
|
function signalInit(signal?: AbortSignal): RequestInit {
|
|
276
412
|
return signal ? { signal } : {};
|
|
277
413
|
}
|
|
@@ -474,10 +610,11 @@ export class A2AClient {
|
|
|
474
610
|
params.set("history_length", String(historyLength));
|
|
475
611
|
}
|
|
476
612
|
const qs = params.toString();
|
|
477
|
-
|
|
613
|
+
const result = await this.fetchJSON<unknown>(
|
|
478
614
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}${qs ? `?${qs}` : ""}`,
|
|
479
615
|
signalInit(signal),
|
|
480
616
|
);
|
|
617
|
+
return parseTaskResponse(result, "tasks:get");
|
|
481
618
|
}
|
|
482
619
|
|
|
483
620
|
async listTasks(
|
|
@@ -497,10 +634,11 @@ export class A2AClient {
|
|
|
497
634
|
if (request?.includeArtifacts !== undefined)
|
|
498
635
|
params.set("include_artifacts", String(request.includeArtifacts));
|
|
499
636
|
const qs = params.toString();
|
|
500
|
-
|
|
637
|
+
const result = await this.fetchJSON<unknown>(
|
|
501
638
|
`${this.getBasePath()}/tasks${qs ? `?${qs}` : ""}`,
|
|
502
639
|
signalInit(signal),
|
|
503
640
|
);
|
|
641
|
+
return parseListTasksResponse(result);
|
|
504
642
|
}
|
|
505
643
|
|
|
506
644
|
async cancelTask(
|
|
@@ -509,7 +647,7 @@ export class A2AClient {
|
|
|
509
647
|
signal?: AbortSignal,
|
|
510
648
|
): Promise<A2ATask> {
|
|
511
649
|
const body = metadata ? { metadata } : {};
|
|
512
|
-
|
|
650
|
+
const result = await this.fetchJSON<unknown>(
|
|
513
651
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}:cancel`,
|
|
514
652
|
{
|
|
515
653
|
method: "POST",
|
|
@@ -517,6 +655,7 @@ export class A2AClient {
|
|
|
517
655
|
...signalInit(signal),
|
|
518
656
|
},
|
|
519
657
|
);
|
|
658
|
+
return parseTaskResponse(result, "tasks:cancel");
|
|
520
659
|
}
|
|
521
660
|
|
|
522
661
|
async *subscribeToTask(
|
|
@@ -549,7 +688,7 @@ export class A2AClient {
|
|
|
549
688
|
): Promise<A2ATaskPushNotificationConfig> {
|
|
550
689
|
const taskId = config.taskId;
|
|
551
690
|
if (!taskId) throw new Error("taskId is required");
|
|
552
|
-
|
|
691
|
+
const result = await this.fetchJSON<unknown>(
|
|
553
692
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs`,
|
|
554
693
|
{
|
|
555
694
|
method: "POST",
|
|
@@ -557,6 +696,10 @@ export class A2AClient {
|
|
|
557
696
|
...signalInit(signal),
|
|
558
697
|
},
|
|
559
698
|
);
|
|
699
|
+
return parseTaskPushNotificationConfigResponse(
|
|
700
|
+
result,
|
|
701
|
+
invalidPushNotificationConfigResponse("pushNotificationConfigs:create"),
|
|
702
|
+
);
|
|
560
703
|
}
|
|
561
704
|
|
|
562
705
|
async getTaskPushNotificationConfig(
|
|
@@ -564,10 +707,14 @@ export class A2AClient {
|
|
|
564
707
|
configId: string,
|
|
565
708
|
signal?: AbortSignal,
|
|
566
709
|
): Promise<A2ATaskPushNotificationConfig> {
|
|
567
|
-
|
|
710
|
+
const result = await this.fetchJSON<unknown>(
|
|
568
711
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs/${encodeURIComponent(configId)}`,
|
|
569
712
|
signalInit(signal),
|
|
570
713
|
);
|
|
714
|
+
return parseTaskPushNotificationConfigResponse(
|
|
715
|
+
result,
|
|
716
|
+
invalidPushNotificationConfigResponse("pushNotificationConfigs:get"),
|
|
717
|
+
);
|
|
571
718
|
}
|
|
572
719
|
|
|
573
720
|
async listTaskPushNotificationConfigs(
|
|
@@ -580,10 +727,11 @@ export class A2AClient {
|
|
|
580
727
|
params.set("page_size", String(options.pageSize));
|
|
581
728
|
if (options?.pageToken) params.set("page_token", options.pageToken);
|
|
582
729
|
const qs = params.toString();
|
|
583
|
-
|
|
730
|
+
const result = await this.fetchJSON<unknown>(
|
|
584
731
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs${qs ? `?${qs}` : ""}`,
|
|
585
732
|
signalInit(signal),
|
|
586
733
|
);
|
|
734
|
+
return parseListTaskPushNotificationConfigsResponse(result);
|
|
587
735
|
}
|
|
588
736
|
|
|
589
737
|
async deleteTaskPushNotificationConfig(
|
|
@@ -616,6 +764,7 @@ export class A2AClient {
|
|
|
616
764
|
const received = contentType
|
|
617
765
|
? `"${contentType}"`
|
|
618
766
|
: "no Content-Type header";
|
|
767
|
+
void response.body?.cancel().catch(() => undefined);
|
|
619
768
|
throw new Error(
|
|
620
769
|
`Expected A2A stream response Content-Type "text/event-stream", received ${received}`,
|
|
621
770
|
);
|