@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.
@@ -1,7 +1,18 @@
1
- import { useState, useCallback, useEffect, useRef, useMemo } from "react";
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 { invokeUserCallback } from "@assistant-ui/core/internal";
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
- stateDeltaRef.current = stateDelta;
78
+ useInsertionEffect(() => {
79
+ stateDeltaRef.current = stateDelta;
80
+ }, [stateDelta]);
67
81
  const artifactDeltaRef = useRef(artifactDelta);
68
- artifactDeltaRef.current = artifactDelta;
82
+ useInsertionEffect(() => {
83
+ artifactDeltaRef.current = artifactDelta;
84
+ }, [artifactDelta]);
69
85
  const messageMetadataRef = useRef(messageMetadata);
70
- messageMetadataRef.current = messageMetadata;
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 stream(newMessagesWithId, {
146
- ...config,
147
- abortSignal: abortController.signal,
148
- initialize: async () => {
149
- return await aui.threadListItem.initialize();
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 response) {
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
+ });
@@ -1,4 +1,11 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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
- loadRef.current = load;
146
+ useInsertionEffect(() => {
147
+ loadRef.current = load;
148
+ }, [load]);
139
149
  const loadController = useMemo(createAbortableThreadLoad, []);
140
150
  const messagesRef = useRef(messages);
141
- messagesRef.current = messages;
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
- isRunningRef.current = effectiveIsRunning;
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
- threadMessagesRef.current = threadMessages;
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
- adkMessagesRef.current = messages;
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
- [toAdkToolConfirmationReply(options, toolApprovalsRef.current)],
475
+ [
476
+ toAdkToolConfirmationReply(
477
+ options,
478
+ projectAdkToolApprovals(adkMessagesRef.current).approvals,
479
+ ),
480
+ ],
435
481
  {},
436
482
  );
437
483
  },