@assistant-ui/react-google-adk 0.0.20 → 0.0.22

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.
Files changed (41) hide show
  1. package/dist/AdkEventAccumulator.d.ts +1 -0
  2. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  3. package/dist/AdkEventAccumulator.js +20 -4
  4. package/dist/AdkEventAccumulator.js.map +1 -1
  5. package/dist/AdkSessionAdapter.d.ts +4 -4
  6. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  7. package/dist/AdkSessionAdapter.js +16 -3
  8. package/dist/AdkSessionAdapter.js.map +1 -1
  9. package/dist/convertAdkMessages.d.ts.map +1 -1
  10. package/dist/convertAdkMessages.js +18 -21
  11. package/dist/convertAdkMessages.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/server/adkEventStream.d.ts.map +1 -1
  14. package/dist/server/adkEventStream.js +11 -1
  15. package/dist/server/adkEventStream.js.map +1 -1
  16. package/dist/types.d.ts +22 -1
  17. package/dist/types.d.ts.map +1 -1
  18. package/dist/types.js.map +1 -1
  19. package/dist/useAdkMessages.d.ts +2 -1
  20. package/dist/useAdkMessages.d.ts.map +1 -1
  21. package/dist/useAdkMessages.js +19 -1
  22. package/dist/useAdkMessages.js.map +1 -1
  23. package/dist/useAdkRuntime.d.ts +10 -4
  24. package/dist/useAdkRuntime.d.ts.map +1 -1
  25. package/dist/useAdkRuntime.js +68 -29
  26. package/dist/useAdkRuntime.js.map +1 -1
  27. package/package.json +8 -5
  28. package/src/AdkEventAccumulator.test.ts +127 -0
  29. package/src/AdkEventAccumulator.ts +31 -5
  30. package/src/AdkSessionAdapter.test.ts +43 -0
  31. package/src/AdkSessionAdapter.ts +21 -4
  32. package/src/convertAdkMessages.test.ts +40 -6
  33. package/src/convertAdkMessages.ts +16 -15
  34. package/src/index.ts +1 -0
  35. package/src/server/adkEventStream.test.ts +38 -0
  36. package/src/server/adkEventStream.ts +16 -1
  37. package/src/types.ts +21 -0
  38. package/src/useAdkMessages.ts +24 -1
  39. package/src/useAdkRuntime.refetch.test.tsx +336 -0
  40. package/src/useAdkRuntime.test.ts +161 -0
  41. package/src/useAdkRuntime.ts +127 -28
