@assistant-ui/react-google-adk 0.0.19 → 0.0.21

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 (45) hide show
  1. package/dist/AdkClient.js +15 -2
  2. package/dist/AdkClient.js.map +1 -1
  3. package/dist/AdkEventAccumulator.d.ts +1 -0
  4. package/dist/AdkEventAccumulator.d.ts.map +1 -1
  5. package/dist/AdkEventAccumulator.js +20 -4
  6. package/dist/AdkEventAccumulator.js.map +1 -1
  7. package/dist/AdkSessionAdapter.d.ts +4 -4
  8. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  9. package/dist/AdkSessionAdapter.js +16 -3
  10. package/dist/AdkSessionAdapter.js.map +1 -1
  11. package/dist/adkExtras.d.ts +1 -1
  12. package/dist/adkExtras.d.ts.map +1 -1
  13. package/dist/adkExtras.js +1 -1
  14. package/dist/adkExtras.js.map +1 -1
  15. package/dist/convertAdkMessages.d.ts.map +1 -1
  16. package/dist/convertAdkMessages.js +18 -11
  17. package/dist/convertAdkMessages.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/types.d.ts +22 -1
  20. package/dist/types.d.ts.map +1 -1
  21. package/dist/types.js.map +1 -1
  22. package/dist/useAdkMessages.d.ts +2 -1
  23. package/dist/useAdkMessages.d.ts.map +1 -1
  24. package/dist/useAdkMessages.js +19 -1
  25. package/dist/useAdkMessages.js.map +1 -1
  26. package/dist/useAdkRuntime.d.ts +10 -4
  27. package/dist/useAdkRuntime.d.ts.map +1 -1
  28. package/dist/useAdkRuntime.js +79 -30
  29. package/dist/useAdkRuntime.js.map +1 -1
  30. package/package.json +7 -4
  31. package/src/AdkClient.test.ts +55 -0
  32. package/src/AdkClient.ts +16 -2
  33. package/src/AdkEventAccumulator.test.ts +127 -0
  34. package/src/AdkEventAccumulator.ts +31 -5
  35. package/src/AdkSessionAdapter.test.ts +43 -0
  36. package/src/AdkSessionAdapter.ts +21 -4
  37. package/src/adkExtras.ts +1 -1
  38. package/src/convertAdkMessages.test.ts +113 -0
  39. package/src/convertAdkMessages.ts +24 -4
  40. package/src/index.ts +1 -0
  41. package/src/types.ts +21 -0
  42. package/src/useAdkMessages.ts +24 -1
  43. package/src/useAdkRuntime.refetch.test.tsx +336 -0
  44. package/src/useAdkRuntime.test.ts +222 -0
  45. package/src/useAdkRuntime.ts +139 -34
