@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/src/A2AClient.ts
CHANGED
|
@@ -272,10 +272,157 @@ 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
|
}
|
|
278
414
|
|
|
415
|
+
const SKIPPED_FRAME_SNIPPET_LENGTH = 120;
|
|
416
|
+
|
|
417
|
+
function describeSkippedFrame(data: string, reason: string): string {
|
|
418
|
+
const collapsed = data.replace(/\s+/g, " ");
|
|
419
|
+
const snippet =
|
|
420
|
+
collapsed.length > SKIPPED_FRAME_SNIPPET_LENGTH
|
|
421
|
+
? `${collapsed.slice(0, SKIPPED_FRAME_SNIPPET_LENGTH)}…`
|
|
422
|
+
: collapsed;
|
|
423
|
+
return `${reason} (frame: ${snippet})`;
|
|
424
|
+
}
|
|
425
|
+
|
|
279
426
|
export class A2AClient {
|
|
280
427
|
private baseUrl: string;
|
|
281
428
|
private basePath: string;
|
|
@@ -458,7 +605,7 @@ export class A2AClient {
|
|
|
458
605
|
await this.throwResponseError(response);
|
|
459
606
|
}
|
|
460
607
|
|
|
461
|
-
yield* this.parseSSE(response);
|
|
608
|
+
return yield* this.parseSSE(response);
|
|
462
609
|
}
|
|
463
610
|
|
|
464
611
|
// --- Tasks ---
|
|
@@ -474,10 +621,11 @@ export class A2AClient {
|
|
|
474
621
|
params.set("history_length", String(historyLength));
|
|
475
622
|
}
|
|
476
623
|
const qs = params.toString();
|
|
477
|
-
|
|
624
|
+
const result = await this.fetchJSON<unknown>(
|
|
478
625
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}${qs ? `?${qs}` : ""}`,
|
|
479
626
|
signalInit(signal),
|
|
480
627
|
);
|
|
628
|
+
return parseTaskResponse(result, "tasks:get");
|
|
481
629
|
}
|
|
482
630
|
|
|
483
631
|
async listTasks(
|
|
@@ -497,10 +645,11 @@ export class A2AClient {
|
|
|
497
645
|
if (request?.includeArtifacts !== undefined)
|
|
498
646
|
params.set("include_artifacts", String(request.includeArtifacts));
|
|
499
647
|
const qs = params.toString();
|
|
500
|
-
|
|
648
|
+
const result = await this.fetchJSON<unknown>(
|
|
501
649
|
`${this.getBasePath()}/tasks${qs ? `?${qs}` : ""}`,
|
|
502
650
|
signalInit(signal),
|
|
503
651
|
);
|
|
652
|
+
return parseListTasksResponse(result);
|
|
504
653
|
}
|
|
505
654
|
|
|
506
655
|
async cancelTask(
|
|
@@ -509,7 +658,7 @@ export class A2AClient {
|
|
|
509
658
|
signal?: AbortSignal,
|
|
510
659
|
): Promise<A2ATask> {
|
|
511
660
|
const body = metadata ? { metadata } : {};
|
|
512
|
-
|
|
661
|
+
const result = await this.fetchJSON<unknown>(
|
|
513
662
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}:cancel`,
|
|
514
663
|
{
|
|
515
664
|
method: "POST",
|
|
@@ -517,6 +666,7 @@ export class A2AClient {
|
|
|
517
666
|
...signalInit(signal),
|
|
518
667
|
},
|
|
519
668
|
);
|
|
669
|
+
return parseTaskResponse(result, "tasks:cancel");
|
|
520
670
|
}
|
|
521
671
|
|
|
522
672
|
async *subscribeToTask(
|
|
@@ -549,7 +699,7 @@ export class A2AClient {
|
|
|
549
699
|
): Promise<A2ATaskPushNotificationConfig> {
|
|
550
700
|
const taskId = config.taskId;
|
|
551
701
|
if (!taskId) throw new Error("taskId is required");
|
|
552
|
-
|
|
702
|
+
const result = await this.fetchJSON<unknown>(
|
|
553
703
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs`,
|
|
554
704
|
{
|
|
555
705
|
method: "POST",
|
|
@@ -557,6 +707,10 @@ export class A2AClient {
|
|
|
557
707
|
...signalInit(signal),
|
|
558
708
|
},
|
|
559
709
|
);
|
|
710
|
+
return parseTaskPushNotificationConfigResponse(
|
|
711
|
+
result,
|
|
712
|
+
invalidPushNotificationConfigResponse("pushNotificationConfigs:create"),
|
|
713
|
+
);
|
|
560
714
|
}
|
|
561
715
|
|
|
562
716
|
async getTaskPushNotificationConfig(
|
|
@@ -564,10 +718,14 @@ export class A2AClient {
|
|
|
564
718
|
configId: string,
|
|
565
719
|
signal?: AbortSignal,
|
|
566
720
|
): Promise<A2ATaskPushNotificationConfig> {
|
|
567
|
-
|
|
721
|
+
const result = await this.fetchJSON<unknown>(
|
|
568
722
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs/${encodeURIComponent(configId)}`,
|
|
569
723
|
signalInit(signal),
|
|
570
724
|
);
|
|
725
|
+
return parseTaskPushNotificationConfigResponse(
|
|
726
|
+
result,
|
|
727
|
+
invalidPushNotificationConfigResponse("pushNotificationConfigs:get"),
|
|
728
|
+
);
|
|
571
729
|
}
|
|
572
730
|
|
|
573
731
|
async listTaskPushNotificationConfigs(
|
|
@@ -580,10 +738,11 @@ export class A2AClient {
|
|
|
580
738
|
params.set("page_size", String(options.pageSize));
|
|
581
739
|
if (options?.pageToken) params.set("page_token", options.pageToken);
|
|
582
740
|
const qs = params.toString();
|
|
583
|
-
|
|
741
|
+
const result = await this.fetchJSON<unknown>(
|
|
584
742
|
`${this.getBasePath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs${qs ? `?${qs}` : ""}`,
|
|
585
743
|
signalInit(signal),
|
|
586
744
|
);
|
|
745
|
+
return parseListTaskPushNotificationConfigsResponse(result);
|
|
587
746
|
}
|
|
588
747
|
|
|
589
748
|
async deleteTaskPushNotificationConfig(
|
|
@@ -609,13 +768,16 @@ export class A2AClient {
|
|
|
609
768
|
|
|
610
769
|
// --- SSE Parsing ---
|
|
611
770
|
|
|
612
|
-
private async *parseSSE(
|
|
771
|
+
private async *parseSSE(
|
|
772
|
+
response: Response,
|
|
773
|
+
): AsyncGenerator<A2AStreamEvent, string | undefined> {
|
|
613
774
|
const contentType = response.headers.get("Content-Type");
|
|
614
775
|
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
615
776
|
if (mediaType !== "text/event-stream") {
|
|
616
777
|
const received = contentType
|
|
617
778
|
? `"${contentType}"`
|
|
618
779
|
: "no Content-Type header";
|
|
780
|
+
void response.body?.cancel().catch(() => undefined);
|
|
619
781
|
throw new Error(
|
|
620
782
|
`Expected A2A stream response Content-Type "text/event-stream", received ${received}`,
|
|
621
783
|
);
|
|
@@ -627,6 +789,11 @@ export class A2AClient {
|
|
|
627
789
|
const decoder = new TextDecoder();
|
|
628
790
|
const sseDecoder = new SSEEventDecoder();
|
|
629
791
|
|
|
792
|
+
let firstSkipReason: string | undefined;
|
|
793
|
+
const noteSkip = (data: string, reason: string) => {
|
|
794
|
+
firstSkipReason ??= describeSkippedFrame(data, reason);
|
|
795
|
+
};
|
|
796
|
+
|
|
630
797
|
const readEvent = (event: SSEEvent): A2AStreamEvent | null => {
|
|
631
798
|
try {
|
|
632
799
|
let parsed = JSON.parse(event.data);
|
|
@@ -641,8 +808,14 @@ export class A2AClient {
|
|
|
641
808
|
}
|
|
642
809
|
|
|
643
810
|
const normalized = normalizeKeys(parsed) as Record<string, unknown>;
|
|
644
|
-
|
|
645
|
-
|
|
811
|
+
const streamEvent = discriminateStreamResponse(normalized);
|
|
812
|
+
if (!streamEvent) noteSkip(event.data, "unrecognized event shape");
|
|
813
|
+
return streamEvent;
|
|
814
|
+
} catch (error) {
|
|
815
|
+
noteSkip(
|
|
816
|
+
event.data,
|
|
817
|
+
error instanceof Error ? error.message : String(error),
|
|
818
|
+
);
|
|
646
819
|
return null;
|
|
647
820
|
}
|
|
648
821
|
};
|
|
@@ -682,5 +855,7 @@ export class A2AClient {
|
|
|
682
855
|
reader.releaseLock();
|
|
683
856
|
}
|
|
684
857
|
}
|
|
858
|
+
|
|
859
|
+
return firstSkipReason;
|
|
685
860
|
}
|
|
686
861
|
}
|
|
@@ -518,6 +518,25 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
520
|
|
|
521
|
+
it("handles malformed status message parts", async () => {
|
|
522
|
+
const core = createCore({
|
|
523
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
524
|
+
yield {
|
|
525
|
+
type: "statusUpdate",
|
|
526
|
+
event: {
|
|
527
|
+
taskId: "t1",
|
|
528
|
+
contextId: "ctx-1",
|
|
529
|
+
status: { state: "completed", message: {} },
|
|
530
|
+
},
|
|
531
|
+
} as unknown as A2AStreamEvent;
|
|
532
|
+
}),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
await core.append(createUserAppendMessage("Go"));
|
|
536
|
+
|
|
537
|
+
expect(core.getMessages()[1]!.content).toEqual([]);
|
|
538
|
+
});
|
|
539
|
+
|
|
521
540
|
it("tracks task state from status updates", async () => {
|
|
522
541
|
const core = createCore({
|
|
523
542
|
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
@@ -628,6 +647,31 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
628
647
|
// --- Artifact handling ---
|
|
629
648
|
|
|
630
649
|
describe("artifacts", () => {
|
|
650
|
+
it("treats malformed artifact parts as empty", async () => {
|
|
651
|
+
const core = createCore({
|
|
652
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
653
|
+
yield {
|
|
654
|
+
type: "artifactUpdate",
|
|
655
|
+
event: {
|
|
656
|
+
taskId: "t1",
|
|
657
|
+
contextId: "ctx-1",
|
|
658
|
+
artifact: { artifactId: "a1", parts: {} },
|
|
659
|
+
},
|
|
660
|
+
} as unknown as A2AStreamEvent;
|
|
661
|
+
yield artifactUpdateEvent("a1", [{ text: "part" }], {
|
|
662
|
+
append: true,
|
|
663
|
+
});
|
|
664
|
+
yield statusUpdateEvent("completed", "Done");
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await core.append(createUserAppendMessage("Go"));
|
|
669
|
+
|
|
670
|
+
expect(core.getArtifacts()).toEqual([
|
|
671
|
+
{ artifactId: "a1", parts: [{ text: "part" }] },
|
|
672
|
+
]);
|
|
673
|
+
});
|
|
674
|
+
|
|
631
675
|
it("accumulates artifacts from artifact update events", async () => {
|
|
632
676
|
const core = createCore({
|
|
633
677
|
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
@@ -825,6 +869,71 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
825
869
|
// --- Task snapshot ---
|
|
826
870
|
|
|
827
871
|
describe("task snapshot", () => {
|
|
872
|
+
it("treats malformed artifact parts as empty", async () => {
|
|
873
|
+
const taskSnapshot = {
|
|
874
|
+
id: "t1",
|
|
875
|
+
status: { state: "completed" },
|
|
876
|
+
artifacts: [{ artifactId: "a1", parts: {} }],
|
|
877
|
+
} as unknown as A2ATask;
|
|
878
|
+
|
|
879
|
+
const core = createCore({
|
|
880
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
881
|
+
yield { type: "task", task: taskSnapshot } as A2AStreamEvent;
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
await core.append(createUserAppendMessage("Go"));
|
|
886
|
+
|
|
887
|
+
expect(core.getArtifacts()).toEqual([{ artifactId: "a1", parts: [] }]);
|
|
888
|
+
expect(core.getTask()?.artifacts).toEqual([
|
|
889
|
+
{ artifactId: "a1", parts: [] },
|
|
890
|
+
]);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it.each([undefined, null, {}, "not-an-array"])(
|
|
894
|
+
"does not consume malformed task artifacts: %j",
|
|
895
|
+
async (artifacts) => {
|
|
896
|
+
const taskSnapshot = {
|
|
897
|
+
id: "t1",
|
|
898
|
+
status: { state: "completed" },
|
|
899
|
+
artifacts,
|
|
900
|
+
history: artifacts,
|
|
901
|
+
} as unknown as A2ATask;
|
|
902
|
+
const core = createCore({
|
|
903
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
904
|
+
yield { type: "task", task: taskSnapshot } as A2AStreamEvent;
|
|
905
|
+
}),
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
await core.append(createUserAppendMessage("Go"));
|
|
909
|
+
|
|
910
|
+
expect(core.getArtifacts()).toEqual([]);
|
|
911
|
+
if (artifacts === undefined) {
|
|
912
|
+
expect(core.getTask()?.artifacts).toBeUndefined();
|
|
913
|
+
expect(core.getTask()?.history).toBeUndefined();
|
|
914
|
+
} else {
|
|
915
|
+
expect(core.getTask()?.artifacts).toEqual([]);
|
|
916
|
+
expect(core.getTask()?.history).toEqual([]);
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
it("handles malformed status message parts", async () => {
|
|
922
|
+
const taskSnapshot = {
|
|
923
|
+
id: "t1",
|
|
924
|
+
status: { state: "completed", message: {} },
|
|
925
|
+
} as unknown as A2ATask;
|
|
926
|
+
const core = createCore({
|
|
927
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
928
|
+
yield { type: "task", task: taskSnapshot } as A2AStreamEvent;
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
await core.append(createUserAppendMessage("Go"));
|
|
933
|
+
|
|
934
|
+
expect(core.getMessages()[1]!.content).toEqual([]);
|
|
935
|
+
});
|
|
936
|
+
|
|
828
937
|
it("handles full task snapshot from stream", async () => {
|
|
829
938
|
const taskSnapshot: A2ATask = {
|
|
830
939
|
id: "t1",
|
|
@@ -1077,17 +1186,73 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1077
1186
|
},
|
|
1078
1187
|
);
|
|
1079
1188
|
|
|
1080
|
-
it("
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1189
|
+
it("rejects a stream that ends without any events", async () => {
|
|
1190
|
+
const onError = vi.fn();
|
|
1191
|
+
const core = createCore(
|
|
1192
|
+
{
|
|
1193
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1194
|
+
return;
|
|
1195
|
+
}),
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
onError,
|
|
1199
|
+
},
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
|
|
1203
|
+
"A2A message stream ended without any events.",
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
expect(onError).toHaveBeenCalledWith(
|
|
1207
|
+
expect.objectContaining({
|
|
1208
|
+
message: "A2A message stream ended without any events.",
|
|
1084
1209
|
}),
|
|
1210
|
+
);
|
|
1211
|
+
const assistant = core.getMessages()[1]!;
|
|
1212
|
+
expect(assistant.status).toEqual({
|
|
1213
|
+
type: "incomplete",
|
|
1214
|
+
reason: "error",
|
|
1085
1215
|
});
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("appends the first skipped frame reason to the empty-stream error", async () => {
|
|
1219
|
+
const onError = vi.fn();
|
|
1220
|
+
const core = createCore(
|
|
1221
|
+
{
|
|
1222
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1223
|
+
return "unrecognized event shape (frame: {})";
|
|
1224
|
+
}),
|
|
1225
|
+
},
|
|
1226
|
+
{ onError },
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
|
|
1230
|
+
"A2A message stream ended without any events. First skipped frame: unrecognized event shape (frame: {})",
|
|
1231
|
+
);
|
|
1232
|
+
expect(onError).toHaveBeenCalledWith(
|
|
1233
|
+
expect.objectContaining({
|
|
1234
|
+
message:
|
|
1235
|
+
"A2A message stream ended without any events. First skipped frame: unrecognized event shape (frame: {})",
|
|
1236
|
+
}),
|
|
1237
|
+
);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it("ignores the skipped frame reason when the stream produced events", async () => {
|
|
1241
|
+
const onError = vi.fn();
|
|
1242
|
+
const core = createCore(
|
|
1243
|
+
{
|
|
1244
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
1245
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1246
|
+
return "unrecognized event shape (frame: {})";
|
|
1247
|
+
}),
|
|
1248
|
+
},
|
|
1249
|
+
{ onError },
|
|
1250
|
+
);
|
|
1086
1251
|
|
|
1087
1252
|
await core.append(createUserAppendMessage("Go"));
|
|
1088
1253
|
|
|
1089
|
-
|
|
1090
|
-
expect(
|
|
1254
|
+
expect(onError).not.toHaveBeenCalled();
|
|
1255
|
+
expect(core.getMessages()[1]!.status).toEqual({
|
|
1091
1256
|
type: "complete",
|
|
1092
1257
|
reason: "stop",
|
|
1093
1258
|
});
|
|
@@ -1152,6 +1317,7 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1152
1317
|
streamMessage.mock.calls.length === 1 ? firstPending : secondPending;
|
|
1153
1318
|
return (async function* () {
|
|
1154
1319
|
await pending;
|
|
1320
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1155
1321
|
})();
|
|
1156
1322
|
});
|
|
1157
1323
|
const core = createCore({ streamMessage });
|
|
@@ -1200,7 +1366,9 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
1200
1366
|
|
|
1201
1367
|
describe("outbound message conversion", () => {
|
|
1202
1368
|
function createCoreWithStream() {
|
|
1203
|
-
const streamMessage = vi.fn().mockImplementation(async function* () {
|
|
1369
|
+
const streamMessage = vi.fn().mockImplementation(async function* () {
|
|
1370
|
+
yield statusUpdateEvent("completed", "Done");
|
|
1371
|
+
});
|
|
1204
1372
|
const core = new A2AThreadRuntimeCore({
|
|
1205
1373
|
client: createMockClient({ streamMessage }),
|
|
1206
1374
|
notifyUpdate: vi.fn() as unknown as () => void,
|
|
@@ -76,6 +76,13 @@ const invokeRuntimeCallback = <TArgs extends unknown[]>(
|
|
|
76
76
|
}
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
function normalizeArtifact(artifact: A2AArtifact): A2AArtifact {
|
|
80
|
+
return {
|
|
81
|
+
...artifact,
|
|
82
|
+
parts: Array.isArray(artifact.parts) ? artifact.parts : [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
export class A2AThreadRuntimeCore {
|
|
80
87
|
private client: A2AClient;
|
|
81
88
|
private contextId: string | undefined;
|
|
@@ -526,12 +533,28 @@ export class A2AThreadRuntimeCore {
|
|
|
526
533
|
abortController.signal,
|
|
527
534
|
);
|
|
528
535
|
|
|
529
|
-
|
|
536
|
+
let receivedEvent = false;
|
|
537
|
+
let skipReason: string | undefined;
|
|
538
|
+
const diagnosedStream = (async function* () {
|
|
539
|
+
const reason = yield* stream;
|
|
540
|
+
if (typeof reason === "string") skipReason = reason;
|
|
541
|
+
})();
|
|
542
|
+
|
|
543
|
+
for await (const event of diagnosedStream) {
|
|
530
544
|
if (abortController.signal.aborted) break;
|
|
545
|
+
receivedEvent = true;
|
|
531
546
|
this.handleStreamEvent(assistantId, event);
|
|
532
547
|
}
|
|
533
548
|
|
|
534
549
|
if (!abortController.signal.aborted) {
|
|
550
|
+
if (!receivedEvent) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
skipReason
|
|
553
|
+
? `A2A message stream ended without any events. First skipped frame: ${skipReason}`
|
|
554
|
+
: "A2A message stream ended without any events.",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
535
558
|
const lastStatus = this.getAssistantStatus(assistantId);
|
|
536
559
|
if (lastStatus?.type === "running") {
|
|
537
560
|
this.updateAssistantStatus(assistantId, {
|
|
@@ -617,7 +640,8 @@ export class A2AThreadRuntimeCore {
|
|
|
617
640
|
}
|
|
618
641
|
|
|
619
642
|
private handleArtifactUpdate(event: A2ATaskArtifactUpdateEvent) {
|
|
620
|
-
const {
|
|
643
|
+
const { append, lastChunk } = event;
|
|
644
|
+
const artifact = normalizeArtifact(event.artifact);
|
|
621
645
|
const existingIdx = this.currentArtifacts.findIndex(
|
|
622
646
|
(a) => a.artifactId === artifact.artifactId,
|
|
623
647
|
);
|
|
@@ -666,13 +690,29 @@ export class A2AThreadRuntimeCore {
|
|
|
666
690
|
}
|
|
667
691
|
|
|
668
692
|
private handleTaskSnapshot(assistantId: string, task: A2ATask) {
|
|
669
|
-
|
|
693
|
+
const artifacts =
|
|
694
|
+
task.artifacts === undefined
|
|
695
|
+
? undefined
|
|
696
|
+
: Array.isArray(task.artifacts)
|
|
697
|
+
? task.artifacts.map(normalizeArtifact)
|
|
698
|
+
: [];
|
|
699
|
+
const history =
|
|
700
|
+
task.history === undefined
|
|
701
|
+
? undefined
|
|
702
|
+
: Array.isArray(task.history)
|
|
703
|
+
? task.history
|
|
704
|
+
: [];
|
|
705
|
+
this.currentTask = {
|
|
706
|
+
...task,
|
|
707
|
+
...(artifacts === undefined ? {} : { artifacts }),
|
|
708
|
+
...(history === undefined ? {} : { history }),
|
|
709
|
+
};
|
|
670
710
|
|
|
671
711
|
if (task.contextId) {
|
|
672
712
|
this.contextId = task.contextId;
|
|
673
713
|
}
|
|
674
|
-
if (
|
|
675
|
-
this.currentArtifacts =
|
|
714
|
+
if (artifacts) {
|
|
715
|
+
this.currentArtifacts = artifacts;
|
|
676
716
|
}
|
|
677
717
|
|
|
678
718
|
if (task.status.message) {
|
package/src/conversions.test.ts
CHANGED
|
@@ -228,6 +228,13 @@ describe("a2aPartsToContent", () => {
|
|
|
228
228
|
it("handles empty parts array", () => {
|
|
229
229
|
expect(a2aPartsToContent([])).toEqual([]);
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
it.each([undefined, null, {}, "not-an-array"])(
|
|
233
|
+
"treats %j parts as empty content",
|
|
234
|
+
(parts) => {
|
|
235
|
+
expect(a2aPartsToContent(parts as unknown as A2APart[])).toEqual([]);
|
|
236
|
+
},
|
|
237
|
+
);
|
|
231
238
|
});
|
|
232
239
|
|
|
233
240
|
describe("a2aMessageToContent", () => {
|
|
@@ -242,6 +249,15 @@ describe("a2aMessageToContent", () => {
|
|
|
242
249
|
expect(result[0]).toEqual({ type: "text", text: "Hello" });
|
|
243
250
|
expect(result[1]).toEqual({ type: "text", text: " world" });
|
|
244
251
|
});
|
|
252
|
+
|
|
253
|
+
it.each([undefined, null, {}, "not-an-array"])(
|
|
254
|
+
"treats a message with %j parts as empty content",
|
|
255
|
+
(parts) => {
|
|
256
|
+
expect(a2aMessageToContent({ parts } as unknown as A2AMessage)).toEqual(
|
|
257
|
+
[],
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
245
261
|
});
|
|
246
262
|
|
|
247
263
|
describe("taskStateToMessageStatus", () => {
|
package/src/conversions.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function a2aPartToContent(
|
|
|
54
54
|
export function a2aPartsToContent(
|
|
55
55
|
parts: A2APart[],
|
|
56
56
|
): ThreadAssistantMessage["content"] {
|
|
57
|
-
return parts.map(a2aPartToContent);
|
|
57
|
+
return (Array.isArray(parts) ? parts : []).map(a2aPartToContent);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const TERMINAL_STATES = new Set<A2ATaskState>([
|
|
@@ -183,5 +183,5 @@ export function contentPartsToA2AParts(
|
|
|
183
183
|
export function a2aMessageToContent(
|
|
184
184
|
message: A2AMessage,
|
|
185
185
|
): ThreadAssistantMessage["content"] {
|
|
186
|
-
return a2aPartsToContent(message
|
|
186
|
+
return a2aPartsToContent(message?.parts ?? []);
|
|
187
187
|
}
|