@assistant-ui/react-a2a 0.2.21 → 0.2.23

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
@@ -1,3 +1,5 @@
1
+ import { SSEEventDecoder, type SSEEvent } from "assistant-stream/utils";
2
+ import { isRecord } from "@assistant-ui/core/internal";
1
3
  import type {
2
4
  A2AAgentCard,
3
5
  A2AErrorInfo,
@@ -152,6 +154,114 @@ function discriminateStreamResponse(
152
154
  return null;
153
155
  }
154
156
 
157
+ const TASK_STATES: ReadonlySet<string> = new Set(
158
+ Object.keys({
159
+ unspecified: true,
160
+ submitted: true,
161
+ working: true,
162
+ completed: true,
163
+ failed: true,
164
+ canceled: true,
165
+ input_required: true,
166
+ rejected: true,
167
+ auth_required: true,
168
+ } satisfies Record<A2ATaskState, true>),
169
+ );
170
+
171
+ const isTaskState = (value: unknown): value is A2ATaskState =>
172
+ typeof value === "string" && TASK_STATES.has(value);
173
+
174
+ const isTask = (value: unknown): value is A2ATask =>
175
+ isRecord(value) &&
176
+ typeof value.id === "string" &&
177
+ value.id.length > 0 &&
178
+ isRecord(value.status) &&
179
+ isTaskState(value.status.state);
180
+
181
+ const isMessage = (value: unknown): value is A2AMessage =>
182
+ isRecord(value) &&
183
+ typeof value.messageId === "string" &&
184
+ value.messageId.length > 0 &&
185
+ typeof value.role === "string" &&
186
+ value.role.length > 0 &&
187
+ Array.isArray(value.parts) &&
188
+ value.parts.every(isRecord);
189
+
190
+ const isStringArray = (value: unknown): value is string[] =>
191
+ Array.isArray(value) && value.every((item) => typeof item === "string");
192
+
193
+ const invalidAgentCard = (): never => {
194
+ throw new Error(
195
+ "Invalid A2A agent card response: expected a valid agent card payload.",
196
+ );
197
+ };
198
+
199
+ const parseCardString = (value: unknown): string =>
200
+ value == null ? "" : typeof value === "string" ? value : invalidAgentCard();
201
+
202
+ const parseCardStringArray = (value: unknown): string[] =>
203
+ value == null ? [] : isStringArray(value) ? value : invalidAgentCard();
204
+
205
+ const parseCardRecordArray = (value: unknown): Record<string, unknown>[] =>
206
+ value == null
207
+ ? []
208
+ : Array.isArray(value) && value.every(isRecord)
209
+ ? (value as Record<string, unknown>[])
210
+ : invalidAgentCard();
211
+
212
+ const parseCardRecord = (value: unknown): Record<string, unknown> =>
213
+ value == null ? {} : isRecord(value) ? value : invalidAgentCard();
214
+
215
+ // Proto3 JSON parsing treats omitted and null fields as defaults, so a valid
216
+ // card may arrive without its empty lists, strings, or capabilities. Fill
217
+ // those per the proto3 JSON mapping rules; a payload without a name or with a
218
+ // present field of the wrong type rejects.
219
+ const parseAgentCardResponse = (value: unknown): A2AAgentCard => {
220
+ if (
221
+ !isRecord(value) ||
222
+ typeof value.name !== "string" ||
223
+ value.name.length === 0
224
+ ) {
225
+ return invalidAgentCard();
226
+ }
227
+
228
+ return {
229
+ ...value,
230
+ name: value.name,
231
+ description: parseCardString(value.description),
232
+ version: parseCardString(value.version),
233
+ supportedInterfaces: parseCardRecordArray(value.supportedInterfaces).map(
234
+ (entry) => ({
235
+ ...entry,
236
+ url: parseCardString(entry.url),
237
+ protocolBinding: parseCardString(entry.protocolBinding),
238
+ protocolVersion: parseCardString(entry.protocolVersion),
239
+ }),
240
+ ),
241
+ capabilities: parseCardRecord(value.capabilities),
242
+ defaultInputModes: parseCardStringArray(value.defaultInputModes),
243
+ defaultOutputModes: parseCardStringArray(value.defaultOutputModes),
244
+ skills: parseCardRecordArray(value.skills).map((entry) => ({
245
+ ...entry,
246
+ id: parseCardString(entry.id),
247
+ name: parseCardString(entry.name),
248
+ description: parseCardString(entry.description),
249
+ tags: parseCardStringArray(entry.tags),
250
+ })),
251
+ } as A2AAgentCard;
252
+ };
253
+
254
+ const parseSendMessageResponse = (value: unknown): A2ATask | A2AMessage => {
255
+ if (isRecord(value)) {
256
+ const candidate = value.task ?? value.message ?? value;
257
+ if (isTask(candidate) || isMessage(candidate)) return candidate;
258
+ }
259
+
260
+ throw new Error(
261
+ "Invalid A2A message:send response: expected a valid task or message payload.",
262
+ );
263
+ };
264
+
155
265
  function signalInit(signal?: AbortSignal): RequestInit {
156
266
  return signal ? { signal } : {};
157
267
  }
@@ -272,14 +382,15 @@ export class A2AClient {
272
382
  await this.throwResponseError(response);
273
383
  }
274
384
  const json = await response.json();
275
- return normalizeKeys(json) as A2AAgentCard;
385
+ return parseAgentCardResponse(normalizeKeys(json));
276
386
  }
