@assistant-ui/react-a2a 0.2.36 → 0.2.38

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.
@@ -15,6 +15,51 @@ function isImageMediaType(mediaType?: string): boolean {
15
15
  return !!mediaType && mediaType.startsWith("image/");
16
16
  }
17
17
 
18
+ const A2UI_OPERATION_KEYS = [
19
+ "createSurface",
20
+ "updateComponents",
21
+ "updateDataModel",
22
+ "deleteSurface",
23
+ ] as const;
24
+
25
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
26
+ typeof value === "object" && value !== null && !Array.isArray(value);
27
+
28
+ const isA2uiOperation = (value: unknown): boolean =>
29
+ isRecord(value) &&
30
+ typeof value.version === "string" &&
31
+ A2UI_OPERATION_KEYS.some((key) => Object.hasOwn(value, key));
32
+
33
+ const hasA2uiMediaType = (value: unknown): boolean =>
34
+ typeof value === "string" && value.toLowerCase().includes("a2ui");
35
+
36
+ export function isA2uiDataPart(part: A2APart): boolean {
37
+ if (part.data === undefined) return false;
38
+
39
+ const metadata = isRecord(part.metadata) ? part.metadata : {};
40
+ return (
41
+ hasA2uiMediaType(part.mediaType) ||
42
+ hasA2uiMediaType(metadata.mimeType) ||
43
+ hasA2uiMediaType(metadata.mediaType) ||
44
+ isA2uiOperation(part.data) ||
45
+ (Array.isArray(part.data) &&
46
+ part.data.length > 0 &&
47
+ part.data.every(isA2uiOperation))
48
+ );
49
+ }
50
+
51
+ export function a2uiPartToOperations(part: A2APart): readonly unknown[] {
52
+ if (!isA2uiDataPart(part)) return [];
53
+ return Array.isArray(part.data) ? part.data : [part.data];
54
+ }
55
+
56
+ export function a2uiPartsToOperations(
57
+ parts: readonly A2APart[],
58
+ ): readonly unknown[] {
59
+ if (!Array.isArray(parts)) return [];
60
+ return parts.flatMap(a2uiPartToOperations);
61
+ }
62
+
18
63
  export function a2aPartToContent(
19
64
  part: A2APart,
20
65
  ): ThreadAssistantMessage["content"][number] {
@@ -61,7 +106,9 @@ export function a2aPartToContent(
61
106
  export function a2aPartsToContent(
62
107
  parts: A2APart[],
63
108
  ): ThreadAssistantMessage["content"] {
64
- return (Array.isArray(parts) ? parts : []).map(a2aPartToContent);
109
+ return (Array.isArray(parts) ? parts : [])
110
+ .filter((part) => !isA2uiDataPart(part))
111
+ .map(a2aPartToContent);
65
112
  }
66
113
 
67
114
  const TERMINAL_STATES = new Set<A2ATaskState>([
@@ -360,6 +360,251 @@ describe("useA2ARuntime", () => {
360
360
  ]);
361
361
  });
362
362
 
363
+ it("does not keep the previous thread as a sibling branch after a switch", async () => {
364
+ const { client } = createMockClient();
365
+ let resolveNext!: (value: { messages: ThreadMessage[] }) => void;
366
+ let pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
367
+ resolveNext = resolve;
368
+ });
369
+ const { result } = renderHook(() => {
370
+ const [threadId, setThreadId] = useState("initial");
371
+ return useA2ARuntime({
372
+ client,
373
+ adapters: {
374
+ threadList: {
375
+ threadId,
376
+ onSwitchToThread: async (nextThreadId) => {
377
+ setThreadId(nextThreadId);
378
+ return pending;
379
+ },
380
+ },
381
+ },
382
+ });
383
+ });
384
+
385
+ await act(async () => {
386
+ const switchA = result.current.threads.switchToThread("thread-a");
387
+ resolveNext({ messages: [createThreadMessage("thread-a")] });
388
+ await switchA;
389
+ });
390
+ expect(
391
+ result.current.thread.export().messages.map((m) => m.message.id),
392
+ ).toEqual(["thread-a"]);
393
+
394
+ pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
395
+ resolveNext = resolve;
396
+ });
397
+ let switchB!: Promise<void>;
398
+ act(() => {
399
+ switchB = result.current.threads.switchToThread("thread-b");
400
+ });
401
+ expect(result.current.thread.export().messages).toEqual([]);
402
+
403
+ await act(async () => {
404
+ resolveNext({ messages: [createThreadMessage("thread-b")] });
405
+ await switchB;
406
+ });
407
+ expect(
408
+ result.current.thread.export().messages.map((m) => m.message.id),
409
+ ).toEqual(["thread-b"]);
410
+ });
411
+
412
+ it("does not keep the previous thread as a sibling branch after switching to a new thread", async () => {
413
+ const { client, streamMessage } = createMockClient(true);
414
+ let resolveNext!: (value: { messages: ThreadMessage[] }) => void;
415
+ let pending = new Promise<{ messages: ThreadMessage[] }>((resolve) => {
416
+ resolveNext = resolve;
417
+ });
418
+ let resolveNew!: () => void;
419
+ const pendingNew = new Promise<void>((resolve) => {
420
+ resolveNew = resolve;
421
+ });
422
+ const { result } = renderHook(() => {
423
+ const [threadId, setThreadId] = useState("initial");
424
+ return useA2ARuntime({
425
+ client,
426
+ adapters: {
427
+ threadList: {
428
+ threadId,
429
+ onSwitchToThread: async (nextThreadId) => {
430
+ setThreadId(nextThreadId);
431
+ return pending;
432
+ },
433
+ onSwitchToNewThread: async () => {
434
+ setThreadId("thread-new");
435
+ await pendingNew;
436
+ },
437
+ },
438
+ },
439
+ });
440
+ });
441
+
442
+ await act(async () => {
443
+ const switchA = result.current.threads.switchToThread("thread-a");
444
+ resolveNext({ messages: [createThreadMessage("thread-a")] });
445
+ await switchA;
446
+ });
447
+ expect(
448
+ result.current.thread.export().messages.map((m) => m.message.id),
449
+ ).toEqual(["thread-a"]);
450
+
451
+ act(() => {
452
+ void result.current.thread.append("still running");
453
+ });
454
+ await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
455
+
456
+ let switchNew!: Promise<void>;
457
+ act(() => {
458
+ switchNew = result.current.threads.switchToNewThread();
459
+ });
460
+ expect(result.current.thread.export().messages).toEqual([]);
461
+
462
+ await act(async () => {
463
+ resolveNew();
464
+ await switchNew;
465
+ });
466
+ expect(result.current.thread.export().messages).toEqual([]);
467
+ });
468
+
469
+ it("leaves a new thread empty after an active run and failed creation", async () => {
470
+ const { client, streamMessage } = createMockClient(true);
471
+ let rejectNew!: (error: Error) => void;
472
+ const pendingNew = new Promise<void>((_, reject) => {
473
+ rejectNew = reject;
474
+ });
475
+ const { result } = renderHook(() => {
476
+ const [threadId, setThreadId] = useState("initial");
477
+ return useA2ARuntime({
478
+ client,
479
+ adapters: {
480
+ threadList: {
481
+ threadId,
482
+ onSwitchToNewThread: async () => {
483
+ setThreadId("thread-new");
484
+ await pendingNew;
485
+ },
486
+ },
487
+ },
488
+ });
489
+ });
490
+
491
+ act(() => {
492
+ void result.current.thread.append("still running");
493
+ });
494
+ await waitFor(() => expect(streamMessage).toHaveBeenCalledOnce());
495
+
496
+ let switchNew!: Promise<void>;
497
+ act(() => {
498
+ switchNew = result.current.threads.switchToNewThread();
499
+ });
500
+ await waitFor(() =>
501
+ expect(result.current.threads.getState().mainThreadId).toBe("thread-new"),
502
+ );
503
+ rejectNew(new Error("create failed"));
504
+ await expect(switchNew).rejects.toThrow("create failed");
505
+
506
+ expect(result.current.thread.export()).toEqual({
507
+ headId: null,
508
+ messages: [],
509
+ });
510
+ });
511
+
512
+ it("does not clear a newer thread when an older creation finishes", async () => {
513
+ const { client } = createMockClient();
514
+ let resolveNew!: () => void;
515
+ const pendingNew = new Promise<void>((resolve) => {
516
+ resolveNew = resolve;
517
+ });
518
+ const { result } = renderHook(() => {
519
+ const [threadId, setThreadId] = useState("initial");
520
+ return useA2ARuntime({
521
+ client,
522
+ adapters: {
523
+ threadList: {
524
+ threadId,
525
+ onSwitchToThread: async (nextThreadId) => {
526
+ setThreadId(nextThreadId);
527
+ return { messages: [createThreadMessage(nextThreadId)] };
528
+ },
529
+ onSwitchToNewThread: async () => {
530
+ setThreadId("thread-new");
531
+ await pendingNew;
532
+ },
533
+ },
534
+ },
535
+ });
536
+ });
537
+
538
+ let switchNew!: Promise<void>;
539
+ act(() => {
540
+ switchNew = result.current.threads.switchToNewThread();
541
+ });
542
+ await act(async () => {
543
+ await result.current.threads.switchToThread("thread-b");
544
+ });
545
+ await act(async () => {
546
+ resolveNew();
547
+ await switchNew;
548
+ });
549
+
550
+ expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
551
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
552
+ "thread-b",
553
+ ]);
554
+ });
555
+
556
+ it("does not restore initial history next to the switched thread", async () => {
557
+ const { client } = createMockClient();
558
+ let resolveHistory!: (repo: ExportedMessageRepository) => void;
559
+ const pendingHistory = new Promise<ExportedMessageRepository>((resolve) => {
560
+ resolveHistory = resolve;
561
+ });
562
+ let resolveSwitch!: (value: { messages: ThreadMessage[] }) => void;
563
+ const pendingSwitch = new Promise<{ messages: ThreadMessage[] }>(
564
+ (resolve) => {
565
+ resolveSwitch = resolve;
566
+ },
567
+ );
568
+ const { result } = renderHook(() => {
569
+ const [threadId, setThreadId] = useState("initial");
570
+ return useA2ARuntime({
571
+ client,
572
+ adapters: {
573
+ history: { load: () => pendingHistory, append: async () => {} },
574
+ threadList: {
575
+ threadId,
576
+ onSwitchToThread: async (nextThreadId) => {
577
+ setThreadId(nextThreadId);
578
+ return pendingSwitch;
579
+ },
580
+ },
581
+ },
582
+ });
583
+ });
584
+
585
+ let switchB!: Promise<void>;
586
+ act(() => {
587
+ switchB = result.current.threads.switchToThread("thread-b");
588
+ });
589
+ await act(async () => {
590
+ resolveHistory({
591
+ headId: "history-a",
592
+ messages: [
593
+ { parentId: null, message: createThreadMessage("history-a") },
594
+ ],
595
+ });
596
+ await pendingHistory;
597
+ });
598
+ await act(async () => {
599
+ resolveSwitch({ messages: [createThreadMessage("thread-b")] });
600
+ await switchB;
601
+ });
602
+
603
+ expect(
604
+ result.current.thread.export().messages.map((m) => m.message.id),
605
+ ).toEqual(["thread-b"]);
606
+ });
607
+
363
608
  it("keeps pending history when a run is cancelled in the same thread", async () => {
364
609
  const { client, streamMessage } = createMockClient(true);
365
610
  let resolve!: (repo: ExportedMessageRepository) => void;
@@ -121,6 +121,10 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
121
121
  onSwitchToNewThread: onSwitchToNewThread
122
122
  ? async () => {
123
123
  const generation = ++threadSwitchGenerationRef.current;
124
+ // Clear before the thread id flips, or the old messages leak
125
+ // into the new thread as a sibling branch.
126
+ core.applyExternalMessages([]);
127
+ core.resetContext();
124
128
  await onSwitchToNewThread();
125
129
  if (generation !== threadSwitchGenerationRef.current) return;
126
130
  // Apply first so the abort inside resetContext finds an already
@@ -133,8 +137,13 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
133
137
  onSwitchToThread: onSwitchToThread
134
138
  ? async (threadId: string) => {
135
139
  const generation = ++threadSwitchGenerationRef.current;
140
+ // Clear before the thread id flips, or the old messages leak
141
+ // into the new thread as a sibling branch.
142
+ core.applyExternalMessages([]);
143
+ core.resetContext();
136
144
  const result = await onSwitchToThread(threadId);
137
145
  if (generation !== threadSwitchGenerationRef.current) return;
146
+ core.applyExternalMessages([]);
138
147
  core.applyExternalMessages(result.messages);
139
148
  core.resetContext();
140
149
  }
@@ -166,15 +175,20 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
166
175
  isLoading: core.isLoading,
167
176
  messageRepository: core.getMessageRepository(),
168
177
  isRunning: core.isRunning(),
178
+ unstable_persistsHistory: true,
169
179
  extras: a2aExtras.provide({
170
180
  task: core.getTask(),
171
181
  artifacts: core.getArtifacts(),
172
182
  agentCard: core.getAgentCard(),
173
183
  }),
174
184
  onNew: (message: AppendMessage) => core.append(message),
185
+ onVoiceTranscript: (message: ThreadMessage) =>
186
+ core.appendVoiceTranscript(message),
175
187
  onEdit: (message: AppendMessage) => core.edit(message),
176
188
  onReload: (parentId: string | null) => core.reload(parentId),
177
189
  onCancel: () => core.cancel(),
190
+ unstable_onRecordToolInteraction: (options) =>
191
+ core.recordToolInteraction(options),
178
192
  setMessages: (messages: readonly ThreadMessage[]) =>
179
193
  core.applyExternalMessages(messages),
180
194
  onImport: (messages: readonly ThreadMessage[]) =>