@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.
- package/LICENSE +1 -1
- package/dist/AdkClient.d.ts +24 -26
- package/dist/AdkClient.d.ts.map +1 -1
- package/dist/AdkClient.js +5 -3
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts +39 -41
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.d.ts +38 -39
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +4 -3
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/adkAuthRequest.d.ts +17 -0
- package/dist/adkAuthRequest.d.ts.map +1 -0
- package/dist/adkAuthRequest.js +45 -0
- package/dist/adkAuthRequest.js.map +1 -0
- package/dist/adkExtras.d.ts +1 -3
- package/dist/adkExtras.d.ts.map +1 -1
- package/dist/adkToolApproval.d.ts +12 -14
- package/dist/adkToolApproval.d.ts.map +1 -1
- package/dist/contentToParts.d.ts +1 -3
- package/dist/contentToParts.d.ts.map +1 -1
- package/dist/contentToParts.js +1 -0
- package/dist/contentToParts.js.map +1 -1
- package/dist/convertAdkMessages.d.ts +3 -5
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +8 -3
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +32 -34
- package/dist/convertToAdkMessages.d.ts.map +1 -1
- package/dist/hooks.d.ts +10 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +11 -9
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +11 -10
- package/dist/index.d.ts.map +1 -0
- package/dist/normalizeAdkPart.d.ts +0 -2
- package/dist/normalizeAdkPart.d.ts.map +1 -1
- package/dist/parseAdkEvent.d.ts +1 -3
- package/dist/parseAdkEvent.d.ts.map +1 -1
- package/dist/raceWithAbortSignal.d.ts +2 -0
- package/dist/raceWithAbortSignal.d.ts.map +1 -0
- package/dist/raceWithAbortSignal.js +45 -0
- package/dist/raceWithAbortSignal.js.map +1 -0
- package/dist/sdkIdentity.d.ts +1 -3
- package/dist/sdkIdentity.d.ts.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/server/adkEventStream.d.ts +23 -24
- package/dist/server/adkEventStream.d.ts.map +1 -1
- package/dist/server/adkEventStream.js +23 -15
- package/dist/server/adkEventStream.js.map +1 -1
- package/dist/server/createAdkApiRoute.d.ts +21 -22
- package/dist/server/createAdkApiRoute.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/parseAdkRequest.d.ts +16 -17
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/structuredEvents.d.ts +1 -3
- package/dist/structuredEvents.d.ts.map +1 -1
- package/dist/toAdkFunctionResponse.d.ts +0 -2
- package/dist/toAdkFunctionResponse.d.ts.map +1 -1
- package/dist/trimTrailingSlashes.d.ts +0 -2
- package/dist/trimTrailingSlashes.d.ts.map +1 -1
- package/dist/types.d.ts +232 -234
- package/dist/types.d.ts.map +1 -1
- package/dist/useAdkMessages.d.ts +25 -27
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +6 -5
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts +48 -49
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/package.json +8 -5
- package/src/AdkClient.test.ts +50 -0
- package/src/AdkClient.ts +10 -3
- package/src/AdkSessionAdapter.test.ts +17 -0
- package/src/AdkSessionAdapter.ts +8 -3
- package/src/adkAuthRequest.test.ts +101 -0
- package/src/adkAuthRequest.ts +50 -0
- package/src/contentToParts.ts +2 -0
- package/src/convertAdkMessages.test.ts +54 -0
- package/src/convertAdkMessages.ts +24 -7
- package/src/convertToAdkMessages.test.ts +6 -0
- package/src/hooks.render.test.tsx +95 -2
- package/src/hooks.ts +12 -13
- package/src/raceWithAbortSignal.test.ts +73 -0
- package/src/raceWithAbortSignal.ts +48 -0
- package/src/server/adkEventStream.test.ts +147 -0
- package/src/server/adkEventStream.ts +27 -20
- package/src/tests/settleOutsideAct.ts +15 -0
- package/src/useAdkMessages.fast-refresh.test.tsx +114 -0
- package/src/useAdkMessages.test.ts +35 -0
- package/src/useAdkMessages.ts +5 -5
- package/src/useAdkRuntime.refetch.test.tsx +7 -6
- package/src/useAdkRuntime.replacement.test.tsx +4 -3
- 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
|
|
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 {
|
|
40
|
+
return {
|
|
41
|
+
type: "text",
|
|
42
|
+
text: typeof part.text === "string" ? part.text : "",
|
|
43
|
+
};
|
|
36
44
|
case "reasoning":
|
|
37
|
-
return {
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// reply to the confirmation itself is dropped: the gated call
|
|
138
|
-
// the same approval, and its own result is the agent's real
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
controller.
|
|
144
|
-
|
|
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 (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
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 = () => {};
|