@assistant-ui/react-google-adk 0.0.27 → 0.0.29
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/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +2 -2
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/convertToAdkMessages.js +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +6 -8
- package/dist/hooks.js.map +1 -1
- package/dist/parseAdkEvent.d.ts.map +1 -1
- package/dist/parseAdkEvent.js +15 -2
- package/dist/parseAdkEvent.js.map +1 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +18 -10
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +27 -9
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/AdkClient.test.ts +53 -0
- package/src/AdkEventAccumulator.test.ts +21 -0
- package/src/AdkEventAccumulator.ts +3 -2
- package/src/hooks.render.test.tsx +74 -0
- package/src/hooks.test.tsx +33 -0
- package/src/hooks.ts +17 -22
- package/src/parseAdkEvent.ts +36 -7
- package/src/useAdkMessages.test.ts +99 -0
- package/src/useAdkMessages.ts +48 -14
- package/src/useAdkRuntime.replacement.test.tsx +138 -0
- package/src/useAdkRuntime.toolOwnership.test.tsx +236 -0
- package/src/useAdkRuntime.ts +54 -8
package/src/useAdkMessages.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useInsertionEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from "react";
|
|
2
9
|
import { generateId } from "@assistant-ui/core";
|
|
3
10
|
import { useAui } from "@assistant-ui/store";
|
|
4
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
abortableIterable,
|
|
13
|
+
invokeUserCallback,
|
|
14
|
+
openAbortableIterable,
|
|
15
|
+
} from "@assistant-ui/core/internal";
|
|
5
16
|
import { AdkEventAccumulator } from "./AdkEventAccumulator";
|
|
6
17
|
import { contentToParts } from "./contentToParts";
|
|
7
18
|
import type {
|
|
@@ -60,14 +71,21 @@ export const useAdkMessages = ({
|
|
|
60
71
|
Map<string, AdkMessageMetadata>
|
|
61
72
|
>(new Map());
|
|
62
73
|
const lastTransferToAgentRef = useRef<string | undefined>(undefined);
|
|
74
|
+
// setMessagesImmediate is the only writer of the messages state and publishes
|
|
75
|
+
// this ref with it, so the ref never trails a commit.
|
|
63
76
|
const messagesRef = useRef(messages);
|
|
64
|
-
messagesRef.current = messages;
|
|
65
77
|
const stateDeltaRef = useRef(stateDelta);
|
|
66
|
-
|
|
78
|
+
useInsertionEffect(() => {
|
|
79
|
+
stateDeltaRef.current = stateDelta;
|
|
80
|
+
}, [stateDelta]);
|
|
67
81
|
const artifactDeltaRef = useRef(artifactDelta);
|
|
68
|
-
|
|
82
|
+
useInsertionEffect(() => {
|
|
83
|
+
artifactDeltaRef.current = artifactDelta;
|
|
84
|
+
}, [artifactDelta]);
|
|
69
85
|
const messageMetadataRef = useRef(messageMetadata);
|
|
70
|
-
|
|
86
|
+
useInsertionEffect(() => {
|
|
87
|
+
messageMetadataRef.current = messageMetadata;
|
|
88
|
+
}, [messageMetadata]);
|
|
71
89
|
|
|
72
90
|
const setMessagesImmediate = useCallback((msgs: AdkMessage[]) => {
|
|
73
91
|
messagesRef.current = msgs;
|
|
@@ -138,19 +156,34 @@ export const useAdkMessages = ({
|
|
|
138
156
|
}
|
|
139
157
|
setMessagesImmediate(accumulator.getMessages());
|
|
140
158
|
|
|
159
|
+
// Google ADK replaces active runs, while React LangGraph queues sends.
|
|
160
|
+
abortControllerRef.current?.abort();
|
|
141
161
|
const abortController = new AbortController();
|
|
142
162
|
abortControllerRef.current = abortController;
|
|
143
163
|
|
|
144
164
|
try {
|
|
145
|
-
const response = await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
165
|
+
const response = await openAbortableIterable(
|
|
166
|
+
stream(newMessagesWithId, {
|
|
167
|
+
...config,
|
|
168
|
+
abortSignal: abortController.signal,
|
|
169
|
+
initialize: async () => {
|
|
170
|
+
return await aui.threadListItem.initialize();
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
abortController.signal,
|
|
174
|
+
);
|
|
175
|
+
if (!response) return;
|
|
152
176
|
|
|
153
|
-
for await (const event of
|
|
177
|
+
for await (const event of abortableIterable(
|
|
178
|
+
response,
|
|
179
|
+
abortController.signal,
|
|
180
|
+
)) {
|
|
181
|
+
if (
|
|
182
|
+
abortController.signal.aborted ||
|
|
183
|
+
abortControllerRef.current !== abortController
|
|
184
|
+
) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
154
187
|
const updatedMessages = accumulator.processEvent(event);
|
|
155
188
|
setMessagesImmediate(updatedMessages);
|
|
156
189
|
setStateDelta({
|
|
@@ -208,6 +241,7 @@ export const useAdkMessages = ({
|
|
|
208
241
|
} catch (error) {
|
|
209
242
|
if (
|
|
210
243
|
!abortController.signal.aborted &&
|
|
244
|
+
abortControllerRef.current === abortController &&
|
|
211
245
|
!(error instanceof Error && error.name === "AbortError")
|
|
212
246
|
) {
|
|
213
247
|
throw error;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { type FC } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
7
|
+
import type {
|
|
8
|
+
AssistantRuntime,
|
|
9
|
+
RemoteThreadListAdapter,
|
|
10
|
+
} from "@assistant-ui/core";
|
|
11
|
+
import { useAdkRuntime } from "./useAdkRuntime";
|
|
12
|
+
import type { AdkEvent } from "./types";
|
|
13
|
+
|
|
14
|
+
const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
|
|
15
|
+
list: vi.fn(async () => ({
|
|
16
|
+
threads: [
|
|
17
|
+
{
|
|
18
|
+
status: "regular" as const,
|
|
19
|
+
remoteId: "adk-1",
|
|
20
|
+
externalId: "adk-1",
|
|
21
|
+
title: "ADK session",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
})),
|
|
25
|
+
initialize: vi.fn(async () => ({
|
|
26
|
+
remoteId: "adk-1",
|
|
27
|
+
externalId: "adk-1",
|
|
28
|
+
})),
|
|
29
|
+
rename: vi.fn(async () => {}),
|
|
30
|
+
archive: vi.fn(async () => {}),
|
|
31
|
+
unarchive: vi.fn(async () => {}),
|
|
32
|
+
delete: vi.fn(async () => {}),
|
|
33
|
+
generateTitle: vi.fn(async () => new ReadableStream() as never),
|
|
34
|
+
fetch: vi.fn(async () => ({
|
|
35
|
+
status: "regular" as const,
|
|
36
|
+
remoteId: "adk-1",
|
|
37
|
+
externalId: "adk-1",
|
|
38
|
+
title: "ADK session",
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const deferred = () => {
|
|
43
|
+
let resolve!: () => void;
|
|
44
|
+
const promise = new Promise<void>((r) => {
|
|
45
|
+
resolve = r;
|
|
46
|
+
});
|
|
47
|
+
return { promise, resolve };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("useAdkRuntime replacement runs", () => {
|
|
51
|
+
it.each([
|
|
52
|
+
{ label: "events after cancellation", cancelFirst: true, failFirst: false },
|
|
53
|
+
{
|
|
54
|
+
label: "events without cancellation",
|
|
55
|
+
cancelFirst: false,
|
|
56
|
+
failFirst: false,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: "errors without cancellation",
|
|
60
|
+
cancelFirst: false,
|
|
61
|
+
failFirst: true,
|
|
62
|
+
},
|
|
63
|
+
])("ignores superseded run $label", async ({ cancelFirst, failFirst }) => {
|
|
64
|
+
const gates = [deferred(), deferred()];
|
|
65
|
+
let calls = 0;
|
|
66
|
+
const stream = vi.fn(async function* (): AsyncGenerator<AdkEvent> {
|
|
67
|
+
const call = calls++;
|
|
68
|
+
await gates[call]!.promise;
|
|
69
|
+
if (call === 0 && failFirst) throw new Error("stale run failed");
|
|
70
|
+
yield {
|
|
71
|
+
id: `event-${call}`,
|
|
72
|
+
invocationId: `run-${call}`,
|
|
73
|
+
author: "agent",
|
|
74
|
+
content: { role: "model", parts: [{ text: `done-${call}` }] },
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
const sessionAdapter = makeThreadListAdapter();
|
|
78
|
+
const capture: { runtime: AssistantRuntime | null } = { runtime: null };
|
|
79
|
+
|
|
80
|
+
const Inner: FC = () => {
|
|
81
|
+
const runtime = useAdkRuntime({
|
|
82
|
+
stream,
|
|
83
|
+
sessionAdapter,
|
|
84
|
+
unstable_allowCancellation: true,
|
|
85
|
+
});
|
|
86
|
+
capture.runtime = runtime;
|
|
87
|
+
return <AssistantRuntimeProvider runtime={runtime} />;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await act(async () => {
|
|
91
|
+
render(<Inner />);
|
|
92
|
+
});
|
|
93
|
+
await waitFor(() => expect(capture.runtime).not.toBeNull());
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await capture.runtime!.threads.switchToThread("adk-1");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let firstSend!: Promise<void>;
|
|
99
|
+
act(() => {
|
|
100
|
+
firstSend = capture.runtime!.thread.append({
|
|
101
|
+
role: "user",
|
|
102
|
+
content: [{ type: "text", text: "first" }],
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
await waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
|
|
106
|
+
|
|
107
|
+
let secondSend!: Promise<void>;
|
|
108
|
+
await act(async () => {
|
|
109
|
+
if (cancelFirst) await capture.runtime!.thread.cancelRun();
|
|
110
|
+
secondSend = capture.runtime!.thread.append({
|
|
111
|
+
role: "user",
|
|
112
|
+
content: [{ type: "text", text: "second" }],
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
await waitFor(() => expect(stream).toHaveBeenCalledTimes(2));
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
gates[0]!.resolve();
|
|
119
|
+
await firstSend;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const messagesAfterFirstSettles = JSON.stringify(
|
|
123
|
+
capture.runtime!.thread.getState().messages,
|
|
124
|
+
);
|
|
125
|
+
expect(messagesAfterFirstSettles).toContain("second");
|
|
126
|
+
expect(messagesAfterFirstSettles).not.toContain("done-0");
|
|
127
|
+
expect(capture.runtime!.thread.getState().isRunning).toBe(true);
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
gates[1]!.resolve();
|
|
131
|
+
await secondSend;
|
|
132
|
+
});
|
|
133
|
+
expect(
|
|
134
|
+
JSON.stringify(capture.runtime!.thread.getState().messages),
|
|
135
|
+
).toContain("done-1");
|
|
136
|
+
expect(capture.runtime!.thread.getState().isRunning).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { type FC } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
AssistantRuntimeProvider,
|
|
8
|
+
useAssistantTool,
|
|
9
|
+
} from "@assistant-ui/core/react";
|
|
10
|
+
import type {
|
|
11
|
+
AssistantRuntime,
|
|
12
|
+
RemoteThreadListAdapter,
|
|
13
|
+
ToolCallMessagePart,
|
|
14
|
+
} from "@assistant-ui/core";
|
|
15
|
+
import { useAdkRuntime } from "./useAdkRuntime";
|
|
16
|
+
import type { AdkEvent } from "./types";
|
|
17
|
+
|
|
18
|
+
const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
|
|
19
|
+
list: vi.fn(async () => ({
|
|
20
|
+
threads: [
|
|
21
|
+
{
|
|
22
|
+
status: "regular" as const,
|
|
23
|
+
remoteId: "adk-1",
|
|
24
|
+
externalId: "adk-1",
|
|
25
|
+
title: "Existing ADK session",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
})),
|
|
29
|
+
initialize: vi.fn(async () => ({ remoteId: "adk-1", externalId: "adk-1" })),
|
|
30
|
+
rename: vi.fn(async () => {}),
|
|
31
|
+
archive: vi.fn(async () => {}),
|
|
32
|
+
unarchive: vi.fn(async () => {}),
|
|
33
|
+
delete: vi.fn(async () => {}),
|
|
34
|
+
generateTitle: vi.fn(async () => new ReadableStream() as never),
|
|
35
|
+
fetch: vi.fn(async () => ({
|
|
36
|
+
status: "regular" as const,
|
|
37
|
+
remoteId: "adk-1",
|
|
38
|
+
externalId: "adk-1",
|
|
39
|
+
title: "Existing ADK session",
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const toolCalls = (runtime: AssistantRuntime): ToolCallMessagePart[] =>
|
|
44
|
+
runtime.thread
|
|
45
|
+
.getState()
|
|
46
|
+
.messages.flatMap((message) => message.content as readonly unknown[])
|
|
47
|
+
.filter(
|
|
48
|
+
(part): part is ToolCallMessagePart =>
|
|
49
|
+
(part as ToolCallMessagePart).type === "tool-call",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const GATED_CALL = {
|
|
53
|
+
id: "adk-original-1",
|
|
54
|
+
name: "delete_file",
|
|
55
|
+
args: { path: "/tmp/a" },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const callEvent = (longRunningToolIds?: string[]): AdkEvent => ({
|
|
59
|
+
id: "ev-1",
|
|
60
|
+
invocationId: "inv-1",
|
|
61
|
+
author: "agent",
|
|
62
|
+
content: { role: "model", parts: [{ functionCall: GATED_CALL }] },
|
|
63
|
+
...(longRunningToolIds && { longRunningToolIds }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const confirmationEvent = (): AdkEvent => ({
|
|
67
|
+
id: "ev-2",
|
|
68
|
+
invocationId: "inv-1",
|
|
69
|
+
author: "agent",
|
|
70
|
+
content: {
|
|
71
|
+
role: "model",
|
|
72
|
+
parts: [
|
|
73
|
+
{
|
|
74
|
+
functionCall: {
|
|
75
|
+
id: "adk-confirmation-1",
|
|
76
|
+
name: "adk_request_confirmation",
|
|
77
|
+
args: {
|
|
78
|
+
originalFunctionCall: GATED_CALL,
|
|
79
|
+
toolConfirmation: { hint: "Delete /tmp/a?" },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
longRunningToolIds: ["adk-confirmation-1"],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const serverResultEvent = (): AdkEvent => ({
|
|
89
|
+
id: "ev-2",
|
|
90
|
+
invocationId: "inv-1",
|
|
91
|
+
author: "agent",
|
|
92
|
+
content: {
|
|
93
|
+
role: "user",
|
|
94
|
+
parts: [
|
|
95
|
+
{
|
|
96
|
+
functionResponse: {
|
|
97
|
+
id: "adk-original-1",
|
|
98
|
+
name: "delete_file",
|
|
99
|
+
response: { server: "deleted" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Streams `first`, hands control back so the window between the two events is
|
|
108
|
+
* observable, then streams `second` once the returned `release` is called.
|
|
109
|
+
*/
|
|
110
|
+
const renderStreamingAdk = async (first: AdkEvent, second: AdkEvent) => {
|
|
111
|
+
let release!: () => void;
|
|
112
|
+
const held = new Promise<void>((r) => (release = r));
|
|
113
|
+
let firstDelivered!: () => void;
|
|
114
|
+
const firstDeliveredP = new Promise<void>((r) => (firstDelivered = r));
|
|
115
|
+
|
|
116
|
+
const stream = vi.fn(async function* (): AsyncGenerator<AdkEvent> {
|
|
117
|
+
yield first;
|
|
118
|
+
firstDelivered();
|
|
119
|
+
await held;
|
|
120
|
+
yield second;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const execute = vi.fn(async () => ({ deleted: true }));
|
|
124
|
+
const capture: { runtime: AssistantRuntime | null } = { runtime: null };
|
|
125
|
+
|
|
126
|
+
const DeleteFileTool = () => {
|
|
127
|
+
useAssistantTool({
|
|
128
|
+
toolName: "delete_file",
|
|
129
|
+
description: "delete a file",
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object" as const,
|
|
132
|
+
properties: { path: { type: "string" } },
|
|
133
|
+
},
|
|
134
|
+
execute,
|
|
135
|
+
});
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const Inner: FC = () => {
|
|
140
|
+
const runtime = useAdkRuntime({
|
|
141
|
+
stream: stream as never,
|
|
142
|
+
sessionAdapter: makeThreadListAdapter(),
|
|
143
|
+
});
|
|
144
|
+
capture.runtime = runtime;
|
|
145
|
+
return (
|
|
146
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
147
|
+
<DeleteFileTool />
|
|
148
|
+
</AssistantRuntimeProvider>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await act(async () => {
|
|
153
|
+
render(<Inner />);
|
|
154
|
+
});
|
|
155
|
+
await waitFor(() => expect(capture.runtime).not.toBeNull());
|
|
156
|
+
await act(async () => {
|
|
157
|
+
await capture.runtime!.threads.switchToThread("adk-1");
|
|
158
|
+
});
|
|
159
|
+
await act(async () => {
|
|
160
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
void capture.runtime!.thread.append({
|
|
165
|
+
role: "user",
|
|
166
|
+
content: [{ type: "text", text: "delete it" }],
|
|
167
|
+
});
|
|
168
|
+
await firstDeliveredP;
|
|
169
|
+
});
|
|
170
|
+
await act(async () => {
|
|
171
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const settle = async () => {
|
|
175
|
+
await act(async () => {
|
|
176
|
+
release();
|
|
177
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
178
|
+
});
|
|
179
|
+
await act(async () => {
|
|
180
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return { capture, execute, settle };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
describe("useAdkRuntime tool ownership", () => {
|
|
188
|
+
it("does not run a frontend execute on a call ADK is about to gate", async () => {
|
|
189
|
+
const { capture, execute, settle } = await renderStreamingAdk(
|
|
190
|
+
callEvent(),
|
|
191
|
+
confirmationEvent(),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const inWindow = toolCalls(capture.runtime!)[0]!;
|
|
195
|
+
expect(inWindow.argsText).toBe('{"path":"/tmp/a"}');
|
|
196
|
+
expect(inWindow.approval).toBeUndefined();
|
|
197
|
+
expect(inWindow.result).toBeUndefined();
|
|
198
|
+
expect(execute).not.toHaveBeenCalled();
|
|
199
|
+
|
|
200
|
+
await settle();
|
|
201
|
+
|
|
202
|
+
const gated = toolCalls(capture.runtime!).find(
|
|
203
|
+
(part) => part.toolCallId === "adk-original-1",
|
|
204
|
+
)!;
|
|
205
|
+
expect(gated.approval).toEqual({ id: "adk-confirmation-1" });
|
|
206
|
+
expect(gated.result).toBeUndefined();
|
|
207
|
+
expect(execute).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("does not run a frontend execute on a call ADK resolves itself", async () => {
|
|
211
|
+
const { capture, execute, settle } = await renderStreamingAdk(
|
|
212
|
+
callEvent(),
|
|
213
|
+
serverResultEvent(),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(toolCalls(capture.runtime!)[0]!.result).toBeUndefined();
|
|
217
|
+
expect(execute).not.toHaveBeenCalled();
|
|
218
|
+
|
|
219
|
+
await settle();
|
|
220
|
+
|
|
221
|
+
expect(toolCalls(capture.runtime!)[0]!.result).toBe('{"server":"deleted"}');
|
|
222
|
+
expect(execute).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("runs a frontend execute on a call ADK marks long-running", async () => {
|
|
226
|
+
const { capture, execute } = await renderStreamingAdk(
|
|
227
|
+
callEvent(["adk-original-1"]),
|
|
228
|
+
serverResultEvent(),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
|
|
232
|
+
await waitFor(() =>
|
|
233
|
+
expect(toolCalls(capture.runtime!)[0]!.result).toBeDefined(),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/useAdkRuntime.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useInsertionEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
2
9
|
import {
|
|
3
10
|
pickExternalStoreSharedOptions,
|
|
4
11
|
type AttachmentAdapter,
|
|
@@ -8,6 +15,7 @@ import {
|
|
|
8
15
|
type RealtimeVoiceAdapter,
|
|
9
16
|
type SpeechSynthesisAdapter,
|
|
10
17
|
type AppendMessage,
|
|
18
|
+
type ToolCallMessagePart,
|
|
11
19
|
type ToolExecutionStatus,
|
|
12
20
|
generateId,
|
|
13
21
|
} from "@assistant-ui/core";
|
|
@@ -135,10 +143,14 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
135
143
|
});
|
|
136
144
|
|
|
137
145
|
const loadRef = useRef(load);
|
|
138
|
-
|
|
146
|
+
useInsertionEffect(() => {
|
|
147
|
+
loadRef.current = load;
|
|
148
|
+
}, [load]);
|
|
139
149
|
const loadController = useMemo(createAbortableThreadLoad, []);
|
|
140
150
|
const messagesRef = useRef(messages);
|
|
141
|
-
|
|
151
|
+
useInsertionEffect(() => {
|
|
152
|
+
messagesRef.current = messages;
|
|
153
|
+
}, [messages]);
|
|
142
154
|
const [isLoadingThread, setIsLoadingThread] = useState(
|
|
143
155
|
() =>
|
|
144
156
|
load !== undefined && aui.threadListItem.getState().externalId != null,
|
|
@@ -153,25 +165,46 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
153
165
|
);
|
|
154
166
|
const effectiveIsRunning = isRunning || hasExecutingTools;
|
|
155
167
|
const isRunningRef = useRef(effectiveIsRunning);
|
|
156
|
-
|
|
168
|
+
useInsertionEffect(() => {
|
|
169
|
+
isRunningRef.current = effectiveIsRunning;
|
|
170
|
+
}, [effectiveIsRunning]);
|
|
171
|
+
const runGenerationRef = useRef(0);
|
|
157
172
|
|
|
158
173
|
const handleSendMessage = async (
|
|
159
174
|
msgs: AdkMessage[],
|
|
160
175
|
config: AdkSendMessageConfig,
|
|
161
176
|
) => {
|
|
177
|
+
const generation = ++runGenerationRef.current;
|
|
162
178
|
try {
|
|
163
179
|
setIsRunning(true);
|
|
164
180
|
await sendMessage(msgs, config);
|
|
165
181
|
} finally {
|
|
166
|
-
setIsRunning(false);
|
|
182
|
+
if (runGenerationRef.current === generation) setIsRunning(false);
|
|
167
183
|
}
|
|
168
184
|
};
|
|
169
185
|
|
|
170
186
|
const { approvals: toolApprovals, key: toolApprovalsKey } =
|
|
171
187
|
projectAdkToolApprovals(messages);
|
|
188
|
+
// The messageConverter memo below reads this during render, where the ref
|
|
189
|
+
// must carry the same render's approvals; a commit-scoped write would feed
|
|
190
|
+
// the memo the previous commit's approvals whenever the key changes. No
|
|
191
|
+
// callback reads it — approval replies project from the committed messages.
|
|
172
192
|
const toolApprovalsRef = useRef(toolApprovals);
|
|
173
193
|
toolApprovalsRef.current = toolApprovals;
|
|
174
194
|
|
|
195
|
+
const longRunningToolIdsRef = useRef(longRunningToolIds);
|
|
196
|
+
useInsertionEffect(() => {
|
|
197
|
+
longRunningToolIdsRef.current = longRunningToolIds;
|
|
198
|
+
}, [longRunningToolIds]);
|
|
199
|
+
// ADK resolves every call it did not mark long-running itself, and yields
|
|
200
|
+
// that call to the client one or more events before its own response, so
|
|
201
|
+
// only a long-running call is the client's to execute.
|
|
202
|
+
const isClientToolCall = useCallback(
|
|
203
|
+
(toolCall: ToolCallMessagePart) =>
|
|
204
|
+
longRunningToolIdsRef.current.includes(toolCall.toolCallId),
|
|
205
|
+
[],
|
|
206
|
+
);
|
|
207
|
+
|
|
175
208
|
const messageConverter = useMemo(
|
|
176
209
|
() =>
|
|
177
210
|
toolApprovalsKey === ""
|
|
@@ -187,10 +220,17 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
187
220
|
});
|
|
188
221
|
|
|
189
222
|
const threadMessagesRef = useRef(threadMessages);
|
|
190
|
-
|
|
223
|
+
useInsertionEffect(() => {
|
|
224
|
+
threadMessagesRef.current = threadMessages;
|
|
225
|
+
}, [threadMessages]);
|
|
191
226
|
|
|
227
|
+
// Staging assigns adkMessagesRef.current directly, so the effect must key on
|
|
228
|
+
// the committed messages alone; a dep-less publication would clobber the
|
|
229
|
+
// optimistic value on any unrelated commit.
|
|
192
230
|
const adkMessagesRef = useRef(messages);
|
|
193
|
-
|
|
231
|
+
useInsertionEffect(() => {
|
|
232
|
+
adkMessagesRef.current = messages;
|
|
233
|
+
}, [messages]);
|
|
194
234
|
|
|
195
235
|
const stagedMessagesRef = useRef(
|
|
196
236
|
new Map<
|
|
@@ -300,6 +340,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
300
340
|
isLoading: isLoadingThread,
|
|
301
341
|
messages: threadMessages,
|
|
302
342
|
unstable_enableToolInvocations: true,
|
|
343
|
+
unstable_isClientToolCall: isClientToolCall,
|
|
303
344
|
setToolStatuses,
|
|
304
345
|
adapters: { attachments, dictation, feedback, speech, voice },
|
|
305
346
|
extras: adkExtras.provide({
|
|
@@ -431,7 +472,12 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
431
472
|
},
|
|
432
473
|
onRespondToToolApproval: async (options) => {
|
|
433
474
|
await handleSendMessage(
|
|
434
|
-
[
|
|
475
|
+
[
|
|
476
|
+
toAdkToolConfirmationReply(
|
|
477
|
+
options,
|
|
478
|
+
projectAdkToolApprovals(adkMessagesRef.current).approvals,
|
|
479
|
+
),
|
|
480
|
+
],
|
|
435
481
|
{},
|
|
436
482
|
);
|
|
437
483
|
},
|