diff --git a/.gitignore b/.gitignore index 898410711f..dc7c864982 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ distribution.cer # Release note previews CHANGELOG.preview.md +*.profraw diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 063911b011..b357f01033 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; import { useT } from '../../lib/i18n/I18nContext'; @@ -36,14 +36,39 @@ interface Props { onToast?: (toast: Toast) => void; } +interface MeetingBotsInlineProps extends Props { + hasSubmittedRef: RefObject; +} + export default function MeetingBotsCard({ onToast }: Props) { + const { t } = useT(); const status = useAppSelector(selectBackendMeetStatus); const showActive = status === 'active'; + // `hasSubmittedRef` lives in this always-mounted parent so the success toast + // fires reliably. When a join succeeds, `status` flips to 'active' and this + // component swaps `MeetingBotsInline` → `ActiveMeetingView`, unmounting the + // inline form before any effect inside it could observe 'active' (#3611 + // flattened these into a mutually-exclusive ternary). The inline form sets + // this ref on submit; we fire the success toast here. The error path stays in + // the inline form, which remains mounted during the 'error' state. + const hasSubmittedRef = useRef(false); + useEffect(() => { + if (!hasSubmittedRef.current) return; + if (status === 'active') { + hasSubmittedRef.current = false; + onToast?.({ + type: 'success', + title: t('skills.meetingBots.joiningTitle'), + message: t('skills.meetingBots.joiningMessage'), + }); + } + }, [status, onToast, t]); + return showActive ? ( ) : ( - + ); } @@ -173,7 +198,7 @@ function ActiveMeetingView({ onToast }: Props) { ); } -function MeetingBotsInline({ onToast }: Props) { +function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) { const { t } = useT(); const dispatch = useAppDispatch(); const [meetUrl, setMeetUrl] = useState(''); @@ -188,7 +213,6 @@ function MeetingBotsInline({ onToast }: Props) { const [error, setError] = useState(null); const meetStatus = useAppSelector(selectBackendMeetStatus); const meetError = useAppSelector(selectBackendMeetError); - const hasSubmittedRef = useRef(false); const [recentCalls, setRecentCalls] = useState(null); const [recentError, setRecentError] = useState(null); @@ -218,18 +242,12 @@ function MeetingBotsInline({ onToast }: Props) { ? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor } : undefined; + // Success ('active') is handled by the parent MeetingBotsCard, which stays + // mounted across the inline→active view swap. The error path lives here + // because the inline form remains mounted during the 'error' state and needs + // to surface the failure inline (setError/setSubmitting) alongside the toast. useEffect(() => { if (!hasSubmittedRef.current) return; - if (meetStatus === 'active') { - hasSubmittedRef.current = false; - onToast?.({ - type: 'success', - title: t('skills.meetingBots.joiningTitle'), - message: t('skills.meetingBots.joiningMessage'), - }); - setMeetUrl(''); - return; - } if (meetStatus === 'error') { hasSubmittedRef.current = false; const message = meetError?.trim() || t('skills.meetingBots.failedToStart'); @@ -237,7 +255,7 @@ function MeetingBotsInline({ onToast }: Props) { setSubmitting(false); onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); } - }, [meetStatus, meetError, onToast, t]); + }, [meetStatus, meetError, onToast, t, hasSubmittedRef]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); diff --git a/app/src/pages/__tests__/Accounts.faceToggle.test.tsx b/app/src/pages/__tests__/Accounts.faceToggle.test.tsx deleted file mode 100644 index c9b80ff5d8..0000000000 --- a/app/src/pages/__tests__/Accounts.faceToggle.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Tests for the Phase 6 face-mode toggle in the Accounts (Assistant) page. - * - * Verifies: - * - Face toggle button is rendered - * - Face mode is off by default - * - Clicking the toggle shows the face-mode panel (data-testid="face-mode-panel") - * - Clicking the toggle again hides the face-mode panel - * - Face mode state is persisted to localStorage (chat.faceMode) - * - When face mode is on, Conversations is rendered with variant="sidebar" - */ -import { configureStore } from '@reduxjs/toolkit'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import accountsReducer from '../../store/accountsSlice'; -import chatRuntimeReducer from '../../store/chatRuntimeSlice'; -import mascotReducer from '../../store/mascotSlice'; -import threadReducer from '../../store/threadSlice'; -// ── Static component import (after mocks are hoisted) ─────────────────────── -import Accounts from '../Accounts'; - -// ── Heavy dependency stubs — all must be declared before the component import ── - -// Stub Conversations so it doesn't pull in the full chat stack. -vi.mock('../Conversations', () => ({ - default: ({ variant }: { variant?: string }) => ( -
- ), - AgentChatPanel: () =>
, -})); - -// Stub webview account components. -vi.mock('../../components/accounts/WebviewHost', () => ({ - default: () =>
, -})); -vi.mock('../../components/accounts/AddAccountModal', () => ({ default: () => null })); -vi.mock('../../components/accounts/providerIcons', () => ({ - AgentIcon: ({ className }: { className?: string }) => ( - - ), - ProviderIcon: ({ provider, className }: { provider: string; className?: string }) => ( - - ), -})); - -// Stub webview account service. -vi.mock('../../services/webviewAccountService', () => ({ - startWebviewAccountService: vi.fn(), - hideWebviewAccount: vi.fn(), - showWebviewAccount: vi.fn(), - purgeWebviewAccount: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../../services/analytics', () => ({ trackEvent: vi.fn() })); - -// Stub mascot subcomponents — they pull in a Rive WASM runtime. -vi.mock('../../features/human/Mascot', () => ({ - RiveMascot: () =>
, - CustomGifMascot: ({ src }: { src: string }) => ( - - ), - MascotChipAvatar: () => , - getMascotPalette: vi.fn(() => ({ bodyFill: '#4A83DD', neckShadowColor: '#2A63BD' })), - hexToArgbInt: vi.fn((_hex: string) => 0xff4a83dd), -})); - -vi.mock('../../features/human/useHumanMascot', () => ({ - useHumanMascot: () => ({ face: 'idle', visemeCode: 0 }), -})); - -vi.mock('../../hooks/usePrewarmMostRecentAccount', () => ({ - usePrewarmMostRecentAccount: vi.fn(), -})); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const FACE_MODE_KEY = 'chat.faceMode'; - -function buildStore() { - return configureStore({ - reducer: { - accounts: accountsReducer, - mascot: mascotReducer, - thread: threadReducer, - chatRuntime: chatRuntimeReducer, - }, - }); -} - -function renderAccounts(store = buildStore()) { - return render( - - - - - - ); -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('Accounts — face-mode toggle', () => { - beforeEach(() => { - localStorage.clear(); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it('renders the face-toggle button', () => { - renderAccounts(); - expect(screen.getByTestId('face-toggle-button')).toBeInTheDocument(); - }); - - it('face mode is off by default (no face-mode-panel rendered)', () => { - renderAccounts(); - expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument(); - }); - - it('clicking the toggle shows the face-mode panel', async () => { - renderAccounts(); - const toggle = screen.getByTestId('face-toggle-button'); - await act(async () => { - fireEvent.click(toggle); - }); - expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument(); - }); - - it('clicking the toggle again hides the face-mode panel', async () => { - renderAccounts(); - const toggle = screen.getByTestId('face-toggle-button'); - await act(async () => { - fireEvent.click(toggle); - }); - expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument(); - await act(async () => { - fireEvent.click(screen.getByTestId('face-toggle-button')); - }); - expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument(); - }); - - it('persists face-mode ON to localStorage', async () => { - renderAccounts(); - await act(async () => { - fireEvent.click(screen.getByTestId('face-toggle-button')); - }); - expect(localStorage.getItem(FACE_MODE_KEY)).toBe('1'); - }); - - it('persists face-mode OFF to localStorage after toggling twice', async () => { - renderAccounts(); - await act(async () => { - fireEvent.click(screen.getByTestId('face-toggle-button')); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('face-toggle-button')); - }); - expect(localStorage.getItem(FACE_MODE_KEY)).toBe('0'); - }); - - it('reads face-mode ON from localStorage on mount', () => { - localStorage.setItem(FACE_MODE_KEY, '1'); - renderAccounts(); - expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument(); - }); - - it('when face mode is on, Conversations is rendered with variant="sidebar"', async () => { - renderAccounts(); - await act(async () => { - fireEvent.click(screen.getByTestId('face-toggle-button')); - }); - const conversations = screen.getByTestId('conversations-stub'); - expect(conversations).toHaveAttribute('data-variant', 'sidebar'); - }); -}); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 85209da8e6..5c0a559d74 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -542,31 +542,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { }); }); - // Covers lines 1455-1483: quota pill loading state - it('renders "Loading…" quota pill when isLoadingBudget=true', async () => { - mockUseUsageState.mockReturnValue({ - teamUsage: null, - currentPlan: null, - currentTier: 'FREE' as const, - isFreeTier: true, - usagePct: 0.0, - isNearLimit: false, - isAtLimit: false, - isBudgetExhausted: false, - shouldShowBudgetCompletedMessage: false, - isLoading: true, - refresh: vi.fn(), - }); - - await act(async () => { - await renderConversations({ thread: emptyThreadState }); - }); - - expect(screen.getByText('Loading…')).toBeInTheDocument(); - }); - - // Covers lines 1417-1439: budget banner + lines 1455-1516: LimitPill + tooltip - it('renders budget-limit banner and limit pills when teamUsage is present', async () => { + // Covers lines 1417-1439: budget-exceeded banner + it('renders budget-limit banner when teamUsage is present', async () => { // cycleBudgetUsd: 0 → renders "Your included budget is complete" branch const teamUsage = { cycleBudgetUsd: 0, remainingUsd: 0, cycleSpentUsd: 0, cycleEndsAt: null }; @@ -591,9 +568,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // Budget-exceeded banner (lines 1417-1439) — cycleBudgetUsd=0 gives "included budget" message expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument(); - - // LimitPill renders with the cycle label - expect(screen.getByText('Cycle')).toBeInTheDocument(); }); // Covers line 247: if (cancelled) return — the non-cancelled path through loadThreads callback @@ -1573,64 +1547,6 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', () // cycleBudgetUsd=0 → false branch of cycleBudgetUsd > 0 ternary → budgetComplete key expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument(); }); - - // Covers line 1910: cycleEndsAt truthy branch inside cycle-pill tooltip - it('renders reset time in cycle-pill tooltip when cycleEndsAt is set', async () => { - const teamUsage = { - cycleBudgetUsd: 10, - remainingUsd: 5, - cycleSpentUsd: 5, - cycleEndsAt: '2026-06-01T00:00:00.000Z', - }; - - mockUseUsageState.mockReturnValue({ - teamUsage, - currentPlan: null, - currentTier: 'PRO' as const, - isFreeTier: false, - usagePct: 0.5, - isNearLimit: false, - isAtLimit: false, - isBudgetExhausted: false, - shouldShowBudgetCompletedMessage: false, - isLoading: false, - refresh: vi.fn(), - }); - - await act(async () => { - await renderConversations({ thread: emptyThreadState }); - }); - - // Tooltip is hidden via CSS but present in DOM; cycleEndsAt truthy → reset span renders - expect(screen.getByText('Cycle')).toBeInTheDocument(); - // The tooltip resets span contains "resets" text (covers line 1910 conditional) - const resetSpans = document.querySelectorAll('[class*="text-stone-400"]'); - expect(resetSpans.length).toBeGreaterThan(0); - }); - - // Covers lines 1903-1904: loading animation span when isLoading=true, teamUsage=null - it('renders loading pulse span in cycle-pill area when isLoading=true', async () => { - mockUseUsageState.mockReturnValue({ - teamUsage: null, - currentPlan: null, - currentTier: 'FREE' as const, - isFreeTier: true, - usagePct: 0, - isNearLimit: false, - isAtLimit: false, - isBudgetExhausted: false, - shouldShowBudgetCompletedMessage: false, - isLoading: true, - refresh: vi.fn(), - }); - - await act(async () => { - await renderConversations({ thread: emptyThreadState }); - }); - - // Loading span with animate-pulse is present when teamUsage=null and loading - expect(screen.getByText('Loading…')).toBeInTheDocument(); - }); }); describe('Conversations — thread title editing', () => { diff --git a/app/test/playwright/specs/connector-gmail-composio.spec.ts b/app/test/playwright/specs/connector-gmail-composio.spec.ts index 0fdd8df62c..9067f96238 100644 --- a/app/test/playwright/specs/connector-gmail-composio.spec.ts +++ b/app/test/playwright/specs/connector-gmail-composio.spec.ts @@ -143,7 +143,10 @@ async function assertSessionNotNuked(page: Page) { }; const snapshot = win.__OPENHUMAN_CORE_STATE__?.()?.snapshot; return { - hash: window.location.hash, + // The connections page now appends an active-tab query (e.g. + // `#/connections?tab=composio`); strip it so we assert we're still on + // the connections route (session not nuked), not the exact sub-tab. + hash: window.location.hash.replace(/\?.*$/, ''), hasToken: Boolean(snapshot?.sessionToken), hasUser: Boolean(snapshot?.currentUser?._id), }; diff --git a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts index 7285ce7d04..1bd9c5ff91 100644 --- a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts +++ b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts @@ -95,7 +95,10 @@ async function assertSessionAlive(page: Page): Promise { } ).__OPENHUMAN_CORE_STATE__?.()?.snapshot; return { - hash: window.location.hash, + // The connections page now appends an active-tab query (e.g. + // `#/connections?tab=composio`); strip it so we assert we're still on + // the connections route (session not nuked), not the exact sub-tab. + hash: window.location.hash.replace(/\?.*$/, ''), hasUser: Boolean(snapshot?.currentUser?._id), hasToken: Boolean(snapshot?.sessionToken), }; diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index 947f117c08..b10fcfdee5 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -667,8 +667,19 @@ async fn auto_save_stores_messages_in_memory() { let _ = agent.turn("Remember this fact").await.unwrap(); - // Both user message and assistant response should be saved - let count = mem.count().await.unwrap(); + // Both user message and assistant response should be saved. The assistant + // reply is persisted synchronously, but the user message is saved + // fire-and-forget (tokio::spawn in turn/core.rs, #3610), so it may land a + // moment after `turn()` returns — poll briefly instead of reading once, + // which otherwise races on a loaded CI runner under llvm-cov instrumentation. + let mut count = 0; + for _ in 0..50 { + count = mem.count().await.unwrap(); + if count >= 2 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } assert!( count >= 2, "Expected at least 2 memory entries, got {count}" diff --git a/src/openhuman/voice/always_on.rs b/src/openhuman/voice/always_on.rs index c55c09c20e..ead080c9d9 100644 --- a/src/openhuman/voice/always_on.rs +++ b/src/openhuman/voice/always_on.rs @@ -677,9 +677,7 @@ fn capture_on_thread( let device = host .default_input_device() .ok_or_else(|| "no default audio input device".to_string())?; - let device_name = device - .name() - .unwrap_or_else(|e| format!("")); + let device_name = device.name().unwrap_or_else(|e| format!("")); let supported = device .default_input_config() .map_err(|e| format!("no default input config: {e}"))?;