@@ -0,0 +1,336 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
4
+ import { type FC, type ReactNode } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
7
+ import type {
8
+ AssistantRuntime,
9
+ RemoteThreadListAdapter,
10
+ } from "@assistant-ui/core";
11
+ import { useAui } from "@assistant-ui/store";
12
+ import { useAdkRuntime } from "./useAdkRuntime";
13
+ import type { AdkMessage, AdkThreadSnapshot } from "./types";
14
+
15
+ const deferred = <T,>() => {
16
+ let resolve!: (value: T) => void;
17
+ let reject!: (reason: unknown) => void;
18
+ const promise = new Promise<T>((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ return { promise, resolve, reject };
23
+ };
24
+
25
+ const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
26
+ list: vi.fn(async () => ({
27
+ threads: [
28
+ {
29
+ status: "regular" as const,
30
+ remoteId: "adk-1",
31
+ externalId: "adk-1",
32
+ title: "Existing ADK session",
33
+ },
34
+ ],
35
+ })),
36
+ initialize: vi.fn(async () => ({
37
+ remoteId: "adk-1",
38
+ externalId: "adk-1",
39
+ })),
40
+ rename: vi.fn(async () => {}),
41
+ archive: vi.fn(async () => {}),
42
+ unarchive: vi.fn(async () => {}),
43
+ delete: vi.fn(async () => {}),
44
+ generateTitle: vi.fn(async () => new ReadableStream() as never),
45
+ fetch: vi.fn(async () => ({
46
+ status: "regular" as const,
47
+ remoteId: "adk-1",
48
+ externalId: "adk-1",
49
+ title: "Existing ADK session",
50
+ })),
51
+ });
52
+
53
+ const wrapperFactory = (runtime: AssistantRuntime) => {
54
+ const Wrapper = ({ children }: { children: ReactNode }) => (
55
+ <AssistantRuntimeProvider runtime={runtime}>
56
+ {children}
57
+ </AssistantRuntimeProvider>
58
+ );
59
+ Wrapper.displayName = "TestWrapper";
60
+ return Wrapper;
61
+ };
62
+
63
+ const aiMessage = (id: string, text: string): AdkMessage => ({
64
+ id,
65
+ type: "ai",
66
+ content: [{ type: "text", text }],
67
+ });
68
+
69
+ const renderAdk = async (
70
+ load?: (
71
+ threadId: string,
72
+ options?: { signal?: AbortSignal | undefined },
73
+ ) => Promise<AdkThreadSnapshot>,
74
+ ) => {
75
+ const streamMock = vi.fn(async function* () {});
76
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
77
+
78
+ // the runtime hook's binder mounts inside the provider, so the provider has
79
+ // to be in the same tree as the hook for a thread switch to settle
80
+ const Inner: FC = () => {
81
+ const runtime = useAdkRuntime({
82
+ stream: streamMock as never,
83
+ ...(load ? { load } : {}),
84
+ sessionAdapter: makeThreadListAdapter(),
85
+ });
86
+ capture.runtime = runtime;
87
+ return (
88
+ <AssistantRuntimeProvider runtime={runtime}>
89
+ {null}
90
+ </AssistantRuntimeProvider>
91
+ );
92
+ };
93
+
94
+ let unmount!: () => void;
95
+ await act(async () => {
96
+ ({ unmount } = render(<Inner />));
97
+ });
98
+ await waitFor(() => expect(capture.runtime).not.toBeNull());
99
+
100
+ await act(async () => {
101
+ await capture.runtime!.threads.switchToThread("adk-1");
102
+ });
103
+
104
+ return { capture, streamMock, unmount };
105
+ };
106
+
107
+ describe("useAdkRuntime refetch", () => {
108
+ it("declares the refetch capability only when a load is supplied", async () => {
109
+ const withLoad = await renderAdk(async () => ({ messages: [] }));
110
+ expect(
111
+ withLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
112
+ ).toBe(true);
113
+
114
+ const withoutLoad = await renderAdk();
115
+ expect(
116
+ withoutLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
117
+ ).toBe(false);
118
+ });
119
+
120
+ it("refetches in place, keeping the composer draft and the runtime", async () => {
121
+ let call = 0;
122
+ const load = vi.fn(async () => {
123
+ call++;
124
+ return {
125
+ messages: [aiMessage(`m-${call}`, `load ${call}`)],
126
+ } satisfies AdkThreadSnapshot;
127
+ });
128
+
129
+ const { capture } = await renderAdk(load);
130
+ const wrapper = wrapperFactory(capture.runtime!);
131
+ const { result: auiResult } = renderHook(() => useAui(), { wrapper });
132
+
133
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
134
+ await waitFor(() =>
135
+ expect(
136
+ JSON.stringify(capture.runtime!.thread.getState().messages),
137
+ ).toContain("load 1"),
138
+ );
139
+
140
+ await act(async () => {
141
+ auiResult.current.composer.setText("draft that must survive");
142
+ });
143
+
144
+ await act(async () => {
145
+ await capture.runtime!.threads.reloadMainThread();
146
+ });
147
+
148
+ expect(load).toHaveBeenCalledTimes(2);
149
+ await waitFor(() =>
150
+ expect(
151
+ JSON.stringify(capture.runtime!.thread.getState().messages),
152
+ ).toContain("load 2"),
153
+ );
154
+ expect(auiResult.current.composer.getState().text).toBe(
155
+ "draft that must survive",
156
+ );
157
+ });
158
+
159
+ it("swaps the per-turn state over with the messages", async () => {
160
+ let call = 0;
161
+ const load = vi.fn(async () => {
162
+ call++;
163
+ return call === 1
164
+ ? {
165
+ messages: [aiMessage("m-1", "first")],
166
+ toolConfirmations: [
167
+ { toolCallId: "tc-1", toolName: "search", hint: "before" },
168
+ ],
169
+ }
170
+ : {
171
+ messages: [aiMessage("m-2", "second")],
172
+ toolConfirmations: [
173
+ { toolCallId: "tc-2", toolName: "search", hint: "after" },
174
+ ],
175
+ };
176
+ });
177
+
178
+ const { capture } = await renderAdk(load as never);
179
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
180
+
181
+ await act(async () => {
182
+ await capture.runtime!.threads.reloadMainThread();
183
+ });
184
+
185
+ await waitFor(() =>
186
+ expect(
187
+ JSON.stringify(capture.runtime!.thread.getState().messages),
188
+ ).toContain("second"),
189
+ );
190
+ // the confirmation from the fresh snapshot replaced the earlier one rather
191
+ // than the thread passing through a cleared state
192
+ const extras = capture.runtime!.thread.getState().extras as {
193
+ toolConfirmations: { hint: string }[];
194
+ };
195
+ expect(extras.toolConfirmations).toHaveLength(1);
196
+ expect(extras.toolConfirmations[0]!.hint).toBe("after");
197
+ });
198
+
199
+ it("leaves the thread untouched when the refetch fails, and rejects", async () => {
200
+ let call = 0;
201
+ const load = vi.fn(async () => {
202
+ call++;
203
+ if (call === 1) return { messages: [aiMessage("m-1", "first")] };
204
+ throw new Error("refetch failed");
205
+ });
206
+
207
+ const { capture } = await renderAdk(load as never);
208
+ await waitFor(() =>
209
+ expect(
210
+ JSON.stringify(capture.runtime!.thread.getState().messages),
211
+ ).toContain("first"),
212
+ );
213
+
214
+ await act(async () => {
215
+ await expect(capture.runtime!.threads.reloadMainThread()).rejects.toThrow(
216
+ "refetch failed",
217
+ );
218
+ });
219
+
220
+ expect(
221
+ JSON.stringify(capture.runtime!.thread.getState().messages),
222
+ ).toContain("first");
223
+ });
224
+
225
+ it("defers to an initial load still in flight rather than taking it over", async () => {
226
+ const pending = deferred<AdkThreadSnapshot>();
227
+ let call = 0;
228
+ const load = vi.fn(async () => {
229
+ call++;
230
+ return call === 1
231
+ ? pending.promise
232
+ : { messages: [aiMessage("m-2", "second")] };
233
+ });
234
+
235
+ const streamMock = vi.fn(async function* () {});
236
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
237
+ const Inner: FC = () => {
238
+ const runtime = useAdkRuntime({
239
+ stream: streamMock as never,
240
+ load: load as never,
241
+ sessionAdapter: makeThreadListAdapter(),
242
+ });
243
+ capture.runtime = runtime;
244
+ return (
245
+ <AssistantRuntimeProvider runtime={runtime}>
246
+ {null}
247
+ </AssistantRuntimeProvider>
248
+ );
249
+ };
250
+ await act(async () => {
251
+ render(<Inner />);
252
+ });
253
+ await act(async () => {
254
+ await capture.runtime!.threads.switchToThread("adk-1");
255
+ });
256
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
257
+
258
+ let settled = false;
259
+ act(() => {
260
+ capture.runtime!.threads.reloadMainThread().then(() => {
261
+ settled = true;
262
+ });
263
+ });
264
+
265
+ // the refetch waits on the initial load instead of starting a second one
266
+ expect(load).toHaveBeenCalledTimes(1);
267
+ expect(settled).toBe(false);
268
+
269
+ await act(async () => {
270
+ pending.resolve({ messages: [aiMessage("m-1", "first")] });
271
+ });
272
+ await waitFor(() => expect(settled).toBe(true));
273
+ expect(
274
+ JSON.stringify(capture.runtime!.thread.getState().messages),
275
+ ).toContain("first");
276
+ });
277
+
278
+ it("does not report an aborted load as a failure", async () => {
279
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
280
+ const pending = deferred<AdkThreadSnapshot>();
281
+ let call = 0;
282
+ const load = vi.fn(
283
+ async (_id: string, options?: { signal?: AbortSignal }) => {
284
+ call++;
285
+ if (call === 1) {
286
+ options?.signal?.addEventListener("abort", () =>
287
+ pending.reject(
288
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
289
+ ),
290
+ );
291
+ return pending.promise;
292
+ }
293
+ return { messages: [] };
294
+ },
295
+ );
296
+
297
+ const { unmount } = await renderAdk(load as never);
298
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
299
+
300
+ unmount();
301
+ await act(async () => {
302
+ await new Promise((resolve) => setTimeout(resolve, 0));
303
+ });
304
+
305
+ expect(warn).not.toHaveBeenCalled();
306
+ warn.mockRestore();
307
+ });
308
+
309
+ it("aborts a refetch still in flight when the thread unmounts", async () => {
310
+ const pending = deferred<AdkThreadSnapshot>();
311
+ let call = 0;
312
+ const signals: (AbortSignal | undefined)[] = [];
313
+ const load = vi.fn(
314
+ async (_id: string, options?: { signal?: AbortSignal }) => {
315
+ call++;
316
+ signals.push(options?.signal);
317
+ if (call === 1) return { messages: [] };
318
+ return pending.promise;
319
+ },
320
+ );
321
+
322
+ const { capture, unmount } = await renderAdk(load as never);
323
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
324
+
325
+ act(() => {
326
+ capture.runtime!.threads.reloadMainThread().catch(() => {});
327
+ });
328
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
329
+ expect(signals[1]?.aborted).toBe(false);
330
+
331
+ unmount();
332
+
333
+ expect(signals[1]?.aborted).toBe(true);
334
+ pending.resolve({ messages: [] });
335
+ });
336
+ });
@@ -4,6 +4,7 @@ import {
4
4
  getPendingCancellations,
5
5
  getPendingToolCalls,
6
6
  } from "./useAdkRuntime";
