@assistant-ui/react-google-adk 0.0.31 → 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 (80) hide show
  1. package/dist/AdkClient.d.ts +24 -26
  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 +39 -41
  6. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  7. package/dist/AdkSessionAdapter.d.ts +38 -39
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +4 -3
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkAuthRequest.d.ts +17 -0
  12. package/dist/adkAuthRequest.d.ts.map +1 -0
  13. package/dist/adkAuthRequest.js +45 -0
  14. package/dist/adkAuthRequest.js.map +1 -0
  15. package/dist/adkExtras.d.ts +1 -3
  16. package/dist/adkExtras.d.ts.map +1 -1
  17. package/dist/adkToolApproval.d.ts +12 -14
  18. package/dist/adkToolApproval.d.ts.map +1 -1
  19. package/dist/contentToParts.d.ts +1 -3
  20. package/dist/contentToParts.d.ts.map +1 -1
  21. package/dist/convertAdkMessages.d.ts +3 -5
  22. package/dist/convertAdkMessages.d.ts.map +1 -1
  23. package/dist/convertAdkMessages.js +4 -3
  24. package/dist/convertAdkMessages.js.map +1 -1
  25. package/dist/convertToAdkMessages.d.ts +32 -34
  26. package/dist/convertToAdkMessages.d.ts.map +1 -1
  27. package/dist/hooks.d.ts +10 -7
  28. package/dist/hooks.d.ts.map +1 -1
  29. package/dist/hooks.js +11 -9
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/index.d.ts +11 -10
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/normalizeAdkPart.d.ts +0 -2
  34. package/dist/normalizeAdkPart.d.ts.map +1 -1
  35. package/dist/parseAdkEvent.d.ts +1 -3
  36. package/dist/parseAdkEvent.d.ts.map +1 -1
  37. package/dist/raceWithAbortSignal.d.ts +2 -0
  38. package/dist/raceWithAbortSignal.d.ts.map +1 -0
  39. package/dist/raceWithAbortSignal.js +45 -0
  40. package/dist/raceWithAbortSignal.js.map +1 -0
  41. package/dist/sdkIdentity.d.ts +1 -3
  42. package/dist/sdkIdentity.d.ts.map +1 -1
  43. package/dist/sdkIdentity.js +1 -1
  44. package/dist/server/adkEventStream.d.ts +23 -24
  45. package/dist/server/adkEventStream.d.ts.map +1 -1
  46. package/dist/server/adkEventStream.js +23 -15
  47. package/dist/server/adkEventStream.js.map +1 -1
  48. package/dist/server/createAdkApiRoute.d.ts +21 -22
  49. package/dist/server/createAdkApiRoute.d.ts.map +1 -1
  50. package/dist/server/index.d.ts +4 -4
  51. package/dist/server/index.d.ts.map +1 -0
  52. package/dist/server/parseAdkRequest.d.ts +16 -17
  53. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  54. package/dist/structuredEvents.d.ts +1 -3
  55. package/dist/structuredEvents.d.ts.map +1 -1
  56. package/dist/toAdkFunctionResponse.d.ts +0 -2
  57. package/dist/toAdkFunctionResponse.d.ts.map +1 -1
  58. package/dist/trimTrailingSlashes.d.ts +0 -2
  59. package/dist/trimTrailingSlashes.d.ts.map +1 -1
  60. package/dist/types.d.ts +232 -234
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/useAdkMessages.d.ts +25 -27
  63. package/dist/useAdkMessages.d.ts.map +1 -1
  64. package/dist/useAdkRuntime.d.ts +48 -49
  65. package/dist/useAdkRuntime.d.ts.map +1 -1
  66. package/package.json +5 -5
  67. package/src/AdkClient.test.ts +50 -0
  68. package/src/AdkClient.ts +10 -3
  69. package/src/AdkSessionAdapter.test.ts +17 -0
  70. package/src/AdkSessionAdapter.ts +8 -3
  71. package/src/adkAuthRequest.test.ts +101 -0
  72. package/src/adkAuthRequest.ts +50 -0
  73. package/src/convertAdkMessages.test.ts +41 -0
  74. package/src/convertAdkMessages.ts +19 -7
  75. package/src/hooks.render.test.tsx +95 -2
  76. package/src/hooks.ts +12 -13
  77. package/src/raceWithAbortSignal.test.ts +73 -0
  78. package/src/raceWithAbortSignal.ts +48 -0
  79. package/src/server/adkEventStream.test.ts +147 -0
  80. package/src/server/adkEventStream.ts +27 -20
