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

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 (50) 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.map +1 -1
  5. package/dist/AdkEventAccumulator.js +11 -1
  6. package/dist/AdkEventAccumulator.js.map +1 -1
  7. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  8. package/dist/AdkSessionAdapter.js +2 -1
  9. package/dist/AdkSessionAdapter.js.map +1 -1
  10. package/dist/adkToolApproval.d.ts +40 -0
  11. package/dist/adkToolApproval.d.ts.map +1 -0
  12. package/dist/adkToolApproval.js +166 -0
  13. package/dist/adkToolApproval.js.map +1 -0
  14. package/dist/convertAdkMessages.d.ts +3 -1
  15. package/dist/convertAdkMessages.d.ts.map +1 -1
  16. package/dist/convertAdkMessages.js +27 -16
  17. package/dist/convertAdkMessages.js.map +1 -1
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/hooks.js +2 -11
  20. package/dist/hooks.js.map +1 -1
  21. package/dist/parseAdkEvent.d.ts +6 -0
  22. package/dist/parseAdkEvent.d.ts.map +1 -0
  23. package/dist/parseAdkEvent.js +16 -0
  24. package/dist/parseAdkEvent.js.map +1 -0
  25. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  26. package/dist/server/parseAdkRequest.js +79 -13
  27. package/dist/server/parseAdkRequest.js.map +1 -1
  28. package/dist/useAdkMessages.js +104 -64
  29. package/dist/useAdkMessages.js.map +1 -1
  30. package/dist/useAdkRuntime.d.ts.map +1 -1
  31. package/dist/useAdkRuntime.js +11 -3
  32. package/dist/useAdkRuntime.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/AdkClient.test.ts +137 -0
  35. package/src/AdkClient.ts +15 -3
  36. package/src/AdkEventAccumulator.test.ts +77 -0
  37. package/src/AdkEventAccumulator.ts +24 -1
  38. package/src/AdkSessionAdapter.test.ts +145 -1
  39. package/src/AdkSessionAdapter.ts +7 -2
  40. package/src/adkToolApproval.test.ts +345 -0
  41. package/src/adkToolApproval.ts +215 -0
  42. package/src/convertAdkMessages.ts +72 -43
  43. package/src/hooks.ts +4 -16
  44. package/src/parseAdkEvent.ts +40 -0
  45. package/src/server/parseAdkRequest.test.ts +141 -0
  46. package/src/server/parseAdkRequest.ts +145 -13
  47. package/src/useAdkMessages.test.ts +313 -0
  48. package/src/useAdkMessages.ts +62 -4
  49. package/src/useAdkRuntime.ts +29 -3
  50. 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,4 +1,4 @@
1
- import { useState, useCallback, useRef, useMemo } from "react";
1
+ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
2
  import { v4 as uuidv4 } from "uuid";
3
3
  import { useAui } from "@assistant-ui/store";