7
+ import { convertAdkMessage } from "./convertAdkMessages";
7
8
  import type { AppendMessage } from "@assistant-ui/core";
8
9
  import type { AdkMessage } from "./types";
9
10
 
@@ -209,6 +210,89 @@ describe("getMessageContent", () => {
209
210
  ]);
210
211
  });
211
212
 
213
+ it("emits a file_url part for file parts with sourceType url", () => {
214
+ const result = getMessageContent(
215
+ makeAppendMessage([
216
+ {
217
+ type: "file",
218
+ mimeType: "application/pdf",
219
+ data: "gs://bucket/report.pdf",
220
+ filename: "report.pdf",
221
+ sourceType: "url",
222
+ },
223
+ ]),
224
+ );
225
+ expect(result).toEqual([
226
+ {
227
+ type: "file_url",
228
+ url: "gs://bucket/report.pdf",
229
+ mimeType: "application/pdf",
230
+ },
231
+ ]);
232
+ });
233
+
234
+ it("keeps file parts inline without sourceType", () => {
235
+ const result = getMessageContent(
236
+ makeAppendMessage([
237
+ {
238
+ type: "file",
239
+ mimeType: "application/pdf",
240
+ data: "gs://bucket/report.pdf",
241
+ },
242
+ ]),
243
+ );
244
+ expect(result).toEqual([
245
+ {
246
+ type: "file",
247
+ mimeType: "application/pdf",
248
+ data: "gs://bucket/report.pdf",
249
+ },
250
+ ]);
251
+ });
252
+
253
+ it("round-trips a file_url part through convert and edit-resend", () => {
254
+ const converted = convertAdkMessage(
255
+ {
256
+ id: "m1",
257
+ type: "human",
258
+ content: [
259
+ {
260
+ type: "file_url",
261
+ url: "gs://bucket/report.pdf",
262
+ mimeType: "application/pdf",
263
+ },
264
+ ],
265
+ },
266
+ {},
267
+ );
268
+ const content = (converted as { content: AppendMessage["content"] })
269
+ .content;
270
+ const result = getMessageContent(makeAppendMessage(content));
271
+ expect(result).toEqual([
272
+ {
273
+ type: "file_url",
274
+ url: "gs://bucket/report.pdf",
275
+ mimeType: "application/pdf",
276
+ },
277
+ ]);
278
+ });
279
+
280
+ it("ignores sourceType id on file parts", () => {
281
+ const result = getMessageContent(
282
+ makeAppendMessage([
283
+ {
284
+ type: "file",
285
+ mimeType: "application/pdf",
286
+ data: "file-abc123",
287
+ sourceType: "id",
288
+ },
289
+ ]),
290
+ );
291
+ expect(result).toEqual([
292
+ { type: "file", mimeType: "application/pdf", data: "file-abc123" },
293
+ ]);
294
+ });
295
+
212
296
  it("forwards an audio part as a file block with the format-derived mime type", () => {
213
297
  const result = getMessageContent(
214
298
  makeAppendMessage([
@@ -245,6 +329,83 @@ describe("getMessageContent", () => {
245
329
  ]);
246
330
  });
247
331
 
332
+ it("strips a data URL envelope from file data", () => {
333
+ const result = getMessageContent(
334
+ makeAppendMessage([
335
+ {
336
+ type: "file",
337
+ data: "data:application/pdf;base64,QUJD",
338
+ mimeType: "application/pdf",
339
+ filename: "a.pdf",
340
+ },
341
+ ]),
342
+ );
343
+ expect(result).toEqual([
344
+ {
345
+ type: "file",
346
+ mimeType: "application/pdf",
347
+ data: "QUJD",
348
+ filename: "a.pdf",
349
+ },
350
+ ]);
351
+ });
352
+
353
+ it("emits a file_url part for an unmarked http source", () => {
354
+ const result = getMessageContent(
355
+ makeAppendMessage([
356
+ {
357
+ type: "file",
358
+ data: "https://cdn.example.com/a.pdf",
359
+ mimeType: "application/pdf",
360
+ },
361
+ ]),
362
+ );
363
+ expect(result).toEqual([
364
+ {
365
+ type: "file_url",
366
+ url: "https://cdn.example.com/a.pdf",
367
+ mimeType: "application/pdf",
368
+ },
369
+ ]);
370
+ });
371
+
372
+ it("leaves bare base64 file data untouched", () => {
373
+ const result = getMessageContent(
374
+ makeAppendMessage([
375
+ { type: "file", data: "QUJD", mimeType: "application/pdf" },
376
+ ]),
377
+ );
378
+ expect(result).toEqual([
379
+ { type: "file", mimeType: "application/pdf", data: "QUJD" },
380
+ ]);
381
+ });
382
+
383
+ it("round-trips an audio file part through both converters", () => {
384
+ const outbound = getMessageContent(
385
+ makeAppendMessage([
386
+ {
387
+ type: "file",
388
+ data: "data:audio/mp3;base64,QUJD",
389
+ mimeType: "audio/mp3",
390
+ },
391
+ ]),
392
+ );
393
+
394
+ expect(outbound).toEqual([
395
+ { type: "file", mimeType: "audio/mp3", data: "QUJD" },
396
+ ]);
397
+
398
+ const inbound = convertAdkMessage(
399
+ { id: "m1", type: "human", content: outbound } as never,
400
+ {},
401
+ );
402
+
403
+ expect(inbound).toMatchObject({
404
+ role: "user",
405
+ content: [{ type: "file", data: "QUJD", mimeType: "audio/mp3" }],
406
+ });
407
+ });
408
+
248
409
  it("skips data parts while keeping surrounding text", () => {
249
410
  const result = getMessageContent(
250
411
  makeAppendMessage([