@@ -1,53 +1,52 @@
1
- import { AdkMessage, AdkStreamCallback, AdkThreadSnapshot, OnAdkAgentTransferCallback, OnAdkCustomEventCallback, OnAdkErrorCallback } from "./types.js";
2
- import { AttachmentAdapter, DictationAdapter, ExternalStoreSharedOptions, FeedbackAdapter, RealtimeVoiceAdapter, RemoteThreadListAdapter, SpeechSynthesisAdapter } from "@assistant-ui/core";
3
- import { AssistantCloud } from "assistant-cloud";
4
- //#region src/useAdkRuntime.d.ts
1
+ import { type AttachmentAdapter, type DictationAdapter, type ExternalStoreSharedOptions, type FeedbackAdapter, type RealtimeVoiceAdapter, type SpeechSynthesisAdapter } from "@assistant-ui/core";
2
+ import type { AssistantCloud } from "assistant-cloud";
3
+ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
4
+ import type { AdkMessage, AdkThreadSnapshot, AdkStreamCallback, OnAdkErrorCallback, OnAdkCustomEventCallback, OnAdkAgentTransferCallback } from "./types.js";
5
5
  export type UseAdkRuntimeOptions = ExternalStoreSharedOptions & {
6
- stream: AdkStreamCallback;
7
- /**
8
- * Called whenever the active thread's canonical (remote) ID changes, so the
9
- * value can be treated as a managed/controlled variable (e.g. synced to a URL
10
- * query param). Only the settled remote ID is emitted: while a freshly created
11
- * thread is still optimistic the value is `undefined`, and the real ID is
12
- * emitted once the thread is initialized; the transient local ID is never
13
- * surfaced.
14
- */
15
- onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
16
- autoCancelPendingToolCalls?: boolean | undefined;
17
- unstable_allowCancellation?: boolean | undefined;
18
- getCheckpointId?: (threadId: string, parentMessages: AdkMessage[]) => Promise<string | null>;
19
- /**
20
- * Loads a thread's stored state. Called when the thread opens, and again for
21
- * `threads.reloadMainThread()`, which refetches in place rather than
22
- * remounting the runtime; the signal aborts a load the runtime no longer
23
- * needs.
24
- */
25
- load?: (threadId: string, options?: {
26
- signal?: AbortSignal | undefined;
27
- }) => Promise<AdkThreadSnapshot>;
28
- create?: () => Promise<{
29
- externalId: string;
30
- }>;
31
- delete?: (threadId: string) => Promise<void>;
32
- adapters?: {
33
- attachments?: AttachmentAdapter;
34
- speech?: SpeechSynthesisAdapter;
35
- dictation?: DictationAdapter;
36
- voice?: RealtimeVoiceAdapter;
37
- feedback?: FeedbackAdapter;
38
- } | undefined;
39
- eventHandlers?: {
40
- onError?: OnAdkErrorCallback;
41
- onCustomEvent?: OnAdkCustomEventCallback;
42
- onAgentTransfer?: OnAdkAgentTransferCallback;
43
- } | undefined;
44
- cloud?: AssistantCloud | undefined;
45
- /**
46
- * A `RemoteThreadListAdapter` to use instead of the cloud adapter.
47
- * Use with `createAdkSessionAdapter` for ADK session-backed persistence.
48
- */
49
- sessionAdapter?: RemoteThreadListAdapter | undefined;
6
+ stream: AdkStreamCallback;
7
+ /**
8
+ * Called whenever the active thread's canonical (remote) ID changes, so the
9
+ * value can be treated as a managed/controlled variable (e.g. synced to a URL
10
+ * query param). Only the settled remote ID is emitted: while a freshly created
11
+ * thread is still optimistic the value is `undefined`, and the real ID is
12
+ * emitted once the thread is initialized; the transient local ID is never
13
+ * surfaced.
14
+ */
15
+ onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
16
+ autoCancelPendingToolCalls?: boolean | undefined;
17
+ unstable_allowCancellation?: boolean | undefined;
18
+ getCheckpointId?: (threadId: string, parentMessages: AdkMessage[]) => Promise<string | null>;
19
+ /**
20
+ * Loads a thread's stored state. Called when the thread opens, and again for
21
+ * `threads.reloadMainThread()`, which refetches in place rather than
22
+ * remounting the runtime; the signal aborts a load the runtime no longer
23
+ * needs.
24
+ */
25
+ load?: (threadId: string, options?: {
26
+ signal?: AbortSignal | undefined;
27
+ }) => Promise<AdkThreadSnapshot>;
28
+ create?: () => Promise<{
29
+ externalId: string;
30
+ }>;
31
+ delete?: (threadId: string) => Promise<void>;
32
+ adapters?: {
33
+ attachments?: AttachmentAdapter;
34
+ speech?: SpeechSynthesisAdapter;
35
+ dictation?: DictationAdapter;
36
+ voice?: RealtimeVoiceAdapter;
37
+ feedback?: FeedbackAdapter;
38
+ } | undefined;
39
+ eventHandlers?: {
40
+ onError?: OnAdkErrorCallback;
41
+ onCustomEvent?: OnAdkCustomEventCallback;
42
+ onAgentTransfer?: OnAdkAgentTransferCallback;
43
+ } | undefined;
44
+ cloud?: AssistantCloud | undefined;
45
+ /**
46
+ * A `RemoteThreadListAdapter` to use instead of the cloud adapter.
47
+ * Use with `createAdkSessionAdapter` for ADK session-backed persistence.
48
+ */
49
+ sessionAdapter?: RemoteThreadListAdapter | undefined;
50
50
  };
