@assistant-ui/react-google-adk 0.0.30 → 0.0.31

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 (72) hide show
  1. package/dist/AdkClient.d.ts +2 -3
  2. package/dist/AdkClient.d.ts.map +1 -1
  3. package/dist/AdkEventAccumulator.d.ts +3 -5
  4. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  5. package/dist/AdkEventAccumulator.js +63 -99
  6. package/dist/AdkEventAccumulator.js.map +1 -1
  7. package/dist/AdkSessionAdapter.d.ts +7 -4
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +7 -4
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkExtras.d.ts +1 -2
  12. package/dist/adkExtras.d.ts.map +1 -1
  13. package/dist/adkToolApproval.d.ts +6 -7
  14. package/dist/adkToolApproval.d.ts.map +1 -1
  15. package/dist/adkToolApproval.js +1 -3
  16. package/dist/adkToolApproval.js.map +1 -1
  17. package/dist/contentToParts.d.ts +1 -2
  18. package/dist/contentToParts.d.ts.map +1 -1
  19. package/dist/convertAdkMessages.d.ts +2 -3
  20. package/dist/convertAdkMessages.d.ts.map +1 -1
  21. package/dist/convertToAdkMessages.d.ts +5 -6
  22. package/dist/convertToAdkMessages.d.ts.map +1 -1
  23. package/dist/hooks.d.ts +15 -16
  24. package/dist/hooks.d.ts.map +1 -1
  25. package/dist/hooks.js +1 -1
  26. package/dist/normalizeAdkPart.d.ts +4 -0
  27. package/dist/normalizeAdkPart.d.ts.map +1 -0
  28. package/dist/normalizeAdkPart.js +31 -0
  29. package/dist/normalizeAdkPart.js.map +1 -0
  30. package/dist/parseAdkEvent.d.ts +1 -2
  31. package/dist/parseAdkEvent.d.ts.map +1 -1
  32. package/dist/sdkIdentity.d.ts +1 -2
  33. package/dist/sdkIdentity.d.ts.map +1 -1
  34. package/dist/sdkIdentity.js +1 -1
  35. package/dist/server/adkEventStream.d.ts +2 -3
  36. package/dist/server/adkEventStream.d.ts.map +1 -1
  37. package/dist/server/createAdkApiRoute.d.ts +2 -3
  38. package/dist/server/createAdkApiRoute.d.ts.map +1 -1
  39. package/dist/server/parseAdkRequest.d.ts +2 -3
  40. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  41. package/dist/structuredEvents.d.ts +1 -2
  42. package/dist/structuredEvents.d.ts.map +1 -1
  43. package/dist/structuredEvents.js +1 -1
  44. package/dist/structuredEvents.js.map +1 -1
  45. package/dist/toAdkFunctionResponse.d.ts +2 -3
  46. package/dist/toAdkFunctionResponse.d.ts.map +1 -1
  47. package/dist/trimTrailingSlashes.d.ts +1 -2
  48. package/dist/trimTrailingSlashes.d.ts.map +1 -1
  49. package/dist/types.d.ts +22 -23
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/useAdkMessages.d.ts +2 -3
  53. package/dist/useAdkMessages.d.ts.map +1 -1
  54. package/dist/useAdkMessages.js +2 -0
  55. package/dist/useAdkMessages.js.map +1 -1
  56. package/dist/useAdkRuntime.d.ts +2 -3
  57. package/dist/useAdkRuntime.d.ts.map +1 -1
  58. package/dist/useAdkRuntime.js +1 -1
  59. package/package.json +10 -9
  60. package/src/AdkEventAccumulator.test.ts +312 -11
  61. package/src/AdkEventAccumulator.ts +68 -142
  62. package/src/AdkSessionAdapter.test.ts +267 -1
  63. package/src/AdkSessionAdapter.ts +24 -5
  64. package/src/adkToolApproval.test.ts +0 -4
  65. package/src/adkToolApproval.ts +1 -3
  66. package/src/normalizeAdkPart.ts +35 -0
  67. package/src/structuredEvents.test.ts +69 -0
  68. package/src/structuredEvents.ts +4 -1
  69. package/src/types.ts +1 -1
  70. package/src/useAdkMessages.test.ts +102 -3
  71. package/src/useAdkMessages.ts +2 -0
  72. package/src/useAdkRuntime.replacement.test.tsx +11 -7
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createAdkSessionAdapter } from "./AdkSessionAdapter";
3
3
  import { projectAdkToolApprovals } from "./adkToolApproval";
