@ateam-ai-mobile/ui 0.1.8 → 0.1.9

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.8",
3
+ "version": "0.1.9",
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.8",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.8",
12
+ "@ateam-ai-mobile/core": "0.1.9",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.9",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -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
- /** The last device-tool call the model made (name + when), or null. */
243
- lastDeviceTool: { name: string; at: number } | null;
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
- lastDeviceTool: null,
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
- private realSnapshot(): VoiceSnapshot {
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: s.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
- 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
- }
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
- /** 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
- };
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.webView?.injectJavaScript?.(
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 a device tool and post the outcome back into the WebView. */
729
- private async runDeviceTool(data: any): Promise<void> {
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
- payload = outcome.ok
736
- ? { correlationId, ok: true, result: outcome.result }
737
- : { correlationId, ok: false, error: outcome.error, reason: outcome.reason };
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
- payload = { correlationId, ok: false, reason: 'failed', error: err?.message || String(err) };
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 they get the real snapshot.
779
- const snap = this.realSnapshot();
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(snap); }
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
  }