@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.mjs CHANGED
@@ -2800,6 +2800,11 @@ var buildObserveScript = (text) => `
2800
2800
  }
2801
2801
  })(${JSON.stringify(text)}); true;
2802
2802
  `;
2803
+ var DEVICE_TOOL_ERROR_MAX = 200;
2804
+ function clipError(message) {
2805
+ const chars = Array.from(message);
2806
+ return chars.length > DEVICE_TOOL_ERROR_MAX ? chars.slice(0, DEVICE_TOOL_ERROR_MAX - 1).join("") + "\u2026" : message;
2807
+ }
2803
2808
  function originOf(url) {
2804
2809
  if (!url) return null;
2805
2810
  const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url);
@@ -2815,7 +2820,7 @@ var IDLE = {
2815
2820
  muted: false,
2816
2821
  userSpeaking: false,
2817
2822
  lastUserSpeechEndedAt: null,
2818
- lastDeviceTool: null,
2823
+ deviceToolCalls: [],
2819
2824
  mounted: false,
2820
2825
  webViewSource: null,
2821
2826
  skillSlug: void 0
@@ -2871,6 +2876,11 @@ var VoiceSessionManager = class {
2871
2876
  * and the bundleManager registers a plugin's tools under that same id (never
2872
2877
  * the bundle's own short `id`), so exact equality is the whole check. */
2873
2878
  this.toolScope = null;
2879
+ /** Bumped whenever a session begins or ends. A device-tool call remembers the
2880
+ * epoch it arrived in, and its outcome is recorded only if that session is
2881
+ * still the current one — a tool that finishes after hang-up (or after a new
2882
+ * call started) must not show up as the new session's last call. */
2883
+ this.sessionEpoch = 0;
2874
2884
  this.injectedTrackHook = INJECT_TRACK_HOOK;
2875
2885
  }
2876
2886
  /** The platform (built-in sheet, mic indicator, session host) sees and
@@ -2900,11 +2910,11 @@ var VoiceSessionManager = class {
2900
2910
  async start(opts, caller = PLATFORM_VOICE_CALLER) {
2901
2911
  if (this.state.callState === "connected") {
2902
2912
  if (!this.canAccess(caller)) throw this.foreignError();
2903
- return this.realSnapshot();
2913
+ return this.realSnapshot(caller);
2904
2914
  }
2905
2915
  if (this.state.callState === "connecting") {
2906
2916
  if (!this.canAccess(caller)) throw this.foreignError();
2907
- return this.waitForConnect();
2917
+ return this.waitForConnect(caller);
2908
2918
  }
2909
2919
  if (opts?.skills === "none" && opts?.skillSlug) {
2910
2920
  throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
@@ -2925,16 +2935,17 @@ var VoiceSessionManager = class {
2925
2935
  const after = this.getInternal().callState;
2926
2936
  if (after === "connected") {
2927
2937
  if (!this.canAccess(caller)) throw this.foreignError();
2928
- return this.realSnapshot();
2938
+ return this.realSnapshot(caller);
2929
2939
  }
2930
2940
  if (after === "connecting") {
2931
2941
  if (!this.canAccess(caller)) throw this.foreignError();
2932
- return this.waitForConnect();
2942
+ return this.waitForConnect(caller);
2933
2943
  }
2934
2944
  this.owner = caller;
2935
2945
  this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
2936
2946
  this.frozenTools = null;
2937
2947
  this.frozenSessionId = null;
2948
+ this.sessionEpoch++;
2938
2949
  const skillSlug = opts?.skillSlug;
2939
2950
  this.setState({
2940
2951
  callState: "connecting",
@@ -2943,12 +2954,15 @@ var VoiceSessionManager = class {
2943
2954
  aiPartial: "",
2944
2955
  userPartial: "",
2945
2956
  muted: false,
2957
+ // A session that ended from inside the voice page (not via stop()) left
2958
+ // its calls here; the new session starts with none.
2959
+ deviceToolCalls: [],
2946
2960
  mounted: true,
2947
2961
  skillSlug,
2948
2962
  persona: opts?.persona,
2949
2963
  webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
2950
2964
  });
2951
- return this.waitForConnect();
2965
+ return this.waitForConnect(caller);
2952
2966
  }
2953
2967
  stop(caller = PLATFORM_VOICE_CALLER) {
2954
2968
  if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
@@ -2962,6 +2976,7 @@ var VoiceSessionManager = class {
2962
2976
  this.rejectPending(new Error("voice: session stopped before it connected"));
2963
2977
  this.frozenTools = null;
2964
2978
  this.frozenSessionId = null;
2979
+ this.sessionEpoch++;
2965
2980
  this.setState({
2966
2981
  ...IDLE,
2967
2982
  // keep the transcript of the call that just ended for any reader still up
@@ -2994,9 +3009,11 @@ var VoiceSessionManager = class {
2994
3009
  this.webView?.injectJavaScript?.(buildObserveScript(text));
2995
3010
  }
2996
3011
  getSnapshot(caller = PLATFORM_VOICE_CALLER) {
2997
- return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
3012
+ return this.canAccess(caller) ? this.realSnapshot(caller) : this.foreignSnapshot();
2998
3013
  }
2999
- realSnapshot() {
3014
+ /** The snapshot for a caller that may access the session. Everything is the
3015
+ * session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
3016
+ realSnapshot(caller) {
3000
3017
  const s = this.state;
3001
3018
  return {
3002
3019
  state: s.callState,
@@ -3006,11 +3023,23 @@ var VoiceSessionManager = class {
3006
3023
  listening: !!s.userPartial,
3007
3024
  userSpeaking: s.userSpeaking,
3008
3025
  lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
3009
- lastDeviceTool: s.lastDeviceTool,
3026
+ lastDeviceTool: this.lastDeviceToolFor(caller),
3010
3027
  authorizedSessionId: this.frozenSessionId,
3011
3028
  lastError: s.error
3012
3029
  };
3013
3030
  }
3031
+ /**
3032
+ * The last finished device-tool call THIS caller may see. The platform (the
3033
+ * user's own surface) sees every call. A plugin sees only calls to tools it
3034
+ * registered — even on a session it owns, the model can call another
3035
+ * solution's tool (a normal call offers every registered tool), and that
3036
+ * tool's name and error are not this plugin's to read.
3037
+ */
3038
+ lastDeviceToolFor(caller) {
3039
+ const calls = this.state.deviceToolCalls;
3040
+ if (caller === PLATFORM_VOICE_CALLER) return calls.length ? calls[calls.length - 1].call : null;
3041
+ return calls.find((r) => r.owner === caller)?.call ?? null;
3042
+ }
3014
3043
  foreignSnapshot() {
3015
3044
  return {
3016
3045
  state: "idle",
@@ -3138,18 +3167,52 @@ var VoiceSessionManager = class {
3138
3167
  console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
3139
3168
  break;
3140
3169
  }
3141
- if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
3142
- if (!this.isToolAuthorized(data.name)) {
3143
- const notReady = this.frozenTools === null;
3144
- console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3145
- this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
3146
- break;
3147
- }
3148
- void this.runDeviceTool(data);
3170
+ void this.handleDeviceToolCall(data);
3149
3171
  break;
3150
3172
  }
3151
3173
  }
3152
3174
  }
