ai 6.0.302 → 6.0.304

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.
package/src/global.ts CHANGED
@@ -2,8 +2,20 @@ import type { ProviderV3 } from '@ai-sdk/provider';
2
2
  import type { LogWarningsFunction } from './logger/log-warnings';
3
3
  import type { TelemetryIntegration } from './telemetry/telemetry-integration';
4
4
 
5
- // add AI SDK default provider to the globalThis object
5
+ // Each installed copy contributes its own types without conflicting with other
6
+ // versions. This is a type-only key; no symbol is created at runtime.
7
+ declare const aiSdkGlobalsKey: unique symbol;
8
+
6
9
  declare global {
10
+ interface AISDKGlobalTypes {
11
+ [aiSdkGlobalsKey]: {
12
+ provider: ProviderV3;
13
+ logWarnings: LogWarningsFunction;
14
+ telemetry: TelemetryIntegration;
15
+ };
16
+ }
17
+
18
+ // Keep these registry-based declarations identical across major versions.
7
19
  /**
8
20
  * The default provider to use for the AI SDK.
9
21
  * String model ids are resolved to the default provider and model id.
@@ -12,7 +24,9 @@ declare global {
12
24
  *
13
25
  * @see https://ai-sdk.dev/docs/ai-sdk-core/provider-management#global-provider-configuration
14
26
  */
15
- var AI_SDK_DEFAULT_PROVIDER: ProviderV3 | undefined;
27
+ var AI_SDK_DEFAULT_PROVIDER:
28
+ | AISDKGlobalTypes[keyof AISDKGlobalTypes]['provider']
29
+ | undefined;
16
30
 
17
31
  /**
18
32
  * The warning logger to use for the AI SDK.
@@ -21,7 +35,10 @@ declare global {
21
35
  *
22
36
  * If set to false, no warnings are logged.
23
37
  */
24
- var AI_SDK_LOG_WARNINGS: LogWarningsFunction | undefined | false;
38
+ var AI_SDK_LOG_WARNINGS:
39
+ | AISDKGlobalTypes[keyof AISDKGlobalTypes]['logWarnings']
40
+ | undefined
41
+ | false;
25
42
 
26
43
  /**
27
44
  * Globally registered telemetry integrations for the AI SDK.
@@ -32,5 +49,7 @@ declare global {
32
49
  * Prefer using `registerTelemetryIntegration()` from `'ai'` instead of
33
50
  * assigning this directly.
34
51
  */
35
- var AI_SDK_TELEMETRY_INTEGRATIONS: TelemetryIntegration[] | undefined;
52
+ var AI_SDK_TELEMETRY_INTEGRATIONS:
53
+ | Array<AISDKGlobalTypes[keyof AISDKGlobalTypes]['telemetry']>
54
+ | undefined;
36
55
  }
