@assistant-ui/react-google-adk 0.0.35 → 0.0.36

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 (65) hide show
  1. package/README.md +12 -2
  2. package/dist/AdkClient.d.ts +4 -0
  3. package/dist/AdkClient.d.ts.map +1 -1
  4. package/dist/AdkClient.js +12 -7
  5. package/dist/AdkClient.js.map +1 -1
  6. package/dist/AdkSessionAdapter.d.ts.map +1 -1
  7. package/dist/AdkSessionAdapter.js +7 -7
  8. package/dist/AdkSessionAdapter.js.map +1 -1
  9. package/dist/AdkThreadController.d.ts +15 -0
  10. package/dist/AdkThreadController.d.ts.map +1 -0
  11. package/dist/AdkThreadController.js +35 -0
  12. package/dist/AdkThreadController.js.map +1 -0
  13. package/dist/adkThreadState.d.ts +54 -0
  14. package/dist/adkThreadState.d.ts.map +1 -0
  15. package/dist/adkThreadState.js +93 -0
  16. package/dist/adkThreadState.js.map +1 -0
  17. package/dist/convertToAdkMessages.js +1 -1
  18. package/dist/convertToAdkMessages.js.map +1 -1
  19. package/dist/sdkIdentity.js +1 -1
  20. package/dist/server/createAdkApiRoute.d.ts +37 -6
  21. package/dist/server/createAdkApiRoute.d.ts.map +1 -1
  22. package/dist/server/createAdkApiRoute.js +55 -5
  23. package/dist/server/createAdkApiRoute.js.map +1 -1
  24. package/dist/server/parseAdkRequest.d.ts +4 -1
  25. package/dist/server/parseAdkRequest.d.ts.map +1 -1
  26. package/dist/server/parseAdkRequest.js +5 -1
  27. package/dist/server/parseAdkRequest.js.map +1 -1
  28. package/dist/useAdkMessages.d.ts +9 -7
  29. package/dist/useAdkMessages.d.ts.map +1 -1
  30. package/dist/useAdkMessages.js +55 -77
  31. package/dist/useAdkMessages.js.map +1 -1
  32. package/dist/useAdkRuntime.d.ts +7 -1
  33. package/dist/useAdkRuntime.d.ts.map +1 -1
  34. package/dist/useAdkRuntime.js +134 -56
  35. package/dist/useAdkRuntime.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/AdkClient.test.ts +78 -2
  38. package/src/AdkClient.ts +24 -6
  39. package/src/AdkSessionAdapter.ts +1 -1
  40. package/src/AdkThreadController.test.ts +90 -0
  41. package/src/AdkThreadController.ts +45 -0
  42. package/src/adkThreadState.test.ts +207 -0
  43. package/src/adkThreadState.ts +124 -0
  44. package/src/convertToAdkMessages.test.ts +19 -0
  45. package/src/convertToAdkMessages.ts +1 -1
  46. package/src/hooks.test.tsx +1 -0
  47. package/src/server/createAdkApiRoute.controls.test.ts +66 -0
  48. package/src/server/createAdkApiRoute.test.ts +282 -0
  49. package/src/server/createAdkApiRoute.ts +119 -11
  50. package/src/server/parseAdkRequest.test.ts +11 -3
  51. package/src/server/parseAdkRequest.ts +7 -1
  52. package/src/useAdkMessages.test.ts +1 -0
  53. package/src/useAdkMessages.ts +61 -96
  54. package/src/useAdkRuntime.cancellation.test.tsx +4 -3
  55. package/src/useAdkRuntime.cloud-options.test.tsx +59 -0
  56. package/src/useAdkRuntime.refetch.test.tsx +548 -4
  57. package/src/useAdkRuntime.replacement.test.tsx +718 -1
  58. package/src/useAdkRuntime.ts +169 -73
  59. package/src/useAdkRuntimeApproval.test.tsx +87 -1
  60. package/dist/raceWithAbortSignal.d.ts +0 -2
  61. package/dist/raceWithAbortSignal.d.ts.map +0 -1
  62. package/dist/raceWithAbortSignal.js +0 -45
  63. package/dist/raceWithAbortSignal.js.map +0 -1
  64. package/src/raceWithAbortSignal.test.ts +0 -73
  65. package/src/raceWithAbortSignal.ts +0 -48
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, render, renderHook, waitFor } from "@testing-library/react";
4
- import { type FC, type ReactNode } from "react";
4
+ import { StrictMode, type FC, type ReactNode } from "react";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
7
7
  import type {
@@ -10,7 +10,7 @@ import type {
10
10
  } from "@assistant-ui/core";
11
11
  import { useAui } from "@assistant-ui/store";
12
12
  import { useAdkRuntime } from "./useAdkRuntime";
13
- import type { AdkMessage, AdkThreadSnapshot } from "./types";
13
+ import type { AdkEvent, AdkMessage, AdkThreadSnapshot } from "./types";
14
14
  import { settleOutsideAct } from "./tests/settleOutsideAct";
15
15
 
16
16
  const deferred = <T,>() => {
@@ -72,8 +72,16 @@ const renderAdk = async (
72
72
  threadId: string,
73
73
  options?: { signal?: AbortSignal | undefined },
74
74
  ) => Promise<AdkThreadSnapshot>,
75
+ getCheckpointId?: (
76
+ threadId: string,
77
+ parentMessages: AdkMessage[],
78
+ ) => Promise<string | null>,
79
+ streamMock = vi.fn(async function* (
80
+ _messages: unknown,
81
+ _config: { checkpointId?: string },
82
+ ): AsyncGenerator<AdkEvent> {}),
83
+ strictMode = false,
75
84
  ) => {
76
- const streamMock = vi.fn(async function* () {});
77
85
  const capture: { runtime: AssistantRuntime | null } = { runtime: null };
78
86
 
79
87
  // the runtime hook's binder mounts inside the provider, so the provider has
@@ -82,6 +90,7 @@ const renderAdk = async (
82
90
  const runtime = useAdkRuntime({
83
91
  stream: streamMock as never,
84
92
  ...(load ? { load } : {}),
93
+ ...(getCheckpointId ? { getCheckpointId } : {}),
85
94
  sessionAdapter: makeThreadListAdapter(),
86
95
  });
87
96
  capture.runtime = runtime;
@@ -94,7 +103,10 @@ const renderAdk = async (
94
103
 
95
104
  let unmount!: () => void;
96
105
  await act(async () => {
97
- ({ unmount } = render(<Inner />));
106
+ const element = <Inner />;
107
+ ({ unmount } = render(
108
+ strictMode ? <StrictMode>{element}</StrictMode> : element,
109
+ ));
98
110
  });
99
111
  await waitFor(() => expect(capture.runtime).not.toBeNull());
100
112
 
@@ -106,6 +118,83 @@ const renderAdk = async (
106
118
  };
107
119
 
108
120
  describe("useAdkRuntime refetch", () => {
121
+ it("cancels pending calls from history when sending during the initial load", async () => {
122
+ const loaded = deferred<AdkThreadSnapshot>();
123
+ const streamMock = vi.fn(async function* (
124
+ _messages: unknown,
125
+ _config: { checkpointId?: string },
126
+ ): AsyncGenerator<AdkEvent> {});
127
+ const { capture } = await renderAdk(
128
+ () => loaded.promise,
129
+ undefined,
130
+ streamMock,
131
+ );
132
+
133
+ act(() => {
134
+ capture.runtime!.thread.append({
135
+ role: "user",
136
+ content: [{ type: "text", text: "next question" }],
137
+ });
138
+ });
139
+ expect(streamMock).not.toHaveBeenCalled();
140
+
141
+ await act(async () => {
142
+ loaded.resolve({
143
+ messages: [
144
+ { id: "h1", type: "human", content: "earlier question" },
145
+ {
146
+ id: "a1",
147
+ type: "ai",
148
+ content: [],
149
+ tool_calls: [
150
+ { id: "cancel-me", name: "lookup", args: {} },
151
+ { id: "keep-me", name: "wait_for_user", args: {} },
152
+ ],
153
+ },
154
+ ],
155
+ longRunningToolIds: ["keep-me"],
156
+ });
157
+ });
158
+ await waitFor(() => expect(streamMock).toHaveBeenCalledOnce());
159
+ expect(streamMock.mock.calls[0]![0]).toEqual(
160
+ expect.arrayContaining([
161
+ expect.objectContaining({
162
+ type: "tool",
163
+ tool_call_id: "cancel-me",
164
+ content: '{"cancelled":true}',
165
+ }),
166
+ ]),
167
+ );
168
+ expect(streamMock.mock.calls[0]![0]).not.toEqual(
169
+ expect.arrayContaining([
170
+ expect.objectContaining({ tool_call_id: "keep-me" }),
171
+ ]),
172
+ );
173
+ });
174
+
175
+ it("drops a queued send when switching threads during the initial load", async () => {
176
+ const loaded = deferred<AdkThreadSnapshot>();
177
+ const load = vi.fn(() => loaded.promise);
178
+ const { capture, streamMock } = await renderAdk(load);
179
+ await waitFor(() => expect(load).toHaveBeenCalledOnce());
180
+
181
+ act(() => {
182
+ capture.runtime!.thread.append({
183
+ role: "user",
184
+ content: [{ type: "text", text: "old thread question" }],
185
+ });
186
+ });
187
+ await settleOutsideAct(() => capture.runtime!.threads.switchToNewThread());
188
+ await act(async () => {
189
+ loaded.resolve({ messages: [aiMessage("old-answer", "old answer")] });
190
+ });
191
+
192
+ expect(streamMock).not.toHaveBeenCalled();
193
+ expect(
194
+ JSON.stringify(capture.runtime!.thread.getState().messages),
195
+ ).not.toContain("old thread question");
196
+ });
197
+
109
198
  it("declares the refetch capability only when a load is supplied", async () => {
110
199
  const withLoad = await renderAdk(async () => ({ messages: [] }));
111
200
  expect(
@@ -157,6 +246,99 @@ describe("useAdkRuntime refetch", () => {
157
246
  );
158
247
  });
159
248
 
249
+ it("waits for the initial snapshot before sending a new turn", async () => {
250
+ const loaded = deferred<AdkThreadSnapshot>();
251
+ const streamStarted = deferred<void>();
252
+ const finishStream = deferred<void>();
253
+ const load = vi.fn(() => loaded.promise);
254
+ const streamMock = vi.fn(async function* (): AsyncGenerator<AdkEvent> {
255
+ streamStarted.resolve();
256
+ await finishStream.promise;
257
+ yield {
258
+ id: "event-1",
259
+ invocationId: "run-1",
260
+ author: "agent",
261
+ content: { role: "model", parts: [{ text: "new answer" }] },
262
+ };
263
+ });
264
+ const { capture } = await renderAdk(load, undefined, streamMock);
265
+ await waitFor(() => expect(load).toHaveBeenCalledOnce());
266
+
267
+ act(() => {
268
+ capture.runtime!.thread.append({
269
+ role: "user",
270
+ content: [{ type: "text", text: "new question" }],
271
+ });
272
+ });
273
+ expect(streamMock).not.toHaveBeenCalled();
274
+
275
+ await act(async () => {
276
+ loaded.resolve({
277
+ messages: [{ id: "h1", type: "human", content: "earlier question" }],
278
+ });
279
+ await streamStarted.promise;
280
+ });
281
+ finishStream.resolve();
282
+ await waitFor(() =>
283
+ expect(
284
+ JSON.stringify(capture.runtime!.thread.getState().messages),
285
+ ).toContain("new answer"),
286
+ );
287
+
288
+ const messages = JSON.stringify(
289
+ capture.runtime!.thread.getState().messages,
290
+ );
291
+ expect(messages).toContain("earlier question");
292
+ expect(messages).toContain("new question");
293
+ expect(messages).toContain("new answer");
294
+ });
295
+
296
+ it("waits for the replay-safe initial load in StrictMode", async () => {
297
+ const loads: Array<{
298
+ threadId: string;
299
+ result: ReturnType<typeof deferred<AdkThreadSnapshot>>;
300
+ }> = [];
301
+ const load = vi.fn((threadId: string) => {
302
+ const result = deferred<AdkThreadSnapshot>();
303
+ loads.push({ threadId, result });
304
+ return result.promise;
305
+ });
306
+ const streamMock = vi.fn(async function* (): AsyncGenerator<AdkEvent> {
307
+ yield {
308
+ id: "event-1",
309
+ invocationId: "run-1",
310
+ author: "agent",
311
+ content: { role: "model", parts: [{ text: "new answer" }] },
312
+ };
313
+ });
314
+ const { capture } = await renderAdk(load, undefined, streamMock, true);
315
+ await waitFor(() => expect(loads).toHaveLength(1));
316
+ expect(loads[0]?.threadId).toBe("adk-1");
317
+
318
+ act(() => {
319
+ capture.runtime!.thread.append({
320
+ role: "user",
321
+ content: [{ type: "text", text: "new question" }],
322
+ });
323
+ });
324
+ expect(streamMock).not.toHaveBeenCalled();
325
+
326
+ loads[0]!.result.resolve({
327
+ messages: [{ id: "h1", type: "human", content: "earlier question" }],
328
+ });
329
+ await waitFor(() =>
330
+ expect(
331
+ JSON.stringify(capture.runtime!.thread.getState().messages),
332
+ ).toContain("new answer"),
333
+ );
334
+
335
+ const messages = JSON.stringify(
336
+ capture.runtime!.thread.getState().messages,
337
+ );
338
+ expect(messages).toContain("earlier question");
339
+ expect(messages).toContain("new question");
340
+ });
341
+
160
342
  it("swaps the per-turn state over with the messages", async () => {
161
343
  let call = 0;
162
344
  const load = vi.fn(async () => {
@@ -223,6 +405,368 @@ describe("useAdkRuntime refetch", () => {
223
405
  ).toContain("first");
224
406
  });
225
407
 
408
+ it("hides the answer during lookup and restores it when the lookup fails", async () => {
409
+ const checkpoint = deferred<string | null>();
410
+ const getCheckpointId = vi.fn(() => checkpoint.promise);
411
+ const checkpointError = new Error("checkpoint lookup failed");
412
+ const consoleError = vi
413
+ .spyOn(console, "error")
414
+ .mockImplementation(() => {});
415
+ try {
416
+ const { capture } = await renderAdk(
417
+ async () => ({
418
+ messages: [
419
+ { id: "q-1", type: "human", content: "question" },
420
+ aiMessage("a-1", "answer"),
421
+ ],
422
+ }),
423
+ getCheckpointId,
424
+ );
425
+ await waitFor(() =>
426
+ expect(
427
+ JSON.stringify(capture.runtime!.thread.getState().messages),
428
+ ).toContain("answer"),
429
+ );
430
+ const answer = capture
431
+ .runtime!.thread.getState()
432
+ .messages.find((message) => message.role === "assistant")!;
433
+
434
+ act(() => {
435
+ capture.runtime!.thread.getMessageById(answer.id).reload();
436
+ });
437
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledOnce());
438
+ expect(
439
+ JSON.stringify(capture.runtime!.thread.getState().messages),
440
+ ).not.toContain("answer");
441
+ await act(async () => {
442
+ checkpoint.reject(checkpointError);
443
+ });
444
+ await waitFor(() => expect(consoleError).toHaveBeenCalledOnce());
445
+
446
+ expect(consoleError).toHaveBeenCalledExactlyOnceWith(
447
+ "[assistant-ui] Message reload failed",
448
+ checkpointError,
449
+ );
450
+ expect(
451
+ JSON.stringify(capture.runtime!.thread.getState().messages),
452
+ ).toContain("answer");
453
+ } finally {
454
+ consoleError.mockRestore();
455
+ }
456
+ });
457
+
458
+ it("restores a failed reload but does not overwrite a newer send", async () => {
459
+ const checkpoints = [deferred<string | null>(), deferred<string | null>()];
460
+ const getCheckpointId = vi.fn(
461
+ () => checkpoints[getCheckpointId.mock.calls.length - 1]!.promise,
462
+ );
463
+ const consoleError = vi
464
+ .spyOn(console, "error")
465
+ .mockImplementation(() => {});
466
+ try {
467
+ const { capture, streamMock } = await renderAdk(
468
+ async () => ({
469
+ messages: [
470
+ { id: "q-1", type: "human", content: "question" },
471
+ aiMessage("a-1", "answer"),
472
+ ],
473
+ }),
474
+ getCheckpointId,
475
+ );
476
+ await waitFor(() =>
477
+ expect(
478
+ JSON.stringify(capture.runtime!.thread.getState().messages),
479
+ ).toContain("answer"),
480
+ );
481
+ const answer = capture
482
+ .runtime!.thread.getState()
483
+ .messages.find((message) => message.role === "assistant")!;
484
+ act(() => {
485
+ capture.runtime!.thread.getMessageById(answer.id).reload();
486
+ });
487
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledOnce());
488
+ await act(async () => {
489
+ checkpoints[0]!.reject(new Error("first lookup failed"));
490
+ });
491
+ await waitFor(() =>
492
+ expect(
493
+ JSON.stringify(capture.runtime!.thread.getState().messages),
494
+ ).toContain("answer"),
495
+ );
496
+
497
+ act(() => {
498
+ capture.runtime!.thread.getMessageById(answer.id).reload();
499
+ });
500
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledTimes(2));
501
+ expect(
502
+ JSON.stringify(capture.runtime!.thread.getState().messages),
503
+ ).not.toContain("answer");
504
+
505
+ act(() => {
506
+ capture.runtime!.thread.append({
507
+ role: "user",
508
+ content: [{ type: "text", text: "follow-up" }],
509
+ });
510
+ });
511
+ await waitFor(() => expect(streamMock).toHaveBeenCalledOnce());
512
+ expect(
513
+ JSON.stringify(capture.runtime!.thread.getState().messages),
514
+ ).toContain("follow-up");
515
+
516
+ await act(async () => {
517
+ checkpoints[1]!.reject(new Error("second lookup failed"));
518
+ });
519
+
520
+ expect(streamMock).toHaveBeenCalledOnce();
521
+ expect(
522
+ JSON.stringify(capture.runtime!.thread.getState().messages),
523
+ ).not.toContain("answer");
524
+ expect(
525
+ JSON.stringify(capture.runtime!.thread.getState().messages),
526
+ ).toContain("follow-up");
527
+ } finally {
528
+ consoleError.mockRestore();
529
+ }
530
+ });
531
+
532
+ it("restores a failed reload but does not overwrite a newer load", async () => {
533
+ const checkpoints = [deferred<string | null>(), deferred<string | null>()];
534
+ const pendingLoad = deferred<AdkThreadSnapshot>();
535
+ const getCheckpointId = vi.fn(
536
+ () => checkpoints[getCheckpointId.mock.calls.length - 1]!.promise,
537
+ );
538
+ let loadCount = 0;
539
+ const load = vi.fn(() => {
540
+ loadCount++;
541
+ return loadCount === 1
542
+ ? Promise.resolve({
543
+ messages: [
544
+ { id: "q-1", type: "human" as const, content: "question" },
545
+ aiMessage("a-1", "answer"),
546
+ ],
547
+ })
548
+ : loadCount === 2
549
+ ? pendingLoad.promise
550
+ : Promise.resolve({ messages: [aiMessage("loaded", "new history")] });
551
+ });
552
+ const consoleError = vi
553
+ .spyOn(console, "error")
554
+ .mockImplementation(() => {});
555
+ try {
556
+ const { capture } = await renderAdk(load, getCheckpointId);
557
+ await waitFor(() =>
558
+ expect(
559
+ JSON.stringify(capture.runtime!.thread.getState().messages),
560
+ ).toContain("answer"),
561
+ );
562
+ const answer = capture
563
+ .runtime!.thread.getState()
564
+ .messages.find((m) => m.role === "assistant")!;
565
+
566
+ act(() => {
567
+ capture.runtime!.thread.getMessageById(answer.id).reload();
568
+ });
569
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledOnce());
570
+ await act(async () => {
571
+ checkpoints[0]!.reject(new Error("first lookup failed"));
572
+ });
573
+ await waitFor(() =>
574
+ expect(
575
+ JSON.stringify(capture.runtime!.thread.getState().messages),
576
+ ).toContain("answer"),
577
+ );
578
+
579
+ act(() => {
580
+ capture.runtime!.thread.getMessageById(answer.id).reload();
581
+ });
582
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledTimes(2));
583
+ expect(
584
+ JSON.stringify(capture.runtime!.thread.getState().messages),
585
+ ).not.toContain("answer");
586
+
587
+ let refetch!: Promise<void>;
588
+ act(() => {
589
+ refetch = capture.runtime!.threads.reloadMainThread();
590
+ });
591
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
592
+ await act(async () => {
593
+ checkpoints[1]!.reject(new Error("second lookup failed"));
594
+ });
595
+ expect(
596
+ JSON.stringify(capture.runtime!.thread.getState().messages),
597
+ ).toContain("answer");
598
+
599
+ await act(async () => {
600
+ pendingLoad.resolve({ messages: [aiMessage("loaded", "new history")] });
601
+ await refetch;
602
+ });
603
+ expect(
604
+ JSON.stringify(capture.runtime!.thread.getState().messages),
605
+ ).toContain("answer");
606
+ expect(
607
+ JSON.stringify(capture.runtime!.thread.getState().messages),
608
+ ).not.toContain("new history");
609
+
610
+ await act(async () => {
611
+ await capture.runtime!.threads.reloadMainThread();
612
+ });
613
+ expect(load).toHaveBeenCalledTimes(3);
614
+ expect(
615
+ JSON.stringify(capture.runtime!.thread.getState().messages),
616
+ ).toContain("new history");
617
+ expect(
618
+ JSON.stringify(capture.runtime!.thread.getState().messages),
619
+ ).not.toContain("answer");
620
+ } finally {
621
+ consoleError.mockRestore();
622
+ }
623
+ });
624
+
625
+ it.each(["resolves", "rejects"] as const)(
626
+ "restores the answer when a refetch %s during a failed lookup",
627
+ async (outcome) => {
628
+ const checkpoint = deferred<string | null>();
629
+ const pendingLoad = deferred<AdkThreadSnapshot>();
630
+ const getCheckpointId = vi.fn(() => checkpoint.promise);
631
+ let loadCount = 0;
632
+ const load = vi.fn(async () => {
633
+ loadCount++;
634
+ return loadCount === 1
635
+ ? {
636
+ messages: [
637
+ { id: "q-1", type: "human" as const, content: "question" },
638
+ aiMessage("a-1", "answer"),
639
+ ],
640
+ }
641
+ : pendingLoad.promise;
642
+ });
643
+ const consoleError = vi
644
+ .spyOn(console, "error")
645
+ .mockImplementation(() => {});
646
+ try {
647
+ const { capture } = await renderAdk(load, getCheckpointId);
648
+ await waitFor(() =>
649
+ expect(
650
+ JSON.stringify(capture.runtime!.thread.getState().messages),
651
+ ).toContain("answer"),
652
+ );
653
+ const answer = capture
654
+ .runtime!.thread.getState()
655
+ .messages.find((message) => message.role === "assistant")!;
656
+
657
+ act(() => {
658
+ capture.runtime!.thread.getMessageById(answer.id).reload();
659
+ });
660
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledOnce());
661
+ expect(
662
+ JSON.stringify(capture.runtime!.thread.getState().messages),
663
+ ).not.toContain("answer");
664
+
665
+ let refetch!: Promise<void>;
666
+ act(() => {
667
+ refetch = capture.runtime!.threads.reloadMainThread();
668
+ });
669
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
670
+ await act(async () => {
671
+ if (outcome === "resolves") {
672
+ pendingLoad.resolve({
673
+ messages: [aiMessage("loaded", "new history")],
674
+ });
675
+ await refetch;
676
+ } else {
677
+ pendingLoad.reject(new Error("refetch failed"));
678
+ await expect(refetch).rejects.toThrow("refetch failed");
679
+ }
680
+ });
681
+ expect(
682
+ JSON.stringify(capture.runtime!.thread.getState().messages),
683
+ ).not.toContain("answer");
684
+ expect(
685
+ JSON.stringify(capture.runtime!.thread.getState().messages),
686
+ ).not.toContain("new history");
687
+
688
+ await act(async () => {
689
+ checkpoint.reject(new Error("checkpoint lookup failed"));
690
+ });
691
+ await waitFor(() => expect(consoleError).toHaveBeenCalledOnce());
692
+ expect(
693
+ JSON.stringify(capture.runtime!.thread.getState().messages),
694
+ ).toContain("answer");
695
+ } finally {
696
+ consoleError.mockRestore();
697
+ }
698
+ },
699
+ );
700
+
701
+ it("restores a failed reload but does not overwrite a newer reload", async () => {
702
+ const checkpoints = [
703
+ deferred<string | null>(),
704
+ deferred<string | null>(),
705
+ deferred<string | null>(),
706
+ ];
707
+ const getCheckpointId = vi.fn(
708
+ () => checkpoints[getCheckpointId.mock.calls.length - 1]!.promise,
709
+ );
710
+ const consoleError = vi
711
+ .spyOn(console, "error")
712
+ .mockImplementation(() => {});
713
+ try {
714
+ const { capture, streamMock } = await renderAdk(
715
+ async () => ({
716
+ messages: [
717
+ { id: "q-1", type: "human", content: "question" },
718
+ aiMessage("a-1", "answer"),
719
+ ],
720
+ }),
721
+ getCheckpointId,
722
+ );
723
+ await waitFor(() =>
724
+ expect(
725
+ JSON.stringify(capture.runtime!.thread.getState().messages),
726
+ ).toContain("answer"),
727
+ );
728
+ const answer = capture
729
+ .runtime!.thread.getState()
730
+ .messages.find((message) => message.role === "assistant")!;
731
+ act(() => {
732
+ capture.runtime!.thread.getMessageById(answer.id).reload();
733
+ });
734
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledOnce());
735
+ await act(async () => {
736
+ checkpoints[0]!.reject(new Error("first lookup failed"));
737
+ });
738
+ await waitFor(() =>
739
+ expect(
740
+ JSON.stringify(capture.runtime!.thread.getState().messages),
741
+ ).toContain("answer"),
742
+ );
743
+
744
+ act(() => {
745
+ capture.runtime!.thread.getMessageById(answer.id).reload();
746
+ capture.runtime!.thread.getMessageById(answer.id).reload();
747
+ });
748
+ await waitFor(() => expect(getCheckpointId).toHaveBeenCalledTimes(3));
749
+
750
+ await act(async () => {
751
+ checkpoints[1]!.reject(new Error("older lookup failed"));
752
+ });
753
+ expect(
754
+ JSON.stringify(capture.runtime!.thread.getState().messages),
755
+ ).not.toContain("answer");
756
+
757
+ await act(async () => {
758
+ checkpoints[2]!.reject(new Error("newer lookup failed"));
759
+ });
760
+
761
+ expect(streamMock).not.toHaveBeenCalled();
762
+ expect(
763
+ JSON.stringify(capture.runtime!.thread.getState().messages),
764
+ ).toContain("answer");
765
+ } finally {
766
+ consoleError.mockRestore();
767
+ }
768
+ });
769
+
226
770
  it("defers to an initial load still in flight rather than taking it over", async () => {
227
771
  const pending = deferred<AdkThreadSnapshot>();
228
772
  let call = 0;