@assistant-ui/react-google-adk 0.0.25 → 0.0.26
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.map +1 -1
- package/dist/AdkClient.js +13 -3
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +11 -1
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +2 -1
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/adkToolApproval.d.ts +40 -0
- package/dist/adkToolApproval.d.ts.map +1 -0
- package/dist/adkToolApproval.js +166 -0
- package/dist/adkToolApproval.js.map +1 -0
- package/dist/convertAdkMessages.d.ts +3 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +27 -16
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +2 -11
- package/dist/hooks.js.map +1 -1
- package/dist/parseAdkEvent.d.ts +6 -0
- package/dist/parseAdkEvent.d.ts.map +1 -0
- package/dist/parseAdkEvent.js +16 -0
- package/dist/parseAdkEvent.js.map +1 -0
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/server/parseAdkRequest.js +79 -13
- package/dist/server/parseAdkRequest.js.map +1 -1
- package/dist/useAdkMessages.js +104 -64
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +11 -3
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +5 -5
- package/src/AdkClient.test.ts +137 -0
- package/src/AdkClient.ts +15 -3
- package/src/AdkEventAccumulator.test.ts +77 -0
- package/src/AdkEventAccumulator.ts +24 -1
- package/src/AdkSessionAdapter.test.ts +145 -1
- package/src/AdkSessionAdapter.ts +7 -2
- package/src/adkToolApproval.test.ts +345 -0
- package/src/adkToolApproval.ts +215 -0
- package/src/convertAdkMessages.ts +72 -43
- package/src/hooks.ts +4 -16
- package/src/parseAdkEvent.ts +40 -0
- package/src/server/parseAdkRequest.test.ts +141 -0
- package/src/server/parseAdkRequest.ts +145 -13
- package/src/useAdkMessages.test.ts +313 -0
- package/src/useAdkMessages.ts +62 -4
- package/src/useAdkRuntime.ts +29 -3
- package/src/useAdkRuntimeApproval.test.tsx +344 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type {
|
|
4
|
+
RespondToToolApprovalOptions,
|
|
5
|
+
ThreadMessage,
|
|
6
|
+
ToolCallMessagePart,
|
|
7
|
+
} from "@assistant-ui/core";
|
|
8
|
+
import type { AdkMessage } from "./types";
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({
|
|
11
|
+
adapters: [] as unknown[],
|
|
12
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
messages: [] as AdkMessage[],
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("@assistant-ui/core/react")>()),
|
|
18
|
+
useCloudThreadListAdapter: () => ({}),
|
|
19
|
+
useExternalStoreRuntime: (adapter: unknown) => {
|
|
20
|
+
mocks.adapters.push(adapter);
|
|
21
|
+
return {};
|
|
22
|
+
},
|
|
23
|
+
useRemoteThreadListRuntime: (options: { runtimeHook: () => unknown }) =>
|
|
24
|
+
options.runtimeHook(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => ({
|
|
28
|
+
...(await importOriginal<typeof import("@assistant-ui/store")>()),
|
|
29
|
+
useAui: () => ({
|
|
30
|
+
threadListItem: {
|
|
31
|
+
source: null,
|
|
32
|
+
getState: () => ({ externalId: undefined }),
|
|
33
|
+
initialize: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("./useAdkMessages", async (importOriginal) => ({
|
|
39
|
+
...(await importOriginal<typeof import("./useAdkMessages")>()),
|
|
40
|
+
useAdkMessages: () => ({
|
|
41
|
+
messages: mocks.messages,
|
|
42
|
+
stateDelta: {},
|
|
43
|
+
agentInfo: {},
|
|
44
|
+
longRunningToolIds: [],
|
|
45
|
+
artifactDelta: {},
|
|
46
|
+
// Deliberately empty: the projection must come from the transcript, not
|
|
47
|
+
// from derived confirmation state that a mid-run accumulator swap drops.
|
|
48
|
+
toolConfirmations: [],
|
|
49
|
+
authRequests: [],
|
|
50
|
+
escalated: false,
|
|
51
|
+
messageMetadata: new Map(),
|
|
52
|
+
sendMessage: mocks.sendMessage,
|
|
53
|
+
cancel: vi.fn(),
|
|
54
|
+
setMessages: vi.fn(),
|
|
55
|
+
replaceMessages: vi.fn(),
|
|
56
|
+
applySnapshot: vi.fn(),
|
|
57
|
+
}),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import { AdkEventAccumulator } from "./AdkEventAccumulator";
|
|
61
|
+
import { useAdkRuntime } from "./useAdkRuntime";
|
|
62
|
+
|
|
63
|
+
type ApprovalAdapter = {
|
|
64
|
+
messages: readonly ThreadMessage[];
|
|
65
|
+
onRespondToToolApproval?: (
|
|
66
|
+
options: RespondToToolApprovalOptions,
|
|
67
|
+
) => Promise<void> | void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const CONFIRMATION_CALL = "adk-confirmation-1";
|
|
71
|
+
|
|
72
|
+
const latestAdapter = () => mocks.adapters.at(-1) as ApprovalAdapter;
|
|
73
|
+
|
|
74
|
+
const makeConfirmationRequest = (): AdkMessage => ({
|
|
75
|
+
id: "ai-1",
|
|
76
|
+
type: "ai",
|
|
77
|
+
content: [],
|
|
78
|
+
tool_calls: [
|
|
79
|
+
{
|
|
80
|
+
id: CONFIRMATION_CALL,
|
|
81
|
+
name: "adk_request_confirmation",
|
|
82
|
+
args: {
|
|
83
|
+
originalFunctionCall: { id: "adk-original-1", name: "delete_file" },
|
|
84
|
+
toolConfirmation: { hint: "Delete /tmp/a?" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const approvalPart = () =>
|
|
91
|
+
latestAdapter()
|
|
92
|
+
.messages.at(-1)!
|
|
93
|
+
.content.find(
|
|
94
|
+
(part) => part.type === "tool-call" && part.approval !== undefined,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
mocks.adapters.length = 0;
|
|
99
|
+
mocks.messages = [];
|
|
100
|
+
vi.clearAllMocks();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("useAdkRuntime tool approvals", () => {
|
|
104
|
+
it("exposes, answers, and settles the default approval seam across a rerender", async () => {
|
|
105
|
+
// Retained across the rerender: core caches converted messages by input
|
|
106
|
+
// object, so only a rebuilt converter can surface the settled decision.
|
|
107
|
+
const confirmationRequest = makeConfirmationRequest();
|
|
108
|
+
mocks.messages = [
|
|
109
|
+
{ id: "u-1", type: "human", content: "delete the file" },
|
|
110
|
+
confirmationRequest,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const { rerender } = renderHook(() => useAdkRuntime({ stream: vi.fn() }));
|
|
114
|
+
|
|
115
|
+
expect(latestAdapter().messages.at(-1)!.status).toMatchObject({
|
|
116
|
+
type: "requires-action",
|
|
117
|
+
reason: "interrupt",
|
|
118
|
+
});
|
|
119
|
+
expect(approvalPart()).toMatchObject({
|
|
120
|
+
toolCallId: CONFIRMATION_CALL,
|
|
121
|
+
approval: { id: CONFIRMATION_CALL },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await act(async () => {
|
|
125
|
+
await latestAdapter().onRespondToToolApproval!({
|
|
126
|
+
approvalId: CONFIRMATION_CALL,
|
|
127
|
+
approved: false,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(mocks.sendMessage.mock.calls.at(-1)![0]).toEqual([
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
type: "tool",
|
|
134
|
+
tool_call_id: CONFIRMATION_CALL,
|
|
135
|
+
name: "adk_request_confirmation",
|
|
136
|
+
content: JSON.stringify({ confirmed: false }),
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
mocks.messages = [
|
|
141
|
+
...mocks.messages,
|
|
142
|
+
{
|
|
143
|
+
id: "tool-1",
|
|
144
|
+
type: "tool",
|
|
145
|
+
tool_call_id: CONFIRMATION_CALL,
|
|
146
|
+
name: "adk_request_confirmation",
|
|
147
|
+
content: JSON.stringify({ confirmed: false }),
|
|
148
|
+
status: "success",
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
rerender();
|
|
152
|
+
|
|
153
|
+
expect(approvalPart()).toMatchObject({
|
|
154
|
+
approval: { id: CONFIRMATION_CALL, approved: false },
|
|
155
|
+
});
|
|
156
|
+
expect(latestAdapter().messages.at(-1)!.status).not.toMatchObject({
|
|
157
|
+
type: "requires-action",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
latestAdapter().onRespondToToolApproval!({
|
|
162
|
+
approvalId: CONFIRMATION_CALL,
|
|
163
|
+
approved: true,
|
|
164
|
+
}),
|
|
165
|
+
).rejects.toThrow("No pending ADK tool confirmation");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps a gate answered by an unreadable reply retryable at the runtime seam", async () => {
|
|
169
|
+
mocks.messages = [
|
|
170
|
+
{ id: "u-1", type: "human", content: "delete the file" },
|
|
171
|
+
makeConfirmationRequest(),
|
|
172
|
+
{
|
|
173
|
+
id: "tool-1",
|
|
174
|
+
type: "tool",
|
|
175
|
+
tool_call_id: CONFIRMATION_CALL,
|
|
176
|
+
name: "adk_request_confirmation",
|
|
177
|
+
// ADK parses the wrapped text without a `try`, so this reply raises
|
|
178
|
+
// rather than denying, and the gate stays answerable.
|
|
179
|
+
content: JSON.stringify({ response: "not-json" }),
|
|
180
|
+
status: "success",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
renderHook(() => useAdkRuntime({ stream: vi.fn() }));
|
|
185
|
+
|
|
186
|
+
expect(latestAdapter().messages.at(-1)!.status).toMatchObject({
|
|
187
|
+
type: "requires-action",
|
|
188
|
+
reason: "interrupt",
|
|
189
|
+
});
|
|
190
|
+
const part = approvalPart() as { result?: unknown; approval: unknown };
|
|
191
|
+
expect(part.result).toBeUndefined();
|
|
192
|
+
expect(part.approval).toEqual({ id: CONFIRMATION_CALL });
|
|
193
|
+
|
|
194
|
+
await act(async () => {
|
|
195
|
+
await latestAdapter().onRespondToToolApproval!({
|
|
196
|
+
approvalId: CONFIRMATION_CALL,
|
|
197
|
+
approved: true,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(mocks.sendMessage.mock.calls.at(-1)![0]).toEqual([
|
|
202
|
+
expect.objectContaining({
|
|
203
|
+
tool_call_id: CONFIRMATION_CALL,
|
|
204
|
+
content: JSON.stringify({ confirmed: true }),
|
|
205
|
+
}),
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("keeps both gates of an event retryable when one reply is unreadable", () => {
|
|
210
|
+
const accumulator = new AdkEventAccumulator();
|
|
211
|
+
const confirmationCall = (id: string, tool: string) => ({
|
|
212
|
+
functionCall: {
|
|
213
|
+
id,
|
|
214
|
+
name: "adk_request_confirmation",
|
|
215
|
+
args: {
|
|
216
|
+
originalFunctionCall: { id: `original-${id}`, name: tool },
|
|
217
|
+
toolConfirmation: { hint: `Run ${tool}?` },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
accumulator.processEvent({
|
|
222
|
+
id: "evt-request",
|
|
223
|
+
author: "agent",
|
|
224
|
+
longRunningToolIds: ["conf-a", "conf-b"],
|
|
225
|
+
content: {
|
|
226
|
+
role: "model",
|
|
227
|
+
parts: [
|
|
228
|
+
confirmationCall("conf-a", "delete_file"),
|
|
229
|
+
confirmationCall("conf-b", "send_email"),
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
mocks.messages = accumulator.processEvent({
|
|
234
|
+
id: "evt-reply",
|
|
235
|
+
author: "user",
|
|
236
|
+
content: {
|
|
237
|
+
role: "user",
|
|
238
|
+
parts: [
|
|
239
|
+
{
|
|
240
|
+
functionResponse: {
|
|
241
|
+
id: "conf-a",
|
|
242
|
+
name: "adk_request_confirmation",
|
|
243
|
+
response: { confirmed: true },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
functionResponse: {
|
|
248
|
+
id: "conf-b",
|
|
249
|
+
name: "adk_request_confirmation",
|
|
250
|
+
response: { response: "not json" },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
renderHook(() => useAdkRuntime({ stream: vi.fn() }));
|
|
258
|
+
|
|
259
|
+
const assistant = latestAdapter().messages.find(
|
|
260
|
+
(message): message is Extract<ThreadMessage, { role: "assistant" }> =>
|
|
261
|
+
message.role === "assistant",
|
|
262
|
+
)!;
|
|
263
|
+
const gates = assistant.content.filter(
|
|
264
|
+
(part): part is ToolCallMessagePart =>
|
|
265
|
+
part.type === "tool-call" && part.approval !== undefined,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(gates.map((gate) => gate.approval)).toEqual([
|
|
269
|
+
{ id: "conf-a" },
|
|
270
|
+
{ id: "conf-b" },
|
|
271
|
+
]);
|
|
272
|
+
expect(gates.map((gate) => gate.result)).toEqual([undefined, undefined]);
|
|
273
|
+
expect(assistant.status).toMatchObject({
|
|
274
|
+
type: "requires-action",
|
|
275
|
+
reason: "interrupt",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("settles a confirmation reply carried beside user text without an orphan message", () => {
|
|
280
|
+
const accumulator = new AdkEventAccumulator();
|
|
281
|
+
accumulator.processEvent({
|
|
282
|
+
id: "evt-request",
|
|
283
|
+
author: "agent",
|
|
284
|
+
longRunningToolIds: [CONFIRMATION_CALL],
|
|
285
|
+
content: {
|
|
286
|
+
role: "model",
|
|
287
|
+
parts: [
|
|
288
|
+
{
|
|
289
|
+
functionCall: {
|
|
290
|
+
id: CONFIRMATION_CALL,
|
|
291
|
+
name: "adk_request_confirmation",
|
|
292
|
+
args: {
|
|
293
|
+
originalFunctionCall: {
|
|
294
|
+
id: "adk-original-1",
|
|
295
|
+
name: "delete_file",
|
|
296
|
+
},
|
|
297
|
+
toolConfirmation: { hint: "Delete /tmp/a?" },
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
mocks.messages = accumulator.processEvent({
|
|
305
|
+
id: "evt-reply",
|
|
306
|
+
author: "user",
|
|
307
|
+
content: {
|
|
308
|
+
role: "user",
|
|
309
|
+
parts: [
|
|
310
|
+
{ text: "go ahead" },
|
|
311
|
+
{
|
|
312
|
+
functionResponse: {
|
|
313
|
+
id: CONFIRMATION_CALL,
|
|
314
|
+
name: "adk_request_confirmation",
|
|
315
|
+
response: { confirmed: true },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
renderHook(() => useAdkRuntime({ stream: vi.fn() }));
|
|
323
|
+
|
|
324
|
+
const messages = latestAdapter().messages;
|
|
325
|
+
const assistant = messages.find(
|
|
326
|
+
(message): message is Extract<ThreadMessage, { role: "assistant" }> =>
|
|
327
|
+
message.role === "assistant",
|
|
328
|
+
);
|
|
329
|
+
const gate = assistant?.content.find(
|
|
330
|
+
(part) => part.type === "tool-call" && part.approval !== undefined,
|
|
331
|
+
) as ToolCallMessagePart | undefined;
|
|
332
|
+
expect(gate?.approval).toEqual({
|
|
333
|
+
id: CONFIRMATION_CALL,
|
|
334
|
+
approved: true,
|
|
335
|
+
});
|
|
336
|
+
expect(
|
|
337
|
+
messages.some(
|
|
338
|
+
(message) =>
|
|
339
|
+
message.role === "assistant" && message.content.length === 0,
|
|
340
|
+
),
|
|
341
|
+
).toBe(false);
|
|
342
|
+
expect(messages.at(-1)!.role).toBe("user");
|
|
343
|
+
});
|
|
344
|
+
});
|