3175
+ /**
3176
+ * One device-tool call, arrival to outcome: validate → authorize → run. The
3177
+ * answer goes back to the mini-app (the model reads it) AND becomes
3178
+ * `lastDeviceTool` — the same object, recorded only once it is known, so a
3179
+ * refused call never looks like one that ran.
3180
+ */
3181
+ async handleDeviceToolCall(data) {
3182
+ const { correlationId, name, args, timeoutMs } = data || {};
3183
+ const at = Date.now();
3184
+ const epoch = this.sessionEpoch;
3185
+ const toolName = typeof name === "string" ? name : "";
3186
+ const sessionOwner = this.owner;
3187
+ const owner = typeof sessionOwner === "string" && toolName && hasDeviceTool(toolName, sessionOwner) ? sessionOwner : null;
3188
+ let answer;
3189
+ if (!toolName) {
3190
+ answer = { ok: false, reason: "invalid", error: "The device tool call named no tool." };
3191
+ } else if (!correlationId) {
3192
+ answer = {
3193
+ ok: false,
3194
+ reason: "invalid",
3195
+ error: `The call to "${toolName}" carried no correlationId, so it could not be answered.`
3196
+ };
3197
+ } else if (!this.isToolAuthorized(toolName)) {
3198
+ const notReady = this.frozenTools === null;
3199
+ console.warn(`[voiceSession] REFUSED "${toolName}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3200
+ answer = notReady ? {
3201
+ ok: false,
3202
+ reason: "not_ready",
3203
+ error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3204
+ } : { ok: false, reason: "not_authorized", error: `"${toolName}" is not available in this voice session.` };
3205
+ } else {
3206
+ answer = await this.runDeviceTool(toolName, args, timeoutMs);
3207
+ }
3208
+ if (correlationId) this.postDeviceToolResult({ correlationId, ...answer });
3209
+ if (epoch !== this.sessionEpoch) return;
3210
+ const ms = Math.max(0, Date.now() - at);
3211
+ 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) };
3212
+ this.setState({
3213
+ deviceToolCalls: [...this.state.deviceToolCalls.filter((r) => r.owner !== owner), { owner, call }]
3214
+ });
3215
+ }
3153
3216
  /**
3154
3217
  * Final authorization = Core's frozen allowset ∩ the current valid local
3155
3218
  * registry, bound to the ACTIVE session. All must hold:
@@ -3169,21 +3232,9 @@ var VoiceSessionManager = class {
3169
3232
  if (!this.frozenTools.has(name)) return false;
3170
3233
  return hasDeviceTool(name, this.toolScope ?? void 0);
3171
3234
  }
3172
- /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
3173
- * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
3174
- * final "not allowed in this session". */
3175
- postDeviceToolRefusal(correlationId, name, notReady) {
3176
- const payload = notReady ? {
3177
- correlationId,
3178
- ok: false,
3179
- reason: "not_ready",
3180
- error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3181
- } : {
3182
- correlationId,
3183
- ok: false,
3184
- reason: "not_authorized",
3185
- error: `"${name}" is not available in this voice session.`
3186
- };
3235
+ /** Answer the mini-app's pending device-tool request (a call's outcome, or the
3236
+ * tool list) — it resolves the request with this payload by correlationId. */
3237
+ postDeviceToolResult(payload) {
3187
3238
  this.webView?.injectJavaScript?.(
3188
3239
  `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3189
3240
  JSON.stringify(payload)
@@ -3200,28 +3251,16 @@ var VoiceSessionManager = class {
3200
3251
  description: t.description,
3201
3252
  input_schema: t.input_schema
3202
3253
  }));
3203
- this.webView?.injectJavaScript?.(
3204
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3205
- JSON.stringify({ correlationId, ok: true, tools })
3206
- )}); true;`
3207
- );
3254
+ this.postDeviceToolResult({ correlationId, ok: true, tools });
3208
3255
  }
3209
- /** Run a device tool and post the outcome back into the WebView. */
3210
- async runDeviceTool(data) {
3211
- const { correlationId, name, args, timeoutMs } = data || {};
3212
- if (!correlationId || typeof name !== "string") return;
3213
- let payload;
3256
+ /** Run an authorized device tool. Never throws: every failure is an answer. */
3257
+ async runDeviceTool(name, args, timeoutMs) {
3214
3258
  try {
3215
3259
  const outcome = await callDeviceTool(name, args || {}, { timeoutMs });
3216
- payload = outcome.ok ? { correlationId, ok: true, result: outcome.result } : { correlationId, ok: false, error: outcome.error, reason: outcome.reason };
3260
+ return outcome.ok ? { ok: true, result: outcome.result } : { ok: false, reason: outcome.reason, error: outcome.error };
3217
3261
  } catch (err) {
3218
- payload = { correlationId, ok: false, reason: "failed", error: err?.message || String(err) };
3262
+ return { ok: false, reason: "failed", error: err?.message || String(err) };
3219
3263
  }
3220
- this.webView?.injectJavaScript?.(
3221
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3222
- JSON.stringify(payload)
3223
- )}); true;`
3224
- );
3225
3264
  }
