@stigmer/react 1.0.3 → 1.0.4

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 (57) hide show
  1. package/agent/__tests__/useAgent.test.d.ts +2 -0
  2. package/agent/__tests__/useAgent.test.d.ts.map +1 -0
  3. package/agent/__tests__/useAgent.test.js +86 -0
  4. package/agent/__tests__/useAgent.test.js.map +1 -0
  5. package/agent/__tests__/useDefaultAgent.test.js +106 -223
  6. package/agent/__tests__/useDefaultAgent.test.js.map +1 -1
  7. package/agent/useDefaultAgent.d.ts +9 -0
  8. package/agent/useDefaultAgent.d.ts.map +1 -1
  9. package/agent/useDefaultAgent.js +35 -5
  10. package/agent/useDefaultAgent.js.map +1 -1
  11. package/composer/ComposerToolbar.d.ts +18 -16
  12. package/composer/ComposerToolbar.d.ts.map +1 -1
  13. package/composer/ComposerToolbar.js +10 -11
  14. package/composer/ComposerToolbar.js.map +1 -1
  15. package/composer/ConfigureMenu.d.ts.map +1 -1
  16. package/composer/ConfigureMenu.js +1 -1
  17. package/composer/ConfigureMenu.js.map +1 -1
  18. package/composer/ContextPopover.d.ts +1 -3
  19. package/composer/ContextPopover.d.ts.map +1 -1
  20. package/composer/ContextPopover.js +2 -2
  21. package/composer/ContextPopover.js.map +1 -1
  22. package/composer/SessionComposer.js +5 -5
  23. package/composer/SessionComposer.js.map +1 -1
  24. package/composer/icons.js +3 -3
  25. package/internal/withTimeout.d.ts +8 -0
  26. package/internal/withTimeout.d.ts.map +1 -0
  27. package/internal/withTimeout.js +19 -0
  28. package/internal/withTimeout.js.map +1 -0
  29. package/package.json +4 -4
  30. package/session/__tests__/useCreateSession.test.js +99 -191
  31. package/session/__tests__/useCreateSession.test.js.map +1 -1
  32. package/session/__tests__/useNewSessionFlow.test.js +71 -0
  33. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  34. package/session/__tests__/useSession.test.js +71 -108
  35. package/session/__tests__/useSession.test.js.map +1 -1
  36. package/session/__tests__/useSessionList.test.d.ts +2 -0
  37. package/session/__tests__/useSessionList.test.d.ts.map +1 -0
  38. package/session/__tests__/useSessionList.test.js +63 -0
  39. package/session/__tests__/useSessionList.test.js.map +1 -0
  40. package/session/useNewSessionFlow.d.ts.map +1 -1
  41. package/session/useNewSessionFlow.js +13 -7
  42. package/session/useNewSessionFlow.js.map +1 -1
  43. package/src/agent/__tests__/useAgent.test.tsx +116 -0
  44. package/src/agent/__tests__/useDefaultAgent.test.tsx +115 -240
  45. package/src/agent/useDefaultAgent.ts +53 -2
  46. package/src/composer/ComposerToolbar.tsx +76 -96
  47. package/src/composer/ConfigureMenu.tsx +16 -14
  48. package/src/composer/ContextPopover.tsx +11 -11
  49. package/src/composer/SessionComposer.tsx +6 -6
  50. package/src/composer/icons.tsx +6 -6
  51. package/src/internal/withTimeout.ts +25 -0
  52. package/src/session/__tests__/useCreateSession.test.tsx +114 -235
  53. package/src/session/__tests__/useNewSessionFlow.test.tsx +96 -1
  54. package/src/session/__tests__/useSession.test.tsx +82 -141
  55. package/src/session/__tests__/useSessionList.test.tsx +86 -0
  56. package/src/session/useNewSessionFlow.ts +18 -9
  57. package/styles.css +1 -1
