@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
@@ -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
+ });
package/src/hooks.ts CHANGED
@@ -2,6 +2,7 @@ import { generateId } from "@assistant-ui/core";
2
2
  import { useAui } from "@assistant-ui/store";
3
3
  import { useShallowSelector } from "@assistant-ui/store/internal";
4
4
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
5
+ import { toAdkAuthReply } from "./adkAuthRequest";
5
6
  import { adkExtras } from "./adkExtras";
6
7
  import { toAdkConfirmationReply } from "./adkToolApproval";
7
8
  import type {
@@ -74,23 +75,21 @@ export const useAdkConfirmTool = () => {
74
75
  .send([toAdkConfirmationReply(toolCallId, confirmed, payload)], {});
75
76
  };
76
77
 
77
- /** Returns a function to submit auth credentials for a pending auth request. */
78
+ /**
79
+ * Returns a function to submit auth credentials for a pending auth request.
80
+ * The reply carries the request's auth config with the credential as its
81
+ * `exchangedAuthCredential`, which is the shape ADK resumes the tool on, so the
82
+ * id must name a request `useAdkAuthRequests` currently lists.
83
+ */
78
84
  export const useAdkSubmitAuth = () => {
79
85
  const aui = useAui();
80
- return (toolCallId: string, credential: AdkAuthCredential) =>
81
- adkExtras.get(aui).send(
82
- [
83
- {
84
- id: generateId(),
85
- type: "tool",
86
- tool_call_id: toolCallId,
87
- name: "adk_request_credential",
88
- content: JSON.stringify(credential),
89
- status: "success",
90
- },
91
- ],
86
+ return (toolCallId: string, credential: AdkAuthCredential) => {
87
+ const extras = adkExtras.get(aui);
88
+ return extras.send(
89
+ [toAdkAuthReply(toolCallId, credential, extras.authRequests)],
92
90
  {},
93
91
  );
92
+ };
94
93
  };
95
94
 
96
95
  /** Returns a function to submit the user's answer for a pending `adk_request_input` HITL interrupt. */
@@ -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,73 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { raceWithAbortSignal } from "./raceWithAbortSignal";
3
+
4
+ describe("raceWithAbortSignal", () => {
5
+ it("invokes the operation synchronously without a signal", async () => {
6
+ const order: string[] = [];
7
+
8
+ const result = raceWithAbortSignal(undefined, () => {
9
+ order.push("operation");
10
+ return "done";
11
+ });
12
+ order.push("after");
13
+
14
+ expect(order).toEqual(["operation", "after"]);
15
+ await expect(result).resolves.toBe("done");
16
+ });
17
+
18
+ it("converts a synchronous operation error to a rejection", async () => {
19
+ const error = new Error("failed");
20
+
21
+ const result = raceWithAbortSignal(undefined, () => {
22
+ throw error;
23
+ });
24
+
25
+ await expect(result).rejects.toBe(error);
26
+ });
27
+
28
+ it("rejects a pending operation with the abort reason", async () => {
29
+ const controller = new AbortController();
30
+ const reason = new Error("cancelled");
31
+ let resolveOperation!: (value: string) => void;
32
+ const operation = new Promise<string>((resolve) => {
33
+ resolveOperation = resolve;
34
+ });
35
+
36
+ const result = raceWithAbortSignal(controller.signal, () => operation);
37
+ controller.abort(reason);
38
+
39
+ await expect(result).rejects.toBe(reason);
40
+ resolveOperation("late result");
41
+ });
42
+
43
+ it("rejects before invoking an operation for an already aborted signal", async () => {
44
+ const controller = new AbortController();
45
+ const reason = new Error("already cancelled");
46
+ const operation = vi.fn(() => "done");
47
+ controller.abort(reason);
48
+
49
+ const result = raceWithAbortSignal(controller.signal, operation);
50
+
51
+ await expect(result).rejects.toBe(reason);
52
+ expect(operation).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it("removes the abort listener after the operation settles", async () => {
56
+ const controller = new AbortController();
57
+ const removeEventListener = vi.spyOn(
58
+ controller.signal,
59
+ "removeEventListener",
60
+ );
61
+ const result = raceWithAbortSignal(controller.signal, () => "done");
62
+
63
+ await expect(result).resolves.toBe("done");
64
+ expect(removeEventListener).toHaveBeenCalledOnce();
65
+ expect(removeEventListener).toHaveBeenCalledWith(
66
+ "abort",
67
+ expect.any(Function),
68
+ );
69
+
70
+ controller.abort(new Error("late abort"));
71
+ await expect(result).resolves.toBe("done");
72
+ });
73
+ });
@@ -0,0 +1,48 @@
1
+ const getAbortReason = (signal: AbortSignal): unknown => {
2
+ if (signal.reason !== undefined) return signal.reason;
3
+ const error = new Error("The operation was aborted");
4
+ error.name = "AbortError";
5
+ return error;
6
+ };
7
+
8
+ export const raceWithAbortSignal = <T>(
9
+ signal: AbortSignal | undefined,
10
+ operation: () => T | PromiseLike<T>,
11
+ ): Promise<T> => {
12
+ if (!signal) {
13
+ try {
14
+ return Promise.resolve(operation());
15
+ } catch (error) {
16
+ return Promise.reject(error);
17
+ }
18
+ }
19
+ if (signal.aborted) return Promise.reject(getAbortReason(signal));
20
+
21
+ return new Promise<T>((resolve, reject) => {
22
+ let settled = false;
23
+ const cleanup = () => signal.removeEventListener("abort", handleAbort);
24
+ const resolveOnce = (value: T) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ cleanup();
28
+ resolve(value);
29
+ };
30
+ const rejectOnce = (error: unknown) => {
31
+ if (settled) return;
32
+ settled = true;
33
+ cleanup();
34
+ reject(error);
35
+ };
36
+ const handleAbort = () => rejectOnce(getAbortReason(signal));
37
+
38
+ signal.addEventListener("abort", handleAbort, { once: true });
39
+ let result: T | PromiseLike<T>;
40
+ try {
41
+ result = operation();
42
+ } catch (error) {
43
+ rejectOnce(error);
44
+ return;
45
+ }
46
+ Promise.resolve(result).then(resolveOnce, rejectOnce);
47
+ });
48
+ };
@@ -20,6 +20,153 @@ async function readSSE(response: Response): Promise<string> {
20
20
  }
21
21
 
22
22
  describe("adkEventStream", () => {
23
+ it("does not advance the generator before the response is read", async () => {
24
+ let produced = 0;
25
+ async function* events() {
26
+ for (let index = 0; index < 1000; index++) {
27
+ produced++;
28
+ yield { id: String(index) };
29
+ }
30
+ }
31
+ const response = adkEventStream(events());
32
+ await new Promise((resolve) => setTimeout(resolve, 0));
33
+ try {
34
+ expect(produced).toBe(0);
35
+ } finally {
36
+ await response.body!.cancel();
37
+ }
38
+ });
39
+
40
+ it("buffers at most one event ahead of a slow reader", async () => {
41
+ let produced = 0;
42
+ async function* events() {
43
+ for (let index = 0; index < 1000; index++) {
44
+ produced++;
45
+ yield { id: String(index) };
46
+ }
47
+ }
48
+ const reader = adkEventStream(events()).body!.getReader();
49
+ try {
50
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe(
51
+ ":ok\n\n",
52
+ );
53
+ await new Promise((resolve) => setTimeout(resolve, 0));
54
+ expect(produced).toBe(1);
55
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain(
56
+ '"id":"0"',
57
+ );
58
+ await new Promise((resolve) => setTimeout(resolve, 0));
59
+ expect(produced).toBe(2);
60
+ } finally {
61
+ await reader.cancel();
62
+ reader.releaseLock();
63
+ }
64
+ });
65
+
66
+ it("finalizes the generator when the reader cancels between events", async () => {
67
+ const finalized = vi.fn();
68
+ const onError = vi.fn();
69
+ async function* events() {
70
+ try {
71
+ while (true) yield { id: "event" };
72
+ } finally {
73
+ finalized();
74
+ }
75
+ }
76
+ const reader = adkEventStream(events(), { onError }).body!.getReader();
77
+ await reader.read();
78
+ await reader.read();
79
+ await reader.cancel();
80
+ expect(finalized).toHaveBeenCalledTimes(1);
81
+ expect(onError).not.toHaveBeenCalled();
82
+ expect(await reader.read()).toEqual({ done: true, value: undefined });
83
+ reader.releaseLock();
84
+ });
85
+
86
+ it("delivers every event in order when the response is fully consumed", async () => {
87
+ async function* events() {
88
+ for (let index = 0; index < 1000; index++) yield { id: String(index) };
89
+ }
90
+ const text = await readSSE(adkEventStream(events()));
91
+ const ids = text
92
+ .split("\n\n")
93
+ .filter((line) => line.startsWith("data: "))
94
+ .map((line) => JSON.parse(line.slice(6)).id);
95
+ expect(ids).toEqual(
96
+ Array.from({ length: 1000 }, (_, index) => String(index)),
97
+ );
98
+ });
99
+
100
+ it("allows onError to cancel the reader without writing to the closed stream", async () => {
101
+ let reader: ReadableStreamDefaultReader<Uint8Array>;
102
+ let cancelled: Promise<void> | undefined;
103
+ async function* events() {
104
+ throw new Error("stream failed");
105
+ }
106
+ const response = adkEventStream(events(), {
107
+ onError: () => {
108
+ cancelled = reader.cancel();
109
+ },
110
+ });
111
+ reader = response.body!.getReader();
112
+ await reader.read();
113
+ expect(await reader.read()).toEqual({ done: true, value: undefined });
114
+ await cancelled;
115
+ reader.releaseLock();
116
+ });
117
+
118
+ it.each(["yield", "finish", "reject"])(
119
+ "does not write after cancellation while next() later %s",
120
+ async (outcome) => {
121
+ let release!: () => void;
122
+ const waiting = new Promise<void>((resolve) => {
123
+ release = resolve;
124
+ });
125
+ let started!: () => void;
126
+ const entered = new Promise<void>((resolve) => {
127
+ started = resolve;
128
+ });
129
+ const finalized = vi.fn();
130
+ const onError = vi.fn();
131
+ async function* events() {
132
+ try {
133
+ started();
134
+ await waiting;
135
+ if (outcome === "reject") throw new Error("late error");
136
+ if (outcome === "yield") yield { id: "late" };
137
+ } finally {
138
+ finalized();
139
+ }
140
+ }
141
+ const reader = adkEventStream(events(), { onError }).body!.getReader();
142
+ await reader.read();
143
+ await entered;
144
+ const cancelled = reader.cancel();
145
+ release();
146
+ await cancelled;
147
+ expect(finalized).toHaveBeenCalledTimes(1);
148
+ expect(onError).not.toHaveBeenCalled();
149
+ expect(await reader.read()).toEqual({ done: true, value: undefined });
150
+ reader.releaseLock();
151
+ },
152
+ );
153
+
154
+ it("finalizes the source and emits an error frame if event serialization fails", async () => {
155
+ const finalized = vi.fn();
156
+ const onError = vi.fn();
157
+ async function* events() {
158
+ try {
159
+ yield { id: "invalid", customMetadata: { unsupported: 1n } };
160
+ } finally {
161
+ finalized();
162
+ }
163
+ }
164
+ const text = await readSSE(adkEventStream(events(), { onError }));
165
+ expect(text).toContain('"errorCode":"STREAM_ERROR"');
166
+ expect(finalized).toHaveBeenCalledTimes(1);
167
+ expect(onError).toHaveBeenCalledTimes(1);
168
+ });
169
+
23
170
  it("returns a Response with text/event-stream content-type", () => {
24
171
  const response = adkEventStream(yieldEvents([]));
25
172
  expect(response.headers.get("Content-Type")).toBe("text/event-stream");
@@ -133,31 +133,38 @@ export const adkEventStream = (
133
133
  const encoder = new TextEncoder();
134
134
  let cancelled = false;
135
135
  const stream = new ReadableStream({
136
- async start(controller) {
136
+ start(controller) {
137
137
  // Initial SSE comment to keep connection alive through proxies
138
138
  controller.enqueue(encoder.encode(":ok\n\n"));
139
+ },
140
+ async pull(controller) {
139
141
  try {
140
- for await (const event of events) {
141
- if (cancelled) break;
142
- const wireEvent = convertSdkEvent(event);
143
- controller.enqueue(
144
- encoder.encode(`data: ${JSON.stringify(wireEvent)}\n\n`),
145
- );
142
+ const { done, value } = await events.next();
143
+ if (cancelled) return;
144
+ if (done) {
145
+ controller.close();
146
+ return;
146
147
  }
148
+ const wireEvent = convertSdkEvent(value);
149
+ controller.enqueue(
150
+ encoder.encode(`data: ${JSON.stringify(wireEvent)}\n\n`),
151
+ );
147
152
  } catch (e) {
148
- if (!cancelled) {
149
- notifyError(options?.onError, e);
150
- const errorEvent: AdkEvent = {
151
- id: "",
152
- errorCode: "STREAM_ERROR",
153
- errorMessage:
154
- e instanceof Error ? e.message : "Unknown stream error",
155
- };
156
- controller.enqueue(
157
- encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`),
158
- );
159
- }
160
- } finally {
153
+ if (cancelled) return;
154
+ try {
155
+ await events.return?.(undefined as any);
156
+ } catch {}
157
+ if (cancelled) return;
158
+ notifyError(options?.onError, e);
159
+ if (cancelled) return;
160
+ const errorEvent: AdkEvent = {
161
+ id: "",
162
+ errorCode: "STREAM_ERROR",
163
+ errorMessage: e instanceof Error ? e.message : "Unknown stream error",
164
+ };
165
+ controller.enqueue(
166
+ encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`),
167
+ );
161
168
  controller.close();
162
169
  }
163
170
  },
@@ -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;