@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.
- package/dist/AdkClient.d.ts +26 -29
- 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 +41 -45
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +63 -99
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts +41 -39
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +11 -7
- 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 +2 -5
- package/dist/adkExtras.d.ts.map +1 -1
- package/dist/adkToolApproval.d.ts +18 -21
- package/dist/adkToolApproval.d.ts.map +1 -1
- package/dist/adkToolApproval.js +1 -3
- package/dist/adkToolApproval.js.map +1 -1
- package/dist/contentToParts.d.ts +2 -5
- package/dist/contentToParts.d.ts.map +1 -1
- package/dist/convertAdkMessages.d.ts +5 -8
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +4 -3
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +36 -39
- package/dist/convertToAdkMessages.d.ts.map +1 -1
- package/dist/hooks.d.ts +25 -23
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +12 -10
- 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 +2 -0
- package/dist/normalizeAdkPart.d.ts.map +1 -0
- package/dist/normalizeAdkPart.js +31 -0
- package/dist/normalizeAdkPart.js.map +1 -0
- package/dist/parseAdkEvent.d.ts +2 -5
- 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 +2 -5
- package/dist/sdkIdentity.d.ts.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/server/adkEventStream.d.ts +25 -27
- 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 +23 -25
- 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 +18 -20
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/structuredEvents.d.ts +2 -5
- package/dist/structuredEvents.d.ts.map +1 -1
- package/dist/structuredEvents.js +1 -1
- package/dist/structuredEvents.js.map +1 -1
- package/dist/toAdkFunctionResponse.d.ts +2 -5
- package/dist/toAdkFunctionResponse.d.ts.map +1 -1
- package/dist/trimTrailingSlashes.d.ts +1 -4
- package/dist/trimTrailingSlashes.d.ts.map +1 -1
- package/dist/types.d.ts +253 -256
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/useAdkMessages.d.ts +26 -29
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +2 -0
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts +50 -52
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +1 -1
- package/package.json +10 -9
- package/src/AdkClient.test.ts +50 -0
- package/src/AdkClient.ts +10 -3
- package/src/AdkEventAccumulator.test.ts +312 -11
- package/src/AdkEventAccumulator.ts +68 -142
- package/src/AdkSessionAdapter.test.ts +284 -1
- package/src/AdkSessionAdapter.ts +32 -8
- package/src/adkAuthRequest.test.ts +101 -0
- package/src/adkAuthRequest.ts +50 -0
- package/src/adkToolApproval.test.ts +0 -4
- package/src/adkToolApproval.ts +1 -3
- package/src/convertAdkMessages.test.ts +41 -0
- package/src/convertAdkMessages.ts +19 -7
- package/src/hooks.render.test.tsx +95 -2
- package/src/hooks.ts +12 -13
- package/src/normalizeAdkPart.ts +35 -0
- 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/structuredEvents.test.ts +69 -0
- package/src/structuredEvents.ts +4 -1
- package/src/types.ts +1 -1
- package/src/useAdkMessages.test.ts +102 -3
- package/src/useAdkMessages.ts +2 -0
- 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
|
|
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",
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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,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
|
-
|
|
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,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
|
+
});
|
package/src/structuredEvents.ts
CHANGED
|
@@ -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: {
|
|
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);
|