@assistant-ui/react-google-adk 0.0.5 → 0.0.7

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.
@@ -166,7 +166,7 @@ export class AdkEventAccumulator {
166
166
  branch?: string | undefined;
167
167
  } = {};
168
168
  private lastTransferToAgent: string | undefined;
169
- private pendingLongRunningToolIds: string[] = [];
169
+ private pendingLongRunningToolIds = new Set<string>();
170
170
  private toolConfirmations: AdkToolConfirmation[] = [];
171
171
  private authRequests: AdkAuthRequest[] = [];
172
172
  private escalated = false;
@@ -202,9 +202,13 @@ export class AdkEventAccumulator {
202
202
  this.lastTransferToAgent = event.actions.transferToAgent;
203
203
  }
204
204
 
205
- // Track long-running tool IDs
205
+ // Track long-running tool IDs. Accumulate across events so that multiple
206
+ // HITL interrupts emitted in the same turn are all tracked — a
207
+ // single-event replacement would drop earlier ids.
206
208
  if (event.longRunningToolIds?.length) {
207
- this.pendingLongRunningToolIds = event.longRunningToolIds;
209
+ for (const id of event.longRunningToolIds) {
210
+ this.pendingLongRunningToolIds.add(id);
211
+ }
208
212
  }
209
213
 
210
214
  // Track tool confirmations from actions
@@ -284,6 +288,41 @@ export class AdkEventAccumulator {
284
288
  return this.getMessages();
285
289
  }
286
290
 
291
+ // User-authored events → create human message, not AI.
292
+ // Without this, user events fall through to processPart →
293
+ // getOrCreateAiMessage, producing type:"ai" messages that
294
+ // convertAdkMessage maps to role:"assistant".
295
+ if (event.author === "user") {
296
+ this.finalizeCurrentMessage();
297
+ const humanParts: AdkMessageContentPart[] = [];
298
+ for (const part of parts) {
299
+ if (part.text != null && !part.thought) {
300
+ humanParts.push({ type: "text", text: part.text });
301
+ } else if (part.inlineData) {
302
+ humanParts.push({
303
+ type: "image",
304
+ mimeType: part.inlineData.mimeType,
305
+ data: part.inlineData.data,
306
+ });
307
+ } else if (part.fileData) {
308
+ humanParts.push({
309
+ type: "image_url",
310
+ url: part.fileData.fileUri,
311
+ });
312
+ }
313
+ }
314
+ if (humanParts.length > 0) {
315
+ const id = event.id ?? uuidv4();
316
+ const first = humanParts[0];
317
+ const content: string | AdkMessageContentPart[] =
318
+ humanParts.length === 1 && first?.type === "text"
319
+ ? first.text
320
+ : humanParts;
321
+ this.messagesMap.set(id, { id, type: "human", content });
322
+ }
323
+ return this.getMessages();
324
+ }
325
+
287
326
  // If author changed, finalize previous message
