@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.
Files changed (58) hide show
  1. package/dist/AdkClient.d.ts.map +1 -1
  2. package/dist/AdkClient.js +13 -3
  3. package/dist/AdkClient.js.map +1 -1
  4. package/dist/AdkEventAccumulator.d.ts +2 -0
  5. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  6. package/dist/AdkEventAccumulator.js +32 -10
  7. package/dist/AdkEventAccumulator.js.map +1 -1
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +2 -1
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkToolApproval.d.ts +40 -0
  12. package/dist/adkToolApproval.d.ts.map +1 -0
  13. package/dist/adkToolApproval.js +166 -0
  14. package/dist/adkToolApproval.js.map +1 -0
  15. package/dist/convertAdkMessages.d.ts +3 -1
  16. package/dist/convertAdkMessages.d.ts.map +1 -1
  17. package/dist/convertAdkMessages.js +27 -16
  18. package/dist/convertAdkMessages.js.map +1 -1
  19. package/dist/convertToAdkMessages.d.ts +49 -0
  20. package/dist/convertToAdkMessages.d.ts.map +1 -0
  21. package/dist/convertToAdkMessages.js +86 -0
  22. package/dist/convertToAdkMessages.js.map +1 -0
  23. package/dist/hooks.d.ts.map +1 -1
  24. package/dist/hooks.js +5 -14
  25. package/dist/hooks.js.map +1 -1
  26. package/dist/parseAdkEvent.d.ts +6 -0
  27. package/dist/parseAdkEvent.d.ts.map +1 -0
  28. package/dist/parseAdkEvent.js +16 -0
  29. package/dist/parseAdkEvent.js.map +1 -0
  30. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  31. package/dist/server/parseAdkRequest.js +79 -13
  32. package/dist/server/parseAdkRequest.js.map +1 -1
  33. package/dist/useAdkMessages.d.ts.map +1 -1
  34. package/dist/useAdkMessages.js +110 -79
  35. package/dist/useAdkMessages.js.map +1 -1
  36. package/dist/useAdkRuntime.d.ts.map +1 -1
  37. package/dist/useAdkRuntime.js +44 -129
  38. package/dist/useAdkRuntime.js.map +1 -1
  39. package/package.json +6 -7
  40. package/src/AdkClient.test.ts +137 -0
  41. package/src/AdkClient.ts +15 -3
  42. package/src/AdkEventAccumulator.test.ts +161 -1
  43. package/src/AdkEventAccumulator.ts +44 -8
  44. package/src/AdkSessionAdapter.test.ts +145 -1
  45. package/src/AdkSessionAdapter.ts +7 -2
  46. package/src/adkToolApproval.test.ts +345 -0
  47. package/src/adkToolApproval.ts +215 -0
  48. package/src/convertAdkMessages.ts +72 -43
  49. package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
  50. package/src/convertToAdkMessages.ts +139 -0
  51. package/src/hooks.ts +7 -19
  52. package/src/parseAdkEvent.ts +40 -0
  53. package/src/server/parseAdkRequest.test.ts +141 -0
  54. package/src/server/parseAdkRequest.ts +145 -13
  55. package/src/useAdkMessages.test.ts +313 -0
  56. package/src/useAdkMessages.ts +72 -25
  57. package/src/useAdkRuntime.ts +66 -188
  58. package/src/useAdkRuntimeApproval.test.tsx +344 -0
@@ -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 { httpUrlPattern, parseDataUrl } from "@assistant-ui/core/internal";
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 { convertAdkMessage } from "./convertAdkMessages";
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 loadControllerRef = useRef<{
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: convertAdkMessage,
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
- if (
357
- purpose === "reload" &&
358
- loadControllerRef.current?.purpose === "initial"
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
- loadControllerRef.current = record;
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
- const task = loadFn(externalId, { signal: controller.signal })
374
- .then((snapshot) => {
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
- .catch((error: unknown) => {
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
- record.promise = task;
403
-
404
- // A refetch reports the failure to whoever awaited it; the initial load
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
- loadControllerRef.current?.controller.abort();
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: effectiveIsRunning,
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: uuidv4(),
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: uuidv4(),
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: uuidv4(),
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: async () => {
584
- if (create) return create();
585
- if (aui.threadListItem.source) return aui.threadListItem.initialize();
586
- return { externalId: undefined };
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
+ });