feat(agent-memory): UI fuer Agent-Memories mit semantischer Suche — Modul 8/16
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Drittes Modul via Phase-Q-Manifest-Architektur: Registrierung komplett ueber das agent_memory-Plugin-Manifest (page_route /agent-memory + menu_item, Brain-Icon) — routes/index.tsx unangetastet. Komponenten-Map mit 40 Eintraegen. Zusaetzlicher Fix: Sidebar-ICON_MAP um Brain, Store, Tags erweitert — Marketplace (Store) und Tags zeigten bisher Fallback-Icons, weil die Manifest-Icons nicht in der kuratierten Map standen. Backend existierte vollstaendig (create mit Embedding, list mit agent_id-Pflichtfilter + Typ + Pagination, semantische Suche via pgvector, update mit Embedding-Regeneration, delete; agent_memory:read/write), Frontend hatte 0% Abdeckung. - api/agentMemory.ts: TanStack-Hooks (useAgentMemories mit enabled-Gating, useAgentMemorySearch, create/update/delete) - pages/AgentMemory.tsx: Agent-Picker (Pflichtfeld — Backend filtert zwingend nach agent_id), Pick-Agent-Prompt, semantische Suche mit Relevanz-Score-Badges und Clear, Memory-Karten mit Typ-Badges (fact/context/pattern/instruction), Create/Edit-Dialog, Delete mit Confirm — Aktionen hinter agent_memory:write - i18n agentMemory.* + nav.agentMemory de/en Verifikation: Vitest 11/11 (Agent-Picker-Pflicht, Pick-Prompt, Badges, Suche mit Score + Clear, Create/Edit prefilled, Delete mit+ohne Confirm, Gating, Empty/Error) · tsc exit 0 · production build exit 0 · Backend-Regressionen (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check OK.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* AgentMemory page tests — persistent agent memories (module 8/16).
|
||||
*
|
||||
* Covers: agent picker requirement, pick-agent prompt, memory cards with
|
||||
* type badges and search score, semantic search flow with clear, create
|
||||
* flow, edit flow prefilled, delete with confirmation, permission gating
|
||||
* (agent_memory:write), empty and error states.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AgentMemoryPage } from '@/pages/AgentMemory';
|
||||
import type { AgentMemory } from '@/api/agentMemory';
|
||||
import type { AgentDefinition } from '@/types/automation';
|
||||
|
||||
const createMut = vi.fn().mockImplementation((_payload, opts) => {
|
||||
opts?.onSuccess?.({});
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const updateMut = vi.fn().mockImplementation((_payload, opts) => {
|
||||
opts?.onSuccess?.({});
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const deleteMut = vi.fn().mockResolvedValue({});
|
||||
|
||||
const AGENT: AgentDefinition = {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
name: 'Test Agent',
|
||||
description: '',
|
||||
model: 'gpt-4o-mini',
|
||||
heartbeat_interval: 0,
|
||||
mode: 'reactive',
|
||||
max_executions_per_hour: 0,
|
||||
max_duration: 0,
|
||||
budget_limit: 0,
|
||||
active: true,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const makeMemory = (overrides: Partial<AgentMemory> = {}): AgentMemory => ({
|
||||
id: 'mem-1',
|
||||
agent_id: AGENT.id,
|
||||
memory_type: 'fact',
|
||||
content: 'Kunde bevorzugt E-Mail',
|
||||
owner_id: null,
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockItems: AgentMemory[] = [];
|
||||
let mockSearchItems: AgentMemory[] = [];
|
||||
let mockCanWrite = true;
|
||||
let mockError = false;
|
||||
|
||||
vi.mock('@/api/agentMemory', () => ({
|
||||
useAgentMemories: () => ({
|
||||
data: { items: mockItems, total: mockItems.length },
|
||||
isLoading: false,
|
||||
isError: mockError,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useAgentMemorySearch: () => ({
|
||||
data: { items: mockSearchItems, total: mockSearchItems.length },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useCreateAgentMemory: () => ({
|
||||
mutate: createMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateAgentMemory: () => ({
|
||||
mutate: updateMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteAgentMemory: () => ({
|
||||
mutate: deleteMut,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/automation', () => ({
|
||||
useAgents: () => ({
|
||||
data: [AGENT],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({
|
||||
hasPermission: (perm: string) =>
|
||||
mockCanWrite || perm !== 'agent_memory:write',
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<AgentMemoryPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function pickAgent() {
|
||||
const select = screen.getByRole('combobox');
|
||||
fireEvent.change(select, { target: { value: AGENT.id } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockItems = [];
|
||||
mockSearchItems = [];
|
||||
mockCanWrite = true;
|
||||
mockError = false;
|
||||
});
|
||||
|
||||
describe('AgentMemoryPage', () => {
|
||||
it('renders the page with title and agent picker', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('agent-memory-page')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the pick-agent prompt before an agent is selected', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('agent-memory-pick-agent')).toBeInTheDocument();
|
||||
// list is not loaded without an agent — no empty state yet
|
||||
expect(screen.queryByTestId('agent-memory-empty')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state after picking an agent with no memories', () => {
|
||||
renderPage();
|
||||
pickAgent();
|
||||
expect(screen.getByTestId('agent-memory-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state on load failure', () => {
|
||||
mockError = true;
|
||||
renderPage();
|
||||
pickAgent();
|
||||
expect(screen.getByTestId('agent-memory-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders memory cards with type badge', () => {
|
||||
mockItems = [makeMemory()];
|
||||
renderPage();
|
||||
pickAgent();
|
||||
expect(screen.getByTestId('memory-card-mem-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-type-mem-1')).toHaveTextContent('fact');
|
||||
expect(screen.getByTestId('memory-content-mem-1')).toHaveTextContent('Kunde bevorzugt E-Mail');
|
||||
});
|
||||
|
||||
it('hides create and action buttons without agent_memory:write', () => {
|
||||
mockItems = [makeMemory()];
|
||||
mockCanWrite = false;
|
||||
renderPage();
|
||||
pickAgent();
|
||||
expect(screen.queryByTestId('memory-create-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('memory-edit-mem-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('memory-delete-mem-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a memory via the dialog after picking an agent', async () => {
|
||||
renderPage();
|
||||
pickAgent();
|
||||
fireEvent.click(screen.getByTestId('memory-create-open'));
|
||||
expect(screen.getByTestId('memory-form-submit')).toBeInTheDocument();
|
||||
|
||||
const contentArea = screen.getByTestId('memory-content-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(contentArea, { target: { value: 'Neues Wissen' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-form-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMut).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent_id: AGENT.id,
|
||||
memory_type: 'fact',
|
||||
content: 'Neues Wissen',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the edit dialog prefilled and submits the update', async () => {
|
||||
mockItems = [makeMemory({ memory_type: 'instruction' })];
|
||||
renderPage();
|
||||
pickAgent();
|
||||
fireEvent.click(screen.getByTestId('memory-edit-mem-1'));
|
||||
|
||||
const contentArea = screen.getByTestId('memory-content-input') as HTMLTextAreaElement;
|
||||
expect(contentArea).toHaveValue('Kunde bevorzugt E-Mail');
|
||||
fireEvent.change(contentArea, { target: { value: 'Geaendertes Wissen' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-form-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMut).toHaveBeenCalledWith(
|
||||
{
|
||||
memoryId: 'mem-1',
|
||||
payload: expect.objectContaining({
|
||||
content: 'Geaendertes Wissen',
|
||||
memory_type: 'instruction',
|
||||
}),
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs a semantic search and shows scored results, then clears', async () => {
|
||||
mockItems = [makeMemory()];
|
||||
mockSearchItems = [makeMemory({ id: 'mem-2', score: 0.87 })];
|
||||
renderPage();
|
||||
pickAgent();
|
||||
|
||||
const searchInput = screen.getByTestId('memory-search');
|
||||
fireEvent.change(searchInput, { target: { value: 'E-Mail' } });
|
||||
fireEvent.click(screen.getByTestId('memory-search-btn'));
|
||||
|
||||
// Scored search result is rendered
|
||||
expect(await screen.findByTestId('memory-card-mem-2')).toBeInTheDocument();
|
||||
expect(screen.getByText(/87%/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-search-clear')).toBeInTheDocument();
|
||||
|
||||
// Clear returns to list mode
|
||||
fireEvent.click(screen.getByTestId('memory-search-clear'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('memory-card-mem-1')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('memory-card-mem-2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a memory after confirmation', async () => {
|
||||
mockItems = [makeMemory()];
|
||||
window.confirm = vi.fn(() => true);
|
||||
renderPage();
|
||||
pickAgent();
|
||||
fireEvent.click(screen.getByTestId('memory-delete-mem-1'));
|
||||
await waitFor(() => {
|
||||
expect(deleteMut).toHaveBeenCalledWith('mem-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when the confirmation is rejected', () => {
|
||||
mockItems = [makeMemory()];
|
||||
window.confirm = vi.fn(() => false);
|
||||
renderPage();
|
||||
pickAgent();
|
||||
fireEvent.click(screen.getByTestId('memory-delete-mem-1'));
|
||||
expect(deleteMut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user