51
51
  export declare const useAdkRuntime: ({ cloud, sessionAdapter, create, delete: deleteFn, onThreadIdChange, ...options }: UseAdkRuntimeOptions) => import("@assistant-ui/core").AssistantRuntime;
52
- //#endregion
53
52
  //# sourceMappingURL=useAdkRuntime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useAdkRuntime.d.ts","names":[],"sources":["../src/useAdkRuntime.ts"],"mappings":";;;;YA6DY,uBAAuB;EACjC,QAAQ;;;;;;;;;EASR,qBAAqB;EACrB;EACA;EACA,mBACE,kBACA,gBAAgB,iBACb;;;;;;;EAOL,QACE,kBACA;IAAY,SAAS;QAClB,QAAQ;EACb,eAAe;IAAU;;EACzB,UAAU,qBAAqB;EAC/B;IAEM,cAAc;IACd,SAAS;IACT,YAAY;IACZ,QAAQ;IACR,WAAW;;EAGjB;IAEM,UAAU;IACV,gBAAgB;IAChB,kBAAkB;;EAGxB,QAAQ;;;;;EAKR,iBAAiB;;qBAkYN,kBAAiB,OAAA,gBAAA,QAAA,QAAA,UAAA,qBAAA,WAO3B,sDAAoB"}