package/src/AdkClient.ts CHANGED
@@ -243,10 +243,20 @@ async function* parseSSEResponse(response: Response): AsyncGenerator<AdkEvent> {
243
243
  const decoder = new TextDecoder();
244
244
  const sseDecoder = new SSEEventDecoder({ trailing: "dispatch" });
245
245
 
246
+ let shouldCancel = true;
246
247
  try {
247
248
  while (true) {
248
- const { done, value } = await reader.read();
249
+ let result: ReadableStreamReadResult<Uint8Array>;
250
+ try {
251
+ result = await reader.read();
252
+ } catch (error) {
253
+ shouldCancel = false;
254
+ throw error;
255
+ }
256
+
257
+ const { done, value } = result;
249
258
  if (done) {
259
+ shouldCancel = false;
250
260
  for (const event of sseDecoder.push(decoder.decode())) {
251
261
  yield JSON.parse(event.data) as AdkEvent;
252
262
  }
@@ -263,6 +273,10 @@ async function* parseSSEResponse(response: Response): AsyncGenerator<AdkEvent> {
263
273
  const trailing = sseDecoder.flush();
264
274
  if (trailing !== null) yield JSON.parse(trailing.data) as AdkEvent;
265
275
  } finally {
266
- reader.releaseLock();
276
+ try {
277
+ if (shouldCancel) await reader.cancel().catch(() => undefined);
278
+ } finally {
279
+ reader.releaseLock();
280
+ }
267
281
  }
268
282
  }
@@ -239,6 +239,133 @@ describe("AdkEventAccumulator - function responses", () => {
239
239
  content: JSON.stringify({ results: [] }),
240
240
  });
241
241
  });
242
+
243
+ // A session load replays the stored events through a fresh accumulator.
244
+ it("gives a tool message the same id on every replay of an event", () => {
245
+ const event = makeEvent({
246
+ id: "evt-tool",
247
+ author: "agent",
248
+ content: {
249
+ role: "model",
250
+ parts: [
251
+ {
252
+ functionResponse: {
253
+ name: "search",
254
+ id: "tc-1",
255
+ response: { results: [] },
256
+ },
257
+ },
258
+ ],
259
+ },
260
+ });
261
+
262
+ const first = new AdkEventAccumulator().processEvent(event);
263
+ const second = new AdkEventAccumulator().processEvent(event);
264
+
265
+ expect(first[0]!.id).toBe(second[0]!.id);
266
+ });
267
+
268
+ it("keeps two tool messages from one event distinct", () => {
269
+ const msgs = new AdkEventAccumulator().processEvent(
270
+ makeEvent({
271
+ id: "evt-tool",
272
+ author: "agent",
273
+ content: {
274
+ role: "model",
275
+ parts: [
276
+ {
277
+ functionResponse: {
278
+ name: "search",
279
+ id: "tc-1",
280
+ response: { a: 1 },
281
+ },
282
+ },
283
+ {
284
+ functionResponse: {
285
+ name: "lookup",
286
+ id: "tc-2",
287
+ response: { b: 2 },
288
+ },
289
+ },
290
+ ],
291
+ },
292
+ }),
293
+ );
294
+
295
+ const toolMessages = msgs.filter((m) => m.type === "tool");
296
+ expect(toolMessages).toHaveLength(2);
297
+ expect(toolMessages[0]!.id).not.toBe(toolMessages[1]!.id);
298
+ });
299
+
300
+ it("gives an assistant message the same id on every replay", () => {
301
+ const events = [
302
+ makeTextEvent("Hel", true),
303
+ makeTextEvent("lo", true),
304
+ makeTextEvent("Hello"),
305
+ ];
306
+ const replay = () => {
307
+ const acc = new AdkEventAccumulator();
308
+ let msgs: AdkMessage[] = [];
309
+ for (const event of events) msgs = acc.processEvent(event);
310
+ return msgs;
311
+ };
312
+
313
+ expect(replay().map((m) => m.id)).toEqual(replay().map((m) => m.id));
314
+ });
315
+
316
+ it("keeps two assistant messages opened by one event distinct and stable", () => {
317
+ const event = makeEvent({
318
+ id: "evt-mixed",
319
+ author: "agent",
320
+ content: {
321
+ role: "model",
322
+ parts: [
323
+ { text: "before" },
324
+ {
325
+ functionResponse: {
326
+ name: "search",
327
+ id: "tc-1",
328
+ response: { ok: true },
329
+ },
330
+ },
331
+ { text: "after" },
332
+ ],
333
+ },
334
+ });
335
+
336
+ const first = new AdkEventAccumulator().processEvent(event);
337
+ const second = new AdkEventAccumulator().processEvent(event);
338
+
339
+ const aiIds = first.filter((m) => m.type === "ai").map((m) => m.id);
340
+ expect(aiIds.length).toBeGreaterThan(1);
341
+ expect(new Set(aiIds).size).toBe(aiIds.length);
342
+ expect(first.map((m) => m.id)).toEqual(second.map((m) => m.id));
343
+ });
344
+
345
+ it("keeps two tool messages distinct when the payload omits response ids", () => {
346
+ const event = makeEvent({
347
+ id: "evt-tool",
348
+ author: "agent",
349
+ content: {
350
+ role: "model",
351
+ parts: [
352
+ { functionResponse: { name: "search", response: { a: 1 } } },
353
+ { functionResponse: { name: "lookup", response: { b: 2 } } },
354
+ ],
355
+ },
356
+ });
357
+
358
+ const first = new AdkEventAccumulator()
359
+ .processEvent(event)
360
+ .filter((m) => m.type === "tool");
361
+ const second = new AdkEventAccumulator()
362
+ .processEvent(event)
363
+ .filter((m) => m.type === "tool");
364
+
365
+ expect(first).toHaveLength(2);
366
+ expect(first[0]!.id).not.toBe(first[1]!.id);
367
+ expect(first.map((m) => m.id)).toEqual(second.map((m) => m.id));
368
+ });
242
369
  });
