@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.
@@ -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,19 +1186,32 @@ 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
1084
- }),
1085
- });
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
+ );
1086
1201
 
1087
- await core.append(createUserAppendMessage("Go"));
1202
+ await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
1203
+ "A2A message stream ended without any events.",
1204
+ );
1088
1205
 
1206
+ expect(onError).toHaveBeenCalledWith(
1207
+ expect.objectContaining({
1208
+ message: "A2A message stream ended without any events.",
1209
+ }),
1210
+ );
1089
1211
  const assistant = core.getMessages()[1]!;
1090
1212
  expect(assistant.status).toEqual({
1091
- type: "complete",
1092
- reason: "stop",
1213
+ type: "incomplete",
1214
+ reason: "error",
1093
1215
  });
1094
1216
  });
1095
1217
  });
@@ -1152,6 +1274,7 @@ describe("A2AThreadRuntimeCore", () => {
1152
1274
  streamMessage.mock.calls.length === 1 ? firstPending : secondPending;
1153
1275
  return (async function* () {
1154
1276
  await pending;
1277
+ yield statusUpdateEvent("completed", "Done");
1155
1278
  })();
1156
1279
  });
1157
1280
  const core = createCore({ streamMessage });
@@ -1200,7 +1323,9 @@ describe("A2AThreadRuntimeCore", () => {
1200
1323
 
1201
1324
  describe("outbound message conversion", () => {
1202
1325
  function createCoreWithStream() {
1203
- const streamMessage = vi.fn().mockImplementation(async function* () {});
1326
+ const streamMessage = vi.fn().mockImplementation(async function* () {
1327
+ yield statusUpdateEvent("completed", "Done");
1328
+ });
1204
1329
  const core = new A2AThreadRuntimeCore({
1205
1330
  client: createMockClient({ streamMessage }),
1206
1331
  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,18 @@ export class A2AThreadRuntimeCore {
526
533
  abortController.signal,
527
534
  );
528
535
 
536
+ let receivedEvent = false;
529
537
  for await (const event of stream) {
530
538
  if (abortController.signal.aborted) break;
539
+ receivedEvent = true;
531
540
  this.handleStreamEvent(assistantId, event);
532
541
  }
533
542
 
534
543
  if (!abortController.signal.aborted) {
544
+ if (!receivedEvent) {
545
+ throw new Error("A2A message stream ended without any events.");
546
+ }
547
+
535
548
  const lastStatus = this.getAssistantStatus(assistantId);
536
549
  if (lastStatus?.type === "running") {
537
550
  this.updateAssistantStatus(assistantId, {
@@ -617,7 +630,8 @@ export class A2AThreadRuntimeCore {
617
630
  }
618
631
 
619
632
  private handleArtifactUpdate(event: A2ATaskArtifactUpdateEvent) {
620
- const { artifact, append, lastChunk } = event;
633
+ const { append, lastChunk } = event;
634
+ const artifact = normalizeArtifact(event.artifact);
621
635
  const existingIdx = this.currentArtifacts.findIndex(
622
636
  (a) => a.artifactId === artifact.artifactId,
623
637
  );
@@ -666,13 +680,29 @@ export class A2AThreadRuntimeCore {
666
680
  }
667
681
 
668
682
  private handleTaskSnapshot(assistantId: string, task: A2ATask) {
669
- this.currentTask = task;
683
+ const artifacts =
684
+ task.artifacts === undefined
685
+ ? undefined
686
+ : Array.isArray(task.artifacts)
687
+ ? task.artifacts.map(normalizeArtifact)
688
+ : [];
689
+ const history =
690
+ task.history === undefined
691
+ ? undefined
692
+ : Array.isArray(task.history)
693
+ ? task.history
694
+ : [];
695
+ this.currentTask = {
696
+ ...task,
697
+ ...(artifacts === undefined ? {} : { artifacts }),
698
+ ...(history === undefined ? {} : { history }),
699
+ };
670
700
 
671
701
  if (task.contextId) {
672
702
  this.contextId = task.contextId;
673
703
  }
674
- if (task.artifacts) {
675
- this.currentArtifacts = task.artifacts;
704
+ if (artifacts) {
705
+ this.currentArtifacts = artifacts;
676
706
  }
677
707
 
678
708
  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
  }
@@ -3,6 +3,7 @@
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { A2AClient } from "./A2AClient";
6
+ import type { A2AStreamEvent } from "./types";
6
7
  import { useA2ARuntime } from "./useA2ARuntime";
7
8
 
8
9
  const createMockClient = (waitForAbort = false) => {
@@ -14,11 +15,21 @@ const createMockClient = (waitForAbort = false) => {
14
15
  _configuration: unknown,
15
16
  _metadata: unknown,
16
17
  signal?: AbortSignal,
17
- ): AsyncIterable<never> => {
18
+ ): AsyncIterable<A2AStreamEvent> => {
18
19
  streamSignal = signal;
19
20
  return {
20
21
  async *[Symbol.asyncIterator]() {
21
- if (!waitForAbort || !signal) return;
22
+ if (!waitForAbort || !signal) {
23
+ yield {
24
+ type: "message",
25
+ message: {
26
+ messageId: "response",
27
+ role: "agent",
28
+ parts: [{ text: "Done" }],
29
+ },
30
+ };
31
+ return;
32
+ }
22
33
  await new Promise<void>((resolve) => {
23
34
  if (signal.aborted) {
24
35
  resolve();
@@ -103,10 +114,13 @@ describe("useA2ARuntime", () => {
103
114
  },
104
115
  );
105
116
  }
106
- return new Response("", {
107
- status: 200,
108
- headers: { "Content-Type": "text/event-stream" },
109
- });
117
+ return new Response(
118
+ 'data: {"message":{"message_id":"response","role":"ROLE_AGENT","parts":[{"text":"Done"}]}}\n\n',
119
+ {
120
+ status: 200,
121
+ headers: { "Content-Type": "text/event-stream" },
122
+ },
123
+ );
110
124
  },
111
125
  );
112
126
  vi.stubGlobal("fetch", fetchMock);