288
327
  if (this.currentMessageId && event.author && event.author !== "user") {
289
328
  const current = this.messagesMap.get(this.currentMessageId);
@@ -302,7 +341,13 @@ export class AdkEventAccumulator {
302
341
  // Check isFinalResponse (can be true even for partial events via skipSummarization/longRunningToolIds)
303
342
  if (isFinalResponse(event) && this.currentMessageId) {
304
343
  const msg = this.messagesMap.get(this.currentMessageId);
305
- if (msg && msg.type === "ai" && !msg.status) {
344
+ // Skip manual "complete" when longRunningToolIds is the sole reason for
345
+ // isFinalResponse — let auto-status apply requires-action for pending tool calls.
346
+ const isHitlOnly =
347
+ event.longRunningToolIds &&
348
+ event.longRunningToolIds.length > 0 &&
349
+ !event.actions?.skipSummarization;
350
+ if (msg && msg.type === "ai" && !msg.status && !isHitlOnly) {
306
351
  const status = finishReasonToStatus(event.finishReason);
307
352
  const updated: InProgressMessage = {
308
353
  ...msg,
@@ -126,7 +126,7 @@ describe("createAdkSessionAdapter - initialize", () => {
126
126
  });
127
127
  expect(JSON.parse(init?.body as string)).toEqual({});
128
128
  expect(result).toMatchObject({
129
- remoteId: "thread-1",
129
+ remoteId: "new-session-1",
130
130
  externalId: "new-session-1",
131
131
  });
132
132
  });
@@ -1,4 +1,4 @@
1
- import { AssistantStream, AssistantStreamChunk } from "assistant-stream";
1
+ import type { AssistantStream, AssistantStreamChunk } from "assistant-stream";
2
2
  import type {
3
3
  RemoteThreadInitializeResponse,
4
4
  RemoteThreadListAdapter,
@@ -112,7 +112,7 @@ export function createAdkSessionAdapter(
112
112
  },
113
113
 
114
114
  async initialize(
115
- threadId: string,
115
+ _threadId: string,
116
116
  ): Promise<RemoteThreadInitializeResponse> {
117
117
  const headers = await getHeaders();
118
118
  const res = await fetch(baseUrl, {
@@ -124,7 +124,7 @@ export function createAdkSessionAdapter(
124
124
  throw new Error(`Failed to create session: ${res.status}`);
125
125
  }
126
126
  const session = (await res.json()) as { id: string };
127
- return { remoteId: threadId, externalId: session.id };
127
+ return { remoteId: session.id, externalId: session.id };
128
128
  },
129
129
 
130
130
  async delete(remoteId: string): Promise<void> {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { ToolCallMessagePart } from "@assistant-ui/core";
4
- import { useExternalMessageConverter } from "@assistant-ui/core/react";
4
+ import type { useExternalMessageConverter } from "@assistant-ui/core/react";
5
5
  import type { AdkMessage, AdkMessageContentPart } from "./types";
6
6
 
7
7
  type ContentPart =
package/src/hooks.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useAui, useAuiState } from "@assistant-ui/store";
4
4
  import { v4 as uuidv4 } from "uuid";
5
+ import type { ReadonlyJSONValue } from "assistant-stream/utils";
5
6
  import type {
6
7
  AdkMessage,
7
8
  AdkSendMessageConfig,
@@ -126,7 +127,11 @@ export const useAdkMessageMetadata = () => {
126
127
  /** Returns a function to confirm or deny a pending tool confirmation. */
127
128
  export const useAdkConfirmTool = () => {
128
129
  const aui = useAui();
129
- return (toolCallId: string, confirmed: boolean, payload?: unknown) => {
130
+ return (
131
+ toolCallId: string,
132
+ confirmed: boolean,
133
+ payload?: ReadonlyJSONValue,
134
+ ) => {
130
135
  const extras = aui.thread().getState().extras;
131
136
  const { send } = asAdkRuntimeExtras(extras);
132
137
  return send(
@@ -170,6 +175,28 @@ export const useAdkSubmitAuth = () => {
170
175
  };
171
176
  };
172
177
 
178
+ /** Returns a function to submit the user's answer for a pending `adk_request_input` HITL interrupt. */
179
+ export const useAdkSubmitInput = () => {
180
+ const aui = useAui();
181
+ return (toolCallId: string, result: ReadonlyJSONValue) => {
182
+ const extras = aui.thread().getState().extras;
183
+ const { send } = asAdkRuntimeExtras(extras);
184
+ return send(
185
+ [
186
+ {
187
+ id: uuidv4(),
188
+ type: "tool",
189
+ tool_call_id: toolCallId,
190
+ name: "adk_request_input",
191
+ content: JSON.stringify({ result }),
192
+ status: "success",
193
+ },
194
+ ],
195
+ {},
196
+ );
197
+ };
198
+ };
199
+
173
200
  // ── State prefix helpers ──
174
201
 
175
202
  const APP_PREFIX = "app:";
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  useAdkMessageMetadata,
61
61
  useAdkConfirmTool,
62
62
  useAdkSubmitAuth,
63
+ useAdkSubmitInput,
63
64
  useAdkAppState,
64
65
  useAdkUserState,
65
66
  useAdkTempState,
@@ -64,6 +64,22 @@ export const useAdkMessages = ({
64
64
  _setMessages(msgs);
65
65
  }, []);
66
66
 
67
+ // Replace the message list AND reset derived per-turn HITL state.
68
+ // Used by truncation paths (edit, reload, load) so that stale interrupt
69
+ // markers and per-message metadata from the removed messages don't leak
70
+ // into the next turn.
71
+ const replaceMessages = useCallback(
72
+ (msgs: AdkMessage[]) => {
73
+ setMessagesImmediate(msgs);
74
+ setLongRunningToolIds([]);
75
+ setToolConfirmations([]);
76
+ setAuthRequests([]);
77
+ setEscalated(false);
78
+ setMessageMetadata(new Map());
79
+ },
80
+ [setMessagesImmediate],
81
+ );
82
+
67
83
  const abortControllerRef = useRef<AbortController | null>(null);
68
84
 
69
85
  const { onError, onCustomEvent, onAgentTransfer } = useMemo(
@@ -180,6 +196,7 @@ export const useAdkMessages = ({
180
196
  sendMessage,
181
197
  cancel,
182
198
  setMessages: setMessagesImmediate,
199
+ replaceMessages,
183
200
  };
184
201
  };
185
202
 
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getPendingCancellations, getPendingToolCalls } from "./useAdkRuntime";
3
+ import type { AdkMessage } from "./types";
4
+
5
+ const aiWithToolCalls = (
6
+ id: string,
7
+ toolCalls: Array<{ id: string; name: string }>,
8
+ ): AdkMessage => ({
9
+ id,
10
+ type: "ai",
11
+ content: [],
12
+ tool_calls: toolCalls.map((tc) => ({
13
+ id: tc.id,
14
+ name: tc.name,
15
+ args: {},
16
+ argsText: "{}",
17
+ })),
18
+ });
19
+
20
+ const toolResponse = (
21
+ id: string,
22
+ toolCallId: string,
23
+ name: string,
24
+ ): AdkMessage => ({
25
+ id,
26
+ type: "tool",
27
+ tool_call_id: toolCallId,
28
+ name,
29
+ content: JSON.stringify({ ok: true }),
30
+ status: "success",
31
+ });
32
+
33
+ describe("getPendingToolCalls", () => {
34
+ it("returns tool calls without matching tool responses", () => {
35
+ const messages: AdkMessage[] = [
36
+ aiWithToolCalls("ai-1", [
37
+ { id: "tc-1", name: "tool_a" },
38
+ { id: "tc-2", name: "tool_b" },
39
+ ]),
40
+ ];
41
+ expect(getPendingToolCalls(messages)).toEqual([
42
+ { id: "tc-1", name: "tool_a", args: {}, argsText: "{}" },
43
+ { id: "tc-2", name: "tool_b", args: {}, argsText: "{}" },
44
+ ]);
45
+ });
46
+
47
+ it("excludes tool calls that have a matching tool response", () => {
48
+ const messages: AdkMessage[] = [
49
+ aiWithToolCalls("ai-1", [
50
+ { id: "tc-1", name: "tool_a" },
51
+ { id: "tc-2", name: "tool_b" },
52
+ ]),
53
+ toolResponse("t-1", "tc-1", "tool_a"),
54
+ ];
55
+ expect(getPendingToolCalls(messages)).toEqual([
56
+ { id: "tc-2", name: "tool_b", args: {}, argsText: "{}" },
57
+ ]);
58
+ });
59
+
60
+ it("returns empty for threads with no ai messages", () => {
61
+ expect(getPendingToolCalls([])).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("getPendingCancellations", () => {
66
+ it("emits a {cancelled:true} tool message for every pending tool call", () => {
67
+ const messages: AdkMessage[] = [
68
+ aiWithToolCalls("ai-1", [{ id: "tc-1", name: "tool_a" }]),
69
+ ];
70
+ const result = getPendingCancellations(messages, []);
71
+ expect(result).toHaveLength(1);
72
+ expect(result[0]).toMatchObject({
73
+ type: "tool",
74
+ name: "tool_a",
75
+ tool_call_id: "tc-1",
76
+ content: JSON.stringify({ cancelled: true }),
77
+ status: "error",
78
+ });
79
+ });
80
+
81
+ it("skips tool calls whose id is in longRunningToolIds", () => {
82
+ const messages: AdkMessage[] = [
83
+ aiWithToolCalls("ai-1", [
84
+ { id: "lrt-1", name: "adk_request_input" },
85
+ { id: "tc-2", name: "regular_tool" },
86
+ ]),
87
+ ];
88
+ const result = getPendingCancellations(messages, ["lrt-1"]);
89
+ expect(result).toHaveLength(1);
90
+ expect(result[0]).toMatchObject({
91
+ type: "tool",
92
+ name: "regular_tool",
93
+ tool_call_id: "tc-2",
94
+ });
95
+ });
96
+
97
+ it("skips all three HITL interrupt types", () => {
98
+ const messages: AdkMessage[] = [
99
+ aiWithToolCalls("ai-1", [
100
+ { id: "lrt-1", name: "adk_request_input" },
101
+ { id: "lrt-2", name: "adk_request_confirmation" },
102
+ { id: "lrt-3", name: "adk_request_credential" },
103
+ ]),
104
+ ];
105
+ const result = getPendingCancellations(messages, [
106
+ "lrt-1",
107
+ "lrt-2",
108
+ "lrt-3",
109
+ ]);
110
+ expect(result).toEqual([]);
111
+ });
112
+
113
+ it("returns empty when no tool calls are pending", () => {
114
+ const messages: AdkMessage[] = [
115
+ aiWithToolCalls("ai-1", [{ id: "tc-1", name: "tool_a" }]),
116
+ toolResponse("t-1", "tc-1", "tool_a"),
117
+ ];
118
+ expect(getPendingCancellations(messages, [])).toEqual([]);
119
+ });
120
+
121
+ it("cancels regular tool calls even when HITL interrupts are resolved", () => {
122
+ const messages: AdkMessage[] = [
123
+ aiWithToolCalls("ai-1", [
124
+ { id: "lrt-1", name: "adk_request_input" },
125
+ { id: "tc-2", name: "regular_tool" },
126
+ ]),
127
+ toolResponse("t-1", "lrt-1", "adk_request_input"),
128
+ ];
129
+ // lrt-1 lingering in longRunningToolIds is harmless because
130
+ // getPendingToolCalls already excluded it via the tool response.
131
+ const result = getPendingCancellations(messages, ["lrt-1"]);
132
+ expect(result).toHaveLength(1);
133
+ expect(result[0]).toMatchObject({
134
+ tool_call_id: "tc-2",
135
+ name: "regular_tool",
136
+ });
137
+ });
138
+ });
@@ -53,12 +53,13 @@ const getMessageContent = (msg: AppendMessage) => {
53
53
  case "tool-call":
54
54
  throw new Error("Tool call appends are not supported.");
55
55
 
56
- default:
56
+ default: {
57
57
  const _exhaustiveCheck: "reasoning" | "source" | "audio" | "data" =
58
58
  type;
59
59
  throw new Error(
60
60
  `Unsupported append message part type: ${_exhaustiveCheck}`,
61
61
  );
62
+ }
62
63
  }
63
64
  });
64
65
 
@@ -69,7 +70,8 @@ const getMessageContent = (msg: AppendMessage) => {
69
70
  return content;
70
71
  };
71
72
 
72
- const getPendingToolCalls = (messages: AdkMessage[]) => {
73
+ /** @internal — exported for unit tests. */
74
+ export const getPendingToolCalls = (messages: AdkMessage[]) => {
73
75
  const pending = new Map<string, { id: string; name: string }>();
74
76
  for (const msg of messages) {
75
77
  if (msg.type === "ai" && msg.tool_calls) {
@@ -84,6 +86,35 @@ const getPendingToolCalls = (messages: AdkMessage[]) => {
84
86
  return [...pending.values()];
85
87
  };
86
88
 
89
+ /**
90
+ * @internal — exported for unit tests.
91
+ *
92
+ * Returns `{cancelled: true}` tool responses for pending tool calls when the
93
+ * user sends a new turn, EXCEPT for HITL interrupts marked via
94
+ * `long_running_tool_ids` (`adk_request_input`, `adk_request_confirmation`,
95
+ * `adk_request_credential`). Those must be answered through a dedicated tool
96
+ * UI + submit helper, not auto-cancelled.
97
+ */
98
+ export const getPendingCancellations = (
99
+ messages: AdkMessage[],
100
+ longRunningToolIds: readonly string[],
101
+ ): Array<AdkMessage & { type: "tool" }> => {
102
+ const longRunningSet = new Set(longRunningToolIds);
103
+ return getPendingToolCalls(messages)
104
+ .filter((t) => !longRunningSet.has(t.id))
105
+ .map(
106
+ (t) =>
107
+ ({
108
+ id: uuidv4(),
109
+ type: "tool",
110
+ name: t.name,
111
+ tool_call_id: t.id,
112
+ content: JSON.stringify({ cancelled: true }),
113
+ status: "error",
114
+ }) satisfies AdkMessage & { type: "tool" },
115
+ );
116
+ };
117
+
87
118
  const truncateAdkMessages = (
88
119
  threadMessages: readonly ThreadMessage[],
89
120
  parentId: string | null,
@@ -140,6 +171,7 @@ const useAdkRuntimeImpl = ({
140
171
  getCheckpointId,
141
172
  eventHandlers,
142
173
  }: UseAdkRuntimeOptions) => {
174
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
143
175
  const aui = useAui();
144
176
  const {
145
177
  messages,
@@ -153,13 +185,16 @@ const useAdkRuntimeImpl = ({
153
185
  messageMetadata,
154
186
  sendMessage,
155
187
  cancel,
156
- setMessages,
188
+ replaceMessages,
189
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
157
190
  } = useAdkMessages({
158
191
  stream,
159
192
  ...(eventHandlers && { eventHandlers }),
160
193
  });
161
194
 
195
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
162
196
  const [isRunning, setIsRunning] = useState(false);
197
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
163
198
  const [toolStatuses, setToolStatuses] = useState<
164
199
  Record<string, ToolExecutionStatus>
165
200
  >({});
@@ -180,21 +215,25 @@ const useAdkRuntimeImpl = ({
180
215
  }
181
216
  };
182
217
 
218
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
183
219
  const threadMessages = useExternalMessageConverter({
184
220
  callback: convertAdkMessage,
185
221
  messages,
186
222
  isRunning: effectiveIsRunning,
187
223
  });
188
224
 
225
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
189
226
  const threadMessagesRef = useRef(threadMessages);
190
227
  threadMessagesRef.current = threadMessages;
191
228
 
229
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
192
230
  const [runtimeRef] = useState(() => ({
193
231
  get current() {
194
232
  return runtime;
195
233
  },
196
234
  }));
197
235
 
236
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
198
237
  const toolInvocations = useToolInvocations({
199
238
  state: { messages: threadMessages, isRunning: effectiveIsRunning },
200
239
  getTools: () => runtimeRef.current.thread.getModelContext().tools,
@@ -218,6 +257,7 @@ const useAdkRuntimeImpl = ({
218
257
  setToolStatuses,
219
258
  });
220
259
 
260
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
221
261
  const runtime = useExternalStoreRuntime({
222
262
  isRunning: effectiveIsRunning,
223
263
  messages: threadMessages,
@@ -239,17 +279,7 @@ const useAdkRuntimeImpl = ({
239
279
 
240
280
  const cancellations =
241
281
  autoCancelPendingToolCalls !== false
242
- ? getPendingToolCalls(messages).map(
243
- (t) =>
244
- ({
245
- id: uuidv4(),
246
- type: "tool",
247
- name: t.name,
248
- tool_call_id: t.id,
249
- content: JSON.stringify({ cancelled: true }),
250
- status: "error",
251
- }) satisfies AdkMessage & { type: "tool" },
252
- )
282
+ ? getPendingCancellations(messages, longRunningToolIds)
253
283
  : [];
254
284
 
255
285
  return handleSendMessage(
@@ -271,7 +301,7 @@ const useAdkRuntimeImpl = ({
271
301
  threadMessagesRef.current,
272
302
  msg.parentId,
273
303
  );
274
- setMessages(truncated);
304
+ replaceMessages(truncated);
275
305
  const externalId = aui.threadListItem().getState().externalId;
276
306
  const checkpointId = externalId
277
307
  ? await getCheckpointId(externalId, truncated)
@@ -298,7 +328,7 @@ const useAdkRuntimeImpl = ({
298
328
  threadMessagesRef.current,
299
329
  parentId,
300
330
  );
301
- setMessages(truncated);
331
+ replaceMessages(truncated);
302
332
  const externalId = aui.threadListItem().getState().externalId;
303
333
  const checkpointId = externalId
304
334
  ? await getCheckpointId(externalId, truncated)
@@ -342,11 +372,14 @@ const useAdkRuntimeImpl = ({
342
372
  });
343
373
 
344
374
  {
375
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
345
376
  const loadRef = useRef(load);
377
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
346
378
  useEffect(() => {
347
379
  loadRef.current = load;
348
380
  });
349
381
 
382
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
350
383
  useEffect(() => {
351
384
  const loadFn = loadRef.current;
352
385
  if (!loadFn) return;
@@ -356,13 +389,13 @@ const useAdkRuntimeImpl = ({
356
389
 
357
390
  loadFn(externalId).then(
358
391
  ({ messages: msgs }) => {
359
- setMessages(msgs);
392
+ replaceMessages(msgs);
360
393
  },
361
394
  (e) => {
362
395
  console.warn("Failed to load ADK session:", e);
363
396
  },
364
397
  );
365
- }, [aui, setMessages]);
398
+ }, [aui, replaceMessages]);
366
399
  }
367
400
 
368
401
  return runtime;
@@ -390,6 +423,7 @@ export const useAdkRuntime = ({
390
423
 
391
424
  return useRemoteThreadListRuntime({
392
425
  runtimeHook: function RuntimeHook() {
426
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
393
427
  return useAdkRuntimeImpl(options);
394
428
  },
395
429
  adapter,