@@ -1,308 +1,183 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, waitFor, act } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
- import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
5
- import type { Stigmer } from "@stigmer/sdk";
6
4
  import { StigmerContext } from "../../context";
5
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
7
6
  import { useDefaultAgent } from "../useDefaultAgent";
8
7
 
9
- const STALE_THRESHOLD_MS = 30_000;
10
-
11
- function fakeAgent(instanceId: string): Agent {
12
- return {
13
- status: { defaultInstanceId: instanceId },
14
- } as unknown as Agent;
15
- }
16
-
17
- function buildMockClient(overrides: {
18
- getDefault?: ReturnType<typeof vi.fn>;
8
+ function createMockStigmer(overrides: {
9
+ getDefault?: () => Promise<unknown>;
19
10
  } = {}) {
20
11
  return {
21
12
  agent: {
22
- getDefault: overrides.getDefault ?? vi.fn(),
13
+ getDefault: overrides.getDefault ?? vi.fn().mockResolvedValue(null),
23
14
  },
24
- } as unknown as Stigmer;
15
+ } as never;
25
16
  }
26
17
 
27
- function makeWrapper(client: Stigmer) {
28
- return ({ children }: { children: ReactNode }) => (
29
- <StigmerContext.Provider value={client}>
30
- {children}
31
- </StigmerContext.Provider>
32
- );
33
- }
34
-
35
- function fireVisibilityChange(state: DocumentVisibilityState) {
36
- Object.defineProperty(document, "visibilityState", {
37
- value: state,
38
- writable: true,
39
- configurable: true,
40
- });
41
- document.dispatchEvent(new Event("visibilitychange"));
18
+ function wrapper(client: unknown) {
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return (
21
+ <FetchCacheContext.Provider value={null}>
22
+ <StigmerContext.Provider value={client as never}>
23
+ {children}
24
+ </StigmerContext.Provider>
25
+ </FetchCacheContext.Provider>
26
+ );
27
+ };
42
28
  }
43
29
 
44
30
  describe("useDefaultAgent", () => {
45
- let getDefaultMock: ReturnType<typeof vi.fn>;
46
- let client: Stigmer;
47
-
48
31
  beforeEach(() => {
49
- getDefaultMock = vi.fn();
50
- client = buildMockClient({ getDefault: getDefaultMock });
51
- });
52
-
53
- afterEach(() => {
54
32
  vi.restoreAllMocks();
55
33
  });
56
34
 
57
- // -----------------------------------------------------------------------
58
- // Basic fetch behavior (no fake timers needed)
59
- // -----------------------------------------------------------------------
35
+ it("returns loading state initially and resolves with agent data", async () => {
36
+ const agent = { metadata: { id: "agt-1", name: "Default Agent" }, status: { defaultInstanceId: "ain-1" } };
37
+ const getDefault = vi.fn().mockResolvedValue(agent);
38
+ const client = createMockStigmer({ getDefault });
60
39
 
61
- it("fetches the default agent on mount", async () => {
62
- const agent = fakeAgent("inst_1");
63
- getDefaultMock.mockResolvedValueOnce(agent);
64
-
65
- const { result } = renderHook(() => useDefaultAgent("acme"), {
66
- wrapper: makeWrapper(client),
40
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
41
+ wrapper: wrapper(client),
67
42
  });
68
43
 
69
44
  expect(result.current.isLoading).toBe(true);
45
+ expect(result.current.agent).toBeNull();
70
46
 
71
- await act(async () => {});
47
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
72
48
 
73
- expect(result.current.isLoading).toBe(false);
74
49
  expect(result.current.agent).toBe(agent);
75
50
  expect(result.current.error).toBeNull();
76
- expect(getDefaultMock).toHaveBeenCalledOnce();
51
+ expect(getDefault).toHaveBeenCalledTimes(1);
77
52
  });
78
53
 
79
54
  it("skips fetching when org is null", () => {
55
+ const getDefault = vi.fn();
56
+ const client = createMockStigmer({ getDefault });
57
+
80
58
  const { result } = renderHook(() => useDefaultAgent(null), {
81
- wrapper: makeWrapper(client),
59
+ wrapper: wrapper(client),
82
60
  });
83
61
 
84
- expect(result.current.agent).toBeNull();
85
62
  expect(result.current.isLoading).toBe(false);
86
- expect(getDefaultMock).not.toHaveBeenCalled();
63
+ expect(result.current.agent).toBeNull();
64
+ expect(result.current.error).toBeNull();
65
+ expect(getDefault).not.toHaveBeenCalled();
87
66
  });
88
67
 
89
- // -----------------------------------------------------------------------
90
- // Retry on transient failure
91
- // -----------------------------------------------------------------------
92
-
93
- it("retries once on transient failure then succeeds", async () => {
94
- vi.useFakeTimers();
95
- try {
96
- const agent = fakeAgent("inst_retry");
97
- getDefaultMock
98
- .mockRejectedValueOnce(new Error("network timeout"))
99
- .mockResolvedValueOnce(agent);
100
-
101
- const { result } = renderHook(() => useDefaultAgent("acme"), {
102
- wrapper: makeWrapper(client),
103
- });
104
-
105
- expect(result.current.isLoading).toBe(true);
106
-
107
- // Flush the rejected first attempt.
108
- await act(async () => {
109
- await vi.advanceTimersByTimeAsync(0);
110
- });
111
-
112
- // Advance past the 1s retry delay.
113
- await act(async () => {
114
- await vi.advanceTimersByTimeAsync(1_100);
115
- });
68
+ it("exposes error when fetch fails after retry", async () => {
69
+ const apiError = new Error("Service unavailable");
70
+ const getDefault = vi.fn().mockRejectedValue(apiError);
71
+ const client = createMockStigmer({ getDefault });
116
72
 
117
- expect(result.current.isLoading).toBe(false);
118
- expect(result.current.agent).toBe(agent);
119
- expect(result.current.error).toBeNull();
120
- expect(getDefaultMock).toHaveBeenCalledTimes(2);
121
- } finally {
122
- vi.useRealTimers();
123
- }
124
- });
125
-
126
- it("surfaces the error after exhausting retries", async () => {
127
- vi.useFakeTimers();
128
- try {
129
- getDefaultMock
130
- .mockRejectedValueOnce(new Error("fail 1"))
131
- .mockRejectedValueOnce(new Error("fail 2"));
73
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
74
+ wrapper: wrapper(client),
75
+ });
132
76
 
133
- const { result } = renderHook(() => useDefaultAgent("acme"), {
134
- wrapper: makeWrapper(client),
135
- });
77
+ // useDefaultAgent retries once (MAX_RETRIES=1) with a 1s delay.
78
+ // useFetch wraps the retry fn, so the final error surfaces after
79
+ // both attempts fail. Wait for the error to propagate.
80
+ await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
136
81
 
137
- // Flush first attempt + advance past retry delay + flush retry.
138
- await act(async () => {
139
- await vi.advanceTimersByTimeAsync(0);
140
- });
141
- await act(async () => {
142
- await vi.advanceTimersByTimeAsync(1_100);
143
- });
82
+ expect(result.current.error!.message).toBe("Service unavailable");
83
+ expect(result.current.agent).toBeNull();
84
+ expect(result.current.isLoading).toBe(false);
85
+ // 1 initial + 1 retry = 2 calls
86
+ expect(getDefault).toHaveBeenCalledTimes(2);
87
+ }, 15_000);
88
+
89
+ it("refetch triggers a new fetch", async () => {
90
+ let callCount = 0;
91
+ const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
92
+ const getDefault = vi.fn().mockImplementation(async () => {
93
+ callCount++;
94
+ return agent;
95
+ });
96
+ const client = createMockStigmer({ getDefault });
144
97
 
145
- expect(result.current.isLoading).toBe(false);
146
- expect(result.current.agent).toBeNull();
147
- expect(result.current.error).toBeInstanceOf(Error);
148
- expect(result.current.error!.message).toBe("fail 2");
149
- expect(getDefaultMock).toHaveBeenCalledTimes(2);
150
- } finally {
151
- vi.useRealTimers();
152
- }
153
- });
98
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
99
+ wrapper: wrapper(client),
100
+ });
154
101
 
155
- // -----------------------------------------------------------------------
156
- // Visibility-aware refetch
157
- // -----------------------------------------------------------------------
158
-
159
- it("refetches when document becomes visible after stale threshold", async () => {
160
- vi.useFakeTimers();
161
- try {
162
- const agent1 = fakeAgent("inst_old");
163
- const agent2 = fakeAgent("inst_new");
164
- getDefaultMock
165
- .mockResolvedValueOnce(agent1)
166
- .mockResolvedValueOnce(agent2);
167
-
168
- const { result } = renderHook(() => useDefaultAgent("acme"), {
169
- wrapper: makeWrapper(client),
170
- });
102
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
103
+ expect(callCount).toBe(1);
171
104
 
172
- // Flush initial fetch.
173
- await act(async () => {
174
- await vi.advanceTimersByTimeAsync(0);
175
- });
176
- expect(result.current.agent).toBe(agent1);
177
- expect(getDefaultMock).toHaveBeenCalledTimes(1);
105
+ act(() => result.current.refetch());
178
106
 
179
- // Simulate idle period longer than the stale threshold.
180
- await act(async () => {
181
- await vi.advanceTimersByTimeAsync(STALE_THRESHOLD_MS + 1_000);
182
- });
107
+ await waitFor(() => expect(callCount).toBe(2), { timeout: 10_000 });
108
+ }, 15_000);
183
109
 
184
- // Simulate app coming back to foreground.
185
- act(() => {
186
- fireVisibilityChange("visible");
187
- });
110
+ describe("waitForResolution", () => {
111
+ it("resolves immediately when agent is already loaded", async () => {
112
+ const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
113
+ const getDefault = vi.fn().mockResolvedValue(agent);
114
+ const client = createMockStigmer({ getDefault });
188
115
 
189
- // Flush the refetch.
190
- await act(async () => {
191
- await vi.advanceTimersByTimeAsync(0);
116
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
117
+ wrapper: wrapper(client),
192
118
  });
193
119
 
194
- expect(result.current.agent).toBe(agent2);
195
- expect(getDefaultMock).toHaveBeenCalledTimes(2);
196
- } finally {
197
- vi.useRealTimers();
198
- }
199
- });
120
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
200
121
 
201
- it("does NOT refetch when visible within the stale window", async () => {
202
- vi.useFakeTimers();
203
- try {
204
- const agent = fakeAgent("inst_fresh");
205
- getDefaultMock.mockResolvedValueOnce(agent);
122
+ const resolved = await result.current.waitForResolution();
123
+ expect(resolved).toBe(agent);
124
+ });
206
125
 
207
- const { result } = renderHook(() => useDefaultAgent("acme"), {
208
- wrapper: makeWrapper(client),
209
- });
126
+ it("awaits and resolves when fetch completes", async () => {
127
+ let resolveExternal!: (value: unknown) => void;
128
+ const fetchPromise = new Promise((res) => { resolveExternal = res; });
129
+ const getDefault = vi.fn().mockReturnValue(fetchPromise);
130
+ const client = createMockStigmer({ getDefault });
210
131
 
211
- // Flush initial fetch.
212
- await act(async () => {
213
- await vi.advanceTimersByTimeAsync(0);
132
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
133
+ wrapper: wrapper(client),
214
134
  });
215
- expect(result.current.agent).toBe(agent);
216
- expect(getDefaultMock).toHaveBeenCalledTimes(1);
217
135
 
218
- // Only a short time passes — well within stale threshold.
219
- await act(async () => {
220
- await vi.advanceTimersByTimeAsync(5_000);
221
- });
136
+ expect(result.current.isLoading).toBe(true);
222
137
 
223
- act(() => {
224
- fireVisibilityChange("visible");
225
- });
138
+ const waitPromise = result.current.waitForResolution();
226
139
 
227
- expect(getDefaultMock).toHaveBeenCalledTimes(1);
228
- } finally {
229
- vi.useRealTimers();
230
- }
231
- });
140
+ const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
141
+ resolveExternal(agent);
232
142
 
233
- it("does not refetch on hidden event", async () => {
234
- vi.useFakeTimers();
235
- try {
236
- const agent = fakeAgent("inst_1");
237
- getDefaultMock.mockResolvedValueOnce(agent);
143
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
238
144
 
239
- const { result } = renderHook(() => useDefaultAgent("acme"), {
240
- wrapper: makeWrapper(client),
241
- });
145
+ const resolved = await waitPromise;
146
+ expect(resolved).toBe(agent);
147
+ });
242
148
 
243
- // Flush initial fetch.
244
- await act(async () => {
245
- await vi.advanceTimersByTimeAsync(0);
246
- });
247
- expect(result.current.agent).toBe(agent);
149
+ it("rejects when fetch fails", async () => {
150
+ const apiError = new Error("Service unavailable");
151
+ const getDefault = vi.fn().mockRejectedValue(apiError);
152
+ const client = createMockStigmer({ getDefault });
248
153
 
249
- // Advance well past stale threshold.
250
- await act(async () => {
251
- await vi.advanceTimersByTimeAsync(STALE_THRESHOLD_MS + 1_000);
154
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
155
+ wrapper: wrapper(client),
252
156
  });
253
157
 
254
- // Hidden (not visible) should not trigger refetch.
255
- act(() => {
256
- fireVisibilityChange("hidden");
257
- });
158
+ expect(result.current.isLoading).toBe(true);
258
159
 
259
- expect(getDefaultMock).toHaveBeenCalledTimes(1);
260
- } finally {
261
- vi.useRealTimers();
262
- }
263
- });
160
+ const waitPromise = result.current.waitForResolution();
161
+ // Prevent unhandled rejection warning — assertion below verifies the value
162
+ waitPromise.catch(() => {});
264
163
 
265
- // -----------------------------------------------------------------------
266
- // Manual refetch
267
- // -----------------------------------------------------------------------
268
-
269
- it("recovers via manual refetch after initial failure", async () => {
270
- vi.useFakeTimers();
271
- try {
272
- const agent = fakeAgent("inst_recovered");
273
- getDefaultMock
274
- .mockRejectedValueOnce(new Error("initial fail"))
275
- .mockRejectedValueOnce(new Error("retry fail"))
276
- .mockResolvedValueOnce(agent);
277
-
278
- const { result } = renderHook(() => useDefaultAgent("acme"), {
279
- wrapper: makeWrapper(client),
280
- });
164
+ await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
281
165
 
282
- // Exhaust initial attempt + retry.
283
- await act(async () => {
284
- await vi.advanceTimersByTimeAsync(0);
285
- });
286
- await act(async () => {
287
- await vi.advanceTimersByTimeAsync(1_100);
288
- });
166
+ await expect(waitPromise).rejects.toThrow("Service unavailable");
167
+ }, 15_000);
289
168
 
290
- expect(result.current.error).not.toBeNull();
169
+ it("rejects immediately when error is already set", async () => {
170
+ const apiError = new Error("Already failed");
171
+ const getDefault = vi.fn().mockRejectedValue(apiError);
172
+ const client = createMockStigmer({ getDefault });
291
173
 
292
- // Manual refetch triggers recovery.
293
- act(() => {
294
- result.current.refetch();
174
+ const { result } = renderHook(() => useDefaultAgent("test-org"), {
175
+ wrapper: wrapper(client),
295
176
  });
296
177
 
297
- // Flush the refetch.
298
- await act(async () => {
299
- await vi.advanceTimersByTimeAsync(0);
300
- });
178
+ await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
301
179
 
302
- expect(result.current.agent).toBe(agent);
303
- expect(result.current.error).toBeNull();
304
- } finally {
305
- vi.useRealTimers();
306
- }
180
+ await expect(result.current.waitForResolution()).rejects.toThrow("Already failed");
181
+ }, 15_000);
307
182
  });
308
183
  });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef } from "react";
3
+ import { useCallback, useEffect, useRef } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
5
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
6
6
  import { GetDefaultAgentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
@@ -29,6 +29,15 @@ export interface UseDefaultAgentReturn {
29
29
  readonly error: Error | null;
30
30
  /** Discard cached data and re-fetch the default agent from the server. */
31
31
  readonly refetch: () => void;
32
+ /**
33
+ * Returns a promise that resolves with the Agent once the in-flight
34
+ * fetch settles. If the agent is already loaded, resolves immediately.
35
+ * If the fetch has already failed, rejects immediately.
36
+ *
37
+ * Use this to await the default agent in async flows (e.g. submit)
38
+ * instead of failing when `isLoading` is still true.
39
+ */
40
+ readonly waitForResolution: () => Promise<Agent>;
32
41
  }
33
42
 
34
43
  /**
@@ -74,6 +83,32 @@ export function useDefaultAgent(org: string | null): UseDefaultAgentReturn {
74
83
  null,
75
84
  );
76
85
 
86
+ // Deferred pattern: allows callers to await the in-flight fetch.
87
+ const deferredRef = useRef<Deferred<Agent> | null>(null);
88
+
89
+ useEffect(() => {
90
+ if (agent && deferredRef.current) {
91
+ deferredRef.current.resolve(agent);
92
+ deferredRef.current = null;
93
+ }
94
+ }, [agent]);
95
+
96
+ useEffect(() => {
97
+ if (error && deferredRef.current) {
98
+ deferredRef.current.reject(error);
99
+ deferredRef.current = null;
100
+ }
101
+ }, [error]);
102
+
103
+ const waitForResolution = useCallback((): Promise<Agent> => {
104
+ if (agent) return Promise.resolve(agent);
105
+ if (error) return Promise.reject(error);
106
+ if (!deferredRef.current) {
107
+ deferredRef.current = createDeferred<Agent>();
108
+ }
109
+ return deferredRef.current.promise;
110
+ }, [agent, error]);
111
+
77
112
  useEffect(() => {
78
113
  if (typeof document === "undefined") return;
79
114
 
@@ -88,13 +123,29 @@ export function useDefaultAgent(org: string | null): UseDefaultAgentReturn {
88
123
  return () => document.removeEventListener("visibilitychange", onVisible);
89
124
  }, [refetch]);
90
125
 
91
- return { agent, isLoading, isRefetching, error, refetch };
126
+ return { agent, isLoading, isRefetching, error, refetch, waitForResolution };
92
127
  }
93
128
 
94
129
  // ---------------------------------------------------------------------------
95
130
  // Internal helpers
96
131
  // ---------------------------------------------------------------------------
97
132
 
133
+ interface Deferred<T> {
134
+ promise: Promise<T>;
135
+ resolve: (value: T) => void;
136
+ reject: (reason: unknown) => void;
137
+ }
138
+
139
+ function createDeferred<T>(): Deferred<T> {
140
+ let resolve!: (value: T) => void;
141
+ let reject!: (reason: unknown) => void;
142
+ const promise = new Promise<T>((res, rej) => {
143
+ resolve = res;
144
+ reject = rej;
145
+ });
146
+ return { promise, resolve, reject };
147
+ }
148
+
98
149
  /**
99
150
  * Retries `fn` up to `retries` times with a fixed delay between attempts.
100
151
  */