// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { MemorySection } from '../../src/components/MemorySection'; import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events'; import { I18nProvider } from '../../src/i18n'; const originalFetch = globalThis.fetch; const originalEventSource = globalThis.EventSource; const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; class StubEventSource { url: string; listeners = new Map void>>(); static instances: StubEventSource[] = []; constructor(url: string | URL) { this.url = String(url); StubEventSource.instances.push(this); } addEventListener(type: string, listener: (event: MessageEvent) => void) { const existing = this.listeners.get(type) ?? []; existing.push(listener); this.listeners.set(type, existing); } emit(type: string, data: unknown) { const event = { data: JSON.stringify(data) } as MessageEvent; for (const listener of this.listeners.get(type) ?? []) { listener(event); } } close() {} } function renderMemorySection(props: Partial> = {}) { return render( , ); } async function findMemoryIndexTextarea() { const indexDetails = (await screen.findByText('MEMORY.md (index)')) .closest('details') as HTMLElement; return within(indexDetails).getByRole('textbox') as HTMLTextAreaElement; } // The source sub-tabs (Work profile / Add manually / Import from apps) now live // behind a collapsed "Add or import" disclosure in the Memories tab. Open it // before reaching any source sub-tab. async function openAddMemories() { fireEvent.click( await screen.findByRole('button', { name: 'Add or import memories' }), ); } // The manual create/edit form, its "New memory" button, and the save/delete // flash toast now render inside the "Add manually" source sub-tab within the // add disclosure. Open the disclosure and activate that sub-tab before driving // the create/edit/delete/index flows that surface those controls. async function openManualMemoryTab() { await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Add manually' })); } async function openAdvancedMemoryModal() { fireEvent.click(await screen.findByRole('button', { name: 'Advanced' })); } // The top-level "Memories" / "How it works" tabs render their label plus a // caption, so their accessible name is composite. Match by the leading label. const howItWorksTopTab = { name: /^How it works/ } as const; describe('MemorySection', () => { afterEach(() => { cleanup(); globalThis.fetch = originalFetch; StubEventSource.instances = []; if (originalEventSource) { globalThis.EventSource = originalEventSource; } else { // @ts-expect-error jsdom shim cleanup delete globalThis.EventSource; } vi.restoreAllMocks(); if (originalScrollIntoView) { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } else { delete (HTMLElement.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView; } window.sessionStorage.clear(); }); it('shows the no-provider banner when the latest extraction skipped for missing credentials', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [ { id: 'ex-1', phase: 'skipped', reason: 'no-provider', kind: 'llm', startedAt: Date.now(), userMessagePreview: 'Remember my UI preferences', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('LLM memory extraction is not running')).toBeTruthy(); expect( screen.getByText(/No API key found for the memory extractor/i), ).toBeTruthy(); }); it('creates a new memory entry and refreshes the list', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entries = [] as Array<{ id: string; name: string; description: string; type: string; updatedAt: number; }>; const createBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory' && init?.method === 'POST') { const body = JSON.parse(String(init.body)); createBodies.push(body); entries = [ { id: 'user_ui_preferences', name: body.name, description: body.description, type: body.type, updatedAt: Date.now(), }, ]; return new Response(JSON.stringify({ entry: { id: 'user_ui_preferences', name: body.name, description: body.description, type: body.type, body: body.body, updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openManualMemoryTab(); fireEvent.click(await screen.findByRole('button', { name: 'New memory' })); fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), { target: { value: 'UI preferences' }, }); fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), { target: { value: 'Persistent UI rendering preferences' }, }); fireEvent.change( screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/), { target: { value: '- Prefer dark mode\n- Prefer generous spacing' }, }, ); fireEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => { expect(screen.getByText('UI preferences')).toBeTruthy(); }); expect(createBodies).toEqual([ { name: 'UI preferences', description: 'Persistent UI rendering preferences', type: 'user', body: '- Prefer dark mode\n- Prefer generous spacing', }, ]); }); it('renders the memory tree and opens an entry editor from a tree node', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const entry = { id: 'project_design_agent_goal', name: 'Design agent goal', description: 'Open Design should evolve from accepted work', type: 'project', body: '- Keep design-system extraction in the loop', updatedAt: Date.now(), }; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [entry], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/tree') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', tree: [ { id: 'folder:project', parentId: null, path: '/project', name: 'Project', kind: 'folder', type: 'project', scope: 'project', sourcePacketIds: [], proposalIds: [], createdAt: new Date(entry.updatedAt).toISOString(), updatedAt: new Date(entry.updatedAt).toISOString(), childrenCount: 1, }, { id: entry.id, parentId: 'folder:project', path: `/project/${entry.id}`, name: entry.name, description: entry.description, kind: 'entry', type: 'project', scope: 'project', sourcePacketIds: [], proposalIds: [], createdAt: new Date(entry.updatedAt).toISOString(), updatedAt: new Date(entry.updatedAt).toISOString(), childrenCount: 0, }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === `/api/memory/${entry.id}`) { return new Response(JSON.stringify({ entry }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAdvancedMemoryModal(); const treeSummary = await screen.findByText('Memory tree'); const treeDetails = treeSummary.closest('details')!; expect(within(treeDetails).getByText('/project')).toBeTruthy(); expect(within(treeDetails).getByText('project_design_agent_goal')).toBeTruthy(); fireEvent.click(within(treeDetails).getByTitle('Edit')); await waitFor(() => { expect((screen.getByPlaceholderText('e.g. UI preferences') as HTMLInputElement).value).toBe( 'Design agent goal', ); }); expect(screen.getByDisplayValue('- Keep design-system extraction in the loop')).toBeTruthy(); }); it('anchors memory tree entry edit controls in the right-side action zone', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const entry = { id: 'project_design_agent_goal', name: 'Design agent goal', description: 'Open Design should evolve from accepted work', type: 'project', body: '- Keep design-system extraction in the loop', updatedAt: Date.now(), }; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [entry], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/tree') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', tree: [ { id: 'folder:project', parentId: null, path: '/project', name: 'Project', kind: 'folder', type: 'project', scope: 'project', sourcePacketIds: [], proposalIds: [], createdAt: new Date(entry.updatedAt).toISOString(), updatedAt: new Date(entry.updatedAt).toISOString(), childrenCount: 1, }, { id: entry.id, parentId: 'folder:project', path: `/project/${entry.id}`, name: entry.name, description: entry.description, kind: 'entry', type: 'project', scope: 'project', sourcePacketIds: [], proposalIds: [], createdAt: new Date(entry.updatedAt).toISOString(), updatedAt: new Date(entry.updatedAt).toISOString(), childrenCount: 0, }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAdvancedMemoryModal(); const treeDetails = (await screen.findByText('Memory tree')).closest('details')!; const childRow = within(treeDetails) .getByText('Design agent goal') .closest('.memory-tree-child-row') as HTMLElement; const editButton = within(childRow).getByTitle('Edit'); const actionZone = editButton.closest('.memory-card-actions'); const childContent = childRow.firstElementChild as HTMLElement; expect(actionZone).toBeTruthy(); expect(actionZone?.parentElement).toBe(childRow); expect(childContent.contains(editButton)).toBe(false); }); it('shows unsaved index state and saves the updated index', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let savedIndex = '# Memory\n\n- Existing bullet\n'; const putBodies: string[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: savedIndex, entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/index' && init?.method === 'PUT') { const body = JSON.parse(String(init.body)); putBodies.push(body.index); savedIndex = body.index; return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAdvancedMemoryModal(); fireEvent.click(await screen.findByText('MEMORY.md (index)')); const indexArea = await findMemoryIndexTextarea(); fireEvent.change(indexArea, { target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' }, }); expect(await screen.findByText(/Unsaved changes/i)).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Save index' })); await waitFor(() => { expect(putBodies).toEqual(['# Memory\n\n- Existing bullet\n- New bullet\n']); }); }); it('reveals the editor after editing an existing memory entry', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const scrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollIntoView as Element['scrollIntoView']; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [ { id: 'user_ui_preferences', name: 'UI preferences', description: 'Persistent UI rendering preferences', type: 'user', updatedAt: Date.now(), }, ], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/user_ui_preferences') { return new Response(JSON.stringify({ entry: { id: 'user_ui_preferences', name: 'UI preferences', description: 'Persistent UI rendering preferences', type: 'user', body: '- Prefer dark mode', updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); // The entry editor form renders inside the Add-manually sub-tab. await openManualMemoryTab(); const entryCard = (await screen.findByText('UI preferences')).closest('.library-card') as HTMLElement; fireEvent.click(within(entryCard).getByTitle('Edit')); const nameInput = await screen.findByDisplayValue('UI preferences'); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', behavior: 'smooth' }); expect(document.activeElement).toBe(nameInput); }); it('renders saved records in a separate memory records section', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [ { id: 'user_ui_preferences', name: 'UI preferences', description: 'Initial preference', type: 'user', updatedAt: Date.now(), }, { id: 'feedback_density', name: 'Feedback density', description: 'Prefer compact review cards', type: 'feedback', updatedAt: Date.now(), }, { id: 'project_brand_rule', name: 'Project brand rule', description: 'Use the project brand kit', type: 'project', updatedAt: Date.now(), }, ], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [ { id: 'ex-1', phase: 'success', kind: 'llm', startedAt: Date.now(), finishedAt: Date.now() + 1200, userMessagePreview: 'Remember I prefer dark mode', writtenCount: 1, }, ], }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); const savedRow = await screen.findByText('UI preferences'); const extractionRow = await screen.findByText('Remember I prefer dark mode'); await openAddMemories(); await openAdvancedMemoryModal(); const indexSummary = screen.getByText('MEMORY.md (index)') .closest('summary') as HTMLElement; expect(savedRow.closest('.library-card')).toBeTruthy(); expect(savedRow.closest('.memory-records-section')).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Add manually' }).closest('.memory-records-section')).toBeNull(); expect(extractionRow.closest('.library-card')?.className).toContain( 'memory-extraction-card', ); expect(indexSummary.closest('.memory-action-modal--advanced')).toBeTruthy(); expect(indexSummary.closest('.memory-advanced-card')).toBeTruthy(); expect(indexSummary.closest('.memory-records-section')).toBeNull(); expect(screen.queryByText('Extraction history')).toBeNull(); expect(indexSummary.className).toContain('memory-details-summary'); expect(document.querySelector('.memory-records-section .library-group-title')).toBeNull(); }); it('suggests and saves memory from selected connected apps', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entries: Array<{ id: string; name: string; description: string; type: string; updatedAt: number; }> = []; const authWindow = { document: { title: '', body: { innerHTML: '' }, }, location: { replace: vi.fn() }, close: vi.fn(), }; vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window); const suggestionBodies: unknown[] = []; const createBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'notion', name: 'Notion', provider: 'composio', category: 'Productivity', status: 'connected', accountLabel: 'Product wiki', tools: [{ name: 'notion.notion_search' }], }, { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') { return new Response(JSON.stringify({ results: { github: { status: 'ready', authConfigId: 'ac_github' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/github/connect' && init?.method === 'POST') { return new Response(JSON.stringify({ connector: { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/github-oauth', }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { github: { status: 'available' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') { suggestionBodies.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ suggestions: [ { id: 'project_memory_context_1', name: 'Memory context', description: 'Connector-derived context', type: 'project', body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.', source: { kind: 'connector', connectorId: 'notion', connectorName: 'Notion', accountLabel: 'Product wiki', toolName: 'notion.notion_search', toolTitle: 'Search Notion', }, }, ], attemptedLLM: true, contextBytes: 128, connectors: [ { connectorId: 'notion', connectorName: 'Notion', accountLabel: 'Product wiki', status: 'succeeded', toolName: 'notion.notion_search', toolTitle: 'Search Notion', summary: 'Found product memory notes.', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/project_memory_context_1' && init?.method === 'PUT') { const body = JSON.parse(String(init.body)); createBodies.push(body); entries = [ { id: 'project_memory_context_1', name: body.name, description: body.description, type: body.type, updatedAt: Date.now(), }, ]; return new Response(JSON.stringify({ entry: { id: 'project_memory_context_1', name: body.name, description: body.description, type: body.type, body: body.body, updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection({ chatAgentId: 'opencode', chatModel: 'openai/gpt-5', }); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); expect(await screen.findByText('Product wiki')).toBeTruthy(); const notionRow = document.querySelector('[data-memory-connector-id="notion"]') as HTMLElement; const githubRow = document.querySelector('[data-memory-connector-id="github"]') as HTMLElement; expect(within(notionRow).getByText('Select')).toBeTruthy(); expect(within(notionRow).queryByText('Connected')).toBeNull(); const connectGitHubButton = within(githubRow).getByRole('button', { name: 'Connect GitHub' }); expect(connectGitHubButton).toBeTruthy(); expect(within(githubRow).queryByText('Not connected')).toBeNull(); expect(screen.queryByText('Connect first')).toBeNull(); const extractButton = await screen.findByRole('button', { name: /Select apps to scan/i }); await waitFor(() => { expect((extractButton as HTMLButtonElement).disabled).toBe(true); }); expect(screen.getByText('Selected 0 of 1 connected app.')).toBeTruthy(); fireEvent.click(connectGitHubButton); await waitFor(() => { expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth'); }); expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy(); expect(within(githubRow).queryByText('Select')).toBeNull(); expect((within(githubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true); fireEvent.click(screen.getByLabelText('Use Notion for memory extraction')); await waitFor(() => { expect((extractButton as HTMLButtonElement).disabled).toBe(false); }); expect(screen.getByText('Selected')).toBeTruthy(); fireEvent.click(extractButton); await waitFor(() => { expect(screen.getByText(/Found 1 suggested memory from 1 app/)).toBeTruthy(); }); expect(screen.getByText('Last scan')).toBeTruthy(); expect(screen.getByText('128 B read')).toBeTruthy(); expect(screen.getByText('Read Notion')).toBeTruthy(); expect(screen.getByText(/Search Notion · Found product memory notes/)).toBeTruthy(); expect(screen.getByText('Suggested memories')).toBeTruthy(); expect(suggestionBodies).toEqual([{ connectorIds: ['notion'], chatAgentId: 'opencode', chatModel: 'openai/gpt-5', }]); expect(screen.getByText('Memory context')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: /Save selected/i })); await waitFor(() => { expect(screen.getByText(/Saved 1 memory from connected apps/)).toBeTruthy(); }); expect(createBodies).toEqual([ { id: 'project_memory_context_1', name: 'Memory context', description: 'Connector-derived context', type: 'project', body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.', }, ]); }); it('saves same-name connector suggestions as distinct memory records', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entries: Array<{ id: string; name: string; description: string; type: string; updatedAt: number; }> = []; const putBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'notion', name: 'Notion', provider: 'composio', category: 'Productivity', status: 'connected', accountLabel: 'Product wiki', tools: [{ name: 'notion.notion_search' }], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { notion: { status: 'connected', accountLabel: 'Product wiki' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') { return new Response(JSON.stringify({ suggestions: [ { id: 'project_comfyui_logo_1', name: 'ComfyUI logo', description: 'Use the black logo in product visuals.', type: 'project', body: 'ComfyUI design work should use the black logo.', }, { id: 'project_comfyui_logo_2', name: 'ComfyUI logo', description: 'Keep the visual style dark.', type: 'project', body: 'ComfyUI related layouts should prefer a dark visual style.', }, ], attemptedLLM: true, contextBytes: 256, connectors: [ { connectorId: 'notion', connectorName: 'Notion', accountLabel: 'Product wiki', status: 'succeeded', toolName: 'notion.notion_search', toolTitle: 'Search Notion', summary: 'Read page content: 设计思路.', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url.startsWith('/api/memory/project_comfyui_logo_') && init?.method === 'PUT') { const body = JSON.parse(String(init.body)); putBodies.push({ url, body }); entries = [ ...entries.filter((entry) => entry.id !== body.id), { id: body.id, name: body.name, description: body.description, type: body.type, updatedAt: Date.now(), }, ]; return new Response(JSON.stringify({ entry: { id: body.id, name: body.name, description: body.description, type: body.type, body: body.body, updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction')); fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i })); await waitFor(() => { expect(screen.getByText(/Found 2 suggested memories from 1 app/)).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: /Save selected/i })); await waitFor(() => { expect(screen.getByText(/Saved 2 memories from connected apps/)).toBeTruthy(); }); expect(putBodies).toEqual([ expect.objectContaining({ url: '/api/memory/project_comfyui_logo_1', body: expect.objectContaining({ id: 'project_comfyui_logo_1' }), }), expect.objectContaining({ url: '/api/memory/project_comfyui_logo_2', body: expect.objectContaining({ id: 'project_comfyui_logo_2' }), }), ]); expect(entries.map((entry) => entry.id).sort()).toEqual([ 'project_comfyui_logo_1', 'project_comfyui_logo_2', ]); }); it('keeps connector authorization pending after the memory panel remounts', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const authWindow = { document: { title: '', body: { innerHTML: '' }, }, location: { replace: vi.fn() }, close: vi.fn(), }; vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window); globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { github: { status: 'available' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') { return new Response(JSON.stringify({ results: { github: { status: 'ready', authConfigId: 'ac_github' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/github/connect' && init?.method === 'POST') { return new Response(JSON.stringify({ connector: { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/github-oauth', }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; const first = renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); const githubRow = await waitFor(() => { const row = document.querySelector('[data-memory-connector-id="github"]'); expect(row).toBeTruthy(); return row as HTMLElement; }); fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' })); await waitFor(() => { expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth'); }); expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy(); expect(within(githubRow).queryByText('Select')).toBeNull(); first.unmount(); renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); const remountedGithubRow = await waitFor(() => { const row = document.querySelector('[data-memory-connector-id="github"]'); expect(row).toBeTruthy(); return row as HTMLElement; }); expect(within(remountedGithubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy(); expect(within(remountedGithubRow).queryByText('Select')).toBeNull(); expect((within(remountedGithubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true); }); it('broadcasts connector changes when pending connector authorization completes', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let connected = false; const authWindow = { document: { title: '', body: { innerHTML: '' }, }, location: { replace: vi.fn() }, close: vi.fn(), }; vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window); const onConnectorsChanged = vi.fn(); window.addEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged); globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { github: connected ? { status: 'connected', accountLabel: 'octo@example.test' } : { status: 'available' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') { return new Response(JSON.stringify({ results: { github: { status: 'ready', authConfigId: 'ac_github' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/github/connect' && init?.method === 'POST') { return new Response(JSON.stringify({ connector: { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/github-oauth', }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; try { renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); const githubRow = await waitFor(() => { const row = document.querySelector('[data-memory-connector-id="github"]'); expect(row).toBeTruthy(); return row as HTMLElement; }); fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' })); await waitFor(() => { expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth'); }); expect(onConnectorsChanged).not.toHaveBeenCalled(); connected = true; window.dispatchEvent(new MessageEvent('message', { origin: window.location.origin, data: { type: 'open-design:connector-connected' }, })); await waitFor(() => expect(onConnectorsChanged).toHaveBeenCalledTimes(1)); await waitFor(() => expect(within(githubRow).getByText('octo@example.test')).toBeTruthy()); expect(within(githubRow).getByText('Select')).toBeTruthy(); } finally { window.removeEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged); } }); it('refreshes pending connector authorization when the window regains focus', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let connected = false; const authWindow = { document: { title: '', body: { innerHTML: '' }, }, location: { replace: vi.fn() }, close: vi.fn(), }; vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window); const onConnectorsChanged = vi.fn(); const suggestionBodies: unknown[] = []; window.addEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged); globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { github: connected ? { status: 'connected', accountLabel: 'External browser GitHub' } : { status: 'available' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') { return new Response(JSON.stringify({ results: { github: { status: 'ready', authConfigId: 'ac_github' }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/github/connect' && init?.method === 'POST') { return new Response(JSON.stringify({ connector: { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'available', tools: [], }, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/github-oauth', }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') { suggestionBodies.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ suggestions: [ { id: 'project_external_github_1', name: 'External GitHub context', description: 'Connector-derived context after focus refresh.', type: 'project', body: 'Remember that this project depends on GitHub context imported after browser authorization.', source: { kind: 'connector', connectorId: 'github', connectorName: 'GitHub', accountLabel: 'External browser GitHub', toolName: 'github.github_search', toolTitle: 'Search GitHub', }, }, ], attemptedLLM: true, contextBytes: 72, connectors: [ { connectorId: 'github', connectorName: 'GitHub', accountLabel: 'External browser GitHub', status: 'succeeded', toolName: 'github.github_search', toolTitle: 'Search GitHub', summary: 'Found repo context after OAuth.', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; try { renderMemorySection({ chatAgentId: 'codex', chatModel: 'default', }); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); const githubRow = await waitFor(() => { const row = document.querySelector('[data-memory-connector-id="github"]'); expect(row).toBeTruthy(); return row as HTMLElement; }); fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' })); await waitFor(() => { expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth'); }); expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy(); expect(within(githubRow).queryByText('Select')).toBeNull(); expect(onConnectorsChanged).not.toHaveBeenCalled(); connected = true; window.dispatchEvent(new Event('focus')); await waitFor(() => expect(onConnectorsChanged).toHaveBeenCalledTimes(1)); await waitFor(() => expect(within(githubRow).getByText('External browser GitHub')).toBeTruthy()); expect(within(githubRow).queryByText('Finish authorization in your browser, then return here')).toBeNull(); expect(within(githubRow).getByText('Select')).toBeTruthy(); fireEvent.click(screen.getByLabelText('Use GitHub for memory extraction')); const scanButton = await screen.findByRole('button', { name: /Scan selected apps/i }); await waitFor(() => { expect((scanButton as HTMLButtonElement).disabled).toBe(false); }); fireEvent.click(scanButton); await waitFor(() => { expect(screen.getByText(/Found 1 suggested memory from 1 app/)).toBeTruthy(); }); expect(screen.getByText('72 B read')).toBeTruthy(); expect(screen.getByText(/Search GitHub · Found repo context after OAuth/)).toBeTruthy(); expect(suggestionBodies).toEqual([{ connectorIds: ['github'], chatAgentId: 'codex', chatModel: 'default', }]); } finally { window.removeEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged); } }); it('shows reconnect guidance for memory connectors with stale authorization', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const lastError = 'GitHub authorization expired. Reconnect GitHub.'; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'github', name: 'GitHub', provider: 'composio', category: 'Developer', status: 'error', lastError, tools: [], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/connectors/status') { return new Response(JSON.stringify({ statuses: { github: { status: 'error', lastError }, }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); const githubRow = await waitFor(() => { const row = document.querySelector('[data-memory-connector-id="github"]'); expect(row).toBeTruthy(); return row as HTMLElement; }); expect(within(githubRow).getByText(lastError)).toBeTruthy(); expect(within(githubRow).getByRole('button', { name: 'Reconnect GitHub' })).toBeTruthy(); expect(within(githubRow).queryByText('Select')).toBeNull(); }); it('shows connector read failures instead of a generic empty state', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/connectors/discovery?hydrateTools=false') { return new Response(JSON.stringify({ connectors: [ { id: 'notion', name: 'Notion', provider: 'composio', category: 'Productivity', status: 'connected', accountLabel: 'Product wiki', tools: [{ name: 'notion.notion_search' }], }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') { return new Response(JSON.stringify({ suggestions: [], attemptedLLM: false, contextBytes: 0, connectors: [ { connectorId: 'notion', connectorName: 'Notion', accountLabel: 'Product wiki', status: 'failed', summary: 'No safe connector read completed.', error: 'Tool NOTION_SEARCH not found', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAddMemories(); fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' })); fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction')); fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i })); const alert = await screen.findByRole('alert'); expect(alert.textContent).toContain("Couldn't read Notion."); expect(alert.textContent).toContain('Tool NOTION_SEARCH not found'); expect(screen.getByText('Last scan')).toBeTruthy(); expect(screen.getByText('No data read')).toBeTruthy(); expect(screen.getByText('Could not read Notion')).toBeTruthy(); expect(screen.getAllByText(/Tool NOTION_SEARCH not found/).length).toBeGreaterThan(0); expect( screen.queryByText(/could not read useful content from the selected app/i), ).toBeNull(); }); it('clears extraction history after clicking Clear', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const deletedUrls: string[] = []; const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ extractions: [ { id: 'ex-1', phase: 'success', kind: 'llm', startedAt: Date.now(), finishedAt: Date.now() + 1200, userMessagePreview: 'Remember I prefer dark mode', proposedCount: 1, writtenCount: 1, }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions' && init?.method === 'DELETE') { deletedUrls.push(url); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Clear' })); await waitFor(() => { expect(screen.queryByText('Remember I prefer dark mode')).toBeNull(); }); expect(deletedUrls).toEqual(['/api/memory/extractions']); expect(confirmSpy).toHaveBeenCalledTimes(1); confirmSpy.mockRestore(); }); it('does not clear extraction history when Clear is cancelled', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const deletedUrls: string[] = []; const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ extractions: [ { id: 'ex-1', phase: 'success', kind: 'llm', startedAt: Date.now(), finishedAt: Date.now() + 1200, userMessagePreview: 'Remember I prefer dark mode', proposedCount: 1, writtenCount: 1, }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions' && init?.method === 'DELETE') { deletedUrls.push(url); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Clear' })); expect(deletedUrls).toEqual([]); expect(screen.getByText('Remember I prefer dark mode')).toBeTruthy(); expect(confirmSpy).toHaveBeenCalledTimes(1); confirmSpy.mockRestore(); }); it('loads preview, edits an entry, and refreshes the saved content', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entryBody = '- Prefer compact cards'; let entryDescription = 'Initial preference'; const putBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [ { id: 'user_ui_preferences', name: 'UI preferences', description: entryDescription, type: 'user', updatedAt: Date.now(), }, ], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/user_ui_preferences' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ entry: { id: 'user_ui_preferences', name: 'UI preferences', description: entryDescription, type: 'user', body: entryBody, updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/user_ui_preferences' && init?.method === 'PUT') { const body = JSON.parse(String(init.body)); putBodies.push(body); entryDescription = body.description; entryBody = body.body; return new Response(JSON.stringify({ entry: { id: 'user_ui_preferences', name: body.name, description: body.description, type: body.type, body: body.body, updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); // The entry editor form and "✓ Memory saved" flash render inside the // Add-manually sub-tab; the card Preview/Edit controls stay in the records // list either way. await openManualMemoryTab(); const card = await screen.findByText('UI preferences'); const row = card.closest('.library-card') as HTMLElement; fireEvent.click(within(row).getByTitle('Preview')); expect(await screen.findByText('Prefer compact cards')).toBeTruthy(); fireEvent.click(within(row).getByTitle('Edit')); fireEvent.change(await screen.findByDisplayValue('Initial preference'), { target: { value: 'Updated preference' }, }); fireEvent.change( await screen.findByDisplayValue('- Prefer compact cards'), { target: { value: '- Prefer spacious layouts' } }, ); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { expect(screen.getByText('Updated preference')).toBeTruthy(); }); expect(putBodies).toEqual([ { id: 'user_ui_preferences', name: 'UI preferences', description: 'Updated preference', type: 'user', body: '- Prefer spacious layouts', }, ]); }); it('keeps the expanded preview control visually distinct from delete', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [ { id: 'project_scope', name: 'Project scope', description: 'Current project constraints', type: 'project', updatedAt: Date.now(), }, ], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/project_scope' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ entry: { id: 'project_scope', name: 'Project scope', description: 'Current project constraints', type: 'project', body: '- Keep memory actions clear', updatedAt: Date.now(), }, }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); const card = (await screen.findByText('Project scope')).closest('.library-card') as HTMLElement; const previewButton = within(card).getByTitle('Preview'); const deleteButton = within(card).getByTitle('Delete'); fireEvent.click(previewButton); await screen.findByText('Keep memory actions clear'); const previewIcon = previewButton.querySelector('svg'); const deleteIcon = deleteButton.querySelector('svg'); const previewPaths = Array.from( previewIcon?.querySelectorAll('path') ?? [], ).map((path) => path.getAttribute('d')); const deletePaths = Array.from( deleteIcon?.querySelectorAll('path') ?? [], ).map((path) => path.getAttribute('d')); expect(previewIcon).toBeTruthy(); expect(deleteIcon).toBeTruthy(); expect(previewPaths).not.toEqual(deletePaths); }); it('deletes an existing memory entry from the list', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entries = [ { id: 'user_ui_preferences', name: 'UI preferences', description: 'Persistent UI rendering preferences', type: 'user', updatedAt: Date.now(), }, ]; const deletedUrls: string[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/user_ui_preferences' && init?.method === 'DELETE') { deletedUrls.push(url); entries = []; return new Response(null, { status: 204 }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); // The "✓ Memory deleted" flash renders inside the Add-manually sub-tab; the // card Delete control stays in the records list either way. await openManualMemoryTab(); const card = (await screen.findByText('UI preferences')).closest('.library-card') as HTMLElement; fireEvent.click(within(card).getByTitle('Delete')); await waitFor(() => { expect(screen.getByText('✓ Memory deleted')).toBeTruthy(); expect(screen.getByText(/No memory yet\./)).toBeTruthy(); }); expect(deletedUrls).toEqual(['/api/memory/user_ui_preferences']); }); it('keeps the editor open when saving a memory entry fails', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory' && init?.method === 'POST') { return new Response(JSON.stringify({ error: 'write failed' }), { status: 500, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openManualMemoryTab(); fireEvent.click(await screen.findByRole('button', { name: 'New memory' })); fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), { target: { value: 'UI preferences' }, }); fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), { target: { value: 'Persistent UI rendering preferences' }, }); fireEvent.change( screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/), { target: { value: '- Prefer dark mode\n- Prefer generous spacing' }, }, ); fireEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => { expect(screen.getByDisplayValue('UI preferences')).toBeTruthy(); }); expect(screen.queryByText('✓ Memory created')).toBeNull(); expect(screen.queryByText('UI preferences')).toBeTruthy(); }); it('keeps unsaved index edits when saving the index fails', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n\n- Existing bullet\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/index' && init?.method === 'PUT') { return new Response(JSON.stringify({ error: 'disk full' }), { status: 500, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAdvancedMemoryModal(); fireEvent.click(await screen.findByText('MEMORY.md (index)')); const indexArea = await findMemoryIndexTextarea(); fireEvent.change(indexArea, { target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' }, }); fireEvent.click(screen.getByRole('button', { name: 'Save index' })); await waitFor(() => { expect(screen.getByText(/Unsaved changes/i)).toBeTruthy(); }); expect(indexArea.value).toContain('- New bullet'); expect(screen.queryByText('✓ Index saved')).toBeNull(); }); it('deletes a single extraction row without clearing the whole history', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const deletedUrls: string[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ extractions: [ { id: 'ex-1', phase: 'success', kind: 'llm', startedAt: Date.now(), userMessagePreview: 'Remember I prefer dark mode', writtenCount: 1, }, { id: 'ex-2', phase: 'skipped', reason: 'no-match', kind: 'heuristic', startedAt: Date.now() - 1000, userMessagePreview: 'No durable memory in this turn', }, ], }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions/ex-1' && init?.method === 'DELETE') { deletedUrls.push(url); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy(); expect(screen.getByText('No durable memory in this turn')).toBeTruthy(); const row = screen.getByText('Remember I prefer dark mode') .closest('.library-card') as HTMLElement; fireEvent.click(within(row).getByRole('button', { name: 'Delete' })); await waitFor(() => { expect(screen.queryByText('Remember I prefer dark mode')).toBeNull(); }); expect(screen.getByText('No durable memory in this turn')).toBeTruthy(); expect(deletedUrls).toEqual(['/api/memory/extractions/ex-1']); }); it('applies extraction and change SSE events to the visible lists', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; let entries = [ { id: 'user_ui_preferences', name: 'UI preferences', description: 'Initial preference', type: 'user', updatedAt: Date.now(), }, ]; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('UI preferences')).toBeTruthy(); expect(screen.queryByText('Remember I prefer dark mode')).toBeNull(); const es = StubEventSource.instances[0]!; es.emit('extraction', { id: 'ex-1', phase: 'running', kind: 'llm', startedAt: Date.now(), userMessagePreview: 'Remember I prefer dark mode', }); await waitFor(() => { expect(screen.getByText('Remember I prefer dark mode')).toBeTruthy(); expect(screen.getAllByText('Running…').length).toBeGreaterThan(0); }); entries = [ ...entries, { id: 'project_brief', name: 'Project brief', description: 'Pinned project context', type: 'project', updatedAt: Date.now(), }, ]; es.emit('change', { kind: 'upsert', id: 'project_brief' }); await openAddMemories(); fireEvent.click(screen.getByRole('tab', { name: 'Add manually' })); await waitFor(() => { expect(screen.getByText('Project brief')).toBeTruthy(); }); }); it('renders failed extraction rows with user-facing error details', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [ { id: 'ex-failed', phase: 'failed', kind: 'llm', startedAt: Date.now(), finishedAt: Date.now() + 2500, userMessagePreview: 'Remember my dashboard preference', error: 'provider returned 429 quota exceeded', }, ], }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); expect(await screen.findByText('Remember my dashboard preference')).toBeTruthy(); expect(screen.getByText('Memory model quota or rate limit hit')).toBeTruthy(); expect(screen.getByText('Try again later or switch the Memory extraction model.')).toBeTruthy(); expect(screen.queryByText('provider returned 429 quota exceeded')).toBeNull(); expect(screen.getByText('Failed')).toBeTruthy(); }); it('renders connector model authentication failures without raw provider JSON', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [ { id: 'ex-connector-failed', phase: 'failed', kind: 'connector', provider: { kind: 'openai', model: 'gpt-4o-mini', credentialSource: 'memory-config', }, startedAt: Date.now(), finishedAt: Date.now() + 1300, userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.', error: 'openai 401: { "error": { "message": "Your authentication token has expired. Please try signing in again.", "type": "invalid_request_error", "code": "token_expired", "param": null }, "status": 401 }', }, ], }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAddMemories(); fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' })); expect(await screen.findByText('Connected app scan failed')).toBeTruthy(); expect( within(screen.getByLabelText('Connected app memory run status')) .getByText('Connected app scan failed'), ).toBeTruthy(); expect( screen.queryByText('Suggest durable OpenDesign memories from connected apps.'), ).toBeNull(); expect( within(document.querySelector('.memory-unified-list') as HTMLElement) .queryByText('Suggest durable OpenDesign memories from connected apps.'), ).toBeNull(); expect(screen.getByText('OpenAI authentication expired')).toBeTruthy(); expect( screen.getByText('Connected apps were read, but OpenDesign could not turn that context into memory.'), ).toBeTruthy(); expect( screen.getByText('Update the Memory extraction model key or sign in again.'), ).toBeTruthy(); expect(screen.queryByText(/token_expired/)).toBeNull(); }); it('renders Local CLI extraction failures without API-key guidance', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [ { id: 'ex-cli-failed', phase: 'failed', kind: 'connector', provider: { kind: 'anthropic', model: 'default', credentialSource: 'chat-cli', }, startedAt: Date.now(), finishedAt: Date.now() + 900, userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.', error: 'Claude Code CLI exit 1: authentication token has expired', }, ], }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); await openAddMemories(); fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' })); expect(await screen.findByText('Claude Code authentication expired')).toBeTruthy(); expect(screen.getByLabelText('Connected app memory run status')).toBeTruthy(); expect( screen.getByText('Sign in to the selected Local CLI or choose a different Memory model.'), ).toBeTruthy(); expect(screen.queryByText(/Update the Memory extraction model key/)).toBeNull(); }); it('renders the disabled banner when memory starts disabled', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); if (url === '/api/memory') { return new Response(JSON.stringify({ enabled: false, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); const banner = await screen.findByRole('status'); expect(banner.textContent).toContain('Memory is currently OFF.'); }); it('toggles memory injection off and persists the PATCH payload', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const patchBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/config' && init?.method === 'PATCH') { patchBodies.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ enabled: false, extraction: null }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); const toggle = await screen.findByRole('checkbox', { name: 'Enable memory injection' }) as HTMLInputElement; fireEvent.click(toggle); await waitFor(() => { expect(screen.getByRole('status').textContent).toContain('Memory is currently OFF.'); }); expect(patchBodies).toEqual([{ enabled: false }]); }); it('toggles chat conversation learning off and persists the PATCH payload', async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; const patchBodies: unknown[] = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/memory' && (!init || init.method === undefined)) { return new Response(JSON.stringify({ enabled: true, chatExtractionEnabled: true, rootDir: '/tmp/memory', index: '# Memory\n', entries: [], extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' } }); } if (url === '/api/memory/extractions') { return new Response(JSON.stringify({ extractions: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/memory/config' && init?.method === 'PATCH') { patchBodies.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ enabled: true, chatExtractionEnabled: false, extraction: null, }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return new Response(JSON.stringify({}), { status: 404 }); }) as typeof fetch; renderMemorySection(); fireEvent.click(await screen.findByRole('tab', howItWorksTopTab)); // The chat-extraction switch now lives in the pluggable-hooks panel under // the "How it works" top tab, named by the hook's label ("Learn from // chats"). Selected by role: checkbox. const toggle = screen.getByRole('checkbox', { name: 'Learn from chats', }) as HTMLInputElement; expect(toggle.checked).toBe(true); fireEvent.click(toggle); await waitFor(() => expect(toggle.checked).toBe(false)); expect(patchBodies).toEqual([{ chatExtractionEnabled: false }]); }); });