Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 17 additions & 3 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,25 @@ cef = { version = "=146.4.1", default-features = false }
# gates existed) means the embedded core does NOT inherit the root crate's
# default gate set, so each default-ON gate must be forwarded explicitly to
# keep the shipped desktop build byte-identical (AGENTS.md "Compile-time
# domain gates"). `media` re-registers the `media_generate_*` agent tools
# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so
# this only restores the pre-gate desktop tool surface.
# domain gates").
#
# This list is NOT optional polish — a gate missing here vanishes from the
# shipped app silently, with no build error and no test failure:
#
# - `voice` — without it the `#[cfg(feature = "voice")]` controllers in
# `src/core/all.rs` are never registered, so the whole `openhuman.voice_*`
# namespace answers "unknown method" at runtime. This shipped broken from
# v0.58.19 to v0.61.x (#4901); the `VOICE_COMPILED_IN` const assert at the
# top of `src/lib.rs` now fails the build if it is dropped again.
# - `media` — re-registers the `media_generate_*` agent tools that #4804 moved
# behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores
# the pre-gate desktop tool surface.
#
# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in
# #4918, deliberately not bundled here because dropping it may be intentional.
openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [
"media",
"voice",
] }
tinyjuice = { version = "0.2.1", default-features = false }

Expand Down
14 changes: 14 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile.");

// The shipped desktop app must always embed the real voice domain. Cargo
// features are per-crate, so `#[cfg(feature = "voice")]` here would test THIS
// crate's features, not the core's — a voice-less core is only observable via
// the core's own always-compiled facade. Without this assert the failure is
// silent and runtime-only: every `openhuman.voice_*` RPC answers "unknown
// method" and the UI blames a stale sidecar (#4901). Keep `voice` in the
// `openhuman_core` feature list in Cargo.toml to satisfy this.
const _: () = assert!(
openhuman_core::openhuman::voice::VOICE_COMPILED_IN,
"openhuman_core must be built with the `voice` feature: the desktop app ships voice, \
and without it every openhuman.voice_* controller is unregistered (#4901). \
Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml."
);

mod app_update;
// Artifact export commands (#2779, #3162) — both cross-platform
// (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
// The host header reads `hasErrors` off `onSaveMetaChange` to disable its
// Save button; the canvas itself also refuses to fire `onSave` through
// the imperative handle while hard errors exist, even though dirty.
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true });
// `hasErrors` reaches the host via a follow-up effect that can lag the
// error-banner render (see `onSaveMetaChange` in EditableFlowCanvas), so
// poll the reported meta rather than reading it once — under a loaded
// full-suite run the synchronous read can still see the pre-validation meta.
await waitFor(() =>
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true })
);
act(() => ref.current?.save());
expect(onSave).not.toHaveBeenCalled();

Expand Down
39 changes: 36 additions & 3 deletions app/src/features/human/MicComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import {
MicComposer,
STT_MAX_RETRIES,
} from './MicComposer';
import { VoiceNotCompiledError } from './voice/sttClient';

// transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries —
// mock them here so we can drive the state machine without touching real APIs.
const transcribeWithFactoryMock = vi.fn();
const encodeBlobToWavMock = vi.fn();
vi.mock('./voice/sttClient', () => ({
// Spread the real module so `VoiceNotCompiledError` / `isVoiceNotCompiledError`
// keep their real behaviour; only the network-touching call is stubbed. A
// factory listing exports by hand silently yields `undefined` for anything it
// forgets, which fails as a TypeError at the call site rather than a clear mock
// error.
vi.mock('./voice/sttClient', async importOriginal => ({
...(await importOriginal<typeof import('./voice/sttClient')>()),
transcribeWithFactory: (...args: unknown[]) => transcribeWithFactoryMock(...args),
}));
vi.mock('./voice/wavEncoder', () => ({
Expand Down Expand Up @@ -794,10 +801,11 @@ describe('MicComposer', () => {
expect(onError).not.toHaveBeenCalled();
});

it('does not retry permanent errors (stale sidecar)', async () => {
it('does not retry permanent errors (voice not compiled into the core)', async () => {
transcribeWithFactoryMock.mockRejectedValueOnce(
new Error(
'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.'
'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' +
'Update OpenHuman to the latest version; restarting will not help.'
)
);
const onError = vi.fn();
Expand All @@ -816,6 +824,31 @@ describe('MicComposer', () => {
expect(onSubmit).not.toHaveBeenCalled();
});

// #4901: the raw error text is untranslated developer copy, so a
// VoiceNotCompiledError must surface the localized `mic.voiceNotCompiled`
// string instead of being spliced into the `{message}` slot.
it('renders translated copy for a voice-not-compiled error, not the raw message', async () => {
transcribeWithFactoryMock.mockRejectedValueOnce(new VoiceNotCompiledError());
const onError = vi.fn();
const onSubmit = vi.fn();
render(<MicComposer disabled={false} onSubmit={onSubmit} onError={onError} />);

fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));

await waitFor(() => expect(onError).toHaveBeenCalled());
const shown = onError.mock.calls[0][0] as string;
// The English `mic.voiceNotCompiled` value, resolved through useT().
expect(shown).toMatch(/not included in this version/i);
// The untranslated developer copy must not leak into the UI.
expect(shown).not.toMatch(/not compiled into the app/i);
expect(onSubmit).not.toHaveBeenCalled();
});

// ── Low-confidence transcript detection (#1206) ────────────────────────────

it('rejects a single-character transcript as low confidence', async () => {
Expand Down
11 changes: 9 additions & 2 deletions app/src/features/human/MicComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { createPortal } from 'react-dom';

import { useT } from '../../lib/i18n/I18nContext';
import { transcribeWithFactory } from './voice/sttClient';
import { isVoiceNotCompiledError, transcribeWithFactory } from './voice/sttClient';
import { encodeBlobToWav } from './voice/wavEncoder';

/** Minimal descriptor for an audio input device. */
Expand Down Expand Up @@ -33,7 +33,7 @@ const STT_RETRY_BASE_MS = 500;
* Matched case-insensitively against the error message.
*/
const PERMANENT_ERROR_PATTERNS = [
'unknown method', // stale sidecar
'unknown method', // core built without the `voice` feature (#4901)
'audio blob is empty',
'unavailable in this build',
];
Expand Down Expand Up @@ -576,6 +576,13 @@ export function MicComposer({
}
const msg = err instanceof Error ? err.message : String(err);
composerLog('transcribe failed: %s', msg);
// The core has no voice domain compiled in (#4901). `msg` is untranslated
// developer copy, so render the localized string rather than splicing it
// into the `{message}` slot.
if (isVoiceNotCompiledError(err)) {
onError?.(t('mic.voiceNotCompiled'));
return;
}
onError?.(t('mic.transcriptionFailed').replace('{message}', msg));
} finally {
if (!disposedRef.current) setState('idle');
Expand Down
41 changes: 34 additions & 7 deletions app/src/features/human/voice/sttClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,31 @@ describe('transcribeCloud', () => {
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 () => {
// #4901: a core built without the `voice` feature never registers the
// `openhuman.voice_*` controllers, so they answer "unknown method". That is a
// compile-time property of the binary, so the message must NOT tell users to
// restart (the pre-#4901 copy did, which could never work).
it('rewrites "unknown method" errors to a not-compiled-in message', 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);
await expect(transcribeCloud(blob)).rejects.toThrow(/not compiled into the app/i);
});

it('does not advise restarting for a compile-time voice gate', 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.not.toThrow(/restart the openhuman desktop app/i);
});

// `MicComposer`'s PERMANENT_ERROR_PATTERNS matches this substring to skip the
// retry/backoff loop — a compile-time gate can never succeed on retry.
it('keeps the "unavailable in this build" substring for retry suppression', 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(/unavailable in this build/i);
});

it('passes through non-unknown-method errors verbatim', async () => {
Expand Down Expand Up @@ -148,11 +165,21 @@ describe('transcribeWithFactory', () => {
expect(mock).not.toHaveBeenCalled();
});

it('rewrites stale-sidecar "unknown method" errors', async () => {
// #4901 — same compile-time gate as the cloud path above.
it('rewrites "unknown method" errors to a not-compiled-in message', async () => {
const mock = callCoreRpc as ReturnType<typeof vi.fn>;
mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch'));
const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' });
await expect(transcribeWithFactory(blob)).rejects.toThrow(/not compiled into the app/i);
});

it('does not advise restarting for a compile-time voice gate', async () => {
const mock = callCoreRpc as ReturnType<typeof vi.fn>;
mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch'));
const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' });
await expect(transcribeWithFactory(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i);
await expect(transcribeWithFactory(blob)).rejects.not.toThrow(
/restart the openhuman desktop app/i
);
});

it('passes through non-unknown-method errors verbatim', async () => {
Expand Down
69 changes: 56 additions & 13 deletions app/src/features/human/voice/sttClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ import { callCoreRpc } from '../../../services/coreRpcClient';

const sttLog = debug('human:stt');

/**
* Stable identifier for "the core has no voice domain compiled in" (#4901).
*
* Callers must branch on this code rather than on the message text: the message
* is untranslated developer/log copy, and the UI is responsible for rendering
* translated copy via `useT()` (see `MicComposer`'s `mic.voiceNotCompiled`).
*/
export const VOICE_NOT_COMPILED_CODE = 'voice_not_compiled';

/**
* Thrown when the core answers `unknown method` for a `voice_*` RPC — i.e. the
* core was compiled without the `voice` feature, so the domain is absent from
* the binary entirely (#4901).
*
* The `message` is English on purpose: it is what reaches debug logs and Sentry.
* User-facing copy is resolved from `code` at the UI boundary.
*
* Keep the `unavailable in this build` substring: `MicComposer`'s
* `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop,
* which can never succeed for a compile-time gate.
*/
export class VoiceNotCompiledError extends Error {
readonly code = VOICE_NOT_COMPILED_CODE;

constructor() {
super(
'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' +
'Update OpenHuman to the latest version; restarting will not help.'
);
this.name = 'VoiceNotCompiledError';
}
}

/**
* Duck-typed on `code` rather than `instanceof`: the error crosses async +
* retry boundaries, and structured-clone/serialization paths can strip the
* prototype while preserving own properties.
*/
export function isVoiceNotCompiledError(err: unknown): err is VoiceNotCompiledError {
return (
typeof err === 'object' &&
err !== null &&
(err as { code?: unknown }).code === VOICE_NOT_COMPILED_CODE
);
}

export interface CloudTranscribeOptions {
/** Override the backend STT model id. Default is whatever the backend
* resolves `whisper-v1` to today. */
Expand Down Expand Up @@ -64,17 +110,16 @@ export async function transcribeCloud(
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.
// An "unknown method" error means the core serving this app was built
// without the `voice` Cargo feature, so the `openhuman.voice_*`
// controllers were never registered (#4901). This is a compile-time
// property of the binary — restarting cannot change it, which is why the
// old #1289-era "restart to pick up the latest core sidecar" copy was
// unactionable (and the sidecar itself is gone since #1061).
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('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg);
throw new VoiceNotCompiledError();
}
sttLog('transcribe rpc failed (passthrough): %O', err);
throw err;
Expand Down Expand Up @@ -149,10 +194,8 @@ export async function transcribeWithFactory(
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('unknown method')) {
sttLog('[voice-stt] dispatch stale-sidecar path: %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('[voice-stt] dispatch: voice domain absent from core build: %s', msg);
throw new VoiceNotCompiledError();
}
sttLog('[voice-stt] dispatch failed (passthrough): %O', err);
throw err;
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3163,6 +3163,8 @@ const messages: TranslationMap = {
'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح: يرجى المحاولة مرة أخرى',
'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}',
'mic.transcriptionFailed': 'فشل النسخ: {message}',
'mic.voiceNotCompiled':
'خاصية تحويل الصوت إلى نص غير متوفرة في هذا الإصدار من التطبيق. حدّث OpenHuman لتفعيلها.',
'reflections.kind.retrospective': 'مراجعة',
'reflections.kind.derivedFact': 'حقيقة مستنتجة',
'reflections.kind.moodInsight': 'رؤية المزاج',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3238,6 +3238,8 @@ const messages: TranslationMap = {
'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি: আবার চেষ্টা করুন',
'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}',
'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}',
'mic.voiceNotCompiled':
'অ্যাপের এই সংস্করণে ভয়েস ট্রান্সক্রিপশন অন্তর্ভুক্ত নেই। এটি চালু করতে OpenHuman আপডেট করুন।',
'reflections.kind.retrospective': 'পূর্বদর্শন',
'reflections.kind.derivedFact': 'ডেরাইভড ফ্যাক্ট',
'reflections.kind.moodInsight': 'মুড ইনসাইট',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3328,6 +3328,8 @@ const messages: TranslationMap = {
'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden: bitte erneut versuchen',
'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}',
'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}',
'mic.voiceNotCompiled':
'Sprachtranskription ist in dieser App-Version nicht enthalten. Aktualisiere OpenHuman, um sie zu aktivieren.',
'reflections.kind.retrospective': 'Retrospektive',
'reflections.kind.derivedFact': 'Abgeleitete Tatsache',
'reflections.kind.moodInsight': 'Stimmungseinsicht',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3629,6 +3629,8 @@ const en: TranslationMap = {
'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again',
'mic.failedToStopRecording': 'Failed to stop recording: {message}',
'mic.transcriptionFailed': 'Transcription failed: {message}',
'mic.voiceNotCompiled':
'Voice transcription is not included in this version of the app. Update OpenHuman to enable it.',

// Reflections: kind labels
'reflections.kind.retrospective': 'Retrospective',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3299,6 +3299,8 @@ const messages: TranslationMap = {
'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad: intenta de nuevo',
'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}',
'mic.transcriptionFailed': 'Transcripción fallida: {message}',
'mic.voiceNotCompiled':
'La transcripción de voz no está incluida en esta versión de la aplicación. Actualiza OpenHuman para activarla.',
'reflections.kind.retrospective': 'Retrospectiva',
'reflections.kind.derivedFact': 'Hecho derivado',
'reflections.kind.moodInsight': 'Insight de ánimo',
Expand Down
Loading
Loading