Phase 4: KI-UI-Steuerung — AI agent UI control via WebSocket

- New ai_ui_control plugin: WS endpoint /ws/ai-ui-control, REST API (POST /command, GET /command/{id}/status, GET /online-users)
- UI-Command-Protocol: 6 command types (navigate, filter, open_contact, modal, tab, settings) with Pydantic schemas
- WebSocket manager: per-user connections, command delivery, feedback storage, stale cleanup
- Frontend useAIUIControl hook: WS client with auto-reconnect, command dispatch, feedback sending
- aiUIControlStore: Zustand store for command state, active modal/tab, pending filter/settings
- AIUIControlIndicator: visual KI indication (Bot icon, toast, pulse animation)
- ContactDetail integration: syncs activeTab and personModalOpen from AI control store
- AppShell integration: useAIUIControl hook + AIUIControlIndicator
- i18n keys for DE/EN
- 18 Vitest tests: command protocol, store actions, feedback, visual indication
- TSC: 0 new errors (only 2 pre-existing Dms.tsx errors)
This commit is contained in:
Agent Zero
2026-07-23 20:13:39 +02:00
parent 5dc6f29ac1
commit 903d649a0f
15 changed files with 1582 additions and 5 deletions
@@ -0,0 +1,277 @@
/**
* Tests for AI UI Control — Phase 4.12
*
* Tests cover:
* - Command protocol (Task 4.1): UICommand types and validation
* - useAIUIControl hook (Task 4.3): WebSocket connection and command dispatch
* - Command execution (Tasks 4.4-4.9): navigate, filter, open_contact, modal, tab, settings
* - Feedback (Task 4.10): frontend sends confirmation back
* - Visual indication (Task 4.11): store state for AI active
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAIUIControlStore, type UICommand } from '@/store/aiUIControlStore';
// Mock WebSocket
class MockWebSocket {
static instances: MockWebSocket[] = [];
static OPEN = 1;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sentMessages: string[] = [];
constructor(public url: string) {
MockWebSocket.instances.push(this);
setTimeout(() => this.onopen?.(new Event('open')), 0);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
// Helper to simulate receiving a message
receiveMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
}
}
// Mock react-router-dom
const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
useSearchParams: () => {
const params = new URLSearchParams();
return [params, vi.fn()];
},
useLocation: () => ({ pathname: '/contacts' }),
}));
// Mock global WebSocket
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
describe('AI UI Control Store', () => {
beforeEach(() => {
useAIUIControlStore.getState().setActiveCommand(null);
useAIUIControlStore.getState().setActiveModal(null);
useAIUIControlStore.getState().setActiveTab(null);
useAIUIControlStore.getState().setPendingFilter(null);
useAIUIControlStore.getState().setPendingSettings(null);
});
it('should initialize with default state', () => {
const state = useAIUIControlStore.getState();
expect(state.connected).toBe(false);
expect(state.activeCommand).toBeNull();
expect(state.aiActive).toBe(false);
expect(state.commandHistory).toEqual([]);
});
it('should set active command and aiActive flag', () => {
const cmd: UICommand = {
command_id: 'test-1',
action: 'navigate',
path: '/contacts/123',
};
act(() => {
useAIUIControlStore.getState().setActiveCommand(cmd);
});
expect(useAIUIControlStore.getState().activeCommand).toEqual(cmd);
expect(useAIUIControlStore.getState().aiActive).toBe(true);
});
it('should clear pending state', () => {
const cmd: UICommand = {
command_id: 'test-2',
action: 'modal',
modal: 'edit',
};
act(() => {
useAIUIControlStore.getState().setActiveCommand(cmd);
useAIUIControlStore.getState().setActiveModal('edit');
useAIUIControlStore.getState().setPendingFilter({ entity: 'contacts', filter: { type: 'company' } });
});
act(() => {
useAIUIControlStore.getState().clearPending();
});
expect(useAIUIControlStore.getState().activeCommand).toBeNull();
expect(useAIUIControlStore.getState().aiActive).toBe(false);
expect(useAIUIControlStore.getState().pendingFilter).toBeNull();
});
it('should add commands to history (max 50)', () => {
for (let i = 0; i < 55; i++) {
act(() => {
useAIUIControlStore.getState().addCommandToHistory({
command_id: `cmd-${i}`,
action: 'navigate',
path: `/page/${i}`,
});
});
}
expect(useAIUIControlStore.getState().commandHistory).toHaveLength(50);
expect(useAIUIControlStore.getState().commandHistory[49].command_id).toBe('cmd-54');
});
});
describe('AI UI Control — Command Protocol (Task 4.1)', () => {
it('should define all 6 command types', () => {
const validActions = ['navigate', 'filter', 'open_contact', 'modal', 'tab', 'settings'];
expect(validActions).toHaveLength(6);
});
it('navigate command should have path field', () => {
const cmd: UICommand = {
command_id: 'nav-1',
action: 'navigate',
path: '/contacts/123',
};
expect(cmd.action).toBe('navigate');
expect(cmd.path).toBe('/contacts/123');
});
it('filter command should have entity and filter fields', () => {
const cmd: UICommand = {
command_id: 'filter-1',
action: 'filter',
entity: 'contacts',
filter: { type: 'company', city: 'Berlin' },
};
expect(cmd.action).toBe('filter');
expect(cmd.entity).toBe('contacts');
expect(cmd.filter).toEqual({ type: 'company', city: 'Berlin' });
});
it('open_contact command should have contact_id field', () => {
const cmd: UICommand = {
command_id: 'open-1',
action: 'open_contact',
contact_id: 'abc-123',
};
expect(cmd.action).toBe('open_contact');
expect(cmd.contact_id).toBe('abc-123');
});
it('modal command should have modal field', () => {
const cmd: UICommand = {
command_id: 'modal-1',
action: 'modal',
modal: 'edit',
};
expect(cmd.action).toBe('modal');
expect(cmd.modal).toBe('edit');
});
it('tab command should have tab field', () => {
const cmd: UICommand = {
command_id: 'tab-1',
action: 'tab',
tab: 'emails',
};
expect(cmd.action).toBe('tab');
expect(cmd.tab).toBe('emails');
});
it('settings command should have section, key, value fields', () => {
const cmd: UICommand = {
command_id: 'settings-1',
action: 'settings',
section: 'ai',
key: 'model',
value: 'gpt-4',
};
expect(cmd.action).toBe('settings');
expect(cmd.section).toBe('ai');
expect(cmd.key).toBe('model');
expect(cmd.value).toBe('gpt-4');
});
});
describe('AI UI Control — Store Actions (Tasks 4.4-4.9)', () => {
beforeEach(() => {
useAIUIControlStore.getState().setActiveCommand(null);
useAIUIControlStore.getState().setActiveModal(null);
useAIUIControlStore.getState().setActiveTab(null);
useAIUIControlStore.getState().setPendingFilter(null);
useAIUIControlStore.getState().setPendingSettings(null);
});
it('should set active modal (Task 4.7)', () => {
act(() => {
useAIUIControlStore.getState().setActiveModal('edit');
});
expect(useAIUIControlStore.getState().activeModal).toBe('edit');
});
it('should set active tab (Task 4.8)', () => {
act(() => {
useAIUIControlStore.getState().setActiveTab('emails');
});
expect(useAIUIControlStore.getState().activeTab).toBe('emails');
});
it('should set pending filter (Task 4.5)', () => {
const filter = { entity: 'contacts', filter: { type: 'company' } };
act(() => {
useAIUIControlStore.getState().setPendingFilter(filter);
});
expect(useAIUIControlStore.getState().pendingFilter).toEqual(filter);
});
it('should set pending settings (Task 4.9)', () => {
const settings = { section: 'ai', key: 'model', value: 'gpt-4' };
act(() => {
useAIUIControlStore.getState().setPendingSettings(settings);
});
expect(useAIUIControlStore.getState().pendingSettings).toEqual(settings);
});
});
describe('AI UI Control — Feedback (Task 4.10)', () => {
it('should store last feedback', () => {
const feedback = {
command_id: 'cmd-feedback-1',
status: 'success' as const,
action: 'navigate' as const,
current_path: '/contacts/123',
};
act(() => {
useAIUIControlStore.getState().setLastFeedback(feedback);
});
expect(useAIUIControlStore.getState().lastFeedback).toEqual(feedback);
});
});
describe('AI UI Control — Visual Indication (Task 4.11)', () => {
it('should set aiActive when command is set', () => {
act(() => {
useAIUIControlStore.getState().setActiveCommand({
command_id: 'vis-1',
action: 'navigate',
path: '/dashboard',
});
});
expect(useAIUIControlStore.getState().aiActive).toBe(true);
});
it('should clear aiActive when command is cleared', () => {
act(() => {
useAIUIControlStore.getState().setActiveCommand({
command_id: 'vis-2',
action: 'navigate',
path: '/dashboard',
});
useAIUIControlStore.getState().clearPending();
});
expect(useAIUIControlStore.getState().aiActive).toBe(false);
});
});