From cf62bb87bf0b9a6d19924de36a50a526491344c1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 16 Sep 2026 12:24:30 -0700 Subject: [PATCH] fix: send engine=edge from the first-party Edge callers The settings UI writes localStorage immediately but persists the server engine through a debounced save, so a first-party Edge selection could fall through the new server default to a stale ElevenLabs/OpenAI engine. Both WebUI Edge callers now pass engine explicitly; regressions cover both call sites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CNmJZVV9u6pBhcwVhwkPp6 --- static/boot.js | 2 +- static/ui.js | 2 +- ...t_issue7391_first_party_edge_tts_engine.py | 143 ++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue7391_first_party_edge_tts_engine.py diff --git a/static/boot.js b/static/boot.js index 526caa24..d6476b60 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1901,7 +1901,7 @@ window.renderTranscript=function(container, messages, opts){ fetch(new URL('api/tts', document.baseURI || location.href).href, { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({text: clean, voice, rate, pitch}) + body: JSON.stringify({text: clean, voice, rate, pitch, engine: 'edge'}) }) .then(r => { if(!r.ok) throw new Error('TTS request failed: ' + r.status); diff --git a/static/ui.js b/static/ui.js index 4a631482..bb006d78 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9168,7 +9168,7 @@ function _playEdgeTtsChunked(text, btn){ fetch(new URL('api/tts', document.baseURI || location.href).href, { method:'POST', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch}) + body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:'edge'}) }) .then(function(r){ if(!r.ok){ diff --git a/tests/test_issue7391_first_party_edge_tts_engine.py b/tests/test_issue7391_first_party_edge_tts_engine.py new file mode 100644 index 00000000..6f74e01d --- /dev/null +++ b/tests/test_issue7391_first_party_edge_tts_engine.py @@ -0,0 +1,143 @@ +"""G7394-1: first-party Edge callers must send engine:'edge' explicitly. + +The Settings selector writes localStorage immediately, then saves the server +setting through a 350ms debounced autosave. During that window (or if the +save fails) the WebUI dispatches the Edge branch while the server still holds +ElevenLabs/OpenAI. Omitting engine lets the persisted-engine fallback silently +play the stale provider. + +These tests execute the production ui.js / boot.js callers with a local Edge +selection and assert the /api/tts request body carries the explicit override. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.js_source_extract import extract_function + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(script: str) -> list[dict]: + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, ( + f"node subprocess failed:\n--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +_UI_HARNESS = r""" +const requests = []; +const store = { + 'hermes-tts-engine': 'edge', + 'hermes-tts-voice': 'en-US-AriaNeural', +}; +globalThis.window = globalThis; +globalThis.speechSynthesis = { cancel() {}, getVoices() { return []; } }; +globalThis.localStorage = { + getItem: (k) => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null, +}; +globalThis.document = { + baseURI: 'http://localhost:8787/', + querySelectorAll: () => [], +}; +globalThis.location = { href: 'http://localhost:8787/' }; +globalThis.fetch = (url, opts) => { + requests.push({ url: String(url), body: JSON.parse(opts.body) }); + return new Promise(() => {}); +}; +let _ttsSpeaking = false; +let _playingEdgeAudio = null; +let _ttsCurrentUtterance = null; +let _ttsChunkQueue = []; +let _ttsChunkIndex = 0; +let _ttsActiveBtn = null; +__UI_FNS__ +const row = { dataset: { rawText: 'Hello from Edge' } }; +const btn = { dataset: { speaking: '0' }, closest: () => row }; +speakMessage(btn); +console.log(JSON.stringify(requests)); +""" + + +_BOOT_HARNESS = r""" +const requests = []; +const store = { + 'hermes-tts-engine': 'edge', + 'hermes-tts-voice': 'en-US-AriaNeural', +}; +globalThis.window = globalThis; +globalThis.localStorage = { + getItem: (k) => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null, +}; +globalThis.document = { + baseURI: 'http://localhost:8787/', + querySelectorAll: () => [{ dataset: { rawText: 'Hello from Edge' } }], +}; +globalThis.location = { href: 'http://localhost:8787/' }; +globalThis.fetch = (url, opts) => { + requests.push({ url: String(url), body: JSON.parse(opts.body) }); + return new Promise(() => {}); +}; +const S = { session: { session_id: 'sid-edge' } }; +let _voiceModeActive = true; +let _voiceModeThinkingSid = null; +let _ttsSpeaking = false; +let _playingEdgeAudio = null; +function _setState() {} +function _startListening() {} +__BOOT_FNS__ +_speakResponse(); +console.log(JSON.stringify(requests)); +""" + + +def test_play_edge_tts_chunked_sends_explicit_engine_when_server_still_elevenlabs(): + """Local Edge selection (Listen button) must override persisted ElevenLabs.""" + fns = "\n".join( + [ + extract_function(UI_JS, "_stripForTTS"), + extract_function(UI_JS, "_splitForTTS"), + extract_function(UI_JS, "_playEdgeTtsChunked"), + extract_function(UI_JS, "stopTTS"), + extract_function(UI_JS, "speakMessage"), + ] + ) + requests = _run_node(_UI_HARNESS.replace("__UI_FNS__", fns)) + + assert len(requests) == 1 + assert requests[0]["url"].endswith("/api/tts") + assert requests[0]["body"]["engine"] == "edge" + assert requests[0]["body"]["text"] == "Hello from Edge" + assert requests[0]["body"]["voice"] == "en-US-AriaNeural" + + +def test_voice_mode_edge_branch_sends_explicit_engine_when_server_still_openai(): + """Local Edge selection (voice-mode auto-read) must override persisted OpenAI.""" + fns = extract_function(BOOT_JS, "_speakResponse") + requests = _run_node(_BOOT_HARNESS.replace("__BOOT_FNS__", fns)) + + assert len(requests) == 1 + assert requests[0]["url"].endswith("/api/tts") + assert requests[0]["body"]["engine"] == "edge" + assert requests[0]["body"]["text"] == "Hello from Edge" + assert requests[0]["body"]["voice"] == "en-US-AriaNeural"