@assistant-ui/react-google-adk 0.0.25 → 0.0.27

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 (58) hide show
  1. package/dist/AdkClient.d.ts.map +1 -1
  2. package/dist/AdkClient.js +13 -3
  3. package/dist/AdkClient.js.map +1 -1
  4. package/dist/AdkEventAccumulator.d.ts +2 -0
  5. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  6. package/dist/AdkEventAccumulator.js +32 -10
  7. package/dist/AdkEventAccumulator.js.map +1 -1
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +2 -1
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkToolApproval.d.ts +40 -0
  12. package/dist/adkToolApproval.d.ts.map +1 -0
  13. package/dist/adkToolApproval.js +166 -0
  14. package/dist/adkToolApproval.js.map +1 -0
  15. package/dist/convertAdkMessages.d.ts +3 -1
  16. package/dist/convertAdkMessages.d.ts.map +1 -1
  17. package/dist/convertAdkMessages.js +27 -16
  18. package/dist/convertAdkMessages.js.map +1 -1
  19. package/dist/convertToAdkMessages.d.ts +49 -0
  20. package/dist/convertToAdkMessages.d.ts.map +1 -0
  21. package/dist/convertToAdkMessages.js +86 -0
  22. package/dist/convertToAdkMessages.js.map +1 -0
  23. package/dist/hooks.d.ts.map +1 -1
  24. package/dist/hooks.js +5 -14
  25. package/dist/hooks.js.map +1 -1
  26. package/dist/parseAdkEvent.d.ts +6 -0
  27. package/dist/parseAdkEvent.d.ts.map +1 -0
  28. package/dist/parseAdkEvent.js +16 -0
  29. package/dist/parseAdkEvent.js.map +1 -0
  30. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  31. package/dist/server/parseAdkRequest.js +79 -13
  32. package/dist/server/parseAdkRequest.js.map +1 -1
  33. package/dist/useAdkMessages.d.ts.map +1 -1
  34. package/dist/useAdkMessages.js +110 -79
  35. package/dist/useAdkMessages.js.map +1 -1
  36. package/dist/useAdkRuntime.d.ts.map +1 -1
  37. package/dist/useAdkRuntime.js +44 -129
  38. package/dist/useAdkRuntime.js.map +1 -1
  39. package/package.json +6 -7
  40. package/src/AdkClient.test.ts +137 -0
  41. package/src/AdkClient.ts +15 -3
  42. package/src/AdkEventAccumulator.test.ts +161 -1
  43. package/src/AdkEventAccumulator.ts +44 -8
  44. package/src/AdkSessionAdapter.test.ts +145 -1
  45. package/src/AdkSessionAdapter.ts +7 -2
  46. package/src/adkToolApproval.test.ts +345 -0
  47. package/src/adkToolApproval.ts +215 -0
  48. package/src/convertAdkMessages.ts +72 -43
  49. package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
  50. package/src/convertToAdkMessages.ts +139 -0
  51. package/src/hooks.ts +7 -19
  52. package/src/parseAdkEvent.ts +40 -0
  53. package/src/server/parseAdkRequest.test.ts +141 -0
  54. package/src/server/parseAdkRequest.ts +145 -13
  55. package/src/useAdkMessages.test.ts +313 -0
  56. package/src/useAdkMessages.ts +72 -25
  57. package/src/useAdkRuntime.ts +66 -188
  58. package/src/useAdkRuntimeApproval.test.tsx +344 -0