277
387
 
278
388
  async getExtendedAgentCard(signal?: AbortSignal): Promise<A2AAgentCard> {
279
- return this.fetchJSON<A2AAgentCard>(
389
+ const result = await this.fetchJSON<unknown>(
280
390
  `${this.getBasePath()}/extendedAgentCard`,
281
391
  signalInit(signal),
282
392
  );
393
+ return parseAgentCardResponse(result);
283
394
  }
284
395
 
285
396
  // --- Message ---
@@ -296,7 +407,7 @@ export class A2AClient {
296
407
  if (configuration) body.configuration = configuration;
297
408
  if (metadata) body.metadata = metadata;
298
409
 
299
- const result = await this.fetchJSON<Record<string, unknown>>(
410
+ const result = await this.fetchJSON<unknown>(
300
411
  `${this.getBasePath()}/message:send`,
301
412
  {
302
413
  method: "POST",
@@ -305,11 +416,7 @@ export class A2AClient {
305
416
  },
306
417
  );
307
418
 
308
- // Unwrap SendMessageResponse: {task: Task} | {message: Message}
309
- if ("task" in result && result.task) return result.task as A2ATask;
310
- if ("message" in result && result.message)
311
- return result.message as A2AMessage;
312
- return result as unknown as A2ATask | A2AMessage;
419
+ return parseSendMessageResponse(result);
313
420
  }
314
421
 
315
422
  async *streamMessage(
@@ -493,57 +600,59 @@ export class A2AClient {
493
600
  // --- SSE Parsing ---
494
601
 
495
602
  private async *parseSSE(response: Response): AsyncGenerator<A2AStreamEvent> {
603
+ const contentType = response.headers.get("Content-Type");
604
+ const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
605
+ if (mediaType !== "text/event-stream") {
606
+ const received = contentType
607
+ ? `"${contentType}"`
608
+ : "no Content-Type header";
609
+ throw new Error(
610
+ `Expected A2A stream response Content-Type "text/event-stream", received ${received}`,
611
+ );
612
+ }
613
+
496
614
  const reader = response.body?.getReader();
497
615
  if (!reader) throw new Error("No response body");
498
616
 
499
617
  const decoder = new TextDecoder();
500
- let buffer = "";
618
+ const sseDecoder = new SSEEventDecoder();
619
+
620
+ const readEvent = (event: SSEEvent): A2AStreamEvent | null => {
621
+ try {
622
+ let parsed = JSON.parse(event.data);
623
+
624
+ if (
625
+ parsed &&
626
+ typeof parsed === "object" &&
627
+ "jsonrpc" in parsed &&
628
+ "result" in parsed
629
+ ) {
630
+ parsed = parsed.result;
631
+ }
632
+
633
+ const normalized = normalizeKeys(parsed) as Record<string, unknown>;
634
+ return discriminateStreamResponse(normalized);
635
+ } catch {
636
+ return null;
637
+ }
638
+ };
501
639
 
502
640
  try {
503
641
  while (true) {
504
642
  const { done, value } = await reader.read();
505
- if (done) break;
506
-
507
- buffer += decoder.decode(value, { stream: true });
508
-
509
- let eventEnd: number = buffer.indexOf("\n\n");
510
- while (eventEnd !== -1) {
511
- const eventText = buffer.slice(0, eventEnd);
512
- buffer = buffer.slice(eventEnd + 2);
513
-
514
- const dataLines: string[] = [];
515
-
516
- for (const line of eventText.split("\n")) {
517
- const trimmed = line.replace(/\r$/, "");
518
- if (trimmed.startsWith("data:")) {
519
- dataLines.push(trimmed.slice(5).trim());
520
- }
521
- // event:, id:, retry: lines are parsed but not used —
522
- // we discriminate event type from the JSON payload.
643
+ if (done) {
644
+ for (const event of sseDecoder.push(decoder.decode())) {
645
+ const parsed = readEvent(event);
646
+ if (parsed) yield parsed;
523
647
  }
648
+ break;
649
+ }
524
650
 
525
- if (dataLines.length === 0) continue;
526
-
527
- try {
528
- let parsed = JSON.parse(dataLines.join("\n"));
529
-
530
- // Unwrap JSON-RPC envelope if present
531
- if (
532
- parsed &&
533
- typeof parsed === "object" &&
534
- "jsonrpc" in parsed &&
535
- "result" in parsed
536
- ) {
537
- parsed = parsed.result;
538
- }
539
-
540
- const normalized = normalizeKeys(parsed) as Record<string, unknown>;
541
- const event = discriminateStreamResponse(normalized);
542
- if (event) yield event;
543
- } catch {
544
- // Skip malformed events
545
- }
546
- eventEnd = buffer.indexOf("\n\n");
651
+ for (const event of sseDecoder.push(
652
+ decoder.decode(value, { stream: true }),
653
+ )) {
654
+ const parsed = readEvent(event);
655
+ if (parsed) yield parsed;
547
656
  }
548
657
  }
549
658
  } finally {