243
370
 
244
371
  describe("AdkEventAccumulator - code execution", () => {
@@ -14,6 +14,23 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
14
14
 
15
15
  type InProgressMessage = AdkMessage & { type: "ai" };
16
16
 
17
+ /**
18
+ * A session load replays the stored events through a fresh accumulator, so a
19
+ * message needs an id derived from the event that carries it rather than one
20
+ * minted per replay. An event with no id of its own has never been through the
21
+ * session and has nothing stable to derive from, so it keeps a generated one.
22
+ *
23
+ * A human message keeps the bare event id it has always had. The other kinds
24
+ * take a suffixed namespace, since one event can carry several of them: a tool
25
+ * message by the index of its part, an assistant message by how many this
26
+ * event has already opened.
27
+ */
28
+ const toolMessageId = (event: AdkEvent, partIndex: number): string =>
29
+ event.id ? `${event.id}:${partIndex}` : uuidv4();
30
+
31
+ const aiMessageId = (event: AdkEvent, ordinal: number): string =>
32
+ event.id ? `${event.id}:ai${ordinal === 0 ? "" : ordinal}` : uuidv4();
33
+
17
34
  const ADK_REQUEST_CONFIRMATION = "adk_request_confirmation";
18
35
  const ADK_REQUEST_CREDENTIAL = "adk_request_credential";
19
36
 
@@ -187,6 +204,9 @@ export class AdkEventAccumulator {
187
204
  private authRequests: AdkAuthRequest[] = [];
188
205
  private escalated = false;
189
206
  private messageMetadataMap = new Map<string, AdkMessageMetadata>();
207
+ // How many assistant messages each event has opened, so a replay of that
208
+ // event opens them with the same ids.
209
+ private aiMessageOrdinals = new Map<string, number>();
190
210
  constructor(initialMessages?: AdkMessage[]) {
191
211
  if (initialMessages) {
192
212
  for (const msg of initialMessages) {
@@ -344,8 +364,8 @@ export class AdkEventAccumulator {
344
364
  }
345
365
  }
346
366
 
347
- for (const part of parts) {
348
- this.processPart(part, event);
367
+ for (const [index, part] of parts.entries()) {
368
+ this.processPart(part, event, index);
349
369
  }
350
370
 
351
371
  // Track per-message metadata (grounding, citation, usage)
@@ -379,7 +399,11 @@ export class AdkEventAccumulator {
379
399
  return this.getMessages();
380
400
  }
381
401
 
382
- private processPart(part: AdkEventPart, event: AdkEvent): void {
402
+ private processPart(
403
+ part: AdkEventPart,
404
+ event: AdkEvent,
405
+ partIndex: number,
406
+ ): void {
383
407
  // Detect special ADK function calls
384
408
  if (part.functionCall && !event.partial) {
385
409
  const name = part.functionCall.name;
@@ -475,7 +499,7 @@ export class AdkEventAccumulator {
475
499
  if (part.functionResponse) {
476
500
  this.finalizeCurrentMessage();
477
501
  const toolMsg: AdkMessage = {
478
- id: uuidv4(),
502
+ id: toolMessageId(event, partIndex),
479
503
  type: "tool",
480
504
  tool_call_id: part.functionResponse.id ?? "",
481
505
  name: part.functionResponse.name,
@@ -559,7 +583,9 @@ export class AdkEventAccumulator {
559
583
  }
560
584
  }
561
585
 
562
- const id = uuidv4();
586
+ const ordinal = this.aiMessageOrdinals.get(event.id ?? "") ?? 0;
587
+ this.aiMessageOrdinals.set(event.id ?? "", ordinal + 1);
588
+ const id = aiMessageId(event, ordinal);
563
589
  const msg: InProgressMessage = {
564
590
  id,
565
591
  type: "ai",
@@ -293,6 +293,49 @@ describe("createAdkSessionAdapter - fetch", () => {
293
293
  // ── load() ──
294
294
 
295
295
  describe("createAdkSessionAdapter - load", () => {
296
+ it("returns the per-turn state the events imply, not just the messages", async () => {
297
+ const session = {
298
+ id: "s1",
299
+ events: [
300
+ {
301
+ id: "e1",
302
+ author: "agent",
303
+ longRunningToolIds: ["tc-1"],
304
+ actions: { stateDelta: { step: 2 }, escalate: true },
305
+ content: {
306
+ role: "model",
307
+ parts: [{ functionCall: { name: "search", id: "tc-1", args: {} } }],
308
+ },
309
+ },
310
+ ],
311
+ };
312
+ mockFetch.mockResolvedValueOnce(
313
+ new Response(JSON.stringify(session), { status: 200 }),
314
+ );
315
+
316
+ const { load } = createAdkSessionAdapter(baseOptions);
317
+ const result = await load("s1");
318
+
319
+ expect(result.longRunningToolIds).toEqual(["tc-1"]);
320
+ expect(result.stateDelta).toEqual({ step: 2 });
321
+ expect(result.messageMetadata).toBeInstanceOf(Map);
322
+ expect(result.toolConfirmations).toEqual([]);
323
+ expect(result.authRequests).toEqual([]);
324
+ });
325
+
326
+ it("passes an abort signal through to the request", async () => {
327
+ mockFetch.mockResolvedValueOnce(
328
+ new Response(JSON.stringify({ id: "s1", events: [] }), { status: 200 }),
329
+ );
330
+ const controller = new AbortController();
331
+
332
+ const { load } = createAdkSessionAdapter(baseOptions);
333
+ await load("s1", { signal: controller.signal });
334
+
335
+ const init = mockFetch.mock.calls[0]![1] as RequestInit;
336
+ expect(init.signal).toBe(controller.signal);
337
+ });
338
+
296
339
  it("reconstructs messages from session events", async () => {
297
340
  const session = {
298
341
  id: "s1",
@@ -6,7 +6,7 @@ import type {
6
6
  RemoteThreadMetadata,
7
7
  } from "@assistant-ui/core";
8
8
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
9
- import type { AdkEvent, AdkMessage } from "./types";
9
+ import type { AdkEvent, AdkMessage, AdkThreadSnapshot } from "./types";
10
10
  import { trimTrailingSlashes } from "./trimTrailingSlashes";
11
11
 
12
12
  export type AdkSessionAdapterOptions = {
@@ -41,7 +41,10 @@ export type AdkArtifactData = {
41
41
 
42
42
  type AdkSessionAdapterResult = {
43
43
  adapter: RemoteThreadListAdapter;
44
- load: (sessionId: string) => Promise<{ messages: AdkMessage[] }>;
44
+ load: (
45
+ sessionId: string,
46
+ options?: { signal?: AbortSignal | undefined },
47
+ ) => Promise<AdkThreadSnapshot>;
45
48
  artifacts: {
46
49
  list: (sessionId: string) => Promise<string[]>;
47
50
  load: (
@@ -302,10 +305,12 @@ export function createAdkSessionAdapter(
302
305
 
303
306
  const load = async (
304
307
  sessionId: string,
305
- ): Promise<{ messages: AdkMessage[] }> => {
308
+ options?: { signal?: AbortSignal | undefined },
309
+ ): Promise<AdkThreadSnapshot> => {
306
310
  const headers = await getHeaders();
307
311
  const res = await fetch(`${baseUrl}/${encodeURIComponent(sessionId)}`, {
308
312
  headers,
313
+ ...(options?.signal ? { signal: options.signal } : {}),
309
314
  });
310
315
  if (!res.ok) {
311
316
  throw new Error(`Failed to load session: ${res.status}`);
@@ -329,7 +334,19 @@ export function createAdkSessionAdapter(
329
334
  for (const event of events) {
330
335
  messages = accumulator.processEvent(event);
331
336
  }
332
- return { messages };
337
+ // The per-turn state rides along, so a refetch can swap the thread over in
338
+ // one commit instead of reconstructing it from the messages alone.
339
+ return {
340
+ messages,
341
+ longRunningToolIds: accumulator.getLongRunningToolIds(),
342
+ toolConfirmations: accumulator.getToolConfirmations(),
343
+ authRequests: accumulator.getAuthRequests(),
344
+ escalated: accumulator.isEscalated(),
345
+ messageMetadata: accumulator.getMessageMetadata(),
346
+ stateDelta: accumulator.getStateDelta(),
347
+ artifactDelta: accumulator.getArtifactDelta(),
348
+ agentInfo: accumulator.getAgentInfo(),
349
+ };
333
350
  };
334
351
 
335
352
  const artifactBaseUrl = (sessionId: string) =>
package/src/adkExtras.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createRuntimeExtras } from "@assistant-ui/core/internal";
1
+ import { createRuntimeExtras } from "@assistant-ui/core/react";
2
2
  import type { AdkRuntimeExtras } from "./types";
3
3
 
4
4
  export const adkExtras = createRuntimeExtras<AdkRuntimeExtras>("useAdkRuntime");
@@ -25,6 +25,106 @@ describe("convertAdkMessage - human messages", () => {
25
25
  content: [{ type: "text", text: "Hello" }],
26
26
  });
27
27
  });
28
+
29
+ it("restores a file_url part as a file part stamped with sourceType url", () => {
30
+ const msg: AdkMessage = {
31
+ id: "m1",
32
+ type: "human",
33
+ content: [
34
+ {
35
+ type: "file_url",
36
+ url: "gs://bucket/report.pdf",
37
+ mimeType: "application/pdf",
38
+ },
39
+ ],
40
+ };
41
+ const result = convertAdkMessage(msg, {});
42
+ expect(result).toMatchObject({
43
+ role: "user",
44
+ content: [
45
+ {
46
+ type: "file",
47
+ data: "gs://bucket/report.pdf",
48
+ mimeType: "application/pdf",
49
+ sourceType: "url",
50
+ },
51
+ ],
52
+ });
53
+ });
54
+
55
+ it("falls back to application/octet-stream for file_url parts without mimeType", () => {
56
+ const msg: AdkMessage = {
57
+ id: "m1",
58
+ type: "human",
59
+ content: [{ type: "file_url", url: "gs://bucket/blob" }],
60
+ };
61
+ const result = convertAdkMessage(msg, {});
62
+ expect(result).toMatchObject({
63
+ role: "user",
64
+ content: [
65
+ {
66
+ type: "file",
67
+ data: "gs://bucket/blob",
68
+ mimeType: "application/octet-stream",
69
+ sourceType: "url",
70
+ },
71
+ ],
72
+ });
73
+ });
74
+
75
+ it("keeps an audio file part as a file part", () => {
76
+ for (const mimeType of ["audio/mp3", "audio/wav"]) {
77
+ const msg: AdkMessage = {
78
+ id: "m1",
79
+ type: "human",
80
+ content: [{ type: "file", mimeType, data: "QUJD" }],
81
+ };
82
+ expect(convertAdkMessage(msg, {})).toMatchObject({
83
+ role: "user",
84
+ content: [{ type: "file", data: "QUJD", mimeType }],
85
+ });
86
+ }
87
+ });
88
+
89
+ it("keeps attachment-derived audio file parts (with filename) as file parts", () => {
90
+ const msg: AdkMessage = {
91
+ id: "m1",
92
+ type: "human",
93
+ content: [
94
+ {
95
+ type: "file",
96
+ mimeType: "audio/wav",
97
+ data: "QUJD",
98
+ filename: "memo.wav",
99
+ },
100
+ ],
101
+ };
102
+ const result = convertAdkMessage(msg, {});
103
+ expect(result).toMatchObject({
104
+ role: "user",
105
+ content: [
106
+ {
107
+ type: "file",
108
+ mimeType: "audio/wav",
109
+ data: "QUJD",
110
+ filename: "memo.wav",
111
+ },
112
+ ],
113
+ });
114
+ });
115
+
116
+ it("keeps file parts with other audio mime types as file parts", () => {
117
+ const msg: AdkMessage = {
118
+ id: "m1",
119
+ type: "human",
120
+ content: [{ type: "file", mimeType: "audio/ogg", data: "QUJD" }],
121
+ };
122
+ const result = convertAdkMessage(msg, {});
123
+ expect(result).toMatchObject({
124
+ role: "user",
125
+ content: [{ type: "file", mimeType: "audio/ogg", data: "QUJD" }],
126
+ });
127
+ });
28
128
  });
29
129
 
30
130
  describe("convertAdkMessage - ai messages", () => {
@@ -78,6 +178,19 @@ describe("convertAdkMessage - ai messages", () => {
78
178
  });
79
179
  });
80
180
 
181
+ it("keeps audio/mp3 file parts as file parts on assistant messages", () => {
182
+ const msg: AdkMessage = {
183
+ id: "m1",
184
+ type: "ai",
185
+ content: [{ type: "file", mimeType: "audio/mp3", data: "QUJD" }],
186
+ };
187
+ const result = convertAdkMessage(msg, {});
188
+ expect(result).toMatchObject({
189
+ role: "assistant",
190
+ content: [{ type: "file", mimeType: "audio/mp3", data: "QUJD" }],
191
+ });
192
+ });
193
+
81
194
  it("converts file content parts to file message parts", () => {
82
195
  const msg: AdkMessage = {
83
196
  id: "m1",
@@ -8,10 +8,19 @@ type ContentPart =
8
8
  | { type: "text"; text: string }
9
9
  | { type: "reasoning"; text: string }
10
10
  | { type: "image"; image: string }
11
- | { type: "file"; data: string; mimeType: string; filename?: string }
11
+ | {
12
+ type: "file";
13
+ data: string;
14
+ mimeType: string;
15
+ filename?: string;
16
+ sourceType?: "url";
17
+ }
12
18
  | { type: "data"; name: string; data: unknown };
13
19
 
14
- const contentToParts = (content: AdkMessage["content"]): ContentPart[] => {
20
+ const contentToParts = (
21
+ content: AdkMessage["content"],
22
+ role: "user" | "assistant",
23
+ ): ContentPart[] => {
15
24
  if (typeof content === "string")
16
25
  return [{ type: "text" as const, text: content }];
17
26
 
@@ -37,6 +46,14 @@ const contentToParts = (content: AdkMessage["content"]): ContentPart[] => {
37
46
  ...(part.filename != null && { filename: part.filename }),
38
47
  };
39
48
  case "file_url":
49
+ if (role === "user") {
50
+ return {
51
+ type: "file",
52
+ data: part.url,
53
+ mimeType: part.mimeType ?? "application/octet-stream",
54
+ sourceType: "url",
55
+ };
56
+ }
40
57
  return {
41
58
  type: "data",
42
59
  name: "file_url",
@@ -72,7 +89,7 @@ export const convertAdkMessage: useExternalMessageConverter.Callback<
72
89
  return {
73
90
  role: "user",
74
91
  id: message.id,
75
- content: contentToParts(message.content),
92
+ content: contentToParts(message.content, "user"),
76
93
  };
77
94
 
78
95
  case "ai": {
@@ -88,7 +105,10 @@ export const convertAdkMessage: useExternalMessageConverter.Callback<
88
105
  return {
89
106
  role: "assistant",
90
107
  id: message.id,
91
- content: [...contentToParts(message.content), ...toolCallParts],
108
+ content: [
109
+ ...contentToParts(message.content, "assistant"),
110
+ ...toolCallParts,
111
+ ],
92
112
  ...(message.status && { status: message.status }),
93
113
  ...(message.author && {
94
114
  metadata: {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type {
9
9
  AdkEventPart,
10
10
  AdkEventActions,
11
11
  AdkMessage,
12
+ AdkThreadSnapshot,
12
13
  AdkMessageContentPart,
13
14
  AdkToolCall,
14
15
  AdkToolConfirmation,
package/src/types.ts CHANGED
@@ -287,3 +287,24 @@ export type AdkRuntimeExtras = {
287
287
  escalated: boolean;
288
288
  messageMetadata: Map<string, AdkMessageMetadata>;
289
289
  };
290
+
291
+ /**
292
+ * What a session load reconstructs. The messages are the whole thread; the
293
+ * rest is the per-turn state the events imply, which a caller that replays
294
+ * them through {@link AdkEventAccumulator} already has. Omitting a field
295
+ * clears it, since a snapshot that cannot describe the state is not evidence
296
+ * that the state survived.
297
+ */
298
+ export type AdkThreadSnapshot = {
299
+ messages: AdkMessage[];
300
+ longRunningToolIds?: string[] | undefined;
301
+ toolConfirmations?: AdkToolConfirmation[] | undefined;
302
+ authRequests?: AdkAuthRequest[] | undefined;
303
+ escalated?: boolean | undefined;
304
+ messageMetadata?: Map<string, AdkMessageMetadata> | undefined;
305
+ stateDelta?: Record<string, unknown> | undefined;
306
+ artifactDelta?: Record<string, number> | undefined;
307
+ agentInfo?:
308
+ | { name?: string | undefined; branch?: string | undefined }
309
+ | undefined;
310
+ };
@@ -11,6 +11,7 @@ import type {
11
11
  AdkStreamCallback,
12
12
  AdkToolConfirmation,
13
13
  AdkAuthRequest,
14
+ AdkThreadSnapshot,
14
15
  OnAdkErrorCallback,
15
16
  OnAdkCustomEventCallback,
16
17
  OnAdkAgentTransferCallback,
@@ -62,8 +63,29 @@ export const useAdkMessages = ({
62
63
  _setMessages(msgs);
63
64
  }, []);
64
65
 
66
+ /**
67
+ * Swap the thread over to a loaded snapshot in one commit. Unlike
68
+ * {@link replaceMessages} this never passes through a cleared state, so a
69
+ * refetch that lands while a confirmation is on screen replaces it rather
70
+ * than blanking it first.
71
+ */
72
+ const applySnapshot = useCallback(
73
+ (snapshot: AdkThreadSnapshot) => {
74
+ setMessagesImmediate(snapshot.messages);
75
+ setLongRunningToolIds(snapshot.longRunningToolIds ?? []);
76
+ setToolConfirmations(snapshot.toolConfirmations ?? []);
77
+ setAuthRequests(snapshot.authRequests ?? []);
78
+ setEscalated(snapshot.escalated ?? false);
79
+ setMessageMetadata(snapshot.messageMetadata ?? new Map());
80
+ setStateDelta(snapshot.stateDelta ?? {});
81
+ setArtifactDelta(snapshot.artifactDelta ?? {});
82
+ setAgentInfo(snapshot.agentInfo ?? {});
83
+ },
84
+ [setMessagesImmediate],
85
+ );
86
+
65
87
  // Replace the message list AND reset derived per-turn HITL state.
66
- // Used by truncation paths (edit, reload, load) so that stale interrupt
88
+ // Used by truncation paths (edit, reload) so that stale interrupt
67
89
  // markers and per-message metadata from the removed messages don't leak
68
90
  // into the next turn.
69
91
  const replaceMessages = useCallback(
@@ -195,6 +217,7 @@ export const useAdkMessages = ({
195
217
  cancel,
196
218
  setMessages: setMessagesImmediate,
197
219
  replaceMessages,
220
+ applySnapshot,
198
221
  };
199
222
  };
200
223