@assistant-ui/react-a2a 0.2.30 → 0.2.32

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
@@ -63,6 +63,37 @@ const OPAQUE_FIELDS = new Set([
63
63
  "scopes",
64
64
  ]);
65
65
 
66
+ const JSONRPC_STATE_MAP: Record<string, string> = {
67
+ "input-required": "input_required",
68
+ "auth-required": "auth_required",
69
+ unknown: "unspecified",
70
+ };
71
+
72
+ // JSON-RPC file parts nest the payload under `file`; the internal A2APart is
73
+ // flat, so the nested fields map onto url/raw/mediaType/filename.
74
+ function normalizeParts(value: unknown[]): unknown[] {
75
+ return value.map((raw) => {
76
+ const part = normalizeKeys(raw, false);
77
+ if (part === null || typeof part !== "object" || Array.isArray(part))
78
+ return part;
79
+ const record = part as Record<string, unknown>;
80
+ if (record.kind === undefined) return part;
81
+ const { kind, ...rest } = record;
82
+ const file = rest.file;
83
+ if (kind !== "file" || file === null || typeof file !== "object")
84
+ return rest;
85
+ const { file: _file, ...others } = rest;
86
+ const nested = file as Record<string, unknown>;
87
+ return {
88
+ ...others,
89
+ ...(nested.uri !== undefined ? { url: nested.uri } : {}),
90
+ ...(nested.bytes !== undefined ? { raw: nested.bytes } : {}),
91
+ ...(nested.mimeType !== undefined ? { mediaType: nested.mimeType } : {}),
92
+ ...(nested.name !== undefined ? { filename: nested.name } : {}),
93
+ };
94
+ });
95
+ }
96
+
66
97
  function normalizeKeys(obj: unknown, opaque = false): unknown {
67
98
  if (Array.isArray(obj)) return obj.map((v) => normalizeKeys(v, opaque));
68
99
  if (obj !== null && typeof obj === "object") {
@@ -80,12 +111,15 @@ function normalizeKeys(obj: unknown, opaque = false): unknown {
80
111
  const camelKey = toCamelCase(key);
81
112
  const isOpaqueChild = OPAQUE_FIELDS.has(camelKey);
82
113
 
83
- if (
84
- camelKey === "state" &&
85
- typeof value === "string" &&
86
- value.startsWith("TASK_STATE_")
87
- ) {
88
- result[camelKey] = value.slice(11).toLowerCase();
114
+ if (camelKey === "state" && typeof value === "string") {
115
+ // Proto-style (TASK_STATE_WORKING) and the JSON-RPC state names map
116
+ // onto the internal snake_case states; anything unrecognized is
117
+ // preserved verbatim.
118
+ if (value.startsWith("TASK_STATE_")) {
119
+ result[camelKey] = value.slice(11).toLowerCase();
120
+ } else {
121
+ result[camelKey] = JSONRPC_STATE_MAP[value] ?? value;
122
+ }
89
123
  } else if (
90
124
  camelKey === "role" &&
91
125
  typeof value === "string" &&
@@ -94,9 +128,11 @@ function normalizeKeys(obj: unknown, opaque = false): unknown {
94
128
  result[camelKey] = value.slice(5).toLowerCase();
95
129
  } else if (camelKey === "content" && Array.isArray(value)) {
96
130
  // v0.3 servers used "content" for message/artifact parts; normalize to "parts" for backward compat
97
- result.parts = normalizeKeys(value, false);
98
- } else if (camelKey !== "parts" || !("parts" in result)) {
131
+ result.parts = normalizeParts(value);
132
+ } else if (camelKey === "parts" && Array.isArray(value)) {
99
133
  // dedup: "content" was already mapped to parts above; don't overwrite
134
+ if (!("parts" in result)) result.parts = normalizeParts(value);
135
+ } else if (camelKey !== "parts" || !("parts" in result)) {
100
136
  result[camelKey] = isOpaqueChild ? value : normalizeKeys(value, false);
101
137
  }
102
138
  }
@@ -123,33 +159,78 @@ function toWireMessage(msg: A2AMessage): unknown {
123
159
  function discriminateStreamResponse(
124
160
  data: Record<string, unknown>,
125
161
  ): A2AStreamEvent | null {
126
- if ("task" in data && data.task) {
127
- return { type: "task", task: data.task as A2ATask };
162
+ if ("task" in data) {
163
+ const task = toWrappedTask(data.task);
164
+ if (task) return { type: "task", task };
128
165
  }
129
- if ("message" in data && data.message) {
130
- return { type: "message", message: data.message as A2AMessage };
166
+ if ("message" in data) {
167
+ const message = toWrappedMessage(data.message);
168
+ if (message) return { type: "message", message };
131
169
  }
132
- if ("statusUpdate" in data && data.statusUpdate) {
133
- return {
134
- type: "statusUpdate",
135
- event: data.statusUpdate as A2AStreamEvent extends {
136
- type: "statusUpdate";
137
- event: infer E;
138
- }
139
- ? E
140
- : never,
141
- };
170
+ if ("statusUpdate" in data) {
171
+ const statusUpdate = toWrappedStatusUpdate(data.statusUpdate);
172
+ if (statusUpdate) {
173
+ return {
174
+ type: "statusUpdate",
175
+ event: statusUpdate as A2AStreamEvent extends {
176
+ type: "statusUpdate";
177
+ event: infer E;
178
+ }
179
+ ? E
180
+ : never,
181
+ };
182
+ }
142
183
  }
143
- if ("artifactUpdate" in data && data.artifactUpdate) {
144
- return {
145
- type: "artifactUpdate",
146
- event: data.artifactUpdate as A2AStreamEvent extends {
147
- type: "artifactUpdate";
148
- event: infer E;
149
- }
150
- ? E
151
- : never,
152
- };
184
+ if ("artifactUpdate" in data) {
185
+ const artifactUpdate = toWrappedArtifactUpdate(data.artifactUpdate);
186
+ if (artifactUpdate) {
187
+ return {
188
+ type: "artifactUpdate",
189
+ event: artifactUpdate as A2AStreamEvent extends {
190
+ type: "artifactUpdate";
191
+ event: infer E;
192
+ }
193
+ ? E
194
+ : never,
195
+ };
196
+ }
197
+ }
198
+ // JSON-RPC streaming results are the event itself, flat, discriminated by
199
+ // `kind` (per the A2A JSON-RPC schema), rather than wrapped in a
200
+ // REST-style single-key envelope. The field sets cannot collide with the
201
+ // wrapper keys above, so this is a pure fallthrough.
202
+ const { kind, ...flat } = data;
203
+ switch (kind) {
204
+ case "task":
205
+ if (!isTask(flat)) break;
206
+ return { type: "task", task: flat };
207
+ case "message":
208
+ if (!isMessage(flat)) break;
209
+ return { type: "message", message: flat };
210
+ case "status-update": {
211
+ if (!isStatusUpdate(flat)) break;
212
+ const { final: _final, ...event } = flat;
213
+ return {
214
+ type: "statusUpdate",
215
+ event: event as unknown as A2AStreamEvent extends {
216
+ type: "statusUpdate";
217
+ event: infer E;
218
+ }
219
+ ? E
220
+ : never,
221
+ };
222
+ }
223
+ case "artifact-update":
224
+ if (!isArtifactUpdate(flat)) break;
225
+ return {
226
+ type: "artifactUpdate",
227
+ event: flat as unknown as A2AStreamEvent extends {
228
+ type: "artifactUpdate";
229
+ event: infer E;
230
+ }
231
+ ? E
232
+ : never,
233
+ };
153
234
  }
154
235
  return null;
155
236
  }
@@ -182,10 +263,20 @@ const ROLES: ReadonlySet<string> = new Set(
182
263
  const isRole = (value: unknown): value is A2ARole =>
183
264
  typeof value === "string" && ROLES.has(value);
184
265
 
266
+ // Ids reach task state and the next request body unchecked by anything
267
+ // downstream, so an id that is present and not null must be a string.
268
+ // An omitted or null id keeps the acceptance each path already had.
269
+ const hasOptionalStringIds = (
270
+ value: Record<string, unknown>,
271
+ keys: readonly string[],
272
+ ): boolean =>
273
+ keys.every((key) => value[key] == null || typeof value[key] === "string");
274
+
185
275
  const isTask = (value: unknown): value is A2ATask =>
186
276
  isRecord(value) &&
187
277
  typeof value.id === "string" &&
188
278
  value.id.length > 0 &&
279
+ hasOptionalStringIds(value, ["contextId"]) &&
189
280
  isRecord(value.status) &&
190
281
  isTaskState(value.status.state);
191
282
 
@@ -193,13 +284,146 @@ const isMessage = (value: unknown): value is A2AMessage =>
193
284
  isRecord(value) &&
194
285
  typeof value.messageId === "string" &&
195
286
  value.messageId.length > 0 &&
287
+ hasOptionalStringIds(value, ["contextId", "taskId"]) &&
196
288
  isRole(value.role) &&
197
289
  Array.isArray(value.parts) &&
198
290
  value.parts.every(isRecord);
199
291
 
292
+ // Legacy wrappers use ProtoJSON, where omitted and null fields decode to proto
293
+ // defaults. Normalize those defaults before enforcing semantic requirements.
294
+ // Filling a null id with the ProtoJSON default does not make it a string, and
295
+ // the shape guards below check the fields they name rather than the ids. The
296
+ // runtime reads these straight into task state and the next request body.
297
+ const hasStringIds = (
298
+ value: Record<string, unknown>,
299
+ keys: readonly string[],
300
+ ): boolean => keys.every((key) => typeof value[key] === "string");
301
+
302
+ const toWrappedTaskStatus = (
303
+ value: unknown,
304
+ ): Record<string, unknown> | null => {
305
+ if (!isRecord(value)) return null;
306
+ return {
307
+ ...value,
308
+ state: value.state == null ? "unspecified" : value.state,
309
+ };
310
+ };
311
+
312
+ const toWrappedTask = (value: unknown): A2ATask | null => {
313
+ if (!isRecord(value)) return null;
314
+ const status = toWrappedTaskStatus(value.status);
315
+ if (!status) return null;
316
+
317
+ const task = {
318
+ ...value,
319
+ contextId: value.contextId == null ? "" : value.contextId,
320
+ status,
321
+ };
322
+ return isTask(task) && hasStringIds(task, ["contextId"]) ? task : null;
323
+ };
324
+
325
+ const toWrappedMessage = (value: unknown): A2AMessage | null => {
326
+ if (!isRecord(value)) return null;
327
+
328
+ const message = {
329
+ ...value,
330
+ contextId: value.contextId == null ? "" : value.contextId,
331
+ taskId: value.taskId == null ? "" : value.taskId,
332
+ role: value.role == null ? "unspecified" : value.role,
333
+ parts: value.parts == null ? [] : value.parts,
334
+ };
335
+ return isMessage(message) && hasStringIds(message, ["contextId", "taskId"])
336
+ ? message
337
+ : null;
338
+ };
339
+
340
+ const isStatusUpdate = (
341
+ value: unknown,
342
+ allowEmptyTaskId = false,
343
+ ): value is Record<string, unknown> =>
344
+ isRecord(value) &&
345
+ typeof value.taskId === "string" &&
346
+ (allowEmptyTaskId || value.taskId.length > 0) &&
347
+ hasOptionalStringIds(value, ["contextId"]) &&
348
+ isRecord(value.status) &&
349
+ isTaskState(value.status.state);
350
+
351
+ const toWrappedStatusUpdate = (
352
+ value: unknown,
353
+ ): Record<string, unknown> | null => {
354
+ if (!isRecord(value) || !isRecord(value.status)) return null;
355
+
356
+ const statusUpdate = {
357
+ ...value,
358
+ taskId: value.taskId == null ? "" : value.taskId,
359
+ contextId: value.contextId == null ? "" : value.contextId,
360
+ status: toWrappedTaskStatus(value.status),
361
+ };
362
+ return isStatusUpdate(statusUpdate, true) &&
363
+ hasStringIds(statusUpdate, ["taskId", "contextId"])
364
+ ? statusUpdate
365
+ : null;
366
+ };
367
+
368
+ const isArtifact = (value: unknown): value is Record<string, unknown> =>
369
+ isRecord(value) &&
370
+ typeof value.artifactId === "string" &&
371
+ Array.isArray(value.parts) &&
372
+ value.parts.every(isRecord);
373
+
374
+ const isArtifactUpdate = (value: unknown): value is Record<string, unknown> =>
375
+ isRecord(value) &&
376
+ hasOptionalStringIds(value, ["contextId", "taskId"]) &&
377
+ isArtifact(value.artifact);
378
+
379
+ const toWrappedArtifact = (value: unknown): Record<string, unknown> | null => {
380
+ if (!isRecord(value)) return null;
381
+
382
+ const artifact = {
383
+ ...value,
384
+ artifactId: value.artifactId == null ? "" : value.artifactId,
385
+ parts: value.parts == null ? [] : value.parts,
386
+ };
387
+ return isArtifact(artifact) ? artifact : null;
388
+ };
389
+
390
+ const toWrappedArtifactUpdate = (
391
+ value: unknown,
392
+ ): Record<string, unknown> | null => {
393
+ if (!isRecord(value)) return null;
394
+ const artifact = toWrappedArtifact(value.artifact);
395
+ if (!artifact) return null;
396
+
397
+ const artifactUpdate = {
398
+ ...value,
399
+ taskId: value.taskId == null ? "" : value.taskId,
400
+ contextId: value.contextId == null ? "" : value.contextId,
401
+ artifact,
402
+ };
403
+ return isArtifactUpdate(artifactUpdate) &&
404
+ hasStringIds(artifactUpdate, ["taskId", "contextId"])
405
+ ? artifactUpdate
406
+ : null;
407
+ };
408
+
200
409
  const isStringArray = (value: unknown): value is string[] =>
201
410
  Array.isArray(value) && value.every((item) => typeof item === "string");
202
411
 
412
+ const toJsonRpcError = (error: unknown): A2AError => {
413
+ const rpcError = error as { code?: number; message?: string; data?: unknown };
414
+ return new A2AError({
415
+ code: rpcError.code ?? -1,
416
+ status: "JSONRPC_ERROR",
417
+ message: rpcError.message ?? "A2A JSON-RPC error",
418
+ details:
419
+ rpcError.data === undefined
420
+ ? undefined
421
+ : Array.isArray(rpcError.data)
422
+ ? rpcError.data
423
+ : [rpcError.data],
424
+ });
425
+ };
426
+
203
427
  const invalidAgentCard = (): never => {
204
428
  throw new Error(
205
429
  "Invalid A2A agent card response: expected a valid agent card payload.",
@@ -412,6 +636,17 @@ function signalInit(signal?: AbortSignal): RequestInit {
412
636
  return signal ? { signal } : {};
413
637
  }
414
638
 
639
+ const SKIPPED_FRAME_SNIPPET_LENGTH = 120;
640
+
641
+ function describeSkippedFrame(data: string, reason: string): string {
642
+ const collapsed = data.replace(/\s+/g, " ");
643
+ const snippet =
644
+ collapsed.length > SKIPPED_FRAME_SNIPPET_LENGTH
645
+ ? `${collapsed.slice(0, SKIPPED_FRAME_SNIPPET_LENGTH)}…`
646
+ : collapsed;
647
+ return `${reason} (frame: ${snippet})`;
648
+ }
649
+
415
650
  export class A2AClient {
416
651
  private baseUrl: string;
417
652
  private basePath: string;
@@ -511,6 +746,19 @@ export class A2AClient {
511
746
  await this.throwResponseError(response);
512
747
  }
513
748
  const json = await response.json();
749
+ if (json && typeof json === "object" && "jsonrpc" in json) {
750
+ if ("error" in json && json.error) {
751
+ throw toJsonRpcError(json.error);
752
+ }
753
+ if ("result" in json) {
754
+ const result = normalizeKeys(json.result);
755
+ if (isRecord(result) && typeof result.kind === "string") {
756
+ const { kind: _kind, ...rest } = result;
757
+ return rest as T;
758
+ }
759
+ return result as T;
760
+ }
761
+ }
514
762
  return normalizeKeys(json) as T;
515
763
  }
516
764
 
@@ -594,7 +842,7 @@ export class A2AClient {
594
842
  await this.throwResponseError(response);
595
843
  }
596
844
 
597
- yield* this.parseSSE(response);
845
+ return yield* this.parseSSE(response);
598
846
  }
599
847
 
600
848
  // --- Tasks ---
@@ -757,7 +1005,9 @@ export class A2AClient {
757
1005
 
758
1006
  // --- SSE Parsing ---
759
1007
 
760
- private async *parseSSE(response: Response): AsyncGenerator<A2AStreamEvent> {
1008
+ private async *parseSSE(
1009
+ response: Response,
1010
+ ): AsyncGenerator<A2AStreamEvent, string | undefined> {
761
1011
  const contentType = response.headers.get("Content-Type");
762
1012
  const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
763
1013
  if (mediaType !== "text/event-stream") {
@@ -776,22 +1026,34 @@ export class A2AClient {
776
1026
  const decoder = new TextDecoder();
777
1027
  const sseDecoder = new SSEEventDecoder();
778
1028
 
1029
+ let firstSkipReason: string | undefined;
1030
+ const noteSkip = (data: string, reason: string) => {
1031
+ firstSkipReason ??= describeSkippedFrame(data, reason);
1032
+ };
1033
+
779
1034
  const readEvent = (event: SSEEvent): A2AStreamEvent | null => {
780
1035
  try {
781
1036
  let parsed = JSON.parse(event.data);
782
1037
 
783
- if (
784
- parsed &&
785
- typeof parsed === "object" &&
786
- "jsonrpc" in parsed &&
787
- "result" in parsed
788
- ) {
789
- parsed = parsed.result;
1038
+ if (parsed && typeof parsed === "object" && "jsonrpc" in parsed) {
1039
+ if ("error" in parsed && parsed.error) {
1040
+ throw toJsonRpcError(parsed.error);
1041
+ }
1042
+ if ("result" in parsed) {
1043
+ parsed = parsed.result;
1044
+ }
790
1045
  }
791
1046
 
792
1047
  const normalized = normalizeKeys(parsed) as Record<string, unknown>;
793
- return discriminateStreamResponse(normalized);
794
- } catch {
1048
+ const streamEvent = discriminateStreamResponse(normalized);
1049
+ if (!streamEvent) noteSkip(event.data, "unrecognized event shape");
1050
+ return streamEvent;
1051
+ } catch (error) {
1052
+ if (error instanceof A2AError) throw error;
1053
+ noteSkip(
1054
+ event.data,
1055
+ error instanceof Error ? error.message : String(error),
1056
+ );
795
1057
  return null;
796
1058
  }
797
1059
  };
@@ -831,5 +1093,7 @@ export class A2AClient {
831
1093
  reader.releaseLock();
832
1094
  }
833
1095
  }
1096
+
1097
+ return firstSkipReason;
834
1098
  }
835
1099
  }