@assistant-ui/react-google-adk 0.0.34 → 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 +29 -4
  29. package/dist/useAdkMessages.d.ts.map +1 -1
  30. package/dist/useAdkMessages.js +62 -76
  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 +182 -54
  35. package/dist/useAdkRuntime.js.map +1 -1
  36. package/package.json +6 -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 +43 -0
  53. package/src/useAdkMessages.ts +89 -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 +253 -75
  59. package/src/useAdkRuntimeApproval.test.tsx +390 -35
  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,17 +1,48 @@
1
+ /** @vitest-environment jsdom */
1
2
  import { act, renderHook } from "@testing-library/react";
2
3
  import { afterEach, describe, expect, it, vi } from "vitest";
3
4
  import type {
5
+ AddToolResultOptions,
6
+ AppendMessage,
4
7
  RespondToToolApprovalOptions,
5
8
  ThreadMessage,
6
9
  ToolCallMessagePart,
7
10
  } from "@assistant-ui/core";
8
- import type { AdkMessage } from "./types";
11
+ import type {
12
+ AdkMessage,
13
+ AdkSendMessageConfig,
14
+ AdkThreadSnapshot,
15
+ } from "./types";
9
16
 
10
- const mocks = vi.hoisted(() => ({
11
- adapters: [] as unknown[],
12
- sendMessage: vi.fn().mockResolvedValue(undefined),
13
- messages: [] as AdkMessage[],
14
- }));
17
+ const mocks = vi.hoisted(() => {
18
+ const threadListItem = {
19
+ source: null as object | null,
20
+ id: "thread-a",
21
+ externalId: undefined as string | undefined,
22
+ getState: () => ({
23
+ id: threadListItem.id,
24
+ externalId: threadListItem.externalId,
25
+ }),
26
+ initialize: vi.fn(),
27
+ };
28
+ return {
29
+ adapters: [] as unknown[],
30
+ sendMessage: vi.fn().mockResolvedValue(undefined),
31
+ messages: [] as AdkMessage[],
32
+ messageRunConfig: undefined as unknown,
33
+ streamedMessages: undefined as AdkMessage[] | undefined,
34
+ applySnapshot: vi.fn(),
35
+ threadListItem,
36
+ };
37
+ });
38
+
39
+ const mockController = {
40
+ subscribe: () => () => {},
41
+ getStagedMessageCount: () => 0,
42
+ getState: () => ({ messages: mocks.messages }),
43
+ dispatch: vi.fn(),
44
+ getStagedRun: () => null,
45
+ };
15
46
 
16
47
  vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
17
48
  ...(await importOriginal<typeof import("@assistant-ui/core/react")>()),