3226
3265
  // ── internals ────────────────────────────────────────────────
3227
3266
  setState(partial) {
@@ -3241,22 +3280,21 @@ var VoiceSessionManager = class {
3241
3280
  }
3242
3281
  }
3243
3282
  }
3244
- waitForConnect() {
3283
+ waitForConnect(caller) {
3245
3284
  return new Promise((resolve, reject) => {
3246
3285
  const timer = setTimeout(() => {
3247
3286
  this.pending = this.pending.filter((p) => p.timer !== timer);
3248
3287
  reject(new Error("voice: timed out waiting to connect (is VoiceSessionHost mounted?)"));
3249
3288
  }, CONNECT_TIMEOUT_MS);
3250
- this.pending.push({ resolve, reject, timer });
3289
+ this.pending.push({ caller, resolve, reject, timer });
3251
3290
  });
3252
3291
  }
3253
3292
  resolvePending() {
3254
- const snap = this.realSnapshot();
3255
3293
  const waiting = this.pending;
3256
3294
  this.pending = [];
3257
3295
  for (const p of waiting) {
3258
3296
  clearTimeout(p.timer);
3259
- p.resolve(snap);
3297
+ p.resolve(this.realSnapshot(p.caller));
3260
3298
  }
3261
3299
  }
3262
3300
  rejectPending(err) {
@@ -3277,6 +3315,7 @@ var VoiceSessionManager = class {
3277
3315
  this.pendingStarts.clear();
3278
3316
  this.frozenTools = null;
3279
3317
  this.frozenSessionId = null;
3318
+ this.sessionEpoch++;
3280
3319
  this.listeners.clear();
3281
3320
  this.webView = null;
3282
3321
  }