@ateam-ai-mobile/ui 0.1.7 → 0.1.8
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 +66 -6
- package/dist/index.d.ts +66 -6
- package/dist/index.js +153 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +154 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/plugin/Plugin.tsx +4 -3
- package/src/voice/voiceSession.ts +190 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Prebuilt ADAS chat experience for React Native — full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"build": "tsup"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@ateam-ai-mobile/core": "0.1.
|
|
13
|
-
"@ateam-ai-mobile/plugin-runtime": "0.1.
|
|
12
|
+
"@ateam-ai-mobile/core": "0.1.8",
|
|
13
|
+
"@ateam-ai-mobile/plugin-runtime": "0.1.8",
|
|
14
14
|
"zustand": "^5.0.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
package/src/plugin/Plugin.tsx
CHANGED
|
@@ -86,9 +86,10 @@ export default function Plugin({ id, manifest, commandBufferRef, props: enginePr
|
|
|
86
86
|
const bridge = useNativeBridge(id, connectorId, commandBufferRef);
|
|
87
87
|
// ONE runtime contract — the SAME resolver every surface uses, so this drawer/
|
|
88
88
|
// fullscreen mount gates device/native + deviceState identically to the
|
|
89
|
-
// embedded mount
|
|
90
|
-
//
|
|
91
|
-
//
|
|
89
|
+
// embedded mount. Memoized so the plugin Component doesn't re-render on a fresh
|
|
90
|
+
// object every pass (useApi effects key off prop identity). The manifest is the
|
|
91
|
+
// sole authority here (it is a required prop); `plugin` is read by the resolver
|
|
92
|
+
// only when there is no manifest, so the loaded bundle never widens its grants.
|
|
92
93
|
const { device, native, deviceState, voice } = useMemo(
|
|
93
94
|
() => resolvePluginRuntimeProps({ pluginId: id, manifest: manifest as any, plugin: plugin as any }),
|
|
94
95
|
[id, manifest, plugin],
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
callDeviceTool,
|
|
29
29
|
ensureDeviceToolsLoaded,
|
|
30
30
|
listDeviceTools,
|
|
31
|
+
hasDeviceTool,
|
|
31
32
|
PLATFORM_VOICE_CALLER,
|
|
32
33
|
type VoiceSessionProvider,
|
|
33
34
|
type VoiceCaller,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
type VoiceState,
|
|
36
37
|
type VoiceTranscriptEntry,
|
|
37
38
|
type VoiceStartOptions,
|
|
39
|
+
type VoicePersona,
|
|
38
40
|
type VoiceEvent,
|
|
39
41
|
type VoiceSubscription,
|
|
40
42
|
} from '@ateam-ai-mobile/plugin-runtime';
|
|
@@ -237,12 +239,17 @@ interface InternalState {
|
|
|
237
239
|
userSpeaking: boolean;
|
|
238
240
|
/** Epoch ms of the last speaking:false, or null before the first utterance. */
|
|
239
241
|
lastUserSpeechEndedAt: number | null;
|
|
242
|
+
/** The last device-tool call the model made (name + when), or null. */
|
|
243
|
+
lastDeviceTool: { name: string; at: number } | null;
|
|
240
244
|
/** The WebView should be mounted (a session is wanted / live). */
|
|
241
245
|
mounted: boolean;
|
|
242
246
|
/** The WebView source, computed ONCE at start() so state changes (transcript,
|
|
243
247
|
* partials) never remount the WebView and drop the session. */
|
|
244
248
|
webViewSource: { uri: string } | null;
|
|
245
249
|
skillSlug?: string;
|
|
250
|
+
/** Per-call persona override ({ name, style }) — sent to the voice page on the
|
|
251
|
+
* session URL and declared on POST /session; the server layers it. */
|
|
252
|
+
persona?: VoicePersona;
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
const IDLE: InternalState = {
|
|
@@ -255,6 +262,7 @@ const IDLE: InternalState = {
|
|
|
255
262
|
muted: false,
|
|
256
263
|
userSpeaking: false,
|
|
257
264
|
lastUserSpeechEndedAt: null,
|
|
265
|
+
lastDeviceTool: null,
|
|
258
266
|
mounted: false,
|
|
259
267
|
webViewSource: null,
|
|
260
268
|
skillSlug: undefined,
|
|
@@ -279,6 +287,44 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
279
287
|
*/
|
|
280
288
|
private owner: VoiceCaller | null = null;
|
|
281
289
|
|
|
290
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
291
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
292
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
293
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
294
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
295
|
+
* all of them); another plugin's stop cancels none. */
|
|
296
|
+
private pendingStarts = new Set<{ caller: VoiceCaller; cancelled: boolean }>();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
300
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
301
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
302
|
+
*
|
|
303
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
304
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
305
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
306
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
307
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
308
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
309
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
310
|
+
* never feeds it.
|
|
311
|
+
*/
|
|
312
|
+
private frozenTools: Set<string> | null = null;
|
|
313
|
+
|
|
314
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
315
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
316
|
+
* allowset from a previous/other session is refused. */
|
|
317
|
+
private frozenSessionId: string | null = null;
|
|
318
|
+
|
|
319
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
320
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
321
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
322
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
323
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
324
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
325
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
326
|
+
private toolScope: string | null = null;
|
|
327
|
+
|
|
282
328
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
283
329
|
* controls any session — it is the user's own surface. A plugin caller may
|
|
284
330
|
* only touch a session it owns. */
|
|
@@ -326,6 +372,12 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
326
372
|
return this.waitForConnect();
|
|
327
373
|
}
|
|
328
374
|
|
|
375
|
+
if (opts?.skills === 'none' && opts?.skillSlug) {
|
|
376
|
+
throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
|
|
377
|
+
}
|
|
378
|
+
const ticket = { caller, cancelled: false };
|
|
379
|
+
this.pendingStarts.add(ticket);
|
|
380
|
+
|
|
329
381
|
// Device tools only exist once their bundle is evaluated; a user who goes
|
|
330
382
|
// straight to voice can otherwise ask for a tool the phone never loaded.
|
|
331
383
|
// Best-effort — a failure just means no device tools this time.
|
|
@@ -344,8 +396,27 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
344
396
|
// error rather than us blocking the call.
|
|
345
397
|
}
|
|
346
398
|
|
|
347
|
-
|
|
399
|
+
this.pendingStarts.delete(ticket);
|
|
400
|
+
if (ticket.cancelled) throw new Error('voice: stopped before the session started');
|
|
401
|
+
// Another start() may have mounted a session while we awaited — attach to it
|
|
402
|
+
// (or be refused) exactly as the entry checks would have, never mount twice.
|
|
403
|
+
// (Re-read: the awaits above can change it; the entry checks' narrowing can't.)
|
|
404
|
+
const after = this.getInternal().callState;
|
|
405
|
+
if (after === 'connected') {
|
|
406
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
407
|
+
return this.realSnapshot();
|
|
408
|
+
}
|
|
409
|
+
if (after === 'connecting') {
|
|
410
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
411
|
+
return this.waitForConnect();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Idle → this caller becomes the owner of the new session. A NEW session
|
|
415
|
+
// re-freezes its tool set on the next tool-list request.
|
|
348
416
|
this.owner = caller;
|
|
417
|
+
this.toolScope = opts?.skills === 'none' && typeof caller === 'string' ? caller : null;
|
|
418
|
+
this.frozenTools = null;
|
|
419
|
+
this.frozenSessionId = null;
|
|
349
420
|
const skillSlug = opts?.skillSlug;
|
|
350
421
|
this.setState({
|
|
351
422
|
callState: 'connecting',
|
|
@@ -356,7 +427,8 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
356
427
|
muted: false,
|
|
357
428
|
mounted: true,
|
|
358
429
|
skillSlug,
|
|
359
|
-
|
|
430
|
+
persona: opts?.persona,
|
|
431
|
+
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) },
|
|
360
432
|
});
|
|
361
433
|
return this.waitForConnect();
|
|
362
434
|
}
|
|
@@ -365,8 +437,18 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
365
437
|
// A plugin may not hang up another solution's live call. (Idle stop is a
|
|
366
438
|
// harmless no-op for anyone.)
|
|
367
439
|
if (this.state.callState !== 'idle' && !this.canAccess(caller)) throw this.foreignError();
|
|
440
|
+
// Starts still before their mount: cancel ALL of this caller's (the platform:
|
|
441
|
+
// everyone's) — another plugin must not be able to abort someone's call.
|
|
442
|
+
for (const t of [...this.pendingStarts]) {
|
|
443
|
+
if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
|
|
444
|
+
t.cancelled = true;
|
|
445
|
+
this.pendingStarts.delete(t);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
368
448
|
this.webView?.injectJavaScript?.('window.hangUp && window.hangUp(); true;');
|
|
369
449
|
this.rejectPending(new Error('voice: session stopped before it connected'));
|
|
450
|
+
this.frozenTools = null; // the session's authorized set ends with the session
|
|
451
|
+
this.frozenSessionId = null;
|
|
370
452
|
this.setState({
|
|
371
453
|
...IDLE,
|
|
372
454
|
// keep the transcript of the call that just ended for any reader still up
|
|
@@ -393,10 +475,9 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
393
475
|
|
|
394
476
|
/**
|
|
395
477
|
* Stream a silent observation into the live session — tell the model without
|
|
396
|
-
* asking it to speak. The cheap
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* plugin API is added. Owner-scoped like every other control.
|
|
478
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
479
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
480
|
+
* material changes. Owner-scoped like every other control.
|
|
400
481
|
*/
|
|
401
482
|
observe(text: string, caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
402
483
|
if (this.state.callState !== 'connected') {
|
|
@@ -424,6 +505,8 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
424
505
|
listening: !!s.userPartial,
|
|
425
506
|
userSpeaking: s.userSpeaking,
|
|
426
507
|
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
508
|
+
lastDeviceTool: s.lastDeviceTool,
|
|
509
|
+
authorizedSessionId: this.frozenSessionId,
|
|
427
510
|
lastError: s.error,
|
|
428
511
|
};
|
|
429
512
|
}
|
|
@@ -431,7 +514,8 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
431
514
|
private foreignSnapshot(): VoiceSnapshot {
|
|
432
515
|
return {
|
|
433
516
|
state: 'idle', sessionId: null, muted: false, speaking: false, listening: false,
|
|
434
|
-
userSpeaking: false, lastUserSpeechEndedAt: null,
|
|
517
|
+
userSpeaking: false, lastUserSpeechEndedAt: null, lastDeviceTool: null,
|
|
518
|
+
authorizedSessionId: null, lastError: null,
|
|
435
519
|
};
|
|
436
520
|
}
|
|
437
521
|
|
|
@@ -451,12 +535,21 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
451
535
|
|
|
452
536
|
// ── WebView plumbing ─────────────────────────────────────────
|
|
453
537
|
|
|
454
|
-
private buildUri(skillSlug?: string): string {
|
|
538
|
+
private buildUri(skillSlug?: string, persona?: VoicePersona, skills?: VoiceStartOptions['skills']): string {
|
|
455
539
|
const token = getToken();
|
|
456
540
|
const tenant = getTenant();
|
|
457
541
|
const actorId = getUser()?.actorId || '';
|
|
458
542
|
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : '';
|
|
459
|
-
|
|
543
|
+
// Declared start option, carried on the same URL as tenant/authToken; the
|
|
544
|
+
// voice page forwards it as POST /session `persona`, where it's sanitized.
|
|
545
|
+
const p = persona && (persona.name || persona.style)
|
|
546
|
+
? { ...(persona.name ? { name: persona.name } : {}), ...(persona.style ? { style: persona.style } : {}) }
|
|
547
|
+
: null;
|
|
548
|
+
const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : '';
|
|
549
|
+
// Clean call: only the one reduce-only value is ever sent; anything else is
|
|
550
|
+
// not a request at all (the server accepts only 'none' too).
|
|
551
|
+
const skillsParam = skills === 'none' ? '&skills=none' : '';
|
|
552
|
+
return `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
|
|
460
553
|
}
|
|
461
554
|
|
|
462
555
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
@@ -523,6 +616,31 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
523
616
|
this.sendDeviceToolList(data.correlationId);
|
|
524
617
|
break;
|
|
525
618
|
}
|
|
619
|
+
// CORE's ALLOWSET for this session — what the model may ASK for. Core sends
|
|
620
|
+
// it (even empty, on purpose) at session start. It is a NECESSARY gate, not
|
|
621
|
+
// the whole story: a call must be in the allowset AND the device must
|
|
622
|
+
// actually have the tool (the registry check inside callDeviceTool) — Core
|
|
623
|
+
// says what may be asked, the device decides what it will do.
|
|
624
|
+
//
|
|
625
|
+
// Origin-checked (only the voice app may set it) and pinned ONCE per session
|
|
626
|
+
// (a later message cannot widen it). An EMPTY allowset (arrived) refuses
|
|
627
|
+
// everything but is distinct from NEVER-arrived (frozenTools stays null);
|
|
628
|
+
// both fail closed here — we never fall back to the phone's own registry,
|
|
629
|
+
// which is the seam this closes.
|
|
630
|
+
case 'deviceToolAllowset': {
|
|
631
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
632
|
+
const expected = originOf(getVoiceApiUrl());
|
|
633
|
+
if (!sender || !expected || sender !== expected) break;
|
|
634
|
+
// Bind the allowset to its sessionId. Pin (or REPLACE for a different
|
|
635
|
+
// session) but never WIDEN within the same session — a second allowset
|
|
636
|
+
// for the same sessionId is ignored. Requires a string sessionId, so an
|
|
637
|
+
// allowset without one never authorizes anything (fail closed).
|
|
638
|
+
if (Array.isArray(data.tools) && typeof data.sessionId === 'string' && data.sessionId !== this.frozenSessionId) {
|
|
639
|
+
this.frozenTools = new Set(data.tools.filter((n: unknown) => typeof n === 'string'));
|
|
640
|
+
this.frozenSessionId = data.sessionId;
|
|
641
|
+
}
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
526
644
|
case 'device-tool-call': {
|
|
527
645
|
const sender = originOf(event?.nativeEvent?.url);
|
|
528
646
|
const expected = originOf(getVoiceApiUrl());
|
|
@@ -530,15 +648,72 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
530
648
|
console.warn(`[voiceSession] REFUSED device tool call from "${sender || 'unknown origin'}" (expected "${expected}")`);
|
|
531
649
|
break;
|
|
532
650
|
}
|
|
651
|
+
if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
|
|
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
|
+
}
|
|
533
665
|
void this.runDeviceTool(data);
|
|
534
666
|
break;
|
|
535
667
|
}
|
|
536
668
|
}
|
|
537
669
|
}
|
|
538
670
|
|
|
539
|
-
/**
|
|
671
|
+
/**
|
|
672
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
673
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
674
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
675
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
676
|
+
* fails closed;
|
|
677
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
678
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
679
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
680
|
+
* clean call, registered by the call's owner.
|
|
681
|
+
* No fallback to the live registry alone, ever.
|
|
682
|
+
*/
|
|
683
|
+
private isToolAuthorized(name: unknown): boolean {
|
|
684
|
+
if (typeof name !== 'string') return false;
|
|
685
|
+
if (this.frozenTools === null || this.frozenSessionId === null) return false;
|
|
686
|
+
if (this.frozenSessionId !== this.state.sessionId) return false;
|
|
687
|
+
if (!this.frozenTools.has(name)) return false;
|
|
688
|
+
return hasDeviceTool(name, this.toolScope ?? undefined); // still registered locally right now
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
692
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
693
|
+
* final "not allowed in this session". */
|
|
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
|
+
};
|
|
704
|
+
this.webView?.injectJavaScript?.(
|
|
705
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
706
|
+
JSON.stringify(payload),
|
|
707
|
+
)}); true;`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
712
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
713
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
540
714
|
private sendDeviceToolList(correlationId: string): void {
|
|
541
|
-
const
|
|
715
|
+
const scope = this.toolScope;
|
|
716
|
+
const tools = listDeviceTools().filter(t => scope === null || t.pluginId === scope).map(t => ({
|
|
542
717
|
name: t.name,
|
|
543
718
|
description: t.description,
|
|
544
719
|
input_schema: t.input_schema,
|
|
@@ -619,6 +794,10 @@ class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
619
794
|
this.rejectPending(new Error('voice: reset'));
|
|
620
795
|
this.state = { ...IDLE };
|
|
621
796
|
this.owner = null;
|
|
797
|
+
this.toolScope = null;
|
|
798
|
+
this.pendingStarts.clear();
|
|
799
|
+
this.frozenTools = null;
|
|
800
|
+
this.frozenSessionId = null;
|
|
622
801
|
this.listeners.clear();
|
|
623
802
|
this.webView = null;
|
|
624
803
|
}
|