@@ -52,38 +52,6 @@ export async function convertToLanguageModelPrompt({
52
52
  supportedUrls,
53
53
  );
54
54
 
55
- const approvalIdToToolCallId = new Map<string, string>();
56
- for (const message of prompt.messages) {
57
- if (message.role === 'assistant' && Array.isArray(message.content)) {
58
- for (const part of message.content) {
59
- if (
60
- part.type === 'tool-approval-request' &&
61
- 'approvalId' in part &&
62
- 'toolCallId' in part
63
- ) {
64
- approvalIdToToolCallId.set(
65
- part.approvalId as string,
66
- part.toolCallId as string,
67
- );
68
- }
69
- }
70
- }
71
- }
72
-
73
- const approvedToolCallIds = new Set<string>();
74
- for (const message of prompt.messages) {
75
- if (message.role === 'tool') {
76
- for (const part of message.content) {
77
- if (part.type === 'tool-approval-response') {
78
- const toolCallId = approvalIdToToolCallId.get(part.approvalId);
79
- if (toolCallId) {
80
- approvedToolCallIds.add(toolCallId);
81
- }
82
- }
83
- }
84
- }
85
- }
86
-
87
55
  const messages = [
88
56
  ...(prompt.system != null
89
57
  ? typeof prompt.system === 'string'
@@ -149,11 +117,6 @@ export async function convertToLanguageModelPrompt({
149
117
  }
150
118
  case 'user':
151
119
  case 'system':
152
- // remove approved tool calls from the set before checking:
153
- for (const id of approvedToolCallIds) {
154
- toolCallIds.delete(id);
155
- }
156
-
157
120
  if (toolCallIds.size > 0) {
158
121
  throw new MissingToolResultsError({
159
122
  toolCallIds: Array.from(toolCallIds),
@@ -163,11 +126,6 @@ export async function convertToLanguageModelPrompt({
163
126
  }
164
127
  }
165
128
 
166
- // remove approved tool calls from the set before checking:
167
- for (const id of approvedToolCallIds) {
168
- toolCallIds.delete(id);
169
- }
170
-
171
129
  if (toolCallIds.size > 0) {
172
130
  throw new MissingToolResultsError({ toolCallIds: Array.from(toolCallIds) });
173
131
  }
@@ -13,6 +13,13 @@ import type { UIMessage } from './ui-messages';
13
13
  * @template UI_MESSAGE - The UI message type extending UIMessage
14
14
  */
15
15
  export interface ChatTransport<UI_MESSAGE extends UIMessage> {
16
+ /**
17
+ * Whether reconnecting returns the complete response from its beginning.
18
+ * Complete replays must reset the streaming message state before processing
19
+ * the first start chunk.
20
+ */
21
+ readonly resumeStreamIsReplay?: boolean;
22
+
16
23
  /**
17
24
  * Sends messages to the chat API endpoint and returns a streaming response.
18
25
  *
package/src/ui/chat.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import type { FinishReason } from '../types/language-model';
8
8
  import type { UIMessageChunk } from '../ui-message-stream/ui-message-chunks';
9
9
  import { consumeStream } from '../util/consume-stream';
10
+ import { createResolvablePromise } from '../util/create-resolvable-promise';
10
11
  import { SerialJobExecutor } from '../util/serial-job-executor';
11
12
  import type { ChatTransport } from './chat-transport';
12
13
  import { convertFileListToFileUIParts } from './convert-file-list-to-file-ui-parts';
@@ -135,12 +136,19 @@ export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
135
136
  type ActiveResponse<UI_MESSAGE extends UIMessage> = {
136
137
  state: StreamingUIMessageState<UI_MESSAGE>;
137
138
  abortController: AbortController;
139
+ completionPromise: Promise<void>;
138
140
  };
139
141
 
140
142
  type ActiveResumeRequest = {
141
143
  abortController: AbortController;
144
+ completionPromise: Promise<void>;
142
145
  };
143
146
 
147
+ type MakeRequestOptions = {
148
+ trigger: 'submit-message' | 'resume-stream' | 'regenerate-message';
149
+ messageId?: string;
150
+ } & ChatRequestOptions;
151
+
144
152
  export interface ChatState<UI_MESSAGE extends UIMessage> {
145
153
  status: ChatStatus;
146
154
 
@@ -266,6 +274,8 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
266
274
  | StreamingUIMessageState<UI_MESSAGE>
267
275
  | undefined = undefined;
268
276
  private jobExecutor = new SerialJobExecutor();
277
+ private activeStopCount = 0;
278
+ private stopGeneration = 0;
269
279
 
270
280
  constructor({
271
281
  generateId = generateIdFunc,
@@ -508,6 +518,33 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
508
518
  await this.makeRequest({ trigger: 'resume-stream', ...options });
509
519
  };
510
520
 
521
+ /**
522
+ * Resume the stream if the latest response ended with a resumable network
523
+ * disconnect. If a response is still active, wait for it to settle first so
524
+ * disconnects reported shortly after the caller's trigger are handled.
525
+ *
526
+ * @internal
527
+ */
528
+ '~resumeStreamIfDisconnected' = async ({
529
+ shouldResume = () => true,
530
+ }: {
531
+ shouldResume?: () => boolean;
532
+ } = {}): Promise<void> => {
533
+ const activeResponse = this.activeResponse;
534
+
535
+ if (this.resumableStreamState == null && activeResponse != null) {
536
+ await activeResponse.completionPromise;
537
+
538
+ if (this.resumableStreamState !== activeResponse.state) {
539
+ return;
540
+ }
541
+ }
542
+
543
+ if (this.resumableStreamState != null && shouldResume()) {
544
+ await this.resumeStream();
545
+ }
546
+ };
547
+
511
548
  /**
512
549
  * Clear the error state and set the status to ready if the chat is in an error state.
513
550
  */
@@ -572,21 +609,23 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
572
609
  this.status !== 'submitted' &&
573
610
  this.sendAutomaticallyWhen
574
611
  ) {
575
- this.shouldSendAutomatically().then(shouldSend => {
576
- if (shouldSend) {
577
- // no await to avoid deadlocking
612
+ const shouldSend = await this.shouldSendAutomatically();
613
+
614
+ if (shouldSend) {
615
+ // no await to avoid deadlocking
616
+ void this.runAutomaticRequest(() => {
578
617
  const messageId =
579
618
  messageIndex === -1
580
619
  ? this.lastMessage?.id
581
620
  : messages[messageIndex].id;
582
621
 
583
- this.makeRequestForToolApproval({
622
+ return this.makeRequestForToolApproval({
584
623
  messageId,
585
624
  messageIndex,
586
625
  ...options,
587
626
  });
588
- }
589
- });
627
+ }, shouldSend);
628
+ }
590
629
  }
591
630
  });
592
631
 
@@ -599,7 +638,6 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
599
638
  }) =>
600
639
  this.jobExecutor.run(async () => {
601
640
  const messages = this.state.messages;
602
- const lastMessage = messages[messages.length - 1];
603
641
 
604
642
  const updatePart = (
605
643
  part: UIMessagePart<UIDataTypes, UITools>,
@@ -608,11 +646,21 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
608
646
  ? ({ ...part, state, output, errorText } as typeof part)
609
647
  : part;
610
648
 
611
- // update the message to trigger an immediate UI update
612
- this.state.replaceMessage(messages.length - 1, {
613
- ...lastMessage,
614
- parts: lastMessage.parts.map(updatePart),
615
- });
649
+ const messageIndex = messages.findIndex(message =>
650
+ message.parts.some(
651
+ part => isToolUIPart(part) && part.toolCallId === toolCallId,
652
+ ),
653
+ );
654
+
655
+ if (messageIndex !== -1) {
656
+ const message = messages[messageIndex];
657
+
658
+ // update the message to trigger an immediate UI update
659
+ this.state.replaceMessage(messageIndex, {
660
+ ...message,
661
+ parts: message.parts.map(updatePart),
662
+ });
663
+ }
616
664
 
617
665
  // update the active response if it exists
618
666
  if (this.activeResponse) {
@@ -622,20 +670,25 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
622
670
 
623
671
  // automatically send the message if the sendAutomaticallyWhen function returns true
624
672
  if (
673
+ messageIndex !== -1 &&
625
674
  this.status !== 'streaming' &&
626
675
  this.status !== 'submitted' &&
627
676
  this.sendAutomaticallyWhen
628
677
  ) {
629
- this.shouldSendAutomatically().then(shouldSend => {
630
- if (shouldSend) {
631
- // no await to avoid deadlocking
632
- this.makeRequest({
633
- trigger: 'submit-message',
634
- messageId: this.lastMessage?.id,
635
- ...options,
636
- });
637
- }
638
- });
678
+ const shouldSend = await this.shouldSendAutomatically();
679
+
680
+ if (shouldSend) {
681
+ // no await to avoid deadlocking
682
+ void this.runAutomaticRequest(
683
+ () =>
684
+ this.makeRequest({
685
+ trigger: 'submit-message',
686
+ messageId: messages[messageIndex].id,
687
+ ...options,
688
+ }),
689
+ shouldSend,
690
+ );
691
+ }
639
692
  }
640
693
  });
641
694
 
@@ -643,11 +696,31 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
643
696
  addToolResult = this.addToolOutput;
644
697
 
645
698
  /**
646
- * Abort the current request immediately, keep the generated tokens if any.
699
+ * Abort the current request, keep the generated tokens if any, and wait for
700
+ * the request pipeline to finish.
647
701
  */
648
702
  stop = async () => {
649
- this.activeResumeRequest?.abortController.abort();
650
- this.activeResponse?.abortController.abort();
703
+ this.activeStopCount++;
704
+ this.stopGeneration++;
705
+
706
+ try {
707
+ const activeResumeRequest = this.activeResumeRequest;
708
+ const activeResponse = this.activeResponse;
709
+
710
+ activeResumeRequest?.abortController.abort();
711
+ activeResponse?.abortController.abort();
712
+
713
+ await Promise.all([
714
+ activeResumeRequest?.completionPromise,
715
+ activeResponse?.completionPromise,
716
+ ]);
717
+
718
+ // Stream cancellation can complete while a processing job is still
719
+ // blocked in onToolCall. Drain that job and any message update it queued.
720
+ await this.jobExecutor.waitForIdle();
721
+ } finally {
722
+ this.activeStopCount--;
723
+ }
651
724
  };
652
725
 
653
726
  private async shouldSendAutomatically(): Promise<boolean> {
@@ -665,6 +738,30 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
665
738
  return result as boolean;
666
739
  }
667
740
 
741
+ private async runAutomaticRequest(
742
+ request: () => Promise<void>,
743
+ shouldSend?: boolean,
744
+ ): Promise<void> {
745
+ const stopGeneration = this.stopGeneration;
746
+ const startedWhileStopping = this.activeStopCount > 0;
747
+
748
+ if (!(shouldSend ?? (await this.shouldSendAutomatically()))) {
749
+ return;
750
+ }
751
+
752
+ // A stop invalidates automatic requests that were pending when it started,
753
+ // including requests queued by callback-driven tool updates.
754
+ if (
755
+ startedWhileStopping ||
756
+ stopGeneration !== this.stopGeneration ||
757
+ this.activeStopCount > 0
758
+ ) {
759
+ return;
760
+ }
761
+
762
+ await request();
763
+ }
764
+
668
765
  private async makeRequestForToolApproval({
669
766
  messageId,
670
767
  messageIndex,
@@ -695,23 +792,38 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
695
792
  }
696
793
  }
697
794
 
698
- private async makeRequest({
795
+ private async makeRequest(options: MakeRequestOptions) {
796
+ const completion = createResolvablePromise<void>();
797
+
798
+ try {
799
+ await this.makeRequestImpl({
800
+ ...options,
801
+ completionPromise: completion.promise,
802
+ });
803
+ } finally {
804
+ completion.resolve();
805
+ }
806
+ }
807
+
808
+ private async makeRequestImpl({
699
809
  trigger,
700
810
  metadata,
701
811
  headers,
702
812
  body,
703
813
  messageId,
704
- }: {
705
- trigger: 'submit-message' | 'resume-stream' | 'regenerate-message';
706
- messageId?: string;
707
- } & ChatRequestOptions) {
814
+ completionPromise,
815
+ }: MakeRequestOptions & {
816
+ completionPromise: Promise<void>;
817
+ }) {
708
818
  if (trigger !== 'resume-stream') {
709
819
  this.resumableStreamState = undefined;
710
820
  }
711
821
 
712
822
  const abortController = new AbortController();
713
823
  const activeResumeRequest =
714
- trigger === 'resume-stream' ? { abortController } : undefined;
824
+ trigger === 'resume-stream'
825
+ ? { abortController, completionPromise }
826
+ : undefined;
715
827
 
716
828
  if (activeResumeRequest) {
717
829
  this.activeResumeRequest?.abortController.abort();
@@ -797,6 +909,7 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
797
909
  responseMessageIndex === -1
798
910
  ? lastMessage
799
911
  : this.state.messages[responseMessageIndex];
912
+ const originalResponseMessageId = responseMessage?.id;
800
913
  const resumableResponseMessage =
801
914
  trigger === 'resume-stream' &&
802
915
  responseMessage?.role === 'assistant' &&
@@ -830,6 +943,7 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
830
943
  messageId: this.generateId(),
831
944
  }),
832
945
  abortController,
946
+ completionPromise,
833
947
  } as ActiveResponse<UI_MESSAGE>;
834
948
 
835
949
  activeResponse = response;
@@ -880,16 +994,28 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
880
994
  this.setStatus({ status: 'streaming' });
881
995
  }
882
996
 
883
- if (usesEarlierAssistantMessage) {
997
+ const existingMessageIndex = this.state.messages.findLastIndex(
998
+ message => message.id === response.state.message.id,
999
+ );
1000
+
1001
+ if (existingMessageIndex !== -1) {
884
1002
  this.state.replaceMessage(
885
- responseMessageIndex,
1003
+ existingMessageIndex,
886
1004
  response.state.message,
887
1005
  );
888
- } else if (response.state.message.id === this.lastMessage?.id) {
889
- this.state.replaceMessage(
890
- this.state.messages.length - 1,
891
- response.state.message,
1006
+ } else if (usesEarlierAssistantMessage) {
1007
+ const originalMessageIndex = this.state.messages.findLastIndex(
1008
+ message => message.id === originalResponseMessageId,
892
1009
  );
1010
+
1011
+ if (originalMessageIndex !== -1) {
1012
+ this.state.replaceMessage(
1013
+ originalMessageIndex,
1014
+ response.state.message,
1015
+ );
1016
+ } else {
1017
+ this.state.pushMessage(response.state.message);
1018
+ }
893
1019
  } else {
894
1020
  this.state.pushMessage(response.state.message);
895
1021
  }
@@ -900,6 +1026,9 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
900
1026
  await consumeStream({
901
1027
  stream: processUIMessageStream({
902
1028
  stream,
1029
+ resetStateOnMessageIdChange: trigger === 'resume-stream',
1030
+ resetStateOnFirstMessageStart:
1031
+ trigger === 'resume-stream' && this.transport.resumeStreamIsReplay,
903
1032
  onToolCall: this.onToolCall,
904
1033
  onData: this.onData,
905
1034
  messageMetadataSchema: this.messageMetadataSchema,
@@ -992,14 +1121,16 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
992
1121
  }
993
1122
 
994
1123
  // automatically send the message if the sendAutomaticallyWhen function returns true
995
- if (!isError && (await this.shouldSendAutomatically())) {
996
- await this.makeRequest({
997
- trigger: 'submit-message',
998
- messageId: this.lastMessage?.id,
999
- metadata,
1000
- headers,
1001
- body,
1002
- });
1124
+ if (!isAbort && !isError) {
1125
+ await this.runAutomaticRequest(() =>
1126
+ this.makeRequest({
1127
+ trigger: 'submit-message',
1128
+ messageId: this.lastMessage?.id,
1129
+ metadata,
1130
+ headers,
1131
+ body,
1132
+ }),
1133
+ );
1003
1134
  }
1004
1135
  }
1005
1136
  }
@@ -12,8 +12,21 @@ import type { UIMessage } from './ui-messages';
12
12
  export class DefaultChatTransport<
13
13
  UI_MESSAGE extends UIMessage,
14
14
  > extends HttpChatTransport<UI_MESSAGE> {
15
- constructor(options: HttpChatTransportInitOptions<UI_MESSAGE> = {}) {
15
+ readonly resumeStreamIsReplay: boolean;
16
+
17
+ constructor({
18
+ resumeStreamIsReplay = true,
19
+ ...options
20
+ }: HttpChatTransportInitOptions<UI_MESSAGE> & {
21
+ /**
22
+ * Whether resumed streams replay the response from the beginning.
23
+ * Defaults to true. Set to false for endpoints that only continue the
24
+ * interrupted response, even when they emit a start chunk.
25
+ */
26
+ resumeStreamIsReplay?: boolean;
27
+ } = {}) {
16
28
  super(options);
29
+ this.resumeStreamIsReplay = resumeStreamIsReplay;
17
30
  }
18
31
 
19
32
  protected processResponseStream(
@@ -120,6 +120,8 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
120
120
  onError,
121
121
  onToolCall,
122
122
  onData,
123
+ resetStateOnMessageIdChange = false,
124
+ resetStateOnFirstMessageStart = false,
123
125
  }: {
124
126
  // input stream is not fully typed yet:
125
127
  stream: ReadableStream<UIMessageChunk>;
@@ -136,7 +138,13 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
136
138
  }) => Promise<void>,
137
139
  ) => Promise<void>;
138
140
  onError: ErrorHandler;
141
+ // During resume, a different message ID identifies a separate response.
142
+ resetStateOnMessageIdChange?: boolean;
143
+ // A transport can replay a response from the beginning using the same ID.
144
+ resetStateOnFirstMessageStart?: boolean;
139
145
  }): ReadableStream<InferUIMessageChunk<UI_MESSAGE>> {
146
+ let isFirstMessageStart = true;
147
+
140
148
  return stream.pipeThrough(
141
149
  new TransformStream<UIMessageChunk, InferUIMessageChunk<UI_MESSAGE>>({
142
150
  async transform(chunk, controller) {
@@ -878,13 +886,39 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
878
886
  }
879
887
 
880
888
  case 'start': {
889
+ const shouldReset =
890
+ (resetStateOnFirstMessageStart && isFirstMessageStart) ||
891
+ (resetStateOnMessageIdChange &&
892
+ chunk.messageId != null &&
893
+ chunk.messageId !== state.message.id);
894
+ isFirstMessageStart = false;
895
+
896
+ if (shouldReset) {
897
+ // Rebuild a replay or separate response from empty state. Only
898
+ // reset a replay once: merged streams can emit more starts.
899
+ // Keep the state object for its callers and preserve the ID
900
+ // when the replay does not include one.
901
+ Object.assign(
902
+ state,
903
+ createStreamingUIMessageState({
904
+ lastMessage: undefined,
905
+ messageId: chunk.messageId ?? state.message.id,
906
+ }),
907
+ { finishReason: undefined },
908
+ );
909
+ }
910
+
881
911
  if (chunk.messageId != null) {
882
912
  state.message.id = chunk.messageId;
883
913
  }
884
914
 
885
915
  await updateMessageMetadata(chunk.messageMetadata);
886
916
 
887
- if (chunk.messageId != null || chunk.messageMetadata != null) {
917
+ if (
918
+ shouldReset ||
919
+ chunk.messageId != null ||
920
+ chunk.messageMetadata != null
921
+ ) {
888
922
  write({ updateStatus: false });
889
923
  }
890
924
  break;
@@ -95,6 +95,7 @@ export function readUIMessageStream<UI_MESSAGE extends UIMessage>({
95
95
  consumeStream({
96
96
  stream: processUIMessageStream({
97
97
  stream,
98
+ resetStateOnMessageIdChange: true,
98
99
  runUpdateMessageJob(
99
100
  job: (options: {
100
101
  state: StreamingUIMessageState<UI_MESSAGE>;
@@ -3,6 +3,7 @@ import type { Job } from './job';
3
3
  export class SerialJobExecutor {
4
4
  private queue: Array<Job> = [];
5
5
  private isProcessing = false;
6
+ private idleWaiters: Array<() => void> = [];
6
7
 
7
8
  private async processQueue() {
8
9
  if (this.isProcessing) {
@@ -11,12 +12,18 @@ export class SerialJobExecutor {
11
12
 
12
13
  this.isProcessing = true;
13
14
 
14
- while (this.queue.length > 0) {
15
- await this.queue[0]();
16
- this.queue.shift();
17
- }
15
+ try {
16
+ while (this.queue.length > 0) {
17
+ await this.queue[0]();
18
+ this.queue.shift();
19
+ }
20
+ } finally {
21
+ this.isProcessing = false;
18
22
 
19
- this.isProcessing = false;
23
+ for (const resolve of this.idleWaiters.splice(0)) {
24
+ resolve();
25
+ }
26
+ }
20
27
  }
21
28
 
22
29
  async run(job: Job): Promise<void> {
@@ -33,4 +40,14 @@ export class SerialJobExecutor {
33
40
  void this.processQueue();
34
41
  });
35
42
  }
43
+
44
+ async waitForIdle(): Promise<void> {
45
+ if (!this.isProcessing && this.queue.length === 0) {
46
+ return;
47
+ }
48
+
49
+ await new Promise<void>(resolve => {
50
+ this.idleWaiters.push(resolve);
51
+ });
52
+ }
36
53
  }