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

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 (103) hide show
  1. package/dist/AdkClient.d.ts +26 -29
  2. package/dist/AdkClient.d.ts.map +1 -1
  3. package/dist/AdkClient.js +5 -3
  4. package/dist/AdkClient.js.map +1 -1
  5. package/dist/AdkEventAccumulator.d.ts +41 -45
  6. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  7. package/dist/AdkEventAccumulator.js +63 -99
  8. package/dist/AdkEventAccumulator.js.map +1 -1
  9. package/dist/AdkSessionAdapter.d.ts +41 -39
  10. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  11. package/dist/AdkSessionAdapter.js +11 -7
  12. package/dist/AdkSessionAdapter.js.map +1 -1
  13. package/dist/adkAuthRequest.d.ts +17 -0
  14. package/dist/adkAuthRequest.d.ts.map +1 -0
  15. package/dist/adkAuthRequest.js +45 -0
  16. package/dist/adkAuthRequest.js.map +1 -0
  17. package/dist/adkExtras.d.ts +2 -5
  18. package/dist/adkExtras.d.ts.map +1 -1
  19. package/dist/adkToolApproval.d.ts +18 -21
  20. package/dist/adkToolApproval.d.ts.map +1 -1
  21. package/dist/adkToolApproval.js +1 -3
  22. package/dist/adkToolApproval.js.map +1 -1
  23. package/dist/contentToParts.d.ts +2 -5
  24. package/dist/contentToParts.d.ts.map +1 -1
  25. package/dist/convertAdkMessages.d.ts +5 -8
  26. package/dist/convertAdkMessages.d.ts.map +1 -1
  27. package/dist/convertAdkMessages.js +4 -3
  28. package/dist/convertAdkMessages.js.map +1 -1
  29. package/dist/convertToAdkMessages.d.ts +36 -39
  30. package/dist/convertToAdkMessages.d.ts.map +1 -1
  31. package/dist/hooks.d.ts +25 -23
  32. package/dist/hooks.d.ts.map +1 -1
  33. package/dist/hooks.js +12 -10
  34. package/dist/hooks.js.map +1 -1
  35. package/dist/index.d.ts +11 -10
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/normalizeAdkPart.d.ts +2 -0
  38. package/dist/normalizeAdkPart.d.ts.map +1 -0
  39. package/dist/normalizeAdkPart.js +31 -0
  40. package/dist/normalizeAdkPart.js.map +1 -0
  41. package/dist/parseAdkEvent.d.ts +2 -5
  42. package/dist/parseAdkEvent.d.ts.map +1 -1
  43. package/dist/raceWithAbortSignal.d.ts +2 -0
  44. package/dist/raceWithAbortSignal.d.ts.map +1 -0
  45. package/dist/raceWithAbortSignal.js +45 -0
  46. package/dist/raceWithAbortSignal.js.map +1 -0
  47. package/dist/sdkIdentity.d.ts +2 -5
  48. package/dist/sdkIdentity.d.ts.map +1 -1
  49. package/dist/sdkIdentity.js +1 -1
  50. package/dist/server/adkEventStream.d.ts +25 -27
  51. package/dist/server/adkEventStream.d.ts.map +1 -1
  52. package/dist/server/adkEventStream.js +23 -15
  53. package/dist/server/adkEventStream.js.map +1 -1
  54. package/dist/server/createAdkApiRoute.d.ts +23 -25
  55. package/dist/server/createAdkApiRoute.d.ts.map +1 -1
  56. package/dist/server/index.d.ts +4 -4
  57. package/dist/server/index.d.ts.map +1 -0
  58. package/dist/server/parseAdkRequest.d.ts +18 -20
  59. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  60. package/dist/structuredEvents.d.ts +2 -5
  61. package/dist/structuredEvents.d.ts.map +1 -1
  62. package/dist/structuredEvents.js +1 -1
  63. package/dist/structuredEvents.js.map +1 -1
  64. package/dist/toAdkFunctionResponse.d.ts +2 -5
  65. package/dist/toAdkFunctionResponse.d.ts.map +1 -1
  66. package/dist/trimTrailingSlashes.d.ts +1 -4
  67. package/dist/trimTrailingSlashes.d.ts.map +1 -1
  68. package/dist/types.d.ts +253 -256
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/types.js.map +1 -1
  71. package/dist/useAdkMessages.d.ts +26 -29
  72. package/dist/useAdkMessages.d.ts.map +1 -1
  73. package/dist/useAdkMessages.js +2 -0
  74. package/dist/useAdkMessages.js.map +1 -1
  75. package/dist/useAdkRuntime.d.ts +50 -52
  76. package/dist/useAdkRuntime.d.ts.map +1 -1
  77. package/dist/useAdkRuntime.js +1 -1
  78. package/package.json +10 -9
  79. package/src/AdkClient.test.ts +50 -0
  80. package/src/AdkClient.ts +10 -3
  81. package/src/AdkEventAccumulator.test.ts +312 -11
  82. package/src/AdkEventAccumulator.ts +68 -142
  83. package/src/AdkSessionAdapter.test.ts +284 -1
  84. package/src/AdkSessionAdapter.ts +32 -8
  85. package/src/adkAuthRequest.test.ts +101 -0
  86. package/src/adkAuthRequest.ts +50 -0
  87. package/src/adkToolApproval.test.ts +0 -4
  88. package/src/adkToolApproval.ts +1 -3
  89. package/src/convertAdkMessages.test.ts +41 -0
  90. package/src/convertAdkMessages.ts +19 -7
  91. package/src/hooks.render.test.tsx +95 -2
  92. package/src/hooks.ts +12 -13
  93. package/src/normalizeAdkPart.ts +35 -0
  94. package/src/raceWithAbortSignal.test.ts +73 -0
  95. package/src/raceWithAbortSignal.ts +48 -0
  96. package/src/server/adkEventStream.test.ts +147 -0
  97. package/src/server/adkEventStream.ts +27 -20
  98. package/src/structuredEvents.test.ts +69 -0
  99. package/src/structuredEvents.ts +4 -1
  100. package/src/types.ts +1 -1
  101. package/src/useAdkMessages.test.ts +102 -3
  102. package/src/useAdkMessages.ts +2 -0
  103. 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
 
