@assistant-ui/react-google-adk 0.0.26 → 0.0.28
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 +2 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +21 -9
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/adkToolApproval.js +2 -2
- package/dist/adkToolApproval.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +49 -0
- package/dist/convertToAdkMessages.d.ts.map +1 -0
- package/dist/convertToAdkMessages.js +86 -0
- package/dist/convertToAdkMessages.js.map +1 -0
- package/dist/hooks.js +3 -3
- package/dist/hooks.js.map +1 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +18 -22
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +56 -133
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +7 -8
- package/src/AdkEventAccumulator.test.ts +84 -1
- package/src/AdkEventAccumulator.ts +20 -7
- package/src/adkToolApproval.ts +2 -2
- package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
- package/src/convertToAdkMessages.ts +139 -0
- package/src/hooks.ts +3 -3
- package/src/useAdkMessages.ts +30 -27
- package/src/useAdkRuntime.toolOwnership.test.tsx +236 -0
- package/src/useAdkRuntime.ts +88 -192
|
@@ -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,6 +1,12 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
1
|
import {
|
|
3
|
-
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useInsertionEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
4
10
|
pickExternalStoreSharedOptions,
|
|
5
11
|
type AttachmentAdapter,
|
|
6
12
|
type DictationAdapter,
|
|
@@ -9,11 +15,14 @@ import {
|
|
|
9
15
|
type RealtimeVoiceAdapter,
|
|
10
16
|
type SpeechSynthesisAdapter,
|
|
11
17
|
type AppendMessage,
|
|
12
|
-
type
|
|
18
|
+
type ToolCallMessagePart,
|
|
13
19
|
type ToolExecutionStatus,
|
|
14
20
|
generateId,
|
|
15
21
|
} from "@assistant-ui/core";
|
|
16
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createAbortableThreadLoad,
|
|
24
|
+
createCloudThreadListAdapterCreateFallback,
|
|
25
|
+
} from "@assistant-ui/core/internal";
|
|
17
26
|
import {
|
|
18
27
|
useCloudThreadListAdapter,
|
|
19
28
|
useRemoteThreadListRuntime,
|
|
@@ -37,139 +46,17 @@ import {
|
|
|
37
46
|
convertAdkMessage,
|
|
38
47
|
createAdkMessageConverter,
|
|
39
48
|
} from "./convertAdkMessages";
|
|
49
|
+
import {
|
|
50
|
+
getMessageContent,
|
|
51
|
+
getPendingCancellations,
|
|
52
|
+
toAdkUserMessage,
|
|
53
|
+
truncateAdkMessages,
|
|
54
|
+
} from "./convertToAdkMessages";
|
|
40
55
|
import {
|
|
41
56
|
projectAdkToolApprovals,
|
|
42
57
|
toAdkToolConfirmationReply,
|
|
43
58
|
} from "./adkToolApproval";
|
|
44
59
|
import { adkExtras } from "./adkExtras";
|
|
45
|
-
import { v4 as uuidv4 } from "uuid";
|
|
46
|
-
|
|
47
|
-
/** @internal — exported for unit tests. */
|
|
48
|
-
export const getMessageContent = (msg: AppendMessage) => {
|
|
49
|
-
const allContent = [
|
|
50
|
-
...msg.content,
|
|
51
|
-
...(msg.attachments?.flatMap((a) => a.content) ?? []),
|
|
52
|
-
];
|
|
53
|
-
const content = allContent.flatMap((part) => {
|
|
54
|
-
const type = part.type;
|
|
55
|
-
switch (type) {
|
|
56
|
-
case "text":
|
|
57
|
-
return { type: "text" as const, text: part.text };
|
|
58
|
-
case "image":
|
|
59
|
-
return { type: "image_url" as const, url: part.image };
|
|
60
|
-
case "file":
|
|
61
|
-
if (part.sourceType === "url" || httpUrlPattern.test(part.data)) {
|
|
62
|
-
return {
|
|
63
|
-
type: "file_url" as const,
|
|
64
|
-
url: part.data,
|
|
65
|
-
mimeType: part.mimeType,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
type: "file" as const,
|
|
70
|
-
mimeType: part.mimeType,
|
|
71
|
-
// Lands in Gemini `inlineData.data`, which takes bare base64, so a
|
|
72
|
-
// data URL envelope is stripped rather than forwarded.
|
|
73
|
-
data: parseDataUrl(part.data)?.data ?? part.data,
|
|
74
|
-
...(part.filename != null && { filename: part.filename }),
|
|
75
|
-
};
|
|
76
|
-
case "audio": {
|
|
77
|
-
const parsed = parseDataUrl(part.audio.data);
|
|
78
|
-
return {
|
|
79
|
-
type: "file" as const,
|
|
80
|
-
mimeType: `audio/${part.audio.format}`,
|
|
81
|
-
data: parsed?.data ?? part.audio.data,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
case "data":
|
|
85
|
-
return [];
|
|
86
|
-
|
|
87
|
-
case "tool-call":
|
|
88
|
-
throw new Error("Tool call appends are not supported.");
|
|
89
|
-
|
|
90
|
-
default: {
|
|
91
|
-
const _exhaustiveCheck: "reasoning" | "source" | "generative-ui" = type;
|
|
92
|
-
throw new Error(
|
|
93
|
-
`Unsupported append message part type: ${_exhaustiveCheck}`,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
if (content.length === 1 && content[0]?.type === "text") {
|
|
100
|
-
return content[0].text ?? "";
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return content;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
/** @internal — exported for unit tests. */
|
|
107
|
-
export const getPendingToolCalls = (messages: AdkMessage[]) => {
|
|
108
|
-
const pending = new Map<string, { id: string; name: string }>();
|
|
109
|
-
for (const msg of messages) {
|
|
110
|
-
if (msg.type === "ai" && msg.tool_calls) {
|
|
111
|
-
for (const tc of msg.tool_calls) {
|
|
112
|
-
pending.set(tc.id, tc);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (msg.type === "tool") {
|
|
116
|
-
pending.delete(msg.tool_call_id);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return [...pending.values()];
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @internal — exported for unit tests.
|
|
124
|
-
*
|
|
125
|
-
* Returns `{cancelled: true}` tool responses for pending tool calls when the
|
|
126
|
-
* user sends a new turn, EXCEPT for HITL interrupts marked via
|
|
127
|
-
* `long_running_tool_ids` (`adk_request_input`, `adk_request_confirmation`,
|
|
128
|
-
* `adk_request_credential`). Those must be answered through a dedicated tool
|
|
129
|
-
* UI + submit helper, not auto-cancelled.
|
|
130
|
-
*/
|
|
131
|
-
export const getPendingCancellations = (
|
|
132
|
-
messages: AdkMessage[],
|
|
133
|
-
longRunningToolIds: readonly string[],
|
|
134
|
-
): Array<AdkMessage & { type: "tool" }> => {
|
|
135
|
-
const longRunningSet = new Set(longRunningToolIds);
|
|
136
|
-
return getPendingToolCalls(messages)
|
|
137
|
-
.filter((t) => !longRunningSet.has(t.id))
|
|
138
|
-
.map(
|
|
139
|
-
(t) =>
|
|
140
|
-
({
|
|
141
|
-
id: uuidv4(),
|
|
142
|
-
type: "tool",
|
|
143
|
-
name: t.name,
|
|
144
|
-
tool_call_id: t.id,
|
|
145
|
-
content: JSON.stringify({ cancelled: true }),
|
|
146
|
-
status: "error",
|
|
147
|
-
}) satisfies AdkMessage & { type: "tool" },
|
|
148
|
-
);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const truncateAdkMessages = (
|
|
152
|
-
threadMessages: readonly ThreadMessage[],
|
|
153
|
-
parentId: string | null,
|
|
154
|
-
): AdkMessage[] => {
|
|
155
|
-
if (parentId === null) return [];
|
|
156
|
-
const parentIndex = threadMessages.findIndex((m) => m.id === parentId);
|
|
157
|
-
if (parentIndex === -1) return [];
|
|
158
|
-
const truncated: AdkMessage[] = [];
|
|
159
|
-
for (let i = 0; i <= parentIndex && i < threadMessages.length; i++) {
|
|
160
|
-
truncated.push(...getExternalStoreMessages<AdkMessage>(threadMessages[i]!));
|
|
161
|
-
}
|
|
162
|
-
return truncated;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const toAdkUserMessage = (
|
|
166
|
-
msg: AppendMessage,
|
|
167
|
-
id = generateId(),
|
|
168
|
-
): AdkMessage & { type: "human"; id: string } => ({
|
|
169
|
-
id,
|
|
170
|
-
type: "human",
|
|
171
|
-
content: getMessageContent(msg),
|
|
172
|
-
});
|
|
173
60
|
|
|
174
61
|
export type UseAdkRuntimeOptions = ExternalStoreSharedOptions & {
|
|
175
62
|
stream: AdkStreamCallback;
|
|
@@ -256,14 +143,14 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
256
143
|
});
|
|
257
144
|
|
|
258
145
|
const loadRef = useRef(load);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
promise?: Promise<void> | undefined;
|
|
264
|
-
} | null>(null);
|
|
146
|
+
useInsertionEffect(() => {
|
|
147
|
+
loadRef.current = load;
|
|
148
|
+
}, [load]);
|
|
149
|
+
const loadController = useMemo(createAbortableThreadLoad, []);
|
|
265
150
|
const messagesRef = useRef(messages);
|
|
266
|
-
|
|
151
|
+
useInsertionEffect(() => {
|
|
152
|
+
messagesRef.current = messages;
|
|
153
|
+
}, [messages]);
|
|
267
154
|
const [isLoadingThread, setIsLoadingThread] = useState(
|
|
268
155
|
() =>
|
|
269
156
|
load !== undefined && aui.threadListItem.getState().externalId != null,
|
|
@@ -278,7 +165,9 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
278
165
|
);
|
|
279
166
|
const effectiveIsRunning = isRunning || hasExecutingTools;
|
|
280
167
|
const isRunningRef = useRef(effectiveIsRunning);
|
|
281
|
-
|
|
168
|
+
useInsertionEffect(() => {
|
|
169
|
+
isRunningRef.current = effectiveIsRunning;
|
|
170
|
+
}, [effectiveIsRunning]);
|
|
282
171
|
|
|
283
172
|
const handleSendMessage = async (
|
|
284
173
|
msgs: AdkMessage[],
|
|
@@ -294,9 +183,26 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
294
183
|
|
|
295
184
|
const { approvals: toolApprovals, key: toolApprovalsKey } =
|
|
296
185
|
projectAdkToolApprovals(messages);
|
|
186
|
+
// The messageConverter memo below reads this during render, where the ref
|
|
187
|
+
// must carry the same render's approvals; a commit-scoped write would feed
|
|
188
|
+
// the memo the previous commit's approvals whenever the key changes. No
|
|
189
|
+
// callback reads it — approval replies project from the committed messages.
|
|
297
190
|
const toolApprovalsRef = useRef(toolApprovals);
|
|
298
191
|
toolApprovalsRef.current = toolApprovals;
|
|
299
192
|
|
|
193
|
+
const longRunningToolIdsRef = useRef(longRunningToolIds);
|
|
194
|
+
useInsertionEffect(() => {
|
|
195
|
+
longRunningToolIdsRef.current = longRunningToolIds;
|
|
196
|
+
}, [longRunningToolIds]);
|
|
197
|
+
// ADK resolves every call it did not mark long-running itself, and yields
|
|
198
|
+
// that call to the client one or more events before its own response, so
|
|
199
|
+
// only a long-running call is the client's to execute.
|
|
200
|
+
const isClientToolCall = useCallback(
|
|
201
|
+
(toolCall: ToolCallMessagePart) =>
|
|
202
|
+
longRunningToolIdsRef.current.includes(toolCall.toolCallId),
|
|
203
|
+
[],
|
|
204
|
+
);
|
|
205
|
+
|
|
300
206
|
const messageConverter = useMemo(
|
|
301
207
|
() =>
|
|
302
208
|
toolApprovalsKey === ""
|
|
@@ -312,10 +218,17 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
312
218
|
});
|
|
313
219
|
|
|
314
220
|
const threadMessagesRef = useRef(threadMessages);
|
|
315
|
-
|
|
221
|
+
useInsertionEffect(() => {
|
|
222
|
+
threadMessagesRef.current = threadMessages;
|
|
223
|
+
}, [threadMessages]);
|
|
316
224
|
|
|
225
|
+
// Staging assigns adkMessagesRef.current directly, so the effect must key on
|
|
226
|
+
// the committed messages alone; a dep-less publication would clobber the
|
|
227
|
+
// optimistic value on any unrelated commit.
|
|
317
228
|
const adkMessagesRef = useRef(messages);
|
|
318
|
-
|
|
229
|
+
useInsertionEffect(() => {
|
|
230
|
+
adkMessagesRef.current = messages;
|
|
231
|
+
}, [messages]);
|
|
319
232
|
|
|
320
233
|
const stagedMessagesRef = useRef(
|
|
321
234
|
new Map<
|
|
@@ -373,26 +286,17 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
373
286
|
|
|
374
287
|
// The initial load is already fetching what a refetch would ask for, and
|
|
375
288
|
// taking it over strands the thread's history if the refetch then fails.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return loadControllerRef.current.promise ?? Promise.resolve();
|
|
381
|
-
|
|
382
|
-
loadControllerRef.current?.controller.abort();
|
|
383
|
-
const controller = new AbortController();
|
|
384
|
-
const record: NonNullable<typeof loadControllerRef.current> = {
|
|
385
|
-
controller,
|
|
289
|
+
// Aborting a load the runtime no longer needs is not a failure.
|
|
290
|
+
// A refetch reports the failure to whoever awaited it; the initial load
|
|
291
|
+
// has no caller to tell.
|
|
292
|
+
return loadController.run({
|
|
386
293
|
purpose,
|
|
387
|
-
|
|
388
|
-
|
|
294
|
+
load: async (signal) => {
|
|
295
|
+
const messagesAtLoadStart = messagesRef.current;
|
|
296
|
+
if (purpose === "initial") setIsLoadingThread(true);
|
|
389
297
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const task = loadFn(externalId, { signal: controller.signal })
|
|
394
|
-
.then((snapshot) => {
|
|
395
|
-
if (controller.signal.aborted) return;
|
|
298
|
+
const snapshot = await loadFn(externalId, { signal });
|
|
299
|
+
if (signal.aborted) return;
|
|
396
300
|
// A snapshot the session assembled before a run cannot speak for what
|
|
397
301
|
// that run has since produced, and an ADK id cannot correlate a
|
|
398
302
|
// message sent optimistically with the one the session stored for it,
|
|
@@ -406,29 +310,16 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
406
310
|
)
|
|
407
311
|
return;
|
|
408
312
|
applySnapshot(snapshot);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Aborting a load the runtime no longer needs is not a failure.
|
|
412
|
-
if (controller.signal.aborted) return;
|
|
413
|
-
throw error;
|
|
414
|
-
})
|
|
415
|
-
.finally(() => {
|
|
416
|
-
if (loadControllerRef.current?.controller === controller) {
|
|
417
|
-
loadControllerRef.current = null;
|
|
418
|
-
}
|
|
419
|
-
if (controller.signal.aborted) return;
|
|
313
|
+
},
|
|
314
|
+
onSettled: () => {
|
|
420
315
|
setIsLoadingThread(false);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// has no caller to tell.
|
|
426
|
-
if (purpose === "reload") return task;
|
|
427
|
-
return task.catch((e: unknown) => {
|
|
428
|
-
console.warn("Failed to load ADK session:", e);
|
|
316
|
+
},
|
|
317
|
+
onInitialError: (error) => {
|
|
318
|
+
console.warn("Failed to load ADK session:", error);
|
|
319
|
+
},
|
|
429
320
|
});
|
|
430
321
|
},
|
|
431
|
-
[threadListItem, applySnapshot],
|
|
322
|
+
[threadListItem, loadController, applySnapshot],
|
|
432
323
|
);
|
|
433
324
|
|
|
434
325
|
useEffect(() => {
|
|
@@ -436,17 +327,18 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
436
327
|
return () => {
|
|
437
328
|
// Whatever is current, not this effect's own controller: a refetch swaps
|
|
438
329
|
// the ref, and one in flight at unmount must be aborted too.
|
|
439
|
-
|
|
330
|
+
loadController.abort();
|
|
440
331
|
setIsLoadingThread(false);
|
|
441
332
|
};
|
|
442
|
-
}, [runLoad]);
|
|
333
|
+
}, [loadController, runLoad]);
|
|
443
334
|
|
|
444
335
|
const runtime = useExternalStoreRuntime({
|
|
445
336
|
...pickExternalStoreSharedOptions(options),
|
|
446
|
-
isRunning
|
|
337
|
+
isRunning,
|
|
447
338
|
isLoading: isLoadingThread,
|
|
448
339
|
messages: threadMessages,
|
|
449
340
|
unstable_enableToolInvocations: true,
|
|
341
|
+
unstable_isClientToolCall: isClientToolCall,
|
|
450
342
|
setToolStatuses,
|
|
451
343
|
adapters: { attachments, dictation, feedback, speech, voice },
|
|
452
344
|
extras: adkExtras.provide({
|
|
@@ -475,7 +367,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
475
367
|
[
|
|
476
368
|
...cancellations,
|
|
477
369
|
{
|
|
478
|
-
id:
|
|
370
|
+
id: generateId(),
|
|
479
371
|
type: "human",
|
|
480
372
|
content: getMessageContent(msg),
|
|
481
373
|
},
|
|
@@ -509,7 +401,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
509
401
|
return handleSendMessage(
|
|
510
402
|
[
|
|
511
403
|
{
|
|
512
|
-
id:
|
|
404
|
+
id: generateId(),
|
|
513
405
|
type: "human",
|
|
514
406
|
content: getMessageContent(msg),
|
|
515
407
|
},
|
|
@@ -564,7 +456,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
564
456
|
await handleSendMessage(
|
|
565
457
|
[
|
|
566
458
|
{
|
|
567
|
-
id:
|
|
459
|
+
id: generateId(),
|
|
568
460
|
type: "tool",
|
|
569
461
|
name: toolName,
|
|
570
462
|
tool_call_id: toolCallId,
|
|
@@ -578,7 +470,12 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
578
470
|
},
|
|
579
471
|
onRespondToToolApproval: async (options) => {
|
|
580
472
|
await handleSendMessage(
|
|
581
|
-
[
|
|
473
|
+
[
|
|
474
|
+
toAdkToolConfirmationReply(
|
|
475
|
+
options,
|
|
476
|
+
projectAdkToolApprovals(adkMessagesRef.current).approvals,
|
|
477
|
+
),
|
|
478
|
+
],
|
|
582
479
|
{},
|
|
583
480
|
);
|
|
584
481
|
},
|
|
@@ -606,11 +503,10 @@ export const useAdkRuntime = ({
|
|
|
606
503
|
const aui = useAui();
|
|
607
504
|
const cloudAdapter = useCloudThreadListAdapter({
|
|
608
505
|
cloud,
|
|
609
|
-
create:
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
},
|
|
506
|
+
create: createCloudThreadListAdapterCreateFallback(
|
|
507
|
+
create,
|
|
508
|
+
aui.threadListItem,
|
|
509
|
+
),
|
|
614
510
|
delete: deleteFn,
|
|
615
511
|
});
|
|
616
512
|
|