fix(onboarding,billing): surface completeAndExit errors and suppress near-limit banner for custom providers (#3278)

This commit is contained in:
YellowSnnowmann
2026-06-03 15:38:51 +05:30
committed by GitHub
parent ed6651aea3
commit a83a2a1439
18 changed files with 172 additions and 18 deletions
+40
View File
@@ -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');
+5 -1
View File
@@ -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,
+1
View File
@@ -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':
'يمكنك لصقها الآن أو تخطيها وإضافتها لاحقًا من الإعدادات › الذكاء الاصطناعي. تُحفظ المفاتيح على هذا الجهاز مشفرةً.',
+1
View File
@@ -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-এ যোগ করুন। কীগুলো এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত থাকে।',
+2
View File
@@ -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.',
+1
View File
@@ -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",
+2
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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 इस डिवाइस पर एन्क्रिप्टेड रहती हैं।',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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에서 추가할 수 있습니다. 키는 이 기기에 저장되며 저장 시 암호화됩니다.',
+1
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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. Ключи хранятся на этом устройстве в зашифрованном виде.',
+1
View File
@@ -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}
</>
);
};