// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const api = vi.hoisted(() => ({ list: vi.fn(), listSummaries: vi.fn(), listReconciliation: vi.fn(), resolveReconciliation: vi.fn(), create: vi.fn(), update: vi.fn(), settings: vi.fn(), consolidate: vi.fn(), remove: vi.fn(), confirm: vi.fn(), pin: vi.fn(), restore: vi.fn(), })); const spaceStoreState = vi.hoisted(() => ({ activeSpaceId: 'space-1', spaces: { 'space-1': { id: 'space-1', name: 'Design Space', description: 'Product design work', sourceType: 'folder' as const, rootPath: '/work/design', status: 'active' as const, schemaVersion: 1, createdAt: 1, updatedAt: 1, }, }, projectsBySpaceId: { 'space-1': { 'project-1': { id: 'project-1', spaceId: 'space-1', name: 'Launch Plan', status: 'active' as const, createdAt: 1, updatedAt: 1, }, }, }, })); vi.mock('@/service/memoryApi', async (importOriginal) => ({ ...(await importOriginal()), listMemoryEntries: api.list, listMemoryScopeSummaries: api.listSummaries, listMemoryReconciliation: api.listReconciliation, resolveMemoryReconciliation: api.resolveReconciliation, createMemoryEntry: api.create, updateMemoryEntry: api.update, updateMemoryScopeSettings: api.settings, consolidateMemoryScope: api.consolidate, archiveMemoryEntry: api.remove, confirmMemoryEntry: api.confirm, pinMemoryEntry: api.pin, restoreMemoryEntry: api.restore, })); vi.mock('@/store/projectStore', () => ({ useProjectStore: (selector: (state: object) => unknown) => selector({ activeProjectId: 'project-1' }), })); vi.mock('@/store/spaceStore', async (importOriginal) => ({ ...(await importOriginal()), useSpaceStore: (selector: (state: object) => unknown) => selector(spaceStoreState), })); vi.mock('@/store/authStore', () => ({ getAuthStore: () => ({ user_id: 'user-1', token: null }), useAuthStore: (selector: (state: object) => unknown) => selector({ user_id: 'user-1' }), })); import Memory from '@/components/Settings/Memory'; function LocationProbe() { const location = useLocation(); return ( {location.pathname} {location.search} ); } const scopeState = { scope_type: 'user', scope_id: 'user-1', owner_kind: 'desktop', revision: 1, capture_enabled: true, use_enabled: true, sync_scope: 'full_memory', token_limit: 1024, current_token_count: 10, consolidate_threshold: 0.75, processed_through_watermark: 'sqlite-project-v1:4', extractor_version: 'memory-v2', last_consolidated_at: null, last_error: null, updated_at: 1, }; describe('Memory Center', () => { beforeEach(() => { vi.clearAllMocks(); api.list.mockResolvedValue({ scope_state: scopeState, items: [], sync_status: { state: 'synced', pending_count: 0, blocked_count: 0, last_error: null, last_synced_at: 1, }, }); api.create.mockResolvedValue({}); api.listSummaries.mockImplementation( (scopes: Array<{ scopeType: 'space' | 'project'; scopeId: string }>) => Promise.resolve({ items: scopes.map((scope) => ({ scope_type: scope.scopeType, scope_id: scope.scopeId, entry_count: 1, scope_state: { ...scopeState, scope_type: scope.scopeType, scope_id: scope.scopeId, }, })), }) ); api.settings.mockResolvedValue({ ...scopeState, revision: 2, capture_enabled: false, }); api.consolidate.mockResolvedValue({}); api.remove.mockResolvedValue({}); api.listReconciliation.mockResolvedValue({ items: [] }); api.resolveReconciliation.mockResolvedValue({}); }); it('offers bounded organization without exposing a sync chooser', async () => { api.list.mockResolvedValue({ scope_state: scopeState, items: [ { memory_id: 'one', deleted_at: null }, { memory_id: 'two', deleted_at: null }, ], sync_status: { state: 'synced' }, }); const user = userEvent.setup(); render(); await screen.findByText(/Up to date on your Eigent account/); expect(screen.queryByRole('combobox', { name: /sync/i })).toBeNull(); const organize = screen.getByRole('button', { name: /Organise/ }); await waitFor(() => expect(organize).toBeEnabled()); await user.click(organize); expect(api.consolidate).toHaveBeenCalledWith('user', 'user-1'); }); it('ignores a stale scope response after the user switches scope', async () => { let resolveUser!: (value: unknown) => void; api.list.mockImplementation((scopeType: string) => { if (scopeType === 'user') { return new Promise((resolve) => { resolveUser = resolve; }); } return Promise.resolve({ scope_state: { ...scopeState, scope_type: 'space', scope_id: 'space-1', }, items: [ { memory_id: 'space-memory', scope_type: 'space', scope_id: 'space-1', kind: 'fact', content: 'Space response', priority: 'normal', version: 1, token_count: 2, pinned_by_user: false, confirmed_by_user: true, created_by: 'user', source_trust: 'user_confirmed', sensitivity: 'normal', source_refs: [], deleted_at: null, created_at: 1, updated_at: 1, }, ], }); }); const user = userEvent.setup(); render(); await user.click(screen.getByRole('tab', { name: 'Space' })); expect(await screen.findByText('Space response')).toBeInTheDocument(); resolveUser({ scope_state: scopeState, items: [{ content: 'Stale user response' }], }); await waitFor(() => expect(screen.queryByText('Stale user response')).toBeNull() ); }); it('explains the History boundary and creates editable Memory', async () => { const user = userEvent.setup(); render(); expect( screen.getByText(/Canonical task history is stored separately/) ).toBeInTheDocument(); await waitFor(() => expect(api.list).toHaveBeenCalledWith('user', 'user-1', false) ); await user.type( screen.getByPlaceholderText( 'Add a preference, constraint, or fact for Eigent to remember' ), 'Use ISO dates.' ); await user.click(screen.getByRole('button', { name: /Add/ })); await waitFor(() => expect(api.create).toHaveBeenCalledWith( 'user', 'user-1', expect.objectContaining({ content: 'Use ISO dates.' }) ) ); }); it('uses scope pills, one-row controls, and saved Memory tools', async () => { api.list.mockResolvedValue({ scope_state: scopeState, items: [ { memory_id: 'memory-visible', scope_type: 'user', scope_id: 'user-1', kind: 'fact', content: 'Visible note', priority: 'normal', version: 1, token_count: 2, pinned_by_user: false, confirmed_by_user: true, created_by: 'user', source_trust: 'user_confirmed', sensitivity: 'normal', source_refs: [], deleted_at: null, created_at: 1, updated_at: 1, }, ], sync_status: { state: 'synced' }, }); render(); await screen.findByText('Synced'); expect( screen.getByRole('tablist', { name: 'Memory scope' }) ).toBeInTheDocument(); expect(screen.getAllByRole('tab')).toHaveLength(3); expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ 'User', 'Space', 'Project', ]); expect(screen.getByRole('tab', { name: 'User' })).toHaveAttribute( 'data-state', 'active' ); expect(screen.getByRole('switch', { name: 'Auto Memory' })).toBeChecked(); expect( screen.getByRole('switch', { name: 'Use Personal Memory' }) ).toBeInTheDocument(); expect(screen.getByRole('status', { name: '' })).toHaveTextContent( 'Synced' ); const storageProgress = screen.getByRole('progressbar', { name: 'Memory storage used', }); expect(storageProgress).toHaveClass('bg-ds-bg-neutral-subtle-default'); expect(storageProgress.firstElementChild).toHaveClass( 'bg-ds-bg-brand-default-default' ); expect( screen.getByRole('textbox', { name: 'New Memory' }) ).toBeInTheDocument(); expect( screen.getByRole('combobox', { name: 'Memory type' }) ).toBeInTheDocument(); expect( screen.getByRole('combobox', { name: 'Order Memory' }) ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Show archived' }) ).toHaveAttribute('aria-pressed', 'false'); }); it('shows searchable Space and Project Memory directories', async () => { const user = userEvent.setup(); render( ); expect(screen.getByRole('tab', { name: 'User' })).toHaveAttribute( 'data-state', 'active' ); expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ 'User', 'Space', 'Project', ]); await waitFor(() => expect(api.list).toHaveBeenCalledWith('user', 'user-1', false) ); const listCallCount = api.list.mock.calls.length; await user.click(screen.getByRole('tab', { name: 'Space' })); expect(await screen.findByText('Design Space')).toBeVisible(); expect(screen.getByText('1 Project')).toBeVisible(); expect( screen.queryByRole('textbox', { name: 'New Memory' }) ).not.toBeInTheDocument(); expect(api.list).toHaveBeenCalledTimes(listCallCount); await user.click( screen.getByRole('button', { name: 'Manage Memory for Design Space' }) ); expect(screen.getByTestId('memory-location')).toHaveTextContent( '/home?section=spaces&spaceId=space-1&spaceTab=memory' ); await user.click(screen.getByRole('tab', { name: 'Project' })); expect(await screen.findByText('Launch Plan')).toBeVisible(); expect(screen.getByText('Space: Design Space')).toBeVisible(); expect(api.list).toHaveBeenCalledTimes(listCallCount); await user.click( screen.getByRole('button', { name: 'Manage Memory for Launch Plan' }) ); expect(screen.getByTestId('memory-location')).toHaveTextContent( '/home?section=spaces&spaceId=space-1&spaceTab=memory&memoryScope=project&projectId=project-1' ); }); it('locks the Space detail Memory view to the selected Space', async () => { render( ); await waitFor(() => expect(api.list).toHaveBeenCalledWith('space', 'selected-space', false) ); expect( screen.queryByRole('tablist', { name: 'Memory scope' }) ).not.toBeInTheDocument(); }); it('updates Auto Memory immediately', async () => { const user = userEvent.setup(); render(); await screen.findByText('Synced'); const autoMemory = await screen.findByRole('switch', { name: 'Auto Memory', }); expect(autoMemory).toBeEnabled(); expect(autoMemory).toBeChecked(); await user.click(autoMemory); expect(autoMemory).not.toBeChecked(); expect(api.settings).toHaveBeenCalledWith('user', 'user-1', { expectedRevision: 1, captureEnabled: false, }); }); it('shows archived entries only on request and restores them', async () => { const archived = { memory_id: 'memory-1', scope_type: 'user', scope_id: 'user-1', kind: 'fact', content: 'Old fact', priority: 'normal', version: 2, token_count: 2, pinned_by_user: false, confirmed_by_user: false, created_by: 'user', source_trust: 'user_confirmed', sensitivity: 'normal', source_refs: [], deleted_at: 2, created_at: 1, updated_at: 2, }; api.list.mockImplementation( (_scopeType: string, _scopeId: string, includeDeleted: boolean) => Promise.resolve({ scope_state: scopeState, items: includeDeleted ? [archived] : [], }) ); api.restore.mockResolvedValue({}); const user = userEvent.setup(); render(); await user.click( await screen.findByRole('button', { name: 'View archived' }) ); expect( screen.getByRole('button', { name: 'Hide archived' }) ).toBeInTheDocument(); expect(await screen.findByText('Old fact')).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'More Memory actions' }) ); await user.click( await screen.findByRole('menuitem', { name: 'Restore Memory' }) ); expect(api.restore).toHaveBeenCalledWith(archived); }); it('stars Memory from item actions and shows starred state as a non-control', async () => { const pinned = { memory_id: 'memory-pinned', scope_type: 'user', scope_id: 'user-1', kind: 'fact', content: 'Pinned first', priority: 'normal', version: 2, token_count: 2, pinned_by_user: true, confirmed_by_user: true, created_by: 'user', source_trust: 'user_confirmed', sensitivity: 'normal', source_refs: [], deleted_at: null, created_at: 1, updated_at: 1, }; const unpinned = { ...pinned, memory_id: 'memory-unpinned', content: 'Unpinned second', pinned_by_user: false, created_at: 2, updated_at: 2, }; api.list.mockResolvedValue({ scope_state: scopeState, items: [unpinned, pinned], sync_status: { state: 'synced' }, }); api.pin.mockResolvedValue({}); const user = userEvent.setup(); render(); expect(await screen.findByText('Pinned first')).toBeInTheDocument(); const memoryItems = screen .getByText('Pinned first') .closest('[data-settings-row]') ?.querySelectorAll('article'); expect(memoryItems?.[0]).toHaveTextContent('Pinned first'); // Starring is one-way until the backend gains an un-pin transition, so a // starred entry renders the filled star as state rather than a control. const starredIndicator = screen.getByRole('img', { name: 'Starred Memory', }); expect(starredIndicator.querySelector('svg')).toHaveClass('fill-current'); expect( screen.queryByRole('button', { name: 'Unstar Memory' }) ).not.toBeInTheDocument(); const starButton = screen.getByRole('button', { name: 'Star Memory' }); expect(starButton.querySelector('svg')).toHaveClass('fill-none'); await user.click(starButton); expect(api.pin).toHaveBeenCalledWith(unpinned); // Permanent delete needs a backend endpoint this branch does not ship. await user.click( screen.getAllByRole('button', { name: 'More Memory actions' })[0] ); expect( await screen.findByRole('menuitem', { name: 'Archive Memory' }) ).toBeInTheDocument(); expect( screen.queryByRole('menuitem', { name: 'Delete Memory' }) ).not.toBeInTheDocument(); }); });