diff --git a/app/src/features/human/voice/sttClient.test.ts b/app/src/features/human/voice/sttClient.test.ts index d53606505..7398d0a24 100644 --- a/app/src/features/human/voice/sttClient.test.ts +++ b/app/src/features/human/voice/sttClient.test.ts @@ -92,4 +92,21 @@ describe('transcribeCloud', () => { const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); expect(await transcribeCloud(blob)).toBe(''); }); + + // Issue #1289: stale sidecar binaries surface a generic + // "unknown method" error. Frontend rewrites it to an actionable + // message so users know to restart the desktop app. + it('rewrites "unknown method" errors to an actionable restart hint', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + }); + + it('passes through non-unknown-method errors verbatim', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('upstream STT failed: 502')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/upstream STT failed/); + }); }); diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 1bf6d1a14..06062792b 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -57,10 +57,28 @@ export async function transcribeCloud( ); const rpcStart = Date.now(); - const result = await callCoreRpc({ - method: 'openhuman.voice_cloud_transcribe', - params, - }); + let result: CloudTranscribeResult; + try { + result = await callCoreRpc({ + method: 'openhuman.voice_cloud_transcribe', + params, + }); + } catch (err) { + // Issue #1289: an "unknown method" error means the bundled core + // sidecar is older than the frontend (e.g. a stale dev build, or a + // cached binary the desktop auto-update hasn't refreshed yet). + // The raw "unknown method: openhuman.voice_cloud_transcribe" string + // is opaque to end users — surface an actionable message instead. + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('unknown method')) { + sttLog('transcribe rpc stale-sidecar path hit; rewriting unknown-method error: %s', msg); + throw new Error( + 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' + ); + } + sttLog('transcribe rpc failed (passthrough): %O', err); + throw err; + } const text = result?.text?.trim() ?? ''; sttLog('transcribed chars=%d rpc_ms=%d', text.length, Math.round(Date.now() - rpcStart)); return text; diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index bcb9f0bb2..6e734af35 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -3768,3 +3768,87 @@ async fn whatsapp_memory_doc_ingest_e2e() { mock_join.abort(); rpc_join.abort(); } + +/// Regression guard for issue #1289: `openhuman.voice_cloud_transcribe` +/// must stay registered in the controller registry and reachable via +/// JSON-RPC dispatch. +/// +/// The user-visible symptom was "Voice transcription failed: unknown +/// method: openhuman.voice_cloud_transcribe" — the frontend (mascot +/// mic-only composer) was calling a method that wasn't reachable. +/// This test pins both ends: +/// +/// 1. `/schema` exposes `openhuman.voice_cloud_transcribe` so the +/// discovery surface stays in sync with the live registry. +/// 2. Calling the method over RPC does NOT hit the dispatcher's +/// unknown-method branch (`Err("unknown method: …")`). The call may +/// still fail downstream (missing audio, unauthenticated, missing +/// upstream STT key) — but it must reach the registered handler, +/// which proves the method is wired all the way through. +#[tokio::test] +async fn voice_cloud_transcribe_registered_e2e() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // ── 1. /schema must list openhuman.voice_cloud_transcribe ─────────────── + let schema = reqwest::get(format!("{rpc_base}/schema")) + .await + .expect("GET /schema") + .json::() + .await + .expect("schema json"); + let methods = schema["methods"] + .as_array() + .unwrap_or_else(|| panic!("/schema must expose methods array: {schema}")); + let names: Vec<&str> = methods + .iter() + .filter_map(|m| m.get("method").and_then(Value::as_str)) + .collect(); + assert!( + names.contains(&"openhuman.voice_cloud_transcribe"), + "voice_cloud_transcribe must appear in /schema dump (got {} methods)", + names.len() + ); + + // ── 2. RPC dispatch must NOT return "unknown method" ─────────────────── + // Send a minimal payload — it'll fail downstream (no upstream STT + // configured in the mock), but the dispatcher should reach the + // handler, not the unknown-method branch. + let resp = post_json_rpc( + &rpc_base, + 9101, + "openhuman.voice_cloud_transcribe", + json!({ "audio_base64": "" }), + ) + .await; + // Inspect the full error blob, not just `error.message`. A future + // server-shape change that moves the dispatcher's unknown-method + // string into `error.data` would otherwise let this regression + // guard silently pass. + let err_blob = resp + .get("error") + .map(|e| e.to_string().to_ascii_lowercase()) + .unwrap_or_default(); + assert!( + !err_blob.contains("unknown method"), + "voice_cloud_transcribe must be a known method; full response: {resp}" + ); + + mock_join.abort(); + rpc_join.abort(); +}