4
+ import type { AdkMessage } from "./types";
4
5
 
5
6
  // ── Helpers ──
6
7
 
@@ -375,6 +376,117 @@ describe("createAdkSessionAdapter - load", () => {
375
376
  ]);
376
377
  });
377
378
 
379
+ it("loads valid events when history contains malformed media", async () => {
380
+ mockFetch.mockResolvedValueOnce(
381
+ new Response(
382
+ JSON.stringify({
383
+ id: "s1",
384
+ events: [
385
+ {
386
+ id: "user-1",
387
+ author: "user",
388
+ content: { parts: [{ text: "before" }] },
389
+ },
390
+ {
391
+ id: "bad-media",
392
+ author: "agent",
393
+ content: {
394
+ parts: [
395
+ { inlineData: { data: "aGVsbG8=" } },
396
+ { fileData: { mimeType: "image/png" } },
397
+ ],
398
+ },
399
+ },
400
+ {
401
+ id: "agent-1",
402
+ author: "agent",
403
+ content: { parts: [{ text: "after" }] },
404
+ },
405
+ ],
406
+ }),
407
+ { status: 200 },
408
+ ),
409
+ );
410
+
411
+ const { load } = createAdkSessionAdapter(baseOptions);
412
+ const result = await load("s1");
413
+
414
+ expect(result.messages).toMatchObject([
415
+ { type: "human", content: "before" },
416
+ { type: "ai", content: [{ type: "text", text: "after" }] },
417
+ ]);
418
+ });
419
+
420
+ it("loads valid events when history contains request calls without args", async () => {
421
+ mockFetch.mockResolvedValueOnce(
422
+ new Response(
423
+ JSON.stringify({
424
+ id: "s1",
425
+ events: [
426
+ {
427
+ id: "user-1",
428
+ author: "user",
429
+ content: { parts: [{ text: "before" }] },
430
+ },
431
+ {
432
+ id: "bad-requests",
433
+ author: "agent",
434
+ content: {
435
+ parts: [
436
+ {
437
+ functionCall: {
438
+ name: "adk_request_confirmation",
439
+ id: "rc-1",
440
+ },
441
+ },
442
+ {
443
+ functionCall: {
444
+ name: "adk_request_credential",
445
+ id: "rc-2",
446
+ },
447
+ },
448
+ ],
449
+ },
450
+ },
451
+ {
452
+ id: "agent-1",
453
+ author: "agent",
454
+ content: { parts: [{ text: "after" }] },
455
+ },
456
+ ],
457
+ }),
458
+ { status: 200 },
459
+ ),
460
+ );
461
+
462
+ const { load } = createAdkSessionAdapter(baseOptions);
463
+ const result = await load("s1");
464
+
465
+ expect(result.messages).toMatchObject([
466
+ { type: "human", content: "before" },
467
+ { type: "ai" },
468
+ { type: "ai", content: [{ type: "text", text: "after" }] },
469
+ ]);
470
+ expect(
471
+ (result.messages[1] as AdkMessage & { type: "ai" }).tool_calls,
472
+ ).toEqual([
473
+ {
474
+ id: "rc-1",
475
+ name: "adk_request_confirmation",
476
+ args: {},
477
+ argsText: "{}",
478
+ },
479
+ {
480
+ id: "rc-2",
481
+ name: "adk_request_credential",
482
+ args: {},
483
+ argsText: "{}",
484
+ },
485
+ ]);
486
+ expect(result.toolConfirmations).toMatchObject([{ toolCallId: "rc-1" }]);
487
+ expect(result.authRequests).toMatchObject([{ toolCallId: "rc-2" }]);
488
+ });
489
+
378
490
  it("returns the per-turn state the events imply, not just the messages", async () => {
379
491
  const session = {
380
492
  id: "s1",
@@ -405,6 +517,85 @@ describe("createAdkSessionAdapter - load", () => {
405
517
  expect(result.authRequests).toEqual([]);
406
518
  });
407
519
 
520
+ it("reports only the requests the stored replies leave unanswered", async () => {
521
+ const session = {
522
+ id: "s1",
523
+ events: [
524
+ {
525
+ id: "e1",
526
+ author: "agent",
527
+ content: {
528
+ role: "model",
529
+ parts: [
530
+ { functionCall: { name: "transfer", id: "gated-1", args: {} } },
531
+ { functionCall: { name: "calendar", id: "gated-2", args: {} } },
532
+ ],
533
+ },
534
+ },
535
+ {
536
+ id: "e2",
537
+ author: "agent",
538
+ longRunningToolIds: ["conf-1", "cred-1"],
539
+ actions: {
540
+ requestedToolConfirmations: { "gated-1": { hint: "Transfer?" } },
541
+ },
542
+ content: {
543
+ role: "user",
544
+ parts: [
545
+ {
546
+ functionCall: {
547
+ name: "adk_request_confirmation",
548
+ id: "conf-1",
549
+ args: {
550
+ originalFunctionCall: { id: "gated-1", name: "transfer" },
551
+ toolConfirmation: { hint: "Transfer?" },
552
+ },
553
+ },
554
+ },
555
+ {
556
+ functionCall: {
557
+ name: "adk_request_credential",
558
+ id: "cred-1",
559
+ args: {
560
+ functionCallId: "gated-2",
561
+ authConfig: { credentialKey: "k" },
562
+ },
563
+ },
564
+ },
565
+ ],
566
+ },
567
+ },
568
+ {
569
+ id: "e3",
570
+ author: "user",
571
+ content: {
572
+ role: "user",
573
+ parts: [
574
+ {
575
+ functionResponse: {
576
+ name: "adk_request_confirmation",
577
+ id: "conf-1",
578
+ response: { confirmed: true },
579
+ },
580
+ },
581
+ ],
582
+ },
583
+ },
584
+ ],
585
+ };
586
+ mockFetch.mockResolvedValueOnce(
587
+ new Response(JSON.stringify(session), { status: 200 }),
588
+ );
589
+
590
+ const { load } = createAdkSessionAdapter(baseOptions);
591
+ const result = await load("s1");
592
+
593
+ expect(result.toolConfirmations).toEqual([]);
594
+ expect(result.authRequests).toEqual([
595
+ { toolCallId: "cred-1", authConfig: { credentialKey: "k" } },
596
+ ]);
597
+ });
598
+
408
599
  it("passes an abort signal through to the request", async () => {
409
600
  mockFetch.mockResolvedValueOnce(
410
601
  new Response(JSON.stringify({ id: "s1", events: [] }), { status: 200 }),
@@ -727,6 +918,7 @@ describe("createAdkSessionAdapter - artifacts", () => {
727
918
  "inline data",
728
919
  { inlineData: { mimeType: "image/png", data: "aGVsbG8=" } },
729
920
  ],
921
+ ["file data", { fileData: { fileUri: "https://example.test/report.pdf" } }],
730
922
  ])("loads valid %s artifacts", async (_label, artifact) => {
731
923
  mockFetch.mockResolvedValueOnce(
732
924
  new Response(JSON.stringify(artifact), { status: 200 }),
@@ -737,6 +929,75 @@ describe("createAdkSessionAdapter - artifacts", () => {
737
929
  await expect(artifacts.load("s1", "report.pdf")).resolves.toEqual(artifact);
738
930
  });
739
931
 
932
+ it.each([
933
+ [
934
+ "inline data",
935
+ { inline_data: { mime_type: "application/pdf", data: "aGVsbG8=" } },
936
+ { inlineData: { mimeType: "application/pdf", data: "aGVsbG8=" } },
937
+ ],
938
+ [
939
+ "file data",
940
+ {
941
+ file_data: {
942
+ mime_type: "application/pdf",
943
+ file_uri: "https://example.test/report.pdf",
944
+ },
945
+ },
946
+ {
947
+ fileData: {
948
+ mimeType: "application/pdf",
949
+ fileUri: "https://example.test/report.pdf",
950
+ },
951
+ },
952
+ ],
953
+ ])(
954
+ "normalizes snake_case %s artifact responses",
955
+ async (_label, value, expected) => {
956
+ mockFetch.mockResolvedValueOnce(
957
+ new Response(JSON.stringify(value), { status: 200 }),
958
+ );
959
+
960
+ const { artifacts } = createAdkSessionAdapter(baseOptions);
961
+
962
+ await expect(artifacts.load("s1", "report.pdf")).resolves.toMatchObject(
963
+ expected,
964
+ );
965
+ },
966
+ );
967
+
968
+ it("prefers camelCase artifact fields when both aliases are present", async () => {
969
+ mockFetch.mockResolvedValueOnce(
970
+ new Response(
971
+ JSON.stringify({
972
+ inlineData: {
973
+ mimeType: "image/png",
974
+ mime_type: "application/pdf",
975
+ data: "right",
976
+ },
977
+ inline_data: { mime_type: "text/plain", data: "wrong" },
978
+ fileData: {
979
+ mimeType: "application/pdf",
980
+ mime_type: "image/png",
981
+ fileUri: "https://example.test/right.pdf",
982
+ file_uri: "https://example.test/wrong.png",
983
+ },
984
+ file_data: { file_uri: "https://example.test/other.png" },
985
+ }),
986
+ { status: 200 },
987
+ ),
988
+ );
989
+
990
+ const { artifacts } = createAdkSessionAdapter(baseOptions);
991
+
992
+ await expect(artifacts.load("s1", "report.pdf")).resolves.toMatchObject({
993
+ inlineData: { mimeType: "image/png", data: "right" },
994
+ fileData: {
995
+ mimeType: "application/pdf",
996
+ fileUri: "https://example.test/right.pdf",
997
+ },
998
+ });
999
+ });
1000
+
740
1001
  it("rejects an artifact without supported content", async () => {
741
1002
  mockFetch.mockResolvedValueOnce(
742
1003
  new Response(JSON.stringify({}), { status: 200 }),
@@ -745,7 +1006,7 @@ describe("createAdkSessionAdapter - artifacts", () => {
745
1006
  const { artifacts } = createAdkSessionAdapter(baseOptions);
746
1007
 
747
1008
  await expect(artifacts.load("s1", "report.pdf")).rejects.toThrow(
748
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
1009
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
749
1010
  );
750
1011
  });
751
1012
 
@@ -760,6 +1021,11 @@ describe("createAdkSessionAdapter - artifacts", () => {
760
1021
  { inlineData: { mimeType: "image/png" } },
761
1022
  'Invalid ADK artifact load response: "inlineData" must contain string "mimeType" and "data" fields.',
762
1023
  ],
1024
+ [
1025
+ "file data",
1026
+ { fileData: { mimeType: "application/pdf" } },
1027
+ 'Invalid ADK artifact load response: "fileData" must contain a string "fileUri" and an optional string "mimeType" field.',
1028
+ ],
763
1029
  ])("rejects malformed %s artifact content", async (_label, value, error) => {
764
1030
  mockFetch.mockResolvedValueOnce(
765
1031
  new Response(JSON.stringify(value), { status: 200 }),
@@ -6,6 +6,7 @@ import type {
6
6
  RemoteThreadMetadata,
7
7
  } from "@assistant-ui/core";
8
8
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
9
+ import { normalizeAdkPart } from "./normalizeAdkPart";
9
10
  import { parseAdkEventValue } from "./parseAdkEvent";
10
11
  import type { AdkMessage, AdkThreadSnapshot } from "./types";
11
12
  import { trimTrailingSlashes } from "./trimTrailingSlashes";
@@ -37,6 +38,7 @@ export type AdkSessionAdapterOptions = {
37
38
 
38
39
  export type AdkArtifactData = {
39
40
  inlineData?: { mimeType: string; data: string } | undefined;
41
+ fileData?: { fileUri: string; mimeType?: string | undefined } | undefined;
40
42
  text?: string | undefined;
41
43
  };
42
44
 
@@ -133,14 +135,19 @@ const parseAdkArtifactListResponse = (value: unknown): string[] => {
133
135
  const parseAdkArtifactResponse = (value: unknown): AdkArtifactData => {
134
136
  if (!isRecord(value)) {
135
137
  throw new Error(
136
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
138
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
137
139
  );
138
140
  }
139
141
 
140
- const { text, inlineData } = value;
141
- if (text === undefined && inlineData === undefined) {
142
+ const normalizedValue = normalizeAdkPart(value);
143
+ const { text, inlineData, fileData } = normalizedValue;
144
+ if (
145
+ text === undefined &&
146
+ inlineData === undefined &&
147
+ fileData === undefined
148
+ ) {
142
149
  throw new Error(
143
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
150
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
144
151
  );
145
152
  }
146
153
 
@@ -161,7 +168,19 @@ const parseAdkArtifactResponse = (value: unknown): AdkArtifactData => {
161
168
  );
162
169
  }
163
170
 
164
- return value as AdkArtifactData;
171
+ if (
172
+ fileData !== undefined &&
173
+ (!isRecord(fileData) ||
174
+ typeof fileData.fileUri !== "string" ||
175
+ (fileData.mimeType !== undefined &&
176
+ typeof fileData.mimeType !== "string"))
177
+ ) {
178
+ throw new Error(
179
+ 'Invalid ADK artifact load response: "fileData" must contain a string "fileUri" and an optional string "mimeType" field.',
180
+ );
181
+ }
182
+
183
+ return normalizedValue as AdkArtifactData;
165
184
  };
166
185
 
167
186
  const parseAdkArtifactVersionsResponse = (value: unknown): number[] => {
@@ -206,10 +206,6 @@ describe("projectAdkToolApprovals", () => {
206
206
  ]);
207
207
  });
208
208
 
209
- /**
210
- * ADK Python spells the confirmation args in snake_case, which the event
211
- * accumulator already reads both ways.
212
- */
213
209
  it("gates the call named by a snake_case confirmation request", () => {
214
210
  const { approvals } = projectAdkToolApprovals([
215
211
  aiCall(GATED_CALL, "delete_file", { path: "/tmp/a" }),
@@ -85,9 +85,7 @@ const sourceEventOf = (toolMessageId: string): string =>
85
85
  toolMessageId.replace(/:\d+$/, "");
86
86
 
87
87
  /**
88
- * ADK builds the confirmation request around the call it gates, carrying that
89
- * call verbatim in `originalFunctionCall` — spelled `original_function_call` by
90
- * ADK Python, which the accumulator reads the same way.
88
+ * ADK builds the confirmation request around the call it gates, carrying that call verbatim in `originalFunctionCall`; `original_function_call` is accepted as well, matching the accumulator.
91
89
  */
92
90
  const gatedCallIdOf = (args: unknown): string | undefined => {
93
91
  if (typeof args !== "object" || args === null) return undefined;
@@ -0,0 +1,35 @@
1
+ import { isRecord } from "@assistant-ui/core/internal";
2
+
3
+ export const normalizeAdkPart = (
4
+ part: Record<string, unknown>,
5
+ ): Record<string, unknown> => {
6
+ const result: Record<string, unknown> = { ...part };
7
+ if ("function_call" in part && !("functionCall" in part))
8
+ result.functionCall = part.function_call;
9
+ if ("function_response" in part && !("functionResponse" in part))
10
+ result.functionResponse = part.function_response;
11
+ if ("inline_data" in part && !("inlineData" in part))
12
+ result.inlineData = part.inline_data;
13
+ if ("file_data" in part && !("fileData" in part))
14
+ result.fileData = part.file_data;
15
+ if (isRecord(result.inlineData)) {
16
+ const data = result.inlineData;
17
+ if ("mime_type" in data && !("mimeType" in data))
18
+ result.inlineData = { ...data, mimeType: data.mime_type };
19
+ }
20
+ if (isRecord(result.fileData)) {
21
+ const data = result.fileData;
22
+ result.fileData = {
23
+ ...data,
24
+ ...("mime_type" in data &&
25
+ !("mimeType" in data) && { mimeType: data.mime_type }),
26
+ ...("file_uri" in data &&
27
+ !("fileUri" in data) && { fileUri: data.file_uri }),
28
+ };
29
+ }
30
+ if ("executable_code" in part && !("executableCode" in part))
31
+ result.executableCode = part.executable_code;
32
+ if ("code_execution_result" in part && !("codeExecutionResult" in part))
33
+ result.codeExecutionResult = part.code_execution_result;
34
+ return result;
35
+ };
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toAdkStructuredEvents } from "./structuredEvents";
3
+ import type { AdkEvent } from "./types";
4
+
5
+ const makeEvent = (overrides: Partial<AdkEvent> = {}): AdkEvent => ({
6
+ id: "evt-1",
7
+ ...overrides,
8
+ });
9
+
10
+ describe("toAdkStructuredEvents", () => {
11
+ it("converts a function call with args", () => {
12
+ expect(
13
+ toAdkStructuredEvents(
14
+ makeEvent({
15
+ author: "agent",
16
+ content: {
17
+ role: "model",
18
+ parts: [
19
+ {
20
+ functionCall: { name: "search", id: "tc-1", args: { q: "x" } },
21
+ },
22
+ ],
23
+ },
24
+ }),
25
+ ),
26
+ ).toEqual([
27
+ {
28
+ type: "tool_call",
29
+ call: { name: "search", id: "tc-1", args: { q: "x" } },
30
+ },
31
+ ]);
32
+ });
33
+
34
+ it("defaults a function call without args to empty args", () => {
35
+ expect(
36
+ toAdkStructuredEvents(
37
+ makeEvent({
38
+ author: "agent",
39
+ content: {
40
+ role: "model",
41
+ parts: [{ functionCall: { name: "search", id: "tc-1" } }],
42
+ },
43
+ }),
44
+ ),
45
+ ).toEqual([
46
+ { type: "tool_call", call: { name: "search", id: "tc-1", args: {} } },
47
+ ]);
48
+ });
49
+
50
+ it("keeps the other parts of an event carrying a call without args", () => {
51
+ expect(
52
+ toAdkStructuredEvents(
53
+ makeEvent({
54
+ author: "agent",
55
+ content: {
56
+ role: "model",
57
+ parts: [
58
+ { functionCall: { name: "search" } },
59
+ { text: "still here" },
60
+ ],
61
+ },
62
+ }),
63
+ ),
64
+ ).toEqual([
65
+ { type: "tool_call", call: { name: "search", args: {} } },
66
+ { type: "content", content: "still here" },
67
+ ]);
68
+ });
69
+ });
@@ -28,7 +28,10 @@ export function toAdkStructuredEvents(event: AdkEvent): AdkStructuredEvent[] {
28
28
  if (part.functionCall) {
29
29
  const call: AdkStructuredEvent & { type: "tool_call" } = {
30
30
  type: "tool_call",
31
- call: { name: part.functionCall.name, args: part.functionCall.args },
31
+ call: {
32
+ name: part.functionCall.name,
33
+ args: part.functionCall.args ?? {},
34
+ },
32
35
  };
33
36
  if (part.functionCall.id != null) call.call.id = part.functionCall.id;
34
37
  result.push(call);
package/src/types.ts CHANGED
@@ -9,7 +9,7 @@ export type AdkEventPart = {
9
9
  functionCall?: {
10
10
  name: string;
11
11
  id?: string;
12
- args: Record<string, unknown>;
12
+ args?: Record<string, unknown>;
13
13
  };
14
14
  functionResponse?: {
15
15
  name: string;
@@ -17,7 +17,6 @@ import {
17
17
  messageToEvent,
18
18
  messagesToEvents,
19
19
  useAdkMessages,
20
- type UseAdkMessagesOptions,
21
20
  } from "./useAdkMessages";
22
21
  import { projectAdkToolApprovals } from "./adkToolApproval";
23
22
  import { createAdkStream } from "./AdkClient";
@@ -113,7 +112,7 @@ describe("ADK runtime callbacks", () => {
113
112
  };
114
113
  const eventHandlers = {
115
114
  [callbackName]: callback,
116
- } as UseAdkMessagesOptions["eventHandlers"];
115
+ };
117
116
  const { result } = renderHook(() =>
118
117
  useAdkMessages({ stream, eventHandlers }),
119
118
  );
@@ -466,6 +465,10 @@ describe("optimistic confirmation replies", () => {
466
465
  projectAdkToolApprovals(result.current.messages).approvals.values(),
467
466
  ),
468
467
  ]).toEqual([{ id: "conf-a" }, { id: "conf-b" }]);
468
+ expect(result.current.toolConfirmations.map((c) => c.toolCallId)).toEqual([
469
+ "conf-a",
470
+ "conf-b",
471
+ ]);
469
472
  });
470
473
 
471
474
  it("keeps both gates pending when an ai message sits between the replies", async () => {
@@ -522,6 +525,102 @@ describe("optimistic confirmation replies", () => {
522
525
  projectAdkToolApprovals(result.current.messages).approvals.values(),
523
526
  ),
524
527
  ]).toEqual([{ id: "conf-a", approved: true }, { id: "conf-b" }]);
528
+ expect(result.current.toolConfirmations.map((c) => c.toolCallId)).toEqual([
529
+ "conf-b",
530
+ ]);
531
+ });
532
+ });
533
+
534
+ describe("pending requests across sends", () => {
535
+ it("keeps an unanswered request listed across sends until its reply is sent", async () => {
536
+ let run = 0;
537
+ const stream: AdkStreamCallback = async function* () {
538
+ run += 1;
539
+ if (run === 1) {
540
+ yield {
541
+ id: "requests",
542
+ author: "agent",
543
+ longRunningToolIds: ["conf-1", "cred-1"],
544
+ content: {
545
+ role: "model",
546
+ parts: [
547
+ {
548
+ functionCall: {
549
+ id: "conf-1",
550
+ name: "adk_request_confirmation",
551
+ args: {
552
+ originalFunctionCall: { id: "gated-1", name: "transfer" },
553
+ toolConfirmation: { hint: "Transfer?" },
554
+ },
555
+ },
556
+ },
557
+ {
558
+ functionCall: {
559
+ id: "cred-1",
560
+ name: "adk_request_credential",
561
+ args: {
562
+ function_call_id: "gated-2",
563
+ auth_config: { credentialKey: "k" },
564
+ },
565
+ },
566
+ },
567
+ ],
568
+ },
569
+ } satisfies AdkEvent;
570
+ } else if (run === 2) {
571
+ yield {
572
+ id: "answer",
573
+ author: "agent",
574
+ content: { role: "model", parts: [{ text: "still waiting" }] },
575
+ } satisfies AdkEvent;
576
+ }
577
+ };
578
+ const { result } = renderHook(() => useAdkMessages({ stream }));
579
+ const pending = () => ({
580
+ confirmations: result.current.toolConfirmations.map((c) => c.toolCallId),
581
+ authRequests: result.current.authRequests.map((r) => r.toolCallId),
582
+ });
583
+
584
+ await act(async () => {
585
+ await result.current.sendMessage(
586
+ [{ id: "user-1", type: "human", content: "start" }],
587
+ {},
588
+ );
589
+ });
590
+ expect(pending()).toEqual({
591
+ confirmations: ["conf-1"],
592
+ authRequests: ["cred-1"],
593
+ });
594
+
595
+ await act(async () => {
596
+ await result.current.sendMessage(
597
+ [{ id: "user-2", type: "human", content: "any news?" }],
598
+ {},
599
+ );
600
+ });
601
+ expect(pending()).toEqual({
602
+ confirmations: ["conf-1"],
603
+ authRequests: ["cred-1"],
604
+ });
605
+
606
+ await act(async () => {
607
+ await result.current.sendMessage(
608
+ [
609
+ {
610
+ id: "reply",
611
+ type: "tool",
612
+ tool_call_id: "cred-1",
613
+ name: "adk_request_credential",
614
+ content: JSON.stringify({
615
+ exchangedAuthCredential: { authType: "apiKey" },
616
+ }),
617
+ status: "success",
618
+ },
619
+ ],
620
+ {},
621
+ );
622
+ });
623
+ expect(pending()).toEqual({ confirmations: ["conf-1"], authRequests: [] });
525
624
  });
526
625
  });
527
626
 
@@ -680,7 +779,7 @@ describe("messageToEvent (contentToParts)", () => {
680
779
  name: "search",
681
780
  });
682
781
 
683
- expect(event.content?.parts[0]?.functionResponse?.response).toEqual(
782
+ expect(event.content?.parts?.[0]?.functionResponse?.response).toEqual(
684
783
  response,
685
784
  );
686
785
  },
@@ -170,6 +170,8 @@ export const useAdkMessages = ({
170
170
  }
171
171
  setMessagesImmediate(accumulator.getMessages());
172
172
  setLongRunningToolIds(accumulator.getLongRunningToolIds());
173
+ setToolConfirmations(accumulator.getToolConfirmations());
174
+ setAuthRequests(accumulator.getAuthRequests());
173
175
 
174
176
  // Google ADK replaces active runs, while React LangGraph queues sends.
175
177
  abortControllerRef.current?.abort();