@webotme/react-native 0.1.2 → 0.1.3

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": "@webotme/react-native",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Drop-in AI chatbot for React Native and Expo apps. Same bot, training and AutoFlow as the WebotMe web widget.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -11,6 +11,7 @@ import {
11
11
  View,
12
12
  type ViewStyle,
13
13
  } from "react-native";
14
+ import { routeTargetFromUrl } from "./navigationAdapter";
14
15
  import type {
15
16
  WebotMeAction,
16
17
  WebotMePendingWorkflow,
@@ -94,8 +95,12 @@ export function AutoFlowExecutor({
94
95
  case "navigate": {
95
96
  const url = action.data?.url;
96
97
  if (!url) throw new Error("Navigate action missing URL");
97
-
98
- // Try to match with known app routes first
98
+
99
+ // Resolve the raw URL/path into a real expo-router path + a
100
+ // friendly PascalCase name for React Navigation named screens.
101
+ const target = routeTargetFromUrl(url);
102
+
103
+ // Try to match known app routes first (named screens).
99
104
  const routeMap: Record<string, string> = {
100
105
  "/dashboard": "Dashboard",
101
106
  "/dashboard/create": "CreateChatbot",
@@ -106,15 +111,21 @@ export function AutoFlowExecutor({
106
111
  "/plans": "Plans",
107
112
  "/login": "Login",
108
113
  };
109
-
110
- const routeName = routeMap[url] || url.replace(/^\//, "").replace(/\//g, "_") || "Home";
111
-
114
+
115
+ const routeName = routeMap[url] || target.name;
116
+ const params = {
117
+ fromAutoFlow: true,
118
+ originalUrl: url,
119
+ screenName: routeName,
120
+ route: target.route,
121
+ };
122
+
112
123
  if (push) {
113
- push(routeName, { fromAutoFlow: true, originalUrl: url });
124
+ push(target.route, params);
114
125
  } else {
115
- navigate(routeName, { fromAutoFlow: true, originalUrl: url });
126
+ navigate(target.route, params);
116
127
  }
117
-
128
+
118
129
  return { success: true };
119
130
  }
120
131
 
package/src/chat.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { useEffect, useState } from "react";
8
8
  import { WebotMeClient } from "./client";
9
+ import { ensureRuntime } from "./runtime";
9
10
  import {
10
11
  WebotMeError,
11
12
  type WebotMeAction,
@@ -66,6 +67,7 @@ class ChatEngine {
66
67
  }
67
68
 
68
69
  configure(config: WebotMeConfig) {
70
+ ensureRuntime();
69
71
  const nextClient = new WebotMeClient({
70
72
  botId: config.botId,
71
73
  apiBaseUrl: config.apiBaseUrl,
package/src/executor.ts CHANGED
@@ -2,6 +2,7 @@ import { Alert, Linking } from "react-native";
2
2
  import { getChatState, nextAutoFlowStep, subscribeChat } from "./chat";
3
3
  import { isActionAllowed } from "./safety";
4
4
  import { findClickHandler, findFillHandler } from "./screenScanner";
5
+ import { routeTargetFromUrl, pushRoute, getCurrentRoute } from "./navigationAdapter";
5
6
  import type { WebotMeAction, WebotMeMessage } from "./types";
6
7
 
7
8
  export type ExecutorOptions = {
@@ -14,27 +15,13 @@ export type ExecutorOptions = {
14
15
  };
15
16
 
16
17
  let handlers: ExecutorOptions | null = null;
18
+ let currentUnsub: (() => void) | null = null;
17
19
  const executedMessageIds = new Set<string>();
18
20
 
19
- function routeNameFromUrl(url: string): string {
20
- const match = /^([^?#]+)/.exec(url)?.[1] ?? url;
21
- const trimmed = match.replace(/^\/+|\/+$/g, "");
22
- if (!trimmed) return "Home";
23
- const parts = trimmed.split("/").map((part) =>
24
- part.charAt(0).toUpperCase() + part.slice(1),
25
- );
26
- return parts.join(":");
27
- }
28
-
29
- function defaultNavigate(routeName: string, params?: Record<string, any>) {
30
- try {
31
- // eslint-disable-next-line @typescript-eslint/no-require-imports
32
- const router = require("expo-router").default ?? require("expo-router").router;
33
- router?.push?.(routeName as any);
34
- return true;
35
- } catch {
36
- return false;
37
- }
21
+ function defaultNavigate(routeName: string, fallbackName?: string, params?: Record<string, any>) {
22
+ if (pushRoute(routeName, params)) return true;
23
+ if (fallbackName && pushRoute(fallbackName, params)) return true;
24
+ return false;
38
25
  }
39
26
 
40
27
  /** Run a single server action on the device. */
@@ -49,17 +36,55 @@ export async function executeAction(action: WebotMeAction): Promise<{ success: b
49
36
  case "navigate": {
50
37
  const url = (action.data as any)?.url;
51
38
  if (!url) throw new Error("Navigate action missing URL");
52
- const name = routeNameFromUrl(url);
39
+ // Resolve the raw URL (full URL, path, or native://screen) into a real
40
+ // route path so router.push actually moves the app — the friendly name
41
+ // is only used as a fallback for React Navigation named screens.
42
+ const target = routeTargetFromUrl(url);
43
+ const params = {
44
+ fromAutoFlow: true,
45
+ originalUrl: url,
46
+ screenName: target.name,
47
+ route: target.route,
48
+ };
53
49
  if (doNavigate) {
54
- doNavigate(name, { fromAutoFlow: true, originalUrl: url });
50
+ doNavigate(target.route, params);
55
51
  return { success: true };
56
52
  }
57
53
  if (handlers?.push) {
58
- handlers.push(name, { fromAutoFlow: true, originalUrl: url });
54
+ handlers.push(target.route, params);
59
55
  return { success: true };
60
56
  }
61
- const ok = defaultNavigate(url);
62
- if (!ok) throw new Error(`No navigation handler for "${url}"`);
57
+ const before = getCurrentRoute();
58
+ const ok = defaultNavigate(target.route, target.name, params);
59
+ if (!ok) {
60
+ // No expo-router stack is available — the app may use tabs or a
61
+ // custom layout. Fall back to tapping a matching control on-screen
62
+ // (e.g. the "Settings" tab) so the bot's promise still lands.
63
+ const tapper =
64
+ findClickHandler(target.name) ||
65
+ findClickHandler((action.data as any)?.label) ||
66
+ findClickHandler((action.data as any)?.text);
67
+ if (tapper) {
68
+ tapper();
69
+ return { success: true };
70
+ }
71
+ throw new Error(`No navigation handler for "${url}"`);
72
+ }
73
+ // Give expo-router a beat to move; if nothing changed, the route likely
74
+ // doesn't exist, so tap a same-named tab/button instead of reporting a
75
+ // success that never happened.
76
+ await new Promise((resolve) => setTimeout(resolve, 800));
77
+ const after = getCurrentRoute();
78
+ if (after === before && after !== target.route) {
79
+ const tapper =
80
+ findClickHandler(target.name) ||
81
+ findClickHandler((action.data as any)?.label) ||
82
+ findClickHandler((action.data as any)?.text);
83
+ if (tapper) {
84
+ tapper();
85
+ return { success: true };
86
+ }
87
+ }
63
88
  return { success: true };
64
89
  }
65
90
  case "click": {
@@ -124,6 +149,7 @@ export async function executeActions(actions: WebotMeAction[]): Promise<void> {
124
149
  */
125
150
  export function setupExecutor(options?: ExecutorOptions): () => void {
126
151
  handlers = options ?? handlers ?? {};
152
+ if (currentUnsub) return currentUnsub;
127
153
  const unsubscribe = subscribeChat(() => {
128
154
  const { messages } = getChatState();
129
155
  for (const message of messages) {
@@ -147,10 +173,18 @@ export function setupExecutor(options?: ExecutorOptions): () => void {
147
173
  });
148
174
  }
149
175
  });
150
- return () => unsubscribe();
176
+ currentUnsub = () => {
177
+ unsubscribe();
178
+ currentUnsub = null;
179
+ };
180
+ return currentUnsub;
151
181
  }
152
182
 
153
183
  export function resetExecutorState() {
154
184
  executedMessageIds.clear();
155
185
  handlers = null;
186
+ if (currentUnsub) {
187
+ currentUnsub();
188
+ currentUnsub = null;
189
+ }
156
190
  }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  export type { VoiceOptions, VoiceResult, VoiceState } from "./voice";
36
36
 
37
37
  export { WebotMeError } from "./types";
38
+ export { ensureRuntime } from "./runtime";
38
39
  export {
39
40
  executeAction,
40
41
  executeActions,
@@ -46,6 +47,8 @@ export {
46
47
  getCurrentRoute,
47
48
  goBack,
48
49
  navigateTo,
50
+ pushRoute,
51
+ routeTargetFromUrl,
49
52
  setCurrentRoute,
50
53
  useNavigationAdapter,
51
54
  } from "./navigationAdapter";
@@ -11,15 +11,74 @@ export function setCurrentRoute(route: string): void {
11
11
  lastRoute = route;
12
12
  }
13
13
 
14
+ function toPascalName(route: string): string {
15
+ const trimmed = route.replace(/^\/+|\/+$/g, "");
16
+ if (!trimmed) return "Home";
17
+ const parts = trimmed.split("/").map((part) =>
18
+ part.charAt(0).toUpperCase() + part.slice(1),
19
+ );
20
+ return parts.join(":");
21
+ }
22
+
23
+ /**
24
+ * Turn any URL a server/AI returns into a navigation target that works with
25
+ * Expo Router (path-first) while keeping a friendly PascalCase name for apps
26
+ * using React Navigation named screens.
27
+ *
28
+ * - "https://host/book" -> { route: "/book", name: "Book" }
29
+ * - "/dashboard/settings" -> { route: "/dashboard/settings", name: "Dashboard:Settings" }
30
+ * - "native://Select Doctor" -> { route: "/select-doctor", name: "Select Doctor" }
31
+ * - "checkout" -> { route: "/checkout", name: "Checkout" }
32
+ */
33
+ export function routeTargetFromUrl(input?: string | null): { route: string; name: string } {
34
+ let url = String(input ?? "").trim();
35
+ if (!url || url === "/") return { route: "/", name: "Home" };
36
+
37
+ // Native screen snapshots come in as native://<screen name>.
38
+ if (/^native:\/\//i.test(url)) {
39
+ const screenName = decodeURIComponent(url.slice("native://".length)) || "Home";
40
+ const slug = screenName
41
+ .toLowerCase()
42
+ .replace(/\s+/g, "-")
43
+ .replace(/[^a-z0-9\-_/]/g, "");
44
+ return { route: "/" + slug, name: screenName };
45
+ }
46
+
47
+ // A full URL (from web scans) must become a bare path for expo-router.
48
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith("//")) {
49
+ try {
50
+ url = new URL(url).pathname || "/";
51
+ } catch {
52
+ url = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "") || "/";
53
+ }
54
+ }
55
+
56
+ // Strip query string and hash.
57
+ url = url.replace(/[?#].*$/, "");
58
+ url = "/" + url.replace(/^\/+/, "");
59
+ url = url.replace(/\/{2,}/g, "/");
60
+
61
+ return { route: url, name: toPascalName(url) };
62
+ }
63
+
14
64
  /** Navigate imperatively using Expo Router's router. */
15
65
  export function navigateTo(route: string, params?: Record<string, any>): void {
66
+ pushRoute(route, params);
67
+ }
68
+
69
+ /** Internal helper that reports whether a push actually happened. */
70
+ export function pushRoute(route: string, params?: Record<string, any>): boolean {
16
71
  try {
72
+ const { route: target } = routeTargetFromUrl(route);
17
73
  const router =
18
74
  require("expo-router").default ?? require("expo-router").router;
19
- if (params) (router as any)?.push?.({ pathname: route as any, params } as any);
20
- else (router as any)?.push?.(route as any);
75
+ const push = (router as any)?.push;
76
+ if (typeof push !== "function") return false;
77
+ if (params) push({ pathname: target as any, params } as any);
78
+ else push(target as any);
79
+ return true;
21
80
  } catch {
22
- /* no router available */
81
+ return false;
23
82
  }
24
83
  }
25
84
 
package/src/provider.tsx CHANGED
@@ -19,6 +19,7 @@ import { registerScreenTree, setupFiberScanner } from "./screenScanner";
19
19
  import { setupExecutor } from "./executor";
20
20
  import { setupSafetyLayer } from "./safety";
21
21
  import { useNavigationAdapter } from "./navigationAdapter";
22
+ import { configureVoice } from "./voice";
22
23
  import type {
23
24
  WebotMeConfig,
24
25
  WebotMeContract,
@@ -165,6 +166,36 @@ export function WebotMeProvider({
165
166
  };
166
167
  }, [client, attempt]);
167
168
 
169
+ // Pull the bot's activation/shutdown voice phrases from the server and arm
170
+ // the native voice engine with them ("hello {botName}" wakes it up, the
171
+ // shutdown phrase turns voice off). Mirrors the web widget exactly.
172
+ useEffect(() => {
173
+ let active = true;
174
+ client
175
+ .settings()
176
+ .then((raw: any) => {
177
+ if (!active) return;
178
+ const bot = raw?.chatbot ?? raw;
179
+ const botName = typeof bot?.name === "string" && bot.name.trim() ? bot.name.trim() : "Assistant";
180
+ const commands: string[] = Array.isArray(bot?.activation?.commands)
181
+ ? bot.activation.commands
182
+ .filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0)
183
+ .map((c: string) => c.replace(/{botName}/gi, botName).trim())
184
+ : [];
185
+ const activateCommands = commands.filter((c) => !/shutdown|bye|stop|off/i.test(c));
186
+ const shutdownCommands = commands.filter((c) => /shutdown|bye|stop|off/i.test(c));
187
+ if (activateCommands.length > 0 || shutdownCommands.length > 0) {
188
+ configureVoice({ botName, activateCommands, shutdownCommands });
189
+ }
190
+ })
191
+ .catch(() => {
192
+ // Settings unavailable -> voice stays always-on, matching the fallback.
193
+ });
194
+ return () => {
195
+ active = false;
196
+ };
197
+ }, [client]);
198
+
168
199
  // New session when the caller bumps resetKey.
169
200
  useEffect(() => {
170
201
  if (resetKey === undefined) return;
package/src/runtime.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Self-healing runtime: apps that chat through `chatWithUs` without mounting
3
+ * `<WebotMeProvider>` should still get screen scanning, action execution and
4
+ * the safety gate. Everything here is idempotent, so double-arming (provider +
5
+ * manual call) is harmless.
6
+ *
7
+ * Modules are required lazily on purpose — `runtime` is imported by `chat`,
8
+ * and `executor`/`scanner` import `chat`, so top-level imports would form a
9
+ * cycle at module load time.
10
+ */
11
+
12
+ let executorArmed = false;
13
+ let scannerArmed = false;
14
+ let safetyArmed = false;
15
+
16
+ export function ensureRuntime(): void {
17
+ if (!executorArmed) {
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const { setupExecutor } = require("./executor");
20
+ setupExecutor();
21
+ executorArmed = true;
22
+ }
23
+ if (!scannerArmed) {
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const { setupFiberScanner } = require("./screenScanner");
26
+ setupFiberScanner({ intervalMs: 2500 });
27
+ scannerArmed = true;
28
+ }
29
+ if (!safetyArmed) {
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ const { setupSafetyLayer } = require("./safety");
32
+ setupSafetyLayer();
33
+ safetyArmed = true;
34
+ }
35
+ }
@@ -344,8 +344,14 @@ export function setupFiberScanner(options?: {
344
344
  intervalMs?: number;
345
345
  onScan?: (ctx: WebotMeScreenContext) => void;
346
346
  }): () => void {
347
- stopFiberScanner();
348
347
  const interval = options?.intervalMs ?? 2000;
348
+ // Idempotent: if a scanner is already running, just refresh the snapshot and
349
+ // keep the single timer (avoids duplicate intervals when the provider arms
350
+ // after an app already called chatWithUs/configureChat).
351
+ if (scanTimer) {
352
+ scanScreen();
353
+ return stopFiberScanner;
354
+ }
349
355
  scanScreen();
350
356
  scanTimer = setInterval(() => {
351
357
  const ctx = scanScreen();
package/src/voice.ts CHANGED
@@ -33,23 +33,22 @@ export type VoiceState =
33
33
  export type VoiceOptions = {
34
34
  /** BCP-47 language for both recognition and speech. Default "en-US". */
35
35
  lang?: string;
36
- /** Speaking speed, 0.5–2. Default 0.9 (same pace the web widget uses). */
37
36
  rate?: number;
38
- /** Speaking pitch, 0.5–2. Default 1. */
39
37
  pitch?: number;
40
- /**
41
- * Speak every bot reply, including ones the user typed. Default false, so
42
- * only answers to spoken questions are read out.
43
- */
44
38
  speakAllReplies?: boolean;
45
- /** Silence in ms that finalises the spoken query. Default 2200. */
46
39
  silenceTimeout?: number;
47
40
  onStateChange?: (state: VoiceState) => void;
48
- /** Interim transcript, updated live while the user speaks. */
49
41
  onPartial?: (text: string) => void;
50
- /** Final transcript once the user stops talking. */
51
42
  onUserQuery?: (text: string) => void;
52
43
  onError?: (message: string) => void;
44
+ /** Wake words that enable the bot once recognised (e.g. ["hello botName"]). */
45
+ activateCommands?: string[];
46
+ /** Shuts down voice once recognised (e.g. ["off botName"]). */
47
+ shutdownCommands?: string[];
48
+ /** Name used to substitute {botName} in the activation/shutdown phrases. */
49
+ botName?: string;
50
+ onActivated?: (transcript: string) => void;
51
+ onDeactivated?: (transcript: string) => void;
53
52
  };
54
53
 
55
54
  /** What `voiceWithUs` hands back, mirroring `ChatResult`. */
@@ -76,6 +75,14 @@ type SpeechLike = {
76
75
 
77
76
  type SpeechEvent = { results?: Array<{ transcript?: string } | string> };
78
77
 
78
+ function normalizeTranscript(v: unknown): string {
79
+ return String(v ?? "")
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9\s]/g, " ")
82
+ .replace(/\s+/g, " ")
83
+ .trim();
84
+ }
85
+
79
86
  type SpeechRecognitionLike = {
80
87
  start: (options?: Record<string, unknown>) => Promise<void> | void;
81
88
  stop: () => Promise<void> | void;
@@ -191,6 +198,8 @@ class VoiceEngine {
191
198
  private speaking = false;
192
199
  private continuous = false;
193
200
  private loopStopped = false;
201
+ private activated = true;
202
+ private commandsConfigured = false;
194
203
 
195
204
  constructor() {
196
205
  this.state = {
@@ -227,6 +236,16 @@ class VoiceEngine {
227
236
  if (typeof options.speakAllReplies === "boolean") {
228
237
  this.patch({ voiceReplies: options.speakAllReplies });
229
238
  }
239
+ if (Array.isArray(options.activateCommands) && options.activateCommands.length > 0) {
240
+ // Lock voice until an activation phrase is heard, but only the first time
241
+ // the commands are applied so re-configuring never re-locks a live bot.
242
+ if (!this.commandsConfigured) this.activated = false;
243
+ this.commandsConfigured = true;
244
+ } else if (options.activateCommands !== undefined) {
245
+ // Commands were explicitly cleared -> fall back to always-available voice.
246
+ this.commandsConfigured = false;
247
+ this.activated = true;
248
+ }
230
249
  this.patch({ supported: this.isSupported() });
231
250
  }
232
251
 
@@ -363,10 +382,110 @@ class VoiceEngine {
363
382
  }
364
383
  }
365
384
 
385
+ /** [a-z0-9] tokens that make up the bot's name (used for phrase matching). */
386
+ private static botNameTokens(botName: string): string[] {
387
+ return String(botName || "assistant")
388
+ .toLowerCase()
389
+ .replace(/[^a-z0-9\s]/g, " ")
390
+ .split(/\s+/)
391
+ .filter(Boolean);
392
+ }
393
+
394
+ /** Remove every mention of the bot's name from a lowercase phrase. */
395
+ private static stripName(text: string, botName: string): string {
396
+ const tokens = VoiceEngine.botNameTokens(botName);
397
+ if (tokens.length === 0) return text;
398
+ let clean = " " + (text || "").toLowerCase() + " ";
399
+ for (const token of tokens) {
400
+ if (!token) continue;
401
+ clean = clean.replace(
402
+ new RegExp(`(^|[^a-z0-9])${token}(?=$|[^a-z0-9])`, "g"),
403
+ "$1 ",
404
+ );
405
+ }
406
+ return clean.replace(/\s+/g, " ").trim();
407
+ }
408
+
409
+ /**
410
+ * True when the spoken phrase matches one of the commands. The bot name is
411
+ * matched both inside the phrase and without it, so "hello rover", "hello",
412
+ * and "hey rover" all activate when the command is "hello {botName}".
413
+ */
414
+ private static matchesAny(
415
+ normalized: string,
416
+ commands: string[] | undefined,
417
+ botName: string,
418
+ ): boolean {
419
+ if (!Array.isArray(commands) || commands.length === 0) return false;
420
+ const stripped = VoiceEngine.stripName(normalized, botName);
421
+ return commands.some((cmd) => {
422
+ const raw = cmd
423
+ .toLowerCase()
424
+ .replace(/{botname}/g, botName.toLowerCase())
425
+ .replace(/\s+/g, " ")
426
+ .trim();
427
+ if (!raw) return false;
428
+ if (normalized.includes(raw)) return true;
429
+ const rawNoName = VoiceEngine.stripName(raw, botName);
430
+ if (!rawNoName) return false;
431
+ return stripped.includes(rawNoName) || stripped === rawNoName;
432
+ });
433
+ }
434
+
366
435
  private async askBot(transcript: string) {
436
+ const normalized = transcript.toLowerCase().replace(/[^a-z0-9\s]/gi, "").trim();
437
+ const botName = this.options.botName || "assistant";
438
+ const wantsActivation = VoiceEngine.matchesAny(normalized, this.options.activateCommands, botName);
439
+ const wantsShutdown = VoiceEngine.matchesAny(normalized, this.options.shutdownCommands, botName);
440
+
441
+ if (wantsShutdown) {
442
+ this.activated = false;
443
+ this.options.onDeactivated?.(transcript);
444
+ this.continuous = false;
445
+ this.loopStopped = true;
446
+ this.stop();
447
+ this.finishSession({
448
+ ok: true,
449
+ transcript,
450
+ reply: "",
451
+ userMessage: null,
452
+ botMessage: null,
453
+ spoken: false,
454
+ });
455
+ return;
456
+ }
457
+
458
+ if (this.options.activateCommands && this.options.activateCommands.length > 0) {
459
+ if (!this.activated) {
460
+ if (wantsActivation) {
461
+ this.activated = true;
462
+ this.options.onActivated?.(transcript);
463
+ } else {
464
+ // Ignore everything before activation; start loop again if continuous.
465
+ if (this.continuous && !this.loopStopped) {
466
+ this.continueLoop();
467
+ } else {
468
+ this.finishSession({
469
+ ok: true,
470
+ transcript,
471
+ reply: "",
472
+ userMessage: null,
473
+ botMessage: null,
474
+ spoken: false,
475
+ });
476
+ }
477
+ return;
478
+ }
479
+ }
480
+ }
481
+
482
+ // Send the phrase without the bare bot-name mention (like the web engine),
483
+ // so "hello rover" reaches the bot as just "hello".
484
+ const forwardText = VoiceEngine.stripName(transcript, botName) || transcript;
485
+
367
486
  let result: ChatResult;
368
487
  try {
369
- result = await chatWithUs(transcript);
488
+ result = await chatWithUs(forwardText);
370
489
  } catch (err) {
371
490
  // chatWithUs already reports its own failures, so this is a real crash.
372
491
  this.fail(
@@ -460,7 +579,7 @@ class VoiceEngine {
460
579
  if (this.state.state === "speaking") this.patch({ state: "idle" });
461
580
  }
462
581
 
463
- /** Stop listening and speaking without settling the promise. */
582
+ /** Stop listening and speaking; a pending `voiceWithUs()` promise resolves. */
464
583
  stop() {
465
584
  if (this.settleTimer) {
466
585
  clearTimeout(this.settleTimer);
@@ -475,6 +594,17 @@ class VoiceEngine {
475
594
  if (this.state.state === "listening" || this.state.state === "thinking" || this.state.state === "speaking") {
476
595
  this.patch({ state: "idle" });
477
596
  }
597
+ // Never leave a caller hanging on `await voiceWithUs()`.
598
+ if (this.resolveSession) {
599
+ this.finishSession({
600
+ ok: true,
601
+ transcript: this.transcript.trim(),
602
+ reply: "",
603
+ userMessage: null,
604
+ botMessage: null,
605
+ spoken: false,
606
+ });
607
+ }
478
608
  }
479
609
 
480
610
  /** A recogniser failure mid-session: report it and go back to idle. */