1
+ {"version":3,"file":"useAdkRuntime.d.ts","sourceRoot":"","sources":["../src/useAdkRuntime.ts"],"names":[],"mappings":"AAQA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAK5B,MAAM,oBAAoB,CAAC;AAY5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAClE,OAAO,KAAK,EACV,UAAU,EACV,iBAAiB,EAEjB,iBAAiB,EACjB,kBAAkB,EAClB,wBAAwB,EACxB,0BAA0B,EAC3B,mBAAgB;AAmBjB,MAAM,MAAM,oBAAoB,GAAG,0BAA0B,GAAG;IAC9D,MAAM,EAAE,iBAAiB,CAAC;IAC1B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;IACxE,0BAA0B,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACjD,0BAA0B,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACjD,eAAe,CAAC,EAAE,CAChB,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,UAAU,EAAE,KACzB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC5B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,CACL,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;KAAE,KAC3C,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/C,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,QAAQ,CAAC,EACL;QACE,WAAW,CAAC,EAAE,iBAAiB,CAAC;QAChC,MAAM,CAAC,EAAE,sBAAsB,CAAC;QAChC,SAAS,CAAC,EAAE,gBAAgB,CAAC;QAC7B,KAAK,CAAC,EAAE,oBAAoB,CAAC;QAC7B,QAAQ,CAAC,EAAE,eAAe,CAAC;KAC5B,GACD,SAAS,CAAC;IACd,aAAa,CAAC,EACV;QACE,OAAO,CAAC,EAAE,kBAAkB,CAAC;QAC7B,aAAa,CAAC,EAAE,wBAAwB,CAAC;QACzC,eAAe,CAAC,EAAE,0BAA0B,CAAC;KAC9C,GACD,SAAS,CAAC;IACd,KAAK,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACnC;;;OAGG;IACH,cAAc,CAAC,EAAE,uBAAuB,GAAG,SAAS,CAAC;CACtD,CAAC;AAiYF,eAAO,MAAM,aAAa,GAAI,mFAO3B,oBAAoB,kDAsBtB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-google-adk",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "description": "Google ADK adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "google-adk",
@@ -34,10 +34,10 @@
34
34
  ],
35
35
  "sideEffects": false,