@@ -21,6 +22,23 @@ const baseOptions = {
21
22
  const expectedBaseUrl =
22
23
  "http://localhost:8000/apps/my-app/users/user-1/sessions";
23
24
 
25
+ describe("createAdkSessionAdapter - load cancellation", () => {
26
+ it("aborts while dynamic headers are pending", async () => {
27
+ const controller = new AbortController();
28
+ const reason = new Error("cancelled");
29
+ const { load } = createAdkSessionAdapter({
30
+ ...baseOptions,
31
+ headers: () => new Promise<Record<string, string>>(() => {}),
32
+ });
33
+
34
+ const result = load("session-1", { signal: controller.signal });
35
+ controller.abort(reason);
36
+
37
+ await expect(result).rejects.toBe(reason);
38
+ expect(mockFetch).not.toHaveBeenCalled();
39
+ });
40
+ });
41
+
24
42
  // ── adapter.list() ──
25
43
 
26
44
  describe("createAdkSessionAdapter - list", () => {
@@ -375,6 +393,117 @@ describe("createAdkSessionAdapter - load", () => {
375
393
  ]);
376
394
  });
377
395
 
396
+ it("loads valid events when history contains malformed media", async () => {
397
+ mockFetch.mockResolvedValueOnce(
398
+ new Response(
399
+ JSON.stringify({
400
+ id: "s1",
401
+ events: [
402
+ {
403
+ id: "user-1",
404
+ author: "user",
405
+ content: { parts: [{ text: "before" }] },
406
+ },
407
+ {
408
+ id: "bad-media",
409
+ author: "agent",
410
+ content: {
411
+ parts: [
412
+ { inlineData: { data: "aGVsbG8=" } },
413
+ { fileData: { mimeType: "image/png" } },
414
+ ],
415
+ },
416
+ },
417
+ {
418
+ id: "agent-1",
419
+ author: "agent",
420
+ content: { parts: [{ text: "after" }] },
421
+ },
422
+ ],
423
+ }),
424
+ { status: 200 },
425
+ ),
426
+ );
427
+
428
+ const { load } = createAdkSessionAdapter(baseOptions);
429
+ const result = await load("s1");
430
+
431
+ expect(result.messages).toMatchObject([
432
+ { type: "human", content: "before" },
433
+ { type: "ai", content: [{ type: "text", text: "after" }] },
434
+ ]);
435
+ });
436
+
437
+ it("loads valid events when history contains request calls without args", async () => {
438
+ mockFetch.mockResolvedValueOnce(
439
+ new Response(
440
+ JSON.stringify({
441
+ id: "s1",
442
+ events: [
443
+ {
444
+ id: "user-1",
445
+ author: "user",
446
+ content: { parts: [{ text: "before" }] },
447
+ },
448
+ {
449
+ id: "bad-requests",
450
+ author: "agent",
451
+ content: {
452
+ parts: [
453
+ {
454
+ functionCall: {
455
+ name: "adk_request_confirmation",
456
+ id: "rc-1",
457
+ },
458
+ },
459
+ {
460
+ functionCall: {
461
+ name: "adk_request_credential",
462
+ id: "rc-2",
463
+ },
464
+ },
465
+ ],
466
+ },
467
+ },
468
+ {
469
+ id: "agent-1",
470
+ author: "agent",
471
+ content: { parts: [{ text: "after" }] },
472
+ },
473
+ ],
474
+ }),
475
+ { status: 200 },
476
+ ),
477
+ );
478
+
479
+ const { load } = createAdkSessionAdapter(baseOptions);
480
+ const result = await load("s1");
481
+
482
+ expect(result.messages).toMatchObject([
483
+ { type: "human", content: "before" },
484
+ { type: "ai" },
485
+ { type: "ai", content: [{ type: "text", text: "after" }] },
486
+ ]);
487
+ expect(
488
+ (result.messages[1] as AdkMessage & { type: "ai" }).tool_calls,
489
+ ).toEqual([
490
+ {
491
+ id: "rc-1",
492
+ name: "adk_request_confirmation",
493
+ args: {},
494
+ argsText: "{}",
495
+ },
496
+ {
497
+ id: "rc-2",
498
+ name: "adk_request_credential",
499
+ args: {},
500
+ argsText: "{}",
501
+ },
502
+ ]);
503
+ expect(result.toolConfirmations).toMatchObject([{ toolCallId: "rc-1" }]);
504
+ expect(result.authRequests).toMatchObject([{ toolCallId: "rc-2" }]);
505
+ });
506
+
378
507
  it("returns the per-turn state the events imply, not just the messages", async () => {
379
508
  const session = {
380
509
  id: "s1",
@@ -405,6 +534,85 @@ describe("createAdkSessionAdapter - load", () => {
405
534
  expect(result.authRequests).toEqual([]);
406
535
  });
407
536
 
537
+ it("reports only the requests the stored replies leave unanswered", async () => {
538
+ const session = {
539
+ id: "s1",
540
+ events: [
541
+ {
542
+ id: "e1",
543
+ author: "agent",
544
+ content: {
545
+ role: "model",
546
+ parts: [
547
+ { functionCall: { name: "transfer", id: "gated-1", args: {} } },
548
+ { functionCall: { name: "calendar", id: "gated-2", args: {} } },
549
+ ],
550
+ },
551
+ },
552
+ {
553
+ id: "e2",
554
+ author: "agent",
555
+ longRunningToolIds: ["conf-1", "cred-1"],
556
+ actions: {
557
+ requestedToolConfirmations: { "gated-1": { hint: "Transfer?" } },
558
+ },
559
+ content: {
560
+ role: "user",
561
+ parts: [
562
+ {
563
+ functionCall: {
564
+ name: "adk_request_confirmation",
565
+ id: "conf-1",
566
+ args: {
567
+ originalFunctionCall: { id: "gated-1", name: "transfer" },
568
+ toolConfirmation: { hint: "Transfer?" },
569
+ },
570
+ },
571
+ },
572
+ {
573
+ functionCall: {
574
+ name: "adk_request_credential",
575
+ id: "cred-1",
576
+ args: {
577
+ functionCallId: "gated-2",
578
+ authConfig: { credentialKey: "k" },
579
+ },
580
+ },
581
+ },
582
+ ],
583
+ },
584
+ },
585
+ {
586
+ id: "e3",
587
+ author: "user",
588
+ content: {
589
+ role: "user",
590
+ parts: [
591
+ {
592
+ functionResponse: {
593
+ name: "adk_request_confirmation",
594
+ id: "conf-1",
595
+ response: { confirmed: true },
596
+ },
597
+ },
598
+ ],
599
+ },
600
+ },
601
+ ],
602
+ };
603
+ mockFetch.mockResolvedValueOnce(
604
+ new Response(JSON.stringify(session), { status: 200 }),
605
+ );
606
+
607
+ const { load } = createAdkSessionAdapter(baseOptions);
608
+ const result = await load("s1");
609
+
610
+ expect(result.toolConfirmations).toEqual([]);
611
+ expect(result.authRequests).toEqual([
612
+ { toolCallId: "cred-1", authConfig: { credentialKey: "k" } },
613
+ ]);
614
+ });
615
+
408
616
  it("passes an abort signal through to the request", async () => {
409
617
  mockFetch.mockResolvedValueOnce(
410
618
  new Response(JSON.stringify({ id: "s1", events: [] }), { status: 200 }),
@@ -727,6 +935,7 @@ describe("createAdkSessionAdapter - artifacts", () => {
727
935
  "inline data",
728
936
  { inlineData: { mimeType: "image/png", data: "aGVsbG8=" } },
729
937
  ],
938
+ ["file data", { fileData: { fileUri: "https://example.test/report.pdf" } }],
730
939
  ])("loads valid %s artifacts", async (_label, artifact) => {
731
940
  mockFetch.mockResolvedValueOnce(
732
941
  new Response(JSON.stringify(artifact), { status: 200 }),
@@ -737,6 +946,75 @@ describe("createAdkSessionAdapter - artifacts", () => {
737
946
  await expect(artifacts.load("s1", "report.pdf")).resolves.toEqual(artifact);
738
947
  });
739
948
 
949
+ it.each([
950
+ [
951
+ "inline data",
952
+ { inline_data: { mime_type: "application/pdf", data: "aGVsbG8=" } },
953
+ { inlineData: { mimeType: "application/pdf", data: "aGVsbG8=" } },
954
+ ],
955
+ [
956
+ "file data",
957
+ {
958
+ file_data: {
959
+ mime_type: "application/pdf",
960
+ file_uri: "https://example.test/report.pdf",
961
+ },
962
+ },
963
+ {
964
+ fileData: {
965
+ mimeType: "application/pdf",
966
+ fileUri: "https://example.test/report.pdf",
967
+ },
968
+ },
969
+ ],
970
+ ])(
971
+ "normalizes snake_case %s artifact responses",
972
+ async (_label, value, expected) => {
973
+ mockFetch.mockResolvedValueOnce(
974
+ new Response(JSON.stringify(value), { status: 200 }),
975
+ );
976
+
977
+ const { artifacts } = createAdkSessionAdapter(baseOptions);
978
+
979
+ await expect(artifacts.load("s1", "report.pdf")).resolves.toMatchObject(
980
+ expected,
981
+ );
982
+ },
983
+ );
984
+
985
+ it("prefers camelCase artifact fields when both aliases are present", async () => {
986
+ mockFetch.mockResolvedValueOnce(
987
+ new Response(
988
+ JSON.stringify({
989
+ inlineData: {
990
+ mimeType: "image/png",
991
+ mime_type: "application/pdf",
992
+ data: "right",
993
+ },
994
+ inline_data: { mime_type: "text/plain", data: "wrong" },
995
+ fileData: {
996
+ mimeType: "application/pdf",
997
+ mime_type: "image/png",
998
+ fileUri: "https://example.test/right.pdf",
999
+ file_uri: "https://example.test/wrong.png",
1000
+ },
1001
+ file_data: { file_uri: "https://example.test/other.png" },
1002
+ }),
1003
+ { status: 200 },
1004
+ ),
1005
+ );
1006
+
1007
+ const { artifacts } = createAdkSessionAdapter(baseOptions);
1008
+
1009
+ await expect(artifacts.load("s1", "report.pdf")).resolves.toMatchObject({
1010
+ inlineData: { mimeType: "image/png", data: "right" },
1011
+ fileData: {
1012
+ mimeType: "application/pdf",
1013
+ fileUri: "https://example.test/right.pdf",
1014
+ },
1015
+ });
1016
+ });
1017
+
740
1018
  it("rejects an artifact without supported content", async () => {
741
1019
  mockFetch.mockResolvedValueOnce(
742
1020
  new Response(JSON.stringify({}), { status: 200 }),
@@ -745,7 +1023,7 @@ describe("createAdkSessionAdapter - artifacts", () => {
745
1023
  const { artifacts } = createAdkSessionAdapter(baseOptions);
746
1024
 
747
1025
  await expect(artifacts.load("s1", "report.pdf")).rejects.toThrow(
748
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
1026
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
749
1027
  );
750
1028
  });
751
1029
 
@@ -760,6 +1038,11 @@ describe("createAdkSessionAdapter - artifacts", () => {
760
1038
  { inlineData: { mimeType: "image/png" } },
761
1039
  'Invalid ADK artifact load response: "inlineData" must contain string "mimeType" and "data" fields.',
762
1040
  ],
1041
+ [
1042
+ "file data",
1043
+ { fileData: { mimeType: "application/pdf" } },
1044
+ 'Invalid ADK artifact load response: "fileData" must contain a string "fileUri" and an optional string "mimeType" field.',
1045
+ ],
763
1046
  ])("rejects malformed %s artifact content", async (_label, value, error) => {
764
1047
  mockFetch.mockResolvedValueOnce(
765
1048
  new Response(JSON.stringify(value), { status: 200 }),
@@ -6,9 +6,11 @@ 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";
13
+ import { raceWithAbortSignal } from "./raceWithAbortSignal";
12
14
 
13
15
  export type AdkSessionAdapterOptions = {
14
16
  /**
@@ -37,6 +39,7 @@ export type AdkSessionAdapterOptions = {
37
39
 
38
40
  export type AdkArtifactData = {
39
41
  inlineData?: { mimeType: string; data: string } | undefined;
42
+ fileData?: { fileUri: string; mimeType?: string | undefined } | undefined;
40
43
  text?: string | undefined;
41
44
  };
42
45
 
@@ -133,14 +136,19 @@ const parseAdkArtifactListResponse = (value: unknown): string[] => {
133
136
  const parseAdkArtifactResponse = (value: unknown): AdkArtifactData => {
134
137
  if (!isRecord(value)) {
135
138
  throw new Error(
136
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
139
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
137
140
  );
138
141
  }
139
142
 
140
- const { text, inlineData } = value;
141
- if (text === undefined && inlineData === undefined) {
143
+ const normalizedValue = normalizeAdkPart(value);
144
+ const { text, inlineData, fileData } = normalizedValue;
145
+ if (
146
+ text === undefined &&
147
+ inlineData === undefined &&
148
+ fileData === undefined
149
+ ) {
142
150
  throw new Error(
143
- 'Invalid ADK artifact load response: expected an object containing "text" or "inlineData".',
151
+ 'Invalid ADK artifact load response: expected an object containing "text", "inlineData", or "fileData".',
144
152
  );
145
153
  }
146
154
 
@@ -161,7 +169,19 @@ const parseAdkArtifactResponse = (value: unknown): AdkArtifactData => {
161
169
  );
162
170
  }
163
171
 
164
- return value as AdkArtifactData;
172
+ if (
173
+ fileData !== undefined &&
174
+ (!isRecord(fileData) ||
175
+ typeof fileData.fileUri !== "string" ||
176
+ (fileData.mimeType !== undefined &&
177
+ typeof fileData.mimeType !== "string"))
178
+ ) {
179
+ throw new Error(
180
+ 'Invalid ADK artifact load response: "fileData" must contain a string "fileUri" and an optional string "mimeType" field.',
181
+ );
182
+ }
183
+
184
+ return normalizedValue as AdkArtifactData;
165
185
  };
166
186
 
167
187
  const parseAdkArtifactVersionsResponse = (value: unknown): number[] => {
@@ -211,9 +231,13 @@ export function createAdkSessionAdapter(
211
231
  const normalizedApiUrl = trimTrailingSlashes(apiUrl);
212
232
  const baseUrl = `${normalizedApiUrl}/apps/${encodeURIComponent(appName)}/users/${encodeURIComponent(userId)}/sessions`;
213
233
 
214
- const getHeaders = async (): Promise<Record<string, string>> => {
234
+ const getHeaders = async (
235
+ signal?: AbortSignal,
236
+ ): Promise<Record<string, string>> => {
215
237
  if (!options.headers) return {};
216
- if (typeof options.headers === "function") return await options.headers();
238
+ if (typeof options.headers === "function") {
239
+ return await raceWithAbortSignal(signal, options.headers);
240
+ }
217
241
  return options.headers;
218
242
  };
219
243
 
@@ -308,7 +332,7 @@ export function createAdkSessionAdapter(
308
332
  sessionId: string,
309
333
  options?: { signal?: AbortSignal | undefined },
310
334
  ): Promise<AdkThreadSnapshot> => {
311
- const headers = await getHeaders();
335
+ const headers = await getHeaders(options?.signal);
312
336
  const res = await fetch(`${baseUrl}/${encodeURIComponent(sessionId)}`, {
313
337
  headers,
314
338
  ...(options?.signal ? { signal: options.signal } : {}),
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toAdkAuthReply } from "./adkAuthRequest";
3
+ import type { AdkAuthRequest } from "./types";
4
+
5
+ const CREDENTIAL_CALL = "adk-credential-1";
6
+
7
+ const apiKeyScheme = { type: "apiKey", in: "header", name: "x-api-key" };
8
+
9
+ const request = (authConfig: unknown): AdkAuthRequest => ({
10
+ toolCallId: CREDENTIAL_CALL,
11
+ authConfig,
12
+ });
13
+
14
+ const wire = (content: string) => JSON.parse(content);
15
+
16
+ describe("toAdkAuthReply", () => {
17
+ it("answers the credential call with the request's config carrying the credential", () => {
18
+ const reply = toAdkAuthReply(
19
+ CREDENTIAL_CALL,
20
+ { authType: "apiKey", apiKey: "secret" },
21
+ [request({ authScheme: apiKeyScheme, credentialKey: "weather" })],
22
+ );
23
+
24
+ expect(reply).toMatchObject({
25
+ type: "tool",
26
+ tool_call_id: CREDENTIAL_CALL,
27
+ name: "adk_request_credential",
28
+ status: "success",
29
+ });
30
+ expect(wire(reply.content)).toEqual({
31
+ authScheme: apiKeyScheme,
32
+ credentialKey: "weather",
33
+ exchangedAuthCredential: { authType: "apiKey", apiKey: "secret" },
34
+ });
35
+ });
36
+
37
+ it("lays the OAuth2 answer over the exchanged credential the request issued", () => {
38
+ const oauth2Scheme = { type: "oauth2", flows: {} };
39
+ const reply = toAdkAuthReply(
40
+ CREDENTIAL_CALL,
41
+ {
42
+ authType: "oauth2",
43
+ oauth2: { authResponseUri: "https://app/cb?code=c&state=s" },
44
+ },
45
+ [
46
+ request({
47
+ authScheme: oauth2Scheme,
48
+ credentialKey: "calendar",
49
+ rawAuthCredential: { authType: "oauth2", oauth2: { clientId: "id" } },
50
+ exchangedAuthCredential: {
51
+ authType: "oauth2",
52
+ oauth2: {
53
+ clientId: "id",
54
+ authUri: "https://idp/authorize?state=s",
55
+ state: "s",
56
+ redirectUri: "https://app/cb",
57
+ },
58
+ },
59
+ }),
60
+ ],
61
+ );
62
+
63
+ expect(wire(reply.content)).toEqual({
64
+ authScheme: oauth2Scheme,
65
+ credentialKey: "calendar",
66
+ rawAuthCredential: { authType: "oauth2", oauth2: { clientId: "id" } },
67
+ exchangedAuthCredential: {
68
+ authType: "oauth2",
69
+ oauth2: {
70
+ clientId: "id",
71
+ authUri: "https://idp/authorize?state=s",
72
+ state: "s",
73
+ redirectUri: "https://app/cb",
74
+ authResponseUri: "https://app/cb?code=c&state=s",
75
+ },
76
+ },
77
+ });
78
+ });
79
+
80
+ it("sends the credential alone when the request carried no config", () => {
81
+ const reply = toAdkAuthReply(
82
+ CREDENTIAL_CALL,
83
+ { authType: "apiKey", apiKey: "secret" },
84
+ [request(undefined)],
85
+ );
86
+
87
+ expect(wire(reply.content)).toEqual({
88
+ exchangedAuthCredential: { authType: "apiKey", apiKey: "secret" },
89
+ });
90
+ });
91
+
92
+ it("throws for an id that is not a pending request", () => {
93
+ expect(() =>
94
+ toAdkAuthReply("adk-credential-2", { authType: "apiKey", apiKey: "x" }, [
95
+ request({ authScheme: apiKeyScheme, credentialKey: "weather" }),
96
+ ]),
97
+ ).toThrow(
98
+ 'No pending ADK auth request for tool call id "adk-credential-2"',
99
+ );
100
+ });
101
+ });
@@ -0,0 +1,50 @@
1
+ import { generateId } from "@assistant-ui/core";
2
+ import { isRecord } from "@assistant-ui/core/internal";
3
+ import type { AdkAuthCredential, AdkAuthRequest, AdkMessage } from "./types";
4
+
5
+ export const ADK_REQUEST_CREDENTIAL = "adk_request_credential";
6
+
7
+ const recordOf = (value: unknown): Record<string, unknown> =>
8
+ isRecord(value) ? value : {};
9
+
10
+ /**
11
+ * ADK binds a credential reply to the `adk_request_credential` call it issued
12
+ * and takes only `exchangedAuthCredential` off the response, so the reply is
13
+ * the request's own auth config with the credential filled in, as ADK's
14
+ * reference client sends it: adk-python validates the response as a whole auth
15
+ * config, and it exchanges an OAuth2 code with the client id, redirect uri and
16
+ * state the request carried, so the caller's fields land over the request's
17
+ * exchanged credential rather than replacing it. An id that is not a pending
18
+ * request would send a reply adk-js ignores and adk-python answers by
19
+ * requesting again, so it throws instead.
20
+ */
21
+ export const toAdkAuthReply = (
22
+ toolCallId: string,
23
+ credential: AdkAuthCredential,
24
+ requests: readonly AdkAuthRequest[],
25
+ ): AdkMessage & { type: "tool" } => {
26
+ const request = requests.find((r) => r.toolCallId === toolCallId);
27
+ if (request === undefined)
28
+ throw new Error(
29
+ `No pending ADK auth request for tool call id "${toolCallId}"`,
30
+ );
31
+
32
+ const authConfig = recordOf(request.authConfig);
33
+ const requested = recordOf(authConfig.exchangedAuthCredential);
34
+ const oauth2 = { ...recordOf(requested.oauth2), ...credential.oauth2 };
35
+ return {
36
+ id: generateId(),
37
+ type: "tool",
38
+ tool_call_id: toolCallId,
39
+ name: ADK_REQUEST_CREDENTIAL,
40
+ content: JSON.stringify({
41
+ ...authConfig,
42
+ exchangedAuthCredential: {
43
+ ...requested,
44
+ ...credential,
45
+ ...(Object.keys(oauth2).length > 0 && { oauth2 }),
46
+ },
47
+ }),
48
+ status: "success",
49
+ };
50
+ };
@@ -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;
@@ -13,6 +13,20 @@ describe("convertAdkMessage - human messages", () => {
13
13
  });
14
14
  });
15
15
 
16
+ it("returns empty content when content is neither a string nor an array", () => {
17
+ for (const content of [null, undefined, { text: "Hello" }]) {
18
+ const msg = {
19
+ id: "m1",
20
+ type: "human",
21
+ content,
22
+ } as unknown as AdkMessage;
23
+ expect(convertAdkMessage(msg, {})).toMatchObject({
24
+ role: "user",
25
+ content: [],
26
+ });
27
+ }
28
+ });
29
+
16
30
  it("converts a human message with text content parts", () => {
17
31
  const msg: AdkMessage = {
18
32
  id: "m1",
@@ -128,6 +142,33 @@ describe("convertAdkMessage - human messages", () => {
128
142
  });
129
143
 
130
144
  describe("convertAdkMessage - ai messages", () => {
145
+ it("coerces missing text on text and reasoning parts to an empty string", () => {
146
+ const msg = {
147
+ id: "m1",
148
+ type: "ai",
149
+ content: [{ type: "text" }, { type: "reasoning" }],
150
+ } as AdkMessage;
151
+ expect(convertAdkMessage(msg, {})).toMatchObject({
152
+ role: "assistant",
153
+ content: [
154
+ { type: "text", text: "" },
155
+ { type: "reasoning", text: "" },
156
+ ],
157
+ });
158
+ });
159
+
160
+ it("skips non-object entries in content arrays", () => {
161
+ const msg = {
162
+ id: "m1",
163
+ type: "ai",
164
+ content: [null, "Hello", { type: "text", text: "Hi" }],
165
+ } as unknown as AdkMessage;
166
+ expect(convertAdkMessage(msg, {})).toMatchObject({
167
+ role: "assistant",
168
+ content: [{ type: "text", text: "Hi" }],
169
+ });
170
+ });
171
+
131
172
  it("converts an ai message with text content", () => {
132
173
  const msg: AdkMessage = {
133
174
  id: "m1",