@assistant-ui/react-google-adk 0.0.25 → 0.0.27
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 +2 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +32 -10
- 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/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.d.ts.map +1 -1
- package/dist/hooks.js +5 -14
- 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.d.ts.map +1 -1
- package/dist/useAdkMessages.js +110 -79
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +44 -129
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +6 -7
- package/src/AdkClient.test.ts +137 -0
- package/src/AdkClient.ts +15 -3
- package/src/AdkEventAccumulator.test.ts +161 -1
- package/src/AdkEventAccumulator.ts +44 -8
- 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/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
- package/src/convertToAdkMessages.ts +139 -0
- package/src/hooks.ts +7 -19
- 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 +72 -25
- package/src/useAdkRuntime.ts +66 -188
- package/src/useAdkRuntimeApproval.test.tsx +344 -0
package/src/useAdkRuntime.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
-
getExternalStoreMessages,
|
|
4
3
|
pickExternalStoreSharedOptions,
|
|
5
4
|
type AttachmentAdapter,
|
|
6
5
|
type DictationAdapter,
|
|
@@ -9,11 +8,13 @@ import {
|
|
|
9
8
|
type RealtimeVoiceAdapter,
|
|
10
9
|
type SpeechSynthesisAdapter,
|
|
11
10
|
type AppendMessage,
|
|
12
|
-
type ThreadMessage,
|
|
13
11
|
type ToolExecutionStatus,
|
|
14
12
|
generateId,
|
|
15
13
|
} from "@assistant-ui/core";
|
|
16
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createAbortableThreadLoad,
|
|
16
|
+
createCloudThreadListAdapterCreateFallback,
|
|
17
|
+
} from "@assistant-ui/core/internal";
|
|
17
18
|
import {
|
|
18
19
|
useCloudThreadListAdapter,
|
|
19
20
|
useRemoteThreadListRuntime,
|
|
@@ -33,136 +34,21 @@ import type {
|
|
|
33
34
|
OnAdkAgentTransferCallback,
|
|
34
35
|
} from "./types";
|
|
35
36
|
import { useAdkMessages } from "./useAdkMessages";
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
convertAdkMessage,
|
|
39
|
+
createAdkMessageConverter,
|
|
40
|
+
} from "./convertAdkMessages";
|
|
41
|
+
import {
|
|
42
|
+
getMessageContent,
|
|
43
|
+
getPendingCancellations,
|
|
44
|
+
toAdkUserMessage,
|
|
45
|
+
truncateAdkMessages,
|
|
46
|
+
} from "./convertToAdkMessages";
|
|
47
|
+
import {
|
|
48
|
+
projectAdkToolApprovals,
|
|
49
|
+
toAdkToolConfirmationReply,
|
|
50
|
+
} from "./adkToolApproval";
|
|
37
51
|
import { adkExtras } from "./adkExtras";
|
|
38
|
-
import { v4 as uuidv4 } from "uuid";
|
|
39
|
-
|
|
40
|
-
/** @internal — exported for unit tests. */
|
|
41
|
-
export const getMessageContent = (msg: AppendMessage) => {
|
|
42
|
-
const allContent = [
|
|
43
|
-
...msg.content,
|
|
44
|
-
...(msg.attachments?.flatMap((a) => a.content) ?? []),
|
|
45
|
-
];
|
|
46
|
-
const content = allContent.flatMap((part) => {
|
|
47
|
-
const type = part.type;
|
|
48
|
-
switch (type) {
|
|
49
|
-
case "text":
|
|
50
|
-
return { type: "text" as const, text: part.text };
|
|
51
|
-
case "image":
|
|
52
|
-
return { type: "image_url" as const, url: part.image };
|
|
53
|
-
case "file":
|
|
54
|
-
if (part.sourceType === "url" || httpUrlPattern.test(part.data)) {
|
|
55
|
-
return {
|
|
56
|
-
type: "file_url" as const,
|
|
57
|
-
url: part.data,
|
|
58
|
-
mimeType: part.mimeType,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
type: "file" as const,
|
|
63
|
-
mimeType: part.mimeType,
|
|
64
|
-
// Lands in Gemini `inlineData.data`, which takes bare base64, so a
|
|
65
|
-
// data URL envelope is stripped rather than forwarded.
|
|
66
|
-
data: parseDataUrl(part.data)?.data ?? part.data,
|
|
67
|
-
...(part.filename != null && { filename: part.filename }),
|
|
68
|
-
};
|
|
69
|
-
case "audio": {
|
|
70
|
-
const parsed = parseDataUrl(part.audio.data);
|
|
71
|
-
return {
|
|
72
|
-
type: "file" as const,
|
|
73
|
-
mimeType: `audio/${part.audio.format}`,
|
|
74
|
-
data: parsed?.data ?? part.audio.data,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
case "data":
|
|
78
|
-
return [];
|
|
79
|
-
|
|
80
|
-
case "tool-call":
|
|
81
|
-
throw new Error("Tool call appends are not supported.");
|
|
82
|
-
|
|
83
|
-
default: {
|
|
84
|
-
const _exhaustiveCheck: "reasoning" | "source" | "generative-ui" = type;
|
|
85
|
-
throw new Error(
|
|
86
|
-
`Unsupported append message part type: ${_exhaustiveCheck}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (content.length === 1 && content[0]?.type === "text") {
|
|
93
|
-
return content[0].text ?? "";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return content;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
/** @internal — exported for unit tests. */
|
|
100
|
-
export const getPendingToolCalls = (messages: AdkMessage[]) => {
|
|
101
|
-
const pending = new Map<string, { id: string; name: string }>();
|
|
102
|
-
for (const msg of messages) {
|
|
103
|
-
if (msg.type === "ai" && msg.tool_calls) {
|
|
104
|
-
for (const tc of msg.tool_calls) {
|
|
105
|
-
pending.set(tc.id, tc);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (msg.type === "tool") {
|
|
109
|
-
pending.delete(msg.tool_call_id);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return [...pending.values()];
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* @internal — exported for unit tests.
|
|
117
|
-
*
|
|
118
|
-
* Returns `{cancelled: true}` tool responses for pending tool calls when the
|
|
119
|
-
* user sends a new turn, EXCEPT for HITL interrupts marked via
|
|
120
|
-
* `long_running_tool_ids` (`adk_request_input`, `adk_request_confirmation`,
|
|
121
|
-
* `adk_request_credential`). Those must be answered through a dedicated tool
|
|
122
|
-
* UI + submit helper, not auto-cancelled.
|
|
123
|
-
*/
|
|
124
|
-
export const getPendingCancellations = (
|
|
125
|
-
messages: AdkMessage[],
|
|
126
|
-
longRunningToolIds: readonly string[],
|
|
127
|
-
): Array<AdkMessage & { type: "tool" }> => {
|
|
128
|
-
const longRunningSet = new Set(longRunningToolIds);
|
|
129
|
-
return getPendingToolCalls(messages)
|
|
130
|
-
.filter((t) => !longRunningSet.has(t.id))
|
|
131
|
-
.map(
|
|
132
|
-
(t) =>
|
|
133
|
-
({
|
|
134
|
-
id: uuidv4(),
|
|
135
|
-
type: "tool",
|
|
136
|
-
name: t.name,
|
|
137
|
-
tool_call_id: t.id,
|
|
138
|
-
content: JSON.stringify({ cancelled: true }),
|
|
139
|
-
status: "error",
|
|
140
|
-
}) satisfies AdkMessage & { type: "tool" },
|
|
141
|
-
);
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const truncateAdkMessages = (
|
|
145
|
-
threadMessages: readonly ThreadMessage[],
|
|
146
|
-
parentId: string | null,
|
|
147
|
-
): AdkMessage[] => {
|
|
148
|
-
if (parentId === null) return [];
|
|
149
|
-
const parentIndex = threadMessages.findIndex((m) => m.id === parentId);
|
|
150
|
-
if (parentIndex === -1) return [];
|
|
151
|
-
const truncated: AdkMessage[] = [];
|
|
152
|
-
for (let i = 0; i <= parentIndex && i < threadMessages.length; i++) {
|
|
153
|
-
truncated.push(...getExternalStoreMessages<AdkMessage>(threadMessages[i]!));
|
|
154
|
-
}
|
|
155
|
-
return truncated;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const toAdkUserMessage = (
|
|
159
|
-
msg: AppendMessage,
|
|
160
|
-
id = generateId(),
|
|
161
|
-
): AdkMessage & { type: "human"; id: string } => ({
|
|
162
|
-
id,
|
|
163
|
-
type: "human",
|
|
164
|
-
content: getMessageContent(msg),
|
|
165
|
-
});
|
|
166
52
|
|
|
167
53
|
export type UseAdkRuntimeOptions = ExternalStoreSharedOptions & {
|
|
168
54
|
stream: AdkStreamCallback;
|
|
@@ -250,11 +136,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
250
136
|
|
|
251
137
|
const loadRef = useRef(load);
|
|
252
138
|
loadRef.current = load;
|
|
253
|
-
const
|
|
254
|
-
controller: AbortController;
|
|
255
|
-
purpose: "initial" | "reload";
|
|
256
|
-
promise?: Promise<void> | undefined;
|
|
257
|
-
} | null>(null);
|
|
139
|
+
const loadController = useMemo(createAbortableThreadLoad, []);
|
|
258
140
|
const messagesRef = useRef(messages);
|
|
259
141
|
messagesRef.current = messages;
|
|
260
142
|
const [isLoadingThread, setIsLoadingThread] = useState(
|
|
@@ -285,8 +167,21 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
285
167
|
}
|
|
286
168
|
};
|
|
287
169
|
|
|
170
|
+
const { approvals: toolApprovals, key: toolApprovalsKey } =
|
|
171
|
+
projectAdkToolApprovals(messages);
|
|
172
|
+
const toolApprovalsRef = useRef(toolApprovals);
|
|
173
|
+
toolApprovalsRef.current = toolApprovals;
|
|
174
|
+
|
|
175
|
+
const messageConverter = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
toolApprovalsKey === ""
|
|
178
|
+
? convertAdkMessage
|
|
179
|
+
: createAdkMessageConverter(toolApprovalsRef.current),
|
|
180
|
+
[toolApprovalsKey],
|
|
181
|
+
);
|
|
182
|
+
|
|
288
183
|
const threadMessages = useExternalMessageConverter({
|
|
289
|
-
callback:
|
|
184
|
+
callback: messageConverter,
|
|
290
185
|
messages,
|
|
291
186
|
isRunning: effectiveIsRunning,
|
|
292
187
|
});
|
|
@@ -353,26 +248,17 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
353
248
|
|
|
354
249
|
// The initial load is already fetching what a refetch would ask for, and
|
|
355
250
|
// taking it over strands the thread's history if the refetch then fails.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return loadControllerRef.current.promise ?? Promise.resolve();
|
|
361
|
-
|
|
362
|
-
loadControllerRef.current?.controller.abort();
|
|
363
|
-
const controller = new AbortController();
|
|
364
|
-
const record: NonNullable<typeof loadControllerRef.current> = {
|
|
365
|
-
controller,
|
|
251
|
+
// Aborting a load the runtime no longer needs is not a failure.
|
|
252
|
+
// A refetch reports the failure to whoever awaited it; the initial load
|
|
253
|
+
// has no caller to tell.
|
|
254
|
+
return loadController.run({
|
|
366
255
|
purpose,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const messagesAtLoadStart = messagesRef.current;
|
|
371
|
-
if (purpose === "initial") setIsLoadingThread(true);
|
|
256
|
+
load: async (signal) => {
|
|
257
|
+
const messagesAtLoadStart = messagesRef.current;
|
|
258
|
+
if (purpose === "initial") setIsLoadingThread(true);
|
|
372
259
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (controller.signal.aborted) return;
|
|
260
|
+
const snapshot = await loadFn(externalId, { signal });
|
|
261
|
+
if (signal.aborted) return;
|
|
376
262
|
// A snapshot the session assembled before a run cannot speak for what
|
|
377
263
|
// that run has since produced, and an ADK id cannot correlate a
|
|
378
264
|
// message sent optimistically with the one the session stored for it,
|
|
@@ -386,29 +272,16 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
386
272
|
)
|
|
387
273
|
return;
|
|
388
274
|
applySnapshot(snapshot);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Aborting a load the runtime no longer needs is not a failure.
|
|
392
|
-
if (controller.signal.aborted) return;
|
|
393
|
-
throw error;
|
|
394
|
-
})
|
|
395
|
-
.finally(() => {
|
|
396
|
-
if (loadControllerRef.current?.controller === controller) {
|
|
397
|
-
loadControllerRef.current = null;
|
|
398
|
-
}
|
|
399
|
-
if (controller.signal.aborted) return;
|
|
275
|
+
},
|
|
276
|
+
onSettled: () => {
|
|
400
277
|
setIsLoadingThread(false);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// has no caller to tell.
|
|
406
|
-
if (purpose === "reload") return task;
|
|
407
|
-
return task.catch((e: unknown) => {
|
|
408
|
-
console.warn("Failed to load ADK session:", e);
|
|
278
|
+
},
|
|
279
|
+
onInitialError: (error) => {
|
|
280
|
+
console.warn("Failed to load ADK session:", error);
|
|
281
|
+
},
|
|
409
282
|
});
|
|
410
283
|
},
|
|
411
|
-
[threadListItem, applySnapshot],
|
|
284
|
+
[threadListItem, loadController, applySnapshot],
|
|
412
285
|
);
|
|
413
286
|
|
|
414
287
|
useEffect(() => {
|
|
@@ -416,14 +289,14 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
416
289
|
return () => {
|
|
417
290
|
// Whatever is current, not this effect's own controller: a refetch swaps
|
|
418
291
|
// the ref, and one in flight at unmount must be aborted too.
|
|
419
|
-
|
|
292
|
+
loadController.abort();
|
|
420
293
|
setIsLoadingThread(false);
|
|
421
294
|
};
|
|
422
|
-
}, [runLoad]);
|
|
295
|
+
}, [loadController, runLoad]);
|
|
423
296
|
|
|
424
297
|
const runtime = useExternalStoreRuntime({
|
|
425
298
|
...pickExternalStoreSharedOptions(options),
|
|
426
|
-
isRunning
|
|
299
|
+
isRunning,
|
|
427
300
|
isLoading: isLoadingThread,
|
|
428
301
|
messages: threadMessages,
|
|
429
302
|
unstable_enableToolInvocations: true,
|
|
@@ -455,7 +328,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
455
328
|
[
|
|
456
329
|
...cancellations,
|
|
457
330
|
{
|
|
458
|
-
id:
|
|
331
|
+
id: generateId(),
|
|
459
332
|
type: "human",
|
|
460
333
|
content: getMessageContent(msg),
|
|
461
334
|
},
|
|
@@ -489,7 +362,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
489
362
|
return handleSendMessage(
|
|
490
363
|
[
|
|
491
364
|
{
|
|
492
|
-
id:
|
|
365
|
+
id: generateId(),
|
|
493
366
|
type: "human",
|
|
494
367
|
content: getMessageContent(msg),
|
|
495
368
|
},
|
|
@@ -544,7 +417,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
544
417
|
await handleSendMessage(
|
|
545
418
|
[
|
|
546
419
|
{
|
|
547
|
-
id:
|
|
420
|
+
id: generateId(),
|
|
548
421
|
type: "tool",
|
|
549
422
|
name: toolName,
|
|
550
423
|
tool_call_id: toolCallId,
|
|
@@ -556,6 +429,12 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
556
429
|
{},
|
|
557
430
|
);
|
|
558
431
|
},
|
|
432
|
+
onRespondToToolApproval: async (options) => {
|
|
433
|
+
await handleSendMessage(
|
|
434
|
+
[toAdkToolConfirmationReply(options, toolApprovalsRef.current)],
|
|
435
|
+
{},
|
|
436
|
+
);
|
|
437
|
+
},
|
|
559
438
|
onCancel: unstable_allowCancellation
|
|
560
439
|
? async () => {
|
|
561
440
|
cancel();
|
|
@@ -580,11 +459,10 @@ export const useAdkRuntime = ({
|
|
|
580
459
|
const aui = useAui();
|
|
581
460
|
const cloudAdapter = useCloudThreadListAdapter({
|
|
582
461
|
cloud,
|
|
583
|
-
create:
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
},
|
|
462
|
+
create: createCloudThreadListAdapterCreateFallback(
|
|
463
|
+
create,
|
|
464
|
+
aui.threadListItem,
|
|
465
|
+
),
|
|
588
466
|
delete: deleteFn,
|
|
589
467
|
});
|
|
590
468
|
|
|
@@ -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
|
+
});
|