@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.
Files changed (50) 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.map +1 -1
  5. package/dist/AdkEventAccumulator.js +11 -1
  6. package/dist/AdkEventAccumulator.js.map +1 -1
  7. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  8. package/dist/AdkSessionAdapter.js +2 -1
  9. package/dist/AdkSessionAdapter.js.map +1 -1
  10. package/dist/adkToolApproval.d.ts +40 -0
  11. package/dist/adkToolApproval.d.ts.map +1 -0
  12. package/dist/adkToolApproval.js +166 -0
  13. package/dist/adkToolApproval.js.map +1 -0
  14. package/dist/convertAdkMessages.d.ts +3 -1
  15. package/dist/convertAdkMessages.d.ts.map +1 -1
  16. package/dist/convertAdkMessages.js +27 -16
  17. package/dist/convertAdkMessages.js.map +1 -1
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/hooks.js +2 -11
  20. package/dist/hooks.js.map +1 -1
  21. package/dist/parseAdkEvent.d.ts +6 -0
  22. package/dist/parseAdkEvent.d.ts.map +1 -0
  23. package/dist/parseAdkEvent.js +16 -0
  24. package/dist/parseAdkEvent.js.map +1 -0
  25. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  26. package/dist/server/parseAdkRequest.js +79 -13
  27. package/dist/server/parseAdkRequest.js.map +1 -1
  28. package/dist/useAdkMessages.js +104 -64
  29. package/dist/useAdkMessages.js.map +1 -1
  30. package/dist/useAdkRuntime.d.ts.map +1 -1
  31. package/dist/useAdkRuntime.js +11 -3
  32. package/dist/useAdkRuntime.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/AdkClient.test.ts +137 -0
  35. package/src/AdkClient.ts +15 -3
  36. package/src/AdkEventAccumulator.test.ts +77 -0
  37. package/src/AdkEventAccumulator.ts +24 -1
  38. package/src/AdkSessionAdapter.test.ts +145 -1
  39. package/src/AdkSessionAdapter.ts +7 -2
  40. package/src/adkToolApproval.test.ts +345 -0
  41. package/src/adkToolApproval.ts +215 -0
  42. package/src/convertAdkMessages.ts +72 -43
  43. package/src/hooks.ts +4 -16
  44. package/src/parseAdkEvent.ts +40 -0
  45. package/src/server/parseAdkRequest.test.ts +141 -0
  46. package/src/server/parseAdkRequest.ts +145 -13
  47. package/src/useAdkMessages.test.ts +313 -0
  48. package/src/useAdkMessages.ts +62 -4
  49. package/src/useAdkRuntime.ts +29 -3
  50. package/src/useAdkRuntimeApproval.test.tsx +344 -0
