mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(onboarding,billing): surface completeAndExit errors and suppress near-limit banner for custom providers (#3278)
This commit is contained in:
@@ -542,6 +542,46 @@ describe('useUsageState', () => {
|
||||
expect(mockGetTeamUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses near-limit banner when chat is fully routed away but background workloads remain (#3097)', async () => {
|
||||
// Background workloads (memory, heartbeat, …) keep the billing API call
|
||||
// alive (ALL_WORKLOADS check), but isFullyRoutedAway (CHAT_WORKLOADS) is
|
||||
// true so the near-limit banner must NOT show — the user's chat is not
|
||||
// on OpenHuman's budget.
|
||||
const { useUsageState } = await import('./useUsageState');
|
||||
|
||||
mockGetCurrentPlan.mockResolvedValue(freePlan());
|
||||
// Usage at 90% — would trigger isNearLimit for OpenHuman-routed users.
|
||||
mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 1, cycleBudgetUsd: 10 }));
|
||||
mockLoadAISettings.mockResolvedValue({
|
||||
cloudProviders: [],
|
||||
routing: {
|
||||
chat: { kind: 'local' as const, model: 'qwen3:8b' },
|
||||
reasoning: { kind: 'local' as const, model: 'qwen3:8b' },
|
||||
agentic: { kind: 'local' as const, model: 'qwen3:8b' },
|
||||
coding: { kind: 'local' as const, model: 'qwen3:8b' },
|
||||
// background workloads still on OpenHuman → billing API is still called
|
||||
memory: { kind: 'openhuman' as const },
|
||||
embeddings: { kind: 'openhuman' as const },
|
||||
heartbeat: { kind: 'openhuman' as const },
|
||||
learning: { kind: 'openhuman' as const },
|
||||
subconscious: { kind: 'openhuman' as const },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsageState());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isFullyRoutedAway).toBe(true);
|
||||
// usagePct ~90% but user is routed away from OpenHuman — no near-limit warning.
|
||||
expect(result.current.isNearLimit).toBe(false);
|
||||
expect(result.current.isAtLimit).toBe(false);
|
||||
// billing was still fetched because background workloads remain on OpenHuman
|
||||
expect(mockGetTeamUsage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still fetches billing when a background workload remains on OpenHuman', async () => {
|
||||
const { useUsageState } = await import('./useUsageState');
|
||||
|
||||
|
||||
@@ -189,7 +189,11 @@ export function useUsageState(): UsageState {
|
||||
|
||||
const isAtLimit = isBudgetExhausted;
|
||||
|
||||
const isNearLimit = !isAtLimit && teamUsage !== null && usagePct >= 0.8;
|
||||
// Mirror the isAtLimit guard: when every chat workload is routed away from
|
||||
// OpenHuman the included-budget cycle does not gate the user, so the
|
||||
// near-limit warning is equally irrelevant (#3097 — top-up banner shown
|
||||
// despite custom provider).
|
||||
const isNearLimit = !isAtLimit && !isFullyRoutedAway && teamUsage !== null && usagePct >= 0.8;
|
||||
|
||||
return {
|
||||
teamUsage,
|
||||
|
||||
@@ -486,6 +486,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'المتابعة بالخيار البسيط',
|
||||
'onboarding.runtimeChoice.continueCustom': 'المتابعة بالخيار المخصص',
|
||||
'onboarding.runtimeChoice.recommended': 'موصى به',
|
||||
'onboarding.runtimeChoice.exitError': 'تعذّر إتمام الإعداد. يُرجى المحاولة مجددًا.',
|
||||
'onboarding.apiKeys.title': 'لنضف مفاتيح API الخاصة بك',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'يمكنك لصقها الآن أو تخطيها وإضافتها لاحقًا من الإعدادات › الذكاء الاصطناعي. تُحفظ المفاتيح على هذا الجهاز مشفرةً.',
|
||||
|
||||
@@ -494,6 +494,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'সহজ দিয়ে চালিয়ে যান',
|
||||
'onboarding.runtimeChoice.continueCustom': 'কাস্টম দিয়ে চালিয়ে যান',
|
||||
'onboarding.runtimeChoice.recommended': 'প্রস্তাবিত',
|
||||
'onboarding.runtimeChoice.exitError': 'অনবোর্ডিং শেষ করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'onboarding.apiKeys.title': 'আপনার API কী যোগ করুন',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'এখনই পেস্ট করুন বা এড়িয়ে গিয়ে পরে Settings › AI-এ যোগ করুন। কীগুলো এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত থাকে।',
|
||||
|
||||
@@ -508,6 +508,8 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Fahre mit „Einfach“ fort',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Fahre mit Benutzerdefiniert fort',
|
||||
'onboarding.runtimeChoice.recommended': 'Empfohlen',
|
||||
'onboarding.runtimeChoice.exitError':
|
||||
'Onboarding konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.',
|
||||
'onboarding.apiKeys.title': 'Fügen wir deine API-Schlüssel hinzu',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Du kannst sie jetzt einfügen oder überspringen und später unter „Einstellungen“ > „KI“ hinzufügen. Schlüssel werden auf diesem Gerät gespeichert und im Ruhezustand verschlüsselt.',
|
||||
|
||||
@@ -613,6 +613,7 @@ const en: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continue with Simple',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continue with Custom',
|
||||
'onboarding.runtimeChoice.recommended': 'Recommended',
|
||||
'onboarding.runtimeChoice.exitError': 'Could not finish onboarding. Please try again.',
|
||||
|
||||
// Onboarding: API keys step (only when Custom is picked)
|
||||
'onboarding.apiKeys.title': "Let's Add Your API Keys",
|
||||
|
||||
@@ -506,6 +506,8 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continuar con Simple',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continuar con Personalizado',
|
||||
'onboarding.runtimeChoice.recommended': 'Recomendado',
|
||||
'onboarding.runtimeChoice.exitError':
|
||||
'No se pudo completar el proceso de incorporación. Por favor, inténtalo de nuevo.',
|
||||
'onboarding.apiKeys.title': 'Agreguemos tus claves API',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Puedes pegarlas ahora u omitir y agregarlas luego en Configuración › IA. Las claves se guardan en este dispositivo, cifradas en reposo.',
|
||||
|
||||
@@ -504,6 +504,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continuer avec Simple',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continuer avec Personnalisé',
|
||||
'onboarding.runtimeChoice.recommended': 'Recommandé',
|
||||
'onboarding.runtimeChoice.exitError': "Impossible de terminer l'intégration. Veuillez réessayer.",
|
||||
'onboarding.apiKeys.title': 'Ajoutons tes clés API',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Tu peux les coller maintenant ou passer et les ajouter plus tard dans Paramètres › IA. Les clés sont stockées sur cet appareil, chiffrées au repos.',
|
||||
|
||||
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'सिंपल के साथ जारी रखें',
|
||||
'onboarding.runtimeChoice.continueCustom': 'कस्टम के साथ जारी रखें',
|
||||
'onboarding.runtimeChoice.recommended': 'सुझावित',
|
||||
'onboarding.runtimeChoice.exitError': 'ऑनबोर्डिंग पूरी नहीं हो सकी। कृपया पुनः प्रयास करें।',
|
||||
'onboarding.apiKeys.title': 'अपनी API Keys जोड़ें',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'अभी पेस्ट करें या बाद में Settings › AI में जोड़ें। Keys इस डिवाइस पर एन्क्रिप्टेड रहती हैं।',
|
||||
|
||||
@@ -497,6 +497,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Lanjutkan dengan Sederhana',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Lanjutkan dengan Kustom',
|
||||
'onboarding.runtimeChoice.recommended': 'Direkomendasikan',
|
||||
'onboarding.runtimeChoice.exitError': 'Tidak dapat menyelesaikan orientasi. Silakan coba lagi.',
|
||||
'onboarding.apiKeys.title': 'Mari Tambahkan API Key Anda',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Anda dapat menempelkannya sekarang atau lewati dan tambahkan nanti di Pengaturan › AI. Key disimpan di perangkat ini, dienkripsi saat penyimpanan.',
|
||||
|
||||
@@ -503,6 +503,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continua con Semplice',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continua con Personalizzato',
|
||||
'onboarding.runtimeChoice.recommended': 'Consigliato',
|
||||
'onboarding.runtimeChoice.exitError': "Impossibile completare l'onboarding. Riprova.",
|
||||
'onboarding.apiKeys.title': 'Aggiungiamo le tue chiavi API',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Puoi incollarle ora o saltare e aggiungerle dopo in Impostazioni › AI. Le chiavi sono memorizzate su questo dispositivo, crittografate a riposo.',
|
||||
|
||||
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': '간단 모드로 계속',
|
||||
'onboarding.runtimeChoice.continueCustom': '사용자 지정으로 계속',
|
||||
'onboarding.runtimeChoice.recommended': '추천',
|
||||
'onboarding.runtimeChoice.exitError': '온보딩을 완료할 수 없습니다. 다시 시도해 주세요.',
|
||||
'onboarding.apiKeys.title': 'API 키를 추가해 봅시다',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'지금 붙여넣거나 건너뛰고 나중에 설정 › AI에서 추가할 수 있습니다. 키는 이 기기에 저장되며 저장 시 암호화됩니다.',
|
||||
|
||||
@@ -501,6 +501,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Kontynuuj w trybie Prosto',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Kontynuuj w trybie Niestandardowo',
|
||||
'onboarding.runtimeChoice.recommended': 'Polecane',
|
||||
'onboarding.runtimeChoice.exitError': 'Nie udało się zakończyć wdrożenia. Spróbuj ponownie.',
|
||||
'onboarding.apiKeys.title': 'Dodajmy Twoje klucze API',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Możesz wkleić je teraz lub pominąć i dodać później w Ustawieniach › AI. Klucze są przechowywane na tym urządzeniu, zaszyfrowane w spoczynku.',
|
||||
|
||||
@@ -504,6 +504,8 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continuar com Simples',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continuar com Personalizado',
|
||||
'onboarding.runtimeChoice.recommended': 'Recomendado',
|
||||
'onboarding.runtimeChoice.exitError':
|
||||
'Não foi possível concluir o processo de integração. Por favor, tente novamente.',
|
||||
'onboarding.apiKeys.title': 'Vamos Adicionar Suas Chaves de API',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Você pode colá-las agora ou pular e adicioná-las depois em Configurações › IA. As chaves são armazenadas neste dispositivo, criptografadas em repouso.',
|
||||
|
||||
@@ -498,6 +498,8 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': 'Продолжить с простым режимом',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Продолжить со своими настройками',
|
||||
'onboarding.runtimeChoice.recommended': 'Рекомендуется',
|
||||
'onboarding.runtimeChoice.exitError':
|
||||
'Не удалось завершить настройку. Пожалуйста, попробуйте ещё раз.',
|
||||
'onboarding.apiKeys.title': 'Добавь свои API-ключи',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'Вставь их сейчас или пропусти и добавь позже в Настройки › AI. Ключи хранятся на этом устройстве в зашифрованном виде.',
|
||||
|
||||
@@ -477,6 +477,7 @@ const messages: TranslationMap = {
|
||||
'onboarding.runtimeChoice.continueCloud': '继续使用简单模式',
|
||||
'onboarding.runtimeChoice.continueCustom': '继续使用自定义模式',
|
||||
'onboarding.runtimeChoice.recommended': '推荐',
|
||||
'onboarding.runtimeChoice.exitError': '无法完成引导流程,请重试。',
|
||||
'onboarding.apiKeys.title': '添加你的 API 密钥',
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'你可以现在粘贴,也可以跳过并稍后在设置 › AI 中添加。密钥会加密存储在此设备上。',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import RuntimeChoicePage from './RuntimeChoicePage';
|
||||
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const setDraftMock = vi.hoisted(() => vi.fn());
|
||||
const completeAndExitMock = vi.hoisted(() => vi.fn());
|
||||
const isLocalSessionTokenMock = vi.hoisted(() => vi.fn(() => false));
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
vi.mock('../../../utils/localSession', () => ({ isLocalSessionToken: isLocalSessionTokenMock }));
|
||||
|
||||
vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
vi.mock('../OnboardingContext', () => ({
|
||||
useOnboardingContext: () => ({ setDraft: setDraftMock, completeAndExit: completeAndExitMock }),
|
||||
}));
|
||||
|
||||
vi.mock('../steps/RuntimeChoiceStep', () => ({
|
||||
default: ({ onNext }: { onNext: (mode: string) => void }) => (
|
||||
<div data-testid="runtime-choice-step">
|
||||
<button onClick={() => onNext('cloud')}>Pick Cloud</button>
|
||||
<button onClick={() => onNext('custom')}>Pick Custom</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RuntimeChoicePage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
setDraftMock.mockReset();
|
||||
completeAndExitMock.mockReset();
|
||||
isLocalSessionTokenMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('redirects to custom inference and renders nothing for local sessions', () => {
|
||||
isLocalSessionTokenMock.mockReturnValue(true);
|
||||
const { container } = renderWithProviders(<RuntimeChoicePage />);
|
||||
expect(navigateMock).toHaveBeenCalledWith('/onboarding/custom/inference', { replace: true });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the choice step for cloud sessions', () => {
|
||||
renderWithProviders(<RuntimeChoicePage />);
|
||||
expect(screen.getByTestId('runtime-choice-step')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to custom inference when custom mode is picked', async () => {
|
||||
renderWithProviders(<RuntimeChoicePage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick Custom' }));
|
||||
expect(setDraftMock).toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith('/onboarding/custom/inference');
|
||||
expect(completeAndExitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls completeAndExit for cloud mode and shows no error on success', async () => {
|
||||
completeAndExitMock.mockResolvedValue(undefined);
|
||||
renderWithProviders(<RuntimeChoicePage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick Cloud' }));
|
||||
await waitFor(() => expect(completeAndExitMock).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId('onboarding-runtime-choice-exit-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error banner when completeAndExit throws', async () => {
|
||||
completeAndExitMock.mockRejectedValue(new Error('network failure'));
|
||||
renderWithProviders(<RuntimeChoicePage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick Cloud' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('onboarding-runtime-choice-exit-error')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
@@ -8,10 +9,12 @@ import { useOnboardingContext } from '../OnboardingContext';
|
||||
import RuntimeChoiceStep from '../steps/RuntimeChoiceStep';
|
||||
|
||||
const RuntimeChoicePage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { setDraft, completeAndExit } = useOnboardingContext();
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
const [exitError, setExitError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalSession) {
|
||||
@@ -24,23 +27,34 @@ const RuntimeChoicePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<RuntimeChoiceStep
|
||||
onNext={async mode => {
|
||||
setDraft(prev => ({ ...prev, aiMode: mode }));
|
||||
trackEvent('onboarding_step_complete', { step_name: 'runtime_choice', ai_mode: mode });
|
||||
<>
|
||||
<RuntimeChoiceStep
|
||||
onNext={async mode => {
|
||||
setExitError(null);
|
||||
setDraft(prev => ({ ...prev, aiMode: mode }));
|
||||
trackEvent('onboarding_step_complete', { step_name: 'runtime_choice', ai_mode: mode });
|
||||
|
||||
if (mode === 'custom') {
|
||||
navigate('/onboarding/custom/inference');
|
||||
return;
|
||||
}
|
||||
// Cloud path: nothing else to configure, finish onboarding.
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:runtime-choice-page] completeAndExit failed', err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
if (mode === 'custom') {
|
||||
navigate('/onboarding/custom/inference');
|
||||
return;
|
||||
}
|
||||
// Cloud path: nothing else to configure, finish onboarding.
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:runtime-choice-page] completeAndExit failed', err);
|
||||
setExitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{exitError ? (
|
||||
<div
|
||||
className="mt-3 rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300"
|
||||
data-testid="onboarding-runtime-choice-exit-error">
|
||||
{t('onboarding.runtimeChoice.exitError')}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user