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

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 (94) hide show
  1. package/LICENSE +1 -1
  2. package/dist/AdkClient.d.ts +24 -26
  3. package/dist/AdkClient.d.ts.map +1 -1
  4. package/dist/AdkClient.js +5 -3
  5. package/dist/AdkClient.js.map +1 -1
  6. package/dist/AdkEventAccumulator.d.ts +39 -41
  7. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  8. package/dist/AdkSessionAdapter.d.ts +38 -39
  9. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  10. package/dist/AdkSessionAdapter.js +4 -3
  11. package/dist/AdkSessionAdapter.js.map +1 -1
  12. package/dist/adkAuthRequest.d.ts +17 -0
  13. package/dist/adkAuthRequest.d.ts.map +1 -0
  14. package/dist/adkAuthRequest.js +45 -0
  15. package/dist/adkAuthRequest.js.map +1 -0
  16. package/dist/adkExtras.d.ts +1 -3
  17. package/dist/adkExtras.d.ts.map +1 -1
  18. package/dist/adkToolApproval.d.ts +12 -14
  19. package/dist/adkToolApproval.d.ts.map +1 -1
  20. package/dist/contentToParts.d.ts +1 -3
  21. package/dist/contentToParts.d.ts.map +1 -1
  22. package/dist/contentToParts.js +1 -0
  23. package/dist/contentToParts.js.map +1 -1
  24. package/dist/convertAdkMessages.d.ts +3 -5
  25. package/dist/convertAdkMessages.d.ts.map +1 -1
  26. package/dist/convertAdkMessages.js +8 -3
  27. package/dist/convertAdkMessages.js.map +1 -1
  28. package/dist/convertToAdkMessages.d.ts +32 -34
  29. package/dist/convertToAdkMessages.d.ts.map +1 -1
  30. package/dist/hooks.d.ts +10 -7
  31. package/dist/hooks.d.ts.map +1 -1
  32. package/dist/hooks.js +11 -9
  33. package/dist/hooks.js.map +1 -1
  34. package/dist/index.d.ts +11 -10
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/normalizeAdkPart.d.ts +0 -2
  37. package/dist/normalizeAdkPart.d.ts.map +1 -1
  38. package/dist/parseAdkEvent.d.ts +1 -3
  39. package/dist/parseAdkEvent.d.ts.map +1 -1
  40. package/dist/raceWithAbortSignal.d.ts +2 -0
  41. package/dist/raceWithAbortSignal.d.ts.map +1 -0
  42. package/dist/raceWithAbortSignal.js +45 -0
  43. package/dist/raceWithAbortSignal.js.map +1 -0
  44. package/dist/sdkIdentity.d.ts +1 -3
  45. package/dist/sdkIdentity.d.ts.map +1 -1
  46. package/dist/sdkIdentity.js +1 -1
  47. package/dist/server/adkEventStream.d.ts +23 -24
  48. package/dist/server/adkEventStream.d.ts.map +1 -1
  49. package/dist/server/adkEventStream.js +23 -15
  50. package/dist/server/adkEventStream.js.map +1 -1
  51. package/dist/server/createAdkApiRoute.d.ts +21 -22
  52. package/dist/server/createAdkApiRoute.d.ts.map +1 -1
  53. package/dist/server/index.d.ts +4 -4
  54. package/dist/server/index.d.ts.map +1 -0
  55. package/dist/server/parseAdkRequest.d.ts +16 -17
  56. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  57. package/dist/structuredEvents.d.ts +1 -3
  58. package/dist/structuredEvents.d.ts.map +1 -1
  59. package/dist/toAdkFunctionResponse.d.ts +0 -2
  60. package/dist/toAdkFunctionResponse.d.ts.map +1 -1
  61. package/dist/trimTrailingSlashes.d.ts +0 -2
  62. package/dist/trimTrailingSlashes.d.ts.map +1 -1
  63. package/dist/types.d.ts +232 -234
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/useAdkMessages.d.ts +25 -27
  66. package/dist/useAdkMessages.d.ts.map +1 -1
  67. package/dist/useAdkMessages.js +6 -5
  68. package/dist/useAdkMessages.js.map +1 -1
  69. package/dist/useAdkRuntime.d.ts +48 -49
  70. package/dist/useAdkRuntime.d.ts.map +1 -1
  71. package/package.json +8 -5
  72. package/src/AdkClient.test.ts +50 -0
  73. package/src/AdkClient.ts +10 -3
  74. package/src/AdkSessionAdapter.test.ts +17 -0
  75. package/src/AdkSessionAdapter.ts +8 -3
  76. package/src/adkAuthRequest.test.ts +101 -0
  77. package/src/adkAuthRequest.ts +50 -0
  78. package/src/contentToParts.ts +2 -0
  79. package/src/convertAdkMessages.test.ts +54 -0
  80. package/src/convertAdkMessages.ts +24 -7
  81. package/src/convertToAdkMessages.test.ts +6 -0
  82. package/src/hooks.render.test.tsx +95 -2
  83. package/src/hooks.ts +12 -13
  84. package/src/raceWithAbortSignal.test.ts +73 -0
  85. package/src/raceWithAbortSignal.ts +48 -0
  86. package/src/server/adkEventStream.test.ts +147 -0
  87. package/src/server/adkEventStream.ts +27 -20
  88. package/src/tests/settleOutsideAct.ts +15 -0
  89. package/src/useAdkMessages.fast-refresh.test.tsx +114 -0
  90. package/src/useAdkMessages.test.ts +35 -0
  91. package/src/useAdkMessages.ts +5 -5
  92. package/src/useAdkRuntime.refetch.test.tsx +7 -6
  93. package/src/useAdkRuntime.replacement.test.tsx +4 -3
  94. package/src/useAdkRuntime.toolOwnership.test.tsx +4 -3
