fix(onboarding): raise snapshot timeout + staged still-working UI (#2156) (#2179)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
obchain
2026-05-19 21:08:59 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent a40272ef45
commit 9376ffc19f
18 changed files with 662 additions and 25 deletions
+8 -1
View File
@@ -151,7 +151,14 @@ const ar4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'جارٍ بناء ملفك الشخصي...',
'onboarding.contextGathering.continueToChat': 'المتابعة إلى المحادثة',
'onboarding.contextGathering.errorDesc':
'لم نتمكّن من بناء ملفك الشخصي بالكامل الآن، لكن لا بأس — يمكنك المتابعة وسيكتمل ملفك تدريجيًا مع مرور الوقت.',
'تعذّر إنشاء ملفك الكامل الآن، لكن لا بأس — يمكنك المتابعة وسيُبنى ملفك مع الوقت.',
'onboarding.contextGathering.coreAlive': 'النواة متاحة — قد يستغرق التشغيل الأول دقيقة.',
'onboarding.contextGathering.coreAliveProbing': 'يجري التحقق من اتصال النواة…',
'onboarding.contextGathering.coreUnreachable':
'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.',
'onboarding.contextGathering.stillWorkingDesc':
'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.',
'onboarding.contextGathering.stillWorkingTitle': 'لا يزال العمل جاريًا على ملفك الشخصي…',
'onboarding.contextGathering.title': 'جمع السياق',
'openhuman.team_list_teams': 'قائمة الفرق',
'overlay.ariaAttention': 'رسالة انتباه',
+8 -1
View File
@@ -152,7 +152,14 @@ const bn4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'আপনার প্রোফাইল তৈরি হচ্ছে...',
'onboarding.contextGathering.continueToChat': 'চ্যাটে চালিয়ে যান',
'onboarding.contextGathering.errorDesc':
'আমরা এই মুহূর্তে আপনার সম্পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন, আপনার প্রোফাইল সময়ের সাথে তৈরি হতে থাকবে।',
'আমরা এখনই আপনার পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন এবং আপনার প্রোফাইল সময়ের সাথে তৈরি হবে।',
'onboarding.contextGathering.coreAlive': 'কোর সংযোগযোগ্য — প্রথম লঞ্চ এক মিনিট সময় নিতে পারে।',
'onboarding.contextGathering.coreAliveProbing': 'কোর সংযোগ যাচাই করা হচ্ছে…',
'onboarding.contextGathering.coreUnreachable':
'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।',
'onboarding.contextGathering.stillWorkingDesc':
'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।',
'onboarding.contextGathering.stillWorkingTitle': 'এখনও আপনার প্রোফাইলে কাজ চলছে…',
'onboarding.contextGathering.title': 'কন্টেক্সট সংগ্রহ',
'openhuman.team_list_teams': 'টিম তালিকা',
'overlay.ariaAttention': 'মনোযোগের বার্তা',
+7
View File
@@ -153,6 +153,13 @@ const en4: TranslationMap = {
'onboarding.contextGathering.continueToChat': 'Continue to chat',
'onboarding.contextGathering.errorDesc':
"Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.",
'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.',
'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…',
'onboarding.contextGathering.coreUnreachable':
'Core is not responding. You can continue and try again later.',
'onboarding.contextGathering.stillWorkingDesc':
'First launch can take 3060 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.',
'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…',
'onboarding.contextGathering.title': 'Context Gathering',
'openhuman.team_list_teams': 'Team list teams',
'overlay.ariaAttention': 'Attention message',
+9 -1
View File
@@ -152,7 +152,15 @@ const es4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'Construyendo tu perfil...',
'onboarding.contextGathering.continueToChat': 'Continuar al chat',
'onboarding.contextGathering.errorDesc':
'No pudimos construir tu perfil completo en este momento, pero no pasa nada: puedes continuar y tu perfil se irá construyendo con el tiempo.',
'No pudimos construir tu perfil completo ahora mismo, pero no pasa nada puedes continuar y tu perfil se construirá con el tiempo.',
'onboarding.contextGathering.coreAlive':
'El núcleo está disponible — el primer arranque puede tardar un minuto.',
'onboarding.contextGathering.coreAliveProbing': 'Comprobando la conexión con el núcleo…',
'onboarding.contextGathering.coreUnreachable':
'El núcleo no responde. Puedes continuar e intentarlo más tarde.',
'onboarding.contextGathering.stillWorkingDesc':
'El primer arranque puede tardar 3060 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.',
'onboarding.contextGathering.stillWorkingTitle': 'Seguimos trabajando en tu perfil…',
'onboarding.contextGathering.title': 'Recopilación de contexto',
'openhuman.team_list_teams': 'Lista de equipos',
'overlay.ariaAttention': 'Mensaje de atención',
+9 -1
View File
@@ -152,7 +152,15 @@ const fr4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'Construction de ton profil…',
'onboarding.contextGathering.continueToChat': 'Accéder au chat',
'onboarding.contextGathering.errorDesc':
"Nous n'avons pas pu construire ton profil complet pour le moment, mais ce n'est pas grave — tu peux continuer et ton profil se construira au fil du temps.",
"Nous n'avons pas pu créer votre profil complet pour l'instant, mais ce n'est pas grave — vous pouvez continuer et votre profil se construira au fil du temps.",
'onboarding.contextGathering.coreAlive':
'Le cœur est accessible — le premier lancement peut prendre une minute.',
'onboarding.contextGathering.coreAliveProbing': 'Vérification de la connexion au cœur…',
'onboarding.contextGathering.coreUnreachable':
'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.',
'onboarding.contextGathering.stillWorkingDesc':
'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.',
'onboarding.contextGathering.stillWorkingTitle': 'Construction de ton profil en cours…',
'onboarding.contextGathering.title': 'Collecte de contexte',
'openhuman.team_list_teams': 'Liste des équipes',
'overlay.ariaAttention': "Message d'attention",
+8
View File
@@ -153,6 +153,14 @@ const hi4: TranslationMap = {
'onboarding.contextGathering.continueToChat': 'चैट पर जाएं',
'onboarding.contextGathering.errorDesc':
'हम अभी आपकी पूरी प्रोफ़ाइल नहीं बना सके, लेकिन कोई बात नहीं — आप जारी रख सकते हैं और आपकी प्रोफ़ाइल समय के साथ बनती जाएगी।',
'onboarding.contextGathering.coreAlive':
'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।',
'onboarding.contextGathering.coreAliveProbing': 'कोर कनेक्शन की जाँच की जा रही है…',
'onboarding.contextGathering.coreUnreachable':
'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।',
'onboarding.contextGathering.stillWorkingDesc':
'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।',
'onboarding.contextGathering.stillWorkingTitle': 'आपकी प्रोफ़ाइल पर अब भी काम चल रहा है…',
'onboarding.contextGathering.title': 'कॉन्टेक्स्ट गैदरिंग',
'openhuman.team_list_teams': 'टीम सूची टीमें',
'overlay.ariaAttention': 'ध्यान संदेश',
+9 -1
View File
@@ -152,7 +152,15 @@ const id4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'Membangun profil Anda...',
'onboarding.contextGathering.continueToChat': 'Lanjutkan ke chat',
'onboarding.contextGathering.errorDesc':
'Kami belum bisa membangun profil lengkap Anda sekarang, tetapi tidak apa-apa — Anda bisa melanjutkan dan profil Anda akan terbangun seiring waktu.',
'Kami tidak bisa membangun profil lengkap Anda sekarang, tapi tidak apa-apa — Anda bisa lanjut dan profil Anda akan terbentuk seiring waktu.',
'onboarding.contextGathering.coreAlive':
'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.',
'onboarding.contextGathering.coreAliveProbing': 'Memeriksa koneksi core…',
'onboarding.contextGathering.coreUnreachable':
'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.',
'onboarding.contextGathering.stillWorkingDesc':
'Peluncuran pertama bisa memakan waktu 3060 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.',
'onboarding.contextGathering.stillWorkingTitle': 'Masih membangun profil Anda…',
'onboarding.contextGathering.title': 'Pengumpulan Konteks',
'openhuman.team_list_teams': 'Daftar tim',
'overlay.ariaAttention': 'Pesan perhatian',
+8
View File
@@ -153,6 +153,14 @@ const it4: TranslationMap = {
'onboarding.contextGathering.continueToChat': 'Continua alla chat',
'onboarding.contextGathering.errorDesc':
'Non siamo riusciti a costruire il tuo profilo completo ora, ma va bene — puoi continuare e il tuo profilo si svilupperà nel tempo.',
'onboarding.contextGathering.coreAlive':
'Il core è raggiungibile — il primo avvio può richiedere un minuto.',
'onboarding.contextGathering.coreAliveProbing': 'Verifica della connessione al core…',
'onboarding.contextGathering.coreUnreachable':
'Il core non risponde. Puoi continuare e riprovare più tardi.',
'onboarding.contextGathering.stillWorkingDesc':
'Il primo avvio può richiedere 3060 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.',
'onboarding.contextGathering.stillWorkingTitle': 'Stiamo ancora preparando il tuo profilo…',
'onboarding.contextGathering.title': 'Raccolta del contesto',
'openhuman.team_list_teams': 'Elenco team',
'overlay.ariaAttention': 'Messaggio di attenzione',
+9 -1
View File
@@ -153,7 +153,15 @@ const pt4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'Construindo seu perfil...',
'onboarding.contextGathering.continueToChat': 'Continuar para o chat',
'onboarding.contextGathering.errorDesc':
'Não conseguimos construir seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído com o tempo.',
'Não conseguimos montar seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído ao longo do tempo.',
'onboarding.contextGathering.coreAlive':
'Núcleo acessível — a primeira inicialização pode demorar um minuto.',
'onboarding.contextGathering.coreAliveProbing': 'Verificando a conexão com o núcleo…',
'onboarding.contextGathering.coreUnreachable':
'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.',
'onboarding.contextGathering.stillWorkingDesc':
'A primeira inicialização pode levar 3060 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.',
'onboarding.contextGathering.stillWorkingTitle': 'Ainda construindo seu perfil…',
'onboarding.contextGathering.title': 'Coleta de Contexto',
'openhuman.team_list_teams': 'Listar equipes',
'overlay.ariaAttention': 'Mensagem de atenção',
+8 -1
View File
@@ -152,7 +152,14 @@ const ru4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': 'Составление профиля...',
'onboarding.contextGathering.continueToChat': 'Перейти в чат',
'onboarding.contextGathering.errorDesc':
'Сейчас не удалось построить ваш полный профиль, но это нормально — вы можете продолжить, и ваш профиль будет формироваться со временем.',
'Мы не смогли построить ваш полный профиль прямо сейчас, но это нормально — вы можете продолжить, и профиль будет дополняться со временем.',
'onboarding.contextGathering.coreAlive': 'Ядро доступно — первый запуск может занять минуту.',
'onboarding.contextGathering.coreAliveProbing': 'Проверка соединения с ядром…',
'onboarding.contextGathering.coreUnreachable':
'Ядро не отвечает. Можно продолжить и попробовать позже.',
'onboarding.contextGathering.stillWorkingDesc':
'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.',
'onboarding.contextGathering.stillWorkingTitle': 'Профиль ещё составляется…',
'onboarding.contextGathering.title': 'Сбор контекста',
'openhuman.team_list_teams': 'Список команд',
'overlay.ariaAttention': 'Сообщение',
+7 -1
View File
@@ -149,7 +149,13 @@ const zhCN4: TranslationMap = {
'onboarding.contextGathering.buildingProfile': '正在构建你的档案...',
'onboarding.contextGathering.continueToChat': '前往对话',
'onboarding.contextGathering.errorDesc':
'暂时无法完整构建你的档案,但没关系——你可以继续,档案会随时间逐步完善。',
'我们暂时无法构建你的完整资料,但没关系——你可以继续,资料会随时间逐步完善。',
'onboarding.contextGathering.coreAlive': '核心可访问 — 首次启动可能需要一分钟。',
'onboarding.contextGathering.coreAliveProbing': '正在检查核心连接…',
'onboarding.contextGathering.coreUnreachable': '核心未响应。你可以继续,稍后再试。',
'onboarding.contextGathering.stillWorkingDesc':
'我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。',
'onboarding.contextGathering.stillWorkingTitle': '仍在生成你的档案…',
'onboarding.contextGathering.title': '上下文收集',
'openhuman.team_list_teams': '团队列表',
'overlay.ariaAttention': '注意消息',
+7
View File
@@ -1493,8 +1493,15 @@ const en: TranslationMap = {
'onboarding.contextGathering.buildingDesc': 'Gathering context from your connected accounts…',
'onboarding.contextGathering.buildingProfile': 'Building your profile...',
'onboarding.contextGathering.continueToChat': 'Continue to chat',
'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.',
'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…',
'onboarding.contextGathering.coreUnreachable':
'Core is not responding. You can continue and try again later.',
'onboarding.contextGathering.errorDesc':
"Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.",
'onboarding.contextGathering.stillWorkingDesc':
'First launch can take 3060 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.',
'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…',
'onboarding.contextGathering.title': 'Context Gathering',
'openhuman.team_list_teams': 'Team list teams',
'overlay.ariaAttention': 'Attention message',
@@ -15,9 +15,30 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import { callCoreRpc, getCoreRpcUrl, testCoreRpcConnection } from '../../../services/coreRpcClient';
import OnboardingNextButton from '../components/OnboardingNextButton';
/**
* Threshold after which the building-profile UI swaps to the "still working"
* staged copy (#2156). Real first-launch completions on slow M-series Macs
* can take 3040s, so users would otherwise see what looks like a stall at
* the global RPC timeout boundary.
*/
const STILL_WORKING_THRESHOLD_MS = 30_000;
/** How often we probe `core.ping` after entering the still-working state. */
const ALIVE_PROBE_INTERVAL_MS = 5_000;
/**
* Per-probe network budget. `testCoreRpcConnection` uses raw `fetch()` with
* no built-in timeout, so we have to bound the probe ourselves — otherwise a
* TCP black-hole (firewall drop, suspended laptop wake) lets the first probe
* hang forever and the single-flight guard blocks every subsequent tick.
*/
const PROBE_TIMEOUT_MS = 3_000;
type AliveState = 'unknown' | 'probing' | 'alive' | 'unreachable';
interface ContextGatheringStepProps {
connectedSources: string[];
onNext: () => void | Promise<void>;
@@ -138,10 +159,20 @@ async function findLinkedInUrlViaComposio(): Promise<string | null> {
return null;
}
/**
* First-launch profile compression on slow hardware (#2156) can run past the
* global 30s RPC timeout while the core LLM compressor finishes. Use a
* longer-but-still-bounded budget so users with a slow-but-alive core are
* not parked on the post-login fallback when the call would otherwise
* succeed. Real failures still abort within `SAVE_PROFILE_TIMEOUT_MS`.
*/
const SAVE_PROFILE_TIMEOUT_MS = 90_000;
async function saveProfile(markdown: string): Promise<void> {
await callCoreRpc<unknown>({
method: 'openhuman.learning_save_profile',
params: { markdown, summarize: true },
timeoutMs: SAVE_PROFILE_TIMEOUT_MS,
});
}
@@ -158,6 +189,10 @@ const ContextGatheringStep = ({
);
const [finished, setFinished] = useState(false);
const [hasError, setHasError] = useState(false);
// Staged "still working" mode kicks in after STILL_WORKING_THRESHOLD_MS so
// a slow-but-alive first launch no longer looks like a stall (#2156).
const [stillWorking, setStillWorking] = useState(false);
const [aliveState, setAliveState] = useState<AliveState>('unknown');
const backgroundClickedRef = useRef(false);
const ranRef = useRef(false);
@@ -271,6 +306,66 @@ const ContextGatheringStep = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Staged "still working" trigger (#2156). After STILL_WORKING_THRESHOLD_MS
// of pipeline runtime, swap to the calmer copy and start probing
// `core.ping` so we can tell users whether the core is slow-but-alive vs
// truly unreachable. Cleared on finish/error so completed runs never flip
// to the slow-path UI retroactively.
useEffect(() => {
if (finished || !hasGmail || hasError) return;
const timer = window.setTimeout(() => {
setStillWorking(true);
}, STILL_WORKING_THRESHOLD_MS);
return () => window.clearTimeout(timer);
}, [finished, hasGmail, hasError]);
// Periodic alive probe while in still-working state. `core.ping` resolves
// quickly even when the busy snapshot RPC is holding the worker, so a
// green ping during a slow snapshot is exactly the alive-but-slow signal
// users need to see. On cold start the bearer token may not be resolved
// yet (IPC race) and the probe will come back 401 — that means auth is
// still warming up, not that the core is down, so we treat 401 as `alive`.
useEffect(() => {
if (!stillWorking || finished || hasError) return;
let cancelled = false;
// Single-flight guard so a slow probe never gets shadowed by the next
// 5s tick. Each probe also bounds itself with its own AbortController
// (PROBE_TIMEOUT_MS) so a TCP-black-hole core (firewall, suspended
// laptop) cannot leave us with a permanently in-flight fetch and a
// permanently blocked guard — that would re-create exactly the
// "Checking core connection…" forever failure mode this UI exists to
// fix.
let inFlight = false;
const probe = async () => {
if (cancelled || inFlight) return;
inFlight = true;
setAliveState(prev => (prev === 'unknown' ? 'probing' : prev));
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const url = await getCoreRpcUrl();
const response = await testCoreRpcConnection(url, undefined, { signal: controller.signal });
if (!cancelled) {
// 401 = auth not ready yet (cold-start IPC race), not a dead core.
setAliveState(response.ok || response.status === 401 ? 'alive' : 'unreachable');
}
} catch {
if (!cancelled) setAliveState('unreachable');
} finally {
window.clearTimeout(timer);
inFlight = false;
}
};
void probe();
const interval = window.setInterval(probe, ALIVE_PROBE_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [stillWorking, finished, hasError]);
// Auto-navigate on successful completion (skip if user already clicked background link)
useEffect(() => {
if (finished && !hasError && !backgroundClickedRef.current) {
@@ -305,6 +400,24 @@ const ContextGatheringStep = ({
);
}
// The slow-path UI must vanish as soon as the pipeline resolves, even
// during the 800ms auto-advance window — otherwise a slow-success user
// sees "still working…" copy after the work has actually finished.
const showStillWorking = stillWorking && !finished && !hasError;
const titleKey = showStillWorking
? 'onboarding.contextGathering.stillWorkingTitle'
: 'onboarding.contextGathering.buildingProfile';
const descKey = showStillWorking
? 'onboarding.contextGathering.stillWorkingDesc'
: 'onboarding.contextGathering.buildingDesc';
const aliveLabelKey: Record<AliveState, string> = {
unknown: 'onboarding.contextGathering.coreAliveProbing',
probing: 'onboarding.contextGathering.coreAliveProbing',
alive: 'onboarding.contextGathering.coreAlive',
unreachable: 'onboarding.contextGathering.coreUnreachable',
};
return (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 shadow-soft animate-fade-up">
<div className="flex flex-col items-center justify-center gap-6 py-8">
@@ -312,11 +425,15 @@ const ContextGatheringStep = ({
<div className="w-20 h-20 rounded-full bg-gradient-to-r from-stone-300 via-stone-100 to-stone-300 bg-[length:200%_100%] animate-shimmer" />
{/* Title */}
<h1 className="text-xl font-bold text-stone-900 dark:text-neutral-100 animate-pulse">
{t('onboarding.contextGathering.buildingProfile')}
<h1
className="text-xl font-bold text-stone-900 dark:text-neutral-100 animate-pulse"
data-testid="context-gathering-title">
{t(titleKey)}
</h1>
<p className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('onboarding.contextGathering.buildingDesc')}
<p
className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed text-center max-w-sm"
data-testid="context-gathering-desc">
{t(descKey)}
</p>
{/* Skeleton bars */}
@@ -326,6 +443,28 @@ const ContextGatheringStep = ({
<div className="h-3 w-1/2 rounded-full bg-gradient-to-r from-stone-300 via-stone-100 to-stone-300 bg-[length:200%_100%] animate-shimmer [animation-delay:300ms]" />
</div>
{/* Alive indicator — only while still-working state is active AND
the pipeline hasn't finished/errored. Avoids flashing the probe
during the 800ms auto-advance window after a slow success. */}
{showStillWorking && (
<div
className="flex items-center gap-2 text-xs text-stone-500 dark:text-neutral-400"
data-testid="core-alive-indicator"
data-alive-state={aliveState}>
<span
className={`inline-block h-2 w-2 rounded-full ${
aliveState === 'alive'
? 'bg-emerald-500'
: aliveState === 'unreachable'
? 'bg-red-500'
: 'bg-stone-300 dark:bg-neutral-600 animate-pulse'
}`}
aria-hidden="true"
/>
<span>{t(aliveLabelKey[aliveState])}</span>
</div>
)}
{hasGmail && !finished && (
<OnboardingNextButton
label={t('onboarding.contextGathering.continueToChat')}
@@ -5,11 +5,22 @@ import { renderWithProviders } from '../../../../test/test-utils';
import ContextGatheringStep from '../ContextGatheringStep';
const callCoreRpc = vi.hoisted(() => vi.fn());
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc }));
const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/rpc'));
const testCoreRpcConnection = vi.hoisted(() =>
vi.fn(async () => ({ ok: true, status: 200 }) as Response)
);
vi.mock('../../../../services/coreRpcClient', () => ({
callCoreRpc,
getCoreRpcUrl,
testCoreRpcConnection,
}));
describe('ContextGatheringStep', () => {
beforeEach(() => {
callCoreRpc.mockReset();
getCoreRpcUrl.mockClear();
testCoreRpcConnection.mockClear();
testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response);
});
it('no-Gmail branch: auto-navigates without any RPC', async () => {
@@ -277,4 +288,228 @@ describe('ContextGatheringStep', () => {
// fireEvent not needed — onNext is available via the button but user can also
// just verify the friendly message is shown
});
// --------------------------------------------------------------------------
// #2156 — slow-but-alive snapshot / save_profile path
// --------------------------------------------------------------------------
describe('staged still-working UI (#2156)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('passes a 90s timeoutMs override on learning_save_profile so slow first launches finish', async () => {
const onNext = vi.fn().mockResolvedValue(undefined);
callCoreRpc.mockImplementation(async (req: { method: string }) => {
if (req.method === 'openhuman.tools_composio_execute') {
return {
successful: true,
data: { messages: [{ messageText: 'https://www.linkedin.com/in/jane-doe' }] },
};
}
if (req.method === 'openhuman.learning_save_profile') {
return { path: '/tmp/PROFILE.md', bytes: 1 };
}
throw new Error(`unexpected RPC ${req.method}`);
});
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={onNext} />
);
await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 });
const saveCall = callCoreRpc.mock.calls.find(
(c: Array<{ method: string; timeoutMs?: number }>) =>
c[0].method === 'openhuman.learning_save_profile'
);
expect(saveCall![0].timeoutMs).toBeGreaterThan(30_000);
expect(saveCall![0].timeoutMs).toBeLessThanOrEqual(10 * 60 * 1_000);
});
it('swaps to the still-working copy after 30s while the pipeline is still pending', async () => {
vi.useFakeTimers();
let resolveGmail!: (v: unknown) => void;
callCoreRpc.mockImplementation(
() =>
new Promise(res => {
resolveGmail = res;
})
);
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
// Initial copy — fast happy path.
expect(screen.getByTestId('context-gathering-title').textContent).toMatch(
/building your profile/i
);
expect(screen.queryByTestId('core-alive-indicator')).not.toBeInTheDocument();
// Cross the 30s threshold.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_500);
});
expect(screen.getByTestId('context-gathering-title').textContent).toMatch(/still working/i);
// Indicator appears so users see whether core is alive or unreachable.
expect(screen.getByTestId('core-alive-indicator')).toBeInTheDocument();
await act(async () => {
resolveGmail({ successful: true, data: { messages: [] } });
});
});
it('reports the core as alive when core.ping returns ok', async () => {
// Fake timers must be active before render so the 30s still-working
// setTimeout registers against the fake clock.
vi.useFakeTimers();
let resolveGmail!: (v: unknown) => void;
callCoreRpc.mockImplementation(
() =>
new Promise(res => {
resolveGmail = res;
})
);
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
// Drive the still-working transition under fake timers, then flip
// back to real timers so waitFor can poll the React commit for the
// resolved indicator state (waitFor doesn't observe fake-time
// microtasks reliably).
await act(async () => {
await vi.advanceTimersByTimeAsync(30_500);
});
vi.useRealTimers();
const indicator = await screen.findByTestId('core-alive-indicator');
await waitFor(() => {
expect(indicator.getAttribute('data-alive-state')).toBe('alive');
});
expect(testCoreRpcConnection).toHaveBeenCalled();
expect(indicator.textContent ?? '').toMatch(/core is reachable/i);
await act(async () => {
resolveGmail({ successful: true, data: { messages: [] } });
});
});
it('treats HTTP 401 as alive (auth not ready yet, core is up)', async () => {
vi.useFakeTimers();
testCoreRpcConnection.mockResolvedValue({ ok: false, status: 401 } as Response);
let resolveGmail!: (v: unknown) => void;
callCoreRpc.mockImplementation(
() =>
new Promise(res => {
resolveGmail = res;
})
);
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_500);
});
vi.useRealTimers();
const indicator = await screen.findByTestId('core-alive-indicator');
await waitFor(() => {
expect(indicator.getAttribute('data-alive-state')).toBe('alive');
});
await act(async () => {
resolveGmail({ successful: true, data: { messages: [] } });
});
});
it('passes an AbortSignal so a TCP black-hole probe cannot hang forever', async () => {
vi.useFakeTimers();
// Hang the probe indefinitely so the only way it can resolve is via
// the controller-driven abort path. Capture whether the signal was
// forwarded.
testCoreRpcConnection.mockImplementation(
((_url: string, _token?: string, init?: { signal?: AbortSignal }) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError'))
);
})) as never
);
let resolveGmail!: (v: unknown) => void;
callCoreRpc.mockImplementation(
() =>
new Promise(res => {
resolveGmail = res;
})
);
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
// Enter still-working state.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_500);
});
// Drive the per-probe 3s timeout to fire the abort.
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
});
vi.useRealTimers();
expect(testCoreRpcConnection).toHaveBeenCalled();
const lastCall = testCoreRpcConnection.mock.calls[
testCoreRpcConnection.mock.calls.length - 1
] as unknown as [string, string | undefined, { signal?: AbortSignal } | undefined];
expect(lastCall[2]?.signal).toBeInstanceOf(AbortSignal);
const indicator = await screen.findByTestId('core-alive-indicator');
await waitFor(() => {
expect(indicator.getAttribute('data-alive-state')).toBe('unreachable');
});
await act(async () => {
resolveGmail({ successful: true, data: { messages: [] } });
});
});
it('reports unreachable when core.ping rejects', async () => {
vi.useFakeTimers();
testCoreRpcConnection.mockRejectedValue(new Error('ECONNREFUSED'));
let resolveGmail!: (v: unknown) => void;
callCoreRpc.mockImplementation(
() =>
new Promise(res => {
resolveGmail = res;
})
);
renderWithProviders(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_500);
});
vi.useRealTimers();
const indicator = await screen.findByTestId('core-alive-indicator');
await waitFor(() => {
expect(indicator.getAttribute('data-alive-state')).toBe('unreachable');
});
expect(indicator.textContent ?? '').toMatch(/core is not responding/i);
await act(async () => {
resolveGmail({ successful: true, data: { messages: [] } });
});
});
});
});
@@ -342,6 +342,131 @@ describe('coreRpcClient', () => {
}
});
test('honors per-call timeoutMs override instead of the global default (#2156)', async () => {
vi.useFakeTimers();
try {
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementationOnce(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
const signal = (init as RequestInit).signal as AbortSignal | undefined;
if (!signal) return;
const onAbort = () => {
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
})
);
const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 });
let settled = false;
pending
.catch(() => {})
.finally(() => {
settled = true;
});
// 30s passes — global default would have aborted by now, but the
// per-call 60s override keeps the request alive. Assert the pending
// promise is still in flight so an early-abort regression on the
// override path cannot slip through (CodeRabbit #2179 review).
await vi.advanceTimersByTimeAsync(31_000);
expect(settled).toBe(false);
// Advance to the override boundary — now the abort fires.
await vi.advanceTimersByTimeAsync(30_000);
await expect(pending).rejects.toThrow(
'Core RPC openhuman.app_state_snapshot timed out after 60000ms'
);
} finally {
vi.useRealTimers();
}
});
test('clamps an oversize timeoutMs to the MAX bound (10 minutes)', async () => {
vi.useFakeTimers();
try {
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementationOnce(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
const signal = (init as RequestInit).signal as AbortSignal | undefined;
if (!signal) return;
const onAbort = () => {
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
})
);
const pending = callCoreRpc({
method: 'openhuman.app_state_snapshot',
// 2 hours — far beyond the 10 minute clamp; should be reduced.
timeoutMs: 2 * 60 * 60 * 1_000,
});
let settled = false;
pending
.catch(() => {})
.finally(() => {
settled = true;
});
const MAX_MS = 10 * 60 * 1_000;
// 1ms before the clamp boundary: still pending. Guards against an
// off-by-one where the clamp accidentally lowers the budget further
// (CodeRabbit #2179 review).
await vi.advanceTimersByTimeAsync(MAX_MS - 1);
expect(settled).toBe(false);
// Cross the clamp boundary — abort fires.
await vi.advanceTimersByTimeAsync(2);
await expect(pending).rejects.toThrow(
`Core RPC openhuman.app_state_snapshot timed out after ${MAX_MS}ms`
);
} finally {
vi.useRealTimers();
}
});
test('falls back to the global default when timeoutMs is undefined', async () => {
vi.useFakeTimers();
try {
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementationOnce(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
const signal = (init as RequestInit).signal as AbortSignal | undefined;
if (!signal) return;
const onAbort = () => {
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
})
);
const pending = callCoreRpc({ method: 'openhuman.threads_list' });
pending.catch(() => {});
await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1);
await expect(pending).rejects.toThrow(
`Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms`
);
} finally {
vi.useRealTimers();
}
});
test('does not trigger the timeout path when fetch resolves promptly', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
+37 -8
View File
@@ -12,6 +12,27 @@ interface CoreRpcRelayRequest {
method: string;
params?: unknown;
serviceManaged?: boolean;
/**
* Per-call timeout override in milliseconds. When omitted, defaults to the
* global `CORE_RPC_TIMEOUT_MS` (30s). Use for slow-but-alive RPCs such as
* first-launch `openhuman.app_state_snapshot` (#2156). Clamped to the same
* [MIN, MAX] window as the global default.
*/
timeoutMs?: number;
}
/** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */
const PER_CALL_TIMEOUT_MIN_MS = 1_000;
const PER_CALL_TIMEOUT_MAX_MS = 10 * 60 * 1_000;
function resolvePerCallTimeoutMs(override: number | undefined): number {
if (override === undefined) return CORE_RPC_TIMEOUT_MS;
if (!Number.isFinite(override)) return CORE_RPC_TIMEOUT_MS;
const clamped = Math.min(
Math.max(Math.round(override), PER_CALL_TIMEOUT_MIN_MS),
PER_CALL_TIMEOUT_MAX_MS
);
return clamped;
}
interface JsonRpcRequestBody {
@@ -344,7 +365,8 @@ export async function getCoreRpcToken(): Promise<string | null> {
*/
export async function testCoreRpcConnection(
url: string,
tokenOverride?: string
tokenOverride?: string,
init?: { signal?: AbortSignal }
): Promise<Response> {
const token = tokenOverride?.trim() || (await getCoreRpcToken());
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
@@ -355,6 +377,7 @@ export async function testCoreRpcConnection(
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }),
signal: init?.signal,
});
}
@@ -393,6 +416,7 @@ export async function callCoreRpc<T>({
method,
params,
serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing.
timeoutMs,
}: CoreRpcRelayRequest): Promise<T> {
void serviceManaged;
@@ -401,6 +425,7 @@ export async function callCoreRpc<T>({
}
const normalizedMethod = normalizeRpcMethod(method);
const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs);
const payload: JsonRpcRequestBody = {
jsonrpc: '2.0',
id: nextJsonRpcId++,
@@ -419,12 +444,14 @@ export async function callCoreRpc<T>({
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Bound the fetch to CORE_RPC_TIMEOUT_MS. Without this a hung core
// sidecar will block every caller (and the UI) forever. We use a
// manual AbortController + setTimeout rather than AbortSignal.timeout()
// so test fake timers can drive the abort deterministically.
// Bound the fetch. Without this a hung core sidecar would block every
// caller (and the UI) forever. We use a manual AbortController +
// setTimeout rather than AbortSignal.timeout() so test fake timers can
// drive the abort deterministically. Per-call `timeoutMs` (clamped) lets
// legitimately-slow RPCs such as first-launch `app_state_snapshot`
// (#2156) opt into a longer-but-still-bounded budget.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CORE_RPC_TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs);
let response: Response;
try {
response = await fetch(rpcUrl, {
@@ -437,9 +464,11 @@ export async function callCoreRpc<T>({
if (controller.signal.aborted) {
// Throw a fully-classified `CoreRpcError` here so the outer catch
// doesn't re-wrap a bare `Error` and so callers can branch on
// `err.kind === 'timeout'` (Sentry filter, soft toast skip).
// `err.kind === 'timeout'` (Sentry filter, soft toast skip). Use
// the per-call `effectiveTimeoutMs` so the message reflects the
// actual budget (#2156 raised the snapshot path to 90s).
throw new CoreRpcError(
`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`,
`Core RPC ${payload.method} timed out after ${effectiveTimeoutMs}ms`,
'timeout'
);
}
+11 -3
View File
@@ -67,13 +67,21 @@ describe('coreStateApi.fetchCoreAppSnapshot', () => {
mockCallCoreRpc.mockReset();
});
it('calls the correct RPC method', async () => {
it('calls the correct RPC method with the slow-snapshot timeout override (#2156)', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() });
const { fetchCoreAppSnapshot } = await import('./coreStateApi');
const { fetchCoreAppSnapshot, SNAPSHOT_TIMEOUT_MS } = await import('./coreStateApi');
await fetchCoreAppSnapshot();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' });
// The first-launch snapshot legitimately runs past the global 30s default
// on slow M-series machines; verify the longer-but-still-bounded budget
// is threaded through to callCoreRpc instead of relying on the default.
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.app_state_snapshot',
timeoutMs: SNAPSHOT_TIMEOUT_MS,
});
expect(SNAPSHOT_TIMEOUT_MS).toBeGreaterThan(30_000);
expect(SNAPSHOT_TIMEOUT_MS).toBeLessThanOrEqual(10 * 60 * 1_000);
});
it('returns the inner result from the RPC envelope', async () => {
+12
View File
@@ -48,9 +48,21 @@ interface AppStateSnapshotResult {
};
}
/**
* First-launch `app_state_snapshot` can take 3040s on M-series Macs while
* memory tree init, Composio registry warmup, and other boot work compete
* for the snapshot critical path (#2156). The global `CORE_RPC_TIMEOUT_MS`
* default of 30s caused users with merely slow-but-alive cores to be parked
* on the post-login fallback. Use a longer-but-still-bounded budget here so
* legitimate slow-success completes inline, while real failures still abort
* within `SNAPSHOT_TIMEOUT_MS` rather than hanging forever.
*/
export const SNAPSHOT_TIMEOUT_MS = 90_000;
export const fetchCoreAppSnapshot = async (): Promise<AppStateSnapshotResult> => {
const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({
method: 'openhuman.app_state_snapshot',
timeoutMs: SNAPSHOT_TIMEOUT_MS,
});
// Normalise the optional #1299 field at the API boundary so older core
// builds without `meetAutoOrchestratorHandoff` still surface the