diff --git a/app/src/hooks/useConsciousItems.test.tsx b/app/src/hooks/useConsciousItems.test.tsx new file mode 100644 index 000000000..3e5656811 --- /dev/null +++ b/app/src/hooks/useConsciousItems.test.tsx @@ -0,0 +1,80 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useConsciousItems } from './useConsciousItems'; + +const mockConsciousLoopRun = vi.fn(); +const mockMemoryQueryNamespace = vi.fn(); + +vi.mock('../utils/tauriCommands', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + isTauri: () => true, + consciousLoopRun: (...args: unknown[]) => mockConsciousLoopRun(...args), + memoryQueryNamespace: (...args: unknown[]) => mockMemoryQueryNamespace(...args), + }; +}); + +// `listen` resolves an unlisten fn but never fires events, so `isRunning` stays +// false — modelling the window before the backend `conscious_loop:started` +// event arrives, which is exactly where the duplicate-run race lived. +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); + +vi.mock('../lib/coreState/store', () => ({ + getCoreStateSnapshot: () => ({ snapshot: { sessionToken: 'test-token' } }), +})); + +vi.mock('../services/backendUrl', () => ({ + getBackendUrl: () => Promise.resolve('http://localhost:9999'), +})); + +describe('useConsciousItems · triggerAnalysis re-entry guard', () => { + beforeEach(() => { + mockConsciousLoopRun.mockReset(); + mockMemoryQueryNamespace.mockReset(); + mockMemoryQueryNamespace.mockResolvedValue({ text: '' }); + }); + + it('does not start a second run while the first is in flight (before isRunning flips)', async () => { + let resolveRun: () => void = () => {}; + mockConsciousLoopRun.mockImplementation( + () => + new Promise(resolve => { + resolveRun = resolve; + }) + ); + + const { result } = renderHook(() => useConsciousItems()); + + // Two synchronous triggers within the pre-`started`-event window: the + // backend has not yet flipped `isRunning`, so only the synchronous ref + // guard can stop the duplicate. + await act(async () => { + void result.current.triggerAnalysis(); + void result.current.triggerAnalysis(); + }); + + expect(mockConsciousLoopRun).toHaveBeenCalledTimes(1); + + // Settle the in-flight run so the ref releases for the next assertion. + await act(async () => { + resolveRun(); + }); + }); + + it('releases the guard after a run settles so a later run can start', async () => { + mockConsciousLoopRun.mockResolvedValue(undefined); + + const { result } = renderHook(() => useConsciousItems()); + + await act(async () => { + await result.current.triggerAnalysis(); + }); + await act(async () => { + await result.current.triggerAnalysis(); + }); + + expect(mockConsciousLoopRun).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/src/hooks/useConsciousItems.ts b/app/src/hooks/useConsciousItems.ts index 073b73095..ca841885b 100644 --- a/app/src/hooks/useConsciousItems.ts +++ b/app/src/hooks/useConsciousItems.ts @@ -149,6 +149,11 @@ export function useConsciousItems(): UseConsciousItemsResult { // Prevent double-fetch on StrictMode double-mount const fetchingRef = useRef(false); + // Synchronous guard for triggerAnalysis: `isRunning` only flips true once the + // backend round-trips the `conscious_loop:started` event, leaving a window + // where a second synchronous call would pass the `isRunning` guard and fire a + // duplicate run. This ref closes that window immediately (mirrors fetchingRef). + const runningRef = useRef(false); const refresh = useCallback(async () => { if (!isTauri() || fetchingRef.current) return; @@ -174,11 +179,14 @@ export function useConsciousItems(): UseConsciousItemsResult { const triggerAnalysis = useCallback(async () => { const authToken = getCoreStateSnapshot().snapshot.sessionToken; - if (!isTauri() || !authToken || isRunning) return; + if (!isTauri() || !authToken || isRunning || runningRef.current) return; + runningRef.current = true; try { await consciousLoopRun(authToken, await getBackendUrl()); } catch (err) { console.warn('[conscious] Failed to trigger analysis:', err); + } finally { + runningRef.current = false; } }, [isRunning]);