diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9606942f..80aec6651 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -410,6 +410,10 @@ jobs: # come from the static `app/src-tauri/tauri.conf.json`. uses: actions/github-script@v7 env: + BASE_URL: ${{ needs.prepare-build.outputs.base_url }} + UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} + UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }} + UPDATER_REPO: tinyhumansai/openhuman WITH_UPDATER: "true" with: script: | @@ -522,6 +526,7 @@ jobs: MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} + WITH_UPDATER: "true" VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} TAURI_CONFIG_OVERRIDE: ${{ steps.config-overrides.outputs.json }} diff --git a/Cargo.lock b/Cargo.lock index 522e54e17..d400aa2d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.0" +version = "0.53.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f7acca320..15f2f9b20 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.53.0" +version = "0.53.1" dependencies = [ "anyhow", "async-trait", diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx index 4a2edd2cd..bcf6a6926 100644 --- a/app/src/components/composio/ComposioConnectModal.tsx +++ b/app/src/components/composio/ComposioConnectModal.tsx @@ -384,25 +384,35 @@ export default function ComposioConnectModal({ <>
- {toolkit.name} is connected. +
+ {toolkit.name} is connected.   + {activeConnection && ( + + (id: {activeConnection.id}) + + )} +
- {activeConnection && ( -

- id: {activeConnection.id} -

- )} - +
+ + +
)} diff --git a/app/src/components/home/HomeBanners.tsx b/app/src/components/home/HomeBanners.tsx new file mode 100644 index 000000000..a63719ad3 --- /dev/null +++ b/app/src/components/home/HomeBanners.tsx @@ -0,0 +1,115 @@ +import { DISCORD_INVITE_URL } from '../../utils/links'; +import { openUrl } from '../../utils/openUrl'; + +const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard'; + +function formatUsd(amount: number): string { + return `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; +} + +export function UsageLimitBanner({ + tone, + icon, + title, + message, + ctaLabel, +}: { + tone: 'warning' | 'danger'; + icon: string; + title: string; + message: string; + ctaLabel: string; +}) { + const styles = + tone === 'danger' + ? { + card: 'border-coral-200 bg-gradient-to-r from-coral-50 via-rose-50 to-orange-50', + title: 'text-coral-700', + body: 'text-coral-500', + button: 'border-coral-700 text-coral-700 hover:text-coral-800', + } + : { + card: 'border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50', + title: 'text-amber-700', + body: 'text-amber-600', + button: 'border-amber-700 text-amber-700 hover:text-amber-800', + }; + + return ( +
+
+
+ {icon} +
+
+

{title}

+

+ {message}  + +