@@ -0,0 +1,345 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ projectAdkToolApprovals,
4
+ toAdkConfirmationReply,
5
+ toAdkToolConfirmationReply,
6
+ } from "./adkToolApproval";
7
+ import type { AdkMessage } from "./types";
8
+
9
+ const GATED_CALL = "adk-original-1";
10
+ const CONFIRMATION_CALL = "adk-confirmation-1";
11
+
12
+ const aiCall = (id: string, name: string, args = {}): AdkMessage => ({
13
+ id: `ai-${id}`,
14
+ type: "ai",
15
+ content: [],
16
+ tool_calls: [{ id, name, args }],
17
+ });
18
+
19
+ /**
20
+ * The shape ADK actually emits: the gated tool call, then a synthetic
21
+ * `adk_request_confirmation` call with an id of its own quoting the original.
22
+ */
23
+ const requestedThread = (...tail: AdkMessage[]): AdkMessage[] => [
24
+ aiCall(GATED_CALL, "delete_file", { path: "/tmp/a" }),
25
+ aiCall(CONFIRMATION_CALL, "adk_request_confirmation", {
26
+ originalFunctionCall: { id: GATED_CALL, name: "delete_file" },
27
+ toolConfirmation: { hint: "Delete /tmp/a?" },
28
+ }),
29
+ ...tail,
30
+ ];
31
+
32
+ const reply = (
33
+ content: string,
34
+ name = "adk_request_confirmation",
35
+ ): AdkMessage => ({
36
+ id: "tool-1",
37
+ type: "tool",
38
+ tool_call_id: CONFIRMATION_CALL,
39
+ name,
40
+ content,
41
+ status: "success",
42
+ });
43
+
44
+ /** The id the accumulator mints for a function response: `<eventId>:<part>`. */
45
+ const eventReply = (
46
+ eventId: string,
47
+ partIndex: number,
48
+ toolCallId: string,
49
+ content: string,
50
+ ): AdkMessage => ({
51
+ id: `${eventId}:${partIndex}`,
52
+ type: "tool",
53
+ tool_call_id: toolCallId,
54
+ name: "adk_request_confirmation",
55
+ content,
56
+ status: "success",
57
+ });
58
+
59
+ describe("projectAdkToolApprovals", () => {
60
+ it.each([
61
+ ["pending", requestedThread(), { id: CONFIRMATION_CALL }],
62
+ [
63
+ "approved",
64
+ requestedThread(reply(JSON.stringify({ confirmed: true }))),
65
+ { id: CONFIRMATION_CALL, approved: true },
66
+ ],
67
+ [
68
+ "denied",
69
+ requestedThread(reply(JSON.stringify({ confirmed: false }))),
70
+ { id: CONFIRMATION_CALL, approved: false },
71
+ ],
72
+ [
73
+ "approved through the ADK client wrapper",
74
+ requestedThread(
75
+ reply(
76
+ JSON.stringify({ response: JSON.stringify({ confirmed: true }) }),
77
+ ),
78
+ ),
79
+ { id: CONFIRMATION_CALL, approved: true },
80
+ ],
81
+ [
82
+ "denied through the ADK client wrapper",
83
+ requestedThread(
84
+ reply(
85
+ JSON.stringify({ response: JSON.stringify({ confirmed: false }) }),
86
+ ),
87
+ ),
88
+ { id: CONFIRMATION_CALL, approved: false },
89
+ ],
90
+ // ADK reads a truthy response it cannot take a `confirmed` off — raw text,
91
+ // a scalar, an array — as a tool it resumes unconfirmed, so the gate is
92
+ // denied rather than left answerable.
93
+ [
94
+ "denied on a reply ADK resumes unconfirmed",
95
+ requestedThread(reply("not json")),
96
+ { id: CONFIRMATION_CALL, approved: false },
97
+ ],
98
+ [
99
+ "denied on a scalar reply",
100
+ requestedThread(reply("5")),
101
+ { id: CONFIRMATION_CALL, approved: false },
102
+ ],
103
+ [
104
+ "denied on a wrapped reply ADK parses to a scalar",
105
+ requestedThread(reply(JSON.stringify({ response: "5" }))),
106
+ { id: CONFIRMATION_CALL, approved: false },
107
+ ],
108
+ // A falsy response records no confirmation at all, so the gate stays open.
109
+ [
110
+ "still pending on a reply ADK records nothing for",
111
+ requestedThread(reply("false")),
112
+ { id: CONFIRMATION_CALL },
113
+ ],
114
+ [
115
+ "still pending on an empty reply",
116
+ requestedThread(reply("")),
117
+ { id: CONFIRMATION_CALL },
118
+ ],
119
+ // ADK parses the wrapped text without a `try`, so these raise.
120
+ [
121
+ "still pending on an unreadable wrapped reply",
122
+ requestedThread(reply(JSON.stringify({ response: "not json" }))),
123
+ { id: CONFIRMATION_CALL },
124
+ ],
125
+ [
126
+ "still pending on a wrapped reply ADK cannot stringify to JSON",
127
+ requestedThread(reply(JSON.stringify({ response: { confirmed: true } }))),
128
+ { id: CONFIRMATION_CALL },
129
+ ],
130
+ // `"response" in "x"` raises on a one-character response.
131
+ [
132
+ "still pending on a one-character reply",
133
+ requestedThread(reply("x")),
134
+ { id: CONFIRMATION_CALL },
135
+ ],
136
+ [
137
+ "still pending when some other tool answers",
138
+ requestedThread(
139
+ reply(JSON.stringify({ confirmed: true }), "delete_file"),
140
+ ),
141
+ { id: CONFIRMATION_CALL },
142
+ ],
143
+ ])(
144
+ "gates the confirmation and the call it gates: %s",
145
+ (_name, messages, expected) => {
146
+ const { approvals, key } = projectAdkToolApprovals(messages);
147
+ // Both calls are in the transcript, so both would otherwise render an
148
+ // approval control; the gated one carries the answerable synthetic id.
149
+ expect([...approvals.keys()]).toEqual([CONFIRMATION_CALL, GATED_CALL]);
150
+ expect(approvals.get(CONFIRMATION_CALL)).toEqual(expected);
151
+ expect(approvals.get(GATED_CALL)).toEqual(expected);
152
+ expect(key).not.toBe("");
153
+ },
154
+ );
155
+
156
+ /**
157
+ * ADK parses every function response in an event before running any tool, so
158
+ * the unreadable reply aborts its whole event and the readable sibling never
159
+ * executes either. Both gates stay answerable.
160
+ */
161
+ const UNREADABLE = JSON.stringify({ response: "not json" });
162
+
163
+ it("keeps every confirmation from one event pending when one reply is unreadable", () => {
164
+ const { approvals } = projectAdkToolApprovals([
165
+ aiCall("conf-a", "adk_request_confirmation"),
166
+ aiCall("conf-b", "adk_request_confirmation"),
167
+ eventReply("evt-1", 0, "conf-a", JSON.stringify({ confirmed: true })),
168
+ eventReply("evt-1", 1, "conf-b", UNREADABLE),
169
+ ]);
170
+
171
+ expect([...approvals.values()]).toEqual([
172
+ { id: "conf-a" },
173
+ { id: "conf-b" },
174
+ ]);
175
+ });
176
+
177
+ it("settles a readable reply when the unreadable one came from another event", () => {
178
+ const { approvals } = projectAdkToolApprovals([
179
+ aiCall("conf-a", "adk_request_confirmation"),
180
+ aiCall("conf-b", "adk_request_confirmation"),
181
+ eventReply("evt-1", 0, "conf-a", JSON.stringify({ confirmed: true })),
182
+ eventReply("evt-2", 0, "conf-b", UNREADABLE),
183
+ ]);
184
+
185
+ expect([...approvals.values()]).toEqual([
186
+ { id: "conf-a", approved: true },
187
+ { id: "conf-b" },
188
+ ]);
189
+ });
190
+
191
+ /**
192
+ * A reply ADK records nothing for does not raise, so its siblings in the same
193
+ * event are parsed and resumed as usual and keep their decisions.
194
+ */
195
+ it("settles a sibling when the unrecorded reply came from the same event", () => {
196
+ const { approvals } = projectAdkToolApprovals([
197
+ aiCall("conf-a", "adk_request_confirmation"),
198
+ aiCall("conf-b", "adk_request_confirmation"),
199
+ eventReply("evt-1", 0, "conf-a", JSON.stringify({ confirmed: true })),
200
+ eventReply("evt-1", 1, "conf-b", "false"),
201
+ ]);
202
+
203
+ expect([...approvals.values()]).toEqual([
204
+ { id: "conf-a", approved: true },
205
+ { id: "conf-b" },
206
+ ]);
207
+ });
208
+
209
+ /**
210
+ * ADK Python spells the confirmation args in snake_case, which the event
211
+ * accumulator already reads both ways.
212
+ */
213
+ it("gates the call named by a snake_case confirmation request", () => {
214
+ const { approvals } = projectAdkToolApprovals([
215
+ aiCall(GATED_CALL, "delete_file", { path: "/tmp/a" }),
216
+ aiCall(CONFIRMATION_CALL, "adk_request_confirmation", {
217
+ original_function_call: { id: GATED_CALL, name: "delete_file" },
218
+ tool_confirmation: { hint: "Delete /tmp/a?" },
219
+ }),
220
+ ]);
221
+
222
+ expect([...approvals.keys()]).toEqual([CONFIRMATION_CALL, GATED_CALL]);
223
+ expect(approvals.get(GATED_CALL)).toEqual({ id: CONFIRMATION_CALL });
224
+ });
225
+
226
+ it("gates nothing when no confirmation was requested", () => {
227
+ const { approvals, key } = projectAdkToolApprovals([
228
+ aiCall("tc-9", "search"),
229
+ ]);
230
+ expect(approvals.size).toBe(0);
231
+ expect(key).toBe("");
232
+ });
233
+ });
234
+
235
+ describe("toAdkToolConfirmationReply", () => {
236
+ const pending = projectAdkToolApprovals(requestedThread()).approvals;
237
+
238
+ it.each([
239
+ [true, JSON.stringify({ confirmed: true })],
240
+ [false, JSON.stringify({ confirmed: false })],
241
+ ])(
242
+ "serializes approved=%s as an ADK confirmation reply",
243
+ (approved, content) => {
244
+ expect(
245
+ toAdkToolConfirmationReply(
246
+ { approvalId: CONFIRMATION_CALL, approved, reason: "ignored" },
247
+ pending,
248
+ ),
249
+ ).toMatchObject({
250
+ type: "tool",
251
+ tool_call_id: CONFIRMATION_CALL,
252
+ name: "adk_request_confirmation",
253
+ content,
254
+ status: "success",
255
+ });
256
+ },
257
+ );
258
+
259
+ it.each([
260
+ ["unknown", "no-such-id", pending],
261
+ [
262
+ "already settled",
263
+ CONFIRMATION_CALL,
264
+ projectAdkToolApprovals(
265
+ requestedThread(reply(JSON.stringify({ confirmed: true }))),
266
+ ).approvals,
267
+ ],
268
+ ])("rejects an approval id that is %s", (_name, approvalId, approvals) => {
269
+ expect(() =>
270
+ toAdkToolConfirmationReply({ approvalId, approved: true }, approvals),
271
+ ).toThrow("No pending ADK tool confirmation");
272
+ });
273
+
274
+ it("still answers a gate whose earlier reply was unreadable", () => {
275
+ const approvals = projectAdkToolApprovals(
276
+ requestedThread(reply(JSON.stringify({ response: "not json" }))),
277
+ ).approvals;
278
+
279
+ expect(
280
+ toAdkToolConfirmationReply(
281
+ { approvalId: CONFIRMATION_CALL, approved: true },
282
+ approvals,
283
+ ),
284
+ ).toMatchObject({
285
+ tool_call_id: CONFIRMATION_CALL,
286
+ content: JSON.stringify({ confirmed: true }),
287
+ });
288
+ });
289
+ });
290
+
291
+ describe("toAdkConfirmationReply", () => {
292
+ it.each([
293
+ [undefined, JSON.stringify({ confirmed: true })],
294
+ [
295
+ { note: "ok" },
296
+ JSON.stringify({ confirmed: true, payload: { note: "ok" } }),
297
+ ],
298
+ ])(
299
+ "preserves useAdkConfirmTool payload serialization: %s",
300
+ (payload, content) => {
301
+ expect(toAdkConfirmationReply(CONFIRMATION_CALL, true, payload)).toEqual({
302
+ id: expect.any(String),
303
+ type: "tool",
304
+ tool_call_id: CONFIRMATION_CALL,
305
+ name: "adk_request_confirmation",
306
+ content,
307
+ status: "success",
308
+ });
309
+ },
310
+ );
311
+ });
312
+
313
+ describe("projectAdkToolApprovals gated call", () => {
314
+ it("answers through the gated call with the synthetic id", () => {
315
+ // ADK yields the gated call before the confirmation request, so it is in
316
+ // the transcript too; answering from its control must still quote the id
317
+ // ADK is waiting on.
318
+ const { approvals } = projectAdkToolApprovals(requestedThread());
319
+
320
+ const gated = approvals.get(GATED_CALL);
321
+ expect(gated).toEqual({ id: CONFIRMATION_CALL });
322
+ expect(
323
+ toAdkToolConfirmationReply(
324
+ { approvalId: gated!.id, approved: true },
325
+ approvals,
326
+ ),
327
+ ).toMatchObject({
328
+ tool_call_id: CONFIRMATION_CALL,
329
+ name: "adk_request_confirmation",
330
+ content: JSON.stringify({ confirmed: true }),
331
+ });
332
+ });
333
+
334
+ it("leaves a confirmation whose original call has no id ungated", () => {
335
+ const messages: AdkMessage[] = [
336
+ aiCall(CONFIRMATION_CALL, "adk_request_confirmation", {
337
+ toolConfirmation: { hint: "Delete /tmp/a?" },
338
+ }),
339
+ ];
340
+
341
+ expect([...projectAdkToolApprovals(messages).approvals.keys()]).toEqual([
342
+ CONFIRMATION_CALL,
343
+ ]);
344
+ });
345
+ });
@@ -0,0 +1,215 @@
1
+ import type {
2
+ RespondToToolApprovalOptions,
3
+ ToolCallMessagePart,
4
+ } from "@assistant-ui/core";
5
+ import type { ReadonlyJSONValue } from "assistant-stream/utils";
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import type { AdkMessage } from "./types";
8
+
9
+ export type AdkToolApproval = NonNullable<ToolCallMessagePart["approval"]>;
10
+
11
+ export const ADK_REQUEST_CONFIRMATION = "adk_request_confirmation";
12
+
13
+ const parseJson = (raw: string): unknown => {
14
+ try {
15
+ return JSON.parse(raw);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ };
20
+
21
+ type AdkConfirmationRead = {
22
+ /**
23
+ * False only where ADK raises reading the reply, which aborts every function
24
+ * response in the same event rather than denying this one.
25
+ */
26
+ readable: boolean;
27
+ /** Undefined where ADK records no confirmation and the gate stays open. */
28
+ confirmed: boolean | undefined;
29
+ };
30
+
31
+ /**
32
+ * The value the transport puts on `functionResponse.response`: the reply
33
+ * content parsed, or the raw text where it is not JSON.
34
+ */
35
+ const wireResponseOf = (content: string): unknown => {
36
+ const parsed = parseJson(content);
37
+ return parsed === undefined ? content : parsed;
38
+ };
39
+
40
+ /**
41
+ * Mirrors ADK's own reading of a confirmation reply, whose three outcomes the
42
+ * gate has to distinguish. A falsy response records nothing, so the gate stays
43
+ * open and its siblings are untouched. Otherwise ADK either unwraps its client
44
+ * wrapper — a lone `response` key whose value it parses as JSON text — or reads
45
+ * `confirmed` off the response directly, and resumes the tool unconfirmed
46
+ * (a denial) for anything that is not explicitly `true`; a bare string, number,
47
+ * or array is such a denial, not an unreadable reply. Only where that reading
48
+ * raises is the reply unreadable, and the raise is uncaught in ADK, so the
49
+ * whole event it belongs to is abandoned.
50
+ */
51
+ const readConfirmation = (content: unknown): AdkConfirmationRead => {
52
+ if (typeof content !== "string")
53
+ return { readable: false, confirmed: undefined };
54
+ const response = wireResponseOf(content);
55
+ try {
56
+ if (!response) return { readable: true, confirmed: undefined };
57
+ const record = response as Record<string, unknown>;
58
+ const decide = (value: unknown) =>
59
+ typeof value === "object" &&
60
+ value !== null &&
61
+ (value as { confirmed?: unknown }).confirmed === true;
62
+ // `in` raises on a primitive response, as ADK's own wrapper test does.
63
+ if (Object.keys(record).length === 1 && "response" in record) {
64
+ const inner = parseJson(String(record.response));
65
+ // ADK parses the wrapped text without a `try`, so it raises here.
66
+ if (inner === undefined) return { readable: false, confirmed: undefined };
67
+ if (!inner) return { readable: true, confirmed: undefined };
68
+ return { readable: true, confirmed: decide(inner) };
69
+ }
70
+ return { readable: true, confirmed: decide(record) };
71
+ } catch {
72
+ return { readable: false, confirmed: undefined };
73
+ }
74
+ };
75
+
76
+ /**
77
+ * ADK's confirmation processor parses every function response in an event
78
+ * before running any tool, so one unreadable reply aborts the event and none of
79
+ * its siblings execute. The accumulator mints a tool message id of
80
+ * `<eventId>:<partIndex>` per response, so that prefix is the batch ADK either
81
+ * runs whole or not at all; a locally minted reply carries a bare uuid and is
82
+ * its own batch.
83
+ */
84
+ const sourceEventOf = (toolMessageId: string): string =>
85
+ toolMessageId.replace(/:\d+$/, "");
86
+
87
+ /**
88
+ * ADK builds the confirmation request around the call it gates, carrying that
89
+ * call verbatim in `originalFunctionCall` — spelled `original_function_call` by
90
+ * ADK Python, which the accumulator reads the same way.
91
+ */
92
+ const gatedCallIdOf = (args: unknown): string | undefined => {
93
+ if (typeof args !== "object" || args === null) return undefined;
94
+ const record = args as {
95
+ originalFunctionCall?: unknown;
96
+ original_function_call?: unknown;
97
+ };
98
+ const original = record.originalFunctionCall ?? record.original_function_call;
99
+ if (typeof original !== "object" || original === null) return undefined;
100
+ const id = (original as { id?: unknown }).id;
101
+ return typeof id === "string" && id.length > 0 ? id : undefined;
102
+ };
103
+
104
+ export type AdkToolApprovalProjection = {
105
+ approvals: ReadonlyMap<string, AdkToolApproval>;
106
+ /**
107
+ * Identity of the decision state. Core re-converts a cached message object
108
+ * only when the converter callback changes, so the callback is rebuilt when
109
+ * this key moves rather than on every render.
110
+ */
111
+ key: string;
112
+ };
113
+
114
+ /**
115
+ * ADK requests a confirmation by emitting a synthetic `adk_request_confirmation`
116
+ * tool call carrying a fresh id, and resumes only when a reply quotes that id.
117
+ * The gated tool's own id is never answerable, so every approval carries the
118
+ * synthetic id — but ADK yields the gated call to the client first, in its own
119
+ * event, so both calls sit result-less in the transcript and both would render
120
+ * an approval control. The gated call is therefore mapped to the same approval
121
+ * rather than left bare, which would let it answer by writing a tool result the
122
+ * agent never produced.
123
+ */
124
+ export const projectAdkToolApprovals = (
125
+ messages: readonly AdkMessage[],
126
+ ): AdkToolApprovalProjection => {
127
+ const batches = new Map<
128
+ string,
129
+ { readable: boolean; entries: [string, boolean | undefined][] }
130
+ >();
131
+ for (const message of messages) {
132
+ if (message.type !== "tool") continue;
133
+ if (message.name !== ADK_REQUEST_CONFIRMATION) continue;
134
+ const key = sourceEventOf(message.id);
135
+ let batch = batches.get(key);
136
+ if (batch === undefined) {
137
+ batch = { readable: true, entries: [] };
138
+ batches.set(key, batch);
139
+ }
140
+ const { readable, confirmed } = readConfirmation(message.content);
141
+ if (!readable) batch.readable = false;
142
+ batch.entries.push([message.tool_call_id, confirmed]);
143
+ }
144
+
145
+ const replies = new Map<string, boolean>();
146
+ for (const batch of batches.values()) {
147
+ for (const [toolCallId, confirmed] of batch.entries) {
148
+ if (batch.readable && confirmed !== undefined)
149
+ replies.set(toolCallId, confirmed);
150
+ else replies.delete(toolCallId);
151
+ }
152
+ }
153
+
154
+ const approvals = new Map<string, AdkToolApproval>();
155
+ for (const message of messages) {
156
+ if (message.type !== "ai") continue;
157
+ for (const call of message.tool_calls ?? []) {
158
+ if (call.name !== ADK_REQUEST_CONFIRMATION) continue;
159
+ const approved = replies.get(call.id);
160
+ const approval: AdkToolApproval = {
161
+ id: call.id,
162
+ ...(approved !== undefined && { approved }),
163
+ };
164
+ approvals.set(call.id, approval);
165
+ // ADK yields the gated call in its own event before the confirmation is
166
+ // requested, so that call is also in the transcript, result-less, and
167
+ // inherits the message's `requires-action` status. Left ungated it
168
+ // renders a second approval control with no approval behind it, which
169
+ // answers by writing a tool result the agent never produced. The same
170
+ // approval carries the synthetic id, so answering either one replies to
171
+ // the gate ADK is actually waiting on.
172
+ const gatedId = gatedCallIdOf(call.args);
173
+ if (gatedId !== undefined) approvals.set(gatedId, approval);
174
+ }
175
+ }
176
+
177
+ return {
178
+ approvals,
179
+ key: approvals.size === 0 ? "" : JSON.stringify([...approvals]),
180
+ };
181
+ };
182
+
183
+ export const toAdkConfirmationReply = (
184
+ toolCallId: string,
185
+ confirmed: boolean,
186
+ payload?: ReadonlyJSONValue,
187
+ ): AdkMessage & { type: "tool" } => ({
188
+ id: uuidv4(),
189
+ type: "tool",
190
+ tool_call_id: toolCallId,
191
+ name: ADK_REQUEST_CONFIRMATION,
192
+ content: JSON.stringify({
193
+ confirmed,
194
+ ...(payload != null && { payload }),
195
+ }),
196
+ status: "success",
197
+ });
198
+
199
+ /**
200
+ * ADK confirmations are a plain confirm/reject pair, so no option or reason
201
+ * from core has a field to land in. An id that is not an open gate would send a
202
+ * reply ADK silently ignores, so it throws instead.
203
+ */
204
+ export const toAdkToolConfirmationReply = (
205
+ { approvalId, approved }: RespondToToolApprovalOptions,
206
+ approvals: ReadonlyMap<string, AdkToolApproval>,
207
+ ): AdkMessage & { type: "tool" } => {
208
+ const approval = approvals.get(approvalId);
209
+ if (approval === undefined || approval.approved !== undefined)
210
+ throw new Error(
211
+ `No pending ADK tool confirmation for approval id "${approvalId}"`,
212
+ );
213
+
214
+ return toAdkConfirmationReply(approvalId, approved);
215
+ };
@@ -2,6 +2,10 @@
2
2
 
3
3
  import type { ToolCallMessagePart } from "@assistant-ui/core";
4
4
  import type { useExternalMessageConverter } from "@assistant-ui/core/react";
5
+ import {
6
+ ADK_REQUEST_CONFIRMATION,
7
+ type AdkToolApproval,
8
+ } from "./adkToolApproval";
5
9
  import type { AdkMessage, AdkMessageContentPart } from "./types";
6
10
 
7
11
  type ContentPart =
@@ -81,50 +85,75 @@ const contentToParts = (
81
85
  .filter((p): p is NonNullable<typeof p> => p !== null);
82
86
  };
83
87
 
84
- export const convertAdkMessage: useExternalMessageConverter.Callback<
85
- AdkMessage
86
- > = (message) => {
87
- switch (message.type) {
88
- case "human":
89
- return {
90
- role: "user",
91
- id: message.id,
92
- content: contentToParts(message.content, "user"),
93
- };
88
+ const EMPTY_APPROVALS: ReadonlyMap<string, AdkToolApproval> = new Map();
94
89
 
95
- case "ai": {
96
- const toolCallParts: ToolCallMessagePart[] =
97
- message.tool_calls?.map((tc) => ({
98
- type: "tool-call",
99
- toolCallId: tc.id,
100
- toolName: tc.name,
101
- args: tc.args,
102
- argsText: tc.argsText ?? JSON.stringify(tc.args),
103
- })) ?? [];
90
+ export const createAdkMessageConverter =
91
+ (
92
+ approvals: ReadonlyMap<string, AdkToolApproval>,
93
+ ): useExternalMessageConverter.Callback<AdkMessage> =>
94
+ (message) => {
95
+ switch (message.type) {
96
+ case "human":
97
+ return {
98
+ role: "user",
99
+ id: message.id,
100
+ content: contentToParts(message.content, "user"),
101
+ };
104
102
 
105
- return {
106
- role: "assistant",
107
- id: message.id,
108
- content: [
109
- ...contentToParts(message.content, "assistant"),
110
- ...toolCallParts,
111
- ],
112
- ...(message.status && { status: message.status }),
113
- ...(message.author && {
114
- metadata: {
115
- custom: { author: message.author, branch: message.branch },
116
- },
117
- }),
118
- };
103
+ case "ai": {
104
+ const toolCallParts: ToolCallMessagePart[] =
105
+ message.tool_calls?.map((tc) => {
106
+ const approval = approvals.get(tc.id);
107
+ return {
108
+ type: "tool-call",
109
+ toolCallId: tc.id,
110
+ toolName: tc.name,
111
+ args: tc.args,
112
+ argsText: tc.argsText ?? JSON.stringify(tc.args),
113
+ ...(approval && { approval }),
114
+ };
115
+ }) ?? [];
116
+
117
+ return {
118
+ role: "assistant",
119
+ id: message.id,
120
+ content: [
121
+ ...contentToParts(message.content, "assistant"),
122
+ ...toolCallParts,
123
+ ],
124
+ ...(message.status && { status: message.status }),
125
+ ...(message.author && {
126
+ metadata: {
127
+ custom: { author: message.author, branch: message.branch },
128
+ },
129
+ }),
130
+ };
131
+ }
132
+
133
+ case "tool": {
134
+ // A confirmation reply ADK could not read leaves its gate undecided.
135
+ // Any result settles the tool call in core, so the reply is dropped
136
+ // here to keep the gate requiring action and answerable again. Only a
137
+ // reply to the confirmation itself is dropped: the gated call carries
138
+ // the same approval, and its own result is the agent's real output.
139
+ const approval = approvals.get(message.tool_call_id);
140
+ if (
141
+ message.name === ADK_REQUEST_CONFIRMATION &&
142
+ approval !== undefined &&
143
+ approval.approved === undefined
144
+ )
145
+ return [];
146
+
147
+ return {
148
+ role: "tool",
149
+ toolCallId: message.tool_call_id,
150
+ toolName: message.name,
151
+ result: message.content,
152
+ isError: message.status === "error",
153
+ };
154
+ }
119
155
  }
156
+ };
120
157
 
121
- case "tool":
122
- return {
123
- role: "tool",
124
- toolCallId: message.tool_call_id,
125
- toolName: message.name,
126
- result: message.content,
127
- isError: message.status === "error",
128
- };
129
- }
130
- };
158
+ export const convertAdkMessage: useExternalMessageConverter.Callback<AdkMessage> =
159
+ createAdkMessageConverter(EMPTY_APPROVALS);