fix(stt): rewrite stale-sidecar voice error + e2e registration guard (#1298)

This commit is contained in:
obchain
2026-05-07 12:05:07 -07:00
committed by GitHub
parent b9c01ca327
commit 3bbf4b5d9f
3 changed files with 123 additions and 4 deletions
@@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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/);
});
});
+22 -4
View File
@@ -57,10 +57,28 @@ export async function transcribeCloud(
);
const rpcStart = Date.now();
const result = await callCoreRpc<CloudTranscribeResult>({
method: 'openhuman.voice_cloud_transcribe',
params,
});
let result: CloudTranscribeResult;
try {
result = await callCoreRpc<CloudTranscribeResult>({
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;
+84
View File
@@ -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::<Value>()
.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();
}