feat(agent-memory): UI fuer Agent-Memories mit semantischer Suche — Modul 8/16
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:
Agent Zero
2026-09-13 19:45:18 +02:00
parent e7b746809b
commit 24423b6802
8 changed files with 898 additions and 4 deletions
@@ -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();
});
});