@@ -27,49 +58,96 @@ vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
27
58
  vi.mock("@assistant-ui/store", async (importOriginal) => ({
28
59
  ...(await importOriginal<typeof import("@assistant-ui/store")>()),
29
60
  useAui: () => ({
30
- threadListItem: {
31
- source: null,
32
- getState: () => ({ externalId: undefined }),
33
- initialize: vi.fn(),
61
+ threadListItem: mocks.threadListItem,
62
+ threads: {
63
+ getState: () => ({ mainThreadId: mocks.threadListItem.id }),
34
64
  },
35
65
  }),
36
66
  }));
37
67
 
38
- vi.mock("./useAdkMessages", async (importOriginal) => ({
39
- ...(await importOriginal<typeof import("./useAdkMessages")>()),
40
- useAdkMessages: () => ({
41
- messages: mocks.messages,
42
- stateDelta: {},
43
- agentInfo: {},
44
- longRunningToolIds: [],
45
- artifactDelta: {},
46
- // Deliberately empty: the projection must come from the transcript, not
47
- // from derived confirmation state that a mid-run accumulator swap drops.
48
- toolConfirmations: [],
49
- authRequests: [],
50
- escalated: false,
51
- messageMetadata: new Map(),
52
- sendMessage: mocks.sendMessage,
53
- cancel: vi.fn(),
54
- setMessages: vi.fn(),
55
- replaceMessages: vi.fn(),
56
- applySnapshot: vi.fn(),
57
- }),
58
- }));
68
+ vi.mock("./useAdkMessages", async (importOriginal) => {
69
+ const useMockAdkMessages = (options: {
70
+ onMessages?: (messages: AdkMessage[], runConfig: unknown) => void;
71
+ }) => {
72
+ options.onMessages?.(mocks.messages, mocks.messageRunConfig);
73
+ const sendMessage = async (
74
+ messages: AdkMessage[],
75
+ config: AdkSendMessageConfig,
76
+ ) => {
77
+ await mocks.sendMessage(messages, config);
78
+ if (mocks.streamedMessages) {
79
+ options.onMessages?.(mocks.streamedMessages, config.runConfig);
80
+ }
81
+ };
82
+ return {
83
+ controller: mockController,
84
+ messages: mocks.messages,
85
+ stateDelta: {},
86
+ agentInfo: {},
87
+ longRunningToolIds: [],
88
+ artifactDelta: {},
89
+ // Deliberately empty: the projection must come from the transcript, not
90
+ // from derived confirmation state that a mid-run accumulator swap drops.
91
+ toolConfirmations: [],
92
+ authRequests: [],
93
+ escalated: false,
94
+ messageMetadata: new Map(),
95
+ sendMessage,
96
+ cancel: vi.fn(),
97
+ setMessages: vi.fn(),
98
+ replaceMessages: vi.fn(),
99
+ applySnapshot: mocks.applySnapshot,
100
+ };
101
+ };
102
+
103
+ return {
104
+ ...(await importOriginal<typeof import("./useAdkMessages")>()),
105
+ useAdkMessages: useMockAdkMessages,
106
+ useAdkMessagesInternal: useMockAdkMessages,
107
+ };
108
+ });
59
109
 
60
110
  import { AdkEventAccumulator } from "./AdkEventAccumulator";
61
111
  import { useAdkRuntime } from "./useAdkRuntime";
62
112
 
63
- type ApprovalAdapter = {
113
+ type RuntimeAdapter = {
64
114
  messages: readonly ThreadMessage[];
115
+ extras: {
116
+ send: (
117
+ messages: AdkMessage[],
118
+ config: AdkSendMessageConfig,
119
+ ) => Promise<void>;
120
+ };
121
+ onNew?: (message: AppendMessage) => Promise<void> | void;
122
+ onEdit?: (message: AppendMessage) => Promise<void> | void;
123
+ onAddToolResult?: (options: AddToolResultOptions) => Promise<void> | void;
65
124
  onRespondToToolApproval?: (
66
125
  options: RespondToToolApprovalOptions,
67
126
  ) => Promise<void> | void;
127
+ onReload?: (
128
+ parentId: string | null,
129
+ config: { runConfig?: AppendMessage["runConfig"] },
130
+ ) => Promise<void> | void;
131
+ onRefetchThread?: () => Promise<void> | void;
68
132
  };
69
133
 
70
134
  const CONFIRMATION_CALL = "adk-confirmation-1";
71
135
 
72
- const latestAdapter = () => mocks.adapters.at(-1) as ApprovalAdapter;
136
+ const latestAdapter = () => mocks.adapters.at(-1) as RuntimeAdapter;
137
+
138
+ const makeUserMessage = (
139
+ text: string,
140
+ runConfig: AppendMessage["runConfig"],
141
+ ): AppendMessage =>
142
+ ({
143
+ role: "user",
144
+ content: [{ type: "text", text }],
145
+ attachments: [],
146
+ parentId: null,
147
+ sourceId: null,
148
+ runConfig,
149
+ metadata: { custom: {} },
150
+ }) as unknown as AppendMessage;
73
151
 
74
152
  const makeConfirmationRequest = (): AdkMessage => ({
75
153
  id: "ai-1",
@@ -96,20 +174,293 @@ const approvalPart = () =>
96
174
 
97
175
  afterEach(() => {
98
176
  mocks.adapters.length = 0;
177
+ mocks.sendMessage.mockClear();
99
178
  mocks.messages = [];
179
+ mocks.messageRunConfig = undefined;
180
+ mocks.streamedMessages = undefined;
181
+ mocks.applySnapshot.mockReset();
182
+ mocks.threadListItem.source = null;
183
+ mocks.threadListItem.externalId = undefined;
100
184
  });
101
185
 
102
186
  describe("useAdkRuntime tool approvals", () => {
187
+ it.each([
188
+ "reload",
189
+ "tool result",
190
+ "approval response",
191
+ "extras send",
192
+ ] as const)("waits for the initial load before %s", async (route) => {
193
+ let resolveLoad!: (snapshot: AdkThreadSnapshot) => void;
194
+ const pendingLoad = new Promise<AdkThreadSnapshot>((resolve) => {
195
+ resolveLoad = resolve;
196
+ });
197
+ const load = vi.fn(() => pendingLoad);
198
+ mocks.threadListItem.source = {};
199
+ mocks.threadListItem.externalId = "thread-a";
200
+ renderHook(() =>
201
+ useAdkRuntime({
202
+ stream: vi.fn(),
203
+ load,
204
+ getCheckpointId: vi.fn(async () => null),
205
+ }),
206
+ );
207
+ expect(load).toHaveBeenCalledOnce();
208
+
209
+ let action: Promise<void>;
210
+ switch (route) {
211
+ case "reload":
212
+ action = Promise.resolve(latestAdapter().onReload!(null, {}));
213
+ break;
214
+ case "tool result":
215
+ action = Promise.resolve(
216
+ latestAdapter().onAddToolResult!({
217
+ messageId: "ai-1",
218
+ toolCallId: "tool-a",
219
+ toolName: "lookup",
220
+ result: { value: "done" },
221
+ isError: false,
222
+ }),
223
+ );
224
+ break;
225
+ case "approval response":
226
+ action = Promise.resolve(
227
+ latestAdapter().onRespondToToolApproval!({
228
+ approvalId: CONFIRMATION_CALL,
229
+ approved: true,
230
+ }),
231
+ );
232
+ break;
233
+ case "extras send":
234
+ action = latestAdapter().extras.send(
235
+ [{ id: "new-user", type: "human", content: "new question" }],
236
+ {},
237
+ );
238
+ }
239
+ void action.catch(() => {});
240
+ await Promise.resolve();
241
+ expect(mocks.sendMessage).not.toHaveBeenCalled();
242
+
243
+ await act(async () => {
244
+ resolveLoad({ messages: [makeConfirmationRequest()] });
245
+ await action;
246
+ });
247
+ expect(mocks.sendMessage).toHaveBeenCalledOnce();
248
+ });
249
+
250
+ it("resumes a delayed tool result with its originating run config", async () => {
251
+ const runConfigA = { custom: { model: "model-a" } };
252
+ const runConfigB = { custom: { model: "model-b" } };
253
+ const { rerender } = renderHook(() => useAdkRuntime({ stream: vi.fn() }));
254
+
255
+ await act(async () => {
256
+ await latestAdapter().onNew!(makeUserMessage("first", runConfigA));
257
+ });
258
+
259
+ mocks.messages = [
260
+ { id: "u-1", type: "human", content: "first" },
261
+ {
262
+ id: "ai-1",
263
+ type: "ai",
264
+ content: [],
265
+ tool_calls: [
266
+ {
267
+ id: "tool-a",
268
+ name: "lookup",
269
+ args: {},
270
+ },
271
+ ],
272
+ },
273
+ ];
274
+ mocks.messageRunConfig = runConfigA;
275
+ rerender();
276
+
277
+ await act(async () => {
278
+ await latestAdapter().onNew!(makeUserMessage("second", runConfigB));
279
+ await latestAdapter().onAddToolResult!({
280
+ messageId: "ai-1",
281
+ toolCallId: "tool-a",
282
+ toolName: "lookup",
283
+ result: { value: "done" },
284
+ isError: false,
285
+ });
286
+ });
287
+
288
+ expect(
289
+ mocks.sendMessage.mock.calls.map((call) => call[1].runConfig),
290
+ ).toEqual([runConfigA, runConfigB, runConfigA]);
291
+ });
292
+
293
+ it("preserves pending tool ownership across a thread refetch", async () => {
294
+ const runConfig = { custom: { model: "model-a" } };
295
+ const laterRunConfig = { custom: { model: "model-b" } };
296
+ const currentMessages: AdkMessage[] = [
297
+ {
298
+ id: "ai-1",
299
+ type: "ai",
300
+ content: [],
301
+ tool_calls: [{ id: "tool-a", name: "lookup", args: {} }],
302
+ },
303
+ ];
304
+ const loadedMessages: AdkMessage[] = [
305
+ ...currentMessages,
306
+ {
307
+ id: "ai-2",
308
+ type: "ai",
309
+ content: [],
310
+ tool_calls: [
311
+ null as never,
312
+ { id: "tool-loaded", name: "lookup", args: {} },
313
+ ],
314
+ },
315
+ ];
316
+ const load = vi.fn(async () => ({ messages: loadedMessages }));
317
+ mocks.threadListItem.source = {};
318
+
319
+ const { rerender } = renderHook(() =>
320
+ useAdkRuntime({ stream: vi.fn(), load }),
321
+ );
322
+
323
+ await act(async () => {
324
+ await latestAdapter().onNew!(makeUserMessage("first", runConfig));
325
+ });
326
+ mocks.messages = currentMessages;
327
+ mocks.messageRunConfig = runConfig;
328
+ rerender();
329
+
330
+ mocks.threadListItem.externalId = "thread-a";
331
+ rerender();
332
+ await act(async () => {
333
+ await latestAdapter().onRefetchThread!();
334
+ });
335
+
336
+ await act(async () => {
337
+ mocks.streamedMessages = [loadedMessages.at(-1)!];
338
+ await latestAdapter().extras.send(
339
+ [{ id: "u-later", type: "human", content: "later" }],
340
+ { runConfig: laterRunConfig },
341
+ );
342
+ mocks.streamedMessages = undefined;
343
+ await latestAdapter().onAddToolResult!({
344
+ messageId: "ai-1",
345
+ toolCallId: "tool-a",
346
+ toolName: "lookup",
347
+ result: { value: "done" },
348
+ isError: false,
349
+ });
350
+ await latestAdapter().onAddToolResult!({
351
+ messageId: "ai-2",
352
+ toolCallId: "tool-loaded",
353
+ toolName: "lookup",
354
+ result: { value: "loaded" },
355
+ isError: false,
356
+ });
357
+ });
358
+
359
+ expect(
360
+ mocks.sendMessage.mock.calls.slice(-2).map((call) => call[1]),
361
+ ).toEqual([{ runConfig }, { runConfig: undefined }]);
362
+ });
363
+
364
+ it("tracks and prunes ownership around malformed tool-call entries", async () => {
365
+ const runConfig = { custom: { model: "model-a" } };
366
+ mocks.messages = [
367
+ { id: "u-1", type: "human", content: "first" },
368
+ {
369
+ id: "ai-1",
370
+ type: "ai",
371
+ content: [],
372
+ tool_calls: [null as never, { id: "tool-a", name: "lookup", args: {} }],
373
+ },
374
+ ];
375
+ mocks.messageRunConfig = runConfig;
376
+ renderHook(() =>
377
+ useAdkRuntime({
378
+ stream: vi.fn(),
379
+ getCheckpointId: vi.fn(async () => null),
380
+ }),
381
+ );
382
+
383
+ await act(async () => {
384
+ await latestAdapter().onEdit!({
385
+ ...makeUserMessage("edited", runConfig),
386
+ parentId: "ai-1",
387
+ });
388
+ await latestAdapter().onAddToolResult!({
389
+ messageId: "ai-1",
390
+ toolCallId: "tool-a",
391
+ toolName: "lookup",
392
+ result: { value: "done" },
393
+ isError: false,
394
+ });
395
+ });
396
+
397
+ expect(mocks.sendMessage.mock.calls.at(-1)![1]).toEqual({ runConfig });
398
+ });
399
+
400
+ it("attributes new tool calls to an explicitly configured continuation", async () => {
401
+ const runConfigA = { custom: { model: "model-a" } };
402
+ const runConfigB = { custom: { model: "model-b" } };
403
+ renderHook(() => useAdkRuntime({ stream: vi.fn() }));
404
+
405
+ await act(async () => {
406
+ await latestAdapter().onNew!(makeUserMessage("first", runConfigA));
407
+ mocks.streamedMessages = [
408
+ {
409
+ id: "ai-b",
410
+ type: "ai",
411
+ content: [],
412
+ tool_calls: [{ id: "tool-b", name: "lookup", args: {} }],
413
+ },
414
+ ];
415
+ await latestAdapter().extras.send(
416
+ [
417
+ {
418
+ id: "tool-result-a",
419
+ type: "tool",
420
+ tool_call_id: "tool-a",
421
+ name: "lookup",
422
+ content: JSON.stringify({ value: "first" }),
423
+ },
424
+ ],
425
+ { runConfig: runConfigB },
426
+ );
427
+ });
428
+
429
+ await act(async () => {
430
+ await latestAdapter().onAddToolResult!({
431
+ messageId: "ai-b",
432
+ toolCallId: "tool-b",
433
+ toolName: "lookup",
434
+ result: { value: "second" },
435
+ isError: false,
436
+ });
437
+ });
438
+
439
+ expect(mocks.sendMessage.mock.calls.at(-1)![1]).toEqual({
440
+ runConfig: runConfigB,
441
+ });
442
+ });
443
+
103
444
  it("exposes, answers, and settles the default approval seam across a rerender", async () => {
445
+ const runConfig = { custom: { model: "model-a" } };
446
+ const nextRunConfig = { custom: { model: "model-b" } };
104
447
  // Retained across the rerender: core caches converted messages by input
105
448
  // object, so only a rebuilt converter can surface the settled decision.
106
449
  const confirmationRequest = makeConfirmationRequest();
450
+ const { rerender } = renderHook(() => useAdkRuntime({ stream: vi.fn() }));
451
+
452
+ await act(async () => {
453
+ await latestAdapter().onNew!(
454
+ makeUserMessage("delete the file", runConfig),
455
+ );
456
+ });
457
+
107
458
  mocks.messages = [
108
459
  { id: "u-1", type: "human", content: "delete the file" },
109
460
  confirmationRequest,
110
461
  ];
111
-
112
- const { rerender } = renderHook(() => useAdkRuntime({ stream: vi.fn() }));
462
+ mocks.messageRunConfig = runConfig;
463
+ rerender();
113
464
 
114
465
  expect(latestAdapter().messages.at(-1)!.status).toMatchObject({
115
466
  type: "requires-action",
@@ -121,6 +472,9 @@ describe("useAdkRuntime tool approvals", () => {
121
472
  });
122
473
 
123
474
  await act(async () => {
475
+ await latestAdapter().onNew!(
476
+ makeUserMessage("start another run", nextRunConfig),
477
+ );
124
478
  await latestAdapter().onRespondToToolApproval!({
125
479
  approvalId: CONFIRMATION_CALL,
126
480
  approved: false,
@@ -135,6 +489,7 @@ describe("useAdkRuntime tool approvals", () => {
135
489
  content: JSON.stringify({ confirmed: false }),
136
490
  }),
137
491
  ]);
492
+ expect(mocks.sendMessage.mock.calls.at(-1)![1]).toEqual({ runConfig });
138
493
 
139
494
  mocks.messages = [
140
495
  ...mocks.messages,
@@ -1,2 +0,0 @@
1
- export declare const raceWithAbortSignal: <T>(signal: AbortSignal | undefined, operation: () => T | PromiseLike<T>) => Promise<T>;
2
- //# sourceMappingURL=raceWithAbortSignal.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"raceWithAbortSignal.d.ts","sourceRoot":"","sources":["../src/raceWithAbortSignal.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,mBAAmB,GAAI,CAAC,EACnC,QAAQ,WAAW,GAAG,SAAS,EAC/B,WAAW,MAAM,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,KAClC,OAAO,CAAC,CAAC,CAqCX,CAAC"}
@@ -1,45 +0,0 @@
1
- //#region src/raceWithAbortSignal.ts
2
- const getAbortReason = (signal) => {
3
- if (signal.reason !== void 0) return signal.reason;
4
- const error = /* @__PURE__ */ new Error("The operation was aborted");
5
- error.name = "AbortError";
6
- return error;
7
- };
8
- const raceWithAbortSignal = (signal, operation) => {
9
- if (!signal) try {
10
- return Promise.resolve(operation());
11
- } catch (error) {
12
- return Promise.reject(error);
13
- }
14
- if (signal.aborted) return Promise.reject(getAbortReason(signal));
15
- return new Promise((resolve, reject) => {
16
- let settled = false;
17
- const cleanup = () => signal.removeEventListener("abort", handleAbort);
18
- const resolveOnce = (value) => {
19
- if (settled) return;
20
- settled = true;
21
- cleanup();
22
- resolve(value);
23
- };
24
- const rejectOnce = (error) => {
25
- if (settled) return;
26
- settled = true;
27
- cleanup();
28
- reject(error);
29
- };
30
- const handleAbort = () => rejectOnce(getAbortReason(signal));
31
- signal.addEventListener("abort", handleAbort, { once: true });
32
- let result;
33
- try {
34
- result = operation();
35
- } catch (error) {
36
- rejectOnce(error);
37
- return;
38
- }
39
- Promise.resolve(result).then(resolveOnce, rejectOnce);
40
- });
41
- };
42
- //#endregion
43
- export { raceWithAbortSignal };
44
-
45
- //# sourceMappingURL=raceWithAbortSignal.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"raceWithAbortSignal.js","names":[],"sources":["../src/raceWithAbortSignal.ts"],"sourcesContent":["const getAbortReason = (signal: AbortSignal): unknown => {\n if (signal.reason !== undefined) return signal.reason;\n const error = new Error(\"The operation was aborted\");\n error.name = \"AbortError\";\n return error;\n};\n\nexport const raceWithAbortSignal = <T>(\n signal: AbortSignal | undefined,\n operation: () => T | PromiseLike<T>,\n): Promise<T> => {\n if (!signal) {\n try {\n return Promise.resolve(operation());\n } catch (error) {\n return Promise.reject(error);\n }\n }\n if (signal.aborted) return Promise.reject(getAbortReason(signal));\n\n return new Promise<T>((resolve, reject) => {\n let settled = false;\n const cleanup = () => signal.removeEventListener(\"abort\", handleAbort);\n const resolveOnce = (value: T) => {\n if (settled) return;\n settled = true;\n cleanup();\n resolve(value);\n };\n const rejectOnce = (error: unknown) => {\n if (settled) return;\n settled = true;\n cleanup();\n reject(error);\n };\n const handleAbort = () => rejectOnce(getAbortReason(signal));\n\n signal.addEventListener(\"abort\", handleAbort, { once: true });\n let result: T | PromiseLike<T>;\n try {\n result = operation();\n } catch (error) {\n rejectOnce(error);\n return;\n }\n Promise.resolve(result).then(resolveOnce, rejectOnce);\n });\n};\n"],"mappings":";AAAA,MAAM,kBAAkB,WAAiC;CACvD,IAAI,OAAO,WAAW,KAAA,GAAW,OAAO,OAAO;CAC/C,MAAM,wBAAQ,IAAI,MAAM,2BAA2B;CACnD,MAAM,OAAO;CACb,OAAO;AACT;AAEA,MAAa,uBACX,QACA,cACe;CACf,IAAI,CAAC,QACH,IAAI;EACF,OAAO,QAAQ,QAAQ,UAAU,CAAC;CACpC,SAAS,OAAO;EACd,OAAO,QAAQ,OAAO,KAAK;CAC7B;CAEF,IAAI,OAAO,SAAS,OAAO,QAAQ,OAAO,eAAe,MAAM,CAAC;CAEhE,OAAO,IAAI,SAAY,SAAS,WAAW;EACzC,IAAI,UAAU;EACd,MAAM,gBAAgB,OAAO,oBAAoB,SAAS,WAAW;EACrE,MAAM,eAAe,UAAa;GAChC,IAAI,SAAS;GACb,UAAU;GACV,QAAQ;GACR,QAAQ,KAAK;EACf;EACA,MAAM,cAAc,UAAmB;GACrC,IAAI,SAAS;GACb,UAAU;GACV,QAAQ;GACR,OAAO,KAAK;EACd;EACA,MAAM,oBAAoB,WAAW,eAAe,MAAM,CAAC;EAE3D,OAAO,iBAAiB,SAAS,aAAa,EAAE,MAAM,KAAK,CAAC;EAC5D,IAAI;EACJ,IAAI;GACF,SAAS,UAAU;EACrB,SAAS,OAAO;GACd,WAAW,KAAK;GAChB;EACF;EACA,QAAQ,QAAQ,MAAM,CAAC,CAAC,KAAK,aAAa,UAAU;CACtD,CAAC;AACH"}
@@ -1,73 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { raceWithAbortSignal } from "./raceWithAbortSignal";
3
-
4
- describe("raceWithAbortSignal", () => {
5
- it("invokes the operation synchronously without a signal", async () => {
6
- const order: string[] = [];
7
-
8
- const result = raceWithAbortSignal(undefined, () => {
9
- order.push("operation");
10
- return "done";
11
- });
12
- order.push("after");
13
-
14
- expect(order).toEqual(["operation", "after"]);
15
- await expect(result).resolves.toBe("done");
16
- });
17
-
18
- it("converts a synchronous operation error to a rejection", async () => {
19
- const error = new Error("failed");
20
-
21
- const result = raceWithAbortSignal(undefined, () => {
22
- throw error;
23
- });
24
-
25
- await expect(result).rejects.toBe(error);
26
- });
27
-
28
- it("rejects a pending operation with the abort reason", async () => {
29
- const controller = new AbortController();
30
- const reason = new Error("cancelled");
31
- let resolveOperation!: (value: string) => void;
32
- const operation = new Promise<string>((resolve) => {
33
- resolveOperation = resolve;
34
- });
35
-
36
- const result = raceWithAbortSignal(controller.signal, () => operation);
37
- controller.abort(reason);
38
-
39
- await expect(result).rejects.toBe(reason);
40
- resolveOperation("late result");
41
- });
42
-
43
- it("rejects before invoking an operation for an already aborted signal", async () => {
44
- const controller = new AbortController();
45
- const reason = new Error("already cancelled");
46
- const operation = vi.fn(() => "done");
47
- controller.abort(reason);
48
-
49
- const result = raceWithAbortSignal(controller.signal, operation);
50
-
51
- await expect(result).rejects.toBe(reason);
52
- expect(operation).not.toHaveBeenCalled();
53
- });
54
-
55
- it("removes the abort listener after the operation settles", async () => {
56
- const controller = new AbortController();
57
- const removeEventListener = vi.spyOn(
58
- controller.signal,
59
- "removeEventListener",
60
- );
61
- const result = raceWithAbortSignal(controller.signal, () => "done");
62
-
63
- await expect(result).resolves.toBe("done");
64
- expect(removeEventListener).toHaveBeenCalledOnce();
65
- expect(removeEventListener).toHaveBeenCalledWith(
66
- "abort",
67
- expect.any(Function),
68
- );
69
-
70
- controller.abort(new Error("late abort"));
71
- await expect(result).resolves.toBe("done");
72
- });
73
- });
@@ -1,48 +0,0 @@
1
- const getAbortReason = (signal: AbortSignal): unknown => {
2
- if (signal.reason !== undefined) return signal.reason;
3
- const error = new Error("The operation was aborted");
4
- error.name = "AbortError";
5
- return error;
6
- };
7
-
8
- export const raceWithAbortSignal = <T>(
9
- signal: AbortSignal | undefined,
10
- operation: () => T | PromiseLike<T>,
11
- ): Promise<T> => {
12
- if (!signal) {
13
- try {
14
- return Promise.resolve(operation());
15
- } catch (error) {
16
- return Promise.reject(error);
17
- }
18
- }
19
- if (signal.aborted) return Promise.reject(getAbortReason(signal));
20
-
21
- return new Promise<T>((resolve, reject) => {
22
- let settled = false;
23
- const cleanup = () => signal.removeEventListener("abort", handleAbort);
24
- const resolveOnce = (value: T) => {
25
- if (settled) return;
26
- settled = true;
27
- cleanup();
28
- resolve(value);
29
- };
30
- const rejectOnce = (error: unknown) => {
31
- if (settled) return;
32
- settled = true;
33
- cleanup();
34
- reject(error);
35
- };
36
- const handleAbort = () => rejectOnce(getAbortReason(signal));
37
-
38
- signal.addEventListener("abort", handleAbort, { once: true });
39
- let result: T | PromiseLike<T>;
40
- try {
41
- result = operation();
42
- } catch (error) {
43
- rejectOnce(error);
44
- return;
45
- }
46
- Promise.resolve(result).then(resolveOnce, rejectOnce);
47
- });
48
- };