@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/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
- return this.fetchJSON<A2ATask>(
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
- return this.fetchJSON<A2AListTasksResponse>(
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
- return this.fetchJSON<A2ATask>(
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
- return this.fetchJSON<A2ATaskPushNotificationConfig>(
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
- return this.fetchJSON<A2ATaskPushNotificationConfig>(
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
- return this.fetchJSON<A2AListTaskPushNotificationConfigsResponse>(
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(response: Response): AsyncGenerator<A2AStreamEvent> {
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
- return discriminateStreamResponse(normalized);
645
- } catch {
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("marks complete when stream ends without terminal status", async () => {
1081
- const core = createCore({
1082
- streamMessage: vi.fn().mockImplementation(async function* () {
1083
- // Stream ends without any events
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
- const assistant = core.getMessages()[1]!;
1090
- expect(assistant.status).toEqual({
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
- for await (const event of stream) {
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 { artifact, append, lastChunk } = event;
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
- this.currentTask = task;
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 (task.artifacts) {
675
- this.currentArtifacts = task.artifacts;
714
+ if (artifacts) {
715
+ this.currentArtifacts = artifacts;
676
716
  }
677
717
 
678
718
  if (task.status.message) {
@@ -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", () => {
@@ -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.parts);
186
+ return a2aPartsToContent(message?.parts ?? []);
187
187
  }