@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.
@@ -0,0 +1,370 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import type {
5
+ RealtimeVoiceAdapter,
6
+ ThreadHistoryAdapter,
7
+ ThreadMessage,
8
+ } from "@assistant-ui/core";
9
+ import { getThreadMessageText } from "@assistant-ui/core/internal";
10
+ import { describe, expect, it, vi } from "vitest";
11
+ import type { A2AClient } from "./A2AClient";
12
+ import type { A2AStreamEvent } from "./types";
13
+ import { a2aExtras } from "./a2aExtras";
14
+ import { useA2ARuntime } from "./useA2ARuntime";
15
+
16
+ const createMockClient = () => {
17
+ const mocks = {
18
+ getAgentCard: vi.fn().mockResolvedValue(undefined),
19
+ streamMessage: vi.fn(
20
+ (_message: unknown): AsyncIterable<A2AStreamEvent> => ({
21
+ async *[Symbol.asyncIterator]() {
22
+ yield {
23
+ type: "task",
24
+ task: {
25
+ id: "task-1",
26
+ contextId: "existing-context",
27
+ status: {
28
+ state: "completed",
29
+ message: {
30
+ messageId: "response",
31
+ role: "agent",
32
+ parts: [{ text: "Done" }],
33
+ },
34
+ },
35
+ artifacts: [
36
+ { artifactId: "artifact-1", parts: [{ text: "Artifact" }] },
37
+ ],
38
+ },
39
+ };
40
+ },
41
+ }),
42
+ ),
43
+ sendMessage: vi.fn(),
44
+ cancelTask: vi.fn(),
45
+ getTask: vi.fn(),
46
+ listTasks: vi.fn(),
47
+ subscribeToTask: vi.fn(),
48
+ getExtendedAgentCard: vi.fn(),
49
+ createTaskPushNotificationConfig: vi.fn(),
50
+ getTaskPushNotificationConfig: vi.fn(),
51
+ listTaskPushNotificationConfigs: vi.fn(),
52
+ deleteTaskPushNotificationConfig: vi.fn(),
53
+ };
54
+ return { client: mocks as unknown as A2AClient, mocks };
55
+ };
56
+
57
+ const createVoiceAdapter = (
58
+ sendText?: RealtimeVoiceAdapter.Session["sendText"],
59
+ ) => {
60
+ let transcriptCallback:
61
+ | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
62
+ | undefined;
63
+ const session: RealtimeVoiceAdapter.Session = {
64
+ status: { type: "running" },
65
+ isMuted: false,
66
+ disconnect: vi.fn(),
67
+ mute: vi.fn(),
68
+ unmute: vi.fn(),
69
+ ...(sendText && { sendText }),
70
+ onStatusChange: () => () => {},
71
+ onTranscript: (callback) => {
72
+ transcriptCallback = callback;
73
+ return () => {
74
+ transcriptCallback = undefined;
75
+ };
76
+ },
77
+ onModeChange: () => () => {},
78
+ onVolumeChange: () => () => {},
79
+ };
80
+ return {
81
+ adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
82
+ emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
83
+ transcriptCallback?.(transcript),
84
+ };
85
+ };
86
+
87
+ const priorMessage: ThreadMessage = {
88
+ id: "prior-head",
89
+ role: "user",
90
+ content: [{ type: "text", text: "Earlier turn" }],
91
+ attachments: [],
92
+ createdAt: new Date(0),
93
+ metadata: { custom: {} },
94
+ };
95
+
96
+ const createHistory = () => ({
97
+ load: vi.fn().mockResolvedValue({
98
+ headId: priorMessage.id,
99
+ messages: [{ parentId: null, message: priorMessage }],
100
+ }),
101
+ append: vi.fn<ThreadHistoryAdapter["append"]>().mockResolvedValue(undefined),
102
+ });
103
+
104
+ const renderVoiceRuntime = async (
105
+ options: {
106
+ history?: ThreadHistoryAdapter;
107
+ sendText?: RealtimeVoiceAdapter.Session["sendText"];
108
+ } = {},
109
+ ) => {
110
+ const { client, mocks } = createMockClient();
111
+ const voice = createVoiceAdapter(options.sendText);
112
+ const rendered = renderHook(() =>
113
+ useA2ARuntime({
114
+ client,
115
+ adapters: {
116
+ voice: voice.adapter,
117
+ ...(options.history && { history: options.history }),
118
+ },
119
+ }),
120
+ );
121
+ await waitFor(() => {
122
+ expect(rendered.result.current.thread.getState().isLoading).toBe(false);
123
+ expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
124
+ true,
125
+ );
126
+ expect(mocks.getAgentCard).toHaveBeenCalledOnce();
127
+ });
128
+ return { ...rendered, mocks, voice };
129
+ };
130
+
131
+ describe("useA2ARuntime voice transcripts", () => {
132
+ it("keeps finalized user and assistant transcripts unchanged after disconnect", async () => {
133
+ const { result, voice, mocks } = await renderVoiceRuntime();
134
+ const callsBefore = Object.values(mocks).map(
135
+ (mock) => mock.mock.calls.length,
136
+ );
137
+ let user!: ThreadMessage;
138
+ let assistant!: ThreadMessage;
139
+
140
+ act(() => {
141
+ result.current.thread.connectVoice();
142
+ voice.emitTranscript({
143
+ role: "user",
144
+ text: "Spoken user",
145
+ isFinal: true,
146
+ });
147
+ user = result.current.thread.getState().messages.at(-1)!;
148
+ voice.emitTranscript({
149
+ role: "assistant",
150
+ text: "Spoken assistant",
151
+ isFinal: true,
152
+ });
153
+ assistant = result.current.thread.getState().messages.at(-1)!;
154
+ });
155
+
156
+ expect(user).toMatchObject({
157
+ id: expect.any(String),
158
+ role: "user",
159
+ content: [{ type: "text", text: "Spoken user" }],
160
+ metadata: { modality: "voice" },
161
+ });
162
+ expect(assistant).toMatchObject({
163
+ id: expect.any(String),
164
+ role: "assistant",
165
+ content: [{ type: "text", text: "Spoken assistant" }],
166
+ metadata: { modality: "voice" },
167
+ });
168
+ expect(user.id).not.toBe(assistant.id);
169
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
170
+ user.id,
171
+ assistant.id,
172
+ ]);
173
+
174
+ act(() => result.current.thread.disconnectVoice());
175
+
176
+ const messages = result.current.thread.getState().messages;
177
+ expect(messages).toHaveLength(2);
178
+ expect(messages[0]).toBe(user);
179
+ expect(messages[1]).toBe(assistant);
180
+ expect(messages[0]?.metadata).toBe(user.metadata);
181
+ expect(messages[1]?.metadata).toBe(assistant.metadata);
182
+ expect(result.current.thread.getState().voice).toBeUndefined();
183
+ expect(Object.values(mocks).map((mock) => mock.mock.calls.length)).toEqual(
184
+ callsBefore,
185
+ );
186
+ });
187
+
188
+ it("appends each transcript once to history under the current head", async () => {
189
+ const history = createHistory();
190
+ const { result, voice } = await renderVoiceRuntime({ history });
191
+
192
+ act(() => {
193
+ result.current.thread.connectVoice();
194
+ voice.emitTranscript({
195
+ role: "user",
196
+ text: "Spoken user",
197
+ isFinal: true,
198
+ });
199
+ voice.emitTranscript({
200
+ role: "assistant",
201
+ text: "Spoken assistant",
202
+ isFinal: true,
203
+ });
204
+ });
205
+
206
+ const [, user, assistant] = result.current.thread.getState().messages;
207
+ expect(history.append).toHaveBeenCalledTimes(2);
208
+ expect(history.append).toHaveBeenNthCalledWith(1, {
209
+ parentId: priorMessage.id,
210
+ message: user,
211
+ });
212
+ expect(history.append).toHaveBeenNthCalledWith(2, {
213
+ parentId: user!.id,
214
+ message: assistant,
215
+ });
216
+ expect(history.append.mock.calls[0]![0].message).toBe(user);
217
+ expect(history.append.mock.calls[1]![0].message).toBe(assistant);
218
+
219
+ act(() => result.current.thread.disconnectVoice());
220
+
221
+ expect(result.current.thread.export()).toEqual({
222
+ headId: assistant!.id,
223
+ messages: [
224
+ { parentId: null, message: priorMessage },
225
+ { parentId: priorMessage.id, message: user },
226
+ { parentId: user!.id, message: assistant },
227
+ ],
228
+ });
229
+ expect(history.append).toHaveBeenCalledTimes(2);
230
+ });
231
+
232
+ it("restores persisted transcripts when the next runtime loads its history", async () => {
233
+ const appended: Parameters<ThreadHistoryAdapter["append"]>[0][] = [];
234
+ const firstHistory = createHistory();
235
+ firstHistory.append.mockImplementation(async (item) => {
236
+ appended.push(item);
237
+ });
238
+ const first = await renderVoiceRuntime({ history: firstHistory });
239
+ act(() => {
240
+ first.result.current.thread.connectVoice();
241
+ first.voice.emitTranscript({
242
+ role: "user",
243
+ text: "Spoken user",
244
+ isFinal: true,
245
+ });
246
+ first.voice.emitTranscript({
247
+ role: "assistant",
248
+ text: "Spoken assistant",
249
+ isFinal: true,
250
+ });
251
+ });
252
+ act(() => first.result.current.thread.disconnectVoice());
253
+ first.unmount();
254
+
255
+ const secondHistory = createHistory();
256
+ secondHistory.load.mockResolvedValue({
257
+ headId: appended.at(-1)!.message.id,
258
+ messages: [{ parentId: null, message: priorMessage }, ...appended],
259
+ });
260
+ const second = await renderVoiceRuntime({ history: secondHistory });
261
+
262
+ expect(
263
+ second.result.current.thread
264
+ .getState()
265
+ .messages.map((message) => [
266
+ message.id,
267
+ message.role,
268
+ getThreadMessageText(message),
269
+ message.metadata.modality,
270
+ ]),
271
+ ).toEqual([
272
+ [priorMessage.id, "user", "Earlier turn", undefined],
273
+ [appended[0]!.message.id, "user", "Spoken user", "voice"],
274
+ [appended[1]!.message.id, "assistant", "Spoken assistant", "voice"],
275
+ ]);
276
+ });
277
+
278
+ it("preserves A2A state and sends only the next typed turn with the existing context", async () => {
279
+ const { result, voice, mocks } = await renderVoiceRuntime();
280
+ await act(async () => {
281
+ await result.current.thread.append("First typed turn");
282
+ });
283
+ expect(mocks.streamMessage).toHaveBeenCalledOnce();
284
+ const before = a2aExtras.tryGet(result.current.thread.getState().extras)!;
285
+ expect(before.task?.id).toBe("task-1");
286
+ expect(before.artifacts).toHaveLength(1);
287
+ const callsBefore = Object.values(mocks).map(
288
+ (mock) => mock.mock.calls.length,
289
+ );
290
+
291
+ act(() => {
292
+ result.current.thread.connectVoice();
293
+ voice.emitTranscript({
294
+ role: "user",
295
+ text: "Spoken user",
296
+ isFinal: true,
297
+ });
298
+ voice.emitTranscript({
299
+ role: "assistant",
300
+ text: "Spoken assistant",
301
+ isFinal: true,
302
+ });
303
+ });
304
+
305
+ const after = a2aExtras.tryGet(result.current.thread.getState().extras)!;
306
+ expect(after.task).toBe(before.task);
307
+ expect(after.artifacts).toBe(before.artifacts);
308
+ expect(Object.values(mocks).map((mock) => mock.mock.calls.length)).toEqual(
309
+ callsBefore,
310
+ );
311
+
312
+ act(() => result.current.thread.disconnectVoice());
313
+ expect(result.current.thread.getState().isRunning).toBe(false);
314
+ expect(result.current.thread.getState().messages).toHaveLength(4);
315
+
316
+ await act(async () => {
317
+ await result.current.thread.append("Next typed turn");
318
+ });
319
+
320
+ expect(mocks.streamMessage).toHaveBeenCalledTimes(2);
321
+ expect(mocks.streamMessage.mock.calls[1]![0]).toMatchObject({
322
+ role: "user",
323
+ parts: [{ text: "Next typed turn" }],
324
+ contextId: "existing-context",
325
+ });
326
+ expect(mocks.sendMessage).not.toHaveBeenCalled();
327
+ expect(mocks.cancelTask).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("persists text sent into the voice session without voice modality or an A2A call", async () => {
331
+ const history = createHistory();
332
+ const sendText = vi.fn(async (_text: string) => {});
333
+ const { result, mocks } = await renderVoiceRuntime({ history, sendText });
334
+ const callsBefore = Object.values(mocks).map(
335
+ (mock) => mock.mock.calls.length,
336
+ );
337
+
338
+ act(() => result.current.thread.connectVoice());
339
+ await act(async () => {
340
+ await result.current.thread.append("Typed into voice");
341
+ });
342
+
343
+ expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed into voice");
344
+ const typed = result.current.thread.getState().messages.at(-1)!;
345
+ expect(typed).toMatchObject({
346
+ id: expect.any(String),
347
+ role: "user",
348
+ content: [{ type: "text", text: "Typed into voice" }],
349
+ });
350
+ expect(typed.metadata.modality).toBeUndefined();
351
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
352
+ priorMessage.id,
353
+ typed.id,
354
+ ]);
355
+ expect(history.append).toHaveBeenCalledExactlyOnceWith({
356
+ parentId: priorMessage.id,
357
+ message: typed,
358
+ });
359
+ expect(history.append.mock.calls[0]![0].message).toBe(typed);
360
+
361
+ act(() => result.current.thread.disconnectVoice());
362
+
363
+ expect(result.current.thread.getState().messages).toHaveLength(2);
364
+ expect(result.current.thread.getState().messages[1]).toBe(typed);
365
+ expect(history.append).toHaveBeenCalledOnce();
366
+ expect(Object.values(mocks).map((mock) => mock.mock.calls.length)).toEqual(
367
+ callsBefore,
368
+ );
369
+ });
370
+ });