@@ -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",
@@ -26,6 +40,19 @@ describe("convertAdkMessage - human messages", () => {
26
40
  });
27
41
  });
28
42
 
43
+ it("preserves activity messages as visible text", () => {
44
+ const msg: AdkMessage = {
45
+ id: "m1",
46
+ type: "human",
47
+ content: [{ type: "activity", message: "Working on it" }],
48
+ };
49
+
50
+ expect(convertAdkMessage(msg, {})).toMatchObject({
51
+ role: "user",
52
+ content: [{ type: "text", text: "Working on it" }],
53
+ });
54
+ });
55
+
29
56
  it("restores a file_url part as a file part stamped with sourceType url", () => {
30
57
  const msg: AdkMessage = {
31
58
  id: "m1",
@@ -128,6 +155,33 @@ describe("convertAdkMessage - human messages", () => {
128
155
  });
129
156
 
130
157
  describe("convertAdkMessage - ai messages", () => {
158
+ it("coerces missing text on text and reasoning parts to an empty string", () => {
159
+ const msg = {
160
+ id: "m1",
161
+ type: "ai",
162
+ content: [{ type: "text" }, { type: "reasoning" }],
163
+ } as AdkMessage;
164
+ expect(convertAdkMessage(msg, {})).toMatchObject({
165
+ role: "assistant",
166
+ content: [
167
+ { type: "text", text: "" },
168
+ { type: "reasoning", text: "" },
169
+ ],
170
+ });
171
+ });
172
+
173
+ it("skips non-object entries in content arrays", () => {
174
+ const msg = {
175
+ id: "m1",
176
+ type: "ai",
177
+ content: [null, "Hello", { type: "text", text: "Hi" }],
178
+ } as unknown as AdkMessage;
179
+ expect(convertAdkMessage(msg, {})).toMatchObject({
180
+ role: "assistant",
181
+ content: [{ type: "text", text: "Hi" }],
182
+ });
183
+ });
184
+
131
185
  it("converts an ai message with text content", () => {
132
186
  const msg: AdkMessage = {
133
187
  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",
@@ -78,6 +89,11 @@ const contentToParts = (
78
89
  name: "code_execution_result",
79
90
  data: { output: part.output, outcome: part.outcome },
80
91
  };
92
+ case "activity":
93
+ return {
94
+ type: "text",
95
+ text: typeof part.message === "string" ? part.message : "",
96
+ };
81
97
  default:
82
98
  return null;
83
99
  }
@@ -132,10 +148,11 @@ export const createAdkMessageConverter =
132
148
 
133
149
  case "tool": {
134
150
  // 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.
151
+ // The reply is not the agent's output, so it is dropped rather than
152
+ // shown as the call's result while the gate waits to be answered again.
153
+ // Only a reply to the confirmation itself is dropped: the gated call
154
+ // carries the same approval, and its own result is the agent's real
155
+ // output.
139
156
  const approval = approvals.get(message.tool_call_id);
140
157
  if (
141
158
  message.name === ADK_REQUEST_CONFIRMATION &&
@@ -156,6 +156,12 @@ describe("getPendingCancellations", () => {
156
156
  });
157
157
 
158
158
  describe("getMessageContent", () => {
159
+ it("sends activity messages as text parts", () => {
160
+ expect(
161
+ contentToParts([{ type: "activity", message: "Working on it" }]),
162
+ ).toEqual([{ text: "Working on it" }]);
163
+ });
164
+
159
165
  it("serializes data URL images as inline data", () => {
160
166
  const content = getMessageContent(
161
167
  makeAppendMessage([
@@ -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,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,15 @@
1
+ import { act, waitFor } from "@testing-library/react";
2
+
3
+ // React 18's act holds renders until its callback settles, so a task that waits on a render starts inside act and settles outside it, where React can commit what the task waits on.
4
+ export async function settleOutsideAct<T>(task: () => Promise<T>): Promise<T> {
5
+ let pending!: Promise<T>;
6
+ await act(async () => {
7
+ pending = task();
8
+ pending.catch(() => {});
9
+ });
10
+ await waitFor(() => pending.then(settled, settled));
11
+ await act(async () => {});
12
+ return pending;
13
+ }
14
+
15
+ const settled = () => {};