@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/ui",
3
- "version": "0.1.7",
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.7",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.7",
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": {
@@ -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 and the tools path. Memoized so the plugin Component doesn't
90
- // re-render on a fresh object every pass (useApi effects key off prop identity);
91
- // `plugin` is in deps so state-domain fallback picks up the bundle once loaded.
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
- // Idle → this caller becomes the owner of the new session.
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
- webViewSource: { uri: this.buildUri(skillSlug) },
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, high-frequency channel (safe once or twice a
397
- * second) behind a future props.voice.observe(); today the host uses it to run
398
- * the real-device stream proof (VOICE_RT_STREAM_DEVICE_PROOF) before the gated
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, lastError: 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
- return `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`;
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
- /** Answer "what device tools do you have?" — the client is the authority. */
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 tools = listDeviceTools().map(t => ({
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
  }