@assistant-ui/react-google-adk 0.0.20 → 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.
@@ -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) =>
@@ -26,32 +26,66 @@ describe("convertAdkMessage - human messages", () => {
26
26
  });
27
27
  });
28
28
 
29
- it("restores an audio/mp3 file part as an audio message part", () => {
29
+ it("restores a file_url part as a file part stamped with sourceType url", () => {
30
30
  const msg: AdkMessage = {
31
31
  id: "m1",
32
32
  type: "human",
33
- content: [{ type: "file", mimeType: "audio/mp3", data: "QUJD" }],
33
+ content: [
34
+ {
35
+ type: "file_url",
36
+ url: "gs://bucket/report.pdf",
37
+ mimeType: "application/pdf",
38
+ },
39
+ ],
34
40
  };
35
41
  const result = convertAdkMessage(msg, {});
36
42
  expect(result).toMatchObject({
37
43
  role: "user",
38
- content: [{ type: "audio", audio: { data: "QUJD", format: "mp3" } }],
44
+ content: [
45
+ {
46
+ type: "file",
47
+ data: "gs://bucket/report.pdf",
48
+ mimeType: "application/pdf",
49
+ sourceType: "url",
50
+ },
51
+ ],
39
52
  });
40
53
  });
41
54
 
42
- it("restores an audio/wav file part as an audio message part", () => {
55
+ it("falls back to application/octet-stream for file_url parts without mimeType", () => {
43
56
  const msg: AdkMessage = {
44
57
  id: "m1",
45
58
  type: "human",
46
- content: [{ type: "file", mimeType: "audio/wav", data: "QUJD" }],
59
+ content: [{ type: "file_url", url: "gs://bucket/blob" }],
47
60
  };
48
61
  const result = convertAdkMessage(msg, {});
49
62
  expect(result).toMatchObject({
50
63
  role: "user",
51
- content: [{ type: "audio", audio: { data: "QUJD", format: "wav" } }],
64
+ content: [
65
+ {
66
+ type: "file",
67
+ data: "gs://bucket/blob",
68
+ mimeType: "application/octet-stream",
69
+ sourceType: "url",
70
+ },
71
+ ],
52
72
  });
53
73
  });
54
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
+
55
89
  it("keeps attachment-derived audio file parts (with filename) as file parts", () => {
56
90
  const msg: AdkMessage = {
57
91
  id: "m1",
@@ -8,8 +8,13 @@ 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 }
12
- | { type: "audio"; audio: { data: string; format: "mp3" | "wav" } }
11
+ | {
12
+ type: "file";
13
+ data: string;
14
+ mimeType: string;
15
+ filename?: string;
16
+ sourceType?: "url";
17
+ }
13
18
  | { type: "data"; name: string; data: unknown };
14
19
 
15
20
  const contentToParts = (
@@ -33,26 +38,22 @@ const contentToParts = (
33
38
  };
34
39
  case "image_url":
35
40
  return { type: "image", image: part.url };
36
- case "file": {
37
- const format =
38
- role === "user" && part.filename == null
39
- ? part.mimeType === "audio/wav"
40
- ? ("wav" as const)
41
- : part.mimeType === "audio/mp3"
42
- ? ("mp3" as const)
43
- : null
44
- : null;
45
- if (format) {
46
- return { type: "audio", audio: { data: part.data, format } };
47
- }
41
+ case "file":
48
42
  return {
49
43
  type: "file",
50
44
  data: part.data,
51
45
  mimeType: part.mimeType,
52
46
  ...(part.filename != null && { filename: part.filename }),
53
47
  };
54
- }
55
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
+ }
56
57
  return {
57
58
  type: "data",
58
59
  name: "file_url",
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
 
@@ -0,0 +1,336 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
4
+ import { type FC, type ReactNode } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
7
+ import type {
8
+ AssistantRuntime,
9
+ RemoteThreadListAdapter,
10
+ } from "@assistant-ui/core";
11
+ import { useAui } from "@assistant-ui/store";
12
+ import { useAdkRuntime } from "./useAdkRuntime";
13
+ import type { AdkMessage, AdkThreadSnapshot } from "./types";
14
+
15
+ const deferred = <T,>() => {
16
+ let resolve!: (value: T) => void;
17
+ let reject!: (reason: unknown) => void;
18
+ const promise = new Promise<T>((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ return { promise, resolve, reject };
23
+ };
24
+
25
+ const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
26
+ list: vi.fn(async () => ({
27
+ threads: [
28
+ {
29
+ status: "regular" as const,
30
+ remoteId: "adk-1",
31
+ externalId: "adk-1",
32
+ title: "Existing ADK session",
33
+ },
34
+ ],
35
+ })),
36
+ initialize: vi.fn(async () => ({
37
+ remoteId: "adk-1",
38
+ externalId: "adk-1",
39
+ })),
40
+ rename: vi.fn(async () => {}),
41
+ archive: vi.fn(async () => {}),
42
+ unarchive: vi.fn(async () => {}),
43
+ delete: vi.fn(async () => {}),
44
+ generateTitle: vi.fn(async () => new ReadableStream() as never),
45
+ fetch: vi.fn(async () => ({
46
+ status: "regular" as const,
47
+ remoteId: "adk-1",
48
+ externalId: "adk-1",
49
+ title: "Existing ADK session",
50
+ })),
51
+ });
52
+
53
+ const wrapperFactory = (runtime: AssistantRuntime) => {
54
+ const Wrapper = ({ children }: { children: ReactNode }) => (
55
+ <AssistantRuntimeProvider runtime={runtime}>
56
+ {children}
57
+ </AssistantRuntimeProvider>
58
+ );
59
+ Wrapper.displayName = "TestWrapper";
60
+ return Wrapper;
61
+ };
62
+
63
+ const aiMessage = (id: string, text: string): AdkMessage => ({
64
+ id,
65
+ type: "ai",
66
+ content: [{ type: "text", text }],
67
+ });
68
+
69
+ const renderAdk = async (
70
+ load?: (
71
+ threadId: string,
72
+ options?: { signal?: AbortSignal | undefined },
73
+ ) => Promise<AdkThreadSnapshot>,
74
+ ) => {
75
+ const streamMock = vi.fn(async function* () {});
76
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
77
+
78
+ // the runtime hook's binder mounts inside the provider, so the provider has
79
+ // to be in the same tree as the hook for a thread switch to settle
80
+ const Inner: FC = () => {
81
+ const runtime = useAdkRuntime({
82
+ stream: streamMock as never,
83
+ ...(load ? { load } : {}),
84
+ sessionAdapter: makeThreadListAdapter(),
85
+ });
86
+ capture.runtime = runtime;
87
+ return (
88
+ <AssistantRuntimeProvider runtime={runtime}>
89
+ {null}
90
+ </AssistantRuntimeProvider>
91
+ );
92
+ };
93
+
94
+ let unmount!: () => void;
95
+ await act(async () => {
96
+ ({ unmount } = render(<Inner />));
97
+ });
98
+ await waitFor(() => expect(capture.runtime).not.toBeNull());
99
+
100
+ await act(async () => {
101
+ await capture.runtime!.threads.switchToThread("adk-1");
102
+ });
103
+
104
+ return { capture, streamMock, unmount };
105
+ };
106
+
107
+ describe("useAdkRuntime refetch", () => {
108
+ it("declares the refetch capability only when a load is supplied", async () => {
109
+ const withLoad = await renderAdk(async () => ({ messages: [] }));
110
+ expect(
111
+ withLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
112
+ ).toBe(true);
113
+
114
+ const withoutLoad = await renderAdk();
115
+ expect(
116
+ withoutLoad.capture.runtime!.thread.getState().capabilities.refetchThread,
117
+ ).toBe(false);
118
+ });
119
+
120
+ it("refetches in place, keeping the composer draft and the runtime", async () => {
121
+ let call = 0;
122
+ const load = vi.fn(async () => {
123
+ call++;
124
+ return {
125
+ messages: [aiMessage(`m-${call}`, `load ${call}`)],
126
+ } satisfies AdkThreadSnapshot;
127
+ });
128
+
129
+ const { capture } = await renderAdk(load);
130
+ const wrapper = wrapperFactory(capture.runtime!);
131
+ const { result: auiResult } = renderHook(() => useAui(), { wrapper });
132
+
133
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
134
+ await waitFor(() =>
135
+ expect(
136
+ JSON.stringify(capture.runtime!.thread.getState().messages),
137
+ ).toContain("load 1"),
138
+ );
139
+
140
+ await act(async () => {
141
+ auiResult.current.composer.setText("draft that must survive");
142
+ });
143
+
144
+ await act(async () => {
145
+ await capture.runtime!.threads.reloadMainThread();
146
+ });
147
+
148
+ expect(load).toHaveBeenCalledTimes(2);
149
+ await waitFor(() =>
150
+ expect(
151
+ JSON.stringify(capture.runtime!.thread.getState().messages),
152
+ ).toContain("load 2"),
153
+ );
154
+ expect(auiResult.current.composer.getState().text).toBe(
155
+ "draft that must survive",
156
+ );
157
+ });
158
+
159
+ it("swaps the per-turn state over with the messages", async () => {
160
+ let call = 0;
161
+ const load = vi.fn(async () => {
162
+ call++;
163
+ return call === 1
164
+ ? {
165
+ messages: [aiMessage("m-1", "first")],
166
+ toolConfirmations: [
167
+ { toolCallId: "tc-1", toolName: "search", hint: "before" },
168
+ ],
169
+ }
170
+ : {
171
+ messages: [aiMessage("m-2", "second")],
172
+ toolConfirmations: [
173
+ { toolCallId: "tc-2", toolName: "search", hint: "after" },
174
+ ],
175
+ };
176
+ });
177
+
178
+ const { capture } = await renderAdk(load as never);
179
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
180
+
181
+ await act(async () => {
182
+ await capture.runtime!.threads.reloadMainThread();
183
+ });
184
+
185
+ await waitFor(() =>
186
+ expect(
187
+ JSON.stringify(capture.runtime!.thread.getState().messages),
188
+ ).toContain("second"),
189
+ );
190
+ // the confirmation from the fresh snapshot replaced the earlier one rather
191
+ // than the thread passing through a cleared state
192
+ const extras = capture.runtime!.thread.getState().extras as {
193
+ toolConfirmations: { hint: string }[];
194
+ };
195
+ expect(extras.toolConfirmations).toHaveLength(1);
196
+ expect(extras.toolConfirmations[0]!.hint).toBe("after");
197
+ });
198
+
199
+ it("leaves the thread untouched when the refetch fails, and rejects", async () => {
200
+ let call = 0;
201
+ const load = vi.fn(async () => {
202
+ call++;
203
+ if (call === 1) return { messages: [aiMessage("m-1", "first")] };
204
+ throw new Error("refetch failed");
205
+ });
206
+
207
+ const { capture } = await renderAdk(load as never);
208
+ await waitFor(() =>
209
+ expect(
210
+ JSON.stringify(capture.runtime!.thread.getState().messages),
211
+ ).toContain("first"),
212
+ );
213
+
214
+ await act(async () => {
215
+ await expect(capture.runtime!.threads.reloadMainThread()).rejects.toThrow(
216
+ "refetch failed",
217
+ );
218
+ });
219
+
220
+ expect(
221
+ JSON.stringify(capture.runtime!.thread.getState().messages),
222
+ ).toContain("first");
223
+ });
224
+
225
+ it("defers to an initial load still in flight rather than taking it over", async () => {
226
+ const pending = deferred<AdkThreadSnapshot>();
227
+ let call = 0;
228
+ const load = vi.fn(async () => {
229
+ call++;
230
+ return call === 1
231
+ ? pending.promise
232
+ : { messages: [aiMessage("m-2", "second")] };
233
+ });
234
+
235
+ const streamMock = vi.fn(async function* () {});
236
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
237
+ const Inner: FC = () => {
238
+ const runtime = useAdkRuntime({
239
+ stream: streamMock as never,
240
+ load: load as never,
241
+ sessionAdapter: makeThreadListAdapter(),
242
+ });
243
+ capture.runtime = runtime;
244
+ return (
245
+ <AssistantRuntimeProvider runtime={runtime}>
246
+ {null}
247
+ </AssistantRuntimeProvider>
248
+ );
249
+ };
250
+ await act(async () => {
251
+ render(<Inner />);
252
+ });
253
+ await act(async () => {
254
+ await capture.runtime!.threads.switchToThread("adk-1");
255
+ });
256
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
257
+
258
+ let settled = false;
259
+ act(() => {
260
+ capture.runtime!.threads.reloadMainThread().then(() => {
261
+ settled = true;
262
+ });
263
+ });
264
+
265
+ // the refetch waits on the initial load instead of starting a second one
266
+ expect(load).toHaveBeenCalledTimes(1);
267
+ expect(settled).toBe(false);
268
+
269
+ await act(async () => {
270
+ pending.resolve({ messages: [aiMessage("m-1", "first")] });
271
+ });
272
+ await waitFor(() => expect(settled).toBe(true));
273
+ expect(
274
+ JSON.stringify(capture.runtime!.thread.getState().messages),
275
+ ).toContain("first");
276
+ });
277
+
278
+ it("does not report an aborted load as a failure", async () => {
279
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
280
+ const pending = deferred<AdkThreadSnapshot>();
281
+ let call = 0;
282
+ const load = vi.fn(
283
+ async (_id: string, options?: { signal?: AbortSignal }) => {
284
+ call++;
285
+ if (call === 1) {
286
+ options?.signal?.addEventListener("abort", () =>
287
+ pending.reject(
288
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
289
+ ),
290
+ );
291
+ return pending.promise;
292
+ }
293
+ return { messages: [] };
294
+ },
295
+ );
296
+
297
+ const { unmount } = await renderAdk(load as never);
298
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
299
+
300
+ unmount();
301
+ await act(async () => {
302
+ await new Promise((resolve) => setTimeout(resolve, 0));
303
+ });
304
+
305
+ expect(warn).not.toHaveBeenCalled();
306
+ warn.mockRestore();
307
+ });
308
+
309
+ it("aborts a refetch still in flight when the thread unmounts", async () => {
310
+ const pending = deferred<AdkThreadSnapshot>();
311
+ let call = 0;
312
+ const signals: (AbortSignal | undefined)[] = [];
313
+ const load = vi.fn(
314
+ async (_id: string, options?: { signal?: AbortSignal }) => {
315
+ call++;
316
+ signals.push(options?.signal);
317
+ if (call === 1) return { messages: [] };
318
+ return pending.promise;
319
+ },
320
+ );
321
+
322
+ const { capture, unmount } = await renderAdk(load as never);
323
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
324
+
325
+ act(() => {
326
+ capture.runtime!.threads.reloadMainThread().catch(() => {});
327
+ });
328
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
329
+ expect(signals[1]?.aborted).toBe(false);
330
+
331
+ unmount();
332
+
333
+ expect(signals[1]?.aborted).toBe(true);
334
+ pending.resolve({ messages: [] });
335
+ });
336
+ });