@ateam-ai-mobile/ui 0.1.8 → 0.1.10
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/dist/index.d.mts +63 -47
- package/dist/index.d.ts +63 -47
- package/dist/index.js +163 -70
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +195 -102
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/auth/HandoffScreen.tsx +3 -11
- package/src/auth/LoginScreen.tsx +68 -17
- package/src/auth/OpenJoinScreen.tsx +4 -6
- package/src/auth/handoffExchange.ts +6 -9
- package/src/auth/login.ts +5 -10
- package/src/auth/openJoin.ts +5 -8
- package/src/index.ts +1 -1
- package/src/plugin/EmbeddedPluginWebView.tsx +4 -1
- package/src/voice/voiceSession.ts +150 -64
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
type VoicePersona,
|
|
40
40
|
type VoiceEvent,
|
|
41
41
|
type VoiceSubscription,
|
|
42
|
+
type VoiceDeviceToolCall,
|
|
43
|
+
type VoiceDeviceToolReason,
|
|
42
44
|
} from '@ateam-ai-mobile/plugin-runtime';
|
|
43
45
|
|
|
44
46
|
// Re-export the transcript/state shapes under their historical names so existing
|
|
@@ -220,6 +222,33 @@ const buildObserveScript = (text: string) => `
|
|
|
220
222
|
})(${JSON.stringify(text)}); true;
|
|
221
223
|
`;
|
|
222
224
|
|
|
225
|
+
/** How much of a device-tool error the snapshot keeps. The model still gets the
|
|
226
|
+
* whole message; the snapshot is a diagnostic any reader of it can see, and a
|
|
227
|
+
* tool's error can echo the call's arguments (what the user said). */
|
|
228
|
+
const DEVICE_TOOL_ERROR_MAX = 200;
|
|
229
|
+
|
|
230
|
+
function clipError(message: string): string {
|
|
231
|
+
const chars = Array.from(message); // by code point, so an emoji is never split
|
|
232
|
+
return chars.length > DEVICE_TOOL_ERROR_MAX
|
|
233
|
+
? chars.slice(0, DEVICE_TOOL_ERROR_MAX - 1).join('') + '…'
|
|
234
|
+
: message;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A device-tool call's answer — what the mini-app gets back (plus its
|
|
238
|
+
* correlationId) and what `lastDeviceTool` is built from. ONE object for both,
|
|
239
|
+
* so the model's answer and the snapshot can never disagree. */
|
|
240
|
+
type DeviceToolAnswer =
|
|
241
|
+
| { ok: true; result: any }
|
|
242
|
+
| { ok: false; reason: VoiceDeviceToolReason; error: string };
|
|
243
|
+
|
|
244
|
+
/** A finished call, tagged with the session owner's pluginId when the tool is
|
|
245
|
+
* that plugin's own; null when it is not (another solution's tool, the
|
|
246
|
+
* platform's, an unregistered name, no name) — then only the platform sees it. */
|
|
247
|
+
interface DeviceToolRecord {
|
|
248
|
+
owner: string | null;
|
|
249
|
+
call: VoiceDeviceToolCall;
|
|
250
|
+
}
|
|
251
|
+
|
|
223
252
|
/** Origin of a URL, or null if it can't be parsed. */
|
|
224
253
|
function originOf(url?: string | null): string | null {
|
|
225
254
|
if (!url) return null;
|
|
@@ -239,8 +268,11 @@ interface InternalState {
|
|
|
239
268
|
userSpeaking: boolean;
|
|
240
269
|
/** Epoch ms of the last speaking:false, or null before the first utterance. */
|
|
241
270
|
lastUserSpeechEndedAt: number | null;
|
|
242
|
-
/**
|
|
243
|
-
|
|
271
|
+
/** This session's finished device-tool calls: the LATEST per owner tag (see
|
|
272
|
+
* DeviceToolRecord), in the order they finished. The platform reads the last
|
|
273
|
+
* one; the owning plugin reads its own (lastDeviceToolFor) — one store, both
|
|
274
|
+
* views derived from it. */
|
|
275
|
+
deviceToolCalls: DeviceToolRecord[];
|
|
244
276
|
/** The WebView should be mounted (a session is wanted / live). */
|
|
245
277
|
mounted: boolean;
|
|
246
278
|
/** The WebView source, computed ONCE at start() so state changes (transcript,
|
|
@@ -262,13 +294,13 @@ const IDLE: InternalState = {
|
|
|
262
294
|
muted: false,
|
|
263
295
|
userSpeaking: false,
|
|
264
296
|
lastUserSpeechEndedAt: null,
|
|
265
|
-
|
|
297
|
+
deviceToolCalls: [],
|
|
266
298
|
mounted: false,
|
|
267
299
|
webViewSource: null,
|
|
268
300
|
skillSlug: undefined,
|
|
269
301
|
};
|
|
270
302
|
|
|
271
|
-
type Pending = { resolve: (s: VoiceSnapshot) => void; reject: (e: Error) => void; timer: any };
|
|
303
|
+
type Pending = { caller: VoiceCaller; resolve: (s: VoiceSnapshot) => void; reject: (e: Error) => void; timer: any };
|
|
272
304
|
|
|
273
305
|
class VoiceSessionManager implements VoiceSessionProvider {
|
|
274
306
|
private state: InternalState = { ...IDLE };
|
|
@@ -325,6 +357,12 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
325
357
|
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
326
358
|
private toolScope: string | null = null;
|
|
327
359
|
|
|
360
|
+
/** Bumped whenever a session begins or ends. A device-tool call remembers the
|
|
361
|
+
* epoch it arrived in, and its outcome is recorded only if that session is
|
|
362
|
+
* still the current one — a tool that finishes after hang-up (or after a new
|
|
363
|
+
* call started) must not show up as the new session's last call. */
|
|
364
|
+
private sessionEpoch = 0;
|
|
365
|
+
|
|
328
366
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
329
367
|
* controls any session — it is the user's own surface. A plugin caller may
|
|
330
368
|
* only touch a session it owns. */
|
|
@@ -365,11 +403,11 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
365
403
|
// different skillSlug does NOT restart it.
|
|
366
404
|
if (this.state.callState === 'connected') {
|
|
367
405
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
368
|
-
return this.realSnapshot();
|
|
406
|
+
return this.realSnapshot(caller);
|
|
369
407
|
}
|
|
370
408
|
if (this.state.callState === 'connecting') {
|
|
371
409
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
372
|
-
return this.waitForConnect();
|
|
410
|
+
return this.waitForConnect(caller);
|
|
373
411
|
}
|
|
374
412
|
|
|
375
413
|
if (opts?.skills === 'none' && opts?.skillSlug) {
|
|
@@ -404,11 +442,11 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
404
442
|
const after = this.getInternal().callState;
|
|
405
443
|
if (after === 'connected') {
|
|
406
444
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
407
|
-
return this.realSnapshot();
|
|
445
|
+
return this.realSnapshot(caller);
|
|
408
446
|
}
|
|
409
447
|
if (after === 'connecting') {
|
|
410
448
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
411
|
-
return this.waitForConnect();
|
|
449
|
+
return this.waitForConnect(caller);
|
|
412
450
|
}
|
|
413
451
|
|
|
414
452
|
// Idle → this caller becomes the owner of the new session. A NEW session
|
|
@@ -417,6 +455,7 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
417
455
|
this.toolScope = opts?.skills === 'none' && typeof caller === 'string' ? caller : null;
|
|
418
456
|
this.frozenTools = null;
|
|
419
457
|
this.frozenSessionId = null;
|
|
458
|
+
this.sessionEpoch++;
|
|
420
459
|
const skillSlug = opts?.skillSlug;
|
|
421
460
|
this.setState({
|
|
422
461
|
callState: 'connecting',
|
|
@@ -425,12 +464,15 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
425
464
|
aiPartial: '',
|
|
426
465
|
userPartial: '',
|
|
427
466
|
muted: false,
|
|
467
|
+
// A session that ended from inside the voice page (not via stop()) left
|
|
468
|
+
// its calls here; the new session starts with none.
|
|
469
|
+
deviceToolCalls: [],
|
|
428
470
|
mounted: true,
|
|
429
471
|
skillSlug,
|
|
430
472
|
persona: opts?.persona,
|
|
431
473
|
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) },
|
|
432
474
|
});
|
|
433
|
-
return this.waitForConnect();
|
|
475
|
+
return this.waitForConnect(caller);
|
|
434
476
|
}
|
|
435
477
|
|
|
436
478
|
stop(caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
@@ -449,6 +491,7 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
449
491
|
this.rejectPending(new Error('voice: session stopped before it connected'));
|
|
450
492
|
this.frozenTools = null; // the session's authorized set ends with the session
|
|
451
493
|
this.frozenSessionId = null;
|
|
494
|
+
this.sessionEpoch++; // a tool still running for this session records nothing
|
|
452
495
|
this.setState({
|
|
453
496
|
...IDLE,
|
|
454
497
|
// keep the transcript of the call that just ended for any reader still up
|
|
@@ -492,10 +535,12 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
492
535
|
// nothing about the conversation another solution is holding. This is not a
|
|
493
536
|
// fake idle (invariant 6 is about an UNDECLARED plugin): a declared plugin
|
|
494
537
|
// that does not own the live session genuinely has none of its own.
|
|
495
|
-
return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
|
|
538
|
+
return this.canAccess(caller) ? this.realSnapshot(caller) : this.foreignSnapshot();
|
|
496
539
|
}
|
|
497
540
|
|
|
498
|
-
|
|
541
|
+
/** The snapshot for a caller that may access the session. Everything is the
|
|
542
|
+
* session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
|
|
543
|
+
private realSnapshot(caller: VoiceCaller): VoiceSnapshot {
|
|
499
544
|
const s = this.state;
|
|
500
545
|
return {
|
|
501
546
|
state: s.callState,
|
|
@@ -505,12 +550,25 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
505
550
|
listening: !!s.userPartial,
|
|
506
551
|
userSpeaking: s.userSpeaking,
|
|
507
552
|
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
508
|
-
lastDeviceTool:
|
|
553
|
+
lastDeviceTool: this.lastDeviceToolFor(caller),
|
|
509
554
|
authorizedSessionId: this.frozenSessionId,
|
|
510
555
|
lastError: s.error,
|
|
511
556
|
};
|
|
512
557
|
}
|
|
513
558
|
|
|
559
|
+
/**
|
|
560
|
+
* The last finished device-tool call THIS caller may see. The platform (the
|
|
561
|
+
* user's own surface) sees every call. A plugin sees only calls to tools it
|
|
562
|
+
* registered — even on a session it owns, the model can call another
|
|
563
|
+
* solution's tool (a normal call offers every registered tool), and that
|
|
564
|
+
* tool's name and error are not this plugin's to read.
|
|
565
|
+
*/
|
|
566
|
+
private lastDeviceToolFor(caller: VoiceCaller): VoiceDeviceToolCall | null {
|
|
567
|
+
const calls = this.state.deviceToolCalls;
|
|
568
|
+
if (caller === PLATFORM_VOICE_CALLER) return calls.length ? calls[calls.length - 1].call : null;
|
|
569
|
+
return calls.find(r => r.owner === caller)?.call ?? null;
|
|
570
|
+
}
|
|
571
|
+
|
|
514
572
|
private foreignSnapshot(): VoiceSnapshot {
|
|
515
573
|
return {
|
|
516
574
|
state: 'idle', sessionId: null, muted: false, speaking: false, listening: false,
|
|
@@ -648,26 +706,75 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
648
706
|
console.warn(`[voiceSession] REFUSED device tool call from "${sender || 'unknown origin'}" (expected "${expected}")`);
|
|
649
707
|
break;
|
|
650
708
|
}
|
|
651
|
-
|
|
652
|
-
// AUTHORIZATION against the FROZEN session set, not the live registry
|
|
653
|
-
// (GPT gate 1). A tool that appeared after connect — the drift case — is
|
|
654
|
-
// refused here, before execution, even if it is registered right now.
|
|
655
|
-
if (!this.isToolAuthorized(data.name)) {
|
|
656
|
-
// Both refuse (fail closed), but they are DIFFERENT answers to the model:
|
|
657
|
-
// NOT-YET-TOLD (Core's allowset hasn't arrived) is transient — retry;
|
|
658
|
-
// TOLD-AND-DENIED (allowset present, tool not in it / wrong session /
|
|
659
|
-
// unregistered) is final. Same refusal, different sentence.
|
|
660
|
-
const notReady = this.frozenTools === null;
|
|
661
|
-
console.warn(`[voiceSession] REFUSED "${data.name}" — ${notReady ? 'allowset not received yet' : 'not authorized for this session'}`);
|
|
662
|
-
this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
|
|
663
|
-
break;
|
|
664
|
-
}
|
|
665
|
-
void this.runDeviceTool(data);
|
|
709
|
+
void this.handleDeviceToolCall(data);
|
|
666
710
|
break;
|
|
667
711
|
}
|
|
668
712
|
}
|
|
669
713
|
}
|
|
670
714
|
|
|
715
|
+
/**
|
|
716
|
+
* One device-tool call, arrival to outcome: validate → authorize → run. The
|
|
717
|
+
* answer goes back to the mini-app (the model reads it) AND becomes
|
|
718
|
+
* `lastDeviceTool` — the same object, recorded only once it is known, so a
|
|
719
|
+
* refused call never looks like one that ran.
|
|
720
|
+
*/
|
|
721
|
+
private async handleDeviceToolCall(data: any): Promise<void> {
|
|
722
|
+
const { correlationId, name, args, timeoutMs } = data || {};
|
|
723
|
+
const at = Date.now();
|
|
724
|
+
const epoch = this.sessionEpoch;
|
|
725
|
+
const toolName = typeof name === 'string' ? name : '';
|
|
726
|
+
// Which plugin may later read this call. The only plugin that can read this
|
|
727
|
+
// session's snapshot at all is its owner (any other gets the foreign one), so
|
|
728
|
+
// the call is tagged with the owner only if the tool is registered BY it, as
|
|
729
|
+
// of arrival — the same check a clean call's scope uses. Anything else
|
|
730
|
+
// (another solution's tool, the platform's, an unregistered name) is null:
|
|
731
|
+
// the platform's to see, no plugin's.
|
|
732
|
+
const sessionOwner = this.owner;
|
|
733
|
+
const owner = typeof sessionOwner === 'string' && toolName && hasDeviceTool(toolName, sessionOwner)
|
|
734
|
+
? sessionOwner
|
|
735
|
+
: null;
|
|
736
|
+
|
|
737
|
+
let answer: DeviceToolAnswer;
|
|
738
|
+
if (!toolName) {
|
|
739
|
+
answer = { ok: false, reason: 'invalid', error: 'The device tool call named no tool.' };
|
|
740
|
+
} else if (!correlationId) {
|
|
741
|
+
answer = {
|
|
742
|
+
ok: false, reason: 'invalid',
|
|
743
|
+
error: `The call to "${toolName}" carried no correlationId, so it could not be answered.`,
|
|
744
|
+
};
|
|
745
|
+
} else if (!this.isToolAuthorized(toolName)) {
|
|
746
|
+
// AUTHORIZATION against the FROZEN session set, not the live registry
|
|
747
|
+
// (GPT gate 1). A tool that appeared after connect — the drift case — is
|
|
748
|
+
// refused here, before execution, even if it is registered right now.
|
|
749
|
+
// Both refusals fail closed, but they are DIFFERENT answers to the model:
|
|
750
|
+
// NOT-YET-TOLD (Core's allowset hasn't arrived) is transient — retry;
|
|
751
|
+
// TOLD-AND-DENIED (allowset present, tool not in it / wrong session /
|
|
752
|
+
// unregistered) is final. Same refusal, different sentence.
|
|
753
|
+
const notReady = this.frozenTools === null;
|
|
754
|
+
console.warn(`[voiceSession] REFUSED "${toolName}" — ${notReady ? 'allowset not received yet' : 'not authorized for this session'}`);
|
|
755
|
+
answer = notReady
|
|
756
|
+
? {
|
|
757
|
+
ok: false, reason: 'not_ready',
|
|
758
|
+
error: 'The voice session is still starting up — its tool allowset has not arrived yet. Try again in a moment.',
|
|
759
|
+
}
|
|
760
|
+
: { ok: false, reason: 'not_authorized', error: `"${toolName}" is not available in this voice session.` };
|
|
761
|
+
} else {
|
|
762
|
+
answer = await this.runDeviceTool(toolName, args, timeoutMs);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A call without a correlationId cannot be answered — nothing is waiting on it.
|
|
766
|
+
if (correlationId) this.postDeviceToolResult({ correlationId, ...answer });
|
|
767
|
+
|
|
768
|
+
if (epoch !== this.sessionEpoch) return; // its session ended while it ran
|
|
769
|
+
const ms = Math.max(0, Date.now() - at);
|
|
770
|
+
const call: VoiceDeviceToolCall = answer.ok
|
|
771
|
+
? { name: toolName, at, ms, ok: true, reason: null, error: null }
|
|
772
|
+
: { name: toolName, at, ms, ok: false, reason: answer.reason, error: clipError(answer.error) };
|
|
773
|
+
this.setState({
|
|
774
|
+
deviceToolCalls: [...this.state.deviceToolCalls.filter(r => r.owner !== owner), { owner, call }],
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
671
778
|
/**
|
|
672
779
|
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
673
780
|
* registry, bound to the ACTIVE session. All must hold:
|
|
@@ -688,19 +795,9 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
688
795
|
return hasDeviceTool(name, this.toolScope ?? undefined); // still registered locally right now
|
|
689
796
|
}
|
|
690
797
|
|
|
691
|
-
/**
|
|
692
|
-
*
|
|
693
|
-
|
|
694
|
-
private postDeviceToolRefusal(correlationId: string, name: string, notReady: boolean): void {
|
|
695
|
-
const payload = notReady
|
|
696
|
-
? {
|
|
697
|
-
correlationId, ok: false, reason: 'not_ready',
|
|
698
|
-
error: 'The voice session is still starting up — its tool allowset has not arrived yet. Try again in a moment.',
|
|
699
|
-
}
|
|
700
|
-
: {
|
|
701
|
-
correlationId, ok: false, reason: 'not_authorized',
|
|
702
|
-
error: `"${name}" is not available in this voice session.`,
|
|
703
|
-
};
|
|
798
|
+
/** Answer the mini-app's pending device-tool request (a call's outcome, or the
|
|
799
|
+
* tool list) — it resolves the request with this payload by correlationId. */
|
|
800
|
+
private postDeviceToolResult(payload: { correlationId: string } & Record<string, unknown>): void {
|
|
704
801
|
this.webView?.injectJavaScript?.(
|
|
705
802
|
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
706
803
|
JSON.stringify(payload),
|
|
@@ -718,31 +815,19 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
718
815
|
description: t.description,
|
|
719
816
|
input_schema: t.input_schema,
|
|
720
817
|
}));
|
|
721
|
-
this.
|
|
722
|
-
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
723
|
-
JSON.stringify({ correlationId, ok: true, tools }),
|
|
724
|
-
)}); true;`,
|
|
725
|
-
);
|
|
818
|
+
this.postDeviceToolResult({ correlationId, ok: true, tools });
|
|
726
819
|
}
|
|
727
820
|
|
|
728
|
-
/** Run
|
|
729
|
-
private async runDeviceTool(
|
|
730
|
-
const { correlationId, name, args, timeoutMs } = data || {};
|
|
731
|
-
if (!correlationId || typeof name !== 'string') return;
|
|
732
|
-
let payload: any;
|
|
821
|
+
/** Run an authorized device tool. Never throws: every failure is an answer. */
|
|
822
|
+
private async runDeviceTool(name: string, args: any, timeoutMs: number | undefined): Promise<DeviceToolAnswer> {
|
|
733
823
|
try {
|
|
734
824
|
const outcome = await callDeviceTool(name, args || {}, { timeoutMs });
|
|
735
|
-
|
|
736
|
-
? {
|
|
737
|
-
: {
|
|
825
|
+
return outcome.ok
|
|
826
|
+
? { ok: true, result: outcome.result }
|
|
827
|
+
: { ok: false, reason: outcome.reason, error: outcome.error };
|
|
738
828
|
} catch (err: any) {
|
|
739
|
-
|
|
829
|
+
return { ok: false, reason: 'failed', error: err?.message || String(err) };
|
|
740
830
|
}
|
|
741
|
-
this.webView?.injectJavaScript?.(
|
|
742
|
-
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
743
|
-
JSON.stringify(payload),
|
|
744
|
-
)}); true;`,
|
|
745
|
-
);
|
|
746
831
|
}
|
|
747
832
|
|
|
748
833
|
// ── internals ────────────────────────────────────────────────
|
|
@@ -763,23 +848,23 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
763
848
|
}
|
|
764
849
|
}
|
|
765
850
|
|
|
766
|
-
private waitForConnect(): Promise<VoiceSnapshot> {
|
|
851
|
+
private waitForConnect(caller: VoiceCaller): Promise<VoiceSnapshot> {
|
|
767
852
|
return new Promise<VoiceSnapshot>((resolve, reject) => {
|
|
768
853
|
const timer = setTimeout(() => {
|
|
769
854
|
this.pending = this.pending.filter(p => p.timer !== timer);
|
|
770
855
|
reject(new Error('voice: timed out waiting to connect (is VoiceSessionHost mounted?)'));
|
|
771
856
|
}, CONNECT_TIMEOUT_MS);
|
|
772
|
-
this.pending.push({ resolve, reject, timer });
|
|
857
|
+
this.pending.push({ caller, resolve, reject, timer });
|
|
773
858
|
});
|
|
774
859
|
}
|
|
775
860
|
|
|
776
861
|
private resolvePending(): void {
|
|
777
862
|
// Everyone still waiting on connect already passed the access check in
|
|
778
|
-
// start(), so
|
|
779
|
-
|
|
863
|
+
// start(), so each gets the real snapshot — as ITS caller (lastDeviceTool
|
|
864
|
+
// is scoped per caller).
|
|
780
865
|
const waiting = this.pending;
|
|
781
866
|
this.pending = [];
|
|
782
|
-
for (const p of waiting) { clearTimeout(p.timer); p.resolve(
|
|
867
|
+
for (const p of waiting) { clearTimeout(p.timer); p.resolve(this.realSnapshot(p.caller)); }
|
|
783
868
|
}
|
|
784
869
|
|
|
785
870
|
private rejectPending(err: Error): void {
|
|
@@ -798,6 +883,7 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
798
883
|
this.pendingStarts.clear();
|
|
799
884
|
this.frozenTools = null;
|
|
800
885
|
this.frozenSessionId = null;
|
|
886
|
+
this.sessionEpoch++;
|
|
801
887
|
this.listeners.clear();
|
|
802
888
|
this.webView = null;
|
|
803
889
|
}
|