+
+
+
+ ); +} + +export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: number }) { + return ( +
+
+
+ πŸŽ‰ +
+
+

+ You have {formatUsd(promoCredits)} of promotional credits. +

+

+ Give OpenHuman a spin, and when you're ready for more,{' '} + {' '} + and get 10x more usage. +

+
+
+
+ ); +} + +export function DiscordBanner() { + return ( + + ); +} diff --git a/app/src/components/home/__tests__/HomeBanners.test.tsx b/app/src/components/home/__tests__/HomeBanners.test.tsx new file mode 100644 index 000000000..a61591571 --- /dev/null +++ b/app/src/components/home/__tests__/HomeBanners.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DISCORD_INVITE_URL } from '../../../utils/links'; +import { openUrl } from '../../../utils/openUrl'; +import { DiscordBanner, PromotionalCreditsBanner, UsageLimitBanner } from '../HomeBanners'; + +vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); + +describe('HomeBanners', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens the billing dashboard through openUrl from the usage limit banner', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Buy top-up credits' })); + + expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard'); + }); + + it('opens the billing dashboard through openUrl from the promotional credits banner', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'get a subscription' })); + + expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard'); + }); + + it('opens the Discord invite through openUrl from the Discord banner', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /join our discord/i })); + + expect(openUrl).toHaveBeenCalledWith(DISCORD_INVITE_URL); + }); +}); diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 1df74bda1..81a297b52 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -78,6 +78,39 @@ interface ConversationsProps { variant?: 'page' | 'sidebar'; } +export function isComposerInteractionBlocked(args: { + activeThreadId: string | null; + welcomePending: boolean; + rustChat: boolean; +}): boolean { + return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending; +} + +function WelcomeThinkingTypewriter() { + const text = 'Your agent is thinking...'; + const [visibleChars, setVisibleChars] = useState(0); + + useEffect(() => { + const isComplete = visibleChars >= text.length; + const delayMs = isComplete ? 950 : 42; + const timeoutId = window.setTimeout(() => { + setVisibleChars(current => (current >= text.length ? 0 : current + 1)); + }, delayMs); + + return () => window.clearTimeout(timeoutId); + }, [text.length, visibleChars]); + + return ( +

+ {text.slice(0, visibleChars)} +

+ ); +} + const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -422,7 +455,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { const normalized = text ?? inputValue; const trimmed = normalized.trim(); - if (!trimmed || !selectedThreadId || composerBlocked) return; + if (!trimmed || !selectedThreadId || composerInteractionBlocked) return; if (handleSlashCommand(trimmed)) return; @@ -443,8 +476,6 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { return; } - if (composerBlocked) return; - const sendingThreadId = selectedThreadId; const userMessage: ThreadMessage = { id: `msg_${globalThis.crypto.randomUUID()}`, @@ -743,9 +774,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { ? (streamingAssistantByThread[selectedThreadId] ?? null) : null; const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); - // composerBlocked: any thread is in-flight (blocks ALL sends/voice actions). + // Blocks all composer interaction while a turn is in-flight, the + // proactive welcome opener is pending, or Rust chat is unavailable. // isSending: the *selected* thread is in-flight (drives selected-thread UI only). - const composerBlocked = Boolean(activeThreadId); + const composerInteractionBlocked = isComposerInteractionBlocked({ + activeThreadId, + welcomePending, + rustChat, + }); const isSending = Boolean( selectedThreadId && (inferenceTurnLifecycleByThread[selectedThreadId] === 'started' || @@ -1232,7 +1268,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
-

Your agent is thinking...

+ ) : (
@@ -1406,7 +1442,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { onKeyDown={handleInputKeyDown} placeholder="Type a message..." rows={1} - disabled={isSending || !rustChat} + disabled={composerInteractionBlocked} className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 placeholder:text-stone-400 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed" /> {/* Voice input mic hidden per #717 (inputMode='voice' path retained). */} @@ -1415,7 +1451,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { onClick={() => { void handleSendMessage(); }} - disabled={!inputValue.trim() || isSending || !rustChat} + disabled={!inputValue.trim() || composerInteractionBlocked} className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"> {isSending ? ( diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 46cb52bd9..64061f392 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -1,28 +1,59 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; +import { + DiscordBanner, + PromotionalCreditsBanner, + UsageLimitBanner, +} from '../components/home/HomeBanners'; +import { useUsageState } from '../hooks/useUsageState'; import { useUser } from '../hooks/useUser'; import { useAppSelector } from '../store/hooks'; import { selectSocketStatus } from '../store/socketSelectors'; -import { - bootstrapLocalAiWithRecommendedPreset, - ensureRecommendedLocalAiPresetIfNeeded, - triggerLocalAiAssetBootstrap, -} from '../utils/localAiBootstrap'; -import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers'; -import { - isTauri, - type LocalAiAssetsStatus, - type LocalAiStatus, - openhumanLocalAiAssetsStatus, - openhumanLocalAiStatus, -} from '../utils/tauriCommands'; +import { APP_VERSION } from '../utils/config'; + +export function resolveHomeUserName(user: unknown): string { + if (!user || typeof user !== 'object') return 'User'; + + const record = user as Record; + const firstName = + (typeof record.firstName === 'string' && record.firstName.trim()) || + (typeof record.first_name === 'string' && record.first_name.trim()) || + ''; + const lastName = + (typeof record.lastName === 'string' && record.lastName.trim()) || + (typeof record.last_name === 'string' && record.last_name.trim()) || + ''; + const username = typeof record.username === 'string' ? record.username.trim() : ''; + const email = typeof record.email === 'string' ? record.email.trim() : ''; + + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim(); + if (fullName) return fullName; + if (firstName) return firstName; + if (username) return username.startsWith('@') ? username : `@${username}`; + if (email) return email.split('@')[0] || 'User'; + return 'User'; +} const Home = () => { const { user } = useUser(); const navigate = useNavigate(); - const userName = user?.firstName || 'User'; + const { isRateLimited, shouldShowBudgetCompletedMessage } = useUsageState(); + const _userName = resolveHomeUserName(user); + const userName = _userName.split(' ')[0]; // Get first name only + const promoCredits = user?.usage?.promotionBalanceUsd ?? 0; + const isFreeTier = + user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription; + const showPromoBanner = isFreeTier && promoCredits > 0; + + const welcomeVariants = useMemo( + () => [`Welcome, ${userName} πŸ‘‹`, `Let's cook, ${userName} πŸ§‘β€πŸ³.`, `Time to Zone In 🧘🏻`], + [userName] + ); + const [welcomeVariantIndex, setWelcomeVariantIndex] = useState(0); + const [typedWelcome, setTypedWelcome] = useState(''); + const [isDeletingWelcome, setIsDeletingWelcome] = useState(false); // Mirror the same socket status the `ConnectionIndicator` pill consumes // so the description copy below the pill never contradicts it (the old // hard-coded "connected" message lied while the pill said "Connecting" @@ -30,242 +61,92 @@ const Home = () => { const socketStatus = useAppSelector(selectSocketStatus); const statusCopy = { connected: - 'Your device is connected to OpenHuman AI. Keep the app running to keep the connection alive β€” message your assistant with the button below.', - connecting: 'Connecting to OpenHuman AI. Hang tight, this usually takes a second.', + 'Your device is connected. Keep the app running to keep the connection alive. Message your assistant with the button below.', + connecting: 'Connecting. Hang tight, this usually takes a second.', disconnected: 'Your device is offline right now. Check your network or restart the app to reconnect.', }[socketStatus]; - const [localAiStatus, setLocalAiStatus] = useState(null); - const [localAiAssets, setLocalAiAssets] = useState(null); - const [downloadBusy, setDownloadBusy] = useState(false); - const [bootstrapMessage, setBootstrapMessage] = useState(''); - const autoRetryDoneRef = useRef(false); - const initialBootstrapHandledRef = useRef(false); - const initialBootstrapInFlightRef = useRef(false); - const initialBootstrapPendingDownloadRef = useRef(false); - const initialBootstrapAttemptsRef = useRef(0); - - // Get greeting based on time - const getGreeting = () => { - const hour = new Date().getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 18) return 'Good afternoon'; - return 'Good evening'; - }; // Open in-app chat. const handleStartCooking = async () => { navigate('/chat'); }; - const refreshLocalAiStatus = async () => { - const status = await openhumanLocalAiStatus(); - setLocalAiStatus(status.result); - return status.result; - }; - - const runManualBootstrap = async (force: boolean) => { - setDownloadBusy(true); - setBootstrapMessage(''); - try { - await bootstrapLocalAiWithRecommendedPreset( - force, - force ? '[Home re-bootstrap]' : '[Home manual bootstrap]' - ); - const freshStatus = await refreshLocalAiStatus(); - if (freshStatus?.state === 'ready') { - setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Local AI is ready'); - } else if (freshStatus?.state === 'degraded') { - setBootstrapMessage('Bootstrap failed β€” check warning below'); - } - setTimeout(() => setBootstrapMessage(''), 3000); - } catch (error) { - console.warn('[Home] manual Local AI bootstrap failed:', error); - setBootstrapMessage('Bootstrap failed'); - setTimeout(() => setBootstrapMessage(''), 3000); - } finally { - setDownloadBusy(false); - } - }; - useEffect(() => { - if (!isTauri()) return; - const MAX_INITIAL_BOOTSTRAP_ATTEMPTS = 3; - let mounted = true; - const load = async () => { - try { - const [status, assets] = await Promise.all([ - openhumanLocalAiStatus(), - openhumanLocalAiAssetsStatus().catch(err => { - console.warn('[Home] failed to load local AI assets status:', err); - return null; - }), - ]); - if (mounted) { - setLocalAiStatus(status.result); - setLocalAiAssets(assets?.result ?? null); + const activeVariant = welcomeVariants[welcomeVariantIndex] ?? ''; + const isFullyTyped = typedWelcome === activeVariant; + const isFullyDeleted = typedWelcome.length === 0; - // Auto-retry bootstrap once if Ollama is degraded (install/server issue). - if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) { - autoRetryDoneRef.current = true; - console.debug('[Home] local AI is degraded; scheduling a one-time re-bootstrap'); - void bootstrapLocalAiWithRecommendedPreset(true, '[Home degraded auto-retry]').catch( - error => { - autoRetryDoneRef.current = false; - console.warn('[Home] degraded local AI re-bootstrap failed:', error); - } - ); - } + const delay = isDeletingWelcome + ? 36 + : isFullyTyped + ? 1400 + : typedWelcome.length === 0 + ? 250 + : 55; - if ( - status.result?.state === 'idle' && - !initialBootstrapHandledRef.current && - !initialBootstrapInFlightRef.current - ) { - initialBootstrapInFlightRef.current = true; - console.debug('[Home] local AI is idle; checking first-run preset selection'); - void ensureRecommendedLocalAiPresetIfNeeded('[Home first-run]') - .then(async preset => { - const shouldTriggerBootstrap = - !preset.hadSelectedTier || initialBootstrapPendingDownloadRef.current; - - if (!shouldTriggerBootstrap) { - console.debug( - '[Home] skipping automatic first-run bootstrap because a tier is already selected' - ); - initialBootstrapHandledRef.current = true; - return; - } - - initialBootstrapPendingDownloadRef.current = true; - console.debug( - '[Home] selected recommended preset for first-run bootstrap', - JSON.stringify({ - recommendedTier: preset.recommendedTier, - hadSelectedTier: preset.hadSelectedTier, - }) - ); - await triggerLocalAiAssetBootstrap(false, '[Home first-run]'); - initialBootstrapPendingDownloadRef.current = false; - initialBootstrapHandledRef.current = true; - initialBootstrapAttemptsRef.current = 0; - }) - .catch(error => { - initialBootstrapAttemptsRef.current += 1; - const attempts = initialBootstrapAttemptsRef.current; - if (attempts >= MAX_INITIAL_BOOTSTRAP_ATTEMPTS) { - initialBootstrapPendingDownloadRef.current = false; - initialBootstrapHandledRef.current = true; - console.warn( - '[Home] first-run local AI bootstrap failed permanently; stopping retries', - { attempts, error } - ); - return; - } - console.warn('[Home] first-run local AI bootstrap failed:', error); - }) - .finally(() => { - initialBootstrapInFlightRef.current = false; - }); - } + const timeoutId = window.setTimeout(() => { + if (!isDeletingWelcome) { + if (isFullyTyped) { + setIsDeletingWelcome(true); + return; } - } catch (error) { - console.warn('[Home] failed to load local AI status:', error); - if (mounted) setLocalAiStatus(null); + + setTypedWelcome(activeVariant.slice(0, typedWelcome.length + 1)); + return; } - }; - void load(); - const timer = setInterval(() => void load(), 2000); - return () => { - mounted = false; - clearInterval(timer); - }; - }, []); - const modelProgress = useMemo(() => progressFromStatus(localAiStatus), [localAiStatus]); - // Hide the Local Model Runtime card once every capability's model file is - // present on disk. We use `assets_status` (which inspects the filesystem) - // instead of the in-memory `LocalAiStatus` sub-states, because the latter - // stay at `idle` until a capability is first exercised β€” even when the - // underlying model has already been downloaded. - // - // A capability is considered "done" when its asset state is: - // - `ready` β†’ model file exists on disk - // - `disabled` β†’ not applicable for the selected preset - // - `ondemand` β†’ vision preset intentionally defers download until first use - const allModelsDownloaded = useMemo(() => { - if (!localAiStatus || !localAiAssets) return false; - if (localAiStatus.state !== 'ready') return false; - const isDone = (state: string | undefined | null): boolean => - state === 'ready' || state === 'disabled' || state === 'ondemand'; + if (!isFullyDeleted) { + setTypedWelcome(activeVariant.slice(0, typedWelcome.length - 1)); + return; + } - return ( - isDone(localAiAssets.chat?.state) && - isDone(localAiAssets.vision?.state) && - isDone(localAiAssets.embedding?.state) && - isDone(localAiAssets.stt?.state) && - isDone(localAiAssets.tts?.state) - ); - }, [localAiStatus, localAiAssets]); + setIsDeletingWelcome(false); + setWelcomeVariantIndex(current => (current + 1) % welcomeVariants.length); + }, delay); - const isInstalling = localAiStatus?.state === 'installing'; - const indeterminateDownload = - isInstalling || - (localAiStatus?.state === 'downloading' && typeof localAiStatus.download_progress !== 'number'); - const isInstallError = - localAiStatus?.state === 'degraded' && localAiStatus?.error_category === 'install'; - const [showErrorDetail, setShowErrorDetail] = useState(false); - const downloadedText = - typeof localAiStatus?.downloaded_bytes === 'number' - ? `${formatBytes(localAiStatus.downloaded_bytes)}${typeof localAiStatus?.total_bytes === 'number' ? ` / ${formatBytes(localAiStatus.total_bytes)}` : ''}` - : ''; - const speedText = - typeof localAiStatus?.download_speed_bps === 'number' && localAiStatus.download_speed_bps > 0 - ? `${formatBytes(localAiStatus.download_speed_bps)}/s` - : ''; - const etaText = formatEta(localAiStatus?.eta_seconds); + return () => window.clearTimeout(timeoutId); + }, [isDeletingWelcome, typedWelcome, welcomeVariantIndex, welcomeVariants]); return (
+ {isRateLimited && ( + + )} + + {!isRateLimited && shouldShowBudgetCompletedMessage && ( + + )} + + {showPromoBanner && } + {/* Main card */}
{/* Header row: logo + version + settings */} -
-
- - - -
- web, 01 - +
+ v{APP_VERSION}
{/* Welcome title */} -

Welcome Onboard

- - {/* Greeting */} -
-

- {getGreeting()}, {userName} -

-
+

+ {typedWelcome} + +

{/* Connection status */}
@@ -285,8 +166,10 @@ const Home = () => {
+ + {/* Next steps β€” compact directory of where to go next */} -
+ {/*
Next steps
-
- - {/* Local AI card (desktop only) β€” hidden once all models are fully downloaded */} - {isTauri() && !allModelsDownloaded && ( -
-
-
- Local model runtime -
- -
- -
- - {localAiStatus?.model_id ?? 'gemma3:4b-it-qat'} - - - {localAiStatus?.state ?? 'starting'} - -
- -
-
-
- -
- - {isInstalling - ? 'Installing Ollama runtime...' - : indeterminateDownload - ? 'Downloading...' - : `${Math.round(modelProgress * 100)}%`} - - {downloadedText && ( - - {downloadedText} - - )} - {speedText && {speedText}} - {etaText && ETA {etaText}} -
- {localAiStatus?.warning && ( -
- {localAiStatus.warning} -
- )} - - {isInstallError && localAiStatus?.error_detail && ( -
- - {showErrorDetail && ( -
-                    {localAiStatus.error_detail}
-                  
- )} -

- Install Ollama manually from{' '} - - ollama.com - {' '} - then set its path in{' '} - - . -

-
- )} - -
- {localAiStatus?.state === 'ready' ? ( - - - - - Running - - ) : ( - - )} - - {bootstrapMessage && ( - - {bootstrapMessage} - - )} -
-
- )} +
*/}
); diff --git a/app/src/pages/__tests__/Conversations.test.tsx b/app/src/pages/__tests__/Conversations.test.tsx new file mode 100644 index 000000000..3cc655d43 --- /dev/null +++ b/app/src/pages/__tests__/Conversations.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { isComposerInteractionBlocked } from '../Conversations'; + +describe('isComposerInteractionBlocked', () => { + it('blocks composer interaction while the welcome agent loader is visible', () => { + expect( + isComposerInteractionBlocked({ activeThreadId: null, welcomePending: true, rustChat: true }) + ).toBe(true); + }); + + it('blocks composer interaction while a thread is actively running', () => { + expect( + isComposerInteractionBlocked({ + activeThreadId: 'thread-1', + welcomePending: false, + rustChat: true, + }) + ).toBe(true); + }); + + it('allows composer interaction when chat is idle and ready', () => { + expect( + isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: true }) + ).toBe(false); + }); + + it('blocks composer interaction when rust chat is unavailable', () => { + expect( + isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: false }) + ).toBe(true); + }); +}); diff --git a/app/src/pages/__tests__/Home.test.tsx b/app/src/pages/__tests__/Home.test.tsx index c1a3ab396..c3e739233 100644 --- a/app/src/pages/__tests__/Home.test.tsx +++ b/app/src/pages/__tests__/Home.test.tsx @@ -1,135 +1,34 @@ -import { waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../../test/test-utils'; -import Home from '../Home'; +import { resolveHomeUserName } from '../Home'; + +const mockUseUser = vi.fn(() => ({ user: { firstName: 'Shrey' } })); vi.mock('../../components/ConnectionIndicator', () => ({ default: () =>
Connection Indicator
, })); -vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Shrey' } }) })); +vi.mock('../../hooks/useUser', () => ({ useUser: () => mockUseUser() })); -vi.mock('../../utils/localAiBootstrap', () => ({ - bootstrapLocalAiWithRecommendedPreset: vi.fn(), - ensureRecommendedLocalAiPresetIfNeeded: vi.fn(), - triggerLocalAiAssetBootstrap: vi.fn(), -})); +vi.mock('../../utils/config', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, APP_VERSION: '0.0.0-test' }; +}); -vi.mock('../../utils/tauriCommands', () => ({ - isTauri: vi.fn(() => true), - openhumanLocalAiStatus: vi.fn(), - openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }), -})); - -describe('Home local AI bootstrap', () => { - beforeEach(() => { - vi.clearAllMocks(); +describe('resolveHomeUserName', () => { + it('uses camelCase name fields when present', () => { + expect(resolveHomeUserName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe('Ada Lovelace'); }); - it('auto-applies the recommended preset and starts bootstrap on first-run idle state', async () => { - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - const tauriCommands = await import('../../utils/tauriCommands'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: null, - hadSelectedTier: false, - appliedTier: 'high', - }); - vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap).mockResolvedValue({ - result: { state: 'downloading', progress: 0 } as never, - logs: [], - }); - - renderWithProviders(); - - await waitFor(() => { - expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith( - '[Home first-run]' - ); - expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledWith( - false, - '[Home first-run]' - ); - }); + it('falls back to snake_case name fields from core snapshot payloads', () => { + expect(resolveHomeUserName({ first_name: 'Ada', last_name: 'Lovelace' })).toBe('Ada Lovelace'); }); - it('does not auto-bootstrap when a tier is already selected', async () => { - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - const tauriCommands = await import('../../utils/tauriCommands'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'idle', model_id: 'gemma3:12b-it-q4_K_M' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - - renderWithProviders(); - - await waitFor(() => { - expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith( - '[Home first-run]' - ); - }); - expect(bootstrapUtils.triggerLocalAiAssetBootstrap).not.toHaveBeenCalled(); + it('falls back to username when no name fields are present', () => { + expect(resolveHomeUserName({ username: 'openhuman' })).toBe('@openhuman'); }); - it('retries the first-run bootstrap trigger after preset application if the first trigger attempt fails', async () => { - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - const tauriCommands = await import('../../utils/tauriCommands'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded) - .mockResolvedValueOnce({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: null, - hadSelectedTier: false, - appliedTier: 'high', - }) - .mockResolvedValueOnce({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap) - .mockRejectedValueOnce(new Error('transient failure')) - .mockResolvedValueOnce({ result: { state: 'downloading', progress: 0 } as never, logs: [] }); - - renderWithProviders(); - - await waitFor( - () => { - expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledTimes(2); - }, - { timeout: 3000 } - ); - expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith( - 1, - false, - '[Home first-run]' - ); - expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith( - 2, - false, - '[Home first-run]' - ); + it('falls back to the email local-part when no explicit name exists', () => { + expect(resolveHomeUserName({ email: 'ada@example.com' })).toBe('ada'); }); }); diff --git a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx deleted file mode 100644 index 5e24ba040..000000000 --- a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../test/test-utils'; -import Home from '../Home'; - -vi.mock('../../components/ConnectionIndicator', () => ({ - default: () =>
Connection Indicator
, -})); - -vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Tester' } }) })); - -vi.mock('../../utils/localAiBootstrap', () => ({ - bootstrapLocalAiWithRecommendedPreset: vi.fn(), - ensureRecommendedLocalAiPresetIfNeeded: vi.fn(), - triggerLocalAiAssetBootstrap: vi.fn(), -})); - -vi.mock('../../utils/tauriCommands', () => ({ - isTauri: vi.fn(() => true), - openhumanLocalAiStatus: vi.fn(), - openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }), -})); - -describe('Home bootstrap button states', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('shows "Running" badge instead of Bootstrap button when state is ready', async () => { - const tauriCommands = await import('../../utils/tauriCommands'); - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText('Running')).toBeInTheDocument(); - }); - - expect(screen.queryByRole('button', { name: 'Bootstrap' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Re-bootstrap' })).toBeInTheDocument(); - }); - - it('shows "Retry" button when state is degraded', async () => { - const tauriCommands = await import('../../utils/tauriCommands'); - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - - // Keep returning degraded so the auto-retry doesn't change the visible state - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { - state: 'degraded', - model_id: 'gemma3:4b-it-qat', - warning: 'Ollama not found', - } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - // The Home component auto-retries on degraded β€” let it resolve without changing state - vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({ - preset: {} as never, - download: {} as never, - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); - }); - - expect(screen.queryByText('Running')).not.toBeInTheDocument(); - }); - - it('shows "Bootstrap" button when state is idle', async () => { - const tauriCommands = await import('../../utils/tauriCommands'); - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Bootstrap' })).toBeInTheDocument(); - }); - - expect(screen.queryByText('Running')).not.toBeInTheDocument(); - }); - - it('shows success message after re-bootstrap completes', async () => { - const tauriCommands = await import('../../utils/tauriCommands'); - const bootstrapUtils = await import('../../utils/localAiBootstrap'); - - vi.mocked(tauriCommands.openhumanLocalAiStatus) - .mockResolvedValueOnce({ - result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }) - .mockResolvedValue({ - result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never, - logs: [], - }); - vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({ - presets: {} as never, - recommendedTier: 'high', - selectedTier: 'high', - hadSelectedTier: true, - appliedTier: null, - }); - vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({ - preset: {} as never, - download: {} as never, - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText('Running')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Re-bootstrap' })); - - await waitFor(() => { - expect(screen.getByText('Re-bootstrap complete')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 1d5ce8ad5..7055f0c6e 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -78,10 +78,14 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null { function normalizeSnapshot( result: Awaited> ): CoreAppSnapshot { + const currentUser = (result.currentUser ?? + result.auth.user ?? + null) as CoreAppSnapshot['currentUser']; + return { auth: result.auth, sessionToken: result.sessionToken, - currentUser: result.currentUser, + currentUser, onboardingCompleted: result.onboardingCompleted, chatOnboardingCompleted: result.chatOnboardingCompleted, analyticsEnabled: result.analyticsEnabled, diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index ac473730d..f8a0d0847 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -15,16 +15,18 @@ function makeSnapshot(overrides: { userId?: string | null; sessionToken?: string | null; isAuthenticated?: boolean; + authUser?: unknown | null; + currentUser?: unknown | null; }): Snapshot { return { auth: { isAuthenticated: overrides.isAuthenticated ?? Boolean(overrides.userId), userId: overrides.userId ?? null, - user: null, + user: (overrides.authUser ?? null) as never, profileId: null, }, sessionToken: overrides.sessionToken ?? null, - currentUser: null, + currentUser: (overrides.currentUser ?? null) as never, onboardingCompleted: false, chatOnboardingCompleted: false, analyticsEnabled: false, @@ -211,4 +213,30 @@ describe('CoreStateProvider β€” identity-change cache clearing', () => { expect(screen.getByTestId('ready').textContent).toBe('boot'); await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); }); + + it('backfills snapshot.currentUser from auth.user when currentUser is missing', async () => { + fetchSnapshot.mockResolvedValue( + makeSnapshot({ + userId: 'u1', + sessionToken: 'tok1', + authUser: { first_name: 'Ada', username: 'ada' }, + currentUser: null, + }) + ); + listTeams.mockResolvedValue([]); + + let ctx: CoreStateContextValue | undefined; + render( + + { + ctx = next; + }} + /> + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' }); + }); }); diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 1d7e467e4..f7bcd6737 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -80,6 +80,7 @@ vi.mock('../utils/config', () => ({ BACKEND_URL: 'http://localhost:5005', TELEGRAM_BOT_USERNAME: 'openhuman_bot', LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest', + APP_VERSION: '0.0.0-test', DEV_JWT_TOKEN: undefined, })); diff --git a/app/src/types/api.ts b/app/src/types/api.ts index d3a0b8f53..26134ed66 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -22,6 +22,7 @@ export interface UserSubscription { } export interface IUserUsage { + promotionBalanceUsd?: number; cycleBudgetUsd: number; spentThisCycleUsd: number; spentTodayUsd: number; diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index 29a63544c..4ac2b04b1 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -3,7 +3,7 @@ use std::fs; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use log::{debug, warn}; use once_cell::sync::Lazy; @@ -26,7 +26,17 @@ use crate::rpc::RpcOutcome; const LOG_PREFIX: &str = "[app_state]"; const APP_STATE_FILENAME: &str = "app-state.json"; +const CURRENT_USER_REFRESH_TTL: Duration = Duration::from_secs(5); static APP_STATE_FILE_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); +static CURRENT_USER_CACHE: Lazy>> = Lazy::new(|| Mutex::new(None)); + +#[derive(Debug, Clone)] +struct CachedCurrentUser { + api_base: String, + token: String, + fetched_at: Instant, + user: Value, +} #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -295,6 +305,58 @@ async fn fetch_current_user(config: &Config, token: &str) -> Result) -> Option { + match user { + Some(Value::Object(map)) if map.is_empty() => None, + Some(Value::Null) => None, + other => other, + } +} + +async fn fetch_current_user_cached(config: &Config, token: &str) -> Result, String> { + let api_base = effective_api_url(&config.api_url) + .trim() + .trim_end_matches('/') + .to_string(); + + { + let cache = CURRENT_USER_CACHE.lock(); + if let Some(entry) = cache.as_ref() { + if entry.api_base == api_base + && entry.token == token + && entry.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL + { + debug!( + "{LOG_PREFIX} using cached current user age_ms={}", + entry.fetched_at.elapsed().as_millis() + ); + return Ok(Some(entry.user.clone())); + } + } + } + + let fetched = sanitize_snapshot_user(fetch_current_user(config, token).await?); + + let mut cache = CURRENT_USER_CACHE.lock(); + match fetched.clone() { + Some(user) => { + debug!("{LOG_PREFIX} refreshed current user from backend"); + *cache = Some(CachedCurrentUser { + api_base, + token: token.to_string(), + fetched_at: Instant::now(), + user, + }); + } + None => { + debug!("{LOG_PREFIX} backend returned empty current user; clearing cache"); + *cache = None; + } + } + + Ok(fetched) +} + async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot { let screen_intelligence = { let _ = crate::openhuman::screen_intelligence::global_engine() @@ -341,15 +403,21 @@ async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot { pub async fn snapshot() -> Result, String> { let config = config_rpc::load_config_with_timeout().await?; - let auth = build_session_state(&config)?; + let mut auth = build_session_state(&config)?; let session_token = get_session_token(&config)?; - let current_user = auth.user.clone().or( - if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) { - fetch_current_user(&config, &token).await? - } else { - None - }, - ); + let stored_user = sanitize_snapshot_user(auth.user.clone()); + let current_user = if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) { + match fetch_current_user_cached(&config, &token).await { + Ok(fresh_user) => fresh_user.or(stored_user.clone()), + Err(error) => { + warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}"); + stored_user.clone() + } + } + } else { + stored_user.clone() + }; + auth.user = current_user.clone(); let local_state = load_stored_app_state(&config)?; let runtime = build_runtime_snapshot(&config).await; @@ -420,3 +488,7 @@ pub async fn update_local_state( vec!["core local app state updated".to_string()], )) } + +#[cfg(test)] +#[path = "ops_tests.rs"] +mod tests; diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs new file mode 100644 index 000000000..3885c0899 --- /dev/null +++ b/src/openhuman/app_state/ops_tests.rs @@ -0,0 +1,36 @@ +use super::*; +use serde_json::json; + +#[test] +fn sanitize_snapshot_user_drops_empty_payloads() { + assert_eq!(sanitize_snapshot_user(Some(json!({}))), None); + assert_eq!(sanitize_snapshot_user(Some(Value::Null)), None); + assert_eq!( + sanitize_snapshot_user(Some(json!({ "firstName": "steven" }))), + Some(json!({ "firstName": "steven" })) + ); +} + +fn make_cached_entry(age: Duration) -> CachedCurrentUser { + CachedCurrentUser { + api_base: "https://staging-api.tinyhumans.ai".to_string(), + token: "tok".to_string(), + fetched_at: Instant::now() - age, + user: json!({ "firstName": "steven" }), + } +} + +// The freshness branch in `fetch_current_user_cached` is `elapsed() < TTL`. +// Lock that contract here so a future TTL change can't silently flip the +// cache from "hit" to "miss" without updating this test. +#[test] +fn cached_entry_is_considered_fresh_within_ttl() { + let fresh = make_cached_entry(Duration::from_millis(0)); + assert!(fresh.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL); +} + +#[test] +fn cached_entry_is_considered_expired_past_ttl() { + let expired = make_cached_entry(CURRENT_USER_REFRESH_TTL + Duration::from_millis(50)); + assert!(expired.fetched_at.elapsed() >= CURRENT_USER_REFRESH_TTL); +} diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index 07ced3589..cce1a58a5 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -143,7 +143,7 @@ pub async fn store_session( { metadata.insert("user_id".to_string(), uid); } - let user_for_store = user.unwrap_or(settings); + let user_for_store = sanitize_stored_session_user(user).unwrap_or(settings); metadata.insert("user_json".to_string(), user_for_store.to_string()); // Determine user_id so we can scope the openhuman directory to this user. @@ -228,6 +228,14 @@ pub async fn store_session( Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs)) } +fn sanitize_stored_session_user(user: Option) -> Option { + match user { + Some(serde_json::Value::Object(map)) if map.is_empty() => None, + Some(serde_json::Value::Null) => None, + other => other, + } +} + pub async fn clear_session(config: &Config) -> Result, String> { let auth = AuthService::from_config(config); let removed = auth diff --git a/src/openhuman/credentials/ops_tests.rs b/src/openhuman/credentials/ops_tests.rs index 20019d0b9..3df7df086 100644 --- a/src/openhuman/credentials/ops_tests.rs +++ b/src/openhuman/credentials/ops_tests.rs @@ -60,6 +60,19 @@ async fn store_session_rejects_empty_or_whitespace_token() { assert!(err.contains("token is required")); } +#[test] +fn sanitize_stored_session_user_discards_empty_objects() { + assert_eq!(sanitize_stored_session_user(Some(json!({}))), None); + assert_eq!( + sanitize_stored_session_user(Some(serde_json::Value::Null)), + None + ); + assert_eq!( + sanitize_stored_session_user(Some(json!({ "firstName": "steven" }))), + Some(json!({ "firstName": "steven" })) + ); +} + // ── clear_session ────────────────────────────────────────────── #[tokio::test]