36
36
  "dependencies": {
37
- "@assistant-ui/core": "^0.3.20",
38
- "@assistant-ui/store": "^0.3.14",
37
+ "@assistant-ui/core": "^0.3.21",
38
+ "@assistant-ui/store": "^0.3.15",
39
39
  "assistant-cloud": "*",
40
- "assistant-stream": "^0.3.44"
40
+ "assistant-stream": "^0.3.45"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@google/adk": ">=0.5.0",
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "devDependencies": {
56
- "@assistant-ui/x-buildutils": "0.0.28",
56
+ "@assistant-ui/x-buildutils": "0.0.29",
57
57
  "@testing-library/dom": "^10.4.2",
58
58
  "@testing-library/react": "^16.3.3",
59
59
  "@types/react": "^19.3.0",
@@ -124,6 +124,24 @@ describe.each(["direct", "proxy", "proxy batch"] as const)(
124
124
  // ── Proxy mode ──
125
125
 
126
126
  describe("createAdkStream - proxy mode", () => {
127
+ it("aborts while dynamic headers are pending", async () => {
128
+ const controller = new AbortController();
129
+ const stream = createAdkStream({
130
+ api: "/api/adk",
131
+ headers: () => new Promise<Record<string, string>>(() => {}),
132
+ });
133
+ const events = await stream(
134
+ [{ id: "m1", type: "human", content: "Hello" }],
135
+ makeConfig({ abortSignal: controller.signal }),
136
+ );
137
+
138
+ const next = events.next();
139
+ controller.abort();
140
+
141
+ await expect(next).rejects.toBe(controller.signal.reason);
142
+ expect(mockFetch).not.toHaveBeenCalled();
143
+ });
144
+
127
145
  it("accumulates snake_case image and file parts from SSE", async () => {
128
146
  const event = {
129
147
  id: "media",
@@ -1077,6 +1095,38 @@ describe("createAdkStream - error handling", () => {
1077
1095
  'Expected ADK stream response Content-Type "text/event-stream", received no Content-Type header',
1078
1096
  );
1079
1097
  });
1098
+
1099
+ it("rejects event-stream responses without a body", async () => {
1100
+ mockFetch.mockResolvedValueOnce(sseResponse(null));
1101
+
1102
+ const stream = createAdkStream({ api: "/api/adk" });
1103
+ await expect(async () => {
1104
+ const gen = await stream(
1105
+ [{ id: "m1", type: "human", content: "Hi" }],
1106
+ makeConfig(),
1107
+ );
1108
+ for await (const _ of gen) {
1109
+ /* noop */
1110
+ }
1111
+ }).rejects.toThrow("Expected ADK stream response body, received no body");
1112
+ });
1113
+
1114
+ it("rejects non-standard responses with an undefined body", async () => {
1115
+ const response = sseResponse(null);
1116
+ Object.defineProperty(response, "body", { value: undefined });
1117
+ mockFetch.mockResolvedValueOnce(response);
1118
+
1119
+ const stream = createAdkStream({ api: "/api/adk" });
1120
+ await expect(async () => {
1121
+ const gen = await stream(
1122
+ [{ id: "m1", type: "human", content: "Hi" }],
1123
+ makeConfig(),
1124
+ );
1125
+ for await (const _ of gen) {
1126
+ /* noop */
1127
+ }
1128
+ }).rejects.toThrow("Expected ADK stream response body, received no body");
1129
+ });
1080
1130
  });
1081
1131
 
1082
1132
  // ── Headers ──
package/src/AdkClient.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SSEEventDecoder } from "assistant-stream/utils";
2
2
  import { contentToParts } from "./contentToParts";
3
3
  import { parseAdkEventValue } from "./parseAdkEvent";
4
+ import { raceWithAbortSignal } from "./raceWithAbortSignal";
4
5
  import { toAdkFunctionResponse } from "./toAdkFunctionResponse";
5
6
  import { trimTrailingSlashes } from "./trimTrailingSlashes";
6
7
  import type {
@@ -76,7 +77,7 @@ export function createAdkStream(
76
77
  }
77
78
 
78
79
  return async function* (messages, config) {
79
- const headers = await resolveHeaders(options.headers);
80
+ const headers = await resolveHeaders(options.headers, config.abortSignal);
80
81
 
81
82
  let url: string;
82
83
  let body: unknown;
@@ -149,9 +150,12 @@ async function resolveHeaders(
149
150
  | Record<string, string>
150
151
  | (() => Record<string, string> | Promise<Record<string, string>>)
151
152
  | undefined,
153
+ signal?: AbortSignal,
152
154
  ): Promise<Record<string, string>> {
153
155
  if (!headers) return {};
154
- if (typeof headers === "function") return await headers();
156
+ if (typeof headers === "function") {
157
+ return await raceWithAbortSignal(signal, headers);
158
+ }
155
159
  return headers;
156
160
  }
157
161
 
@@ -252,7 +256,10 @@ function messagesToProxyBody(
252
256
  }
253
257
 
254
258
  async function* parseSSEResponse(response: Response): AsyncGenerator<AdkEvent> {
255
- const reader = response.body!.getReader();
259
+ if (!response.body) {
260
+ throw new Error("Expected ADK stream response body, received no body");
261
+ }
262
+ const reader = response.body.getReader();
256
263
  const decoder = new TextDecoder();
257
264
  const sseDecoder = new SSEEventDecoder({ trailing: "dispatch" });
258
265
 
@@ -22,6 +22,23 @@ const baseOptions = {
22
22
  const expectedBaseUrl =
23
23
  "http://localhost:8000/apps/my-app/users/user-1/sessions";
24
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
+
25
42
  // ── adapter.list() ──
26
43
 
27
44
  describe("createAdkSessionAdapter - list", () => {
@@ -10,6 +10,7 @@ import { normalizeAdkPart } from "./normalizeAdkPart";
10
10
  import { parseAdkEventValue } from "./parseAdkEvent";
11
11
  import type { AdkMessage, AdkThreadSnapshot } from "./types";
12
12
  import { trimTrailingSlashes } from "./trimTrailingSlashes";
13
+ import { raceWithAbortSignal } from "./raceWithAbortSignal";
13
14
 
14
15
  export type AdkSessionAdapterOptions = {
15
16
  /**
@@ -230,9 +231,13 @@ export function createAdkSessionAdapter(
230
231
  const normalizedApiUrl = trimTrailingSlashes(apiUrl);
231
232
  const baseUrl = `${normalizedApiUrl}/apps/${encodeURIComponent(appName)}/users/${encodeURIComponent(userId)}/sessions`;
232
233
 
233
- const getHeaders = async (): Promise<Record<string, string>> => {
234
+ const getHeaders = async (
235
+ signal?: AbortSignal,
236
+ ): Promise<Record<string, string>> => {
234
237
  if (!options.headers) return {};
235
- if (typeof options.headers === "function") return await options.headers();
238
+ if (typeof options.headers === "function") {
239
+ return await raceWithAbortSignal(signal, options.headers);
240
+ }
236
241
  return options.headers;
237
242
  };
238
243
 
@@ -327,7 +332,7 @@ export function createAdkSessionAdapter(
327
332
  sessionId: string,
328
333
  options?: { signal?: AbortSignal | undefined },
329
334
  ): Promise<AdkThreadSnapshot> => {
330
- const headers = await getHeaders();
335
+ const headers = await getHeaders(options?.signal);
331
336
  const res = await fetch(`${baseUrl}/${encodeURIComponent(sessionId)}`, {
332
337
  headers,
333
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
+ };
@@ -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",
@@ -27,14 +27,25 @@ const contentToParts = (
27
27
  ): ContentPart[] => {
28
28
  if (typeof content === "string")
29
29
  return [{ type: "text" as const, text: content }];
30
+ if (!Array.isArray(content)) return [];
30
31
 
31
- return (content as AdkMessageContentPart[])
32
+ return content
33
+ .filter(
34
+ (part): part is AdkMessageContentPart =>
35
+ typeof part === "object" && part !== null,
36
+ )
32
37
  .map((part): ContentPart | null => {
33
38
  switch (part.type) {
34
39
  case "text":
35
- return { type: "text", text: part.text };
40
+ return {
41
+ type: "text",
42
+ text: typeof part.text === "string" ? part.text : "",
43
+ };
36
44
  case "reasoning":
37
- return { type: "reasoning", text: part.text };
45
+ return {
46
+ type: "reasoning",
47
+ text: typeof part.text === "string" ? part.text : "",
48
+ };
38
49
  case "image":
39
50
  return {
40
51
  type: "image",
@@ -132,10 +143,11 @@ export const createAdkMessageConverter =
132
143
 
133
144
  case "tool": {
134
145
  // A confirmation reply ADK could not read leaves its gate undecided.
135
- // Any result settles the tool call in core, so the reply is dropped
136
- // here to keep the gate requiring action and answerable again. Only a
137
- // reply to the confirmation itself is dropped: the gated call carries
138
- // the same approval, and its own result is the agent's real output.
146
+ // The reply is not the agent's output, so it is dropped rather than
147
+ // shown as the call's result while the gate waits to be answered again.
148
+ // Only a reply to the confirmation itself is dropped: the gated call
149
+ // carries the same approval, and its own result is the agent's real
150
+ // output.
139
151
  const approval = approvals.get(message.tool_call_id);
140
152
  if (
141
153
  message.name === ADK_REQUEST_CONFIRMATION &&
@@ -4,8 +4,8 @@ import { act, render, waitFor } from "@testing-library/react";
4
4
  import type { AssistantRuntime } from "@assistant-ui/core";
5
5
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
6
6
  import { describe, expect, it, vi } from "vitest";
7
- import { useAdkAppState } from "./hooks";
8
- import type { AdkEvent } from "./types";
7
+ import { useAdkAppState, useAdkAuthRequests, useAdkSubmitAuth } from "./hooks";
8
+ import type { AdkAuthRequest, AdkEvent, AdkMessage } from "./types";
9
9
  import { useAdkRuntime } from "./useAdkRuntime";
10
10
 
11
11
  describe("ADK state hook rendering", () => {
@@ -72,3 +72,96 @@ describe("ADK state hook rendering", () => {
72
72
  expect(appState).not.toBe(initial);
73
73
  });
74
74
  });
75
+
76
+ describe("useAdkSubmitAuth", () => {
77
+ it("answers the pending credential request with its config and the credential", async () => {
78
+ const authConfig = {
79
+ authScheme: { type: "apiKey", in: "header", name: "x-api-key" },
80
+ credentialKey: "weather",
81
+ };
82
+ const stream = vi.fn(async function* () {
83
+ if (stream.mock.calls.length === 1) {
84
+ yield {
85
+ id: "event-1",
86
+ author: "agent",
87
+ content: {
88
+ role: "model",
89
+ parts: [
90
+ {
91
+ functionCall: {
92
+ id: "cred-1",
93
+ name: "adk_request_credential",
94
+ args: { function_call_id: "tc-1", auth_config: authConfig },
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ longRunningToolIds: ["cred-1"],
100
+ turnComplete: true,
101
+ } satisfies AdkEvent;
102
+ return;
103
+ }
104
+ yield {
105
+ id: "event-2",
106
+ author: "agent",
107
+ content: { role: "model", parts: [{ text: "Sunny" }] },
108
+ turnComplete: true,
109
+ } satisfies AdkEvent;
110
+ });
111
+
112
+ let runtime: AssistantRuntime | undefined;
113
+ let authRequests: AdkAuthRequest[] | undefined;
114
+ let submitAuth: ReturnType<typeof useAdkSubmitAuth> | undefined;
115
+
116
+ const Probe = () => {
117
+ authRequests = useAdkAuthRequests();
118
+ submitAuth = useAdkSubmitAuth();
119
+ return null;
120
+ };
121
+
122
+ const App = () => {
123
+ runtime = useAdkRuntime({
124
+ stream,
125
+ create: async () => ({ externalId: "thread-1" }),
126
+ });
127
+ return (
128
+ <AssistantRuntimeProvider runtime={runtime}>
129
+ <Probe />
130
+ </AssistantRuntimeProvider>
131
+ );
132
+ };
133
+
134
+ render(<App />);
135
+ await act(async () => {
136
+ await runtime!.thread.append({
137
+ role: "user",
138
+ content: [{ type: "text", text: "weather?" }],
139
+ });
140
+ });
141
+ await waitFor(() =>
142
+ expect(authRequests).toEqual([{ toolCallId: "cred-1", authConfig }]),
143
+ );
144
+
145
+ await act(async () => {
146
+ await submitAuth!("cred-1", { authType: "apiKey", apiKey: "secret" });
147
+ });
148
+
149
+ const [replied] = stream.mock.calls[1] as unknown as [AdkMessage[]];
150
+ expect(replied).toHaveLength(1);
151
+ expect(replied[0]).toMatchObject({
152
+ type: "tool",
153
+ tool_call_id: "cred-1",
154
+ name: "adk_request_credential",
155
+ });
156
+ expect(JSON.parse((replied[0] as { content: string }).content)).toEqual({
157
+ ...authConfig,
158
+ exchangedAuthCredential: { authType: "apiKey", apiKey: "secret" },
159
+ });
160
+ await waitFor(() => expect(authRequests).toEqual([]));
161
+
162
+ expect(() =>
163
+ submitAuth!("cred-1", { authType: "apiKey", apiKey: "again" }),
164
+ ).toThrow('No pending ADK auth request for tool call id "cred-1"');
165
+ expect(stream).toHaveBeenCalledTimes(2);
166
+ });
167
+ });