diff --git a/src/hooks/useAgentZeroStatusIndicator.ts b/src/hooks/useAgentZeroStatusIndicator.ts new file mode 100644 index 0000000000000..9049b9c9699f6 --- /dev/null +++ b/src/hooks/useAgentZeroStatusIndicator.ts @@ -0,0 +1,340 @@ +import agentZeroProcessingIndicatorSelector from '@selectors/ReportNameValuePairs'; +import {useEffect, useRef, useState, useSyncExternalStore} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {clearAgentZeroProcessingIndicator, getNewerActions, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; +import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; +import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +type AgentZeroStatusState = { + isProcessing: boolean; + reasoningHistory: ReasoningEntry[]; + statusLabel: string; + kickoffWaitingIndicator: () => void; +}; + +type NewestReportAction = { + reportActionID: string; + actorAccountID?: number; +}; + +/** + * Polling interval for fetching missed Concierge responses while the thinking indicator is visible. + * + * While the indicator is active, we poll getNewerActions every 30s to recover from + * WebSocket drops or missed Pusher events. If a Concierge reply arrives (via Pusher + * or the poll response), the normal Onyx update clears the indicator automatically. + * + * A hard safety clear at MAX_POLL_DURATION_MS ensures the indicator doesn't stay + * forever if something goes wrong. + */ +const POLL_INTERVAL_MS = 30000; + +/** + * Maximum duration to poll before hard-clearing the indicator (safety net). + * After this time, if we're online and no response has arrived, we clear the indicator. + */ +const MAX_POLL_DURATION_MS = 120000; + +// Minimum time to display a label before allowing change (prevents rapid flicker) +const MIN_DISPLAY_TIME = 300; // ms +// Debounce delay for server label updates +const DEBOUNCE_DELAY = 150; // ms + +/** Selector that extracts the newest report action ID and actor from the report actions collection. */ +function selectNewestReportAction(reportActions: OnyxEntry): NewestReportAction | undefined { + if (!reportActions) { + return undefined; + } + const actionIDs = Object.keys(reportActions); + if (actionIDs.length === 0) { + return undefined; + } + const newestReportActionID = actionIDs.reduce((a, b) => (Number(a) > Number(b) ? a : b)); + return { + reportActionID: newestReportActionID, + actorAccountID: reportActions[newestReportActionID]?.actorAccountID, + }; +} + +/** + * Hook to manage AgentZero status indicator for chats where AgentZero responds. + * This includes both Concierge DM chats and policy #admins rooms (where Concierge handles onboarding). + * @param reportID - The report ID to monitor + * @param isAgentZeroChat - Whether the chat is an AgentZero-enabled chat (Concierge DM or #admins room) + */ +function useAgentZeroStatusIndicator(reportID: string, isAgentZeroChat: boolean): AgentZeroStatusState { + // Server-driven processing label from report name-value pairs (e.g. "Looking up categories...") + // Uses selector to only re-render when the specific field changes, not on any NVP change. + const [serverLabel] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: agentZeroProcessingIndicatorSelector}); + + // Track the newest report action so we can fetch missed actions and detect actual Concierge replies. + const [newestReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: selectNewestReportAction}); + const newestReportActionRef = useRef(newestReportAction); + useEffect(() => { + newestReportActionRef.current = newestReportAction; + }, [newestReportAction]); + + // Track pending optimistic requests with a counter. + // Each kickoffWaitingIndicator() call increments the counter; when a Concierge reply + // is detected (via polling, Pusher, or reconnect), the counter resets to 0. + const [pendingOptimisticRequests, setPendingOptimisticRequests] = useState(0); + // Debounced label shown to the user — smooths rapid server label changes. + // displayedLabelRef mirrors state so the label-sync effect can read the current value + // without including displayedLabel in its dependency array (avoids extra effect cycles). + const displayedLabelRef = useRef(''); + const [displayedLabel, setDisplayedLabel] = useState(''); + const {translate} = useLocalize(); + const prevServerLabelRef = useRef(serverLabel ?? ''); + const updateTimerRef = useRef(null); + const lastUpdateTimeRef = useRef(0); + const pollIntervalRef = useRef(null); + const pollSafetyTimerRef = useRef(null); + const isOfflineRef = useRef(false); + + /** + * Clear the polling interval and safety timer. Called when the indicator clears normally, + * when a new processing cycle starts, or when the component unmounts. + */ + const clearPolling = () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + if (pollSafetyTimerRef.current) { + clearTimeout(pollSafetyTimerRef.current); + pollSafetyTimerRef.current = null; + } + }; + + /** + * Hard-clear the indicator by resetting local state and clearing the Onyx NVP. + * Called as a safety net after MAX_POLL_DURATION_MS if no response has arrived. + */ + const hardClearIndicator = () => { + // If offline, don't clear — the response may arrive when reconnected + if (isOfflineRef.current) { + return; + } + clearPolling(); + setPendingOptimisticRequests(0); + displayedLabelRef.current = ''; + setDisplayedLabel(''); + clearAgentZeroProcessingIndicator(reportID); + getNewerActions(reportID, newestReportActionRef.current?.reportActionID); + }; + + /** + * Start polling for missed actions every POLL_INTERVAL_MS. Every time processing + * becomes active or the server label changes (renewal), the existing polling is + * cleared and restarted. + * + * - Every 30s: call getNewerActions to fetch any missed Concierge responses + * - After MAX_POLL_DURATION_MS: hard-clear the indicator if still showing (safety net) + * + * Polling stops when: indicator clears, component unmounts, or user goes offline. + */ + const startPolling = () => { + clearPolling(); + + // Poll every 30s for missed actions. Track the newest action ID before polling + // so we can detect if new actions arrived (meaning Concierge responded). + // If new actions arrive but the NVP CLEAR was missed via Pusher, we clear + // the indicator client-side. + const prePollingActionID = newestReportActionRef.current?.reportActionID; + pollIntervalRef.current = setInterval(() => { + if (isOfflineRef.current) { + return; + } + const currentNewestReportAction = newestReportActionRef.current; + const didConciergeReplyAfterPollingStarted = + currentNewestReportAction?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE && currentNewestReportAction.reportActionID !== prePollingActionID; + + if (didConciergeReplyAfterPollingStarted) { + clearAgentZeroProcessingIndicator(reportID); + clearPolling(); + setPendingOptimisticRequests(0); + return; + } + getNewerActions(reportID, currentNewestReportAction?.reportActionID); + }, POLL_INTERVAL_MS); + + // Safety net: hard-clear after MAX_POLL_DURATION_MS + pollSafetyTimerRef.current = setTimeout(() => { + hardClearIndicator(); + }, MAX_POLL_DURATION_MS); + }; + + // On reconnect, clear stale optimistic state and fetch missed actions. + // Optimistic state from before going offline is unreliable — the server state + // (serverLabel) is the source of truth after reconnection. + const {isOffline} = useNetwork({ + onReconnect: () => { + const wasOptimistic = pendingOptimisticRequests > 0; + + // Clear stale optimistic state — server state takes over as source of truth + if (wasOptimistic) { + setPendingOptimisticRequests(0); + } + + if (!serverLabel && !wasOptimistic) { + return; + } + + // Fetch missed actions AND start polling to detect when the Concierge response arrives. + // getNewerActions is a one-shot fetch; polling ensures we keep checking until + // the response is detected (via actorAccountID === CONCIERGE check in the poll). + getNewerActions(reportID, newestReportActionRef.current?.reportActionID); + startPolling(); + }, + }); + + // Subscribe to ConciergeReasoningStore using useSyncExternalStore for + // correct synchronization with React's render cycle. + const subscribeToReasoningStore = (onStoreChange: () => void) => { + const unsubscribe = ConciergeReasoningStore.subscribe((updatedReportID) => { + if (updatedReportID !== reportID) { + return; + } + onStoreChange(); + }); + return unsubscribe; + }; + const getReasoningSnapshot = () => ConciergeReasoningStore.getReasoningHistory(reportID); + const reasoningHistory = useSyncExternalStore(subscribeToReasoningStore, getReasoningSnapshot, getReasoningSnapshot); + + useEffect(() => { + if (!isAgentZeroChat) { + return; + } + + subscribeToReportReasoningEvents(reportID); + + // Cleanup: unsubscribeFromReportReasoningChannel handles Pusher unsubscribing, + // clearing reasoning history from ConciergeReasoningStore, and subscription tracking + return () => { + unsubscribeFromReportReasoningChannel(reportID); + }; + }, [isAgentZeroChat, reportID]); + + // Synchronize the displayed label with debounce and minimum display time. + // displayedLabelRef mirrors state so the effect can check the current value without depending on displayedLabel. + useEffect(() => { + const hadServerLabel = !!prevServerLabelRef.current; + const hasServerLabel = !!serverLabel; + + let targetLabel = ''; + if (hasServerLabel) { + targetLabel = serverLabel ?? ''; + } else if (pendingOptimisticRequests > 0) { + targetLabel = translate('common.thinking'); + } + + // Start/reset polling when server label arrives (acts as a lease renewal) + if (hasServerLabel) { + startPolling(); + if (pendingOptimisticRequests > 0) { + setPendingOptimisticRequests(0); + } + } + // Clear polling when processing ends + else if (pendingOptimisticRequests === 0) { + clearPolling(); + if (hadServerLabel && reasoningHistory.length > 0) { + ConciergeReasoningStore.clearReasoning(reportID); + } + } + + // Use ref to check current value without depending on displayedLabel in deps + if (displayedLabelRef.current === targetLabel) { + prevServerLabelRef.current = serverLabel ?? ''; + return; + } + + const now = Date.now(); + const timeSinceLastUpdate = now - lastUpdateTimeRef.current; + const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate); + + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current); + updateTimerRef.current = null; + } + + // Immediate update when enough time has passed or when clearing the label + if (remainingMinTime === 0 || targetLabel === '') { + displayedLabelRef.current = targetLabel; + // eslint-disable-next-line react-hooks/set-state-in-effect -- guarded by displayedLabelRef check above; fires once per serverLabel/optimistic transition + setDisplayedLabel(targetLabel); + lastUpdateTimeRef.current = now; + } else { + // Schedule update after debounce + remaining min display time + const delay = DEBOUNCE_DELAY + remainingMinTime; + updateTimerRef.current = setTimeout(() => { + displayedLabelRef.current = targetLabel; + setDisplayedLabel(targetLabel); + lastUpdateTimeRef.current = Date.now(); + updateTimerRef.current = null; + }, delay); + } + + prevServerLabelRef.current = serverLabel ?? ''; + + return () => { + if (!updateTimerRef.current) { + return; + } + clearTimeout(updateTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- displayedLabelRef avoids depending on displayedLabel; startPolling/clearPolling use refs + }, [serverLabel, reasoningHistory.length, reportID, pendingOptimisticRequests, translate]); + + useEffect(() => { + isOfflineRef.current = isOffline; + }, [isOffline]); + + // Clean up polling on unmount + useEffect( + () => () => { + clearPolling(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const kickoffWaitingIndicator = () => { + setPendingOptimisticRequests((prev) => prev + 1); + startPolling(); + }; + + // Immediately clear the indicator when a Concierge response arrives while processing. + // This eliminates the 30s delay waiting for the next poll cycle to detect it. + const newestActorAccountID = newestReportAction?.actorAccountID; + useEffect(() => { + if (newestActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE) { + return; + } + if (!serverLabel && pendingOptimisticRequests === 0) { + return; + } + clearAgentZeroProcessingIndicator(reportID); + clearPolling(); + setPendingOptimisticRequests(0); + }, [newestActorAccountID, serverLabel, pendingOptimisticRequests, reportID]); + + const isProcessing = !isOffline && (!!serverLabel || pendingOptimisticRequests > 0); + + return { + isProcessing, + reasoningHistory, + statusLabel: displayedLabel, + kickoffWaitingIndicator, + }; +} + +export default useAgentZeroStatusIndicator; +export type {AgentZeroStatusState}; diff --git a/src/libs/ConciergeReasoningStore.ts b/src/libs/ConciergeReasoningStore.ts new file mode 100644 index 0000000000000..0fe31039bbed5 --- /dev/null +++ b/src/libs/ConciergeReasoningStore.ts @@ -0,0 +1,127 @@ +/** + * Ephemeral in-memory store for managing Concierge reasoning summaries per report. + * This data is transient UI feedback and is NOT persisted to Onyx. + */ + +type ReasoningEntry = { + reasoning: string; + loopCount: number; + timestamp: number; +}; + +type ReasoningData = { + reasoning: string; + agentZeroRequestID: string; + loopCount: number; +}; + +type ReportState = { + agentZeroRequestID: string; + entries: ReasoningEntry[]; +}; + +type Listener = (reportID: string, state: ReasoningEntry[]) => void; + +// In-memory store +const store = new Map(); +const listeners = new Set(); + +// Stable empty array reference for useSyncExternalStore compatibility. +// getSnapshot must return the same reference when data hasn't changed, +// otherwise React will re-render infinitely. +const EMPTY_ENTRIES: ReasoningEntry[] = []; + +/** + * Notify all subscribers of state changes + */ +function notifyListeners(reportID: string, entries: ReasoningEntry[]) { + for (const listener of listeners) { + listener(reportID, entries); + } +} + +/** + * Add a reasoning entry to a report's history. + * If the agentZeroRequestID differs from the current state, resets all entries (new request). + * Skips duplicates (same loopCount + same reasoning text). + */ +function addReasoning(reportID: string, data: ReasoningData) { + // Ignore empty reasoning strings + if (!data.reasoning.trim()) { + return; + } + + const currentState = store.get(reportID); + + // If agentZeroRequestID differs, reset all entries (new request) + if (currentState && currentState.agentZeroRequestID !== data.agentZeroRequestID) { + store.set(reportID, { + agentZeroRequestID: data.agentZeroRequestID, + entries: [], + }); + } + + // Get or create state + const state = store.get(reportID) ?? { + agentZeroRequestID: data.agentZeroRequestID, + entries: [], + }; + + // Skip duplicates (same loopCount + same reasoning text) + const isDuplicate = state.entries.some((entry) => entry.loopCount === data.loopCount && entry.reasoning === data.reasoning); + + if (!isDuplicate) { + const timestamp = Date.now(); + const newEntries = [ + ...state.entries, + { + reasoning: data.reasoning, + loopCount: data.loopCount, + timestamp, + }, + ]; + const newState = { + agentZeroRequestID: state.agentZeroRequestID, + entries: newEntries, + }; + store.set(reportID, newState); + notifyListeners(reportID, newEntries); + } +} + +/** + * Remove all reasoning entries for a report. + * Called when the final Concierge message arrives or when unsubscribing. + */ +function clearReasoning(reportID: string) { + store.delete(reportID); + notifyListeners(reportID, []); +} + +/** + * Get the reasoning history for a report + */ +function getReasoningHistory(reportID: string): ReasoningEntry[] { + return store.get(reportID)?.entries ?? EMPTY_ENTRIES; +} + +/** + * Subscribe to state changes. + * Listener receives (reportID, state) on every change. + * Returns an unsubscribe function. + */ +function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export default { + addReasoning, + clearReasoning, + getReasoningHistory, + subscribe, +}; + +export type {ReasoningEntry, ReasoningData}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index a9e5d5f5b452d..68baed4963ee4 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -63,6 +63,7 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs import * as ApiUtils from '@libs/ApiUtils'; import * as Browser from '@libs/Browser'; import * as CollectionUtils from '@libs/CollectionUtils'; +import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import DateUtils from '@libs/DateUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; @@ -411,6 +412,10 @@ Onyx.connect({ const typingWatchTimers: Record = {}; +// Track subscriptions to conciergeReasoning Pusher events to avoid duplicates. +// Maps reportID to the PusherSubscription handle for proper per-callback cleanup. +const reasoningSubscriptions = new Map>(); + let reportIDDeeplinkedFromOldDot: string | undefined; Linking.getInitialURL().then((url) => { reportIDDeeplinkedFromOldDot = processReportIDDeeplink(url ?? ''); @@ -588,6 +593,64 @@ function unsubscribeFromLeavingRoomReportChannel(reportID: string | undefined) { Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM); } +/** + * Subscribe to conciergeReasoning Pusher events for a report. + * Tracks subscriptions to avoid duplicates and updates ConciergeReasoningStore with reasoning data. + */ +function subscribeToReportReasoningEvents(reportID: string) { + if (!reportID || reasoningSubscriptions.has(reportID)) { + return; + } + + const pusherChannelName = getReportChannelName(reportID); + + const handle = Pusher.subscribe(pusherChannelName, Pusher.TYPE.CONCIERGE_REASONING, (data: Record) => { + const eventData = data as {reasoning: string; agentZeroRequestID: string; loopCount: number}; + + ConciergeReasoningStore.addReasoning(reportID, { + reasoning: eventData.reasoning, + agentZeroRequestID: eventData.agentZeroRequestID, + loopCount: eventData.loopCount, + }); + }); + + // Store the handle immediately to prevent duplicate subscriptions + reasoningSubscriptions.set(reportID, handle); + + handle.catch((error: ReportError) => { + Log.hmmm('[Report] Failed to subscribe to Pusher concierge reasoning events', {errorType: error.type, pusherChannelName, reportID}); + // Remove from subscriptions if subscription failed + reasoningSubscriptions.delete(reportID); + }); +} + +/** + * Unsubscribe from conciergeReasoning Pusher events for a report. + * Clears reasoning state and removes from subscription tracking. + */ +function unsubscribeFromReportReasoningChannel(reportID: string) { + const handle = reasoningSubscriptions.get(reportID); + if (!reportID || !handle) { + return; + } + + // Use the per-callback handle for precise cleanup instead of the global + // Pusher.unsubscribe which removes ALL callbacks for the event on the channel. + handle.unsubscribe(); + ConciergeReasoningStore.clearReasoning(reportID); + reasoningSubscriptions.delete(reportID); +} + +/** + * Clear the AgentZero processing indicator for a report. + * Used by the safety timeout (lease pattern) and network reconnect handler + * to auto-clear stale indicators when the CLEAR update was missed. + */ +function clearAgentZeroProcessingIndicator(reportID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {agentZeroProcessingRequestIndicator: null}); + ConciergeReasoningStore.clearReasoning(reportID); +} + // New action subscriber array for report pages let newActionSubscribers: ActionSubscriber[] = []; @@ -7340,6 +7403,9 @@ export { startNewChat, subscribeToNewActionEvent, subscribeToReportLeavingEvents, + clearAgentZeroProcessingIndicator, + subscribeToReportReasoningEvents, + unsubscribeFromReportReasoningChannel, subscribeToReportTypingEvents, toggleEmojiReaction, togglePinnedState, diff --git a/src/pages/inbox/AgentZeroStatusContext.tsx b/src/pages/inbox/AgentZeroStatusContext.tsx index 6343d43a7b560..a67d2e9df9fef 100644 --- a/src/pages/inbox/AgentZeroStatusContext.tsx +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -1,34 +1,24 @@ import {getReportChatType} from '@selectors/Report'; -import agentZeroProcessingIndicatorSelector from '@selectors/ReportNameValuePairs'; -import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; +import React, {createContext, useContext} from 'react'; +import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; import useOnyx from '@hooks/useOnyx'; -import {getReportChannelName} from '@libs/actions/Report'; -import Log from '@libs/Log'; -import Pusher from '@libs/Pusher'; +import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -type ReasoningEntry = { - reasoning: string; - loopCount: number; - timestamp: number; -}; - type AgentZeroStatusState = { - /** Whether AgentZero is actively working — true when the server sent a processing label or we're optimistically waiting */ + /** Whether AgentZero is actively working */ isProcessing: boolean; - /** Chronological list of reasoning steps streamed via Pusher during the current processing request */ + /** Chronological list of reasoning steps for the current processing request */ reasoningHistory: ReasoningEntry[]; - /** Debounced label shown in the thinking bubble (e.g. "Looking up categories...") */ + /** Debounced label shown in the thinking bubble */ statusLabel: string; }; type AgentZeroStatusActions = { - /** Sets optimistic "thinking" state immediately after the user sends a message, before the server responds */ + /** Sets optimistic "thinking" state immediately after the user sends a message */ kickoffWaitingIndicator: () => void; }; @@ -47,8 +37,7 @@ const AgentZeroStatusActionsContext = createContext(defa /** * Cheap outer guard — only subscribes to the scalar CONCIERGE_REPORT_ID. - * For non-AgentZero reports (the common case), returns children directly - * without mounting any Pusher subscriptions or heavy state logic. + * For non-AgentZero reports (the common case), returns children directly. * * AgentZero chats include Concierge DMs and policy #admins rooms. */ @@ -73,172 +62,9 @@ function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{ ); } -// Minimum time to display a label before allowing change (prevents rapid flicker) -const MIN_DISPLAY_TIME = 300; // ms -// Debounce delay for server label updates -const DEBOUNCE_DELAY = 150; // ms - -/** - * Inner gate — all Pusher, reasoning, label, and processing state. - * Only mounted when reportID matches the Concierge report. - * Remounted via key prop when reportID changes, so all state resets automatically. - */ function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{reportID: string}>) { - // Server-driven processing label from report name-value pairs (e.g. "Looking up categories...") - const [serverLabel] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: agentZeroProcessingIndicatorSelector}); - - // Timestamp set when the user sends a message, before the server label arrives — shows "Concierge is thinking..." - const [optimisticStartTime, setOptimisticStartTime] = useState(null); - // Debounced label shown to the user — smooths rapid server label changes - const displayedLabelRef = useRef(''); - const [displayedLabel, setDisplayedLabel] = useState(''); - // Chronological list of reasoning steps streamed via Pusher during a single processing request - const [reasoningHistory, setReasoningHistory] = useState([]); - const {translate} = useLocalize(); - // Timer for debounced label updates — ensures a minimum display time before switching - const updateTimerRef = useRef(null); - // Timestamp of the last label update — used to enforce MIN_DISPLAY_TIME - const lastUpdateTimeRef = useRef(0); - const {isOffline} = useNetwork(); - - // Tracks the current agentZeroRequestID so the Pusher callback can detect new requests - const agentZeroRequestIDRef = useRef(''); - - // Clear optimistic state once server label arrives — the server has taken over - if (serverLabel && optimisticStartTime) { - setOptimisticStartTime(null); - } - - // Clear optimistic state when coming back online — stale optimism from offline - const [prevIsOffline, setPrevIsOffline] = useState(isOffline); - if (prevIsOffline !== isOffline) { - setPrevIsOffline(isOffline); - if (!isOffline && optimisticStartTime) { - setOptimisticStartTime(null); - } - } - - // Clear reasoning when processing ends (server label transitions from truthy → falsy) - const [prevServerLabel, setPrevServerLabel] = useState(serverLabel); - if (prevServerLabel !== serverLabel) { - setPrevServerLabel(serverLabel); - if (prevServerLabel && !serverLabel && reasoningHistory.length > 0) { - setReasoningHistory([]); - } - } - - /** Appends a reasoning entry from Pusher. Resets history when a new request ID is detected; skips duplicates. */ - const addReasoning = (data: {reasoning: string; agentZeroRequestID: string; loopCount: number}) => { - if (!data.reasoning.trim()) { - return; - } - - const isNewRequest = agentZeroRequestIDRef.current !== data.agentZeroRequestID; - if (isNewRequest) { - agentZeroRequestIDRef.current = data.agentZeroRequestID; - } - - const entry: ReasoningEntry = { - reasoning: data.reasoning, - loopCount: data.loopCount, - timestamp: Date.now(), - }; - - if (isNewRequest) { - setReasoningHistory([entry]); - return; - } - - setReasoningHistory((prev) => { - const isDuplicate = prev.some((e) => e.loopCount === data.loopCount && e.reasoning === data.reasoning); - if (isDuplicate) { - return prev; - } - return [...prev, entry]; - }); - }; - - // Subscribe to Pusher reasoning events for this report's channel - useEffect(() => { - const channelName = getReportChannelName(reportID); - - const listener = Pusher.subscribe(channelName, Pusher.TYPE.CONCIERGE_REASONING, (data: Record) => { - const eventData = data as {reasoning: string; agentZeroRequestID: string; loopCount: number}; - addReasoning(eventData); - }); - listener.catch((error: unknown) => { - Log.hmmm('[AgentZeroStatusGate] Failed to subscribe to Pusher concierge reasoning events', {reportID, error}); - }); - - return () => { - listener.unsubscribe(); - }; - }, [reportID, addReasoning]); - - // Synchronize the displayed label with debounce and minimum display time. - // displayedLabelRef mirrors state so the effect can check the current value without depending on displayedLabel. - useEffect(() => { - let targetLabel = ''; - if (serverLabel) { - targetLabel = serverLabel; - } else if (optimisticStartTime) { - targetLabel = translate('common.thinking'); - } - - if (displayedLabelRef.current === targetLabel) { - return; - } - - const now = Date.now(); - const timeSinceLastUpdate = now - lastUpdateTimeRef.current; - const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate); - - if (updateTimerRef.current) { - clearTimeout(updateTimerRef.current); - updateTimerRef.current = null; - } - - // Immediate update when enough time has passed or when clearing the label - if (remainingMinTime === 0 || targetLabel === '') { - displayedLabelRef.current = targetLabel; - // eslint-disable-next-line react-hooks/set-state-in-effect -- guarded by displayedLabelRef check above; fires once per serverLabel/optimistic transition - setDisplayedLabel(targetLabel); - lastUpdateTimeRef.current = now; - } else { - // Schedule update after debounce + remaining min display time - const delay = DEBOUNCE_DELAY + remainingMinTime; - updateTimerRef.current = setTimeout(() => { - displayedLabelRef.current = targetLabel; - setDisplayedLabel(targetLabel); - lastUpdateTimeRef.current = Date.now(); - updateTimerRef.current = null; - }, delay); - } - - return () => { - if (!updateTimerRef.current) { - return; - } - clearTimeout(updateTimerRef.current); - }; - }, [serverLabel, optimisticStartTime, translate]); - - const kickoffWaitingIndicator = () => { - setOptimisticStartTime(Date.now()); - }; - - // True when AgentZero is actively working — either the server sent a label or we're optimistically waiting - const isProcessing = !isOffline && (!!serverLabel || !!optimisticStartTime); - - const stateValue: AgentZeroStatusState = { - isProcessing, - reasoningHistory, - statusLabel: displayedLabel, - }; - - const actionsValue: AgentZeroStatusActions = { - kickoffWaitingIndicator, - }; + const {kickoffWaitingIndicator, ...stateValue} = useAgentZeroStatusIndicator(reportID, true); + const actionsValue = {kickoffWaitingIndicator}; return ( diff --git a/tests/unit/AgentZeroStatusContextTest.ts b/tests/unit/AgentZeroStatusContextTest.ts index 3ecd96563c75f..b7f38d2cb8805 100644 --- a/tests/unit/AgentZeroStatusContextTest.ts +++ b/tests/unit/AgentZeroStatusContextTest.ts @@ -1,7 +1,11 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import fs from 'fs'; +import path from 'path'; import React from 'react'; import Onyx from 'react-native-onyx'; -import Pusher from '@libs/Pusher'; +import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; +import {clearAgentZeroProcessingIndicator, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; +import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; import {AgentZeroStatusProvider, useAgentZeroStatus, useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; import ONYXKEYS from '@src/ONYXKEYS'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -21,30 +25,29 @@ jest.mock('@hooks/useLocalize', () => ({ }), })); -jest.mock('@libs/Pusher'); - -const mockPusher = Pusher as jest.Mocked; +jest.mock('@libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = jest.requireActual('@libs/actions/Report'); + return { + ...actual, + clearAgentZeroProcessingIndicator: jest.fn(), + getNewerActions: jest.fn(), + subscribeToReportReasoningEvents: jest.fn(), + unsubscribeFromReportReasoningChannel: jest.fn(), + }; +}); -type PusherCallback = (data: Record) => void; +const mockClearAgentZeroProcessingIndicator = clearAgentZeroProcessingIndicator as jest.MockedFunction; +const mockSubscribeToReportReasoningEvents = subscribeToReportReasoningEvents as jest.MockedFunction; +const mockUnsubscribeFromReportReasoningChannel = unsubscribeFromReportReasoningChannel as jest.MockedFunction; -/** Captures the reasoning callback passed to Pusher.subscribe for CONCIERGE_REASONING */ -function capturePusherCallback(): PusherCallback { - const call = mockPusher.subscribe.mock.calls.find((c) => c.at(1) === Pusher.TYPE.CONCIERGE_REASONING); - const callback = call?.at(2) as PusherCallback | undefined; - if (!callback) { - throw new Error('Pusher.subscribe was not called for CONCIERGE_REASONING'); - } - return callback; -} +const reportID = '123'; -/** Simulates a Pusher reasoning event */ +/** Simulates a reasoning event via ConciergeReasoningStore (the real store, since it's not mocked) */ function simulateReasoning(data: {reasoning: string; agentZeroRequestID: string; loopCount: number}) { - const callback = capturePusherCallback(); - callback(data as unknown as Record); + ConciergeReasoningStore.addReasoning(reportID, data); } -const reportID = '123'; - function wrapper({children}: {children: React.ReactNode}) { return React.createElement(AgentZeroStatusProvider, {reportID}, children); } @@ -56,8 +59,15 @@ describe('AgentZeroStatusContext', () => { jest.clearAllMocks(); await Onyx.clear(); - mockPusher.subscribe = jest.fn().mockImplementation(() => Object.assign(Promise.resolve(), {unsubscribe: jest.fn()})); - mockPusher.unsubscribe = jest.fn(); + // Clear ConciergeReasoningStore between tests + ConciergeReasoningStore.clearReasoning(reportID); + + // Make clearAgentZeroProcessingIndicator actually clear the Onyx NVP + // so safety timeout and reconnect tests can verify the full clearing flow + mockClearAgentZeroProcessingIndicator.mockImplementation((rID: string) => { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${rID}`, {agentZeroProcessingRequestIndicator: null}); + ConciergeReasoningStore.clearReasoning(rID); + }); // Mark this report as Concierge by default await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, reportID); @@ -82,8 +92,8 @@ describe('AgentZeroStatusContext', () => { expect(result.current.reasoningHistory).toEqual([]); expect(result.current.kickoffWaitingIndicator).toBeInstanceOf(Function); - // And no Pusher subscription should have been created - expect(mockPusher.subscribe).not.toHaveBeenCalled(); + // And no reasoning subscription should have been created + expect(mockSubscribeToReportReasoningEvents).not.toHaveBeenCalled(); }); it('should return processing state when server label is present in Concierge chat', async () => { @@ -288,34 +298,164 @@ describe('AgentZeroStatusContext', () => { }); describe('Pusher lifecycle', () => { - it('should subscribe to Pusher for Concierge chat on mount', async () => { + it('should subscribe to reasoning events for Concierge chat on mount', async () => { renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - expect(mockPusher.subscribe).toHaveBeenCalledWith(expect.stringContaining(reportID), Pusher.TYPE.CONCIERGE_REASONING, expect.any(Function)); + expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledWith(reportID); }); - it('should not subscribe to Pusher for non-Concierge chat', async () => { + it('should not subscribe to reasoning events for non-Concierge chat', async () => { await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - expect(mockPusher.subscribe).not.toHaveBeenCalledWith(expect.anything(), Pusher.TYPE.CONCIERGE_REASONING, expect.anything()); + expect(mockSubscribeToReportReasoningEvents).not.toHaveBeenCalled(); }); - it('should unsubscribe from Pusher on unmount', async () => { - // Track the per-callback unsubscribe handle - const handleUnsubscribe = jest.fn(); - mockPusher.subscribe = jest.fn().mockImplementation(() => Object.assign(Promise.resolve(), {unsubscribe: handleUnsubscribe})); - + it('should unsubscribe from reasoning events on unmount', async () => { const {unmount} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); unmount(); await waitForBatchedUpdates(); - expect(handleUnsubscribe).toHaveBeenCalled(); + expect(mockUnsubscribeFromReportReasoningChannel).toHaveBeenCalledWith(reportID); + }); + }); + + describe('batched Onyx updates (stuck indicator fix)', () => { + const POLL_INTERVAL_MS = 30000; + const MAX_POLL_DURATION_MS = 120000; + let pollIntervalId: ReturnType | null; + let safetyTimerId: ReturnType | null; + let originalSetInterval: typeof setInterval; + let originalClearInterval: typeof clearInterval; + let originalSetTimeout: typeof setTimeout; + let originalClearTimeout: typeof clearTimeout; + + beforeEach(() => { + pollIntervalId = null; + safetyTimerId = null; + originalSetInterval = global.setInterval; + originalClearInterval = global.clearInterval; + originalSetTimeout = global.setTimeout; + originalClearTimeout = global.clearTimeout; + + jest.spyOn(global, 'setInterval').mockImplementation(((callback: () => void, ms?: number) => { + if (ms === POLL_INTERVAL_MS) { + const id = originalSetInterval(() => {}, 999999); + pollIntervalId = id; + return id; + } + return originalSetInterval(callback, ms); + }) as typeof setInterval); + + jest.spyOn(global, 'clearInterval').mockImplementation((id) => { + if (id !== undefined && id !== null && id === pollIntervalId) { + pollIntervalId = null; + originalClearInterval(id); + return; + } + originalClearInterval(id); + }); + + jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void, ms?: number) => { + if (ms === MAX_POLL_DURATION_MS) { + const id = originalSetTimeout(() => {}, 0); + safetyTimerId = id; + return id; + } + return originalSetTimeout(callback, ms); + }) as typeof setTimeout); + + jest.spyOn(global, 'clearTimeout').mockImplementation((id) => { + if (id !== undefined && id !== null && id === safetyTimerId) { + safetyTimerId = null; + return; + } + originalClearTimeout(id); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should clear optimistic state when server SET and CLEAR arrive sequentially', async () => { + // Given a Concierge chat where the user triggered optimistic waiting + const isConciergeChat = true; + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + + // User sends message -> optimistic waiting state + act(() => { + result.current.kickoffWaitingIndicator(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + expect(result.current.statusLabel).toBe('Thinking...'); + + // When the server SET arrives, it clears optimistic state and shows server label + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: 'Concierge is looking up categories...', + }); + await waitForBatchedUpdates(); + + // Then server CLEAR arrives (processing complete) + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: '', + }); + await waitForBatchedUpdates(); + + // The indicator should be fully cleared (normal path, no polling needed) + // The polling should also have been cancelled + await waitFor(() => { + expect(result.current.isProcessing).toBe(false); + }); + expect(result.current.statusLabel).toBe(''); + expect(pollIntervalId).toBeNull(); + expect(safetyTimerId).toBeNull(); + }); + }); + + describe('server label transitions', () => { + it('should clear optimistic state when server CLEAR arrives after a visible SET', async () => { + // Given a Concierge chat where the user triggered optimistic waiting + const isConciergeChat = true; + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + + // User sends message -> optimistic waiting state + act(() => { + result.current.kickoffWaitingIndicator(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + expect(result.current.statusLabel).toBe('Thinking...'); + + // When the server sets a label (processing started) + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: 'Processing...', + }); + await waitForBatchedUpdates(); + + await waitFor(() => { + expect(result.current.statusLabel).toBe('Processing...'); + }); + + // And then clears it (processing complete) + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: '', + }); + + // Then the indicator should be fully cleared + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(false); + expect(result.current.statusLabel).toBe(''); }); }); @@ -389,4 +529,255 @@ describe('AgentZeroStatusContext', () => { expect(result.current.statusLabel).toBe(''); }); }); + + describe('safety timeout (polling pattern)', () => { + // We spy on setInterval/clearInterval and setTimeout/clearTimeout to capture + // the polling and safety timer callbacks rather than using jest.useFakeTimers(), + // which interferes with Onyx's async batching. + // + // Polling: every 30s → getNewerActions, 120s safety → hard clear + const POLL_INTERVAL_MS = 30000; + const MAX_POLL_DURATION_MS = 120000; + let pollCallback: (() => void) | null; + let safetyCallback: (() => void) | null; + let pollIntervalId: ReturnType | null; + let safetyTimerId: ReturnType | null; + let originalSetInterval: typeof setInterval; + let originalClearInterval: typeof clearInterval; + let originalSetTimeout: typeof setTimeout; + let originalClearTimeout: typeof clearTimeout; + + beforeEach(() => { + pollCallback = null; + safetyCallback = null; + pollIntervalId = null; + safetyTimerId = null; + originalSetInterval = global.setInterval; + originalClearInterval = global.clearInterval; + originalSetTimeout = global.setTimeout; + originalClearTimeout = global.clearTimeout; + + // Intercept setInterval to capture the 30s polling callback + jest.spyOn(global, 'setInterval').mockImplementation(((callback: () => void, ms?: number) => { + if (ms === POLL_INTERVAL_MS) { + const id = originalSetInterval(() => {}, 999999); + pollCallback = callback; + pollIntervalId = id; + return id; + } + return originalSetInterval(callback, ms); + }) as typeof setInterval); + + jest.spyOn(global, 'clearInterval').mockImplementation((id) => { + if (id !== undefined && id !== null && id === pollIntervalId) { + pollIntervalId = null; + pollCallback = null; + originalClearInterval(id); + return; + } + originalClearInterval(id); + }); + + // Intercept setTimeout to capture the 120s safety callback + jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void, ms?: number) => { + if (ms === MAX_POLL_DURATION_MS) { + const id = originalSetTimeout(() => {}, 0); + safetyCallback = callback; + safetyTimerId = id; + return id; + } + return originalSetTimeout(callback, ms); + }) as typeof setTimeout); + + jest.spyOn(global, 'clearTimeout').mockImplementation((id) => { + if (id !== undefined && id !== null && id === safetyTimerId) { + safetyTimerId = null; + safetyCallback = null; + return; + } + originalClearTimeout(id); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should poll every 30s and auto-clear after 120s safety timeout', async () => { + // Given a Concierge chat where the server sets a processing indicator + const isConciergeChat = true; + const serverLabel = 'Concierge is looking up categories...'; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: serverLabel, + }); + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + + // Verify processing is active and polling was started + expect(result.current.isProcessing).toBe(true); + expect(result.current.statusLabel).toBe(serverLabel); + expect(pollCallback).not.toBeNull(); + expect(safetyCallback).not.toBeNull(); + + // When the poll fires at 30s — fetches newer actions, indicator stays + act(() => { + pollCallback?.(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + + // When another poll fires at 60s — fetches newer actions, indicator stays + act(() => { + pollCallback?.(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + + // When the safety timeout fires at 120s — should hard-clear the indicator + act(() => { + safetyCallback?.(); + }); + await waitForBatchedUpdates(); + + // Then the indicator should auto-clear + await waitFor(() => { + expect(result.current.isProcessing).toBe(false); + }); + expect(result.current.statusLabel).toBe(''); + }); + + it('should auto-clear optimistic indicator after safety timeout', async () => { + // Given a Concierge chat where the user triggered optimistic waiting + const isConciergeChat = true; + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + + // User sends message -> optimistic waiting state + act(() => { + result.current.kickoffWaitingIndicator(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + expect(result.current.statusLabel).toBe('Thinking...'); + expect(pollCallback).not.toBeNull(); + + // When a poll fires — fetches newer actions, indicator stays + act(() => { + pollCallback?.(); + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + + // When the safety timeout fires at 120s — should hard-clear + act(() => { + safetyCallback?.(); + }); + await waitForBatchedUpdates(); + + // Then the indicator should auto-clear + await waitFor(() => { + expect(result.current.isProcessing).toBe(false); + }); + expect(result.current.statusLabel).toBe(''); + }); + + it('should cancel polling when indicator clears normally', async () => { + // Given a Concierge chat with an active processing indicator + const isConciergeChat = true; + const serverLabel = 'Concierge is looking up categories...'; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: serverLabel, + }); + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + expect(pollCallback).not.toBeNull(); + + // When the server clears the indicator normally (before safety timeout) + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: '', + }); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(false); + + // Then polling should have been cancelled + expect(pollIntervalId).toBeNull(); + expect(safetyTimerId).toBeNull(); + }); + + it('should reset polling when a new server label arrives', async () => { + // Given a Concierge chat with an active processing indicator + const isConciergeChat = true; + const serverLabel1 = 'Concierge is looking up categories...'; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: serverLabel1, + }); + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + expect(pollCallback).not.toBeNull(); + + // When a new label arrives (still processing), polling should reset + const serverLabel2 = 'Concierge is preparing your response...'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: serverLabel2, + }); + await waitForBatchedUpdates(); + + // Then polling should still be active (was reset with new interval) + expect(pollCallback).not.toBeNull(); + expect(result.current.isProcessing).toBe(true); + }); + }); + + describe('reconnect reset', () => { + it('should keep indicator on network reconnect and restart polling', async () => { + // Given a Concierge chat with an active processing indicator + const isConciergeChat = true; + const serverLabel = 'Concierge is looking up categories...'; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + agentZeroProcessingRequestIndicator: serverLabel, + }); + + const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + await waitForBatchedUpdates(); + expect(result.current.isProcessing).toBe(true); + + // When the network goes offline + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true, networkStatus: 'offline'}); + await waitForBatchedUpdates(); + + // The indicator is hidden while offline (original design: !isOffline in isProcessing) + expect(result.current.isProcessing).toBe(false); + + // When the network reconnects + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false, networkStatus: 'online'}); + await waitForBatchedUpdates(); + + // The indicator reappears (server NVP still has processing state) + // onReconnect fetches newer actions and restarts polling, but does NOT clear the indicator + await waitFor(() => { + expect(result.current.isProcessing).toBe(true); + }); + expect(result.current.statusLabel).toBe(serverLabel); + }); + }); + + describe('NVPIndicatorVersionTracker removal', () => { + it('should NOT use NVPIndicatorVersionTracker (module should not exist)', () => { + // The NVPIndicatorVersionTracker module was removed as part of the TTL fix. + // The TTL (lease pattern) handles all failure modes that the version tracker + // was designed to handle (batching coalescing + missed CLEAR). + const trackerPath = path.resolve(__dirname, '../../src/libs/NVPIndicatorVersionTracker.ts'); + expect(fs.existsSync(trackerPath)).toBe(false); + }); + }); });