@@ -15,13 +15,18 @@ vi.mock("@assistant-ui/store", async (importOriginal) => ({
15
15
 
16
16
  import {
17
17
  messageToEvent,
18
+ messagesToEvents,
18
19
  useAdkMessages,
19
20
  type UseAdkMessagesOptions,
20
21
  } from "./useAdkMessages";
22
+ import { projectAdkToolApprovals } from "./adkToolApproval";
23
+ import { createAdkStream } from "./AdkClient";
24
+ import { AdkEventAccumulator } from "./AdkEventAccumulator";
21
25
  import type { AdkEvent, AdkMessage, AdkStreamCallback } from "./types";
22
26
 
23
27
  afterEach(() => {
24
28
  vi.restoreAllMocks();
29
+ vi.unstubAllGlobals();
25
30
  });
26
31
 
27
32
  describe("ADK runtime callbacks", () => {
@@ -125,6 +130,314 @@ describe("ADK runtime callbacks", () => {
125
130
  });
126
131
  });
127
132
 
133
+ describe("ADK stream lifecycle", () => {
134
+ it("aborts the active stream when the hook unmounts", async () => {
135
+ let runSignal: AbortSignal | undefined;
136
+ let resolveStarted!: () => void;
137
+ const started = new Promise<void>((resolve) => {
138
+ resolveStarted = resolve;
139
+ });
140
+ const stream: AdkStreamCallback = async function* (
141
+ _messages,
142
+ { abortSignal },
143
+ ) {
144
+ runSignal = abortSignal;
145
+ resolveStarted();
146
+ await new Promise<void>((resolve) => {
147
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
148
+ });
149
+ };
150
+ const { result, unmount } = renderHook(() => useAdkMessages({ stream }));
151
+
152
+ let sendPromise!: Promise<void>;
153
+ act(() => {
154
+ sendPromise = result.current.sendMessage(
155
+ [{ id: "user", type: "human", content: "hello" }],
156
+ {},
157
+ );
158
+ });
159
+ await started;
160
+
161
+ unmount();
162
+
163
+ expect(runSignal?.aborted).toBe(true);
164
+ await expect(sendPromise).resolves.toBeUndefined();
165
+ });
166
+ });
167
+
168
+ describe("optimistic confirmation replies", () => {
169
+ const confirmationCall = (id: string): AdkMessage => ({
170
+ id: `ai-${id}`,
171
+ type: "ai",
172
+ content: [],
173
+ tool_calls: [
174
+ {
175
+ id,
176
+ name: "adk_request_confirmation",
177
+ args: {
178
+ originalFunctionCall: { id: `orig-${id}`, name: "delete_file" },
179
+ toolConfirmation: { hint: "Delete?" },
180
+ },
181
+ },
182
+ ],
183
+ });
184
+
185
+ /**
186
+ * ADK parses the text inside its client wrapper without a `try`, so a wrapper
187
+ * holding text that is not JSON raises and abandons the whole event.
188
+ */
189
+ const UNREADABLE_REPLY = JSON.stringify({ response: "not json" });
190
+
191
+ const confirmationReply = (
192
+ id: string,
193
+ toolCallId: string,
194
+ content: string,
195
+ ): AdkMessage => ({
196
+ id,
197
+ type: "tool",
198
+ tool_call_id: toolCallId,
199
+ name: "adk_request_confirmation",
200
+ content,
201
+ status: "success",
202
+ });
203
+
204
+ const emptyStream: AdkStreamCallback = async function* () {};
205
+
206
+ const renderWithGates = async () => {
207
+ const { result } = renderHook(() =>
208
+ useAdkMessages({ stream: emptyStream }),
209
+ );
210
+ await act(async () => {
211
+ result.current.setMessages([
212
+ confirmationCall("conf-a"),
213
+ confirmationCall("conf-b"),
214
+ ]);
215
+ });
216
+ return result;
217
+ };
218
+
219
+ it("keeps both gates pending when one send carries an unreadable reply", async () => {
220
+ const result = await renderWithGates();
221
+
222
+ await act(async () => {
223
+ await result.current.sendMessage(
224
+ [
225
+ confirmationReply(
226
+ "reply-a",
227
+ "conf-a",
228
+ JSON.stringify({ confirmed: true }),
229
+ ),
230
+ confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY),
231
+ ],
232
+ {},
233
+ );
234
+ });
235
+
236
+ expect([
237
+ // The confirmation and the call it gates share one approval object.
238
+ ...new Set(
239
+ projectAdkToolApprovals(result.current.messages).approvals.values(),
240
+ ),
241
+ ]).toEqual([{ id: "conf-a" }, { id: "conf-b" }]);
242
+ });
243
+
244
+ it("keeps both gates pending when an ai message sits between the replies", async () => {
245
+ const result = await renderWithGates();
246
+
247
+ await act(async () => {
248
+ await result.current.sendMessage(
249
+ [
250
+ confirmationReply(
251
+ "reply-a",
252
+ "conf-a",
253
+ JSON.stringify({ confirmed: true }),
254
+ ),
255
+ { id: "ai-interleaved", type: "ai", content: "thinking" },
256
+ confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY),
257
+ ],
258
+ {},
259
+ );
260
+ });
261
+
262
+ expect([
263
+ // The confirmation and the call it gates share one approval object.
264
+ ...new Set(
265
+ projectAdkToolApprovals(result.current.messages).approvals.values(),
266
+ ),
267
+ ]).toEqual([{ id: "conf-a" }, { id: "conf-b" }]);
268
+ });
269
+
270
+ it("settles the readable reply when the two replies were sent separately", async () => {
271
+ const result = await renderWithGates();
272
+
273
+ await act(async () => {
274
+ await result.current.sendMessage(
275
+ [
276
+ confirmationReply(
277
+ "reply-a",
278
+ "conf-a",
279
+ JSON.stringify({ confirmed: true }),
280
+ ),
281
+ ],
282
+ {},
283
+ );
284
+ });
285
+ await act(async () => {
286
+ await result.current.sendMessage(
287
+ [confirmationReply("reply-b", "conf-b", UNREADABLE_REPLY)],
288
+ {},
289
+ );
290
+ });
291
+
292
+ expect([
293
+ // The confirmation and the call it gates share one approval object.
294
+ ...new Set(
295
+ projectAdkToolApprovals(result.current.messages).approvals.values(),
296
+ ),
297
+ ]).toEqual([{ id: "conf-a", approved: true }, { id: "conf-b" }]);
298
+ });
299
+ });
300
+
301
+ describe("messagesToEvents", () => {
302
+ const toolReply = (id: string, toolCallId: string): AdkMessage => ({
303
+ id,
304
+ type: "tool",
305
+ tool_call_id: toolCallId,
306
+ name: "adk_request_confirmation",
307
+ content: JSON.stringify({ confirmed: true }),
308
+ status: "success",
309
+ });
310
+
311
+ it("emits nothing for an empty batch", () => {
312
+ // `onReload` sends no messages. The transport still puts an empty user
313
+ // content on the wire, but projecting one here would append an empty user
314
+ // bubble above every regenerated turn.
315
+ expect(messagesToEvents([])).toEqual([]);
316
+ });
317
+
318
+ it("merges the human/tool run across an interleaved ai message", () => {
319
+ const events = messagesToEvents([
320
+ toolReply("reply-a", "conf-a"),
321
+ { id: "ai-interleaved", type: "ai", content: "thinking" },
322
+ toolReply("reply-b", "conf-b"),
323
+ ]);
324
+
325
+ expect(events).toHaveLength(2);
326
+ expect(events[0]?.id).toBe("reply-a");
327
+ expect(
328
+ events[0]?.content?.parts?.map((p) => p.functionResponse?.id),
329
+ ).toEqual(["conf-a", "conf-b"]);
330
+ expect(events[1]?.id).toBe("ai-interleaved");
331
+ });
332
+
333
+ it("mirrors the transport's empty user content for an ai-only batch", async () => {
334
+ const aiOnly: AdkMessage[] = [
335
+ { id: "ai-1", type: "ai", content: "one" },
336
+ { id: "ai-2", type: "ai", content: "two" },
337
+ ];
338
+
339
+ let sentBody = "";
340
+ vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
341
+ sentBody = init.body as string;
342
+ return new Response(
343
+ new ReadableStream<Uint8Array>({
344
+ start: (controller) => controller.close(),
345
+ }),
346
+ { status: 200, headers: { "Content-Type": "text/event-stream" } },
347
+ );
348
+ });
349
+ const stream = createAdkStream({
350
+ api: "http://localhost:8000",
351
+ appName: "app",
352
+ userId: "user-1",
353
+ });
354
+ for await (const _ of await stream(aiOnly, {
355
+ abortSignal: new AbortController().signal,
356
+ initialize: async () => ({ remoteId: "r1", externalId: "s1" }),
357
+ })) {
358
+ /* drain */
359
+ }
360
+ const sent = JSON.parse(sentBody);
361
+
362
+ const events = messagesToEvents(aiOnly);
363
+ const userEvent = events.find((e) => e.author === "user");
364
+ expect(sent.newMessage.parts).toEqual([{ text: "" }]);
365
+ expect(userEvent?.content?.parts).toEqual(sent.newMessage.parts);
366
+ expect(events.map((e) => e.id)).toEqual(["ai-1", "ai-2", userEvent?.id]);
367
+
368
+ const accumulator = new AdkEventAccumulator([]);
369
+ let messages: AdkMessage[] = [];
370
+ for (const event of events) messages = accumulator.processEvent(event);
371
+ expect(messages.filter((m) => m.type === "human")).toEqual([
372
+ { id: userEvent?.id, type: "human", content: "" },
373
+ ]);
374
+ });
375
+
376
+ it("emits the merged event at the position of the run it replaces", () => {
377
+ const events = messagesToEvents([
378
+ { id: "ai-first", type: "ai", content: "thinking" },
379
+ { id: "human-a", type: "human", content: "a" },
380
+ ]);
381
+
382
+ expect(events.map((e) => e.id)).toEqual(["ai-first", "human-a"]);
383
+ });
384
+ });
385
+
386
+ describe("optimistic multi-message sends", () => {
387
+ const emptyStream: AdkStreamCallback = async function* () {};
388
+
389
+ it("does not duplicate later messages of a staged multi-human send", async () => {
390
+ const staged: AdkMessage[] = [
391
+ { id: "human-a", type: "human", content: "a" },
392
+ { id: "human-b", type: "human", content: "b" },
393
+ ];
394
+ const { result } = renderHook(() =>
395
+ useAdkMessages({ stream: emptyStream }),
396
+ );
397
+ await act(async () => {
398
+ result.current.setMessages(staged);
399
+ });
400
+
401
+ await act(async () => {
402
+ await result.current.sendMessage(staged, {});
403
+ });
404
+
405
+ expect(result.current.messages).toEqual([
406
+ {
407
+ id: "human-a",
408
+ type: "human",
409
+ content: [
410
+ { type: "text", text: "a" },
411
+ { type: "text", text: "b" },
412
+ ],
413
+ },
414
+ ]);
415
+ });
416
+
417
+ it("keeps messages that are not part of the send", async () => {
418
+ const { result } = renderHook(() =>
419
+ useAdkMessages({ stream: emptyStream }),
420
+ );
421
+ await act(async () => {
422
+ result.current.setMessages([
423
+ { id: "earlier", type: "human", content: "earlier" },
424
+ ]);
425
+ });
426
+
427
+ await act(async () => {
428
+ await result.current.sendMessage(
429
+ [{ id: "human-a", type: "human", content: "a" }],
430
+ {},
431
+ );
432
+ });
433
+
434
+ expect(result.current.messages.map((m) => m.id)).toEqual([
435
+ "earlier",
436
+ "human-a",
437
+ ]);
438
+ });
439
+ });
440
+
128
441
  describe("messageToEvent (contentToParts)", () => {
129
442
  it("serializes a file content part as inlineData", () => {
130
443
  const msg: AdkMessage = {
@@ -1,6 +1,7 @@
1
- import { useState, useCallback, useRef, useMemo } from "react";
2
- import { v4 as uuidv4 } from "uuid";
1
+ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
+ import { generateId } from "@assistant-ui/core";
3
3
  import { useAui } from "@assistant-ui/store";
4
+ import { invokeUserCallback } from "@assistant-ui/core/internal";
4
5
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
5
6
  import { contentToParts } from "./contentToParts";
6
7
  import type {
@@ -28,24 +29,12 @@ export type UseAdkMessagesOptions = {
28
29
 
29
30
  type AdkRuntimeCallbackName = "onError" | "onCustomEvent" | "onAgentTransfer";
30
31
 
31
- const reportCallbackError = (name: AdkRuntimeCallbackName, error: unknown) => {
32
- console.error(`[react-google-adk] ${name} callback threw an error`, error);
33
- };
34
-
35
- const invokeAdkRuntimeCallback = <TArgs extends unknown[]>(
32
+ const invokeAdkRuntimeCallback = <TArgs extends readonly unknown[]>(
36
33
  name: AdkRuntimeCallbackName,
37
- callback: ((...args: TArgs) => void | Promise<void>) | undefined,
34
+ callback: ((...args: TArgs) => unknown) | undefined,
38
35
  ...args: TArgs
39
- ) => {
40
- if (!callback) return;
41
-
42
- try {
43
- void Promise.resolve(callback(...args)).catch((error) => {
44
- reportCallbackError(name, error);
45
- });
46
- } catch (error) {
47
- reportCallbackError(name, error);
48
- }
36
+ ): void => {
37
+ void invokeUserCallback("react-google-adk", name, callback, ...args);
49
38
  };
50
39
 
51
40
  export const useAdkMessages = ({
@@ -133,12 +122,19 @@ export const useAdkMessages = ({
133
122
  const sendMessage = useCallback(
134
123
  async (newMessages: AdkMessage[], config: AdkSendMessageConfig) => {
135
124
  const newMessagesWithId = newMessages.map((m) =>
136
- m.id ? m : { ...m, id: uuidv4() },
125
+ m.id ? m : { ...m, id: generateId() },
137
126
  ) as AdkMessage[];
138
127
 
139
- const accumulator = new AdkEventAccumulator(messagesRef.current);
140
- for (const msg of newMessagesWithId) {
141
- accumulator.processEvent(messageToEvent(msg));
128
+ // A staged message is already in the thread under its own id, and the
129
+ // merged event below re-emits the whole batch under the first one. Seeding
130
+ // with the originals would leave every later staged id beside the merged
131
+ // copy of itself.
132
+ const resentIds = new Set(newMessagesWithId.map((m) => m.id));
133
+ const accumulator = new AdkEventAccumulator(
134
+ messagesRef.current.filter((m) => !resentIds.has(m.id)),
135
+ );
136
+ for (const event of messagesToEvents(newMessagesWithId)) {
137
+ accumulator.processEvent(event);
142
138
  }
143
139
  setMessagesImmediate(accumulator.getMessages());
144
140
 
@@ -238,6 +234,8 @@ export const useAdkMessages = ({
238
234
  }
239
235
  }, []);
240
236
 
237
+ useEffect(() => cancel, [cancel]);
238
+
241
239
  return {
242
240
  messages,
243
241
  stateDelta,
@@ -256,11 +254,60 @@ export const useAdkMessages = ({
256
254
  };
257
255
  };
258
256
 
257
+ /**
258
+ * Transport sends every human and tool message of one `send` call as a single
259
+ * ADK `Content`, and ADK parses that event's function responses before running
260
+ * any tool, so the batch runs whole or not at all. The optimistic projection
261
+ * has to sit on the same boundary, so a run of those messages becomes one
262
+ * synthetic event whose parts come from the same per-message conversion.
263
+ *
264
+ * The transport drops `ai` messages from that `Content`, so one interleaved
265
+ * between two replies does not split the batch on the wire and must not split
266
+ * it here either. It still becomes its own event, placed after the merged one,
267
+ * so the optimistic projection keeps the assistant turn.
268
+ *
269
+ * @internal — exported for unit tests.
270
+ */
271
+ export const messagesToEvents = (messages: AdkMessage[]): AdkEvent[] => {
272
+ // A reload sends no messages at all, and the empty user content the transport
273
+ // puts on the wire for it is not part of the optimistic view: projecting one
274
+ // would put an empty user bubble above every regenerated turn.
275
+ if (messages.length === 0) return [];
276
+
277
+ const events: AdkEvent[] = [];
278
+ const run: AdkMessage[] = [];
279
+ let runIndex = 0;
280
+
281
+ for (const msg of messages) {
282
+ if (msg.type === "ai") {
283
+ events.push(messageToEvent(msg));
284
+ } else {
285
+ if (run.length === 0) runIndex = events.length;
286
+ run.push(msg);
287
+ }
288
+ }
289
+
290
+ const parts = run.flatMap((m) => messageToEvent(m).content?.parts ?? []);
291
+ const human = run.find((m) => m.type === "human");
292
+
293
+ // A batch that contributes no part still reaches the wire: the transport
294
+ // sends an empty user `Content`, which a reload replays as an empty human
295
+ // message. Emitting it here keeps the optimistic view equal to that replay.
296
+ if (parts.length === 0) parts.push({ text: "" });
297
+
298
+ const event: AdkEvent = { id: (human ?? run[0])?.id ?? generateId() };
299
+ if (human || run.length === 0) event.author = "user";
300
+ event.content = { role: "user", parts };
301
+ events.splice(run.length > 0 ? runIndex : events.length, 0, event);
302
+
303
+ return events;
304
+ };
305
+
259
306
  /** @internal — exported for unit tests. */
260
307
  export const messageToEvent = (msg: AdkMessage): AdkEvent => {
261
308
  if (msg.type === "human") {
262
309
  return {
263
- id: msg.id ?? uuidv4(),
310
+ id: msg.id ?? generateId(),
264
311
  author: "user",
265
312
  content: { role: "user", parts: contentToParts(msg.content) },
266
313
  };
@@ -274,7 +321,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
274
321
  response = msg.content;
275
322
  }
276
323
  return {
277
- id: msg.id ?? uuidv4(),
324
+ id: msg.id ?? generateId(),
278
325
  content: {
279
326
  role: "user",
280
327
  parts: [
@@ -290,7 +337,7 @@ export const messageToEvent = (msg: AdkMessage): AdkEvent => {
290
337
  };
291
338
  }
292
339
 
293
- const result: AdkEvent = { id: msg.id ?? uuidv4() };
340
+ const result: AdkEvent = { id: msg.id ?? generateId() };
294
341
  if (msg.author != null) result.author = msg.author;
295
342
  result.content = {
296
343
  role: "model",