@assistant-ui/react-a2a 0.2.29 → 0.2.31
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 +91 -9
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +25 -4
- 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 +273 -9
- package/src/A2AClient.ts +185 -10
- package/src/A2AThreadRuntimeCore.test.ts +175 -7
- package/src/A2AThreadRuntimeCore.ts +45 -5
- 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.31",
|
|
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.15",
|
|
33
|
+
"@assistant-ui/store": "^0.3.10",
|
|
34
|
+
"assistant-stream": "^0.3.39"
|
|
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,61 @@ 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
|
+
]);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it("returns the first skip reason when a frame fails to parse", async () => {
|
|
1447
|
+
fetchMock.mockResolvedValue(
|
|
1448
|
+
mockSSEResponse(["data: {invalid json}", "", "data: {}", "", ""]),
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
const stream = client.streamMessage(userMessage);
|
|
1452
|
+
const events: A2AStreamEvent[] = [];
|
|
1453
|
+
let result = await stream.next();
|
|
1454
|
+
while (!result.done) {
|
|
1455
|
+
events.push(result.value);
|
|
1456
|
+
result = await stream.next();
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
let parseMessage = "";
|
|
1460
|
+
try {
|
|
1461
|
+
JSON.parse("{invalid json}");
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
parseMessage = (error as Error).message;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
expect(events).toEqual([]);
|
|
1467
|
+
expect(result.value).toBe(`${parseMessage} (frame: {invalid json})`);
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it("returns an unrecognized-shape skip reason with the frame on one line", async () => {
|
|
1471
|
+
fetchMock.mockResolvedValue(
|
|
1472
|
+
mockSSEResponse(["data: {", "data: }", "", ""]),
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
const stream = client.streamMessage(userMessage);
|
|
1476
|
+
const result = await stream.next();
|
|
1477
|
+
|
|
1478
|
+
expect(result).toEqual({
|
|
1479
|
+
done: true,
|
|
1480
|
+
value: "unrecognized event shape (frame: { })",
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it("truncates an oversized skipped frame", async () => {
|
|
1485
|
+
const frame = `{"nonsense":"${"x".repeat(400)}"}`;
|
|
1486
|
+
fetchMock.mockResolvedValue(mockSSEResponse([`data: ${frame}`, "", ""]));
|
|
1487
|
+
|
|
1488
|
+
const stream = client.streamMessage(userMessage);
|
|
1489
|
+
const result = await stream.next();
|
|
1490
|
+
|
|
1491
|
+
expect(result.value).toBe(
|
|
1492
|
+
`unrecognized event shape (frame: ${frame.slice(0, 120)}…)`,
|
|
1493
|
+
);
|
|
1336
1494
|
});
|
|
1337
1495
|
});
|
|
1338
1496
|
|
|
@@ -1403,6 +1561,112 @@ describe("A2AClient", () => {
|
|
|
1403
1561
|
expect(url).toBe("https://agent.test/tasks/t1/pushNotificationConfigs");
|
|
1404
1562
|
});
|
|
1405
1563
|
|
|
1564
|
+
it.each([
|
|
1565
|
+
[
|
|
1566
|
+
"pushNotificationConfigs:create",
|
|
1567
|
+
() =>
|
|
1568
|
+
client.createTaskPushNotificationConfig({
|
|
1569
|
+
taskId: "t1",
|
|
1570
|
+
url: "https://hook.test",
|
|
1571
|
+
}),
|
|
1572
|
+
],
|
|
1573
|
+
[
|
|
1574
|
+
"pushNotificationConfigs:get",
|
|
1575
|
+
() => client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1576
|
+
],
|
|
1577
|
+
])("rejects malformed %s responses", async (operation, request) => {
|
|
1578
|
+
fetchMock.mockResolvedValue(mockFetchResponse({}));
|
|
1579
|
+
|
|
1580
|
+
await expect(request()).rejects.toThrow(
|
|
1581
|
+
`Invalid A2A ${operation} response`,
|
|
1582
|
+
);
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
it.each([
|
|
1586
|
+
{ configs: {} },
|
|
1587
|
+
{ configs: [{}] },
|
|
1588
|
+
{ configs: [], nextPageToken: 42 },
|
|
1589
|
+
])("rejects malformed list responses", async (response) => {
|
|
1590
|
+
fetchMock.mockResolvedValue(mockFetchResponse(response));
|
|
1591
|
+
|
|
1592
|
+
await expect(
|
|
1593
|
+
client.listTaskPushNotificationConfigs("t1"),
|
|
1594
|
+
).rejects.toThrow("Invalid A2A pushNotificationConfigs:list response");
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it.each([{}, { configs: null, nextPageToken: null }])(
|
|
1598
|
+
"normalizes omitted ProtoJSON list defaults",
|
|
1599
|
+
async (response) => {
|
|
1600
|
+
fetchMock.mockResolvedValue(mockFetchResponse(response));
|
|
1601
|
+
|
|
1602
|
+
await expect(
|
|
1603
|
+
client.listTaskPushNotificationConfigs("t1"),
|
|
1604
|
+
).resolves.toEqual({
|
|
1605
|
+
configs: [],
|
|
1606
|
+
});
|
|
1607
|
+
},
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
it("normalizes an omitted authentication scheme", async () => {
|
|
1611
|
+
fetchMock.mockResolvedValue(
|
|
1612
|
+
mockFetchResponse({
|
|
1613
|
+
url: "https://hook.test",
|
|
1614
|
+
authentication: { credentials: "secret" },
|
|
1615
|
+
}),
|
|
1616
|
+
);
|
|
1617
|
+
|
|
1618
|
+
await expect(
|
|
1619
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1620
|
+
).resolves.toEqual({
|
|
1621
|
+
url: "https://hook.test",
|
|
1622
|
+
authentication: { scheme: "", credentials: "secret" },
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it("rejects malformed nested authentication responses", async () => {
|
|
1627
|
+
fetchMock.mockResolvedValue(
|
|
1628
|
+
mockFetchResponse({
|
|
1629
|
+
url: "https://hook.test",
|
|
1630
|
+
authentication: { scheme: 42 },
|
|
1631
|
+
}),
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
await expect(
|
|
1635
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1636
|
+
).rejects.toThrow("Invalid A2A pushNotificationConfigs:get response");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
it("accepts valid nested authentication responses", async () => {
|
|
1640
|
+
const config = {
|
|
1641
|
+
url: "https://hook.test",
|
|
1642
|
+
authentication: { scheme: "Bearer", credentials: "secret" },
|
|
1643
|
+
};
|
|
1644
|
+
fetchMock.mockResolvedValue(mockFetchResponse(config));
|
|
1645
|
+
|
|
1646
|
+
await expect(
|
|
1647
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1648
|
+
).resolves.toEqual(config);
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
it("normalizes nullable optional config fields", async () => {
|
|
1652
|
+
fetchMock.mockResolvedValue(
|
|
1653
|
+
mockFetchResponse({
|
|
1654
|
+
tenant: null,
|
|
1655
|
+
id: null,
|
|
1656
|
+
taskId: null,
|
|
1657
|
+
url: "https://hook.test",
|
|
1658
|
+
token: null,
|
|
1659
|
+
authentication: null,
|
|
1660
|
+
}),
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
await expect(
|
|
1664
|
+
client.getTaskPushNotificationConfig("t1", "pnc-1"),
|
|
1665
|
+
).resolves.toEqual({
|
|
1666
|
+
url: "https://hook.test",
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1406
1670
|
it("deleteTaskPushNotificationConfig sends DELETE", async () => {
|
|
1407
1671
|
fetchMock.mockResolvedValue({
|
|
1408
1672
|
ok: true,
|