4
4
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
@@ -136,9 +136,16 @@ export const useAdkMessages = ({
136
136
  m.id ? m : { ...m, id: uuidv4() },
137
137
  ) as AdkMessage[];
138
138
 
139
- const accumulator = new AdkEventAccumulator(messagesRef.current);
140
- for (const msg of newMessagesWithId) {
141
- accumulator.processEvent(messageToEvent(msg));
139
+ // A staged message is already in the thread under its own id, and the
140
+ // merged event below re-emits the whole batch under the first one. Seeding
141
+ // with the originals would leave every later staged id beside the merged
142
+ // copy of itself.
143
+ const resentIds = new Set(newMessagesWithId.map((m) => m.id));
144
+ const accumulator = new AdkEventAccumulator(
145
+ messagesRef.current.filter((m) => !resentIds.has(m.id)),
146
+ );
147
+ for (const event of messagesToEvents(newMessagesWithId)) {
148
+ accumulator.processEvent(event);
142
149
  }
143
150
  setMessagesImmediate(accumulator.getMessages());
144
151
 
@@ -238,6 +245,8 @@ export const useAdkMessages = ({
238
245
  }
239
246
  }, []);
240
247
 
248
+ useEffect(() => cancel, [cancel]);
249
+
241
250
  return {
242
251
  messages,
243
252
  stateDelta,
@@ -256,6 +265,55 @@ export const useAdkMessages = ({
256
265
  };
257
266
  };
258
267
 
268
+ /**
269
+ * Transport sends every human and tool message of one `send` call as a single
270
+ * ADK `Content`, and ADK parses that event's function responses before running
271
+ * any tool, so the batch runs whole or not at all. The optimistic projection
272
+ * has to sit on the same boundary, so a run of those messages becomes one
273
+ * synthetic event whose parts come from the same per-message conversion.
274
+ *
275
+ * The transport drops `ai` messages from that `Content`, so one interleaved
276
+ * between two replies does not split the batch on the wire and must not split
277
+ * it here either. It still becomes its own event, placed after the merged one,
278
+ * so the optimistic projection keeps the assistant turn.
279
+ *
280
+ * @internal — exported for unit tests.
281
+ */
282
+ export const messagesToEvents = (messages: AdkMessage[]): AdkEvent[] => {
283
+ // A reload sends no messages at all, and the empty user content the transport
284
+ // puts on the wire for it is not part of the optimistic view: projecting one
285
+ // would put an empty user bubble above every regenerated turn.
286
+ if (messages.length === 0) return [];
287
+
288
+ const events: AdkEvent[] = [];
289
+ const run: AdkMessage[] = [];
290
+ let runIndex = 0;
291
+
292
+ for (const msg of messages) {
293
+ if (msg.type === "ai") {
294
+ events.push(messageToEvent(msg));
295
+ } else {
296
+ if (run.length === 0) runIndex = events.length;
297
+ run.push(msg);
298
+ }
299
+ }
300
+
301
+ const parts = run.flatMap((m) => messageToEvent(m).content?.parts ?? []);
302
+ const human = run.find((m) => m.type === "human");
303
+
304
+ // A batch that contributes no part still reaches the wire: the transport
305
+ // sends an empty user `Content`, which a reload replays as an empty human
306
+ // message. Emitting it here keeps the optimistic view equal to that replay.
307
+ if (parts.length === 0) parts.push({ text: "" });
308
+
309
+ const event: AdkEvent = { id: (human ?? run[0])?.id ?? uuidv4() };
310
+ if (human || run.length === 0) event.author = "user";
311
+ event.content = { role: "user", parts };
312
+ events.splice(run.length > 0 ? runIndex : events.length, 0, event);
313
+
314
+ return events;
315
+ };
316
+
259
317
  /** @internal — exported for unit tests. */
260
318
  export const messageToEvent = (msg: AdkMessage): AdkEvent => {
261
319
  if (msg.type === "human") {
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  getExternalStoreMessages,
4
4
  pickExternalStoreSharedOptions,
@@ -33,7 +33,14 @@ import type {
33
33
  OnAdkAgentTransferCallback,
34
34
  } from "./types";
35
35
  import { useAdkMessages } from "./useAdkMessages";
36
- import { convertAdkMessage } from "./convertAdkMessages";
36
+ import {
37
+ convertAdkMessage,
38
+ createAdkMessageConverter,
39
+ } from "./convertAdkMessages";
40
+ import {
41
+ projectAdkToolApprovals,
42
+ toAdkToolConfirmationReply,
43
+ } from "./adkToolApproval";
37
44
  import { adkExtras } from "./adkExtras";
38
45
  import { v4 as uuidv4 } from "uuid";
39
46
 
@@ -285,8 +292,21 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
285
292
  }
286
293
  };
287
294
 
295
+ const { approvals: toolApprovals, key: toolApprovalsKey } =
296
+ projectAdkToolApprovals(messages);
297
+ const toolApprovalsRef = useRef(toolApprovals);
298
+ toolApprovalsRef.current = toolApprovals;
299
+
300
+ const messageConverter = useMemo(
301
+ () =>
302
+ toolApprovalsKey === ""
303
+ ? convertAdkMessage
304
+ : createAdkMessageConverter(toolApprovalsRef.current),
305
+ [toolApprovalsKey],
306
+ );
307
+
288
308
  const threadMessages = useExternalMessageConverter({
289
- callback: convertAdkMessage,
309
+ callback: messageConverter,
290
310
  messages,
291
311
  isRunning: effectiveIsRunning,
292
312
  });
@@ -556,6 +576,12 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
556
576
  {},
557
577
  );
558
578
  },
579
+ onRespondToToolApproval: async (options) => {
580
+ await handleSendMessage(
581
+ [toAdkToolConfirmationReply(options, toolApprovalsRef.current)],
582
+ {},
583
+ );
584
+ },
559
585
  onCancel: unstable_allowCancellation
560
586
  ? async () => {
561
587
  cancel();