@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/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
3
3
  import * as React$1 from 'react';
4
4
  import React__default, { ReactNode } from 'react';
5
5
  import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
6
- import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
6
+ import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceDeviceToolCall, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
7
7
  import * as zustand from 'zustand';
8
8
 
9
9
  interface AdasChatScreenProps {
@@ -382,6 +382,13 @@ declare function VoiceCallSheet({ visible, onClose, skillSlug }: Props): react_j
382
382
 
383
383
  type CallState = VoiceState;
384
384
  type TranscriptEntry = VoiceTranscriptEntry;
385
+ /** A finished call, tagged with the session owner's pluginId when the tool is
386
+ * that plugin's own; null when it is not (another solution's tool, the
387
+ * platform's, an unregistered name, no name) — then only the platform sees it. */
388
+ interface DeviceToolRecord {
389
+ owner: string | null;
390
+ call: VoiceDeviceToolCall;
391
+ }
385
392
  interface InternalState {
386
393
  callState: CallState;
387
394
  sessionId: string | null;
@@ -394,11 +401,11 @@ interface InternalState {
394
401
  userSpeaking: boolean;
395
402
  /** Epoch ms of the last speaking:false, or null before the first utterance. */
396
403
  lastUserSpeechEndedAt: number | null;
397
- /** The last device-tool call the model made (name + when), or null. */
398
- lastDeviceTool: {
399
- name: string;
400
- at: number;
401
- } | null;
404
+ /** This session's finished device-tool calls: the LATEST per owner tag (see
405
+ * DeviceToolRecord), in the order they finished. The platform reads the last
406
+ * one; the owning plugin reads its own (lastDeviceToolFor) — one store, both
407
+ * views derived from it. */
408
+ deviceToolCalls: DeviceToolRecord[];
402
409
  /** The WebView should be mounted (a session is wanted / live). */
403
410
  mounted: boolean;
404
411
  /** The WebView source, computed ONCE at start() so state changes (transcript,
@@ -461,6 +468,11 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
461
468
  * and the bundleManager registers a plugin's tools under that same id (never
462
469
  * the bundle's own short `id`), so exact equality is the whole check. */
463
470
  private toolScope;
471
+ /** Bumped whenever a session begins or ends. A device-tool call remembers the
472
+ * epoch it arrived in, and its outcome is recorded only if that session is
473
+ * still the current one — a tool that finishes after hang-up (or after a new
474
+ * call started) must not show up as the new session's last call. */
475
+ private sessionEpoch;
464
476
  /** The platform (built-in sheet, mic indicator, session host) sees and
465
477
  * controls any session — it is the user's own surface. A plugin caller may
466
478
  * only touch a session it owns. */
@@ -486,13 +498,30 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
486
498
  */
487
499
  observe(text: string, caller?: VoiceCaller): void;
488
500
  getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
501
+ /** The snapshot for a caller that may access the session. Everything is the
502
+ * session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
489
503
  private realSnapshot;
504
+ /**
505
+ * The last finished device-tool call THIS caller may see. The platform (the
506
+ * user's own surface) sees every call. A plugin sees only calls to tools it
507
+ * registered — even on a session it owns, the model can call another
508
+ * solution's tool (a normal call offers every registered tool), and that
509
+ * tool's name and error are not this plugin's to read.
510
+ */
511
+ private lastDeviceToolFor;
490
512
  private foreignSnapshot;
491
513
  subscribe(cb: (event: VoiceEvent) => void, caller?: VoiceCaller): VoiceSubscription;
492
514
  transcript(caller?: VoiceCaller): TranscriptEntry[];
493
515
  private buildUri;
494
516
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
495
517
  handleMessage(event: any): void;
518
+ /**
519
+ * One device-tool call, arrival to outcome: validate → authorize → run. The
520
+ * answer goes back to the mini-app (the model reads it) AND becomes
521
+ * `lastDeviceTool` — the same object, recorded only once it is known, so a
522
+ * refused call never looks like one that ran.
523
+ */
524
+ private handleDeviceToolCall;
496
525
  /**
497
526
  * Final authorization = Core's frozen allowset ∩ the current valid local
498
527
  * registry, bound to the ACTIVE session. All must hold:
@@ -506,15 +535,14 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
506
535
  * No fallback to the live registry alone, ever.
507
536
  */
508
537
  private isToolAuthorized;
509
- /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
510
- * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
511
- * final "not allowed in this session". */
512
- private postDeviceToolRefusal;
538
+ /** Answer the mini-app's pending device-tool request (a call's outcome, or the
539
+ * tool list) — it resolves the request with this payload by correlationId. */
540
+ private postDeviceToolResult;
513
541
  /** PROPOSE the phone's available tools — this is an offer to Core, not the
514
542
  * authorization set. Core sanitizes it and returns the accepted set in a
515
543
  * `deviceToolAllowset` message, which is what actually gates execution. */
516
544
  private sendDeviceToolList;
517
- /** Run a device tool and post the outcome back into the WebView. */
545
+ /** Run an authorized device tool. Never throws: every failure is an answer. */
518
546
  private runDeviceTool;
519
547
  private setState;
520
548
  private appendTranscript;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
3
3
  import * as React$1 from 'react';
4
4
  import React__default, { ReactNode } from 'react';
5
5
  import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
6
- import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
6
+ import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceDeviceToolCall, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
7
7
  import * as zustand from 'zustand';
8
8
 
9
9
  interface AdasChatScreenProps {
@@ -382,6 +382,13 @@ declare function VoiceCallSheet({ visible, onClose, skillSlug }: Props): react_j
382
382
 
383
383
  type CallState = VoiceState;
384
384
  type TranscriptEntry = VoiceTranscriptEntry;
385
+ /** A finished call, tagged with the session owner's pluginId when the tool is
386
+ * that plugin's own; null when it is not (another solution's tool, the
387
+ * platform's, an unregistered name, no name) — then only the platform sees it. */
388
+ interface DeviceToolRecord {
389
+ owner: string | null;
390
+ call: VoiceDeviceToolCall;
391
+ }
385
392
  interface InternalState {
386
393
  callState: CallState;
387
394
  sessionId: string | null;
@@ -394,11 +401,11 @@ interface InternalState {
394
401
  userSpeaking: boolean;
395
402
  /** Epoch ms of the last speaking:false, or null before the first utterance. */
396
403
  lastUserSpeechEndedAt: number | null;
397
- /** The last device-tool call the model made (name + when), or null. */
398
- lastDeviceTool: {
399
- name: string;
400
- at: number;
401
- } | null;
404
+ /** This session's finished device-tool calls: the LATEST per owner tag (see
405
+ * DeviceToolRecord), in the order they finished. The platform reads the last
406
+ * one; the owning plugin reads its own (lastDeviceToolFor) — one store, both
407
+ * views derived from it. */
408
+ deviceToolCalls: DeviceToolRecord[];
402
409
  /** The WebView should be mounted (a session is wanted / live). */
403
410
  mounted: boolean;
404
411
  /** The WebView source, computed ONCE at start() so state changes (transcript,
@@ -461,6 +468,11 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
461
468
  * and the bundleManager registers a plugin's tools under that same id (never
462
469
  * the bundle's own short `id`), so exact equality is the whole check. */
463
470
  private toolScope;
471
+ /** Bumped whenever a session begins or ends. A device-tool call remembers the
472
+ * epoch it arrived in, and its outcome is recorded only if that session is
473
+ * still the current one — a tool that finishes after hang-up (or after a new
474
+ * call started) must not show up as the new session's last call. */
475
+ private sessionEpoch;
464
476
  /** The platform (built-in sheet, mic indicator, session host) sees and
465
477
  * controls any session — it is the user's own surface. A plugin caller may
466
478
  * only touch a session it owns. */
@@ -486,13 +498,30 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
486
498
  */
487
499
  observe(text: string, caller?: VoiceCaller): void;
488
500
  getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
501
+ /** The snapshot for a caller that may access the session. Everything is the
502
+ * session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
489
503
  private realSnapshot;
504
+ /**
505
+ * The last finished device-tool call THIS caller may see. The platform (the
506
+ * user's own surface) sees every call. A plugin sees only calls to tools it
507
+ * registered — even on a session it owns, the model can call another
508
+ * solution's tool (a normal call offers every registered tool), and that
509
+ * tool's name and error are not this plugin's to read.
510
+ */
511
+ private lastDeviceToolFor;
490
512
  private foreignSnapshot;
491
513
  subscribe(cb: (event: VoiceEvent) => void, caller?: VoiceCaller): VoiceSubscription;
492
514
  transcript(caller?: VoiceCaller): TranscriptEntry[];
493
515
  private buildUri;
494
516
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
495
517
  handleMessage(event: any): void;
518
+ /**
519
+ * One device-tool call, arrival to outcome: validate → authorize → run. The
520
+ * answer goes back to the mini-app (the model reads it) AND becomes
521
+ * `lastDeviceTool` — the same object, recorded only once it is known, so a
522
+ * refused call never looks like one that ran.
523
+ */
524
+ private handleDeviceToolCall;
496
525
  /**
497
526
  * Final authorization = Core's frozen allowset ∩ the current valid local
498
527
  * registry, bound to the ACTIVE session. All must hold:
@@ -506,15 +535,14 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
506
535
  * No fallback to the live registry alone, ever.
507
536
  */
508
537
  private isToolAuthorized;
509
- /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
510
- * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
511
- * final "not allowed in this session". */
512
- private postDeviceToolRefusal;
538
+ /** Answer the mini-app's pending device-tool request (a call's outcome, or the
539
+ * tool list) — it resolves the request with this payload by correlationId. */
540
+ private postDeviceToolResult;
513
541
  /** PROPOSE the phone's available tools — this is an offer to Core, not the
514
542
  * authorization set. Core sanitizes it and returns the accepted set in a
515
543
  * `deviceToolAllowset` message, which is what actually gates execution. */
516
544
  private sendDeviceToolList;
517
- /** Run a device tool and post the outcome back into the WebView. */
545
+ /** Run an authorized device tool. Never throws: every failure is an answer. */
518
546
  private runDeviceTool;
519
547
  private setState;
520
548
  private appendTranscript;
package/dist/index.js CHANGED
@@ -2822,6 +2822,11 @@ var buildObserveScript = (text) => `
2822
2822
  }
2823
2823
  })(${JSON.stringify(text)}); true;
2824
2824
  `;
2825
+ var DEVICE_TOOL_ERROR_MAX = 200;
2826
+ function clipError(message) {
2827
+ const chars = Array.from(message);
2828
+ return chars.length > DEVICE_TOOL_ERROR_MAX ? chars.slice(0, DEVICE_TOOL_ERROR_MAX - 1).join("") + "\u2026" : message;
2829
+ }
2825
2830
  function originOf(url) {
2826
2831
  if (!url) return null;
2827
2832
  const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url);
@@ -2837,7 +2842,7 @@ var IDLE = {
2837
2842
  muted: false,
2838
2843
  userSpeaking: false,
2839
2844
  lastUserSpeechEndedAt: null,
2840
- lastDeviceTool: null,
2845
+ deviceToolCalls: [],
2841
2846
  mounted: false,
2842
2847
  webViewSource: null,
2843
2848
  skillSlug: void 0
@@ -2893,6 +2898,11 @@ var VoiceSessionManager = class {
2893
2898
  * and the bundleManager registers a plugin's tools under that same id (never
2894
2899
  * the bundle's own short `id`), so exact equality is the whole check. */
2895
2900
  this.toolScope = null;
2901
+ /** Bumped whenever a session begins or ends. A device-tool call remembers the
2902
+ * epoch it arrived in, and its outcome is recorded only if that session is
2903
+ * still the current one — a tool that finishes after hang-up (or after a new
2904
+ * call started) must not show up as the new session's last call. */
2905
+ this.sessionEpoch = 0;
2896
2906
  this.injectedTrackHook = INJECT_TRACK_HOOK;
2897
2907
  }
2898
2908
  /** The platform (built-in sheet, mic indicator, session host) sees and
@@ -2922,11 +2932,11 @@ var VoiceSessionManager = class {
2922
2932
  async start(opts, caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
2923
2933
  if (this.state.callState === "connected") {
2924
2934
  if (!this.canAccess(caller)) throw this.foreignError();
2925
- return this.realSnapshot();
2935
+ return this.realSnapshot(caller);
2926
2936
  }
2927
2937
  if (this.state.callState === "connecting") {
2928
2938
  if (!this.canAccess(caller)) throw this.foreignError();
2929
- return this.waitForConnect();
2939
+ return this.waitForConnect(caller);
2930
2940
  }
2931
2941
  if (opts?.skills === "none" && opts?.skillSlug) {
2932
2942
  throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
@@ -2947,16 +2957,17 @@ var VoiceSessionManager = class {
2947
2957
  const after = this.getInternal().callState;
2948
2958
  if (after === "connected") {
2949
2959
  if (!this.canAccess(caller)) throw this.foreignError();
2950
- return this.realSnapshot();
2960
+ return this.realSnapshot(caller);
2951
2961
  }
2952
2962
  if (after === "connecting") {
2953
2963
  if (!this.canAccess(caller)) throw this.foreignError();
2954
- return this.waitForConnect();
2964
+ return this.waitForConnect(caller);
2955
2965
  }
2956
2966
  this.owner = caller;
2957
2967
  this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
2958
2968
  this.frozenTools = null;
2959
2969
  this.frozenSessionId = null;
2970
+ this.sessionEpoch++;
2960
2971
  const skillSlug = opts?.skillSlug;
2961
2972
  this.setState({
2962
2973
  callState: "connecting",
@@ -2965,12 +2976,15 @@ var VoiceSessionManager = class {
2965
2976
  aiPartial: "",
2966
2977
  userPartial: "",
2967
2978
  muted: false,
2979
+ // A session that ended from inside the voice page (not via stop()) left
2980
+ // its calls here; the new session starts with none.
2981
+ deviceToolCalls: [],
2968
2982
  mounted: true,
2969
2983
  skillSlug,
2970
2984
  persona: opts?.persona,
2971
2985
  webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
2972
2986
  });
2973
- return this.waitForConnect();
2987
+ return this.waitForConnect(caller);
2974
2988
  }
2975
2989
  stop(caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
2976
2990
  if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
@@ -2984,6 +2998,7 @@ var VoiceSessionManager = class {
2984
2998
  this.rejectPending(new Error("voice: session stopped before it connected"));
2985
2999
  this.frozenTools = null;
2986
3000
  this.frozenSessionId = null;
3001
+ this.sessionEpoch++;
2987
3002
  this.setState({
2988
3003
  ...IDLE,
2989
3004
  // keep the transcript of the call that just ended for any reader still up
@@ -3016,9 +3031,11 @@ var VoiceSessionManager = class {
3016
3031
  this.webView?.injectJavaScript?.(buildObserveScript(text));
3017
3032
  }
3018
3033
  getSnapshot(caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
3019
- return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
3034
+ return this.canAccess(caller) ? this.realSnapshot(caller) : this.foreignSnapshot();
3020
3035
  }
3021
- realSnapshot() {
3036
+ /** The snapshot for a caller that may access the session. Everything is the
3037
+ * session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
3038
+ realSnapshot(caller) {
3022
3039
  const s = this.state;
3023
3040
  return {
3024
3041
  state: s.callState,
@@ -3028,11 +3045,23 @@ var VoiceSessionManager = class {
3028
3045
  listening: !!s.userPartial,
3029
3046
  userSpeaking: s.userSpeaking,
3030
3047
  lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
3031
- lastDeviceTool: s.lastDeviceTool,
3048
+ lastDeviceTool: this.lastDeviceToolFor(caller),
3032
3049
  authorizedSessionId: this.frozenSessionId,
3033
3050
  lastError: s.error
3034
3051
  };
3035
3052
  }
3053
+ /**
3054
+ * The last finished device-tool call THIS caller may see. The platform (the
3055
+ * user's own surface) sees every call. A plugin sees only calls to tools it
3056
+ * registered — even on a session it owns, the model can call another
3057
+ * solution's tool (a normal call offers every registered tool), and that
3058
+ * tool's name and error are not this plugin's to read.
3059
+ */
3060
+ lastDeviceToolFor(caller) {
3061
+ const calls = this.state.deviceToolCalls;
3062
+ if (caller === import_plugin_runtime13.PLATFORM_VOICE_CALLER) return calls.length ? calls[calls.length - 1].call : null;
3063
+ return calls.find((r) => r.owner === caller)?.call ?? null;
3064
+ }
3036
3065
  foreignSnapshot() {
3037
3066
  return {
3038
3067
  state: "idle",
@@ -3160,18 +3189,52 @@ var VoiceSessionManager = class {
3160
3189
  console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
3161
3190
  break;
3162
3191
  }
3163
- if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
3164
- if (!this.isToolAuthorized(data.name)) {
3165
- const notReady = this.frozenTools === null;
3166
- console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3167
- this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
3168
- break;
3169
- }
3170
- void this.runDeviceTool(data);
3192
+ void this.handleDeviceToolCall(data);
3171
3193
  break;
3172
3194
  }
3173
3195
  }
3174
3196
  }
3197
+ /**
3198
+ * One device-tool call, arrival to outcome: validate → authorize → run. The
3199
+ * answer goes back to the mini-app (the model reads it) AND becomes
3200
+ * `lastDeviceTool` — the same object, recorded only once it is known, so a
3201
+ * refused call never looks like one that ran.
3202
+ */
3203
+ async handleDeviceToolCall(data) {
3204
+ const { correlationId, name, args, timeoutMs } = data || {};
3205
+ const at = Date.now();
3206
+ const epoch = this.sessionEpoch;
3207
+ const toolName = typeof name === "string" ? name : "";
3208
+ const sessionOwner = this.owner;
3209
+ const owner = typeof sessionOwner === "string" && toolName && (0, import_plugin_runtime13.hasDeviceTool)(toolName, sessionOwner) ? sessionOwner : null;
3210
+ let answer;
3211
+ if (!toolName) {
3212
+ answer = { ok: false, reason: "invalid", error: "The device tool call named no tool." };
3213
+ } else if (!correlationId) {
3214
+ answer = {
3215
+ ok: false,
3216
+ reason: "invalid",
3217
+ error: `The call to "${toolName}" carried no correlationId, so it could not be answered.`
3218
+ };
3219
+ } else if (!this.isToolAuthorized(toolName)) {
3220
+ const notReady = this.frozenTools === null;
3221
+ console.warn(`[voiceSession] REFUSED "${toolName}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3222
+ answer = notReady ? {
3223
+ ok: false,
3224
+ reason: "not_ready",
3225
+ error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3226
+ } : { ok: false, reason: "not_authorized", error: `"${toolName}" is not available in this voice session.` };
3227
+ } else {
3228
+ answer = await this.runDeviceTool(toolName, args, timeoutMs);
3229
+ }
3230
+ if (correlationId) this.postDeviceToolResult({ correlationId, ...answer });
3231
+ if (epoch !== this.sessionEpoch) return;
3232
+ const ms = Math.max(0, Date.now() - at);
3233
+ const call = answer.ok ? { name: toolName, at, ms, ok: true, reason: null, error: null } : { name: toolName, at, ms, ok: false, reason: answer.reason, error: clipError(answer.error) };
3234
+ this.setState({
3235
+ deviceToolCalls: [...this.state.deviceToolCalls.filter((r) => r.owner !== owner), { owner, call }]
3236
+ });
3237
+ }
3175
3238
  /**
3176
3239
  * Final authorization = Core's frozen allowset ∩ the current valid local
3177
3240
  * registry, bound to the ACTIVE session. All must hold:
@@ -3191,21 +3254,9 @@ var VoiceSessionManager = class {
3191
3254
  if (!this.frozenTools.has(name)) return false;
3192
3255
  return (0, import_plugin_runtime13.hasDeviceTool)(name, this.toolScope ?? void 0);
3193
3256
  }
3194
- /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
3195
- * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
3196
- * final "not allowed in this session". */
3197
- postDeviceToolRefusal(correlationId, name, notReady) {
3198
- const payload = notReady ? {
3199
- correlationId,
3200
- ok: false,
3201
- reason: "not_ready",
3202
- error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3203
- } : {
3204
- correlationId,
3205
- ok: false,
3206
- reason: "not_authorized",
3207
- error: `"${name}" is not available in this voice session.`
3208
- };
3257
+ /** Answer the mini-app's pending device-tool request (a call's outcome, or the
3258
+ * tool list) — it resolves the request with this payload by correlationId. */
3259
+ postDeviceToolResult(payload) {
3209
3260
  this.webView?.injectJavaScript?.(
3210
3261
  `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3211
3262
  JSON.stringify(payload)
@@ -3222,28 +3273,16 @@ var VoiceSessionManager = class {
3222
3273
  description: t.description,
3223
3274
  input_schema: t.input_schema
3224
3275
  }));
3225
- this.webView?.injectJavaScript?.(
3226
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3227
- JSON.stringify({ correlationId, ok: true, tools })
3228
- )}); true;`
3229
- );
3276
+ this.postDeviceToolResult({ correlationId, ok: true, tools });
3230
3277
  }
3231
- /** Run a device tool and post the outcome back into the WebView. */
3232
- async runDeviceTool(data) {
3233
- const { correlationId, name, args, timeoutMs } = data || {};
3234
- if (!correlationId || typeof name !== "string") return;
3235
- let payload;
3278
+ /** Run an authorized device tool. Never throws: every failure is an answer. */
3279
+ async runDeviceTool(name, args, timeoutMs) {
3236
3280
  try {
3237
3281
  const outcome = await (0, import_plugin_runtime13.callDeviceTool)(name, args || {}, { timeoutMs });
3238
- payload = outcome.ok ? { correlationId, ok: true, result: outcome.result } : { correlationId, ok: false, error: outcome.error, reason: outcome.reason };
3282
+ return outcome.ok ? { ok: true, result: outcome.result } : { ok: false, reason: outcome.reason, error: outcome.error };
3239
3283
  } catch (err) {
3240
- payload = { correlationId, ok: false, reason: "failed", error: err?.message || String(err) };
3284
+ return { ok: false, reason: "failed", error: err?.message || String(err) };
3241
3285
  }
3242
- this.webView?.injectJavaScript?.(
3243
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3244
- JSON.stringify(payload)
3245
- )}); true;`
3246
- );
3247
3286
  }
3248
3287
  // ── internals ────────────────────────────────────────────────
3249
3288
  setState(partial) {
@@ -3263,22 +3302,21 @@ var VoiceSessionManager = class {
3263
3302
  }
3264
3303
  }
3265
3304
  }
3266
- waitForConnect() {
3305
+ waitForConnect(caller) {
3267
3306
  return new Promise((resolve, reject) => {
3268
3307
  const timer = setTimeout(() => {
3269
3308
  this.pending = this.pending.filter((p) => p.timer !== timer);
3270
3309
  reject(new Error("voice: timed out waiting to connect (is VoiceSessionHost mounted?)"));
3271
3310
  }, CONNECT_TIMEOUT_MS);
3272
- this.pending.push({ resolve, reject, timer });
3311
+ this.pending.push({ caller, resolve, reject, timer });
3273
3312
  });
3274
3313
  }
3275
3314
  resolvePending() {
3276
- const snap = this.realSnapshot();
3277
3315
  const waiting = this.pending;
3278
3316
  this.pending = [];
3279
3317
  for (const p of waiting) {
3280
3318
  clearTimeout(p.timer);
3281
- p.resolve(snap);
3319
+ p.resolve(this.realSnapshot(p.caller));
3282
3320
  }
3283
3321
  }
3284
3322
  rejectPending(err) {
@@ -3299,6 +3337,7 @@ var VoiceSessionManager = class {
3299
3337
  this.pendingStarts.clear();
3300
3338
  this.frozenTools = null;
3301
3339
  this.frozenSessionId = null;
3340
+ this.sessionEpoch++;
3302
3341
  this.listeners.clear();
3303
3342
  this.webView = null;
3304
3343
  }