diff --git a/.gitmodules b/.gitmodules index 06c7d7f..b771e60 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "external/whisper.cpp"] - path = external/whisper.cpp - url = https://github.com/ggerganov/whisper.cpp +[submodule "external/whisper.cpp"] + path = external/whisper.cpp + url = https://github.com/ggerganov/whisper.cpp diff --git a/PLAN_TESTS_INTEGRATION.md b/PLAN_TESTS_INTEGRATION.md index 52be6ad..ce47ee3 100644 --- a/PLAN_TESTS_INTEGRATION.md +++ b/PLAN_TESTS_INTEGRATION.md @@ -1,733 +1,733 @@ -# Plan d'Implementation - Tests d'Integration AISSIA - -## Vue d'Ensemble - -Ce document decrit le plan complet pour implementer 110 tests d'integration (TI) : -- 10 TI par module (6 modules = 60 TI) -- 50 TI pour le systeme MCP - -## Architecture des Tests - -``` -tests/ -├── CMakeLists.txt # Configuration tests -├── main.cpp # Entry point Catch2 -├── mocks/ -│ ├── MockIO.hpp # Mock IIO pub/sub -│ ├── MockDataNode.hpp # Mock IDataNode -│ ├── MockTaskScheduler.hpp # Mock ITaskScheduler -│ ├── MockTransport.hpp # Mock IMCPTransport -│ └── MockLLMProvider.hpp # Mock ILLMProvider -├── utils/ -│ ├── TestHelpers.hpp # Utilitaires communs -│ ├── MessageCapture.hpp # Capture messages IIO -│ └── TimeSimulator.hpp # Simulation temps (gameTime) -├── modules/ -│ ├── SchedulerModuleTests.cpp # 10 TI -│ ├── NotificationModuleTests.cpp # 10 TI -│ ├── MonitoringModuleTests.cpp # 10 TI -│ ├── AIModuleTests.cpp # 10 TI -│ ├── VoiceModuleTests.cpp # 10 TI -│ └── StorageModuleTests.cpp # 10 TI -└── mcp/ - ├── MCPTypesTests.cpp # 15 TI - ├── StdioTransportTests.cpp # 20 TI - └── MCPClientTests.cpp # 15 TI -``` - ---- - -## Phase 1: Infrastructure (Priorite Haute) - -### 1.1 Mocks Essentiels - -#### MockIO.hpp -```cpp -class MockIO : public grove::IIO { -public: - // Capture des messages publies - std::vector> publishedMessages; - - // Queue de messages a recevoir - std::queue incomingMessages; - - void publish(const std::string& topic, const grove::IDataNode& data) override; - bool hasMessages() const override; - grove::Message popMessage() override; - - // Helpers de test - void injectMessage(const std::string& topic, const json& data); - bool wasPublished(const std::string& topic) const; - json getLastPublished(const std::string& topic) const; - void clear(); -}; -``` - -#### MockTransport.hpp -```cpp -class MockTransport : public aissia::mcp::IMCPTransport { -public: - bool m_running = false; - std::vector sentRequests; - std::queue preparedResponses; - - bool start() override; - void stop() override; - bool isRunning() const override; - JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs) override; - - // Test helpers - void prepareResponse(const JsonRpcResponse& response); - void setStartFailure(bool fail); -}; -``` - -### 1.2 Utilitaires - -#### TimeSimulator.hpp -```cpp -class TimeSimulator { -public: - float m_gameTime = 0.0f; - - json createInput(float deltaTime = 0.1f); - void advance(float seconds); - void setTime(float time); -}; -``` - -#### MessageCapture.hpp -```cpp -class MessageCapture { -public: - void captureFrom(MockIO& io); - bool waitForMessage(const std::string& topic, int timeoutMs = 1000); - json getMessage(const std::string& topic); - int countMessages(const std::string& topic); -}; -``` - ---- - -## Phase 2: Tests des Modules (60 TI) - -### 2.1 SchedulerModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_SCHEDULER_001_StartTask` | Demarrer une tache publie `scheduler:task_started` | `scheduler:task_started` | -| 2 | `TI_SCHEDULER_002_CompleteTask` | Completer une tache publie `scheduler:task_completed` avec duree | `scheduler:task_completed` | -| 3 | `TI_SCHEDULER_003_HyperfocusDetection` | Session > 120min declenche `scheduler:hyperfocus_alert` | `scheduler:hyperfocus_alert` | -| 4 | `TI_SCHEDULER_004_HyperfocusAlertOnce` | Alerte hyperfocus envoyee une seule fois par session | single publish | -| 5 | `TI_SCHEDULER_005_BreakReminder` | Rappel de pause toutes les 45min | `scheduler:break_reminder` | -| 6 | `TI_SCHEDULER_006_IdlePausesSession` | Reception `monitoring:idle_detected` pause le tracking | state | -| 7 | `TI_SCHEDULER_007_ActivityResumesSession` | Reception `monitoring:activity_resumed` reprend le tracking | state | -| 8 | `TI_SCHEDULER_008_ToolQueryGetCurrentTask` | Query tool `get_current_task` retourne tache courante | `scheduler:response` | -| 9 | `TI_SCHEDULER_009_ToolCommandStartBreak` | Command tool `start_break` publie `scheduler:break_started` | `scheduler:break_started` | -| 10 | `TI_SCHEDULER_010_StateSerialization` | `getState()` et `setState()` preservent l'etat complet | state roundtrip | - -**Implementation:** -```cpp -TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") { - MockIO io; - SchedulerModule module; - TimeSimulator time; - - // Configure - json config = {{"hyperfocusThresholdMinutes", 120}}; - module.setConfiguration(JsonDataNode(config), &io, nullptr); - - // Add task - io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - - // Process - module.process(JsonDataNode(time.createInput())); - - // Verify - REQUIRE(io.wasPublished("scheduler:task_started")); - auto msg = io.getLastPublished("scheduler:task_started"); - REQUIRE(msg["taskId"] == "task-1"); -} -``` - ---- - -### 2.2 NotificationModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_NOTIF_001_QueueNotification` | `notify()` ajoute a la queue | state queue size | -| 2 | `TI_NOTIF_002_ProcessQueue` | `process()` traite max 3 notifications/frame | queue drain | -| 3 | `TI_NOTIF_003_PriorityOrdering` | URGENT traite avant NORMAL | order | -| 4 | `TI_NOTIF_004_SilentModeBlocksNonUrgent` | Mode silencieux bloque LOW/NORMAL/HIGH | filtering | -| 5 | `TI_NOTIF_005_SilentModeAllowsUrgent` | Mode silencieux laisse passer URGENT | filtering | -| 6 | `TI_NOTIF_006_MaxQueueSize` | Queue limitee a `maxQueueSize` (50) | overflow | -| 7 | `TI_NOTIF_007_LanguageConfig` | Langue configuree via `setConfiguration` | config | -| 8 | `TI_NOTIF_008_NotificationCountTracking` | Compteurs `notificationCount` et `urgentCount` | state | -| 9 | `TI_NOTIF_009_StateSerialization` | `getState()`/`setState()` preservent queue et compteurs | state roundtrip | -| 10 | `TI_NOTIF_010_MultipleFrameProcessing` | Queue > 3 elements necessite plusieurs frames | multi-frame | - -**Implementation:** -```cpp -TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") { - MockIO io; - NotificationModule module; - TimeSimulator time; - - json config = {{"silentMode", true}}; - module.setConfiguration(JsonDataNode(config), &io, nullptr); - - module.notify("Test", "Normal message", NotificationModule::Priority::NORMAL); - module.notify("Test", "High message", NotificationModule::Priority::HIGH); - - // State should show 0 pending (blocked) - auto state = module.getState(); - REQUIRE(state->getInt("pendingCount") == 0); -} -``` - ---- - -### 2.3 MonitoringModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_MONITOR_001_AppChanged` | Reception `platform:window_changed` publie `monitoring:app_changed` | `monitoring:app_changed` | -| 2 | `TI_MONITOR_002_ProductiveAppClassification` | Apps dans `productiveApps` classees "productive" | classification | -| 3 | `TI_MONITOR_003_DistractingAppClassification` | Apps dans `distractingApps` classees "distracting" | classification | -| 4 | `TI_MONITOR_004_NeutralAppClassification` | Apps inconnues classees "neutral" | classification | -| 5 | `TI_MONITOR_005_DurationTracking` | `m_appDurations` accumule temps par app | duration map | -| 6 | `TI_MONITOR_006_IdleDetectedPausesTracking` | `platform:idle_detected` pause accumulation | state | -| 7 | `TI_MONITOR_007_ActivityResumedResumesTracking` | `platform:activity_resumed` reprend accumulation | state | -| 8 | `TI_MONITOR_008_ProductivityStats` | `m_totalProductiveSeconds` et `m_totalDistractingSeconds` corrects | stats | -| 9 | `TI_MONITOR_009_ToolQueryGetCurrentApp` | Query `get_current_app` retourne app courante | `monitoring:response` | -| 10 | `TI_MONITOR_010_StateSerialization` | `getState()`/`setState()` preservent stats et durations | state roundtrip | - -**Implementation:** -```cpp -TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") { - MockIO io; - MonitoringModule module; - TimeSimulator time; - - json config = { - {"productive_apps", {"Code", "CLion", "Visual Studio"}} - }; - module.setConfiguration(JsonDataNode(config), &io, nullptr); - - io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Code"}, - {"duration", 0} - }); - - module.process(JsonDataNode(time.createInput())); - - REQUIRE(io.wasPublished("monitoring:app_changed")); - auto msg = io.getLastPublished("monitoring:app_changed"); - REQUIRE(msg["classification"] == "productive"); -} -``` - ---- - -### 2.4 AIModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_AI_001_QuerySendsLLMRequest` | Reception `ai:query` publie `llm:request` | `llm:request` | -| 2 | `TI_AI_002_VoiceTranscriptionTriggersQuery` | Reception `voice:transcription` envoie query | `llm:request` | -| 3 | `TI_AI_003_LLMResponseHandled` | Reception `llm:response` met `m_awaitingResponse = false` | state | -| 4 | `TI_AI_004_LLMErrorHandled` | Reception `llm:error` met `m_awaitingResponse = false` | state | -| 5 | `TI_AI_005_HyperfocusAlertGeneratesSuggestion` | `scheduler:hyperfocus_alert` publie `ai:suggestion` | `ai:suggestion` | -| 6 | `TI_AI_006_BreakReminderGeneratesSuggestion` | `scheduler:break_reminder` publie `ai:suggestion` | `ai:suggestion` | -| 7 | `TI_AI_007_SystemPromptInRequest` | `llm:request` contient `systemPrompt` de config | request content | -| 8 | `TI_AI_008_ConversationIdTracking` | Requetes utilisent `m_currentConversationId` | conversation | -| 9 | `TI_AI_009_TokenCountingAccumulates` | `m_totalTokens` s'accumule apres chaque reponse | state | -| 10 | `TI_AI_010_StateSerialization` | `getState()`/`setState()` preservent compteurs et conversation | state roundtrip | - -**Implementation:** -```cpp -TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") { - MockIO io; - AIModule module; - TimeSimulator time; - - json config = {{"system_prompt", "Tu es un assistant"}}; - module.setConfiguration(JsonDataNode(config), &io, nullptr); - - io.injectMessage("scheduler:hyperfocus_alert", { - {"sessionMinutes", 130}, - {"task", "coding"} - }); - - module.process(JsonDataNode(time.createInput())); - - REQUIRE(io.wasPublished("ai:suggestion")); - auto msg = io.getLastPublished("ai:suggestion"); - REQUIRE(msg.contains("message")); -} -``` - ---- - -### 2.5 VoiceModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_VOICE_001_AIResponseTriggersSpeak` | Reception `ai:response` publie `voice:speak` | `voice:speak` | -| 2 | `TI_VOICE_002_SuggestionPrioritySpeak` | Reception `ai:suggestion` publie `voice:speak` avec priorite | `voice:speak` priority | -| 3 | `TI_VOICE_003_SpeakingStartedUpdatesState` | `voice:speaking_started` met `m_isSpeaking = true` | state | -| 4 | `TI_VOICE_004_SpeakingEndedUpdatesState` | `voice:speaking_ended` met `m_isSpeaking = false` | state | -| 5 | `TI_VOICE_005_IsIdleReflectsSpeaking` | `isIdle()` retourne `!m_isSpeaking` | interface | -| 6 | `TI_VOICE_006_TranscriptionForwarded` | `voice:transcription` non traite par VoiceModule (forward only) | no re-publish | -| 7 | `TI_VOICE_007_TotalSpokenIncremented` | `m_totalSpoken` incremente apres chaque `speaking_ended` | counter | -| 8 | `TI_VOICE_008_TTSDisabledConfig` | Config `ttsEnabled: false` empeche `voice:speak` | config | -| 9 | `TI_VOICE_009_ToolCommandSpeak` | Command tool `speak` publie `voice:speak` | tool | -| 10 | `TI_VOICE_010_StateSerialization` | `getState()`/`setState()` preservent compteurs | state roundtrip | - -**Implementation:** -```cpp -TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") { - MockIO io; - VoiceModule module; - TimeSimulator time; - - json config = {{"ttsEnabled", true}}; - module.setConfiguration(JsonDataNode(config), &io, nullptr); - - io.injectMessage("ai:suggestion", { - {"message", "Tu devrais faire une pause"}, - {"duration", 5} - }); - - module.process(JsonDataNode(time.createInput())); - - REQUIRE(io.wasPublished("voice:speak")); - auto msg = io.getLastPublished("voice:speak"); - REQUIRE(msg["priority"] == true); -} -``` - ---- - -### 2.6 StorageModule (10 TI) - -| # | Test | Description | Topics Verifies | -|---|------|-------------|-----------------| -| 1 | `TI_STORAGE_001_TaskCompletedSavesSession` | `scheduler:task_completed` publie `storage:save_session` | `storage:save_session` | -| 2 | `TI_STORAGE_002_AppChangedSavesUsage` | `monitoring:app_changed` publie `storage:save_app_usage` | `storage:save_app_usage` | -| 3 | `TI_STORAGE_003_SessionSavedUpdatesLastId` | `storage:session_saved` met a jour `m_lastSessionId` | state | -| 4 | `TI_STORAGE_004_StorageErrorHandled` | `storage:error` log l'erreur sans crash | error handling | -| 5 | `TI_STORAGE_005_PendingSavesTracking` | `m_pendingSaves` incremente/decremente correctement | counter | -| 6 | `TI_STORAGE_006_TotalSavedTracking` | `m_totalSaved` s'accumule | counter | -| 7 | `TI_STORAGE_007_ToolQueryNotes` | Query `query_notes` retourne notes filtrees | `storage:response` | -| 8 | `TI_STORAGE_008_ToolCommandSaveNote` | Command `save_note` ajoute note a `m_notes` | state | -| 9 | `TI_STORAGE_009_NoteTagsFiltering` | Query notes avec tags filtre correctement | filtering | -| 10 | `TI_STORAGE_010_StateSerialization` | `getState()`/`setState()` preservent notes et compteurs | state roundtrip | - ---- - -## Phase 3: Tests MCP (50 TI) - -### 3.1 MCPTypes (15 TI) - -| # | Test | Description | -|---|------|-------------| -| 1 | `TI_TYPES_001_MCPToolToJson` | `MCPTool::toJson()` serialise correctement | -| 2 | `TI_TYPES_002_MCPToolFromJson` | `MCPTool::fromJson()` deserialise correctement | -| 3 | `TI_TYPES_003_MCPToolFromJsonMissingFields` | `fromJson()` avec champs manquants utilise defauts | -| 4 | `TI_TYPES_004_MCPResourceFromJson` | `MCPResource::fromJson()` deserialise correctement | -| 5 | `TI_TYPES_005_MCPToolResultToJson` | `MCPToolResult::toJson()` serialise content et isError | -| 6 | `TI_TYPES_006_MCPCapabilitiesFromJson` | Detection correcte des capabilities | -| 7 | `TI_TYPES_007_MCPCapabilitiesEmpty` | Capabilities vides si pas de champs | -| 8 | `TI_TYPES_008_MCPServerInfoFromJson` | `MCPServerInfo::fromJson()` parse name/version/caps | -| 9 | `TI_TYPES_009_JsonRpcRequestToJson` | Serialisation avec/sans params | -| 10 | `TI_TYPES_010_JsonRpcResponseFromJson` | Parse result ou error | -| 11 | `TI_TYPES_011_JsonRpcResponseIsError` | `isError()` detecte presence de error | -| 12 | `TI_TYPES_012_MCPServerConfigFromJson` | Parse command, args, env, enabled | -| 13 | `TI_TYPES_013_MCPServerConfigEnvExpansion` | Variables env `${VAR}` expandees | -| 14 | `TI_TYPES_014_MCPServerConfigDisabled` | `enabled: false` honore | -| 15 | `TI_TYPES_015_JsonRpcRequestIdIncrement` | IDs uniques et croissants | - -**Implementation:** -```cpp -TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") { - MCPTool tool; - tool.name = "read_file"; - tool.description = "Read a file"; - tool.inputSchema = {{"type", "object"}, {"properties", {{"path", {{"type", "string"}}}}}}; - - json j = tool.toJson(); - - REQUIRE(j["name"] == "read_file"); - REQUIRE(j["description"] == "Read a file"); - REQUIRE(j["inputSchema"]["type"] == "object"); -} - -TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") { - json errorJson = { - {"jsonrpc", "2.0"}, - {"id", 1}, - {"error", {{"code", -32600}, {"message", "Invalid Request"}}} - }; - - auto response = JsonRpcResponse::fromJson(errorJson); - - REQUIRE(response.isError() == true); - REQUIRE(response.error.value()["code"] == -32600); -} -``` - ---- - -### 3.2 StdioTransport (20 TI) - -| # | Test | Description | -|---|------|-------------| -| 1 | `TI_TRANSPORT_001_StartSpawnsProcess` | `start()` lance le processus enfant | -| 2 | `TI_TRANSPORT_002_StartFailsInvalidCommand` | `start()` retourne false si commande invalide | -| 3 | `TI_TRANSPORT_003_StopKillsProcess` | `stop()` termine le processus | -| 4 | `TI_TRANSPORT_004_IsRunningReflectsState` | `isRunning()` reflete l'etat reel | -| 5 | `TI_TRANSPORT_005_SendRequestWritesToStdin` | Request serialisee vers stdin | -| 6 | `TI_TRANSPORT_006_SendRequestReadsResponse` | Response lue depuis stdout | -| 7 | `TI_TRANSPORT_007_SendRequestTimeout` | Timeout si pas de reponse | -| 8 | `TI_TRANSPORT_008_SendRequestIdMatching` | Response matchee par ID | -| 9 | `TI_TRANSPORT_009_ConcurrentRequests` | Multiple requests simultanées OK | -| 10 | `TI_TRANSPORT_010_SendNotificationNoResponse` | Notification n'attend pas de reponse | -| 11 | `TI_TRANSPORT_011_ReaderThreadStartsOnStart` | Thread reader demarre avec `start()` | -| 12 | `TI_TRANSPORT_012_ReaderThreadStopsOnStop` | Thread reader s'arrete avec `stop()` | -| 13 | `TI_TRANSPORT_013_JsonParseErrorHandled` | JSON invalide n'crash pas | -| 14 | `TI_TRANSPORT_014_ProcessCrashDetected` | Crash process detecte rapidement | -| 15 | `TI_TRANSPORT_015_LargeMessageHandling` | Messages > 64KB transmis | -| 16 | `TI_TRANSPORT_016_MultilineJsonHandling` | JSON multiline traite correctement | -| 17 | `TI_TRANSPORT_017_EnvVariablesPassedToProcess` | Variables env transmises au processus | -| 18 | `TI_TRANSPORT_018_ArgsPassedToProcess` | Arguments CLI transmis | -| 19 | `TI_TRANSPORT_019_DestructorCleansUp` | Destructeur nettoie ressources | -| 20 | `TI_TRANSPORT_020_RestartAfterStop` | `start()` apres `stop()` fonctionne | - -**Implementation (avec mock process):** -```cpp -// Pour les tests, on peut creer un mini-serveur echo en Python ou utiliser un mock - -TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") { - MCPServerConfig config; - config.command = "cat"; // cat ne repond jamais au JSON-RPC - - StdioTransport transport(config); - transport.start(); - - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - - auto response = transport.sendRequest(request, 100); // 100ms timeout - - REQUIRE(response.isError() == true); - transport.stop(); -} - -// Test avec echo server (Python script) -TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") { - MCPServerConfig config; - config.command = "python"; - config.args = {"tests/fixtures/echo_server.py"}; - - StdioTransport transport(config); - REQUIRE(transport.start() == true); - - JsonRpcRequest request; - request.id = 42; - request.method = "echo"; - request.params = {{"message", "hello"}}; - - auto response = transport.sendRequest(request, 5000); - - REQUIRE(response.isError() == false); - REQUIRE(response.id == 42); - transport.stop(); -} -``` - ---- - -### 3.3 MCPClient (15 TI) - -| # | Test | Description | -|---|------|-------------| -| 1 | `TI_CLIENT_001_LoadConfigValid` | `loadConfig()` parse mcp.json valide | -| 2 | `TI_CLIENT_002_LoadConfigInvalid` | `loadConfig()` gere fichier invalide | -| 3 | `TI_CLIENT_003_LoadConfigMissingFile` | `loadConfig()` retourne false si fichier absent | -| 4 | `TI_CLIENT_004_ConnectAllStartsServers` | `connectAll()` demarre tous les serveurs enabled | -| 5 | `TI_CLIENT_005_ConnectAllSkipsDisabled` | `connectAll()` skip les serveurs `enabled: false` | -| 6 | `TI_CLIENT_006_ConnectSingleServer` | `connect(name)` demarre un seul serveur | -| 7 | `TI_CLIENT_007_DisconnectSingleServer` | `disconnect(name)` arrete un serveur | -| 8 | `TI_CLIENT_008_DisconnectAllCleansUp` | `disconnectAll()` arrete tous les serveurs | -| 9 | `TI_CLIENT_009_ListAllToolsAggregates` | `listAllTools()` combine tools de tous serveurs | -| 10 | `TI_CLIENT_010_ToolNamePrefixed` | Tools prefixes par nom serveur (`server:tool`) | -| 11 | `TI_CLIENT_011_CallToolRoutesToServer` | `callTool()` route vers bon serveur | -| 12 | `TI_CLIENT_012_CallToolInvalidName` | `callTool()` avec nom invalide retourne erreur | -| 13 | `TI_CLIENT_013_CallToolDisconnectedServer` | `callTool()` sur serveur deconnecte retourne erreur | -| 14 | `TI_CLIENT_014_ToolCountAccurate` | `toolCount()` reflete nombre total de tools | -| 15 | `TI_CLIENT_015_IsConnectedAccurate` | `isConnected(name)` reflete etat reel | - -**Implementation:** -```cpp -TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") { - MCPClient client; - - // Utiliser mock transport - client.loadConfig("tests/fixtures/mock_mcp.json"); - // Le mock simule un serveur avec tool "read_file" - - client.connectAll(); - auto tools = client.listAllTools(); - - bool hasPrefix = false; - for (const auto& tool : tools) { - if (tool.name.find(":") != std::string::npos) { - hasPrefix = true; - break; - } - } - - REQUIRE(hasPrefix == true); - client.disconnectAll(); -} - -TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") { - MCPClient client; - client.loadConfig("tests/fixtures/mock_mcp.json"); - client.connectAll(); - - auto result = client.callTool("nonexistent:tool", {}); - - REQUIRE(result.isError == true); - client.disconnectAll(); -} -``` - ---- - -## Phase 4: Fixtures et Scripts de Test - -### 4.1 Echo Server MCP (Python) -`tests/fixtures/echo_server.py` -```python -#!/usr/bin/env python3 -import json -import sys - -def main(): - while True: - line = sys.stdin.readline() - if not line: - break - try: - request = json.loads(line) - response = { - "jsonrpc": "2.0", - "id": request.get("id"), - "result": request.get("params", {}) - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - except: - pass - -if __name__ == "__main__": - main() -``` - -### 4.2 Mock MCP Server (pour tests d'integration) -`tests/fixtures/mock_mcp_server.py` -```python -#!/usr/bin/env python3 -import json -import sys - -TOOLS = [ - {"name": "test_tool", "description": "A test tool", "inputSchema": {"type": "object"}} -] - -def handle_request(request): - method = request.get("method") - - if method == "initialize": - return { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "MockServer", "version": "1.0"} - } - elif method == "tools/list": - return {"tools": TOOLS} - elif method == "tools/call": - return {"content": [{"type": "text", "text": "Tool executed"}]} - - return {"error": {"code": -32601, "message": "Method not found"}} - -def main(): - while True: - line = sys.stdin.readline() - if not line: - break - try: - request = json.loads(line) - result = handle_request(request) - response = {"jsonrpc": "2.0", "id": request.get("id")} - if "error" in result: - response["error"] = result["error"] - else: - response["result"] = result - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - except Exception as e: - pass - -if __name__ == "__main__": - main() -``` - -### 4.3 Config Mock MCP -`tests/fixtures/mock_mcp.json` -```json -{ - "mock_server": { - "command": "python", - "args": ["tests/fixtures/mock_mcp_server.py"], - "enabled": true - }, - "disabled_server": { - "command": "nonexistent", - "enabled": false - } -} -``` - ---- - -## Phase 5: CMakeLists.txt Tests - -```cmake -# ============================================================================ -# Tests d'Integration -# ============================================================================ - -# Fetch Catch2 if not already available -FetchContent_Declare( - Catch2 - GIT_REPOSITORY https://github.com/catchorg/Catch2.git - GIT_TAG v3.4.0 -) -FetchContent_MakeAvailable(Catch2) - -# Test executable -add_executable(aissia_tests - tests/main.cpp - - # Mocks - tests/mocks/MockIO.cpp - - # Module tests - tests/modules/SchedulerModuleTests.cpp - tests/modules/NotificationModuleTests.cpp - tests/modules/MonitoringModuleTests.cpp - tests/modules/AIModuleTests.cpp - tests/modules/VoiceModuleTests.cpp - tests/modules/StorageModuleTests.cpp - - # MCP tests - tests/mcp/MCPTypesTests.cpp - tests/mcp/StdioTransportTests.cpp - tests/mcp/MCPClientTests.cpp -) - -target_link_libraries(aissia_tests PRIVATE - Catch2::Catch2WithMain - GroveEngine::impl - AissiaTools - spdlog::spdlog -) - -target_include_directories(aissia_tests PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/src - ${CMAKE_CURRENT_SOURCE_DIR}/tests -) - -# Copy test fixtures -file(COPY tests/fixtures/ DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures) - -# CTest integration -include(CTest) -include(Catch) -catch_discover_tests(aissia_tests) - -# Custom target for running tests -add_custom_target(test_all - COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure - DEPENDS aissia_tests - COMMENT "Running all integration tests" -) -``` - ---- - -## Ordre d'Implementation Recommande - -### Sprint 1: Infrastructure (2-3 jours) -1. Creer structure `tests/` -2. Implementer `MockIO.hpp` -3. Implementer `MockTransport.hpp` -4. Implementer `TimeSimulator.hpp` -5. Setup CMakeLists.txt tests -6. Creer fixtures Python - -### Sprint 2: Tests MCPTypes (1 jour) -1. `TI_TYPES_001` a `TI_TYPES_015` -2. Valider serialisation/deserialisation - -### Sprint 3: Tests StdioTransport (2 jours) -1. `TI_TRANSPORT_001` a `TI_TRANSPORT_010` (basic) -2. `TI_TRANSPORT_011` a `TI_TRANSPORT_020` (advanced) - -### Sprint 4: Tests MCPClient (1 jour) -1. `TI_CLIENT_001` a `TI_CLIENT_015` - -### Sprint 5: Tests Modules (3-4 jours) -1. SchedulerModule (10 TI) -2. NotificationModule (10 TI) -3. MonitoringModule (10 TI) -4. AIModule (10 TI) -5. VoiceModule (10 TI) -6. StorageModule (10 TI) - ---- - -## Metriques de Succes - -- [ ] 110 tests implementes -- [ ] Couverture > 80% pour chaque module -- [ ] Tous les tests passent en CI -- [ ] Temps d'execution < 30 secondes -- [ ] Aucune dependance externe (sauf Python pour mock MCP) - ---- - -## Commandes de Build et Execution - -```bash -# Build complet avec tests -cmake -B build -DBUILD_TESTING=ON -cmake --build build - -# Executer tous les tests -cmake --build build --target test_all - -# Executer tests specifiques -./build/aissia_tests "[scheduler]" # Tests scheduler -./build/aissia_tests "[mcp]" # Tests MCP -./build/aissia_tests "[integration]" # Tous les TI - -# Avec verbose -./build/aissia_tests -s -d yes -``` +# Plan d'Implementation - Tests d'Integration AISSIA + +## Vue d'Ensemble + +Ce document decrit le plan complet pour implementer 110 tests d'integration (TI) : +- 10 TI par module (6 modules = 60 TI) +- 50 TI pour le systeme MCP + +## Architecture des Tests + +``` +tests/ +├── CMakeLists.txt # Configuration tests +├── main.cpp # Entry point Catch2 +├── mocks/ +│ ├── MockIO.hpp # Mock IIO pub/sub +│ ├── MockDataNode.hpp # Mock IDataNode +│ ├── MockTaskScheduler.hpp # Mock ITaskScheduler +│ ├── MockTransport.hpp # Mock IMCPTransport +│ └── MockLLMProvider.hpp # Mock ILLMProvider +├── utils/ +│ ├── TestHelpers.hpp # Utilitaires communs +│ ├── MessageCapture.hpp # Capture messages IIO +│ └── TimeSimulator.hpp # Simulation temps (gameTime) +├── modules/ +│ ├── SchedulerModuleTests.cpp # 10 TI +│ ├── NotificationModuleTests.cpp # 10 TI +│ ├── MonitoringModuleTests.cpp # 10 TI +│ ├── AIModuleTests.cpp # 10 TI +│ ├── VoiceModuleTests.cpp # 10 TI +│ └── StorageModuleTests.cpp # 10 TI +└── mcp/ + ├── MCPTypesTests.cpp # 15 TI + ├── StdioTransportTests.cpp # 20 TI + └── MCPClientTests.cpp # 15 TI +``` + +--- + +## Phase 1: Infrastructure (Priorite Haute) + +### 1.1 Mocks Essentiels + +#### MockIO.hpp +```cpp +class MockIO : public grove::IIO { +public: + // Capture des messages publies + std::vector> publishedMessages; + + // Queue de messages a recevoir + std::queue incomingMessages; + + void publish(const std::string& topic, const grove::IDataNode& data) override; + bool hasMessages() const override; + grove::Message popMessage() override; + + // Helpers de test + void injectMessage(const std::string& topic, const json& data); + bool wasPublished(const std::string& topic) const; + json getLastPublished(const std::string& topic) const; + void clear(); +}; +``` + +#### MockTransport.hpp +```cpp +class MockTransport : public aissia::mcp::IMCPTransport { +public: + bool m_running = false; + std::vector sentRequests; + std::queue preparedResponses; + + bool start() override; + void stop() override; + bool isRunning() const override; + JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs) override; + + // Test helpers + void prepareResponse(const JsonRpcResponse& response); + void setStartFailure(bool fail); +}; +``` + +### 1.2 Utilitaires + +#### TimeSimulator.hpp +```cpp +class TimeSimulator { +public: + float m_gameTime = 0.0f; + + json createInput(float deltaTime = 0.1f); + void advance(float seconds); + void setTime(float time); +}; +``` + +#### MessageCapture.hpp +```cpp +class MessageCapture { +public: + void captureFrom(MockIO& io); + bool waitForMessage(const std::string& topic, int timeoutMs = 1000); + json getMessage(const std::string& topic); + int countMessages(const std::string& topic); +}; +``` + +--- + +## Phase 2: Tests des Modules (60 TI) + +### 2.1 SchedulerModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_SCHEDULER_001_StartTask` | Demarrer une tache publie `scheduler:task_started` | `scheduler:task_started` | +| 2 | `TI_SCHEDULER_002_CompleteTask` | Completer une tache publie `scheduler:task_completed` avec duree | `scheduler:task_completed` | +| 3 | `TI_SCHEDULER_003_HyperfocusDetection` | Session > 120min declenche `scheduler:hyperfocus_alert` | `scheduler:hyperfocus_alert` | +| 4 | `TI_SCHEDULER_004_HyperfocusAlertOnce` | Alerte hyperfocus envoyee une seule fois par session | single publish | +| 5 | `TI_SCHEDULER_005_BreakReminder` | Rappel de pause toutes les 45min | `scheduler:break_reminder` | +| 6 | `TI_SCHEDULER_006_IdlePausesSession` | Reception `monitoring:idle_detected` pause le tracking | state | +| 7 | `TI_SCHEDULER_007_ActivityResumesSession` | Reception `monitoring:activity_resumed` reprend le tracking | state | +| 8 | `TI_SCHEDULER_008_ToolQueryGetCurrentTask` | Query tool `get_current_task` retourne tache courante | `scheduler:response` | +| 9 | `TI_SCHEDULER_009_ToolCommandStartBreak` | Command tool `start_break` publie `scheduler:break_started` | `scheduler:break_started` | +| 10 | `TI_SCHEDULER_010_StateSerialization` | `getState()` et `setState()` preservent l'etat complet | state roundtrip | + +**Implementation:** +```cpp +TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") { + MockIO io; + SchedulerModule module; + TimeSimulator time; + + // Configure + json config = {{"hyperfocusThresholdMinutes", 120}}; + module.setConfiguration(JsonDataNode(config), &io, nullptr); + + // Add task + io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + + // Process + module.process(JsonDataNode(time.createInput())); + + // Verify + REQUIRE(io.wasPublished("scheduler:task_started")); + auto msg = io.getLastPublished("scheduler:task_started"); + REQUIRE(msg["taskId"] == "task-1"); +} +``` + +--- + +### 2.2 NotificationModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_NOTIF_001_QueueNotification` | `notify()` ajoute a la queue | state queue size | +| 2 | `TI_NOTIF_002_ProcessQueue` | `process()` traite max 3 notifications/frame | queue drain | +| 3 | `TI_NOTIF_003_PriorityOrdering` | URGENT traite avant NORMAL | order | +| 4 | `TI_NOTIF_004_SilentModeBlocksNonUrgent` | Mode silencieux bloque LOW/NORMAL/HIGH | filtering | +| 5 | `TI_NOTIF_005_SilentModeAllowsUrgent` | Mode silencieux laisse passer URGENT | filtering | +| 6 | `TI_NOTIF_006_MaxQueueSize` | Queue limitee a `maxQueueSize` (50) | overflow | +| 7 | `TI_NOTIF_007_LanguageConfig` | Langue configuree via `setConfiguration` | config | +| 8 | `TI_NOTIF_008_NotificationCountTracking` | Compteurs `notificationCount` et `urgentCount` | state | +| 9 | `TI_NOTIF_009_StateSerialization` | `getState()`/`setState()` preservent queue et compteurs | state roundtrip | +| 10 | `TI_NOTIF_010_MultipleFrameProcessing` | Queue > 3 elements necessite plusieurs frames | multi-frame | + +**Implementation:** +```cpp +TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") { + MockIO io; + NotificationModule module; + TimeSimulator time; + + json config = {{"silentMode", true}}; + module.setConfiguration(JsonDataNode(config), &io, nullptr); + + module.notify("Test", "Normal message", NotificationModule::Priority::NORMAL); + module.notify("Test", "High message", NotificationModule::Priority::HIGH); + + // State should show 0 pending (blocked) + auto state = module.getState(); + REQUIRE(state->getInt("pendingCount") == 0); +} +``` + +--- + +### 2.3 MonitoringModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_MONITOR_001_AppChanged` | Reception `platform:window_changed` publie `monitoring:app_changed` | `monitoring:app_changed` | +| 2 | `TI_MONITOR_002_ProductiveAppClassification` | Apps dans `productiveApps` classees "productive" | classification | +| 3 | `TI_MONITOR_003_DistractingAppClassification` | Apps dans `distractingApps` classees "distracting" | classification | +| 4 | `TI_MONITOR_004_NeutralAppClassification` | Apps inconnues classees "neutral" | classification | +| 5 | `TI_MONITOR_005_DurationTracking` | `m_appDurations` accumule temps par app | duration map | +| 6 | `TI_MONITOR_006_IdleDetectedPausesTracking` | `platform:idle_detected` pause accumulation | state | +| 7 | `TI_MONITOR_007_ActivityResumedResumesTracking` | `platform:activity_resumed` reprend accumulation | state | +| 8 | `TI_MONITOR_008_ProductivityStats` | `m_totalProductiveSeconds` et `m_totalDistractingSeconds` corrects | stats | +| 9 | `TI_MONITOR_009_ToolQueryGetCurrentApp` | Query `get_current_app` retourne app courante | `monitoring:response` | +| 10 | `TI_MONITOR_010_StateSerialization` | `getState()`/`setState()` preservent stats et durations | state roundtrip | + +**Implementation:** +```cpp +TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") { + MockIO io; + MonitoringModule module; + TimeSimulator time; + + json config = { + {"productive_apps", {"Code", "CLion", "Visual Studio"}} + }; + module.setConfiguration(JsonDataNode(config), &io, nullptr); + + io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Code"}, + {"duration", 0} + }); + + module.process(JsonDataNode(time.createInput())); + + REQUIRE(io.wasPublished("monitoring:app_changed")); + auto msg = io.getLastPublished("monitoring:app_changed"); + REQUIRE(msg["classification"] == "productive"); +} +``` + +--- + +### 2.4 AIModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_AI_001_QuerySendsLLMRequest` | Reception `ai:query` publie `llm:request` | `llm:request` | +| 2 | `TI_AI_002_VoiceTranscriptionTriggersQuery` | Reception `voice:transcription` envoie query | `llm:request` | +| 3 | `TI_AI_003_LLMResponseHandled` | Reception `llm:response` met `m_awaitingResponse = false` | state | +| 4 | `TI_AI_004_LLMErrorHandled` | Reception `llm:error` met `m_awaitingResponse = false` | state | +| 5 | `TI_AI_005_HyperfocusAlertGeneratesSuggestion` | `scheduler:hyperfocus_alert` publie `ai:suggestion` | `ai:suggestion` | +| 6 | `TI_AI_006_BreakReminderGeneratesSuggestion` | `scheduler:break_reminder` publie `ai:suggestion` | `ai:suggestion` | +| 7 | `TI_AI_007_SystemPromptInRequest` | `llm:request` contient `systemPrompt` de config | request content | +| 8 | `TI_AI_008_ConversationIdTracking` | Requetes utilisent `m_currentConversationId` | conversation | +| 9 | `TI_AI_009_TokenCountingAccumulates` | `m_totalTokens` s'accumule apres chaque reponse | state | +| 10 | `TI_AI_010_StateSerialization` | `getState()`/`setState()` preservent compteurs et conversation | state roundtrip | + +**Implementation:** +```cpp +TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") { + MockIO io; + AIModule module; + TimeSimulator time; + + json config = {{"system_prompt", "Tu es un assistant"}}; + module.setConfiguration(JsonDataNode(config), &io, nullptr); + + io.injectMessage("scheduler:hyperfocus_alert", { + {"sessionMinutes", 130}, + {"task", "coding"} + }); + + module.process(JsonDataNode(time.createInput())); + + REQUIRE(io.wasPublished("ai:suggestion")); + auto msg = io.getLastPublished("ai:suggestion"); + REQUIRE(msg.contains("message")); +} +``` + +--- + +### 2.5 VoiceModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_VOICE_001_AIResponseTriggersSpeak` | Reception `ai:response` publie `voice:speak` | `voice:speak` | +| 2 | `TI_VOICE_002_SuggestionPrioritySpeak` | Reception `ai:suggestion` publie `voice:speak` avec priorite | `voice:speak` priority | +| 3 | `TI_VOICE_003_SpeakingStartedUpdatesState` | `voice:speaking_started` met `m_isSpeaking = true` | state | +| 4 | `TI_VOICE_004_SpeakingEndedUpdatesState` | `voice:speaking_ended` met `m_isSpeaking = false` | state | +| 5 | `TI_VOICE_005_IsIdleReflectsSpeaking` | `isIdle()` retourne `!m_isSpeaking` | interface | +| 6 | `TI_VOICE_006_TranscriptionForwarded` | `voice:transcription` non traite par VoiceModule (forward only) | no re-publish | +| 7 | `TI_VOICE_007_TotalSpokenIncremented` | `m_totalSpoken` incremente apres chaque `speaking_ended` | counter | +| 8 | `TI_VOICE_008_TTSDisabledConfig` | Config `ttsEnabled: false` empeche `voice:speak` | config | +| 9 | `TI_VOICE_009_ToolCommandSpeak` | Command tool `speak` publie `voice:speak` | tool | +| 10 | `TI_VOICE_010_StateSerialization` | `getState()`/`setState()` preservent compteurs | state roundtrip | + +**Implementation:** +```cpp +TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") { + MockIO io; + VoiceModule module; + TimeSimulator time; + + json config = {{"ttsEnabled", true}}; + module.setConfiguration(JsonDataNode(config), &io, nullptr); + + io.injectMessage("ai:suggestion", { + {"message", "Tu devrais faire une pause"}, + {"duration", 5} + }); + + module.process(JsonDataNode(time.createInput())); + + REQUIRE(io.wasPublished("voice:speak")); + auto msg = io.getLastPublished("voice:speak"); + REQUIRE(msg["priority"] == true); +} +``` + +--- + +### 2.6 StorageModule (10 TI) + +| # | Test | Description | Topics Verifies | +|---|------|-------------|-----------------| +| 1 | `TI_STORAGE_001_TaskCompletedSavesSession` | `scheduler:task_completed` publie `storage:save_session` | `storage:save_session` | +| 2 | `TI_STORAGE_002_AppChangedSavesUsage` | `monitoring:app_changed` publie `storage:save_app_usage` | `storage:save_app_usage` | +| 3 | `TI_STORAGE_003_SessionSavedUpdatesLastId` | `storage:session_saved` met a jour `m_lastSessionId` | state | +| 4 | `TI_STORAGE_004_StorageErrorHandled` | `storage:error` log l'erreur sans crash | error handling | +| 5 | `TI_STORAGE_005_PendingSavesTracking` | `m_pendingSaves` incremente/decremente correctement | counter | +| 6 | `TI_STORAGE_006_TotalSavedTracking` | `m_totalSaved` s'accumule | counter | +| 7 | `TI_STORAGE_007_ToolQueryNotes` | Query `query_notes` retourne notes filtrees | `storage:response` | +| 8 | `TI_STORAGE_008_ToolCommandSaveNote` | Command `save_note` ajoute note a `m_notes` | state | +| 9 | `TI_STORAGE_009_NoteTagsFiltering` | Query notes avec tags filtre correctement | filtering | +| 10 | `TI_STORAGE_010_StateSerialization` | `getState()`/`setState()` preservent notes et compteurs | state roundtrip | + +--- + +## Phase 3: Tests MCP (50 TI) + +### 3.1 MCPTypes (15 TI) + +| # | Test | Description | +|---|------|-------------| +| 1 | `TI_TYPES_001_MCPToolToJson` | `MCPTool::toJson()` serialise correctement | +| 2 | `TI_TYPES_002_MCPToolFromJson` | `MCPTool::fromJson()` deserialise correctement | +| 3 | `TI_TYPES_003_MCPToolFromJsonMissingFields` | `fromJson()` avec champs manquants utilise defauts | +| 4 | `TI_TYPES_004_MCPResourceFromJson` | `MCPResource::fromJson()` deserialise correctement | +| 5 | `TI_TYPES_005_MCPToolResultToJson` | `MCPToolResult::toJson()` serialise content et isError | +| 6 | `TI_TYPES_006_MCPCapabilitiesFromJson` | Detection correcte des capabilities | +| 7 | `TI_TYPES_007_MCPCapabilitiesEmpty` | Capabilities vides si pas de champs | +| 8 | `TI_TYPES_008_MCPServerInfoFromJson` | `MCPServerInfo::fromJson()` parse name/version/caps | +| 9 | `TI_TYPES_009_JsonRpcRequestToJson` | Serialisation avec/sans params | +| 10 | `TI_TYPES_010_JsonRpcResponseFromJson` | Parse result ou error | +| 11 | `TI_TYPES_011_JsonRpcResponseIsError` | `isError()` detecte presence de error | +| 12 | `TI_TYPES_012_MCPServerConfigFromJson` | Parse command, args, env, enabled | +| 13 | `TI_TYPES_013_MCPServerConfigEnvExpansion` | Variables env `${VAR}` expandees | +| 14 | `TI_TYPES_014_MCPServerConfigDisabled` | `enabled: false` honore | +| 15 | `TI_TYPES_015_JsonRpcRequestIdIncrement` | IDs uniques et croissants | + +**Implementation:** +```cpp +TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") { + MCPTool tool; + tool.name = "read_file"; + tool.description = "Read a file"; + tool.inputSchema = {{"type", "object"}, {"properties", {{"path", {{"type", "string"}}}}}}; + + json j = tool.toJson(); + + REQUIRE(j["name"] == "read_file"); + REQUIRE(j["description"] == "Read a file"); + REQUIRE(j["inputSchema"]["type"] == "object"); +} + +TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") { + json errorJson = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"error", {{"code", -32600}, {"message", "Invalid Request"}}} + }; + + auto response = JsonRpcResponse::fromJson(errorJson); + + REQUIRE(response.isError() == true); + REQUIRE(response.error.value()["code"] == -32600); +} +``` + +--- + +### 3.2 StdioTransport (20 TI) + +| # | Test | Description | +|---|------|-------------| +| 1 | `TI_TRANSPORT_001_StartSpawnsProcess` | `start()` lance le processus enfant | +| 2 | `TI_TRANSPORT_002_StartFailsInvalidCommand` | `start()` retourne false si commande invalide | +| 3 | `TI_TRANSPORT_003_StopKillsProcess` | `stop()` termine le processus | +| 4 | `TI_TRANSPORT_004_IsRunningReflectsState` | `isRunning()` reflete l'etat reel | +| 5 | `TI_TRANSPORT_005_SendRequestWritesToStdin` | Request serialisee vers stdin | +| 6 | `TI_TRANSPORT_006_SendRequestReadsResponse` | Response lue depuis stdout | +| 7 | `TI_TRANSPORT_007_SendRequestTimeout` | Timeout si pas de reponse | +| 8 | `TI_TRANSPORT_008_SendRequestIdMatching` | Response matchee par ID | +| 9 | `TI_TRANSPORT_009_ConcurrentRequests` | Multiple requests simultanées OK | +| 10 | `TI_TRANSPORT_010_SendNotificationNoResponse` | Notification n'attend pas de reponse | +| 11 | `TI_TRANSPORT_011_ReaderThreadStartsOnStart` | Thread reader demarre avec `start()` | +| 12 | `TI_TRANSPORT_012_ReaderThreadStopsOnStop` | Thread reader s'arrete avec `stop()` | +| 13 | `TI_TRANSPORT_013_JsonParseErrorHandled` | JSON invalide n'crash pas | +| 14 | `TI_TRANSPORT_014_ProcessCrashDetected` | Crash process detecte rapidement | +| 15 | `TI_TRANSPORT_015_LargeMessageHandling` | Messages > 64KB transmis | +| 16 | `TI_TRANSPORT_016_MultilineJsonHandling` | JSON multiline traite correctement | +| 17 | `TI_TRANSPORT_017_EnvVariablesPassedToProcess` | Variables env transmises au processus | +| 18 | `TI_TRANSPORT_018_ArgsPassedToProcess` | Arguments CLI transmis | +| 19 | `TI_TRANSPORT_019_DestructorCleansUp` | Destructeur nettoie ressources | +| 20 | `TI_TRANSPORT_020_RestartAfterStop` | `start()` apres `stop()` fonctionne | + +**Implementation (avec mock process):** +```cpp +// Pour les tests, on peut creer un mini-serveur echo en Python ou utiliser un mock + +TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") { + MCPServerConfig config; + config.command = "cat"; // cat ne repond jamais au JSON-RPC + + StdioTransport transport(config); + transport.start(); + + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + + auto response = transport.sendRequest(request, 100); // 100ms timeout + + REQUIRE(response.isError() == true); + transport.stop(); +} + +// Test avec echo server (Python script) +TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") { + MCPServerConfig config; + config.command = "python"; + config.args = {"tests/fixtures/echo_server.py"}; + + StdioTransport transport(config); + REQUIRE(transport.start() == true); + + JsonRpcRequest request; + request.id = 42; + request.method = "echo"; + request.params = {{"message", "hello"}}; + + auto response = transport.sendRequest(request, 5000); + + REQUIRE(response.isError() == false); + REQUIRE(response.id == 42); + transport.stop(); +} +``` + +--- + +### 3.3 MCPClient (15 TI) + +| # | Test | Description | +|---|------|-------------| +| 1 | `TI_CLIENT_001_LoadConfigValid` | `loadConfig()` parse mcp.json valide | +| 2 | `TI_CLIENT_002_LoadConfigInvalid` | `loadConfig()` gere fichier invalide | +| 3 | `TI_CLIENT_003_LoadConfigMissingFile` | `loadConfig()` retourne false si fichier absent | +| 4 | `TI_CLIENT_004_ConnectAllStartsServers` | `connectAll()` demarre tous les serveurs enabled | +| 5 | `TI_CLIENT_005_ConnectAllSkipsDisabled` | `connectAll()` skip les serveurs `enabled: false` | +| 6 | `TI_CLIENT_006_ConnectSingleServer` | `connect(name)` demarre un seul serveur | +| 7 | `TI_CLIENT_007_DisconnectSingleServer` | `disconnect(name)` arrete un serveur | +| 8 | `TI_CLIENT_008_DisconnectAllCleansUp` | `disconnectAll()` arrete tous les serveurs | +| 9 | `TI_CLIENT_009_ListAllToolsAggregates` | `listAllTools()` combine tools de tous serveurs | +| 10 | `TI_CLIENT_010_ToolNamePrefixed` | Tools prefixes par nom serveur (`server:tool`) | +| 11 | `TI_CLIENT_011_CallToolRoutesToServer` | `callTool()` route vers bon serveur | +| 12 | `TI_CLIENT_012_CallToolInvalidName` | `callTool()` avec nom invalide retourne erreur | +| 13 | `TI_CLIENT_013_CallToolDisconnectedServer` | `callTool()` sur serveur deconnecte retourne erreur | +| 14 | `TI_CLIENT_014_ToolCountAccurate` | `toolCount()` reflete nombre total de tools | +| 15 | `TI_CLIENT_015_IsConnectedAccurate` | `isConnected(name)` reflete etat reel | + +**Implementation:** +```cpp +TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") { + MCPClient client; + + // Utiliser mock transport + client.loadConfig("tests/fixtures/mock_mcp.json"); + // Le mock simule un serveur avec tool "read_file" + + client.connectAll(); + auto tools = client.listAllTools(); + + bool hasPrefix = false; + for (const auto& tool : tools) { + if (tool.name.find(":") != std::string::npos) { + hasPrefix = true; + break; + } + } + + REQUIRE(hasPrefix == true); + client.disconnectAll(); +} + +TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") { + MCPClient client; + client.loadConfig("tests/fixtures/mock_mcp.json"); + client.connectAll(); + + auto result = client.callTool("nonexistent:tool", {}); + + REQUIRE(result.isError == true); + client.disconnectAll(); +} +``` + +--- + +## Phase 4: Fixtures et Scripts de Test + +### 4.1 Echo Server MCP (Python) +`tests/fixtures/echo_server.py` +```python +#!/usr/bin/env python3 +import json +import sys + +def main(): + while True: + line = sys.stdin.readline() + if not line: + break + try: + request = json.loads(line) + response = { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": request.get("params", {}) + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + except: + pass + +if __name__ == "__main__": + main() +``` + +### 4.2 Mock MCP Server (pour tests d'integration) +`tests/fixtures/mock_mcp_server.py` +```python +#!/usr/bin/env python3 +import json +import sys + +TOOLS = [ + {"name": "test_tool", "description": "A test tool", "inputSchema": {"type": "object"}} +] + +def handle_request(request): + method = request.get("method") + + if method == "initialize": + return { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "MockServer", "version": "1.0"} + } + elif method == "tools/list": + return {"tools": TOOLS} + elif method == "tools/call": + return {"content": [{"type": "text", "text": "Tool executed"}]} + + return {"error": {"code": -32601, "message": "Method not found"}} + +def main(): + while True: + line = sys.stdin.readline() + if not line: + break + try: + request = json.loads(line) + result = handle_request(request) + response = {"jsonrpc": "2.0", "id": request.get("id")} + if "error" in result: + response["error"] = result["error"] + else: + response["result"] = result + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + except Exception as e: + pass + +if __name__ == "__main__": + main() +``` + +### 4.3 Config Mock MCP +`tests/fixtures/mock_mcp.json` +```json +{ + "mock_server": { + "command": "python", + "args": ["tests/fixtures/mock_mcp_server.py"], + "enabled": true + }, + "disabled_server": { + "command": "nonexistent", + "enabled": false + } +} +``` + +--- + +## Phase 5: CMakeLists.txt Tests + +```cmake +# ============================================================================ +# Tests d'Integration +# ============================================================================ + +# Fetch Catch2 if not already available +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.4.0 +) +FetchContent_MakeAvailable(Catch2) + +# Test executable +add_executable(aissia_tests + tests/main.cpp + + # Mocks + tests/mocks/MockIO.cpp + + # Module tests + tests/modules/SchedulerModuleTests.cpp + tests/modules/NotificationModuleTests.cpp + tests/modules/MonitoringModuleTests.cpp + tests/modules/AIModuleTests.cpp + tests/modules/VoiceModuleTests.cpp + tests/modules/StorageModuleTests.cpp + + # MCP tests + tests/mcp/MCPTypesTests.cpp + tests/mcp/StdioTransportTests.cpp + tests/mcp/MCPClientTests.cpp +) + +target_link_libraries(aissia_tests PRIVATE + Catch2::Catch2WithMain + GroveEngine::impl + AissiaTools + spdlog::spdlog +) + +target_include_directories(aissia_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/tests +) + +# Copy test fixtures +file(COPY tests/fixtures/ DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures) + +# CTest integration +include(CTest) +include(Catch) +catch_discover_tests(aissia_tests) + +# Custom target for running tests +add_custom_target(test_all + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS aissia_tests + COMMENT "Running all integration tests" +) +``` + +--- + +## Ordre d'Implementation Recommande + +### Sprint 1: Infrastructure (2-3 jours) +1. Creer structure `tests/` +2. Implementer `MockIO.hpp` +3. Implementer `MockTransport.hpp` +4. Implementer `TimeSimulator.hpp` +5. Setup CMakeLists.txt tests +6. Creer fixtures Python + +### Sprint 2: Tests MCPTypes (1 jour) +1. `TI_TYPES_001` a `TI_TYPES_015` +2. Valider serialisation/deserialisation + +### Sprint 3: Tests StdioTransport (2 jours) +1. `TI_TRANSPORT_001` a `TI_TRANSPORT_010` (basic) +2. `TI_TRANSPORT_011` a `TI_TRANSPORT_020` (advanced) + +### Sprint 4: Tests MCPClient (1 jour) +1. `TI_CLIENT_001` a `TI_CLIENT_015` + +### Sprint 5: Tests Modules (3-4 jours) +1. SchedulerModule (10 TI) +2. NotificationModule (10 TI) +3. MonitoringModule (10 TI) +4. AIModule (10 TI) +5. VoiceModule (10 TI) +6. StorageModule (10 TI) + +--- + +## Metriques de Succes + +- [ ] 110 tests implementes +- [ ] Couverture > 80% pour chaque module +- [ ] Tous les tests passent en CI +- [ ] Temps d'execution < 30 secondes +- [ ] Aucune dependance externe (sauf Python pour mock MCP) + +--- + +## Commandes de Build et Execution + +```bash +# Build complet avec tests +cmake -B build -DBUILD_TESTING=ON +cmake --build build + +# Executer tous les tests +cmake --build build --target test_all + +# Executer tests specifiques +./build/aissia_tests "[scheduler]" # Tests scheduler +./build/aissia_tests "[mcp]" # Tests MCP +./build/aissia_tests "[integration]" # Tous les TI + +# Avec verbose +./build/aissia_tests -s -d yes +``` diff --git a/audits/2025-11-26-engine-compliance-audit.md b/audits/2025-11-26-engine-compliance-audit.md index 4034ed6..6dd6932 100644 --- a/audits/2025-11-26-engine-compliance-audit.md +++ b/audits/2025-11-26-engine-compliance-audit.md @@ -1,340 +1,340 @@ -# AUDIT DE CONFORMITÉ GROVEENGINE - AISSIA - -**Date** : 2025-11-26 -**Auditeur** : Claude Code -**Version auditée** : Commit bc3b6cb - ---- - -## RÉSUMÉ EXÉCUTIF - -**Verdict : Le code contourne massivement les principes de GroveEngine.** - -| Module | Lignes | Conformité Engine | Statut | -|--------|--------|-------------------|--------| -| AIModule | 306 | VIOLATION | Infrastructure dans module | -| MonitoringModule | 222 | VIOLATION | Appels OS dans module | -| StorageModule | 273 | VIOLATION | SQLite dans module | -| VoiceModule | 209 | VIOLATION | TTS/COM dans module | -| SchedulerModule | 179 | CONFORME | Logique métier pure | -| NotificationModule | 172 | CONFORME | Logique métier pure | - -**Score global** : 2/6 modules conformes (33%) - ---- - -## RAPPEL DES PRINCIPES GROVEENGINE - -Selon `docs/GROVEENGINE_GUIDE.md` : - -1. **Modules = Pure business logic** (200-300 lignes recommandées) -2. **No infrastructure code in modules** : threading, networking, persistence -3. **All data via IDataNode abstraction** (backend agnostic) -4. **Pull-based message processing** via IIO pub/sub -5. **Hot-reload ready** : sérialiser tout l'état dans `getState()` - ---- - -## VIOLATIONS CRITIQUES - -### 1. AIModule - Networking dans le module - -**Fichier** : `src/modules/AIModule.cpp:146` - -```cpp -nlohmann::json AIModule::agenticLoop(const std::string& userQuery) { - // ... - auto response = m_provider->chat(m_systemPrompt, messages, tools); - // Appel HTTP synchrone bloquant ! -} -``` - -**Violation** : Appels HTTP synchrones directement dans `process()` via la boucle agentique. - -**Impact** : -- Bloque la boucle principale pendant chaque requête LLM (timeout 60s) -- `isIdle()` retourne false pendant l'appel, mais le module reste bloquant -- Hot-reload impossible pendant une requête en cours -- Tous les autres modules sont bloqués - -**Correction requise** : Déléguer les appels LLM à un service infrastructure externe, communication via IIO async. - ---- - -### 2. StorageModule - Persistence dans le module - -**Fichier** : `src/modules/StorageModule.cpp:78-91` - -```cpp -bool StorageModule::openDatabase() { - int rc = sqlite3_open(m_dbPath.c_str(), &m_db); - // Handle SQLite directement dans le module -} -``` - -**Violation** : Gestion directe de SQLite. L'engine préconise `IDataNode` abstractions pour la persistence. - -**Impact** : -- Hot-reload risqué (handle DB ouvert) -- Risque de corruption si reload pendant transaction -- Couplage fort avec SQLite - -**Correction requise** : Service StorageService dans main.cpp, modules communiquent via topics `storage:*`. - ---- - -### 3. MonitoringModule - Appels OS dans le module - -**Fichier** : `src/modules/MonitoringModule.cpp:78-79` - -```cpp -void MonitoringModule::checkCurrentApp(float currentTime) { - std::string newApp = m_tracker->getCurrentAppName(); - // Appelle GetForegroundWindow(), OpenProcess(), etc. -} -``` - -**Violation** : Appels Win32 API dans `process()`. Même encapsulé dans `IWindowTracker`, c'est du code plateforme dans un module hot-reloadable. - -**Impact** : -- Dépendance plateforme dans le module -- Handles système potentiellement orphelins au reload - -**Correction requise** : Service PlatformService qui publie `monitoring:window_info` périodiquement. - ---- - -### 4. VoiceModule - COM/SAPI dans le module - -**Fichier** : `src/modules/VoiceModule.cpp:122` - -```cpp -void VoiceModule::speak(const std::string& text) { - m_ttsEngine->speak(text, true); - // Appel ISpVoice::Speak via COM -} -``` - -**Fichier** : `src/shared/audio/SAPITTSEngine.hpp:26` - -```cpp -HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); -``` - -**Violation** : Initialisation COM et appels SAPI dans le module. - -**Impact** : -- `CoInitializeEx` par thread, hot-reload peut causer des fuites -- Appels asynchrones SAPI difficiles à gérer au shutdown - -**Correction requise** : Service VoiceService dédié, modules envoient `voice:speak`. - ---- - -## PROBLÈMES DE DESIGN - -### 5. Topics incohérents - -**SchedulerModule.h:26** utilise le format slash : -```cpp -// "scheduler/hyperfocus_alert" -``` - -**AIModule.cpp:52** utilise le format colon : -```cpp -m_io->subscribe("scheduler:hyperfocus_alert", subConfig); -``` - -**Standard GroveEngine** : Format `module:event` (colon) - -**Impact** : Les messages ne seront jamais reçus si les formats ne correspondent pas. - ---- - -### 6. SchedulerModule - IIO non utilisé - -**Fichier** : `src/modules/SchedulerModule.cpp:66-68` - -```cpp -void SchedulerModule::checkHyperfocus(float currentTime) { - // ... - // Publier l'alerte (si IO disponible) - // Note: Dans une version complète, on publierait via m_io -} -``` - -**Problème** : Le SchedulerModule a `m_io` mais ne publie JAMAIS rien. Les autres modules s'abonnent à `scheduler:*` mais ne recevront rien. - ---- - -### 7. État non restaurable - StorageModule - -**Fichier** : `src/modules/StorageModule.cpp:246-258` - -```cpp -std::unique_ptr StorageModule::getState() { - state->setBool("isConnected", m_isConnected); - // ... -} - -void StorageModule::setState(const grove::IDataNode& state) { - // NE ROUVRE PAS la connexion DB ! - m_logger->info("Etat restore..."); -} -``` - -**Problème** : `setState()` ne restaure pas la connexion SQLite. Après hot-reload, le module est dans un état incohérent. - ---- - -### 8. Libraries statiques dans modules - -**CMakeLists.txt:86-101** : - -```cmake -add_library(AissiaLLM STATIC ...) -target_link_libraries(AIModule PRIVATE AissiaLLM) -``` - -**Problème** : Les libs `AissiaLLM`, `AissiaPlatform`, `AissiaAudio` sont compilées en STATIC et linkées dans chaque .so. - -**Impact** : -- Code dupliqué dans chaque module -- Hot-reload ne rafraîchit pas ces libs -- Pas de partage d'état entre modules - ---- - -### 9. Dépassement limite de lignes - -| Module | Lignes | Limite recommandée | -|--------|--------|-------------------| -| AIModule | 306 | 200-300 | - -Le dépassement est mineur mais symptomatique : le module fait trop de choses. - ---- - -## CE QUI EST CONFORME - -### SchedulerModule & NotificationModule - -Ces deux modules respectent les principes : -- Logique métier pure -- Pas d'appels système -- État sérialisable -- Taille appropriée - -### Structure IModule - -Tous les modules implémentent correctement : -- `process()` -- `setConfiguration()` -- `getState()` / `setState()` -- `getHealthStatus()` -- `shutdown()` -- Exports C `createModule()` / `destroyModule()` - -### main.cpp - -La boucle principale est bien implémentée : -- FileWatcher pour hot-reload -- Frame timing à 10Hz -- Signal handling propre -- Chargement/déchargement correct - ---- - -## ARCHITECTURE RECOMMANDÉE - -### Actuelle (INCORRECTE) - -``` -┌─────────────────────────────────────────────────────────┐ -│ main.cpp │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │AIModule │ │Storage │ │Monitor │ │Voice │ │ -│ │ +HTTP │ │ +SQLite │ │ +Win32 │ │ +COM │ │ -│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ -└─────────────────────────────────────────────────────────┘ - Infrastructure DANS les modules = VIOLATION -``` - -### Corrigée (CONFORME) - -``` -┌─────────────────────────────────────────────────────────┐ -│ main.cpp │ -│ │ -│ ┌─────────────── INFRASTRUCTURE ──────────────────┐ │ -│ │ LLMService │ StorageService │ PlatformService │ │ │ -│ │ (async) │ (SQLite) │ (Win32) │ │ │ -│ └──────────────────────────────────────────────────┘ │ -│ ↑↓ IIO pub/sub (async, non-bloquant) │ -│ ┌─────────────── MODULES (hot-reload) ────────────┐ │ -│ │ AIModule │ StorageModule │ MonitoringModule │ │ │ -│ │ (logic) │ (logic) │ (logic) │ │ │ -│ └──────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - Infrastructure HORS modules = CONFORME -``` - -### Flux de données corrigé - -``` -User query → voice:transcription → AIModule -AIModule → ai:query_request → LLMService (async) -LLMService → ai:response → AIModule -AIModule → ai:response → VoiceModule → voice:speak -``` - ---- - -## ACTIONS REQUISES - -### Priorité HAUTE - -1. **Extraire LLM de AIModule** - - Créer `LLMService` dans main.cpp ou service dédié - - AIModule publie `ai:query_request`, reçoit `ai:response` - - Appels HTTP dans thread séparé - -2. **Extraire SQLite de StorageModule** - - Créer `StorageService` - - Modules publient `storage:save_*`, reçoivent `storage:result` - -3. **Extraire Win32 de MonitoringModule** - - Créer `PlatformService` - - Publie `platform:window_changed` périodiquement - -4. **Extraire TTS de VoiceModule** - - Créer `VoiceService` - - Modules publient `voice:speak` - -### Priorité MOYENNE - -5. **Corriger format topics** : Tout en `module:event` -6. **Implémenter publish dans SchedulerModule** -7. **Corriger setState dans StorageModule** - -### Priorité BASSE - -8. **Refactorer libs STATIC en services** -9. **Réduire AIModule sous 300 lignes** - ---- - -## CONCLUSION - -Le code actuel **simule** l'utilisation de GroveEngine mais le **contourne** en plaçant l'infrastructure directement dans les modules. - -Les modules ne sont **pas véritablement hot-reloadable** car ils : -1. Possèdent des ressources système (DB handles, COM objects) -2. Font des appels bloquants (HTTP 60s timeout, TTS) -3. Ne communiquent pas correctement via IIO - -**Refactoring majeur requis** pour extraire l'infrastructure des modules vers des services dédiés dans main.cpp. - ---- - -*Audit généré automatiquement par Claude Code* +# AUDIT DE CONFORMITÉ GROVEENGINE - AISSIA + +**Date** : 2025-11-26 +**Auditeur** : Claude Code +**Version auditée** : Commit bc3b6cb + +--- + +## RÉSUMÉ EXÉCUTIF + +**Verdict : Le code contourne massivement les principes de GroveEngine.** + +| Module | Lignes | Conformité Engine | Statut | +|--------|--------|-------------------|--------| +| AIModule | 306 | VIOLATION | Infrastructure dans module | +| MonitoringModule | 222 | VIOLATION | Appels OS dans module | +| StorageModule | 273 | VIOLATION | SQLite dans module | +| VoiceModule | 209 | VIOLATION | TTS/COM dans module | +| SchedulerModule | 179 | CONFORME | Logique métier pure | +| NotificationModule | 172 | CONFORME | Logique métier pure | + +**Score global** : 2/6 modules conformes (33%) + +--- + +## RAPPEL DES PRINCIPES GROVEENGINE + +Selon `docs/GROVEENGINE_GUIDE.md` : + +1. **Modules = Pure business logic** (200-300 lignes recommandées) +2. **No infrastructure code in modules** : threading, networking, persistence +3. **All data via IDataNode abstraction** (backend agnostic) +4. **Pull-based message processing** via IIO pub/sub +5. **Hot-reload ready** : sérialiser tout l'état dans `getState()` + +--- + +## VIOLATIONS CRITIQUES + +### 1. AIModule - Networking dans le module + +**Fichier** : `src/modules/AIModule.cpp:146` + +```cpp +nlohmann::json AIModule::agenticLoop(const std::string& userQuery) { + // ... + auto response = m_provider->chat(m_systemPrompt, messages, tools); + // Appel HTTP synchrone bloquant ! +} +``` + +**Violation** : Appels HTTP synchrones directement dans `process()` via la boucle agentique. + +**Impact** : +- Bloque la boucle principale pendant chaque requête LLM (timeout 60s) +- `isIdle()` retourne false pendant l'appel, mais le module reste bloquant +- Hot-reload impossible pendant une requête en cours +- Tous les autres modules sont bloqués + +**Correction requise** : Déléguer les appels LLM à un service infrastructure externe, communication via IIO async. + +--- + +### 2. StorageModule - Persistence dans le module + +**Fichier** : `src/modules/StorageModule.cpp:78-91` + +```cpp +bool StorageModule::openDatabase() { + int rc = sqlite3_open(m_dbPath.c_str(), &m_db); + // Handle SQLite directement dans le module +} +``` + +**Violation** : Gestion directe de SQLite. L'engine préconise `IDataNode` abstractions pour la persistence. + +**Impact** : +- Hot-reload risqué (handle DB ouvert) +- Risque de corruption si reload pendant transaction +- Couplage fort avec SQLite + +**Correction requise** : Service StorageService dans main.cpp, modules communiquent via topics `storage:*`. + +--- + +### 3. MonitoringModule - Appels OS dans le module + +**Fichier** : `src/modules/MonitoringModule.cpp:78-79` + +```cpp +void MonitoringModule::checkCurrentApp(float currentTime) { + std::string newApp = m_tracker->getCurrentAppName(); + // Appelle GetForegroundWindow(), OpenProcess(), etc. +} +``` + +**Violation** : Appels Win32 API dans `process()`. Même encapsulé dans `IWindowTracker`, c'est du code plateforme dans un module hot-reloadable. + +**Impact** : +- Dépendance plateforme dans le module +- Handles système potentiellement orphelins au reload + +**Correction requise** : Service PlatformService qui publie `monitoring:window_info` périodiquement. + +--- + +### 4. VoiceModule - COM/SAPI dans le module + +**Fichier** : `src/modules/VoiceModule.cpp:122` + +```cpp +void VoiceModule::speak(const std::string& text) { + m_ttsEngine->speak(text, true); + // Appel ISpVoice::Speak via COM +} +``` + +**Fichier** : `src/shared/audio/SAPITTSEngine.hpp:26` + +```cpp +HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); +``` + +**Violation** : Initialisation COM et appels SAPI dans le module. + +**Impact** : +- `CoInitializeEx` par thread, hot-reload peut causer des fuites +- Appels asynchrones SAPI difficiles à gérer au shutdown + +**Correction requise** : Service VoiceService dédié, modules envoient `voice:speak`. + +--- + +## PROBLÈMES DE DESIGN + +### 5. Topics incohérents + +**SchedulerModule.h:26** utilise le format slash : +```cpp +// "scheduler/hyperfocus_alert" +``` + +**AIModule.cpp:52** utilise le format colon : +```cpp +m_io->subscribe("scheduler:hyperfocus_alert", subConfig); +``` + +**Standard GroveEngine** : Format `module:event` (colon) + +**Impact** : Les messages ne seront jamais reçus si les formats ne correspondent pas. + +--- + +### 6. SchedulerModule - IIO non utilisé + +**Fichier** : `src/modules/SchedulerModule.cpp:66-68` + +```cpp +void SchedulerModule::checkHyperfocus(float currentTime) { + // ... + // Publier l'alerte (si IO disponible) + // Note: Dans une version complète, on publierait via m_io +} +``` + +**Problème** : Le SchedulerModule a `m_io` mais ne publie JAMAIS rien. Les autres modules s'abonnent à `scheduler:*` mais ne recevront rien. + +--- + +### 7. État non restaurable - StorageModule + +**Fichier** : `src/modules/StorageModule.cpp:246-258` + +```cpp +std::unique_ptr StorageModule::getState() { + state->setBool("isConnected", m_isConnected); + // ... +} + +void StorageModule::setState(const grove::IDataNode& state) { + // NE ROUVRE PAS la connexion DB ! + m_logger->info("Etat restore..."); +} +``` + +**Problème** : `setState()` ne restaure pas la connexion SQLite. Après hot-reload, le module est dans un état incohérent. + +--- + +### 8. Libraries statiques dans modules + +**CMakeLists.txt:86-101** : + +```cmake +add_library(AissiaLLM STATIC ...) +target_link_libraries(AIModule PRIVATE AissiaLLM) +``` + +**Problème** : Les libs `AissiaLLM`, `AissiaPlatform`, `AissiaAudio` sont compilées en STATIC et linkées dans chaque .so. + +**Impact** : +- Code dupliqué dans chaque module +- Hot-reload ne rafraîchit pas ces libs +- Pas de partage d'état entre modules + +--- + +### 9. Dépassement limite de lignes + +| Module | Lignes | Limite recommandée | +|--------|--------|-------------------| +| AIModule | 306 | 200-300 | + +Le dépassement est mineur mais symptomatique : le module fait trop de choses. + +--- + +## CE QUI EST CONFORME + +### SchedulerModule & NotificationModule + +Ces deux modules respectent les principes : +- Logique métier pure +- Pas d'appels système +- État sérialisable +- Taille appropriée + +### Structure IModule + +Tous les modules implémentent correctement : +- `process()` +- `setConfiguration()` +- `getState()` / `setState()` +- `getHealthStatus()` +- `shutdown()` +- Exports C `createModule()` / `destroyModule()` + +### main.cpp + +La boucle principale est bien implémentée : +- FileWatcher pour hot-reload +- Frame timing à 10Hz +- Signal handling propre +- Chargement/déchargement correct + +--- + +## ARCHITECTURE RECOMMANDÉE + +### Actuelle (INCORRECTE) + +``` +┌─────────────────────────────────────────────────────────┐ +│ main.cpp │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │AIModule │ │Storage │ │Monitor │ │Voice │ │ +│ │ +HTTP │ │ +SQLite │ │ +Win32 │ │ +COM │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────────┘ + Infrastructure DANS les modules = VIOLATION +``` + +### Corrigée (CONFORME) + +``` +┌─────────────────────────────────────────────────────────┐ +│ main.cpp │ +│ │ +│ ┌─────────────── INFRASTRUCTURE ──────────────────┐ │ +│ │ LLMService │ StorageService │ PlatformService │ │ │ +│ │ (async) │ (SQLite) │ (Win32) │ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ↑↓ IIO pub/sub (async, non-bloquant) │ +│ ┌─────────────── MODULES (hot-reload) ────────────┐ │ +│ │ AIModule │ StorageModule │ MonitoringModule │ │ │ +│ │ (logic) │ (logic) │ (logic) │ │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + Infrastructure HORS modules = CONFORME +``` + +### Flux de données corrigé + +``` +User query → voice:transcription → AIModule +AIModule → ai:query_request → LLMService (async) +LLMService → ai:response → AIModule +AIModule → ai:response → VoiceModule → voice:speak +``` + +--- + +## ACTIONS REQUISES + +### Priorité HAUTE + +1. **Extraire LLM de AIModule** + - Créer `LLMService` dans main.cpp ou service dédié + - AIModule publie `ai:query_request`, reçoit `ai:response` + - Appels HTTP dans thread séparé + +2. **Extraire SQLite de StorageModule** + - Créer `StorageService` + - Modules publient `storage:save_*`, reçoivent `storage:result` + +3. **Extraire Win32 de MonitoringModule** + - Créer `PlatformService` + - Publie `platform:window_changed` périodiquement + +4. **Extraire TTS de VoiceModule** + - Créer `VoiceService` + - Modules publient `voice:speak` + +### Priorité MOYENNE + +5. **Corriger format topics** : Tout en `module:event` +6. **Implémenter publish dans SchedulerModule** +7. **Corriger setState dans StorageModule** + +### Priorité BASSE + +8. **Refactorer libs STATIC en services** +9. **Réduire AIModule sous 300 lignes** + +--- + +## CONCLUSION + +Le code actuel **simule** l'utilisation de GroveEngine mais le **contourne** en plaçant l'infrastructure directement dans les modules. + +Les modules ne sont **pas véritablement hot-reloadable** car ils : +1. Possèdent des ressources système (DB handles, COM objects) +2. Font des appels bloquants (HTTP 60s timeout, TTS) +3. Ne communiquent pas correctement via IIO + +**Refactoring majeur requis** pour extraire l'infrastructure des modules vers des services dédiés dans main.cpp. + +--- + +*Audit généré automatiquement par Claude Code* diff --git a/config/README_MCP.md b/config/README_MCP.md index 911c6bc..7babee3 100644 --- a/config/README_MCP.md +++ b/config/README_MCP.md @@ -1,305 +1,305 @@ -# AISSIA MCP Configuration for Claude Code - -This directory contains an example MCP (Model Context Protocol) configuration for integrating AISSIA with Claude Code. - -## Quick Setup - -### 1. Locate Claude Code MCP Settings - -The MCP configuration file location depends on your operating system: - -**Windows**: -``` -%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json -``` - -Full path example: -``` -C:\Users\YourUsername\AppData\Roaming\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json -``` - -**macOS**: -``` -~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json -``` - -**Linux**: -``` -~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json -``` - -### 2. Copy Configuration - -Copy the contents of `claude_code_mcp_config.json` to the Claude Code MCP settings file. - -**Important**: Update the `command` path to point to your actual AISSIA executable: - -```json -{ - "mcpServers": { - "aissia": { - "command": "C:\\path\\to\\your\\aissia\\build\\aissia.exe", - "args": ["--mcp-server"], - "disabled": false - } - } -} -``` - -### 3. Restart Claude Code - -Restart VS Code (or reload window: `Ctrl+Shift+P` → "Developer: Reload Window") to apply the changes. - -### 4. Verify Integration - -Open Claude Code and check that AISSIA tools are available: - -``` -You: Can you list the available MCP servers? -Claude: I have access to the following MCP servers: -- aissia: 13 tools available -``` - -## Available Tools - -Once configured, Claude will have access to these 13 AISSIA tools: - -### AISSIA Core (5 tools) -1. **chat_with_aissia** ⭐ - Dialogue with AISSIA's AI assistant (Claude Sonnet 4) -2. **transcribe_audio** - Transcribe audio files to text -3. **text_to_speech** - Convert text to speech audio files -4. **save_memory** - Save notes to AISSIA's persistent storage -5. **search_memories** - Search through saved memories - -### File System (8 tools) -6. **read_file** - Read file contents -7. **write_file** - Write content to files -8. **list_directory** - List files in a directory -9. **search_files** - Search for files by pattern -10. **file_exists** - Check if a file exists -11. **create_directory** - Create directories -12. **delete_file** - Delete files -13. **move_file** - Move or rename files - -## Configuration Options - -### Basic Configuration - -```json -{ - "mcpServers": { - "aissia": { - "command": "path/to/aissia.exe", - "args": ["--mcp-server"], - "disabled": false - } - } -} -``` - -### With Auto-Approval - -To skip confirmation prompts for specific tools: - -```json -{ - "mcpServers": { - "aissia": { - "command": "path/to/aissia.exe", - "args": ["--mcp-server"], - "disabled": false, - "alwaysAllow": ["chat_with_aissia", "read_file", "write_file"] - } - } -} -``` - -### Disable Server - -To temporarily disable AISSIA without removing the configuration: - -```json -{ - "mcpServers": { - "aissia": { - "command": "path/to/aissia.exe", - "args": ["--mcp-server"], - "disabled": true // <-- Set to true - } - } -} -``` - -## Prerequisites - -Before running AISSIA in MCP server mode, ensure these config files exist: - -### config/ai.json -```json -{ - "provider": "claude", - "api_key": "sk-ant-api03-...", - "model": "claude-sonnet-4-20250514", - "max_iterations": 10, - "system_prompt": "Tu es AISSIA, un assistant personnel intelligent..." -} -``` - -### config/storage.json -```json -{ - "database_path": "./data/aissia.db", - "journal_mode": "WAL", - "busy_timeout_ms": 5000 -} -``` - -### config/voice.json (optional) -```json -{ - "tts": { - "enabled": true, - "rate": 0, - "volume": 80 - }, - "stt": { - "active_mode": { - "enabled": false - } - } -} -``` - -## Testing MCP Server - -You can test the MCP server independently before integrating with Claude Code: - -```bash -# Test tools/list -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server - -# Test chat_with_aissia tool -echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"What time is it?"}}}' | ./build/aissia.exe --mcp-server -``` - -## Troubleshooting - -### "Server not found" or "Connection failed" - -1. Verify the `command` path is correct and points to `aissia.exe` -2. Make sure AISSIA compiles successfully: `cmake --build build` -3. Test running `./build/aissia.exe --mcp-server` manually - -### "LLMService not initialized" - -AISSIA requires `config/ai.json` with a valid Claude API key. Check: -1. File exists: `config/ai.json` -2. API key is valid: `"api_key": "sk-ant-api03-..."` -3. Provider is set: `"provider": "claude"` - -### "Tool execution failed" - -Some tools have limited functionality in Phase 8 MVP: -- `transcribe_audio` - Not fully implemented yet (STT file support needed) -- `text_to_speech` - Not fully implemented yet (TTS file output needed) -- `save_memory` - Not fully implemented yet (Storage sync methods needed) -- `search_memories` - Not fully implemented yet (Storage sync methods needed) - -These will be completed in Phase 8.1 and 8.2. - -### Server starts but tools don't appear - -1. Check Claude Code logs: `Ctrl+Shift+P` → "Developer: Open Extension Logs" -2. Look for MCP server initialization errors -3. Verify JSON syntax in the MCP configuration file - -## Example Use Cases - -### 1. Ask AISSIA for Help - -``` -You: Use chat_with_aissia to ask "What are my top productivity patterns?" -Claude: [calls chat_with_aissia tool] -AISSIA: Based on your activity data, your most productive hours are 9-11 AM... -``` - -### 2. File Operations + AI - -``` -You: Read my TODO.md file and ask AISSIA to prioritize the tasks -Claude: [calls read_file("TODO.md")] -Claude: [calls chat_with_aissia with task list] -AISSIA: Here's a prioritized version based on urgency and dependencies... -``` - -### 3. Voice Transcription (future) - -``` -You: Transcribe meeting-notes.wav to text -Claude: [calls transcribe_audio("meeting-notes.wav")] -Result: "Welcome to the team meeting. Today we're discussing..." -``` - -## Advanced Configuration - -### Multiple MCP Servers - -You can configure multiple MCP servers alongside AISSIA: - -```json -{ - "mcpServers": { - "aissia": { - "command": "C:\\path\\to\\aissia\\build\\aissia.exe", - "args": ["--mcp-server"], - "disabled": false - }, - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:\\Users"], - "disabled": false - }, - "brave-search": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-brave-search"], - "disabled": false, - "env": { - "BRAVE_API_KEY": "your-brave-api-key" - } - } - } -} -``` - -### Environment Variables - -Pass environment variables to AISSIA: - -```json -{ - "mcpServers": { - "aissia": { - "command": "C:\\path\\to\\aissia\\build\\aissia.exe", - "args": ["--mcp-server"], - "disabled": false, - "env": { - "AISSIA_LOG_LEVEL": "debug", - "CLAUDE_API_KEY": "sk-ant-api03-..." - } - } - } -} -``` - -## References - -- **Full Documentation**: `docs/CLAUDE_CODE_INTEGRATION.md` -- **MCP Specification**: https://github.com/anthropics/mcp -- **Claude Code Extension**: https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev - -## Support - -For issues or questions: -1. Check the full documentation: `docs/CLAUDE_CODE_INTEGRATION.md` -2. Review logs: AISSIA writes to stderr in MCP mode -3. Test manually: `./build/aissia.exe --mcp-server` and send JSON-RPC requests +# AISSIA MCP Configuration for Claude Code + +This directory contains an example MCP (Model Context Protocol) configuration for integrating AISSIA with Claude Code. + +## Quick Setup + +### 1. Locate Claude Code MCP Settings + +The MCP configuration file location depends on your operating system: + +**Windows**: +``` +%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json +``` + +Full path example: +``` +C:\Users\YourUsername\AppData\Roaming\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json +``` + +**macOS**: +``` +~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json +``` + +**Linux**: +``` +~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json +``` + +### 2. Copy Configuration + +Copy the contents of `claude_code_mcp_config.json` to the Claude Code MCP settings file. + +**Important**: Update the `command` path to point to your actual AISSIA executable: + +```json +{ + "mcpServers": { + "aissia": { + "command": "C:\\path\\to\\your\\aissia\\build\\aissia.exe", + "args": ["--mcp-server"], + "disabled": false + } + } +} +``` + +### 3. Restart Claude Code + +Restart VS Code (or reload window: `Ctrl+Shift+P` → "Developer: Reload Window") to apply the changes. + +### 4. Verify Integration + +Open Claude Code and check that AISSIA tools are available: + +``` +You: Can you list the available MCP servers? +Claude: I have access to the following MCP servers: +- aissia: 13 tools available +``` + +## Available Tools + +Once configured, Claude will have access to these 13 AISSIA tools: + +### AISSIA Core (5 tools) +1. **chat_with_aissia** ⭐ - Dialogue with AISSIA's AI assistant (Claude Sonnet 4) +2. **transcribe_audio** - Transcribe audio files to text +3. **text_to_speech** - Convert text to speech audio files +4. **save_memory** - Save notes to AISSIA's persistent storage +5. **search_memories** - Search through saved memories + +### File System (8 tools) +6. **read_file** - Read file contents +7. **write_file** - Write content to files +8. **list_directory** - List files in a directory +9. **search_files** - Search for files by pattern +10. **file_exists** - Check if a file exists +11. **create_directory** - Create directories +12. **delete_file** - Delete files +13. **move_file** - Move or rename files + +## Configuration Options + +### Basic Configuration + +```json +{ + "mcpServers": { + "aissia": { + "command": "path/to/aissia.exe", + "args": ["--mcp-server"], + "disabled": false + } + } +} +``` + +### With Auto-Approval + +To skip confirmation prompts for specific tools: + +```json +{ + "mcpServers": { + "aissia": { + "command": "path/to/aissia.exe", + "args": ["--mcp-server"], + "disabled": false, + "alwaysAllow": ["chat_with_aissia", "read_file", "write_file"] + } + } +} +``` + +### Disable Server + +To temporarily disable AISSIA without removing the configuration: + +```json +{ + "mcpServers": { + "aissia": { + "command": "path/to/aissia.exe", + "args": ["--mcp-server"], + "disabled": true // <-- Set to true + } + } +} +``` + +## Prerequisites + +Before running AISSIA in MCP server mode, ensure these config files exist: + +### config/ai.json +```json +{ + "provider": "claude", + "api_key": "sk-ant-api03-...", + "model": "claude-sonnet-4-20250514", + "max_iterations": 10, + "system_prompt": "Tu es AISSIA, un assistant personnel intelligent..." +} +``` + +### config/storage.json +```json +{ + "database_path": "./data/aissia.db", + "journal_mode": "WAL", + "busy_timeout_ms": 5000 +} +``` + +### config/voice.json (optional) +```json +{ + "tts": { + "enabled": true, + "rate": 0, + "volume": 80 + }, + "stt": { + "active_mode": { + "enabled": false + } + } +} +``` + +## Testing MCP Server + +You can test the MCP server independently before integrating with Claude Code: + +```bash +# Test tools/list +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server + +# Test chat_with_aissia tool +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"What time is it?"}}}' | ./build/aissia.exe --mcp-server +``` + +## Troubleshooting + +### "Server not found" or "Connection failed" + +1. Verify the `command` path is correct and points to `aissia.exe` +2. Make sure AISSIA compiles successfully: `cmake --build build` +3. Test running `./build/aissia.exe --mcp-server` manually + +### "LLMService not initialized" + +AISSIA requires `config/ai.json` with a valid Claude API key. Check: +1. File exists: `config/ai.json` +2. API key is valid: `"api_key": "sk-ant-api03-..."` +3. Provider is set: `"provider": "claude"` + +### "Tool execution failed" + +Some tools have limited functionality in Phase 8 MVP: +- `transcribe_audio` - Not fully implemented yet (STT file support needed) +- `text_to_speech` - Not fully implemented yet (TTS file output needed) +- `save_memory` - Not fully implemented yet (Storage sync methods needed) +- `search_memories` - Not fully implemented yet (Storage sync methods needed) + +These will be completed in Phase 8.1 and 8.2. + +### Server starts but tools don't appear + +1. Check Claude Code logs: `Ctrl+Shift+P` → "Developer: Open Extension Logs" +2. Look for MCP server initialization errors +3. Verify JSON syntax in the MCP configuration file + +## Example Use Cases + +### 1. Ask AISSIA for Help + +``` +You: Use chat_with_aissia to ask "What are my top productivity patterns?" +Claude: [calls chat_with_aissia tool] +AISSIA: Based on your activity data, your most productive hours are 9-11 AM... +``` + +### 2. File Operations + AI + +``` +You: Read my TODO.md file and ask AISSIA to prioritize the tasks +Claude: [calls read_file("TODO.md")] +Claude: [calls chat_with_aissia with task list] +AISSIA: Here's a prioritized version based on urgency and dependencies... +``` + +### 3. Voice Transcription (future) + +``` +You: Transcribe meeting-notes.wav to text +Claude: [calls transcribe_audio("meeting-notes.wav")] +Result: "Welcome to the team meeting. Today we're discussing..." +``` + +## Advanced Configuration + +### Multiple MCP Servers + +You can configure multiple MCP servers alongside AISSIA: + +```json +{ + "mcpServers": { + "aissia": { + "command": "C:\\path\\to\\aissia\\build\\aissia.exe", + "args": ["--mcp-server"], + "disabled": false + }, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:\\Users"], + "disabled": false + }, + "brave-search": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "disabled": false, + "env": { + "BRAVE_API_KEY": "your-brave-api-key" + } + } + } +} +``` + +### Environment Variables + +Pass environment variables to AISSIA: + +```json +{ + "mcpServers": { + "aissia": { + "command": "C:\\path\\to\\aissia\\build\\aissia.exe", + "args": ["--mcp-server"], + "disabled": false, + "env": { + "AISSIA_LOG_LEVEL": "debug", + "CLAUDE_API_KEY": "sk-ant-api03-..." + } + } + } +} +``` + +## References + +- **Full Documentation**: `docs/CLAUDE_CODE_INTEGRATION.md` +- **MCP Specification**: https://github.com/anthropics/mcp +- **Claude Code Extension**: https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev + +## Support + +For issues or questions: +1. Check the full documentation: `docs/CLAUDE_CODE_INTEGRATION.md` +2. Review logs: AISSIA writes to stderr in MCP mode +3. Test manually: `./build/aissia.exe --mcp-server` and send JSON-RPC requests diff --git a/config/claude_code_mcp_config.json b/config/claude_code_mcp_config.json index 885f84c..2766c62 100644 --- a/config/claude_code_mcp_config.json +++ b/config/claude_code_mcp_config.json @@ -1,10 +1,10 @@ -{ - "mcpServers": { - "aissia": { - "command": "C:\\Users\\alexi\\Documents\\projects\\aissia\\build\\aissia.exe", - "args": ["--mcp-server"], - "disabled": false, - "alwaysAllow": [] - } - } -} +{ + "mcpServers": { + "aissia": { + "command": "C:\\Users\\alexi\\Documents\\projects\\aissia\\build\\aissia.exe", + "args": ["--mcp-server"], + "disabled": false, + "alwaysAllow": [] + } + } +} diff --git a/create_test_audio.py b/create_test_audio.py index 17acbfc..903bf97 100644 --- a/create_test_audio.py +++ b/create_test_audio.py @@ -1,35 +1,35 @@ -#!/usr/bin/env python3 -"""Generate test audio WAV file for STT testing""" - -import sys - -try: - from gtts import gTTS - import os - from pydub import AudioSegment - - # Generate French test audio - text = "Bonjour, ceci est un test de reconnaissance vocale." - print(f"Generating audio: '{text}'") - - # Create TTS - tts = gTTS(text=text, lang='fr', slow=False) - tts.save("test_audio_temp.mp3") - print("✓ Generated MP3") - - # Convert to WAV (16kHz, mono, 16-bit PCM) - audio = AudioSegment.from_mp3("test_audio_temp.mp3") - audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) - audio.export("test_audio.wav", format="wav") - print("✓ Converted to WAV (16kHz, mono, 16-bit)") - - # Cleanup - os.remove("test_audio_temp.mp3") - print("✓ Saved as test_audio.wav") - print(f"Duration: {len(audio)/1000:.1f}s") - -except ImportError as e: - print(f"Missing dependency: {e}") - print("\nInstall with: pip install gtts pydub") - print("Note: pydub also requires ffmpeg") - sys.exit(1) +#!/usr/bin/env python3 +"""Generate test audio WAV file for STT testing""" + +import sys + +try: + from gtts import gTTS + import os + from pydub import AudioSegment + + # Generate French test audio + text = "Bonjour, ceci est un test de reconnaissance vocale." + print(f"Generating audio: '{text}'") + + # Create TTS + tts = gTTS(text=text, lang='fr', slow=False) + tts.save("test_audio_temp.mp3") + print("✓ Generated MP3") + + # Convert to WAV (16kHz, mono, 16-bit PCM) + audio = AudioSegment.from_mp3("test_audio_temp.mp3") + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + audio.export("test_audio.wav", format="wav") + print("✓ Converted to WAV (16kHz, mono, 16-bit)") + + # Cleanup + os.remove("test_audio_temp.mp3") + print("✓ Saved as test_audio.wav") + print(f"Duration: {len(audio)/1000:.1f}s") + +except ImportError as e: + print(f"Missing dependency: {e}") + print("\nInstall with: pip install gtts pydub") + print("Note: pydub also requires ffmpeg") + sys.exit(1) diff --git a/create_test_audio_simple.py b/create_test_audio_simple.py index eeff8f2..b453047 100644 --- a/create_test_audio_simple.py +++ b/create_test_audio_simple.py @@ -1,38 +1,38 @@ -#!/usr/bin/env python3 -"""Generate simple test audio WAV file using only stdlib""" - -import wave -import struct -import math - -# WAV parameters -sample_rate = 16000 -duration = 2 # seconds -frequency = 440 # Hz (A4 note) - -# Generate sine wave samples -samples = [] -for i in range(int(sample_rate * duration)): - # Sine wave value (-1.0 to 1.0) - value = math.sin(2.0 * math.pi * frequency * i / sample_rate) - - # Convert to 16-bit PCM (-32768 to 32767) - sample = int(value * 32767) - samples.append(sample) - -# Write WAV file -with wave.open("test_audio.wav", "w") as wav_file: - # Set parameters (1 channel, 2 bytes per sample, 16kHz) - wav_file.setnchannels(1) - wav_file.setsampwidth(2) - wav_file.setframerate(sample_rate) - - # Write frames - for sample in samples: - wav_file.writeframes(struct.pack('publish("llm:request", data); -// ... wait for response on "llm:response" topic - -// MCP mode (sync) -auto response = llmService->sendMessageSync(message, conversationId); -// immediate result -``` - -This is necessary because: -1. MCP protocol expects immediate JSON-RPC responses -2. No event loop in MCP server mode (stdin/stdout blocking I/O) -3. Simplifies integration with external tools - -### Service Integration - -``` -MCPServer (stdio JSON-RPC) - ↓ -MCPServerTools (tool handlers) - ↓ -Services (sync methods) - ├── LLMService::sendMessageSync() - ├── VoiceService::transcribeFileSync() - ├── VoiceService::textToSpeechSync() - └── StorageService (stub implementations) -``` - -### Tool Registry - -All tools are registered in a central `ToolRegistry`: - -```cpp -ToolRegistry registry; - -// 1. Internal tools (get_current_time) -registry.registerTool("get_current_time", ...); - -// 2. FileSystem tools (8 tools) -for (auto& toolDef : FileSystemTools::getToolDefinitions()) { - registry.registerTool(toolDef); -} - -// 3. AISSIA tools (5 tools) -MCPServerTools aissiaTools(llmService, storageService, voiceService); -for (const auto& toolDef : aissiaTools.getToolDefinitions()) { - registry.registerTool(toolDef); -} -``` - -Total: **13 tools** - -## Configuration Files - -AISSIA MCP Server requires these config files (same as normal mode): - -- `config/ai.json` - LLM provider configuration (Claude API key) -- `config/storage.json` - Database path and settings -- `config/voice.json` - TTS/STT engine settings - -**Important**: Make sure these files are present before running `--mcp-server` mode. - -## Limitations (Phase 8 MVP) - -1. **STT/TTS file operations**: `transcribe_audio` and `text_to_speech` are not fully implemented yet - - STT service needs file transcription support (currently only streaming) - - TTS engine needs file output support (currently only direct playback) - -2. **Storage sync methods**: `save_memory` and `search_memories` return "not implemented" errors - - StorageService needs `saveMemorySync()` and `searchMemoriesSync()` methods - - Current storage only works via async pub/sub - -3. **No hot-reload**: MCP server mode doesn't load hot-reloadable modules - - Only services and tools are available - - No SchedulerModule, MonitoringModule, etc. - -4. **Single-threaded**: MCP server runs synchronously on main thread - - LLMService worker thread still runs for agentic loops - - But overall server is blocking on stdin - -## Roadmap - -### Phase 8.1 - Complete STT/TTS Sync Methods -- [ ] Implement `VoiceService::transcribeFileSync()` using STT engines -- [ ] Implement `VoiceService::textToSpeechSync()` with file output -- [ ] Test audio file transcription via MCP - -### Phase 8.2 - Storage Sync Methods -- [ ] Implement `StorageService::saveMemorySync()` -- [ ] Implement `StorageService::searchMemoriesSync()` -- [ ] Add vector embeddings for semantic search - -### Phase 8.3 - Advanced Tools -- [ ] `schedule_task` - Add tasks to AISSIA's scheduler -- [ ] `get_focus_stats` - Retrieve hyperfocus detection stats -- [ ] `list_active_apps` - Get current monitored applications -- [ ] `send_notification` - Trigger system notifications - -### Phase 8.4 - Multi-Modal Support -- [ ] Image input for LLM (Claude vision) -- [ ] PDF/document parsing tools -- [ ] Web scraping integration - -## Use Cases - -### 1. AI Assistant Collaboration - -Claude Code can delegate complex reasoning tasks to AISSIA: - -``` -Claude: "I need to analyze user behavior patterns. Let me ask AISSIA." -→ calls chat_with_aissia("Analyze recent focus patterns") -AISSIA: "Based on monitoring data, user has 3 hyperfocus sessions daily averaging 2.5 hours..." -``` - -### 2. Voice Transcription Workflow - -``` -Claude: "Transcribe meeting-2025-01-30.wav" -→ calls transcribe_audio(file_path="meeting-2025-01-30.wav", language="en") -→ calls write_file(path="transcript.txt", content=result) -``` - -### 3. Knowledge Management - -``` -Claude: "Save this important insight to AISSIA's memory" -→ calls save_memory( - title="Project architecture decision", - content="We decided to use hot-reload modules for business logic...", - tags=["architecture", "project"] -) -``` - -### 4. File + AI Operations - -``` -Claude: "Read todos.md, ask AISSIA to prioritize tasks, update file" -→ calls read_file("todos.md") -→ calls chat_with_aissia("Prioritize these tasks: ...") -→ calls write_file("todos-prioritized.md", content=...) -``` - -## Development - -### Adding New Tools - -1. **Declare tool in MCPServerTools.hpp**: -```cpp -json handleNewTool(const json& input); -``` - -2. **Implement in MCPServerTools.cpp**: -```cpp -json MCPServerTools::handleNewTool(const json& input) { - // Extract input parameters - std::string param = input["param"]; - - // Call service - auto result = m_someService->doSomethingSync(param); - - // Return JSON result - return { - {"output", result}, - {"status", "success"} - }; -} -``` - -3. **Register in getToolDefinitions()**: -```cpp -tools.push_back({ - "new_tool", - "Description of what this tool does", - { - {"type", "object"}, - {"properties", { - {"param", { - {"type", "string"}, - {"description", "Parameter description"} - }} - }}, - {"required", json::array({"param"})} - }, - [this](const json& input) { return handleNewTool(input); } -}); -``` - -4. **Add to execute() switch**: -```cpp -if (toolName == "new_tool") { - return handleNewTool(input); -} -``` - -### Testing MCP Server - -Test with `nc` or `socat`: - -```bash -# Send tools/list request -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server - -# Send tool call -echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"Hello AISSIA"}}}' | ./build/aissia.exe --mcp-server -``` - -Expected output format: -```json -{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"chat_with_aissia","description":"...","inputSchema":{...}}]}} -``` - -## Troubleshooting - -### "LLMService not initialized" - -Make sure `config/ai.json` exists with valid API key: -```json -{ - "provider": "claude", - "api_key": "sk-ant-...", - "model": "claude-sonnet-4-20250514" -} -``` - -### "VoiceService not available" - -Voice tools are optional. If you don't need STT/TTS, this is normal. - -### "StorageService not available" - -Make sure `config/storage.json` exists: -```json -{ - "database_path": "./data/aissia.db", - "journal_mode": "WAL", - "busy_timeout_ms": 5000 -} -``` - -### "Tool not found" - -Check `tools/list` output to see which tools are actually registered. - -## References - -- **MCP Specification**: https://github.com/anthropics/mcp -- **AISSIA Architecture**: `docs/project-overview.md` -- **GroveEngine Guide**: `docs/GROVEENGINE_GUIDE.md` -- **LLM Service**: `src/services/LLMService.hpp` -- **MCPServer**: `src/shared/mcp/MCPServer.hpp` +# AISSIA - Claude Code Integration (Phase 8) + +## Overview + +AISSIA can now be exposed as an **MCP Server** (Model Context Protocol) to integrate with Claude Code and other MCP-compatible clients. This allows Claude to use AISSIA's capabilities as tools during conversations. + +**Mode MCP Server**: `./aissia --mcp-server` + +This mode exposes AISSIA's services via JSON-RPC 2.0 over stdio, following the MCP specification. + +## Available Tools + +AISSIA exposes **13 tools** total: + +### 1. AISSIA Core Tools (Priority) + +#### `chat_with_aissia` ⭐ **PRIORITY** +Dialogue with AISSIA's built-in AI assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities. + +**Input**: +```json +{ + "message": "string (required) - Message to send to AISSIA", + "conversation_id": "string (optional) - Conversation ID for continuity", + "system_prompt": "string (optional) - Custom system prompt" +} +``` + +**Output**: +```json +{ + "response": "AISSIA's response text", + "conversation_id": "conversation-id", + "tokens": 1234, + "iterations": 2 +} +``` + +**Example use case**: "Hey AISSIA, can you analyze my focus patterns this week?" + +#### `transcribe_audio` +Transcribe audio file to text using Speech-to-Text engines (Whisper.cpp, OpenAI Whisper API, Google Speech). + +**Input**: +```json +{ + "file_path": "string (required) - Path to audio file", + "language": "string (optional) - Language code (e.g., 'fr', 'en'). Default: 'fr'" +} +``` + +**Output**: +```json +{ + "text": "Transcribed text from audio", + "file": "/path/to/audio.wav", + "language": "fr" +} +``` + +**Status**: ⚠️ Not yet implemented - requires STT service file transcription support + +#### `text_to_speech` +Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format. + +**Input**: +```json +{ + "text": "string (required) - Text to synthesize", + "output_file": "string (required) - Output audio file path (WAV)", + "voice": "string (optional) - Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'" +} +``` + +**Output**: +```json +{ + "success": true, + "file": "/path/to/output.wav", + "voice": "fr-fr" +} +``` + +**Status**: ⚠️ Not yet implemented - requires TTS engine file output support + +#### `save_memory` +Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later. + +**Input**: +```json +{ + "title": "string (required) - Memory title", + "content": "string (required) - Memory content", + "tags": ["array of strings (optional) - Tags for categorization"] +} +``` + +**Output**: +```json +{ + "id": "memory-uuid", + "title": "Meeting notes", + "timestamp": "2025-01-30T10:00:00Z" +} +``` + +**Status**: ⚠️ Not yet implemented - requires StorageService sync methods + +#### `search_memories` +Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores. + +**Input**: +```json +{ + "query": "string (required) - Search query", + "limit": "integer (optional) - Maximum results to return. Default: 10" +} +``` + +**Output**: +```json +{ + "results": [ + { + "id": "memory-uuid", + "title": "Meeting notes", + "content": "...", + "score": 0.85, + "tags": ["work", "meeting"] + } + ], + "count": 5 +} +``` + +**Status**: ⚠️ Not yet implemented - requires StorageService sync methods + +### 2. File System Tools (8 tools) + +- `read_file` - Read a file from the filesystem +- `write_file` - Write content to a file +- `list_directory` - List files in a directory +- `search_files` - Search for files by pattern +- `file_exists` - Check if a file exists +- `create_directory` - Create a new directory +- `delete_file` - Delete a file +- `move_file` - Move or rename a file + +These tools provide Claude with direct filesystem access to work with files on your system. + +## Installation for Claude Code + +### 1. Configure Claude Code MCP + +Create or edit your Claude Code MCP configuration file: + +**Windows**: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` +**macOS/Linux**: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` + +Add AISSIA as an MCP server: + +```json +{ + "mcpServers": { + "aissia": { + "command": "C:\\path\\to\\aissia\\build\\aissia.exe", + "args": ["--mcp-server"], + "disabled": false + } + } +} +``` + +**Note**: Replace `C:\\path\\to\\aissia\\build\\aissia.exe` with the actual path to your compiled AISSIA executable. + +### 2. Verify Configuration + +Restart Claude Code (or VS Code) to reload the MCP configuration. + +Claude should now have access to all 13 AISSIA tools during conversations. + +### 3. Test Integration + +In Claude Code, try: + +``` +"Can you use the chat_with_aissia tool to ask AISSIA what time it is?" +``` + +Claude will call the `chat_with_aissia` tool, which internally uses AISSIA's LLM service to process the query. + +## Architecture + +### Synchronous Mode (MCP Server) + +When running as an MCP server, AISSIA uses **synchronous blocking calls** instead of the async pub/sub architecture used in normal mode: + +```cpp +// Normal mode (async) +io->publish("llm:request", data); +// ... wait for response on "llm:response" topic + +// MCP mode (sync) +auto response = llmService->sendMessageSync(message, conversationId); +// immediate result +``` + +This is necessary because: +1. MCP protocol expects immediate JSON-RPC responses +2. No event loop in MCP server mode (stdin/stdout blocking I/O) +3. Simplifies integration with external tools + +### Service Integration + +``` +MCPServer (stdio JSON-RPC) + ↓ +MCPServerTools (tool handlers) + ↓ +Services (sync methods) + ├── LLMService::sendMessageSync() + ├── VoiceService::transcribeFileSync() + ├── VoiceService::textToSpeechSync() + └── StorageService (stub implementations) +``` + +### Tool Registry + +All tools are registered in a central `ToolRegistry`: + +```cpp +ToolRegistry registry; + +// 1. Internal tools (get_current_time) +registry.registerTool("get_current_time", ...); + +// 2. FileSystem tools (8 tools) +for (auto& toolDef : FileSystemTools::getToolDefinitions()) { + registry.registerTool(toolDef); +} + +// 3. AISSIA tools (5 tools) +MCPServerTools aissiaTools(llmService, storageService, voiceService); +for (const auto& toolDef : aissiaTools.getToolDefinitions()) { + registry.registerTool(toolDef); +} +``` + +Total: **13 tools** + +## Configuration Files + +AISSIA MCP Server requires these config files (same as normal mode): + +- `config/ai.json` - LLM provider configuration (Claude API key) +- `config/storage.json` - Database path and settings +- `config/voice.json` - TTS/STT engine settings + +**Important**: Make sure these files are present before running `--mcp-server` mode. + +## Limitations (Phase 8 MVP) + +1. **STT/TTS file operations**: `transcribe_audio` and `text_to_speech` are not fully implemented yet + - STT service needs file transcription support (currently only streaming) + - TTS engine needs file output support (currently only direct playback) + +2. **Storage sync methods**: `save_memory` and `search_memories` return "not implemented" errors + - StorageService needs `saveMemorySync()` and `searchMemoriesSync()` methods + - Current storage only works via async pub/sub + +3. **No hot-reload**: MCP server mode doesn't load hot-reloadable modules + - Only services and tools are available + - No SchedulerModule, MonitoringModule, etc. + +4. **Single-threaded**: MCP server runs synchronously on main thread + - LLMService worker thread still runs for agentic loops + - But overall server is blocking on stdin + +## Roadmap + +### Phase 8.1 - Complete STT/TTS Sync Methods +- [ ] Implement `VoiceService::transcribeFileSync()` using STT engines +- [ ] Implement `VoiceService::textToSpeechSync()` with file output +- [ ] Test audio file transcription via MCP + +### Phase 8.2 - Storage Sync Methods +- [ ] Implement `StorageService::saveMemorySync()` +- [ ] Implement `StorageService::searchMemoriesSync()` +- [ ] Add vector embeddings for semantic search + +### Phase 8.3 - Advanced Tools +- [ ] `schedule_task` - Add tasks to AISSIA's scheduler +- [ ] `get_focus_stats` - Retrieve hyperfocus detection stats +- [ ] `list_active_apps` - Get current monitored applications +- [ ] `send_notification` - Trigger system notifications + +### Phase 8.4 - Multi-Modal Support +- [ ] Image input for LLM (Claude vision) +- [ ] PDF/document parsing tools +- [ ] Web scraping integration + +## Use Cases + +### 1. AI Assistant Collaboration + +Claude Code can delegate complex reasoning tasks to AISSIA: + +``` +Claude: "I need to analyze user behavior patterns. Let me ask AISSIA." +→ calls chat_with_aissia("Analyze recent focus patterns") +AISSIA: "Based on monitoring data, user has 3 hyperfocus sessions daily averaging 2.5 hours..." +``` + +### 2. Voice Transcription Workflow + +``` +Claude: "Transcribe meeting-2025-01-30.wav" +→ calls transcribe_audio(file_path="meeting-2025-01-30.wav", language="en") +→ calls write_file(path="transcript.txt", content=result) +``` + +### 3. Knowledge Management + +``` +Claude: "Save this important insight to AISSIA's memory" +→ calls save_memory( + title="Project architecture decision", + content="We decided to use hot-reload modules for business logic...", + tags=["architecture", "project"] +) +``` + +### 4. File + AI Operations + +``` +Claude: "Read todos.md, ask AISSIA to prioritize tasks, update file" +→ calls read_file("todos.md") +→ calls chat_with_aissia("Prioritize these tasks: ...") +→ calls write_file("todos-prioritized.md", content=...) +``` + +## Development + +### Adding New Tools + +1. **Declare tool in MCPServerTools.hpp**: +```cpp +json handleNewTool(const json& input); +``` + +2. **Implement in MCPServerTools.cpp**: +```cpp +json MCPServerTools::handleNewTool(const json& input) { + // Extract input parameters + std::string param = input["param"]; + + // Call service + auto result = m_someService->doSomethingSync(param); + + // Return JSON result + return { + {"output", result}, + {"status", "success"} + }; +} +``` + +3. **Register in getToolDefinitions()**: +```cpp +tools.push_back({ + "new_tool", + "Description of what this tool does", + { + {"type", "object"}, + {"properties", { + {"param", { + {"type", "string"}, + {"description", "Parameter description"} + }} + }}, + {"required", json::array({"param"})} + }, + [this](const json& input) { return handleNewTool(input); } +}); +``` + +4. **Add to execute() switch**: +```cpp +if (toolName == "new_tool") { + return handleNewTool(input); +} +``` + +### Testing MCP Server + +Test with `nc` or `socat`: + +```bash +# Send tools/list request +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server + +# Send tool call +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"Hello AISSIA"}}}' | ./build/aissia.exe --mcp-server +``` + +Expected output format: +```json +{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"chat_with_aissia","description":"...","inputSchema":{...}}]}} +``` + +## Troubleshooting + +### "LLMService not initialized" + +Make sure `config/ai.json` exists with valid API key: +```json +{ + "provider": "claude", + "api_key": "sk-ant-...", + "model": "claude-sonnet-4-20250514" +} +``` + +### "VoiceService not available" + +Voice tools are optional. If you don't need STT/TTS, this is normal. + +### "StorageService not available" + +Make sure `config/storage.json` exists: +```json +{ + "database_path": "./data/aissia.db", + "journal_mode": "WAL", + "busy_timeout_ms": 5000 +} +``` + +### "Tool not found" + +Check `tools/list` output to see which tools are actually registered. + +## References + +- **MCP Specification**: https://github.com/anthropics/mcp +- **AISSIA Architecture**: `docs/project-overview.md` +- **GroveEngine Guide**: `docs/GROVEENGINE_GUIDE.md` +- **LLM Service**: `src/services/LLMService.hpp` +- **MCPServer**: `src/shared/mcp/MCPServer.hpp` diff --git a/docs/STT_SETUP.md b/docs/STT_SETUP.md index 249762c..5e14950 100644 --- a/docs/STT_SETUP.md +++ b/docs/STT_SETUP.md @@ -1,268 +1,268 @@ -# Speech-to-Text (STT) Setup Guide - Windows - -Guide pour configurer les moteurs de reconnaissance vocale STT sur Windows. - -## État Actuel - -AISSIA supporte **5 moteurs STT** avec priorités automatiques : - -| Moteur | Type | Status | Requis | -|--------|------|--------|--------| -| **Whisper.cpp** | Local | ✅ Configuré | Modèle téléchargé | -| **OpenAI Whisper API** | Cloud | ✅ Configuré | API key dans .env | -| **Google Speech** | Cloud | ✅ Configuré | API key dans .env | -| **Azure STT** | Cloud | ⚠️ Optionnel | API key manquante | -| **Deepgram** | Cloud | ⚠️ Optionnel | API key manquante | - -**3 moteurs sont déjà fonctionnels** (Whisper.cpp, OpenAI, Google) ✅ - ---- - -## 1. Whisper.cpp (Local, Offline) ✅ - -### Avantages -- ✅ Complètement offline (pas d'internet requis) -- ✅ Excellente précision (qualité OpenAI Whisper) -- ✅ Gratuit, pas de limite d'utilisation -- ✅ Support multilingue (99 langues) -- ❌ Plus lent que les APIs cloud (temps réel difficile) - -### Installation - -**Modèle téléchargé** : `models/ggml-base.bin` (142MB) - -Autres modèles disponibles : -```bash -cd models/ - -# Tiny (75MB) - Rapide mais moins précis -curl -L -o ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin - -# Small (466MB) - Bon compromis -curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin - -# Medium (1.5GB) - Très bonne qualité -curl -L -o ggml-medium.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin - -# Large (2.9GB) - Meilleure qualité -curl -L -o ggml-large-v3.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin -``` - -**Recommandé** : `base` ou `small` pour la plupart des usages. - ---- - -## 2. OpenAI Whisper API ✅ - -### Avantages -- ✅ Très rapide (temps réel) -- ✅ Excellente précision -- ✅ Support multilingue -- ❌ Requiert internet -- ❌ Coût : $0.006/minute ($0.36/heure) - -### Configuration - -1. Obtenir une clé API OpenAI : https://platform.openai.com/api-keys -2. Ajouter à `.env` : -```bash -OPENAI_API_KEY=sk-proj-... -``` - -**Status** : ✅ Déjà configuré - ---- - -## 3. Google Speech-to-Text ✅ - -### Avantages -- ✅ Très rapide -- ✅ Bonne précision -- ✅ Support multilingue (125+ langues) -- ❌ Requiert internet -- ❌ Coût : $0.006/15s ($1.44/heure) - -### Configuration - -1. Activer l'API : https://console.cloud.google.com/apis/library/speech.googleapis.com -2. Créer une clé API -3. Ajouter à `.env` : -```bash -GOOGLE_API_KEY=AIzaSy... -``` - -**Status** : ✅ Déjà configuré - ---- - -## 4. Azure Speech-to-Text (Optionnel) - -### Avantages -- ✅ Excellente précision -- ✅ Support multilingue -- ✅ Free tier : 5h/mois gratuit -- ❌ Requiert internet - -### Configuration - -1. Créer une ressource Azure Speech : https://portal.azure.com -2. Copier la clé et la région -3. Ajouter à `.env` : -```bash -AZURE_SPEECH_KEY=votre_cle_azure -AZURE_SPEECH_REGION=westeurope # ou votre région -``` - -**Status** : ⚠️ Optionnel (non configuré) - ---- - -## 5. Deepgram (Optionnel) - -### Avantages -- ✅ Très rapide (streaming temps réel) -- ✅ Bonne précision -- ✅ Free tier : $200 crédit / 45,000 minutes -- ❌ Requiert internet - -### Configuration - -1. Créer un compte : https://console.deepgram.com -2. Créer une API key -3. Ajouter à `.env` : -```bash -DEEPGRAM_API_KEY=votre_cle_deepgram -``` - -**Status** : ⚠️ Optionnel (non configuré) - ---- - -## Tester les Moteurs STT - -### Option 1 : Test avec fichier audio - -1. Générer un fichier audio de test : -```bash -python create_test_audio_simple.py -``` - -2. Lancer le test (quand compilé) : -```bash -./build/test_stt_live test_audio.wav -``` - -Ceci testera automatiquement tous les moteurs disponibles. - -### Option 2 : Test depuis AISSIA - -Les moteurs STT sont intégrés dans `VoiceModule` et accessibles via : -- `voice:start_listening` (pub/sub) -- `voice:stop_listening` -- `voice:transcribe` (avec fichier audio) - ---- - -## Configuration Recommandée - -Pour un usage optimal, voici l'ordre de priorité recommandé : - -### Pour développement/tests locaux -1. **Whisper.cpp** (`ggml-base.bin`) - Offline, gratuit -2. **OpenAI Whisper API** - Si internet disponible -3. **Google Speech** - Fallback - -### Pour production/temps réel -1. **Deepgram** - Meilleur streaming temps réel -2. **Azure STT** - Bonne qualité, free tier -3. **Whisper.cpp** (`ggml-small.bin`) - Offline fallback - ---- - -## Fichiers de Configuration - -### .env (API Keys) -```bash -# OpenAI Whisper API (✅ configuré) -OPENAI_API_KEY=sk-proj-... - -# Google Speech (✅ configuré) -GOOGLE_API_KEY=AIzaSy... - -# Azure STT (optionnel) -#AZURE_SPEECH_KEY=votre_cle -#AZURE_SPEECH_REGION=westeurope - -# Deepgram (optionnel) -#DEEPGRAM_API_KEY=votre_cle -``` - -### config/voice.json -```json -{ - "stt": { - "active_mode": { - "enabled": true, - "engine": "whisper_cpp", - "model_path": "./models/ggml-base.bin", - "language": "fr", - "fallback_engine": "whisper_api" - } - } -} -``` - ---- - -## Dépendances - -### Whisper.cpp -- ✅ Intégré dans le build (external/whisper.cpp) -- ✅ Lié statiquement à AissiaAudio -- ❌ Modèle requis : téléchargé dans `models/` - -### APIs Cloud -- ✅ Httplib pour requêtes HTTP (déjà dans le projet) -- ✅ nlohmann/json pour sérialisation (déjà dans le projet) -- ❌ OpenSSL désactivé (HTTP-only mode OK) - ---- - -## Troubleshooting - -### "Whisper model not found" -```bash -cd models/ -curl -L -o ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin -``` - -### "API key not found" -Vérifier que `.env` contient les clés et est chargé : -```bash -cat .env | grep -E "OPENAI|GOOGLE|AZURE|DEEPGRAM" -``` - -### "Transcription failed" -1. Vérifier le format audio : 16kHz, mono, 16-bit PCM WAV -2. Générer un test : `python create_test_audio_simple.py` -3. Activer les logs : `spdlog::set_level(spdlog::level::debug)` - ---- - -## Prochaines Étapes - -1. ✅ Whisper.cpp configuré et fonctionnel -2. ✅ OpenAI + Google APIs configurées -3. ⚠️ Optionnel : Ajouter Azure ou Deepgram pour redondance -4. 🔜 Tester avec `./build/test_stt_live test_audio.wav` -5. 🔜 Intégrer dans VoiceModule via pub/sub - ---- - -## Références - -- [Whisper.cpp GitHub](https://github.com/ggerganov/whisper.cpp) -- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text) -- [Google Speech-to-Text](https://cloud.google.com/speech-to-text) -- [Azure Speech](https://azure.microsoft.com/en-us/services/cognitive-services/speech-to-text/) -- [Deepgram](https://developers.deepgram.com/) +# Speech-to-Text (STT) Setup Guide - Windows + +Guide pour configurer les moteurs de reconnaissance vocale STT sur Windows. + +## État Actuel + +AISSIA supporte **5 moteurs STT** avec priorités automatiques : + +| Moteur | Type | Status | Requis | +|--------|------|--------|--------| +| **Whisper.cpp** | Local | ✅ Configuré | Modèle téléchargé | +| **OpenAI Whisper API** | Cloud | ✅ Configuré | API key dans .env | +| **Google Speech** | Cloud | ✅ Configuré | API key dans .env | +| **Azure STT** | Cloud | ⚠️ Optionnel | API key manquante | +| **Deepgram** | Cloud | ⚠️ Optionnel | API key manquante | + +**3 moteurs sont déjà fonctionnels** (Whisper.cpp, OpenAI, Google) ✅ + +--- + +## 1. Whisper.cpp (Local, Offline) ✅ + +### Avantages +- ✅ Complètement offline (pas d'internet requis) +- ✅ Excellente précision (qualité OpenAI Whisper) +- ✅ Gratuit, pas de limite d'utilisation +- ✅ Support multilingue (99 langues) +- ❌ Plus lent que les APIs cloud (temps réel difficile) + +### Installation + +**Modèle téléchargé** : `models/ggml-base.bin` (142MB) + +Autres modèles disponibles : +```bash +cd models/ + +# Tiny (75MB) - Rapide mais moins précis +curl -L -o ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin + +# Small (466MB) - Bon compromis +curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin + +# Medium (1.5GB) - Très bonne qualité +curl -L -o ggml-medium.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin + +# Large (2.9GB) - Meilleure qualité +curl -L -o ggml-large-v3.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin +``` + +**Recommandé** : `base` ou `small` pour la plupart des usages. + +--- + +## 2. OpenAI Whisper API ✅ + +### Avantages +- ✅ Très rapide (temps réel) +- ✅ Excellente précision +- ✅ Support multilingue +- ❌ Requiert internet +- ❌ Coût : $0.006/minute ($0.36/heure) + +### Configuration + +1. Obtenir une clé API OpenAI : https://platform.openai.com/api-keys +2. Ajouter à `.env` : +```bash +OPENAI_API_KEY=sk-proj-... +``` + +**Status** : ✅ Déjà configuré + +--- + +## 3. Google Speech-to-Text ✅ + +### Avantages +- ✅ Très rapide +- ✅ Bonne précision +- ✅ Support multilingue (125+ langues) +- ❌ Requiert internet +- ❌ Coût : $0.006/15s ($1.44/heure) + +### Configuration + +1. Activer l'API : https://console.cloud.google.com/apis/library/speech.googleapis.com +2. Créer une clé API +3. Ajouter à `.env` : +```bash +GOOGLE_API_KEY=AIzaSy... +``` + +**Status** : ✅ Déjà configuré + +--- + +## 4. Azure Speech-to-Text (Optionnel) + +### Avantages +- ✅ Excellente précision +- ✅ Support multilingue +- ✅ Free tier : 5h/mois gratuit +- ❌ Requiert internet + +### Configuration + +1. Créer une ressource Azure Speech : https://portal.azure.com +2. Copier la clé et la région +3. Ajouter à `.env` : +```bash +AZURE_SPEECH_KEY=votre_cle_azure +AZURE_SPEECH_REGION=westeurope # ou votre région +``` + +**Status** : ⚠️ Optionnel (non configuré) + +--- + +## 5. Deepgram (Optionnel) + +### Avantages +- ✅ Très rapide (streaming temps réel) +- ✅ Bonne précision +- ✅ Free tier : $200 crédit / 45,000 minutes +- ❌ Requiert internet + +### Configuration + +1. Créer un compte : https://console.deepgram.com +2. Créer une API key +3. Ajouter à `.env` : +```bash +DEEPGRAM_API_KEY=votre_cle_deepgram +``` + +**Status** : ⚠️ Optionnel (non configuré) + +--- + +## Tester les Moteurs STT + +### Option 1 : Test avec fichier audio + +1. Générer un fichier audio de test : +```bash +python create_test_audio_simple.py +``` + +2. Lancer le test (quand compilé) : +```bash +./build/test_stt_live test_audio.wav +``` + +Ceci testera automatiquement tous les moteurs disponibles. + +### Option 2 : Test depuis AISSIA + +Les moteurs STT sont intégrés dans `VoiceModule` et accessibles via : +- `voice:start_listening` (pub/sub) +- `voice:stop_listening` +- `voice:transcribe` (avec fichier audio) + +--- + +## Configuration Recommandée + +Pour un usage optimal, voici l'ordre de priorité recommandé : + +### Pour développement/tests locaux +1. **Whisper.cpp** (`ggml-base.bin`) - Offline, gratuit +2. **OpenAI Whisper API** - Si internet disponible +3. **Google Speech** - Fallback + +### Pour production/temps réel +1. **Deepgram** - Meilleur streaming temps réel +2. **Azure STT** - Bonne qualité, free tier +3. **Whisper.cpp** (`ggml-small.bin`) - Offline fallback + +--- + +## Fichiers de Configuration + +### .env (API Keys) +```bash +# OpenAI Whisper API (✅ configuré) +OPENAI_API_KEY=sk-proj-... + +# Google Speech (✅ configuré) +GOOGLE_API_KEY=AIzaSy... + +# Azure STT (optionnel) +#AZURE_SPEECH_KEY=votre_cle +#AZURE_SPEECH_REGION=westeurope + +# Deepgram (optionnel) +#DEEPGRAM_API_KEY=votre_cle +``` + +### config/voice.json +```json +{ + "stt": { + "active_mode": { + "enabled": true, + "engine": "whisper_cpp", + "model_path": "./models/ggml-base.bin", + "language": "fr", + "fallback_engine": "whisper_api" + } + } +} +``` + +--- + +## Dépendances + +### Whisper.cpp +- ✅ Intégré dans le build (external/whisper.cpp) +- ✅ Lié statiquement à AissiaAudio +- ❌ Modèle requis : téléchargé dans `models/` + +### APIs Cloud +- ✅ Httplib pour requêtes HTTP (déjà dans le projet) +- ✅ nlohmann/json pour sérialisation (déjà dans le projet) +- ❌ OpenSSL désactivé (HTTP-only mode OK) + +--- + +## Troubleshooting + +### "Whisper model not found" +```bash +cd models/ +curl -L -o ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin +``` + +### "API key not found" +Vérifier que `.env` contient les clés et est chargé : +```bash +cat .env | grep -E "OPENAI|GOOGLE|AZURE|DEEPGRAM" +``` + +### "Transcription failed" +1. Vérifier le format audio : 16kHz, mono, 16-bit PCM WAV +2. Générer un test : `python create_test_audio_simple.py` +3. Activer les logs : `spdlog::set_level(spdlog::level::debug)` + +--- + +## Prochaines Étapes + +1. ✅ Whisper.cpp configuré et fonctionnel +2. ✅ OpenAI + Google APIs configurées +3. ⚠️ Optionnel : Ajouter Azure ou Deepgram pour redondance +4. 🔜 Tester avec `./build/test_stt_live test_audio.wav` +5. 🔜 Intégrer dans VoiceModule via pub/sub + +--- + +## Références + +- [Whisper.cpp GitHub](https://github.com/ggerganov/whisper.cpp) +- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text) +- [Google Speech-to-Text](https://cloud.google.com/speech-to-text) +- [Azure Speech](https://azure.microsoft.com/en-us/services/cognitive-services/speech-to-text/) +- [Deepgram](https://developers.deepgram.com/) diff --git a/docs/SUCCESSION.md b/docs/SUCCESSION.md index df4199c..8815838 100644 --- a/docs/SUCCESSION.md +++ b/docs/SUCCESSION.md @@ -1,227 +1,227 @@ -# Document de Succession - AISSIA - -## Contexte - -AISSIA = Assistant vocal agentique basé sur GroveEngine (C++17 hot-reload). Architecture "Claude Code en vocal" avec tools internes + FileSystem + MCP. - -**Dernier commit** : `37b62b5` - -## État Actuel - -### Ce qui fonctionne - -✅ **Build complet** - `cmake -B build && cmake --build build -j4` -✅ **6 modules hot-reload** - Scheduler, Notification, Monitoring, AI, Voice, Storage -✅ **4 services** - LLMService, StorageService, PlatformService, VoiceService -✅ **17 tools pour l'agent** : - - 11 tools internes (via IIO pub/sub) - - 6 FileSystem tools (read/write/edit/list/glob/grep) - - MCP tools (désactivés par défaut) -✅ **Tests** - 67/75 tests modules+types passent - -### Lancement - -```bash -# Build -cmake -B build && cmake --build build -j4 - -# Run (depuis racine ou build/) -./build/aissia - -# Mode MCP Server (expose les tools via JSON-RPC stdio) -./build/aissia --mcp-server - -# Tests -cmake -B build -DBUILD_TESTING=ON -./build/tests/aissia_tests "[scheduler],[notification]" # Modules -./build/tests/aissia_tests "[types]" # MCP types -``` - -### Variables d'Environnement - -```bash -export ANTHROPIC_API_KEY="sk-ant-..." # Requis pour Claude API -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ LLMService │ -│ (Agentic Loop) │ -├─────────────────────────────────────────────────────────────┤ -│ ToolRegistry │ -│ ├── InternalTools (11) ─────► IIO pub/sub │ -│ ├── FileSystemTools (6) ────► Direct C++ (read/write/edit) │ -│ └── MCPClient (optionnel) ──► stdio JSON-RPC │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌──────────────┬─────┴──────┬──────────────┐ - Scheduler Monitoring Storage Voice - Module Module Module Module -``` - -### Tools Disponibles - -| Catégorie | Tools | Communication | -|-----------|-------|---------------| -| Scheduler | get_current_task, list_tasks, start_task, complete_task, start_break | IIO | -| Monitoring | get_focus_stats, get_current_app | IIO | -| Storage | save_note, query_notes, get_session_history | IIO | -| Voice | speak | IIO | -| FileSystem | read_file, write_file, edit_file, list_directory, glob_files, grep_files | Direct C++ | - -### FileSystem Tools (Nouveau) - -Implémentés dans `src/shared/tools/FileSystemTools.*` : - -```cpp -// Lecture avec numéros de ligne -FileSystemTools::execute("read_file", {{"path", "/path/to/file"}, {"limit", 10}}); - -// Édition style Claude Code -FileSystemTools::execute("edit_file", { - {"path", "/path/to/file"}, - {"old_string", "foo"}, - {"new_string", "bar"} -}); - -// Recherche -FileSystemTools::execute("glob_files", {{"pattern", "**/*.cpp"}}); -FileSystemTools::execute("grep_files", {{"pattern", "TODO"}, {"path", "./src"}}); -``` - -**Sécurité** : -- Chemins autorisés configurables -- Patterns bloqués : `*.env`, `*.key`, `*credentials*` -- Limites : 1MB lecture, 10MB écriture - -## Fichiers Clés - -### Nouveaux (Session actuelle) -``` -src/shared/tools/FileSystemTools.hpp -src/shared/tools/FileSystemTools.cpp -PLAN_FILESYSTEM_TOOLS.md -``` - -### Services -``` -src/services/LLMService.* # Agentic loop, tools registry -src/services/StorageService.* # SQLite persistence -src/services/PlatformService.* # Window tracking -src/services/VoiceService.* # TTS/STT -``` - -### Modules (Hot-Reload) -``` -src/modules/SchedulerModule.* -src/modules/NotificationModule.* -src/modules/MonitoringModule.* -src/modules/AIModule.* -src/modules/VoiceModule.* -src/modules/StorageModule.* -``` - -### MCP -``` -src/shared/mcp/MCPTypes.hpp -src/shared/mcp/MCPClient.* # Client MCP (consomme des serveurs externes) -src/shared/mcp/MCPServer.* # Serveur MCP (expose AISSIA comme serveur) -src/shared/mcp/StdioTransport.* -config/mcp.json -``` - -## Tests - -```bash -# Build avec tests -cmake -B build -DBUILD_TESTING=ON && cmake --build build -j4 - -# Par catégorie -./build/tests/aissia_tests "[scheduler]" # 10 tests -./build/tests/aissia_tests "[notification]" # 10 tests -./build/tests/aissia_tests "[types]" # 15 tests MCP - -# Tous les modules -./build/tests/aissia_tests "[scheduler],[notification],[monitoring],[ai],[voice],[storage]" -``` - -**Résultats actuels** : -- Modules : 52/60 (87%) -- MCP Types : 15/15 (100%) -- MCP Transport/Client : Nécessite fix serveurs Python - -## Prochaines Étapes - -### Priorité Haute -1. **Tester avec API key** - Vérifier la boucle agentique complète -2. **Activer MCP filesystem** - Pour tests end-to-end avec tools externes - -### Priorité Moyenne -3. **Fixer tests MCP Transport** - Les serveurs Python reçoivent EOF -4. **Ajouter plus de tools** - add_task, set_reminder, etc. -5. **Streaming responses** - Feedback temps réel pendant génération - -### Priorité Basse -6. **Tests end-to-end** - Flux complet inter-modules -7. **CI/CD** - GitHub Actions -8. **Documentation API** - Doxygen - -## MCP Server Mode - -AISSIA peut fonctionner comme **serveur MCP**, exposant ses tools à des clients externes via JSON-RPC sur stdio. - -```bash -./build/aissia --mcp-server -``` - -### Protocole - -Communication JSON-RPC 2.0 sur stdin/stdout : - -```json -// Client → AISSIA -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"1.0"}}} - -// AISSIA → Client -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"aissia","version":"0.2.0"},...}} - -// Lister les tools -{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} - -// Appeler un tool -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_directory","arguments":{"path":"."}}} -``` - -### Utilisation avec Claude Code - -Ajouter dans la config MCP : -```json -{ - "servers": { - "aissia": { - "command": "/chemin/vers/build/aissia", - "args": ["--mcp-server"] - } - } -} -``` - -### Tools Exposés (actuellement) - -6 FileSystem tools. TODO: exposer les tools internes (scheduler, voice, etc.). - -## Notes Techniques - -### WSL -- Window tracker non disponible (stub utilisé) -- espeak non installé (TTS stub) -- Tout le reste fonctionne - -### Hot-Reload -Les modules sont des `.so` chargés dynamiquement. Pour recompiler un module : -```bash -cmake --build build --target SchedulerModule -# Le module sera rechargé au prochain cycle si modifié -``` +# Document de Succession - AISSIA + +## Contexte + +AISSIA = Assistant vocal agentique basé sur GroveEngine (C++17 hot-reload). Architecture "Claude Code en vocal" avec tools internes + FileSystem + MCP. + +**Dernier commit** : `37b62b5` + +## État Actuel + +### Ce qui fonctionne + +✅ **Build complet** - `cmake -B build && cmake --build build -j4` +✅ **6 modules hot-reload** - Scheduler, Notification, Monitoring, AI, Voice, Storage +✅ **4 services** - LLMService, StorageService, PlatformService, VoiceService +✅ **17 tools pour l'agent** : + - 11 tools internes (via IIO pub/sub) + - 6 FileSystem tools (read/write/edit/list/glob/grep) + - MCP tools (désactivés par défaut) +✅ **Tests** - 67/75 tests modules+types passent + +### Lancement + +```bash +# Build +cmake -B build && cmake --build build -j4 + +# Run (depuis racine ou build/) +./build/aissia + +# Mode MCP Server (expose les tools via JSON-RPC stdio) +./build/aissia --mcp-server + +# Tests +cmake -B build -DBUILD_TESTING=ON +./build/tests/aissia_tests "[scheduler],[notification]" # Modules +./build/tests/aissia_tests "[types]" # MCP types +``` + +### Variables d'Environnement + +```bash +export ANTHROPIC_API_KEY="sk-ant-..." # Requis pour Claude API +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ LLMService │ +│ (Agentic Loop) │ +├─────────────────────────────────────────────────────────────┤ +│ ToolRegistry │ +│ ├── InternalTools (11) ─────► IIO pub/sub │ +│ ├── FileSystemTools (6) ────► Direct C++ (read/write/edit) │ +│ └── MCPClient (optionnel) ──► stdio JSON-RPC │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌──────────────┬─────┴──────┬──────────────┐ + Scheduler Monitoring Storage Voice + Module Module Module Module +``` + +### Tools Disponibles + +| Catégorie | Tools | Communication | +|-----------|-------|---------------| +| Scheduler | get_current_task, list_tasks, start_task, complete_task, start_break | IIO | +| Monitoring | get_focus_stats, get_current_app | IIO | +| Storage | save_note, query_notes, get_session_history | IIO | +| Voice | speak | IIO | +| FileSystem | read_file, write_file, edit_file, list_directory, glob_files, grep_files | Direct C++ | + +### FileSystem Tools (Nouveau) + +Implémentés dans `src/shared/tools/FileSystemTools.*` : + +```cpp +// Lecture avec numéros de ligne +FileSystemTools::execute("read_file", {{"path", "/path/to/file"}, {"limit", 10}}); + +// Édition style Claude Code +FileSystemTools::execute("edit_file", { + {"path", "/path/to/file"}, + {"old_string", "foo"}, + {"new_string", "bar"} +}); + +// Recherche +FileSystemTools::execute("glob_files", {{"pattern", "**/*.cpp"}}); +FileSystemTools::execute("grep_files", {{"pattern", "TODO"}, {"path", "./src"}}); +``` + +**Sécurité** : +- Chemins autorisés configurables +- Patterns bloqués : `*.env`, `*.key`, `*credentials*` +- Limites : 1MB lecture, 10MB écriture + +## Fichiers Clés + +### Nouveaux (Session actuelle) +``` +src/shared/tools/FileSystemTools.hpp +src/shared/tools/FileSystemTools.cpp +PLAN_FILESYSTEM_TOOLS.md +``` + +### Services +``` +src/services/LLMService.* # Agentic loop, tools registry +src/services/StorageService.* # SQLite persistence +src/services/PlatformService.* # Window tracking +src/services/VoiceService.* # TTS/STT +``` + +### Modules (Hot-Reload) +``` +src/modules/SchedulerModule.* +src/modules/NotificationModule.* +src/modules/MonitoringModule.* +src/modules/AIModule.* +src/modules/VoiceModule.* +src/modules/StorageModule.* +``` + +### MCP +``` +src/shared/mcp/MCPTypes.hpp +src/shared/mcp/MCPClient.* # Client MCP (consomme des serveurs externes) +src/shared/mcp/MCPServer.* # Serveur MCP (expose AISSIA comme serveur) +src/shared/mcp/StdioTransport.* +config/mcp.json +``` + +## Tests + +```bash +# Build avec tests +cmake -B build -DBUILD_TESTING=ON && cmake --build build -j4 + +# Par catégorie +./build/tests/aissia_tests "[scheduler]" # 10 tests +./build/tests/aissia_tests "[notification]" # 10 tests +./build/tests/aissia_tests "[types]" # 15 tests MCP + +# Tous les modules +./build/tests/aissia_tests "[scheduler],[notification],[monitoring],[ai],[voice],[storage]" +``` + +**Résultats actuels** : +- Modules : 52/60 (87%) +- MCP Types : 15/15 (100%) +- MCP Transport/Client : Nécessite fix serveurs Python + +## Prochaines Étapes + +### Priorité Haute +1. **Tester avec API key** - Vérifier la boucle agentique complète +2. **Activer MCP filesystem** - Pour tests end-to-end avec tools externes + +### Priorité Moyenne +3. **Fixer tests MCP Transport** - Les serveurs Python reçoivent EOF +4. **Ajouter plus de tools** - add_task, set_reminder, etc. +5. **Streaming responses** - Feedback temps réel pendant génération + +### Priorité Basse +6. **Tests end-to-end** - Flux complet inter-modules +7. **CI/CD** - GitHub Actions +8. **Documentation API** - Doxygen + +## MCP Server Mode + +AISSIA peut fonctionner comme **serveur MCP**, exposant ses tools à des clients externes via JSON-RPC sur stdio. + +```bash +./build/aissia --mcp-server +``` + +### Protocole + +Communication JSON-RPC 2.0 sur stdin/stdout : + +```json +// Client → AISSIA +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"1.0"}}} + +// AISSIA → Client +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"aissia","version":"0.2.0"},...}} + +// Lister les tools +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + +// Appeler un tool +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_directory","arguments":{"path":"."}}} +``` + +### Utilisation avec Claude Code + +Ajouter dans la config MCP : +```json +{ + "servers": { + "aissia": { + "command": "/chemin/vers/build/aissia", + "args": ["--mcp-server"] + } + } +} +``` + +### Tools Exposés (actuellement) + +6 FileSystem tools. TODO: exposer les tools internes (scheduler, voice, etc.). + +## Notes Techniques + +### WSL +- Window tracker non disponible (stub utilisé) +- espeak non installé (TTS stub) +- Tout le reste fonctionne + +### Hot-Reload +Les modules sont des `.so` chargés dynamiquement. Pour recompiler un module : +```bash +cmake --build build --target SchedulerModule +# Le module sera rechargé au prochain cycle si modifié +``` diff --git a/run_tests.ps1 b/run_tests.ps1 index 0ba94f9..83ef658 100644 --- a/run_tests.ps1 +++ b/run_tests.ps1 @@ -1,16 +1,16 @@ -$ErrorActionPreference = "Continue" -cd "C:\Users\alexi\Documents\projects\aissia" - -Write-Host "=== Running aissia_tests.exe ===" -ForegroundColor Cyan -& ".\build\tests\aissia_tests.exe" 2>&1 | Tee-Object -FilePath "test_output.txt" -$testExitCode = $LASTEXITCODE -Write-Host "`nTest exit code: $testExitCode" -ForegroundColor $(if ($testExitCode -eq 0) { "Green" } else { "Red" }) - -Write-Host "`n=== Running test_stt_engines.exe ===" -ForegroundColor Cyan -& ".\build\test_stt_engines.exe" 2>&1 | Tee-Object -FilePath "stt_test_output.txt" -Append -$sttExitCode = $LASTEXITCODE -Write-Host "`nSTT Test exit code: $sttExitCode" -ForegroundColor $(if ($sttExitCode -eq 0) { "Green" } else { "Red" }) - -Write-Host "`n=== Test Summary ===" -ForegroundColor Cyan -Write-Host "aissia_tests: $(if ($testExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" -Write-Host "test_stt_engines: $(if ($sttExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" +$ErrorActionPreference = "Continue" +cd "C:\Users\alexi\Documents\projects\aissia" + +Write-Host "=== Running aissia_tests.exe ===" -ForegroundColor Cyan +& ".\build\tests\aissia_tests.exe" 2>&1 | Tee-Object -FilePath "test_output.txt" +$testExitCode = $LASTEXITCODE +Write-Host "`nTest exit code: $testExitCode" -ForegroundColor $(if ($testExitCode -eq 0) { "Green" } else { "Red" }) + +Write-Host "`n=== Running test_stt_engines.exe ===" -ForegroundColor Cyan +& ".\build\test_stt_engines.exe" 2>&1 | Tee-Object -FilePath "stt_test_output.txt" -Append +$sttExitCode = $LASTEXITCODE +Write-Host "`nSTT Test exit code: $sttExitCode" -ForegroundColor $(if ($sttExitCode -eq 0) { "Green" } else { "Red" }) + +Write-Host "`n=== Test Summary ===" -ForegroundColor Cyan +Write-Host "aissia_tests: $(if ($testExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" +Write-Host "test_stt_engines: $(if ($sttExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" diff --git a/src/services/IService.hpp b/src/services/IService.hpp index 7f6ab4d..615457e 100644 --- a/src/services/IService.hpp +++ b/src/services/IService.hpp @@ -1,39 +1,39 @@ -#pragma once - -#include -#include - -namespace aissia { - -/** - * @brief Interface for infrastructure services - * - * Services handle non-hot-reloadable infrastructure: - * - LLM HTTP calls - * - SQLite database - * - Platform APIs (Win32/X11) - * - TTS/STT engines - * - * Services communicate with modules via IIO pub/sub. - */ -class IService { -public: - virtual ~IService() = default; - - /// Initialize the service with IIO for pub/sub - virtual bool initialize(grove::IIO* io) = 0; - - /// Process pending work (called each frame from main loop) - virtual void process() = 0; - - /// Clean shutdown - virtual void shutdown() = 0; - - /// Service name for logging - virtual std::string getName() const = 0; - - /// Check if service is healthy - virtual bool isHealthy() const = 0; -}; - -} // namespace aissia +#pragma once + +#include +#include + +namespace aissia { + +/** + * @brief Interface for infrastructure services + * + * Services handle non-hot-reloadable infrastructure: + * - LLM HTTP calls + * - SQLite database + * - Platform APIs (Win32/X11) + * - TTS/STT engines + * + * Services communicate with modules via IIO pub/sub. + */ +class IService { +public: + virtual ~IService() = default; + + /// Initialize the service with IIO for pub/sub + virtual bool initialize(grove::IIO* io) = 0; + + /// Process pending work (called each frame from main loop) + virtual void process() = 0; + + /// Clean shutdown + virtual void shutdown() = 0; + + /// Service name for logging + virtual std::string getName() const = 0; + + /// Check if service is healthy + virtual bool isHealthy() const = 0; +}; + +} // namespace aissia diff --git a/src/services/LLMService.cpp b/src/services/LLMService.cpp index 4543c35..8180e26 100644 --- a/src/services/LLMService.cpp +++ b/src/services/LLMService.cpp @@ -1,375 +1,375 @@ -#include "LLMService.hpp" -#include "../shared/llm/LLMProviderFactory.hpp" - -#include -#include - -namespace aissia { - -LLMService::LLMService() { - m_logger = spdlog::get("LLMService"); - if (!m_logger) { - m_logger = spdlog::stdout_color_mt("LLMService"); - } -} - -LLMService::~LLMService() { - shutdown(); -} - -bool LLMService::initialize(grove::IIO* io) { - m_io = io; - - if (m_io) { - grove::SubscriptionConfig config; - m_io->subscribe("llm:request", config); - } - - // Start worker thread - m_running = true; - m_workerThread = std::thread(&LLMService::workerLoop, this); - - m_logger->info("LLMService initialized"); - return true; -} - -bool LLMService::loadConfig(const std::string& configPath) { - try { - std::ifstream file(configPath); - if (!file.is_open()) { - m_logger->warn("Config file not found: {}", configPath); - return false; - } - - nlohmann::json config; - file >> config; - - m_provider = LLMProviderFactory::create(config); - if (!m_provider) { - m_logger->error("Failed to create LLM provider"); - return false; - } - - m_providerName = config.value("provider", "claude"); - m_maxIterations = config.value("max_iterations", 10); - m_defaultSystemPrompt = config.value("system_prompt", - "Tu es AISSIA, un assistant personnel intelligent. " - "Tu peux utiliser des tools pour accomplir des taches: " - "gerer le planning, verifier le focus, sauvegarder des notes, " - "lire des fichiers, faire des recherches web, etc."); - - m_logger->info("LLM provider loaded: {} ({})", m_providerName, m_provider->getModel()); - - // Initialize tools after provider is ready - initializeTools(); - - return true; - - } catch (const std::exception& e) { - m_logger->error("Failed to load config: {}", e.what()); - return false; - } -} - -void LLMService::initializeTools() { - m_logger->info("Initializing tools..."); - - // 1. Internal tools (via GroveEngine IIO) - if (m_io) { - m_internalTools = std::make_unique(m_io); - for (const auto& tool : m_internalTools->getTools()) { - m_toolRegistry.registerTool(tool); - } - m_logger->info("Registered {} internal tools", m_internalTools->size()); - } - - // 2. FileSystem tools (direct C++ execution) - for (const auto& toolDef : tools::FileSystemTools::getToolDefinitions()) { - std::string toolName = toolDef["name"].get(); - m_toolRegistry.registerTool( - toolName, - toolDef["description"].get(), - toolDef["input_schema"], - [toolName](const nlohmann::json& input) -> nlohmann::json { - return tools::FileSystemTools::execute(toolName, input); - } - ); - } - m_logger->info("Registered {} filesystem tools", tools::FileSystemTools::getToolDefinitions().size()); - - // 3. MCP tools (via external servers) - m_mcpClient = std::make_unique(); - if (loadMCPConfig("config/mcp.json")) { - int connected = m_mcpClient->connectAll(); - if (connected > 0) { - for (const auto& tool : m_mcpClient->listAllTools()) { - // Convert MCP tool to our ToolDefinition format - m_toolRegistry.registerTool( - tool.name, - tool.description, - tool.inputSchema, - [this, toolName = tool.name](const nlohmann::json& input) -> nlohmann::json { - auto result = m_mcpClient->callTool(toolName, input); - // Convert MCP result to simple JSON - if (result.isError) { - return {{"error", true}, {"content", result.content}}; - } - // Extract text content - std::string text; - for (const auto& content : result.content) { - if (content.contains("text")) { - text += content["text"].get(); - } - } - return {{"content", text}}; - } - ); - } - m_logger->info("Registered {} MCP tools from {} servers", - m_mcpClient->toolCount(), connected); - } - } - - m_logger->info("Total tools available: {}", m_toolRegistry.size()); -} - -bool LLMService::loadMCPConfig(const std::string& configPath) { - return m_mcpClient->loadConfig(configPath); -} - -void LLMService::registerTool(const std::string& name, const std::string& description, - const nlohmann::json& schema, - std::function handler) { - m_toolRegistry.registerTool(name, description, schema, handler); - m_logger->debug("Tool registered: {}", name); -} - -void LLMService::process() { - processIncomingMessages(); - publishResponses(); -} - -void LLMService::processIncomingMessages() { - if (!m_io) return; - - while (m_io->hasMessages() > 0) { - auto msg = m_io->pullMessage(); - - if (msg.topic == "llm:request" && msg.data) { - Request req; - req.query = msg.data->getString("query", ""); - req.systemPrompt = msg.data->getString("systemPrompt", m_defaultSystemPrompt); - req.conversationId = msg.data->getString("conversationId", "default"); - req.maxIterations = msg.data->getInt("maxIterations", m_maxIterations); - - // Get tools from message or use registered tools - auto* toolsNode = msg.data->getChildReadOnly("tools"); - if (toolsNode) { - // Custom tools from message - // (would need to parse from IDataNode) - } - - if (!req.query.empty()) { - std::lock_guard lock(m_requestMutex); - m_requestQueue.push(std::move(req)); - m_requestCV.notify_one(); - m_logger->debug("Request queued: {}", req.query.substr(0, 50)); - } - } - } -} - -void LLMService::publishResponses() { - if (!m_io) return; - - std::lock_guard lock(m_responseMutex); - while (!m_responseQueue.empty()) { - auto resp = std::move(m_responseQueue.front()); - m_responseQueue.pop(); - - if (resp.isError) { - auto event = std::make_unique("error"); - event->setString("message", resp.text); - event->setString("conversationId", resp.conversationId); - m_io->publish("llm:error", std::move(event)); - } else { - auto event = std::make_unique("response"); - event->setString("text", resp.text); - event->setString("conversationId", resp.conversationId); - event->setInt("tokens", resp.tokens); - event->setInt("iterations", resp.iterations); - m_io->publish("llm:response", std::move(event)); - - m_logger->info("Response published: {} chars", resp.text.size()); - } - } -} - -void LLMService::workerLoop() { - m_logger->debug("Worker thread started"); - - while (m_running) { - Request req; - - { - std::unique_lock lock(m_requestMutex); - m_requestCV.wait_for(lock, std::chrono::milliseconds(100), [this] { - return !m_requestQueue.empty() || !m_running; - }); - - if (!m_running) break; - if (m_requestQueue.empty()) continue; - - req = std::move(m_requestQueue.front()); - m_requestQueue.pop(); - } - - // Process request (HTTP calls happen here) - auto resp = processRequest(req); - - { - std::lock_guard lock(m_responseMutex); - m_responseQueue.push(std::move(resp)); - } - } - - m_logger->debug("Worker thread stopped"); -} - -LLMService::Response LLMService::processRequest(const Request& request) { - Response resp; - resp.conversationId = request.conversationId; - - if (!m_provider) { - resp.text = "LLM provider not initialized"; - resp.isError = true; - return resp; - } - - try { - // Get or create conversation history - auto& history = m_conversations[request.conversationId]; - if (history.is_null()) { - history = nlohmann::json::array(); - } - - // Add user message - history.push_back({{"role", "user"}, {"content", request.query}}); - - // Get tool definitions - nlohmann::json tools = m_toolRegistry.getToolDefinitions(); - - // Run agentic loop - auto result = agenticLoop(request.query, request.systemPrompt, - history, tools, request.maxIterations); - - if (result.contains("error")) { - resp.text = result["error"].get(); - resp.isError = true; - } else { - resp.text = result["response"].get(); - resp.tokens = result.value("tokens", 0); - resp.iterations = result.value("iterations", 1); - - // Add assistant response to history - history.push_back({{"role", "assistant"}, {"content", resp.text}}); - } - - } catch (const std::exception& e) { - resp.text = e.what(); - resp.isError = true; - } - - return resp; -} - -nlohmann::json LLMService::agenticLoop(const std::string& query, const std::string& systemPrompt, - nlohmann::json& messages, const nlohmann::json& tools, - int maxIterations) { - int totalTokens = 0; - - for (int iteration = 0; iteration < maxIterations; iteration++) { - m_logger->debug("Agentic loop iteration {}", iteration + 1); - - auto response = m_provider->chat(systemPrompt, messages, tools); - totalTokens += response.input_tokens + response.output_tokens; - - if (response.is_end_turn) { - return { - {"response", response.text}, - {"iterations", iteration + 1}, - {"tokens", totalTokens} - }; - } - - // Execute tool calls - if (!response.tool_calls.empty()) { - std::vector results; - - for (const auto& call : response.tool_calls) { - m_logger->debug("Executing tool: {}", call.name); - nlohmann::json result = m_toolRegistry.execute(call.name, call.input); - results.push_back({call.id, result.dump(), false}); - } - - // Append assistant message and tool results - m_provider->appendAssistantMessage(messages, response); - auto toolResultsMsg = m_provider->formatToolResults(results); - - if (toolResultsMsg.is_array()) { - for (const auto& msg : toolResultsMsg) { - messages.push_back(msg); - } - } else { - messages.push_back(toolResultsMsg); - } - } - } - - return {{"error", "max_iterations_reached"}}; -} - -void LLMService::shutdown() { - m_running = false; - m_requestCV.notify_all(); - - if (m_workerThread.joinable()) { - m_workerThread.join(); - } - - m_logger->info("LLMService shutdown"); -} - -LLMService::SyncResponse LLMService::sendMessageSync( - const std::string& message, - const std::string& conversationId, - const std::string& systemPrompt -) { - SyncResponse syncResp; - - // Create request (same as async mode) - Request request; - request.query = message; - request.conversationId = conversationId.empty() ? "mcp-session" : conversationId; - request.systemPrompt = systemPrompt.empty() ? m_defaultSystemPrompt : systemPrompt; - request.maxIterations = m_maxIterations; - - // Process synchronously (blocking call) - auto response = processRequest(request); - - // Convert to SyncResponse - if (!response.isError) { - syncResp.text = response.text; - syncResp.tokens = response.tokens; - syncResp.iterations = response.iterations; - } else { - // On error, return error in text - syncResp.text = "Error: " + response.text; - syncResp.tokens = 0; - syncResp.iterations = 0; - } - - return syncResp; -} - -} // namespace aissia +#include "LLMService.hpp" +#include "../shared/llm/LLMProviderFactory.hpp" + +#include +#include + +namespace aissia { + +LLMService::LLMService() { + m_logger = spdlog::get("LLMService"); + if (!m_logger) { + m_logger = spdlog::stdout_color_mt("LLMService"); + } +} + +LLMService::~LLMService() { + shutdown(); +} + +bool LLMService::initialize(grove::IIO* io) { + m_io = io; + + if (m_io) { + grove::SubscriptionConfig config; + m_io->subscribe("llm:request", config); + } + + // Start worker thread + m_running = true; + m_workerThread = std::thread(&LLMService::workerLoop, this); + + m_logger->info("LLMService initialized"); + return true; +} + +bool LLMService::loadConfig(const std::string& configPath) { + try { + std::ifstream file(configPath); + if (!file.is_open()) { + m_logger->warn("Config file not found: {}", configPath); + return false; + } + + nlohmann::json config; + file >> config; + + m_provider = LLMProviderFactory::create(config); + if (!m_provider) { + m_logger->error("Failed to create LLM provider"); + return false; + } + + m_providerName = config.value("provider", "claude"); + m_maxIterations = config.value("max_iterations", 10); + m_defaultSystemPrompt = config.value("system_prompt", + "Tu es AISSIA, un assistant personnel intelligent. " + "Tu peux utiliser des tools pour accomplir des taches: " + "gerer le planning, verifier le focus, sauvegarder des notes, " + "lire des fichiers, faire des recherches web, etc."); + + m_logger->info("LLM provider loaded: {} ({})", m_providerName, m_provider->getModel()); + + // Initialize tools after provider is ready + initializeTools(); + + return true; + + } catch (const std::exception& e) { + m_logger->error("Failed to load config: {}", e.what()); + return false; + } +} + +void LLMService::initializeTools() { + m_logger->info("Initializing tools..."); + + // 1. Internal tools (via GroveEngine IIO) + if (m_io) { + m_internalTools = std::make_unique(m_io); + for (const auto& tool : m_internalTools->getTools()) { + m_toolRegistry.registerTool(tool); + } + m_logger->info("Registered {} internal tools", m_internalTools->size()); + } + + // 2. FileSystem tools (direct C++ execution) + for (const auto& toolDef : tools::FileSystemTools::getToolDefinitions()) { + std::string toolName = toolDef["name"].get(); + m_toolRegistry.registerTool( + toolName, + toolDef["description"].get(), + toolDef["input_schema"], + [toolName](const nlohmann::json& input) -> nlohmann::json { + return tools::FileSystemTools::execute(toolName, input); + } + ); + } + m_logger->info("Registered {} filesystem tools", tools::FileSystemTools::getToolDefinitions().size()); + + // 3. MCP tools (via external servers) + m_mcpClient = std::make_unique(); + if (loadMCPConfig("config/mcp.json")) { + int connected = m_mcpClient->connectAll(); + if (connected > 0) { + for (const auto& tool : m_mcpClient->listAllTools()) { + // Convert MCP tool to our ToolDefinition format + m_toolRegistry.registerTool( + tool.name, + tool.description, + tool.inputSchema, + [this, toolName = tool.name](const nlohmann::json& input) -> nlohmann::json { + auto result = m_mcpClient->callTool(toolName, input); + // Convert MCP result to simple JSON + if (result.isError) { + return {{"error", true}, {"content", result.content}}; + } + // Extract text content + std::string text; + for (const auto& content : result.content) { + if (content.contains("text")) { + text += content["text"].get(); + } + } + return {{"content", text}}; + } + ); + } + m_logger->info("Registered {} MCP tools from {} servers", + m_mcpClient->toolCount(), connected); + } + } + + m_logger->info("Total tools available: {}", m_toolRegistry.size()); +} + +bool LLMService::loadMCPConfig(const std::string& configPath) { + return m_mcpClient->loadConfig(configPath); +} + +void LLMService::registerTool(const std::string& name, const std::string& description, + const nlohmann::json& schema, + std::function handler) { + m_toolRegistry.registerTool(name, description, schema, handler); + m_logger->debug("Tool registered: {}", name); +} + +void LLMService::process() { + processIncomingMessages(); + publishResponses(); +} + +void LLMService::processIncomingMessages() { + if (!m_io) return; + + while (m_io->hasMessages() > 0) { + auto msg = m_io->pullMessage(); + + if (msg.topic == "llm:request" && msg.data) { + Request req; + req.query = msg.data->getString("query", ""); + req.systemPrompt = msg.data->getString("systemPrompt", m_defaultSystemPrompt); + req.conversationId = msg.data->getString("conversationId", "default"); + req.maxIterations = msg.data->getInt("maxIterations", m_maxIterations); + + // Get tools from message or use registered tools + auto* toolsNode = msg.data->getChildReadOnly("tools"); + if (toolsNode) { + // Custom tools from message + // (would need to parse from IDataNode) + } + + if (!req.query.empty()) { + std::lock_guard lock(m_requestMutex); + m_requestQueue.push(std::move(req)); + m_requestCV.notify_one(); + m_logger->debug("Request queued: {}", req.query.substr(0, 50)); + } + } + } +} + +void LLMService::publishResponses() { + if (!m_io) return; + + std::lock_guard lock(m_responseMutex); + while (!m_responseQueue.empty()) { + auto resp = std::move(m_responseQueue.front()); + m_responseQueue.pop(); + + if (resp.isError) { + auto event = std::make_unique("error"); + event->setString("message", resp.text); + event->setString("conversationId", resp.conversationId); + m_io->publish("llm:error", std::move(event)); + } else { + auto event = std::make_unique("response"); + event->setString("text", resp.text); + event->setString("conversationId", resp.conversationId); + event->setInt("tokens", resp.tokens); + event->setInt("iterations", resp.iterations); + m_io->publish("llm:response", std::move(event)); + + m_logger->info("Response published: {} chars", resp.text.size()); + } + } +} + +void LLMService::workerLoop() { + m_logger->debug("Worker thread started"); + + while (m_running) { + Request req; + + { + std::unique_lock lock(m_requestMutex); + m_requestCV.wait_for(lock, std::chrono::milliseconds(100), [this] { + return !m_requestQueue.empty() || !m_running; + }); + + if (!m_running) break; + if (m_requestQueue.empty()) continue; + + req = std::move(m_requestQueue.front()); + m_requestQueue.pop(); + } + + // Process request (HTTP calls happen here) + auto resp = processRequest(req); + + { + std::lock_guard lock(m_responseMutex); + m_responseQueue.push(std::move(resp)); + } + } + + m_logger->debug("Worker thread stopped"); +} + +LLMService::Response LLMService::processRequest(const Request& request) { + Response resp; + resp.conversationId = request.conversationId; + + if (!m_provider) { + resp.text = "LLM provider not initialized"; + resp.isError = true; + return resp; + } + + try { + // Get or create conversation history + auto& history = m_conversations[request.conversationId]; + if (history.is_null()) { + history = nlohmann::json::array(); + } + + // Add user message + history.push_back({{"role", "user"}, {"content", request.query}}); + + // Get tool definitions + nlohmann::json tools = m_toolRegistry.getToolDefinitions(); + + // Run agentic loop + auto result = agenticLoop(request.query, request.systemPrompt, + history, tools, request.maxIterations); + + if (result.contains("error")) { + resp.text = result["error"].get(); + resp.isError = true; + } else { + resp.text = result["response"].get(); + resp.tokens = result.value("tokens", 0); + resp.iterations = result.value("iterations", 1); + + // Add assistant response to history + history.push_back({{"role", "assistant"}, {"content", resp.text}}); + } + + } catch (const std::exception& e) { + resp.text = e.what(); + resp.isError = true; + } + + return resp; +} + +nlohmann::json LLMService::agenticLoop(const std::string& query, const std::string& systemPrompt, + nlohmann::json& messages, const nlohmann::json& tools, + int maxIterations) { + int totalTokens = 0; + + for (int iteration = 0; iteration < maxIterations; iteration++) { + m_logger->debug("Agentic loop iteration {}", iteration + 1); + + auto response = m_provider->chat(systemPrompt, messages, tools); + totalTokens += response.input_tokens + response.output_tokens; + + if (response.is_end_turn) { + return { + {"response", response.text}, + {"iterations", iteration + 1}, + {"tokens", totalTokens} + }; + } + + // Execute tool calls + if (!response.tool_calls.empty()) { + std::vector results; + + for (const auto& call : response.tool_calls) { + m_logger->debug("Executing tool: {}", call.name); + nlohmann::json result = m_toolRegistry.execute(call.name, call.input); + results.push_back({call.id, result.dump(), false}); + } + + // Append assistant message and tool results + m_provider->appendAssistantMessage(messages, response); + auto toolResultsMsg = m_provider->formatToolResults(results); + + if (toolResultsMsg.is_array()) { + for (const auto& msg : toolResultsMsg) { + messages.push_back(msg); + } + } else { + messages.push_back(toolResultsMsg); + } + } + } + + return {{"error", "max_iterations_reached"}}; +} + +void LLMService::shutdown() { + m_running = false; + m_requestCV.notify_all(); + + if (m_workerThread.joinable()) { + m_workerThread.join(); + } + + m_logger->info("LLMService shutdown"); +} + +LLMService::SyncResponse LLMService::sendMessageSync( + const std::string& message, + const std::string& conversationId, + const std::string& systemPrompt +) { + SyncResponse syncResp; + + // Create request (same as async mode) + Request request; + request.query = message; + request.conversationId = conversationId.empty() ? "mcp-session" : conversationId; + request.systemPrompt = systemPrompt.empty() ? m_defaultSystemPrompt : systemPrompt; + request.maxIterations = m_maxIterations; + + // Process synchronously (blocking call) + auto response = processRequest(request); + + // Convert to SyncResponse + if (!response.isError) { + syncResp.text = response.text; + syncResp.tokens = response.tokens; + syncResp.iterations = response.iterations; + } else { + // On error, return error in text + syncResp.text = "Error: " + response.text; + syncResp.tokens = 0; + syncResp.iterations = 0; + } + + return syncResp; +} + +} // namespace aissia diff --git a/src/services/LLMService.hpp b/src/services/LLMService.hpp index 8dabf8a..d81d35e 100644 --- a/src/services/LLMService.hpp +++ b/src/services/LLMService.hpp @@ -1,140 +1,140 @@ -#pragma once - -#include "IService.hpp" -#include "../shared/llm/ILLMProvider.hpp" -#include "../shared/llm/ToolRegistry.hpp" -#include "../shared/tools/InternalTools.hpp" -#include "../shared/tools/FileSystemTools.hpp" -#include "../shared/mcp/MCPClient.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace aissia { - -/** - * @brief LLM Service - Async HTTP calls to LLM providers - * - * Handles all LLM API calls in a background thread. - * Modules communicate via IIO: - * - * Subscribes to: - * - "llm:request" : { query, systemPrompt?, tools?, conversationId? } - * - * Publishes: - * - "llm:response" : { text, conversationId, tokens, iterations } - * - "llm:error" : { message, conversationId } - * - "llm:thinking" : { conversationId } (during agentic loop) - */ -class LLMService : public IService { -public: - LLMService(); - ~LLMService() override; - - bool initialize(grove::IIO* io) override; - void process() override; - void shutdown() override; - std::string getName() const override { return "LLMService"; } - bool isHealthy() const override { return m_provider != nullptr; } - - /// Load provider from config file - bool loadConfig(const std::string& configPath); - - /// Register a tool that can be called by the LLM - void registerTool(const std::string& name, const std::string& description, - const nlohmann::json& schema, - std::function handler); - - /// Load and initialize all tools (internal + MCP) - void initializeTools(); - - /// Load MCP server configurations - bool loadMCPConfig(const std::string& configPath); - - /** - * @brief Synchronous response structure for MCP Server mode - */ - struct SyncResponse { - std::string text; - int tokens = 0; - int iterations = 0; - }; - - /** - * @brief Send message synchronously (blocking, for MCP Server mode) - * - * @param message User message - * @param conversationId Conversation ID (optional) - * @param systemPrompt Custom system prompt (optional) - * @return Sync response with text, tokens, iterations - */ - SyncResponse sendMessageSync( - const std::string& message, - const std::string& conversationId = "", - const std::string& systemPrompt = "" - ); - -private: - struct Request { - std::string query; - std::string systemPrompt; - std::string conversationId; - nlohmann::json tools; - int maxIterations = 10; - }; - - struct Response { - std::string text; - std::string conversationId; - int tokens = 0; - int iterations = 0; - bool isError = false; - }; - - // Configuration - std::string m_providerName = "claude"; - std::string m_defaultSystemPrompt; - int m_maxIterations = 10; - - // State - std::unique_ptr m_provider; - ToolRegistry m_toolRegistry; - std::unique_ptr m_internalTools; - std::unique_ptr m_mcpClient; - std::map m_conversations; // conversationId -> history - - // Threading - std::thread m_workerThread; - std::atomic m_running{false}; - std::queue m_requestQueue; - std::queue m_responseQueue; - std::mutex m_requestMutex; - std::mutex m_responseMutex; - std::condition_variable m_requestCV; - - // Services - grove::IIO* m_io = nullptr; - std::shared_ptr m_logger; - - // Worker thread - void workerLoop(); - Response processRequest(const Request& request); - nlohmann::json agenticLoop(const std::string& query, const std::string& systemPrompt, - nlohmann::json& messages, const nlohmann::json& tools, - int maxIterations); - - // Message handling - void processIncomingMessages(); - void publishResponses(); -}; - -} // namespace aissia +#pragma once + +#include "IService.hpp" +#include "../shared/llm/ILLMProvider.hpp" +#include "../shared/llm/ToolRegistry.hpp" +#include "../shared/tools/InternalTools.hpp" +#include "../shared/tools/FileSystemTools.hpp" +#include "../shared/mcp/MCPClient.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace aissia { + +/** + * @brief LLM Service - Async HTTP calls to LLM providers + * + * Handles all LLM API calls in a background thread. + * Modules communicate via IIO: + * + * Subscribes to: + * - "llm:request" : { query, systemPrompt?, tools?, conversationId? } + * + * Publishes: + * - "llm:response" : { text, conversationId, tokens, iterations } + * - "llm:error" : { message, conversationId } + * - "llm:thinking" : { conversationId } (during agentic loop) + */ +class LLMService : public IService { +public: + LLMService(); + ~LLMService() override; + + bool initialize(grove::IIO* io) override; + void process() override; + void shutdown() override; + std::string getName() const override { return "LLMService"; } + bool isHealthy() const override { return m_provider != nullptr; } + + /// Load provider from config file + bool loadConfig(const std::string& configPath); + + /// Register a tool that can be called by the LLM + void registerTool(const std::string& name, const std::string& description, + const nlohmann::json& schema, + std::function handler); + + /// Load and initialize all tools (internal + MCP) + void initializeTools(); + + /// Load MCP server configurations + bool loadMCPConfig(const std::string& configPath); + + /** + * @brief Synchronous response structure for MCP Server mode + */ + struct SyncResponse { + std::string text; + int tokens = 0; + int iterations = 0; + }; + + /** + * @brief Send message synchronously (blocking, for MCP Server mode) + * + * @param message User message + * @param conversationId Conversation ID (optional) + * @param systemPrompt Custom system prompt (optional) + * @return Sync response with text, tokens, iterations + */ + SyncResponse sendMessageSync( + const std::string& message, + const std::string& conversationId = "", + const std::string& systemPrompt = "" + ); + +private: + struct Request { + std::string query; + std::string systemPrompt; + std::string conversationId; + nlohmann::json tools; + int maxIterations = 10; + }; + + struct Response { + std::string text; + std::string conversationId; + int tokens = 0; + int iterations = 0; + bool isError = false; + }; + + // Configuration + std::string m_providerName = "claude"; + std::string m_defaultSystemPrompt; + int m_maxIterations = 10; + + // State + std::unique_ptr m_provider; + ToolRegistry m_toolRegistry; + std::unique_ptr m_internalTools; + std::unique_ptr m_mcpClient; + std::map m_conversations; // conversationId -> history + + // Threading + std::thread m_workerThread; + std::atomic m_running{false}; + std::queue m_requestQueue; + std::queue m_responseQueue; + std::mutex m_requestMutex; + std::mutex m_responseMutex; + std::condition_variable m_requestCV; + + // Services + grove::IIO* m_io = nullptr; + std::shared_ptr m_logger; + + // Worker thread + void workerLoop(); + Response processRequest(const Request& request); + nlohmann::json agenticLoop(const std::string& query, const std::string& systemPrompt, + nlohmann::json& messages, const nlohmann::json& tools, + int maxIterations); + + // Message handling + void processIncomingMessages(); + void publishResponses(); +}; + +} // namespace aissia diff --git a/src/services/PlatformService.cpp b/src/services/PlatformService.cpp index 4dc3c02..9235d5c 100644 --- a/src/services/PlatformService.cpp +++ b/src/services/PlatformService.cpp @@ -1,139 +1,139 @@ -#include "PlatformService.hpp" - -#include - -namespace aissia { - -PlatformService::PlatformService() { - m_logger = spdlog::get("PlatformService"); - if (!m_logger) { - m_logger = spdlog::stdout_color_mt("PlatformService"); - } -} - -bool PlatformService::initialize(grove::IIO* io) { - m_io = io; - - // Create platform-specific window tracker - m_tracker = WindowTrackerFactory::create(); - - if (!m_tracker || !m_tracker->isAvailable()) { - m_logger->warn("Window tracker not available on this platform"); - return true; // Non-fatal, module can work without tracking - } - - if (m_io) { - grove::SubscriptionConfig config; - m_io->subscribe("platform:query_window", config); - } - - m_logger->info("PlatformService initialized: {}", m_tracker->getPlatformName()); - return true; -} - -void PlatformService::configure(int pollIntervalMs, int idleThresholdSeconds) { - m_pollIntervalMs = pollIntervalMs; - m_idleThresholdSeconds = idleThresholdSeconds; - m_logger->debug("Configured: poll={}ms, idle={}s", pollIntervalMs, idleThresholdSeconds); -} - -void PlatformService::process() { - if (!m_tracker || !m_tracker->isAvailable()) return; - - // Use monotonic clock for timing - static auto startTime = std::chrono::steady_clock::now(); - auto now = std::chrono::steady_clock::now(); - float currentTime = std::chrono::duration(now - startTime).count(); - - float pollIntervalSec = m_pollIntervalMs / 1000.0f; - if (currentTime - m_lastPollTime >= pollIntervalSec) { - m_lastPollTime = currentTime; - pollWindowInfo(currentTime); - } - - // Handle query requests - if (m_io) { - while (m_io->hasMessages() > 0) { - auto msg = m_io->pullMessage(); - if (msg.topic == "platform:query_window") { - publishWindowInfo(); - } - } - } -} - -void PlatformService::pollWindowInfo(float currentTime) { - std::string newApp = m_tracker->getCurrentAppName(); - std::string newTitle = m_tracker->getCurrentWindowTitle(); - - // Check for app change - if (newApp != m_currentApp) { - int duration = static_cast(currentTime - m_appStartTime); - - if (!m_currentApp.empty() && duration > 0) { - publishWindowChanged(m_currentApp, newApp, duration); - } - - m_currentApp = newApp; - m_currentWindowTitle = newTitle; - m_appStartTime = currentTime; - - m_logger->debug("App: {} - {}", m_currentApp, - m_currentWindowTitle.size() > 50 ? - m_currentWindowTitle.substr(0, 50) + "..." : m_currentWindowTitle); - } - - // Check idle state - bool isIdle = m_tracker->isUserIdle(m_idleThresholdSeconds); - - if (isIdle && !m_wasIdle) { - m_logger->info("User idle detected ({}s)", m_idleThresholdSeconds); - if (m_io) { - auto event = std::make_unique("idle"); - event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); - m_io->publish("platform:idle_detected", std::move(event)); - } - } - else if (!isIdle && m_wasIdle) { - m_logger->info("User activity resumed"); - if (m_io) { - auto event = std::make_unique("active"); - m_io->publish("platform:activity_resumed", std::move(event)); - } - } - - m_wasIdle = isIdle; - - // Publish periodic window info - publishWindowInfo(); -} - -void PlatformService::publishWindowInfo() { - if (!m_io || !m_tracker) return; - - auto event = std::make_unique("window"); - event->setString("appName", m_currentApp); - event->setString("windowTitle", m_currentWindowTitle); - event->setBool("isIdle", m_wasIdle); - event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); - m_io->publish("platform:window_info", std::move(event)); -} - -void PlatformService::publishWindowChanged(const std::string& oldApp, - const std::string& newApp, - int duration) { - if (!m_io) return; - - auto event = std::make_unique("changed"); - event->setString("oldApp", oldApp); - event->setString("newApp", newApp); - event->setInt("duration", duration); - m_io->publish("platform:window_changed", std::move(event)); -} - -void PlatformService::shutdown() { - m_tracker.reset(); - m_logger->info("PlatformService shutdown"); -} - -} // namespace aissia +#include "PlatformService.hpp" + +#include + +namespace aissia { + +PlatformService::PlatformService() { + m_logger = spdlog::get("PlatformService"); + if (!m_logger) { + m_logger = spdlog::stdout_color_mt("PlatformService"); + } +} + +bool PlatformService::initialize(grove::IIO* io) { + m_io = io; + + // Create platform-specific window tracker + m_tracker = WindowTrackerFactory::create(); + + if (!m_tracker || !m_tracker->isAvailable()) { + m_logger->warn("Window tracker not available on this platform"); + return true; // Non-fatal, module can work without tracking + } + + if (m_io) { + grove::SubscriptionConfig config; + m_io->subscribe("platform:query_window", config); + } + + m_logger->info("PlatformService initialized: {}", m_tracker->getPlatformName()); + return true; +} + +void PlatformService::configure(int pollIntervalMs, int idleThresholdSeconds) { + m_pollIntervalMs = pollIntervalMs; + m_idleThresholdSeconds = idleThresholdSeconds; + m_logger->debug("Configured: poll={}ms, idle={}s", pollIntervalMs, idleThresholdSeconds); +} + +void PlatformService::process() { + if (!m_tracker || !m_tracker->isAvailable()) return; + + // Use monotonic clock for timing + static auto startTime = std::chrono::steady_clock::now(); + auto now = std::chrono::steady_clock::now(); + float currentTime = std::chrono::duration(now - startTime).count(); + + float pollIntervalSec = m_pollIntervalMs / 1000.0f; + if (currentTime - m_lastPollTime >= pollIntervalSec) { + m_lastPollTime = currentTime; + pollWindowInfo(currentTime); + } + + // Handle query requests + if (m_io) { + while (m_io->hasMessages() > 0) { + auto msg = m_io->pullMessage(); + if (msg.topic == "platform:query_window") { + publishWindowInfo(); + } + } + } +} + +void PlatformService::pollWindowInfo(float currentTime) { + std::string newApp = m_tracker->getCurrentAppName(); + std::string newTitle = m_tracker->getCurrentWindowTitle(); + + // Check for app change + if (newApp != m_currentApp) { + int duration = static_cast(currentTime - m_appStartTime); + + if (!m_currentApp.empty() && duration > 0) { + publishWindowChanged(m_currentApp, newApp, duration); + } + + m_currentApp = newApp; + m_currentWindowTitle = newTitle; + m_appStartTime = currentTime; + + m_logger->debug("App: {} - {}", m_currentApp, + m_currentWindowTitle.size() > 50 ? + m_currentWindowTitle.substr(0, 50) + "..." : m_currentWindowTitle); + } + + // Check idle state + bool isIdle = m_tracker->isUserIdle(m_idleThresholdSeconds); + + if (isIdle && !m_wasIdle) { + m_logger->info("User idle detected ({}s)", m_idleThresholdSeconds); + if (m_io) { + auto event = std::make_unique("idle"); + event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); + m_io->publish("platform:idle_detected", std::move(event)); + } + } + else if (!isIdle && m_wasIdle) { + m_logger->info("User activity resumed"); + if (m_io) { + auto event = std::make_unique("active"); + m_io->publish("platform:activity_resumed", std::move(event)); + } + } + + m_wasIdle = isIdle; + + // Publish periodic window info + publishWindowInfo(); +} + +void PlatformService::publishWindowInfo() { + if (!m_io || !m_tracker) return; + + auto event = std::make_unique("window"); + event->setString("appName", m_currentApp); + event->setString("windowTitle", m_currentWindowTitle); + event->setBool("isIdle", m_wasIdle); + event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); + m_io->publish("platform:window_info", std::move(event)); +} + +void PlatformService::publishWindowChanged(const std::string& oldApp, + const std::string& newApp, + int duration) { + if (!m_io) return; + + auto event = std::make_unique("changed"); + event->setString("oldApp", oldApp); + event->setString("newApp", newApp); + event->setInt("duration", duration); + m_io->publish("platform:window_changed", std::move(event)); +} + +void PlatformService::shutdown() { + m_tracker.reset(); + m_logger->info("PlatformService shutdown"); +} + +} // namespace aissia diff --git a/src/services/PlatformService.hpp b/src/services/PlatformService.hpp index fc10fbf..5a5d17f 100644 --- a/src/services/PlatformService.hpp +++ b/src/services/PlatformService.hpp @@ -1,67 +1,67 @@ -#pragma once - -#include "IService.hpp" -#include "../shared/platform/IWindowTracker.hpp" - -#include -#include -#include - -#include -#include - -namespace aissia { - -/** - * @brief Platform Service - OS-specific APIs (window tracking, etc.) - * - * Handles platform-specific operations that can't be in hot-reload modules. - * Polls foreground window at configurable interval. - * - * Subscribes to: - * - "platform:query_window" : Request current window info - * - * Publishes: - * - "platform:window_info" : { appName, windowTitle, isIdle, idleSeconds } - * - "platform:window_changed" : { oldApp, newApp, duration } - * - "platform:idle_detected" : { idleSeconds } - * - "platform:activity_resumed" : {} - */ -class PlatformService : public IService { -public: - PlatformService(); - ~PlatformService() override = default; - - bool initialize(grove::IIO* io) override; - void process() override; - void shutdown() override; - std::string getName() const override { return "PlatformService"; } - bool isHealthy() const override { return m_tracker != nullptr && m_tracker->isAvailable(); } - - /// Configure polling interval and idle threshold - void configure(int pollIntervalMs = 1000, int idleThresholdSeconds = 300); - -private: - // Configuration - int m_pollIntervalMs = 1000; - int m_idleThresholdSeconds = 300; - - // State - std::unique_ptr m_tracker; - std::string m_currentApp; - std::string m_currentWindowTitle; - float m_appStartTime = 0.0f; - float m_lastPollTime = 0.0f; - bool m_wasIdle = false; - - // Services - grove::IIO* m_io = nullptr; - std::shared_ptr m_logger; - - // Helpers - void pollWindowInfo(float currentTime); - void publishWindowInfo(); - void publishWindowChanged(const std::string& oldApp, const std::string& newApp, int duration); -}; - -} // namespace aissia +#pragma once + +#include "IService.hpp" +#include "../shared/platform/IWindowTracker.hpp" + +#include +#include +#include + +#include +#include + +namespace aissia { + +/** + * @brief Platform Service - OS-specific APIs (window tracking, etc.) + * + * Handles platform-specific operations that can't be in hot-reload modules. + * Polls foreground window at configurable interval. + * + * Subscribes to: + * - "platform:query_window" : Request current window info + * + * Publishes: + * - "platform:window_info" : { appName, windowTitle, isIdle, idleSeconds } + * - "platform:window_changed" : { oldApp, newApp, duration } + * - "platform:idle_detected" : { idleSeconds } + * - "platform:activity_resumed" : {} + */ +class PlatformService : public IService { +public: + PlatformService(); + ~PlatformService() override = default; + + bool initialize(grove::IIO* io) override; + void process() override; + void shutdown() override; + std::string getName() const override { return "PlatformService"; } + bool isHealthy() const override { return m_tracker != nullptr && m_tracker->isAvailable(); } + + /// Configure polling interval and idle threshold + void configure(int pollIntervalMs = 1000, int idleThresholdSeconds = 300); + +private: + // Configuration + int m_pollIntervalMs = 1000; + int m_idleThresholdSeconds = 300; + + // State + std::unique_ptr m_tracker; + std::string m_currentApp; + std::string m_currentWindowTitle; + float m_appStartTime = 0.0f; + float m_lastPollTime = 0.0f; + bool m_wasIdle = false; + + // Services + grove::IIO* m_io = nullptr; + std::shared_ptr m_logger; + + // Helpers + void pollWindowInfo(float currentTime); + void publishWindowInfo(); + void publishWindowChanged(const std::string& oldApp, const std::string& newApp, int duration); +}; + +} // namespace aissia diff --git a/src/services/STTService.cpp b/src/services/STTService.cpp index ac22365..6aed1a2 100644 --- a/src/services/STTService.cpp +++ b/src/services/STTService.cpp @@ -1,189 +1,189 @@ -// CRITICAL ORDER: Include system headers first -#include -#include -#include -#include - -// Include local headers before spdlog -#include "STTService.hpp" -#include "../shared/audio/ISTTEngine.hpp" - -// Include spdlog after local headers -#include -#include - -namespace aissia { - -STTService::STTService(const nlohmann::json& config) - : m_config(config) -{ - m_logger = spdlog::get("STTService"); - if (!m_logger) { - m_logger = spdlog::stdout_color_mt("STTService"); - } - - // Extract language from config - if (config.contains("active_mode") && config["active_mode"].contains("language")) { - m_language = config["active_mode"]["language"].get(); - } - - m_logger->info("STTService created"); -} - -STTService::~STTService() { - stop(); -} - -bool STTService::start() { - m_logger->info("Starting STT service"); - - loadEngines(); - - if (!m_activeEngine || !m_activeEngine->isAvailable()) { - m_logger->error("No active STT engine available"); - return false; - } - - m_logger->info("STT service started"); - return true; -} - -void STTService::stop() { - m_logger->info("Stopping STT service"); - stopListening(); - m_activeEngine.reset(); -} - -void STTService::setMode(STTMode mode) { - if (m_currentMode == mode) { - return; - } - - m_logger->info("Switching STT mode"); - m_currentMode = mode; -} - -std::string STTService::transcribeFile(const std::string& filePath) { - if (!m_activeEngine || !m_activeEngine->isAvailable()) { - m_logger->warn("No STT engine available for transcription"); - return ""; - } - - m_logger->info("Transcribing file"); - - try { - std::string result = m_activeEngine->transcribeFile(filePath); - m_logger->info("Transcription complete"); - return result; - } catch (const std::exception& e) { - m_logger->error("Transcription failed"); - return ""; - } -} - -std::string STTService::transcribe(const std::vector& audioData) { - if (!m_activeEngine || !m_activeEngine->isAvailable()) { - return ""; - } - - if (audioData.empty()) { - return ""; - } - - try { - std::string result = m_activeEngine->transcribe(audioData); - - if (!result.empty() && m_listening && m_onTranscription) { - m_onTranscription(result, m_currentMode); - } - - return result; - } catch (const std::exception& e) { - m_logger->error("Transcription failed"); - return ""; - } -} - -void STTService::startListening(TranscriptionCallback onTranscription, - KeywordCallback onKeyword) { - m_logger->info("Start listening"); - - m_onTranscription = onTranscription; - m_onKeyword = onKeyword; - m_listening = true; - - m_logger->warn("Streaming microphone capture not yet implemented"); -} - -void STTService::stopListening() { - if (!m_listening) { - return; - } - - m_logger->info("Stop listening"); - m_listening = false; -} - -void STTService::setLanguage(const std::string& language) { - m_logger->info("Setting language"); - m_language = language; - - if (m_activeEngine) { - m_activeEngine->setLanguage(language); - } -} - -bool STTService::isAvailable() const { - return m_activeEngine && m_activeEngine->isAvailable(); -} - -std::string STTService::getCurrentEngine() const { - if (m_activeEngine) { - return m_activeEngine->getEngineName(); - } - return "none"; -} - -void STTService::loadEngines() { - m_logger->info("Loading STT engines"); - - std::string engineType = "auto"; - if (m_config.contains("active_mode")) { - const auto& activeMode = m_config["active_mode"]; - if (activeMode.contains("engine")) { - engineType = activeMode["engine"]; - } - } - - std::string modelPath; - if (m_config.contains("active_mode")) { - const auto& activeMode = m_config["active_mode"]; - if (activeMode.contains("model_path")) { - modelPath = activeMode["model_path"]; - } - } - - std::string apiKey; - if (m_config.contains("whisper_api")) { - const auto& whisperApi = m_config["whisper_api"]; - std::string apiKeyEnv = "OPENAI_API_KEY"; - if (whisperApi.contains("api_key_env")) { - apiKeyEnv = whisperApi["api_key_env"]; - } - const char* envVal = std::getenv(apiKeyEnv.c_str()); - if (envVal) { - apiKey = envVal; - } - } - - m_activeEngine = STTEngineFactory::create(engineType, modelPath, apiKey); - - if (m_activeEngine && m_activeEngine->isAvailable()) { - m_activeEngine->setLanguage(m_language); - m_logger->info("STT engine loaded successfully"); - } else { - m_logger->warn("No active STT engine available"); - } -} - -} // namespace aissia +// CRITICAL ORDER: Include system headers first +#include +#include +#include +#include + +// Include local headers before spdlog +#include "STTService.hpp" +#include "../shared/audio/ISTTEngine.hpp" + +// Include spdlog after local headers +#include +#include + +namespace aissia { + +STTService::STTService(const nlohmann::json& config) + : m_config(config) +{ + m_logger = spdlog::get("STTService"); + if (!m_logger) { + m_logger = spdlog::stdout_color_mt("STTService"); + } + + // Extract language from config + if (config.contains("active_mode") && config["active_mode"].contains("language")) { + m_language = config["active_mode"]["language"].get(); + } + + m_logger->info("STTService created"); +} + +STTService::~STTService() { + stop(); +} + +bool STTService::start() { + m_logger->info("Starting STT service"); + + loadEngines(); + + if (!m_activeEngine || !m_activeEngine->isAvailable()) { + m_logger->error("No active STT engine available"); + return false; + } + + m_logger->info("STT service started"); + return true; +} + +void STTService::stop() { + m_logger->info("Stopping STT service"); + stopListening(); + m_activeEngine.reset(); +} + +void STTService::setMode(STTMode mode) { + if (m_currentMode == mode) { + return; + } + + m_logger->info("Switching STT mode"); + m_currentMode = mode; +} + +std::string STTService::transcribeFile(const std::string& filePath) { + if (!m_activeEngine || !m_activeEngine->isAvailable()) { + m_logger->warn("No STT engine available for transcription"); + return ""; + } + + m_logger->info("Transcribing file"); + + try { + std::string result = m_activeEngine->transcribeFile(filePath); + m_logger->info("Transcription complete"); + return result; + } catch (const std::exception& e) { + m_logger->error("Transcription failed"); + return ""; + } +} + +std::string STTService::transcribe(const std::vector& audioData) { + if (!m_activeEngine || !m_activeEngine->isAvailable()) { + return ""; + } + + if (audioData.empty()) { + return ""; + } + + try { + std::string result = m_activeEngine->transcribe(audioData); + + if (!result.empty() && m_listening && m_onTranscription) { + m_onTranscription(result, m_currentMode); + } + + return result; + } catch (const std::exception& e) { + m_logger->error("Transcription failed"); + return ""; + } +} + +void STTService::startListening(TranscriptionCallback onTranscription, + KeywordCallback onKeyword) { + m_logger->info("Start listening"); + + m_onTranscription = onTranscription; + m_onKeyword = onKeyword; + m_listening = true; + + m_logger->warn("Streaming microphone capture not yet implemented"); +} + +void STTService::stopListening() { + if (!m_listening) { + return; + } + + m_logger->info("Stop listening"); + m_listening = false; +} + +void STTService::setLanguage(const std::string& language) { + m_logger->info("Setting language"); + m_language = language; + + if (m_activeEngine) { + m_activeEngine->setLanguage(language); + } +} + +bool STTService::isAvailable() const { + return m_activeEngine && m_activeEngine->isAvailable(); +} + +std::string STTService::getCurrentEngine() const { + if (m_activeEngine) { + return m_activeEngine->getEngineName(); + } + return "none"; +} + +void STTService::loadEngines() { + m_logger->info("Loading STT engines"); + + std::string engineType = "auto"; + if (m_config.contains("active_mode")) { + const auto& activeMode = m_config["active_mode"]; + if (activeMode.contains("engine")) { + engineType = activeMode["engine"]; + } + } + + std::string modelPath; + if (m_config.contains("active_mode")) { + const auto& activeMode = m_config["active_mode"]; + if (activeMode.contains("model_path")) { + modelPath = activeMode["model_path"]; + } + } + + std::string apiKey; + if (m_config.contains("whisper_api")) { + const auto& whisperApi = m_config["whisper_api"]; + std::string apiKeyEnv = "OPENAI_API_KEY"; + if (whisperApi.contains("api_key_env")) { + apiKeyEnv = whisperApi["api_key_env"]; + } + const char* envVal = std::getenv(apiKeyEnv.c_str()); + if (envVal) { + apiKey = envVal; + } + } + + m_activeEngine = STTEngineFactory::create(engineType, modelPath, apiKey); + + if (m_activeEngine && m_activeEngine->isAvailable()) { + m_activeEngine->setLanguage(m_language); + m_logger->info("STT engine loaded successfully"); + } else { + m_logger->warn("No active STT engine available"); + } +} + +} // namespace aissia diff --git a/src/services/StorageService.cpp b/src/services/StorageService.cpp index b7e2db8..58d6ff7 100644 --- a/src/services/StorageService.cpp +++ b/src/services/StorageService.cpp @@ -1,348 +1,348 @@ -#include "StorageService.hpp" - -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace aissia { - -StorageService::StorageService() { - m_logger = spdlog::get("StorageService"); - if (!m_logger) { - m_logger = spdlog::stdout_color_mt("StorageService"); - } -} - -StorageService::~StorageService() { - shutdown(); -} - -bool StorageService::initialize(grove::IIO* io) { - m_io = io; - - if (m_io) { - grove::SubscriptionConfig config; - m_io->subscribe("storage:save_session", config); - m_io->subscribe("storage:save_app_usage", config); - m_io->subscribe("storage:save_conversation", config); - m_io->subscribe("storage:update_metrics", config); - } - - m_logger->info("StorageService initialized"); - return true; -} - -bool StorageService::openDatabase(const std::string& dbPath, - const std::string& journalMode, - int busyTimeoutMs) { - m_dbPath = dbPath; - - // Ensure directory exists - fs::path path(dbPath); - if (path.has_parent_path()) { - fs::create_directories(path.parent_path()); - } - - int rc = sqlite3_open(dbPath.c_str(), &m_db); - if (rc != SQLITE_OK) { - m_logger->error("SQLite open error: {}", sqlite3_errmsg(m_db)); - return false; - } - - // Set pragmas - std::string pragmas = "PRAGMA journal_mode=" + journalMode + ";" - "PRAGMA busy_timeout=" + std::to_string(busyTimeoutMs) + ";" - "PRAGMA foreign_keys=ON;"; - if (!executeSQL(pragmas)) { - return false; - } - - if (!initializeSchema()) { - return false; - } - - if (!prepareStatements()) { - return false; - } - - m_isConnected = true; - - // Publish ready event - if (m_io) { - auto event = std::make_unique("ready"); - event->setString("database", dbPath); - m_io->publish("storage:ready", std::move(event)); - } - - m_logger->info("Database opened: {}", dbPath); - return true; -} - -bool StorageService::initializeSchema() { - const char* schema = R"SQL( - CREATE TABLE IF NOT EXISTS work_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_name TEXT, - start_time INTEGER, - end_time INTEGER, - duration_minutes INTEGER, - hyperfocus_detected BOOLEAN DEFAULT 0, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - - CREATE TABLE IF NOT EXISTS app_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER, - app_name TEXT, - duration_seconds INTEGER, - is_productive BOOLEAN, - created_at INTEGER DEFAULT (strftime('%s', 'now')), - FOREIGN KEY (session_id) REFERENCES work_sessions(id) - ); - - CREATE TABLE IF NOT EXISTS conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - role TEXT, - content TEXT, - provider TEXT, - model TEXT, - tokens_used INTEGER, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - - CREATE TABLE IF NOT EXISTS daily_metrics ( - date TEXT PRIMARY KEY, - total_focus_minutes INTEGER DEFAULT 0, - total_breaks INTEGER DEFAULT 0, - hyperfocus_count INTEGER DEFAULT 0, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - - CREATE INDEX IF NOT EXISTS idx_sessions_date ON work_sessions(created_at); - CREATE INDEX IF NOT EXISTS idx_app_usage_session ON app_usage(session_id); - CREATE INDEX IF NOT EXISTS idx_conversations_date ON conversations(created_at); - )SQL"; - - return executeSQL(schema); -} - -bool StorageService::prepareStatements() { - int rc; - - // Save session statement - const char* sqlSession = "INSERT INTO work_sessions " - "(task_name, start_time, end_time, duration_minutes, hyperfocus_detected) " - "VALUES (?, ?, ?, ?, ?)"; - rc = sqlite3_prepare_v2(m_db, sqlSession, -1, &m_stmtSaveSession, nullptr); - if (rc != SQLITE_OK) { - m_logger->error("Failed to prepare save_session: {}", sqlite3_errmsg(m_db)); - return false; - } - - // Save app usage statement - const char* sqlAppUsage = "INSERT INTO app_usage " - "(session_id, app_name, duration_seconds, is_productive) " - "VALUES (?, ?, ?, ?)"; - rc = sqlite3_prepare_v2(m_db, sqlAppUsage, -1, &m_stmtSaveAppUsage, nullptr); - if (rc != SQLITE_OK) { - m_logger->error("Failed to prepare save_app_usage: {}", sqlite3_errmsg(m_db)); - return false; - } - - // Save conversation statement - const char* sqlConv = "INSERT INTO conversations " - "(role, content, provider, model, tokens_used) " - "VALUES (?, ?, ?, ?, ?)"; - rc = sqlite3_prepare_v2(m_db, sqlConv, -1, &m_stmtSaveConversation, nullptr); - if (rc != SQLITE_OK) { - m_logger->error("Failed to prepare save_conversation: {}", sqlite3_errmsg(m_db)); - return false; - } - - // Update metrics statement - const char* sqlMetrics = "INSERT INTO daily_metrics " - "(date, total_focus_minutes, total_breaks, hyperfocus_count) " - "VALUES (?, ?, ?, ?) " - "ON CONFLICT(date) DO UPDATE SET " - "total_focus_minutes = total_focus_minutes + excluded.total_focus_minutes, " - "total_breaks = total_breaks + excluded.total_breaks, " - "hyperfocus_count = hyperfocus_count + excluded.hyperfocus_count, " - "updated_at = strftime('%s', 'now')"; - rc = sqlite3_prepare_v2(m_db, sqlMetrics, -1, &m_stmtUpdateMetrics, nullptr); - if (rc != SQLITE_OK) { - m_logger->error("Failed to prepare update_metrics: {}", sqlite3_errmsg(m_db)); - return false; - } - - m_logger->debug("Prepared statements created"); - return true; -} - -void StorageService::finalizeStatements() { - if (m_stmtSaveSession) { sqlite3_finalize(m_stmtSaveSession); m_stmtSaveSession = nullptr; } - if (m_stmtSaveAppUsage) { sqlite3_finalize(m_stmtSaveAppUsage); m_stmtSaveAppUsage = nullptr; } - if (m_stmtSaveConversation) { sqlite3_finalize(m_stmtSaveConversation); m_stmtSaveConversation = nullptr; } - if (m_stmtUpdateMetrics) { sqlite3_finalize(m_stmtUpdateMetrics); m_stmtUpdateMetrics = nullptr; } -} - -void StorageService::process() { - processMessages(); -} - -void StorageService::processMessages() { - if (!m_io || !m_isConnected) return; - - while (m_io->hasMessages() > 0) { - auto msg = m_io->pullMessage(); - - if (msg.topic == "storage:save_session" && msg.data) { - handleSaveSession(*msg.data); - } - else if (msg.topic == "storage:save_app_usage" && msg.data) { - handleSaveAppUsage(*msg.data); - } - else if (msg.topic == "storage:save_conversation" && msg.data) { - handleSaveConversation(*msg.data); - } - else if (msg.topic == "storage:update_metrics" && msg.data) { - handleUpdateMetrics(*msg.data); - } - } -} - -void StorageService::handleSaveSession(const grove::IDataNode& data) { - std::string taskName = data.getString("taskName", "unknown"); - int durationMinutes = data.getInt("durationMinutes", 0); - bool hyperfocus = data.getBool("hyperfocus", false); - - std::time_t now = std::time(nullptr); - std::time_t startTime = now - (durationMinutes * 60); - - sqlite3_reset(m_stmtSaveSession); - sqlite3_bind_text(m_stmtSaveSession, 1, taskName.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(m_stmtSaveSession, 2, startTime); - sqlite3_bind_int64(m_stmtSaveSession, 3, now); - sqlite3_bind_int(m_stmtSaveSession, 4, durationMinutes); - sqlite3_bind_int(m_stmtSaveSession, 5, hyperfocus ? 1 : 0); - - int rc = sqlite3_step(m_stmtSaveSession); - if (rc == SQLITE_DONE) { - m_lastSessionId = static_cast(sqlite3_last_insert_rowid(m_db)); - m_totalQueries++; - m_logger->debug("Session saved: {} ({}min), id={}", taskName, durationMinutes, m_lastSessionId); - - if (m_io) { - auto event = std::make_unique("saved"); - event->setInt("sessionId", m_lastSessionId); - m_io->publish("storage:session_saved", std::move(event)); - } - } else { - publishError(sqlite3_errmsg(m_db)); - } -} - -void StorageService::handleSaveAppUsage(const grove::IDataNode& data) { - int sessionId = data.getInt("sessionId", m_lastSessionId); - std::string appName = data.getString("appName", ""); - int durationSeconds = data.getInt("durationSeconds", 0); - bool productive = data.getBool("productive", false); - - sqlite3_reset(m_stmtSaveAppUsage); - sqlite3_bind_int(m_stmtSaveAppUsage, 1, sessionId); - sqlite3_bind_text(m_stmtSaveAppUsage, 2, appName.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_int(m_stmtSaveAppUsage, 3, durationSeconds); - sqlite3_bind_int(m_stmtSaveAppUsage, 4, productive ? 1 : 0); - - int rc = sqlite3_step(m_stmtSaveAppUsage); - if (rc == SQLITE_DONE) { - m_totalQueries++; - } else { - publishError(sqlite3_errmsg(m_db)); - } -} - -void StorageService::handleSaveConversation(const grove::IDataNode& data) { - std::string role = data.getString("role", ""); - std::string content = data.getString("content", ""); - std::string provider = data.getString("provider", ""); - std::string model = data.getString("model", ""); - int tokens = data.getInt("tokens", 0); - - sqlite3_reset(m_stmtSaveConversation); - sqlite3_bind_text(m_stmtSaveConversation, 1, role.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(m_stmtSaveConversation, 2, content.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(m_stmtSaveConversation, 3, provider.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(m_stmtSaveConversation, 4, model.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_int(m_stmtSaveConversation, 5, tokens); - - int rc = sqlite3_step(m_stmtSaveConversation); - if (rc == SQLITE_DONE) { - m_totalQueries++; - } else { - publishError(sqlite3_errmsg(m_db)); - } -} - -void StorageService::handleUpdateMetrics(const grove::IDataNode& data) { - int focusMinutes = data.getInt("focusMinutes", 0); - int breaks = data.getInt("breaks", 0); - int hyperfocusCount = data.getInt("hyperfocusCount", 0); - - std::time_t now = std::time(nullptr); - std::tm* tm = std::localtime(&now); - char dateStr[11]; - std::strftime(dateStr, sizeof(dateStr), "%Y-%m-%d", tm); - - sqlite3_reset(m_stmtUpdateMetrics); - sqlite3_bind_text(m_stmtUpdateMetrics, 1, dateStr, -1, SQLITE_TRANSIENT); - sqlite3_bind_int(m_stmtUpdateMetrics, 2, focusMinutes); - sqlite3_bind_int(m_stmtUpdateMetrics, 3, breaks); - sqlite3_bind_int(m_stmtUpdateMetrics, 4, hyperfocusCount); - - int rc = sqlite3_step(m_stmtUpdateMetrics); - if (rc == SQLITE_DONE) { - m_totalQueries++; - } else { - publishError(sqlite3_errmsg(m_db)); - } -} - -bool StorageService::executeSQL(const std::string& sql) { - char* errMsg = nullptr; - int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &errMsg); - - if (rc != SQLITE_OK) { - m_logger->error("SQL error: {}", errMsg ? errMsg : "unknown"); - sqlite3_free(errMsg); - return false; - } - - m_totalQueries++; - return true; -} - -void StorageService::publishError(const std::string& message) { - m_logger->error("Storage error: {}", message); - if (m_io) { - auto event = std::make_unique("error"); - event->setString("message", message); - m_io->publish("storage:error", std::move(event)); - } -} - -void StorageService::shutdown() { - finalizeStatements(); - - if (m_db) { - sqlite3_close(m_db); - m_db = nullptr; - m_isConnected = false; - } - - m_logger->info("StorageService shutdown. Total queries: {}", m_totalQueries); -} - -} // namespace aissia +#include "StorageService.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace aissia { + +StorageService::StorageService() { + m_logger = spdlog::get("StorageService"); + if (!m_logger) { + m_logger = spdlog::stdout_color_mt("StorageService"); + } +} + +StorageService::~StorageService() { + shutdown(); +} + +bool StorageService::initialize(grove::IIO* io) { + m_io = io; + + if (m_io) { + grove::SubscriptionConfig config; + m_io->subscribe("storage:save_session", config); + m_io->subscribe("storage:save_app_usage", config); + m_io->subscribe("storage:save_conversation", config); + m_io->subscribe("storage:update_metrics", config); + } + + m_logger->info("StorageService initialized"); + return true; +} + +bool StorageService::openDatabase(const std::string& dbPath, + const std::string& journalMode, + int busyTimeoutMs) { + m_dbPath = dbPath; + + // Ensure directory exists + fs::path path(dbPath); + if (path.has_parent_path()) { + fs::create_directories(path.parent_path()); + } + + int rc = sqlite3_open(dbPath.c_str(), &m_db); + if (rc != SQLITE_OK) { + m_logger->error("SQLite open error: {}", sqlite3_errmsg(m_db)); + return false; + } + + // Set pragmas + std::string pragmas = "PRAGMA journal_mode=" + journalMode + ";" + "PRAGMA busy_timeout=" + std::to_string(busyTimeoutMs) + ";" + "PRAGMA foreign_keys=ON;"; + if (!executeSQL(pragmas)) { + return false; + } + + if (!initializeSchema()) { + return false; + } + + if (!prepareStatements()) { + return false; + } + + m_isConnected = true; + + // Publish ready event + if (m_io) { + auto event = std::make_unique("ready"); + event->setString("database", dbPath); + m_io->publish("storage:ready", std::move(event)); + } + + m_logger->info("Database opened: {}", dbPath); + return true; +} + +bool StorageService::initializeSchema() { + const char* schema = R"SQL( + CREATE TABLE IF NOT EXISTS work_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_name TEXT, + start_time INTEGER, + end_time INTEGER, + duration_minutes INTEGER, + hyperfocus_detected BOOLEAN DEFAULT 0, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS app_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER, + app_name TEXT, + duration_seconds INTEGER, + is_productive BOOLEAN, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (session_id) REFERENCES work_sessions(id) + ); + + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT, + content TEXT, + provider TEXT, + model TEXT, + tokens_used INTEGER, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS daily_metrics ( + date TEXT PRIMARY KEY, + total_focus_minutes INTEGER DEFAULT 0, + total_breaks INTEGER DEFAULT 0, + hyperfocus_count INTEGER DEFAULT 0, + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_date ON work_sessions(created_at); + CREATE INDEX IF NOT EXISTS idx_app_usage_session ON app_usage(session_id); + CREATE INDEX IF NOT EXISTS idx_conversations_date ON conversations(created_at); + )SQL"; + + return executeSQL(schema); +} + +bool StorageService::prepareStatements() { + int rc; + + // Save session statement + const char* sqlSession = "INSERT INTO work_sessions " + "(task_name, start_time, end_time, duration_minutes, hyperfocus_detected) " + "VALUES (?, ?, ?, ?, ?)"; + rc = sqlite3_prepare_v2(m_db, sqlSession, -1, &m_stmtSaveSession, nullptr); + if (rc != SQLITE_OK) { + m_logger->error("Failed to prepare save_session: {}", sqlite3_errmsg(m_db)); + return false; + } + + // Save app usage statement + const char* sqlAppUsage = "INSERT INTO app_usage " + "(session_id, app_name, duration_seconds, is_productive) " + "VALUES (?, ?, ?, ?)"; + rc = sqlite3_prepare_v2(m_db, sqlAppUsage, -1, &m_stmtSaveAppUsage, nullptr); + if (rc != SQLITE_OK) { + m_logger->error("Failed to prepare save_app_usage: {}", sqlite3_errmsg(m_db)); + return false; + } + + // Save conversation statement + const char* sqlConv = "INSERT INTO conversations " + "(role, content, provider, model, tokens_used) " + "VALUES (?, ?, ?, ?, ?)"; + rc = sqlite3_prepare_v2(m_db, sqlConv, -1, &m_stmtSaveConversation, nullptr); + if (rc != SQLITE_OK) { + m_logger->error("Failed to prepare save_conversation: {}", sqlite3_errmsg(m_db)); + return false; + } + + // Update metrics statement + const char* sqlMetrics = "INSERT INTO daily_metrics " + "(date, total_focus_minutes, total_breaks, hyperfocus_count) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(date) DO UPDATE SET " + "total_focus_minutes = total_focus_minutes + excluded.total_focus_minutes, " + "total_breaks = total_breaks + excluded.total_breaks, " + "hyperfocus_count = hyperfocus_count + excluded.hyperfocus_count, " + "updated_at = strftime('%s', 'now')"; + rc = sqlite3_prepare_v2(m_db, sqlMetrics, -1, &m_stmtUpdateMetrics, nullptr); + if (rc != SQLITE_OK) { + m_logger->error("Failed to prepare update_metrics: {}", sqlite3_errmsg(m_db)); + return false; + } + + m_logger->debug("Prepared statements created"); + return true; +} + +void StorageService::finalizeStatements() { + if (m_stmtSaveSession) { sqlite3_finalize(m_stmtSaveSession); m_stmtSaveSession = nullptr; } + if (m_stmtSaveAppUsage) { sqlite3_finalize(m_stmtSaveAppUsage); m_stmtSaveAppUsage = nullptr; } + if (m_stmtSaveConversation) { sqlite3_finalize(m_stmtSaveConversation); m_stmtSaveConversation = nullptr; } + if (m_stmtUpdateMetrics) { sqlite3_finalize(m_stmtUpdateMetrics); m_stmtUpdateMetrics = nullptr; } +} + +void StorageService::process() { + processMessages(); +} + +void StorageService::processMessages() { + if (!m_io || !m_isConnected) return; + + while (m_io->hasMessages() > 0) { + auto msg = m_io->pullMessage(); + + if (msg.topic == "storage:save_session" && msg.data) { + handleSaveSession(*msg.data); + } + else if (msg.topic == "storage:save_app_usage" && msg.data) { + handleSaveAppUsage(*msg.data); + } + else if (msg.topic == "storage:save_conversation" && msg.data) { + handleSaveConversation(*msg.data); + } + else if (msg.topic == "storage:update_metrics" && msg.data) { + handleUpdateMetrics(*msg.data); + } + } +} + +void StorageService::handleSaveSession(const grove::IDataNode& data) { + std::string taskName = data.getString("taskName", "unknown"); + int durationMinutes = data.getInt("durationMinutes", 0); + bool hyperfocus = data.getBool("hyperfocus", false); + + std::time_t now = std::time(nullptr); + std::time_t startTime = now - (durationMinutes * 60); + + sqlite3_reset(m_stmtSaveSession); + sqlite3_bind_text(m_stmtSaveSession, 1, taskName.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(m_stmtSaveSession, 2, startTime); + sqlite3_bind_int64(m_stmtSaveSession, 3, now); + sqlite3_bind_int(m_stmtSaveSession, 4, durationMinutes); + sqlite3_bind_int(m_stmtSaveSession, 5, hyperfocus ? 1 : 0); + + int rc = sqlite3_step(m_stmtSaveSession); + if (rc == SQLITE_DONE) { + m_lastSessionId = static_cast(sqlite3_last_insert_rowid(m_db)); + m_totalQueries++; + m_logger->debug("Session saved: {} ({}min), id={}", taskName, durationMinutes, m_lastSessionId); + + if (m_io) { + auto event = std::make_unique("saved"); + event->setInt("sessionId", m_lastSessionId); + m_io->publish("storage:session_saved", std::move(event)); + } + } else { + publishError(sqlite3_errmsg(m_db)); + } +} + +void StorageService::handleSaveAppUsage(const grove::IDataNode& data) { + int sessionId = data.getInt("sessionId", m_lastSessionId); + std::string appName = data.getString("appName", ""); + int durationSeconds = data.getInt("durationSeconds", 0); + bool productive = data.getBool("productive", false); + + sqlite3_reset(m_stmtSaveAppUsage); + sqlite3_bind_int(m_stmtSaveAppUsage, 1, sessionId); + sqlite3_bind_text(m_stmtSaveAppUsage, 2, appName.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(m_stmtSaveAppUsage, 3, durationSeconds); + sqlite3_bind_int(m_stmtSaveAppUsage, 4, productive ? 1 : 0); + + int rc = sqlite3_step(m_stmtSaveAppUsage); + if (rc == SQLITE_DONE) { + m_totalQueries++; + } else { + publishError(sqlite3_errmsg(m_db)); + } +} + +void StorageService::handleSaveConversation(const grove::IDataNode& data) { + std::string role = data.getString("role", ""); + std::string content = data.getString("content", ""); + std::string provider = data.getString("provider", ""); + std::string model = data.getString("model", ""); + int tokens = data.getInt("tokens", 0); + + sqlite3_reset(m_stmtSaveConversation); + sqlite3_bind_text(m_stmtSaveConversation, 1, role.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(m_stmtSaveConversation, 2, content.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(m_stmtSaveConversation, 3, provider.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(m_stmtSaveConversation, 4, model.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(m_stmtSaveConversation, 5, tokens); + + int rc = sqlite3_step(m_stmtSaveConversation); + if (rc == SQLITE_DONE) { + m_totalQueries++; + } else { + publishError(sqlite3_errmsg(m_db)); + } +} + +void StorageService::handleUpdateMetrics(const grove::IDataNode& data) { + int focusMinutes = data.getInt("focusMinutes", 0); + int breaks = data.getInt("breaks", 0); + int hyperfocusCount = data.getInt("hyperfocusCount", 0); + + std::time_t now = std::time(nullptr); + std::tm* tm = std::localtime(&now); + char dateStr[11]; + std::strftime(dateStr, sizeof(dateStr), "%Y-%m-%d", tm); + + sqlite3_reset(m_stmtUpdateMetrics); + sqlite3_bind_text(m_stmtUpdateMetrics, 1, dateStr, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(m_stmtUpdateMetrics, 2, focusMinutes); + sqlite3_bind_int(m_stmtUpdateMetrics, 3, breaks); + sqlite3_bind_int(m_stmtUpdateMetrics, 4, hyperfocusCount); + + int rc = sqlite3_step(m_stmtUpdateMetrics); + if (rc == SQLITE_DONE) { + m_totalQueries++; + } else { + publishError(sqlite3_errmsg(m_db)); + } +} + +bool StorageService::executeSQL(const std::string& sql) { + char* errMsg = nullptr; + int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &errMsg); + + if (rc != SQLITE_OK) { + m_logger->error("SQL error: {}", errMsg ? errMsg : "unknown"); + sqlite3_free(errMsg); + return false; + } + + m_totalQueries++; + return true; +} + +void StorageService::publishError(const std::string& message) { + m_logger->error("Storage error: {}", message); + if (m_io) { + auto event = std::make_unique("error"); + event->setString("message", message); + m_io->publish("storage:error", std::move(event)); + } +} + +void StorageService::shutdown() { + finalizeStatements(); + + if (m_db) { + sqlite3_close(m_db); + m_db = nullptr; + m_isConnected = false; + } + + m_logger->info("StorageService shutdown. Total queries: {}", m_totalQueries); +} + +} // namespace aissia diff --git a/src/services/StorageService.hpp b/src/services/StorageService.hpp index 354f41b..b40ff22 100644 --- a/src/services/StorageService.hpp +++ b/src/services/StorageService.hpp @@ -1,91 +1,91 @@ -#pragma once - -#include "IService.hpp" - -#include -#include -#include - -#include -#include -#include -#include - -struct sqlite3; -struct sqlite3_stmt; - -namespace aissia { - -/** - * @brief Storage Service - SQLite persistence - * - * Handles all database operations synchronously in main thread. - * Uses prepared statements to prevent SQL injection. - * - * Subscribes to: - * - "storage:save_session" : { taskName, durationMinutes, hyperfocus } - * - "storage:save_app_usage" : { sessionId, appName, durationSeconds, productive } - * - "storage:save_conversation" : { role, content, provider, model, tokens } - * - "storage:update_metrics" : { focusMinutes, breaks, hyperfocusCount } - * - "storage:query" : { sql, params[] } - * - * Publishes: - * - "storage:ready" : Database initialized - * - "storage:session_saved": { sessionId } - * - "storage:error" : { message } - */ -class StorageService : public IService { -public: - StorageService(); - ~StorageService() override; - - bool initialize(grove::IIO* io) override; - void process() override; - void shutdown() override; - std::string getName() const override { return "StorageService"; } - bool isHealthy() const override { return m_isConnected; } - - /// Open database with config - bool openDatabase(const std::string& dbPath, - const std::string& journalMode = "WAL", - int busyTimeoutMs = 5000); - - /// Get last inserted session ID - int getLastSessionId() const { return m_lastSessionId; } - -private: - // Database - sqlite3* m_db = nullptr; - std::string m_dbPath; - bool m_isConnected = false; - int m_lastSessionId = 0; - int m_totalQueries = 0; - - // Prepared statements - sqlite3_stmt* m_stmtSaveSession = nullptr; - sqlite3_stmt* m_stmtSaveAppUsage = nullptr; - sqlite3_stmt* m_stmtSaveConversation = nullptr; - sqlite3_stmt* m_stmtUpdateMetrics = nullptr; - - // Services - grove::IIO* m_io = nullptr; - std::shared_ptr m_logger; - - // Database operations - bool initializeSchema(); - bool prepareStatements(); - void finalizeStatements(); - - // Message handlers - void processMessages(); - void handleSaveSession(const grove::IDataNode& data); - void handleSaveAppUsage(const grove::IDataNode& data); - void handleSaveConversation(const grove::IDataNode& data); - void handleUpdateMetrics(const grove::IDataNode& data); - - // Helpers - bool executeSQL(const std::string& sql); - void publishError(const std::string& message); -}; - -} // namespace aissia +#pragma once + +#include "IService.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +struct sqlite3; +struct sqlite3_stmt; + +namespace aissia { + +/** + * @brief Storage Service - SQLite persistence + * + * Handles all database operations synchronously in main thread. + * Uses prepared statements to prevent SQL injection. + * + * Subscribes to: + * - "storage:save_session" : { taskName, durationMinutes, hyperfocus } + * - "storage:save_app_usage" : { sessionId, appName, durationSeconds, productive } + * - "storage:save_conversation" : { role, content, provider, model, tokens } + * - "storage:update_metrics" : { focusMinutes, breaks, hyperfocusCount } + * - "storage:query" : { sql, params[] } + * + * Publishes: + * - "storage:ready" : Database initialized + * - "storage:session_saved": { sessionId } + * - "storage:error" : { message } + */ +class StorageService : public IService { +public: + StorageService(); + ~StorageService() override; + + bool initialize(grove::IIO* io) override; + void process() override; + void shutdown() override; + std::string getName() const override { return "StorageService"; } + bool isHealthy() const override { return m_isConnected; } + + /// Open database with config + bool openDatabase(const std::string& dbPath, + const std::string& journalMode = "WAL", + int busyTimeoutMs = 5000); + + /// Get last inserted session ID + int getLastSessionId() const { return m_lastSessionId; } + +private: + // Database + sqlite3* m_db = nullptr; + std::string m_dbPath; + bool m_isConnected = false; + int m_lastSessionId = 0; + int m_totalQueries = 0; + + // Prepared statements + sqlite3_stmt* m_stmtSaveSession = nullptr; + sqlite3_stmt* m_stmtSaveAppUsage = nullptr; + sqlite3_stmt* m_stmtSaveConversation = nullptr; + sqlite3_stmt* m_stmtUpdateMetrics = nullptr; + + // Services + grove::IIO* m_io = nullptr; + std::shared_ptr m_logger; + + // Database operations + bool initializeSchema(); + bool prepareStatements(); + void finalizeStatements(); + + // Message handlers + void processMessages(); + void handleSaveSession(const grove::IDataNode& data); + void handleSaveAppUsage(const grove::IDataNode& data); + void handleSaveConversation(const grove::IDataNode& data); + void handleUpdateMetrics(const grove::IDataNode& data); + + // Helpers + bool executeSQL(const std::string& sql); + void publishError(const std::string& message); +}; + +} // namespace aissia diff --git a/src/services/VoiceService.cpp b/src/services/VoiceService.cpp index f073b7e..882674c 100644 --- a/src/services/VoiceService.cpp +++ b/src/services/VoiceService.cpp @@ -1,294 +1,294 @@ -// CRITICAL ORDER: Include system headers before local headers to avoid macro conflicts -#include -#include -#include -#include -#include -#include - -// Include VoiceService.hpp BEFORE spdlog to avoid logger macro conflicts -#include "VoiceService.hpp" -#include "STTService.hpp" - -// Include spdlog after VoiceService.hpp -#include - -namespace aissia { - -VoiceService::VoiceService() { - m_logger = spdlog::get("VoiceService"); - if (!m_logger) { - m_logger = spdlog::stdout_color_mt("VoiceService"); - } -} - -bool VoiceService::initialize(grove::IIO* io) { - m_io = io; - - // Create TTS engine - m_ttsEngine = TTSEngineFactory::create(); - if (m_ttsEngine && m_ttsEngine->isAvailable()) { - m_ttsEngine->setRate(m_ttsRate); - m_ttsEngine->setVolume(m_ttsVolume); - m_logger->info("TTS engine initialized"); - } else { - m_logger->warn("TTS engine not available"); - } - - if (m_io) { - grove::SubscriptionConfig config; - m_io->subscribe("voice:speak", config); - m_io->subscribe("voice:stop", config); - m_io->subscribe("voice:listen", config); - } - - m_logger->info("VoiceService initialized"); - return true; -} - -void VoiceService::configureTTS(bool enabled, int rate, int volume) { - m_ttsEnabled = enabled; - m_ttsRate = rate; - m_ttsVolume = volume; - - if (m_ttsEngine) { - m_ttsEngine->setRate(rate); - m_ttsEngine->setVolume(volume); - } -} - -void VoiceService::configureSTT(bool enabled, const std::string& language, - const std::string& apiKey) { - m_sttEnabled = enabled; - m_language = language; - - if (!apiKey.empty()) { - m_sttEngine = STTEngineFactory::create(apiKey); - if (m_sttEngine) { - m_sttEngine->setLanguage(language); - m_logger->info("STT engine configured"); - } - } -} - -void VoiceService::process() { - processMessages(); - processSpeakQueue(); -} - -void VoiceService::processMessages() { - if (!m_io) return; - - while (m_io->hasMessages() > 0) { - auto msg = m_io->pullMessage(); - - if (msg.topic == "voice:speak" && msg.data) { - handleSpeakRequest(*msg.data); - } - else if (msg.topic == "voice:stop") { - if (m_ttsEngine) { - m_ttsEngine->stop(); - } - // Clear queue - while (!m_speakQueue.empty()) m_speakQueue.pop(); - } - else if (msg.topic == "voice:listen" && m_sttEnabled && m_sttEngine) { - // STT would be handled here - // For now just log - m_logger->debug("STT listen requested"); - } - } -} - -void VoiceService::handleSpeakRequest(const grove::IDataNode& data) { - std::string text = data.getString("text", ""); - bool priority = data.getBool("priority", false); - - if (text.empty()) return; - - if (priority) { - // Clear queue and stop current speech - while (!m_speakQueue.empty()) m_speakQueue.pop(); - if (m_ttsEngine) m_ttsEngine->stop(); - } - - m_speakQueue.push(text); -} - -void VoiceService::processSpeakQueue() { - if (!m_ttsEnabled || !m_ttsEngine || m_speakQueue.empty()) return; - - // Only speak if not currently speaking - if (!m_ttsEngine->isSpeaking() && !m_speakQueue.empty()) { - std::string text = m_speakQueue.front(); - m_speakQueue.pop(); - speak(text); - } -} - -void VoiceService::speak(const std::string& text) { - if (!m_ttsEngine || !m_ttsEnabled) return; - - // Publish speaking started - if (m_io) { - auto event = std::unique_ptr( - new grove::JsonDataNode("event") - ); - event->setString("text", text.size() > 100 ? text.substr(0, 100) + "..." : text); - m_io->publish("voice:speaking_started", std::move(event)); - } - - m_ttsEngine->speak(text, true); - m_totalSpoken++; - - m_logger->debug("Speaking"); -} - -// Phase 7: New STT configuration with full config support -void VoiceService::configureSTT(const nlohmann::json& sttConfig) { - m_logger->info("[VoiceService] Configuring STT service (Phase 7)"); - - // Extract enabled flag - bool enabled = false; - if (sttConfig.contains("active_mode")) { - const auto& activeMode = sttConfig["active_mode"]; - enabled = activeMode.value("enabled", true); - } - - m_sttEnabled = enabled; - - if (!enabled) { - m_logger->info("[VoiceService] STT disabled in config"); - return; - } - - // Create and start STT service - m_sttService = std::make_unique(sttConfig); - - if (!m_sttService->start()) { - m_logger->error("[VoiceService] Failed to start STT service"); - m_sttService.reset(); - return; - } - - m_logger->info("[VoiceService] STT service started"); - - // Setup callbacks for transcription events - // Note: For MVP Milestone 1, we don't start streaming yet - // This will be implemented in Milestone 2 (passive mode) -} - -// STT event handlers (Phase 7) -void VoiceService::handleKeyword(const std::string& keyword) { - m_logger->info("[VoiceService] Keyword detected"); - - // Publish keyword detection event - if (m_io) { - auto event = std::unique_ptr( - new grove::JsonDataNode("event") - ); - event->setString("keyword", keyword); - event->setInt("timestamp", static_cast(std::time(nullptr))); - m_io->publish("voice:keyword_detected", std::move(event)); - } - - // Auto-switch to active mode (Phase 7.2) - if (m_sttService) { - m_sttService->setMode(STTMode::ACTIVE); - } -} - -void VoiceService::handleTranscription(const std::string& text, STTMode mode) { - m_logger->info("[VoiceService] Transcription received"); - - // Publish transcription event - if (m_io) { - std::string modeStr = (mode == STTMode::PASSIVE ? "passive" : "active"); - auto event = std::unique_ptr( - new grove::JsonDataNode("event") - ); - event->setString("text", text); - event->setString("mode", modeStr); - event->setInt("timestamp", static_cast(std::time(nullptr))); - m_io->publish("voice:transcription", std::move(event)); - } -} - -void VoiceService::shutdown() { - if (m_ttsEngine) { - m_ttsEngine->stop(); - } - - if (m_sttService) { - m_sttService->stop(); - } - - m_logger->info("[VoiceService] Shutdown"); -} - -bool VoiceService::loadConfig(const std::string& configPath) { - try { - std::ifstream file(configPath); - if (!file.is_open()) { - m_logger->warn("[VoiceService] Config file not found: {}", configPath); - return false; - } - - nlohmann::json config; - file >> config; - - // Load TTS config - if (config.contains("tts")) { - const auto& ttsConfig = config["tts"]; - m_ttsEnabled = ttsConfig.value("enabled", true); - m_ttsRate = ttsConfig.value("rate", 0); - m_ttsVolume = ttsConfig.value("volume", 80); - } - - // Load STT config (Phase 7 format) - if (config.contains("stt")) { - configureSTT(config["stt"]); - } - - m_logger->info("[VoiceService] Config loaded from {}", configPath); - return true; - - } catch (const std::exception& e) { - m_logger->error("[VoiceService] Failed to load config: {}", e.what()); - return false; - } -} - -std::string VoiceService::transcribeFileSync( - const std::string& filePath, - const std::string& language -) { - m_logger->info("[VoiceService] transcribeFileSync: {}", filePath); - - if (!m_sttService) { - throw std::runtime_error("STT service not initialized"); - } - - // Use STT service to transcribe file synchronously - // Note: This requires STT service to support file transcription - // For MVP, we'll throw not implemented - throw std::runtime_error("transcribeFileSync not yet implemented - STT service needs file transcription support"); -} - -bool VoiceService::textToSpeechSync( - const std::string& text, - const std::string& outputFile, - const std::string& voice -) { - m_logger->info("[VoiceService] textToSpeechSync: {} -> {}", text.substr(0, 50), outputFile); - - if (!m_ttsEngine) { - throw std::runtime_error("TTS engine not initialized"); - } - - // For MVP, we don't support saving to file yet - // The TTS engine currently only speaks directly - throw std::runtime_error("textToSpeechSync file output not yet implemented - TTS engine needs file output support"); -} - -} // namespace aissia +// CRITICAL ORDER: Include system headers before local headers to avoid macro conflicts +#include +#include +#include +#include +#include +#include + +// Include VoiceService.hpp BEFORE spdlog to avoid logger macro conflicts +#include "VoiceService.hpp" +#include "STTService.hpp" + +// Include spdlog after VoiceService.hpp +#include + +namespace aissia { + +VoiceService::VoiceService() { + m_logger = spdlog::get("VoiceService"); + if (!m_logger) { + m_logger = spdlog::stdout_color_mt("VoiceService"); + } +} + +bool VoiceService::initialize(grove::IIO* io) { + m_io = io; + + // Create TTS engine + m_ttsEngine = TTSEngineFactory::create(); + if (m_ttsEngine && m_ttsEngine->isAvailable()) { + m_ttsEngine->setRate(m_ttsRate); + m_ttsEngine->setVolume(m_ttsVolume); + m_logger->info("TTS engine initialized"); + } else { + m_logger->warn("TTS engine not available"); + } + + if (m_io) { + grove::SubscriptionConfig config; + m_io->subscribe("voice:speak", config); + m_io->subscribe("voice:stop", config); + m_io->subscribe("voice:listen", config); + } + + m_logger->info("VoiceService initialized"); + return true; +} + +void VoiceService::configureTTS(bool enabled, int rate, int volume) { + m_ttsEnabled = enabled; + m_ttsRate = rate; + m_ttsVolume = volume; + + if (m_ttsEngine) { + m_ttsEngine->setRate(rate); + m_ttsEngine->setVolume(volume); + } +} + +void VoiceService::configureSTT(bool enabled, const std::string& language, + const std::string& apiKey) { + m_sttEnabled = enabled; + m_language = language; + + if (!apiKey.empty()) { + m_sttEngine = STTEngineFactory::create(apiKey); + if (m_sttEngine) { + m_sttEngine->setLanguage(language); + m_logger->info("STT engine configured"); + } + } +} + +void VoiceService::process() { + processMessages(); + processSpeakQueue(); +} + +void VoiceService::processMessages() { + if (!m_io) return; + + while (m_io->hasMessages() > 0) { + auto msg = m_io->pullMessage(); + + if (msg.topic == "voice:speak" && msg.data) { + handleSpeakRequest(*msg.data); + } + else if (msg.topic == "voice:stop") { + if (m_ttsEngine) { + m_ttsEngine->stop(); + } + // Clear queue + while (!m_speakQueue.empty()) m_speakQueue.pop(); + } + else if (msg.topic == "voice:listen" && m_sttEnabled && m_sttEngine) { + // STT would be handled here + // For now just log + m_logger->debug("STT listen requested"); + } + } +} + +void VoiceService::handleSpeakRequest(const grove::IDataNode& data) { + std::string text = data.getString("text", ""); + bool priority = data.getBool("priority", false); + + if (text.empty()) return; + + if (priority) { + // Clear queue and stop current speech + while (!m_speakQueue.empty()) m_speakQueue.pop(); + if (m_ttsEngine) m_ttsEngine->stop(); + } + + m_speakQueue.push(text); +} + +void VoiceService::processSpeakQueue() { + if (!m_ttsEnabled || !m_ttsEngine || m_speakQueue.empty()) return; + + // Only speak if not currently speaking + if (!m_ttsEngine->isSpeaking() && !m_speakQueue.empty()) { + std::string text = m_speakQueue.front(); + m_speakQueue.pop(); + speak(text); + } +} + +void VoiceService::speak(const std::string& text) { + if (!m_ttsEngine || !m_ttsEnabled) return; + + // Publish speaking started + if (m_io) { + auto event = std::unique_ptr( + new grove::JsonDataNode("event") + ); + event->setString("text", text.size() > 100 ? text.substr(0, 100) + "..." : text); + m_io->publish("voice:speaking_started", std::move(event)); + } + + m_ttsEngine->speak(text, true); + m_totalSpoken++; + + m_logger->debug("Speaking"); +} + +// Phase 7: New STT configuration with full config support +void VoiceService::configureSTT(const nlohmann::json& sttConfig) { + m_logger->info("[VoiceService] Configuring STT service (Phase 7)"); + + // Extract enabled flag + bool enabled = false; + if (sttConfig.contains("active_mode")) { + const auto& activeMode = sttConfig["active_mode"]; + enabled = activeMode.value("enabled", true); + } + + m_sttEnabled = enabled; + + if (!enabled) { + m_logger->info("[VoiceService] STT disabled in config"); + return; + } + + // Create and start STT service + m_sttService = std::make_unique(sttConfig); + + if (!m_sttService->start()) { + m_logger->error("[VoiceService] Failed to start STT service"); + m_sttService.reset(); + return; + } + + m_logger->info("[VoiceService] STT service started"); + + // Setup callbacks for transcription events + // Note: For MVP Milestone 1, we don't start streaming yet + // This will be implemented in Milestone 2 (passive mode) +} + +// STT event handlers (Phase 7) +void VoiceService::handleKeyword(const std::string& keyword) { + m_logger->info("[VoiceService] Keyword detected"); + + // Publish keyword detection event + if (m_io) { + auto event = std::unique_ptr( + new grove::JsonDataNode("event") + ); + event->setString("keyword", keyword); + event->setInt("timestamp", static_cast(std::time(nullptr))); + m_io->publish("voice:keyword_detected", std::move(event)); + } + + // Auto-switch to active mode (Phase 7.2) + if (m_sttService) { + m_sttService->setMode(STTMode::ACTIVE); + } +} + +void VoiceService::handleTranscription(const std::string& text, STTMode mode) { + m_logger->info("[VoiceService] Transcription received"); + + // Publish transcription event + if (m_io) { + std::string modeStr = (mode == STTMode::PASSIVE ? "passive" : "active"); + auto event = std::unique_ptr( + new grove::JsonDataNode("event") + ); + event->setString("text", text); + event->setString("mode", modeStr); + event->setInt("timestamp", static_cast(std::time(nullptr))); + m_io->publish("voice:transcription", std::move(event)); + } +} + +void VoiceService::shutdown() { + if (m_ttsEngine) { + m_ttsEngine->stop(); + } + + if (m_sttService) { + m_sttService->stop(); + } + + m_logger->info("[VoiceService] Shutdown"); +} + +bool VoiceService::loadConfig(const std::string& configPath) { + try { + std::ifstream file(configPath); + if (!file.is_open()) { + m_logger->warn("[VoiceService] Config file not found: {}", configPath); + return false; + } + + nlohmann::json config; + file >> config; + + // Load TTS config + if (config.contains("tts")) { + const auto& ttsConfig = config["tts"]; + m_ttsEnabled = ttsConfig.value("enabled", true); + m_ttsRate = ttsConfig.value("rate", 0); + m_ttsVolume = ttsConfig.value("volume", 80); + } + + // Load STT config (Phase 7 format) + if (config.contains("stt")) { + configureSTT(config["stt"]); + } + + m_logger->info("[VoiceService] Config loaded from {}", configPath); + return true; + + } catch (const std::exception& e) { + m_logger->error("[VoiceService] Failed to load config: {}", e.what()); + return false; + } +} + +std::string VoiceService::transcribeFileSync( + const std::string& filePath, + const std::string& language +) { + m_logger->info("[VoiceService] transcribeFileSync: {}", filePath); + + if (!m_sttService) { + throw std::runtime_error("STT service not initialized"); + } + + // Use STT service to transcribe file synchronously + // Note: This requires STT service to support file transcription + // For MVP, we'll throw not implemented + throw std::runtime_error("transcribeFileSync not yet implemented - STT service needs file transcription support"); +} + +bool VoiceService::textToSpeechSync( + const std::string& text, + const std::string& outputFile, + const std::string& voice +) { + m_logger->info("[VoiceService] textToSpeechSync: {} -> {}", text.substr(0, 50), outputFile); + + if (!m_ttsEngine) { + throw std::runtime_error("TTS engine not initialized"); + } + + // For MVP, we don't support saving to file yet + // The TTS engine currently only speaks directly + throw std::runtime_error("textToSpeechSync file output not yet implemented - TTS engine needs file output support"); +} + +} // namespace aissia diff --git a/src/services/VoiceService.hpp b/src/services/VoiceService.hpp index 099e190..617ee4e 100644 --- a/src/services/VoiceService.hpp +++ b/src/services/VoiceService.hpp @@ -1,117 +1,117 @@ -#pragma once - -// Include nlohmann/json BEFORE grove headers to avoid macro conflicts -#include - -#include "IService.hpp" -#include "ISTTService.hpp" -#include "../shared/audio/ITTSEngine.hpp" -#include "../shared/audio/ISTTEngine.hpp" - -#include -#include -#include - -#include -#include -#include - -namespace aissia { - -/** - * @brief Voice Service - TTS and STT engines - * - * Handles platform-specific audio engines (SAPI on Windows, espeak on Linux). - * Manages speak queue and processes TTS/STT requests. - * - * Subscribes to: - * - "voice:speak" : { text, priority? } - * - "voice:stop" : Stop current speech - * - "voice:listen" : Start STT recording - * - * Publishes: - * - "voice:speaking_started" : { text } - * - "voice:speaking_ended" : {} - * - "voice:transcription" : { text, confidence } - */ -class VoiceService : public IService { -public: - VoiceService(); - ~VoiceService() override = default; - - bool initialize(grove::IIO* io) override; - void process() override; - void shutdown() override; - std::string getName() const override { return "VoiceService"; } - bool isHealthy() const override { return m_ttsEngine != nullptr; } - - /// Configure TTS settings - void configureTTS(bool enabled = true, int rate = 0, int volume = 80); - - /// Configure STT settings (legacy API) - void configureSTT(bool enabled = true, const std::string& language = "fr", - const std::string& apiKey = ""); - - /// Configure STT with full config (Phase 7) - void configureSTT(const nlohmann::json& sttConfig); - - /// Load configuration from JSON file - bool loadConfig(const std::string& configPath); - - /** - * @brief Transcribe audio file synchronously (for MCP Server mode) - * - * @param filePath Path to audio file - * @param language Language code (e.g., "fr", "en") - * @return Transcribed text - */ - std::string transcribeFileSync( - const std::string& filePath, - const std::string& language = "fr" - ); - - /** - * @brief Convert text to speech synchronously (for MCP Server mode) - * - * @param text Text to synthesize - * @param outputFile Output audio file path - * @param voice Voice identifier (e.g., "fr-fr") - * @return true if successful - */ - bool textToSpeechSync( - const std::string& text, - const std::string& outputFile, - const std::string& voice = "fr-fr" - ); - -private: - // Configuration - bool m_ttsEnabled = true; - bool m_sttEnabled = true; - int m_ttsRate = 0; - int m_ttsVolume = 80; - std::string m_language = "fr"; - - // State - std::unique_ptr m_ttsEngine; - std::unique_ptr m_sttEngine; // Legacy direct engine (deprecated) - std::unique_ptr m_sttService; // Phase 7: New STT service layer - std::queue m_speakQueue; - int m_totalSpoken = 0; - - // Services - grove::IIO* m_io = nullptr; - std::shared_ptr m_logger; - - // Helpers - void processMessages(); - void processSpeakQueue(); - void speak(const std::string& text); - void handleSpeakRequest(const grove::IDataNode& data); - - // STT handlers (Phase 7) - void handleTranscription(const std::string& text, STTMode mode); - void handleKeyword(const std::string& keyword); -}; - -} // namespace aissia +#pragma once + +// Include nlohmann/json BEFORE grove headers to avoid macro conflicts +#include + +#include "IService.hpp" +#include "ISTTService.hpp" +#include "../shared/audio/ITTSEngine.hpp" +#include "../shared/audio/ISTTEngine.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace aissia { + +/** + * @brief Voice Service - TTS and STT engines + * + * Handles platform-specific audio engines (SAPI on Windows, espeak on Linux). + * Manages speak queue and processes TTS/STT requests. + * + * Subscribes to: + * - "voice:speak" : { text, priority? } + * - "voice:stop" : Stop current speech + * - "voice:listen" : Start STT recording + * + * Publishes: + * - "voice:speaking_started" : { text } + * - "voice:speaking_ended" : {} + * - "voice:transcription" : { text, confidence } + */ +class VoiceService : public IService { +public: + VoiceService(); + ~VoiceService() override = default; + + bool initialize(grove::IIO* io) override; + void process() override; + void shutdown() override; + std::string getName() const override { return "VoiceService"; } + bool isHealthy() const override { return m_ttsEngine != nullptr; } + + /// Configure TTS settings + void configureTTS(bool enabled = true, int rate = 0, int volume = 80); + + /// Configure STT settings (legacy API) + void configureSTT(bool enabled = true, const std::string& language = "fr", + const std::string& apiKey = ""); + + /// Configure STT with full config (Phase 7) + void configureSTT(const nlohmann::json& sttConfig); + + /// Load configuration from JSON file + bool loadConfig(const std::string& configPath); + + /** + * @brief Transcribe audio file synchronously (for MCP Server mode) + * + * @param filePath Path to audio file + * @param language Language code (e.g., "fr", "en") + * @return Transcribed text + */ + std::string transcribeFileSync( + const std::string& filePath, + const std::string& language = "fr" + ); + + /** + * @brief Convert text to speech synchronously (for MCP Server mode) + * + * @param text Text to synthesize + * @param outputFile Output audio file path + * @param voice Voice identifier (e.g., "fr-fr") + * @return true if successful + */ + bool textToSpeechSync( + const std::string& text, + const std::string& outputFile, + const std::string& voice = "fr-fr" + ); + +private: + // Configuration + bool m_ttsEnabled = true; + bool m_sttEnabled = true; + int m_ttsRate = 0; + int m_ttsVolume = 80; + std::string m_language = "fr"; + + // State + std::unique_ptr m_ttsEngine; + std::unique_ptr m_sttEngine; // Legacy direct engine (deprecated) + std::unique_ptr m_sttService; // Phase 7: New STT service layer + std::queue m_speakQueue; + int m_totalSpoken = 0; + + // Services + grove::IIO* m_io = nullptr; + std::shared_ptr m_logger; + + // Helpers + void processMessages(); + void processSpeakQueue(); + void speak(const std::string& text); + void handleSpeakRequest(const grove::IDataNode& data); + + // STT handlers (Phase 7) + void handleTranscription(const std::string& text, STTMode mode); + void handleKeyword(const std::string& keyword); +}; + +} // namespace aissia diff --git a/src/shared/tools/MCPServerTools.cpp b/src/shared/tools/MCPServerTools.cpp index ceb4971..cf6e441 100644 --- a/src/shared/tools/MCPServerTools.cpp +++ b/src/shared/tools/MCPServerTools.cpp @@ -1,310 +1,310 @@ -#include "MCPServerTools.hpp" -#include "../../services/LLMService.hpp" -#include "../../services/StorageService.hpp" -#include "../../services/VoiceService.hpp" - -#include - -namespace aissia::tools { - -MCPServerTools::MCPServerTools( - LLMService* llm, - StorageService* storage, - VoiceService* voice -) : m_llmService(llm), - m_storageService(storage), - m_voiceService(voice) -{ -} - -std::vector MCPServerTools::getToolDefinitions() { - std::vector tools; - - // Tool 1: chat_with_aissia (PRIORITÉ) - if (m_llmService) { - tools.push_back({ - "chat_with_aissia", - "Dialogue with AISSIA assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities.", - { - {"type", "object"}, - {"properties", { - {"message", { - {"type", "string"}, - {"description", "Message to send to AISSIA"} - }}, - {"conversation_id", { - {"type", "string"}, - {"description", "Conversation ID for continuity (optional)"} - }}, - {"system_prompt", { - {"type", "string"}, - {"description", "Custom system prompt (optional)"} - }} - }}, - {"required", json::array({"message"})} - }, - [this](const json& input) { return handleChatWithAissia(input); } - }); - } - - // Tool 2: transcribe_audio - if (m_voiceService) { - tools.push_back({ - "transcribe_audio", - "Transcribe audio file to text using Speech-to-Text (Whisper.cpp, OpenAI Whisper API, or Google Speech). Supports WAV, MP3, and other common audio formats.", - { - {"type", "object"}, - {"properties", { - {"file_path", { - {"type", "string"}, - {"description", "Path to audio file"} - }}, - {"language", { - {"type", "string"}, - {"description", "Language code (e.g., 'fr', 'en'). Default: 'fr'"} - }} - }}, - {"required", json::array({"file_path"})} - }, - [this](const json& input) { return handleTranscribeAudio(input); } - }); - - // Tool 3: text_to_speech - tools.push_back({ - "text_to_speech", - "Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format.", - { - {"type", "object"}, - {"properties", { - {"text", { - {"type", "string"}, - {"description", "Text to synthesize"} - }}, - {"output_file", { - {"type", "string"}, - {"description", "Output audio file path (WAV)"} - }}, - {"voice", { - {"type", "string"}, - {"description", "Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'"} - }} - }}, - {"required", json::array({"text", "output_file"})} - }, - [this](const json& input) { return handleTextToSpeech(input); } - }); - } - - // Tool 4: save_memory - if (m_storageService) { - tools.push_back({ - "save_memory", - "Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later.", - { - {"type", "object"}, - {"properties", { - {"title", { - {"type", "string"}, - {"description", "Memory title"} - }}, - {"content", { - {"type", "string"}, - {"description", "Memory content"} - }}, - {"tags", { - {"type", "array"}, - {"items", {{"type", "string"}}}, - {"description", "Tags for categorization (optional)"} - }} - }}, - {"required", json::array({"title", "content"})} - }, - [this](const json& input) { return handleSaveMemory(input); } - }); - - // Tool 5: search_memories - tools.push_back({ - "search_memories", - "Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores.", - { - {"type", "object"}, - {"properties", { - {"query", { - {"type", "string"}, - {"description", "Search query"} - }}, - {"limit", { - {"type", "integer"}, - {"description", "Maximum results to return. Default: 10"} - }} - }}, - {"required", json::array({"query"})} - }, - [this](const json& input) { return handleSearchMemories(input); } - }); - } - - return tools; -} - -json MCPServerTools::execute(const std::string& toolName, const json& input) { - if (toolName == "chat_with_aissia") { - return handleChatWithAissia(input); - } else if (toolName == "transcribe_audio") { - return handleTranscribeAudio(input); - } else if (toolName == "text_to_speech") { - return handleTextToSpeech(input); - } else if (toolName == "save_memory") { - return handleSaveMemory(input); - } else if (toolName == "search_memories") { - return handleSearchMemories(input); - } - - return { - {"error", "Unknown tool: " + toolName} - }; -} - -// ============================================================================ -// Tool Handlers -// ============================================================================ - -json MCPServerTools::handleChatWithAissia(const json& input) { - if (!m_llmService) { - return {{"error", "LLMService not available"}}; - } - - try { - std::string message = input["message"]; - std::string conversationId = input.value("conversation_id", ""); - std::string systemPrompt = input.value("system_prompt", ""); - - spdlog::info("[chat_with_aissia] Message: {}", message.substr(0, 100)); - - // Call synchronous LLM method - auto response = m_llmService->sendMessageSync(message, conversationId, systemPrompt); - - return { - {"response", response.text}, - {"conversation_id", conversationId}, - {"tokens", response.tokens}, - {"iterations", response.iterations} - }; - } catch (const std::exception& e) { - spdlog::error("[chat_with_aissia] Error: {}", e.what()); - return {{"error", e.what()}}; - } -} - -json MCPServerTools::handleTranscribeAudio(const json& input) { - if (!m_voiceService) { - return {{"error", "VoiceService not available"}}; - } - - try { - std::string filePath = input["file_path"]; - std::string language = input.value("language", "fr"); - - spdlog::info("[transcribe_audio] File: {}, Language: {}", filePath, language); - - // Call synchronous STT method - std::string text = m_voiceService->transcribeFileSync(filePath, language); - - return { - {"text", text}, - {"file", filePath}, - {"language", language} - }; - } catch (const std::exception& e) { - spdlog::error("[transcribe_audio] Error: {}", e.what()); - return {{"error", e.what()}}; - } -} - -json MCPServerTools::handleTextToSpeech(const json& input) { - if (!m_voiceService) { - return {{"error", "VoiceService not available"}}; - } - - try { - std::string text = input["text"]; - std::string outputFile = input["output_file"]; - std::string voice = input.value("voice", "fr-fr"); - - spdlog::info("[text_to_speech] Text: {}, Output: {}", text.substr(0, 50), outputFile); - - // Call synchronous TTS method - bool success = m_voiceService->textToSpeechSync(text, outputFile, voice); - - if (success) { - return { - {"success", true}, - {"file", outputFile}, - {"voice", voice} - }; - } else { - return {{"error", "TTS generation failed"}}; - } - } catch (const std::exception& e) { - spdlog::error("[text_to_speech] Error: {}", e.what()); - return {{"error", e.what()}}; - } -} - -json MCPServerTools::handleSaveMemory(const json& input) { - if (!m_storageService) { - return {{"error", "StorageService not available"}}; - } - - try { - std::string title = input["title"]; - std::string content = input["content"]; - std::vector tags; - - if (input.contains("tags") && input["tags"].is_array()) { - for (const auto& tag : input["tags"]) { - tags.push_back(tag.get()); - } - } - - spdlog::info("[save_memory] Title: {}", title); - - // TODO: Implement saveMemorySync in StorageService - // For now, return not implemented - return json({ - {"error", "save_memory not yet implemented"}, - {"note", "StorageService sync methods need to be added"}, - {"title", title} - }); - } catch (const std::exception& e) { - spdlog::error("[save_memory] Error: {}", e.what()); - return {{"error", e.what()}}; - } -} - -json MCPServerTools::handleSearchMemories(const json& input) { - if (!m_storageService) { - return {{"error", "StorageService not available"}}; - } - - try { - std::string query = input["query"]; - int limit = input.value("limit", 10); - - spdlog::info("[search_memories] Query: {}, Limit: {}", query, limit); - - // TODO: Implement searchMemoriesSync in StorageService - // For now, return not implemented - return json({ - {"error", "search_memories not yet implemented"}, - {"note", "StorageService sync methods need to be added"}, - {"query", query}, - {"limit", limit} - }); - } catch (const std::exception& e) { - spdlog::error("[search_memories] Error: {}", e.what()); - return {{"error", e.what()}}; - } -} - -} // namespace aissia::tools +#include "MCPServerTools.hpp" +#include "../../services/LLMService.hpp" +#include "../../services/StorageService.hpp" +#include "../../services/VoiceService.hpp" + +#include + +namespace aissia::tools { + +MCPServerTools::MCPServerTools( + LLMService* llm, + StorageService* storage, + VoiceService* voice +) : m_llmService(llm), + m_storageService(storage), + m_voiceService(voice) +{ +} + +std::vector MCPServerTools::getToolDefinitions() { + std::vector tools; + + // Tool 1: chat_with_aissia (PRIORITÉ) + if (m_llmService) { + tools.push_back({ + "chat_with_aissia", + "Dialogue with AISSIA assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities.", + { + {"type", "object"}, + {"properties", { + {"message", { + {"type", "string"}, + {"description", "Message to send to AISSIA"} + }}, + {"conversation_id", { + {"type", "string"}, + {"description", "Conversation ID for continuity (optional)"} + }}, + {"system_prompt", { + {"type", "string"}, + {"description", "Custom system prompt (optional)"} + }} + }}, + {"required", json::array({"message"})} + }, + [this](const json& input) { return handleChatWithAissia(input); } + }); + } + + // Tool 2: transcribe_audio + if (m_voiceService) { + tools.push_back({ + "transcribe_audio", + "Transcribe audio file to text using Speech-to-Text (Whisper.cpp, OpenAI Whisper API, or Google Speech). Supports WAV, MP3, and other common audio formats.", + { + {"type", "object"}, + {"properties", { + {"file_path", { + {"type", "string"}, + {"description", "Path to audio file"} + }}, + {"language", { + {"type", "string"}, + {"description", "Language code (e.g., 'fr', 'en'). Default: 'fr'"} + }} + }}, + {"required", json::array({"file_path"})} + }, + [this](const json& input) { return handleTranscribeAudio(input); } + }); + + // Tool 3: text_to_speech + tools.push_back({ + "text_to_speech", + "Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format.", + { + {"type", "object"}, + {"properties", { + {"text", { + {"type", "string"}, + {"description", "Text to synthesize"} + }}, + {"output_file", { + {"type", "string"}, + {"description", "Output audio file path (WAV)"} + }}, + {"voice", { + {"type", "string"}, + {"description", "Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'"} + }} + }}, + {"required", json::array({"text", "output_file"})} + }, + [this](const json& input) { return handleTextToSpeech(input); } + }); + } + + // Tool 4: save_memory + if (m_storageService) { + tools.push_back({ + "save_memory", + "Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later.", + { + {"type", "object"}, + {"properties", { + {"title", { + {"type", "string"}, + {"description", "Memory title"} + }}, + {"content", { + {"type", "string"}, + {"description", "Memory content"} + }}, + {"tags", { + {"type", "array"}, + {"items", {{"type", "string"}}}, + {"description", "Tags for categorization (optional)"} + }} + }}, + {"required", json::array({"title", "content"})} + }, + [this](const json& input) { return handleSaveMemory(input); } + }); + + // Tool 5: search_memories + tools.push_back({ + "search_memories", + "Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores.", + { + {"type", "object"}, + {"properties", { + {"query", { + {"type", "string"}, + {"description", "Search query"} + }}, + {"limit", { + {"type", "integer"}, + {"description", "Maximum results to return. Default: 10"} + }} + }}, + {"required", json::array({"query"})} + }, + [this](const json& input) { return handleSearchMemories(input); } + }); + } + + return tools; +} + +json MCPServerTools::execute(const std::string& toolName, const json& input) { + if (toolName == "chat_with_aissia") { + return handleChatWithAissia(input); + } else if (toolName == "transcribe_audio") { + return handleTranscribeAudio(input); + } else if (toolName == "text_to_speech") { + return handleTextToSpeech(input); + } else if (toolName == "save_memory") { + return handleSaveMemory(input); + } else if (toolName == "search_memories") { + return handleSearchMemories(input); + } + + return { + {"error", "Unknown tool: " + toolName} + }; +} + +// ============================================================================ +// Tool Handlers +// ============================================================================ + +json MCPServerTools::handleChatWithAissia(const json& input) { + if (!m_llmService) { + return {{"error", "LLMService not available"}}; + } + + try { + std::string message = input["message"]; + std::string conversationId = input.value("conversation_id", ""); + std::string systemPrompt = input.value("system_prompt", ""); + + spdlog::info("[chat_with_aissia] Message: {}", message.substr(0, 100)); + + // Call synchronous LLM method + auto response = m_llmService->sendMessageSync(message, conversationId, systemPrompt); + + return { + {"response", response.text}, + {"conversation_id", conversationId}, + {"tokens", response.tokens}, + {"iterations", response.iterations} + }; + } catch (const std::exception& e) { + spdlog::error("[chat_with_aissia] Error: {}", e.what()); + return {{"error", e.what()}}; + } +} + +json MCPServerTools::handleTranscribeAudio(const json& input) { + if (!m_voiceService) { + return {{"error", "VoiceService not available"}}; + } + + try { + std::string filePath = input["file_path"]; + std::string language = input.value("language", "fr"); + + spdlog::info("[transcribe_audio] File: {}, Language: {}", filePath, language); + + // Call synchronous STT method + std::string text = m_voiceService->transcribeFileSync(filePath, language); + + return { + {"text", text}, + {"file", filePath}, + {"language", language} + }; + } catch (const std::exception& e) { + spdlog::error("[transcribe_audio] Error: {}", e.what()); + return {{"error", e.what()}}; + } +} + +json MCPServerTools::handleTextToSpeech(const json& input) { + if (!m_voiceService) { + return {{"error", "VoiceService not available"}}; + } + + try { + std::string text = input["text"]; + std::string outputFile = input["output_file"]; + std::string voice = input.value("voice", "fr-fr"); + + spdlog::info("[text_to_speech] Text: {}, Output: {}", text.substr(0, 50), outputFile); + + // Call synchronous TTS method + bool success = m_voiceService->textToSpeechSync(text, outputFile, voice); + + if (success) { + return { + {"success", true}, + {"file", outputFile}, + {"voice", voice} + }; + } else { + return {{"error", "TTS generation failed"}}; + } + } catch (const std::exception& e) { + spdlog::error("[text_to_speech] Error: {}", e.what()); + return {{"error", e.what()}}; + } +} + +json MCPServerTools::handleSaveMemory(const json& input) { + if (!m_storageService) { + return {{"error", "StorageService not available"}}; + } + + try { + std::string title = input["title"]; + std::string content = input["content"]; + std::vector tags; + + if (input.contains("tags") && input["tags"].is_array()) { + for (const auto& tag : input["tags"]) { + tags.push_back(tag.get()); + } + } + + spdlog::info("[save_memory] Title: {}", title); + + // TODO: Implement saveMemorySync in StorageService + // For now, return not implemented + return json({ + {"error", "save_memory not yet implemented"}, + {"note", "StorageService sync methods need to be added"}, + {"title", title} + }); + } catch (const std::exception& e) { + spdlog::error("[save_memory] Error: {}", e.what()); + return {{"error", e.what()}}; + } +} + +json MCPServerTools::handleSearchMemories(const json& input) { + if (!m_storageService) { + return {{"error", "StorageService not available"}}; + } + + try { + std::string query = input["query"]; + int limit = input.value("limit", 10); + + spdlog::info("[search_memories] Query: {}, Limit: {}", query, limit); + + // TODO: Implement searchMemoriesSync in StorageService + // For now, return not implemented + return json({ + {"error", "search_memories not yet implemented"}, + {"note", "StorageService sync methods need to be added"}, + {"query", query}, + {"limit", limit} + }); + } catch (const std::exception& e) { + spdlog::error("[search_memories] Error: {}", e.what()); + return {{"error", e.what()}}; + } +} + +} // namespace aissia::tools diff --git a/src/shared/tools/MCPServerTools.hpp b/src/shared/tools/MCPServerTools.hpp index fbe8fe4..13bd296 100644 --- a/src/shared/tools/MCPServerTools.hpp +++ b/src/shared/tools/MCPServerTools.hpp @@ -1,76 +1,76 @@ -#pragma once - -#include "../llm/ToolRegistry.hpp" -#include -#include -#include - -// Forward declarations -namespace aissia { - class LLMService; - class StorageService; - class VoiceService; -} - -namespace aissia::tools { - -using json = nlohmann::json; - -/** - * @brief MCP Server Tools - Bridge between MCP Server and AISSIA services - * - * Provides tool definitions for AISSIA modules exposed via MCP Server: - * - chat_with_aissia: Dialogue with AISSIA (Claude Sonnet 4) - * - transcribe_audio: Speech-to-text (Whisper.cpp/OpenAI/Google) - * - text_to_speech: Text-to-speech synthesis - * - save_memory: Save note/memory to storage - * - search_memories: Search stored memories - * - * Note: These tools run in synchronous mode (no IIO pub/sub, no main loop) - */ -class MCPServerTools { -public: - /** - * @brief Construct MCP server tools with service dependencies - * - * @param llm LLMService for chat_with_aissia (can be nullptr) - * @param storage StorageService for save/search memories (can be nullptr) - * @param voice VoiceService for TTS/STT (can be nullptr) - */ - MCPServerTools( - LLMService* llm, - StorageService* storage, - VoiceService* voice - ); - - /** - * @brief Get all tool definitions for registration - * - * @return Vector of ToolDefinition structs - */ - std::vector getToolDefinitions(); - - /** - * @brief Execute a tool by name - * - * @param toolName Tool to execute - * @param input Tool arguments (JSON) - * @return Tool result (JSON) - */ - json execute(const std::string& toolName, const json& input); - -private: - // Tool handlers - json handleChatWithAissia(const json& input); - json handleTranscribeAudio(const json& input); - json handleTextToSpeech(const json& input); - json handleSaveMemory(const json& input); - json handleSearchMemories(const json& input); - - // Service references (nullable) - LLMService* m_llmService; - StorageService* m_storageService; - VoiceService* m_voiceService; -}; - -} // namespace aissia::tools +#pragma once + +#include "../llm/ToolRegistry.hpp" +#include +#include +#include + +// Forward declarations +namespace aissia { + class LLMService; + class StorageService; + class VoiceService; +} + +namespace aissia::tools { + +using json = nlohmann::json; + +/** + * @brief MCP Server Tools - Bridge between MCP Server and AISSIA services + * + * Provides tool definitions for AISSIA modules exposed via MCP Server: + * - chat_with_aissia: Dialogue with AISSIA (Claude Sonnet 4) + * - transcribe_audio: Speech-to-text (Whisper.cpp/OpenAI/Google) + * - text_to_speech: Text-to-speech synthesis + * - save_memory: Save note/memory to storage + * - search_memories: Search stored memories + * + * Note: These tools run in synchronous mode (no IIO pub/sub, no main loop) + */ +class MCPServerTools { +public: + /** + * @brief Construct MCP server tools with service dependencies + * + * @param llm LLMService for chat_with_aissia (can be nullptr) + * @param storage StorageService for save/search memories (can be nullptr) + * @param voice VoiceService for TTS/STT (can be nullptr) + */ + MCPServerTools( + LLMService* llm, + StorageService* storage, + VoiceService* voice + ); + + /** + * @brief Get all tool definitions for registration + * + * @return Vector of ToolDefinition structs + */ + std::vector getToolDefinitions(); + + /** + * @brief Execute a tool by name + * + * @param toolName Tool to execute + * @param input Tool arguments (JSON) + * @return Tool result (JSON) + */ + json execute(const std::string& toolName, const json& input); + +private: + // Tool handlers + json handleChatWithAissia(const json& input); + json handleTranscribeAudio(const json& input); + json handleTextToSpeech(const json& input); + json handleSaveMemory(const json& input); + json handleSearchMemories(const json& input); + + // Service references (nullable) + LLMService* m_llmService; + StorageService* m_storageService; + VoiceService* m_voiceService; +}; + +} // namespace aissia::tools diff --git a/test-results.json b/test-results.json index 3b8a371..80cbbb0 100644 --- a/test-results.json +++ b/test-results.json @@ -1,16 +1,16 @@ -{ - "environment": { - "platform": "linux", - "testDirectory": "tests/integration" - }, - "summary": { - "failed": 0, - "passed": 0, - "skipped": 0, - "successRate": 0.0, - "total": 0, - "totalDurationMs": 0 - }, - "tests": [], - "timestamp": "2025-11-29T09:01:38Z" +{ + "environment": { + "platform": "linux", + "testDirectory": "tests/integration" + }, + "summary": { + "failed": 0, + "passed": 0, + "skipped": 0, + "successRate": 0.0, + "total": 0, + "totalDurationMs": 0 + }, + "tests": [], + "timestamp": "2025-11-29T09:01:38Z" } \ No newline at end of file diff --git a/test_interactive.sh b/test_interactive.sh index 899f64f..5464c1d 100644 --- a/test_interactive.sh +++ b/test_interactive.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -a -source .env -set +a -echo "Quelle heure est-il ?" | timeout 30 ./build/aissia --interactive +#!/bin/bash +set -a +source .env +set +a +echo "Quelle heure est-il ?" | timeout 30 ./build/aissia --interactive diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..d2cb9b9 Binary files /dev/null and b/test_output.txt differ diff --git a/test_stt_live.cpp b/test_stt_live.cpp index 67d4ff6..4ba25b9 100644 --- a/test_stt_live.cpp +++ b/test_stt_live.cpp @@ -1,237 +1,237 @@ -/** - * @file test_stt_live.cpp - * @brief Live STT testing tool - Test all 4 engines - */ - -#include "src/shared/audio/ISTTEngine.hpp" -#include -#include -#include -#include -#include - -using namespace aissia; - -// Helper: Load .env file -void loadEnv(const std::string& path = ".env") { - std::ifstream file(path); - if (!file.is_open()) { - spdlog::warn("No .env file found at: {}", path); - return; - } - - std::string line; - while (std::getline(file, line)) { - if (line.empty() || line[0] == '#') continue; - - auto pos = line.find('='); - if (pos != std::string::npos) { - std::string key = line.substr(0, pos); - std::string value = line.substr(pos + 1); - - // Remove quotes - if (!value.empty() && value.front() == '"' && value.back() == '"') { - value = value.substr(1, value.length() - 2); - } - - #ifdef _WIN32 - _putenv_s(key.c_str(), value.c_str()); - #else - setenv(key.c_str(), value.c_str(), 1); - #endif - } - } - spdlog::info("Loaded environment from {}", path); -} - -// Helper: Get API key from env -std::string getEnvVar(const std::string& name) { - const char* val = std::getenv(name.c_str()); - return val ? std::string(val) : ""; -} - -// Helper: Load audio file as WAV (simplified - assumes 16-bit PCM) -std::vector loadWavFile(const std::string& path) { - std::ifstream file(path, std::ios::binary); - if (!file.is_open()) { - spdlog::error("Failed to open audio file: {}", path); - return {}; - } - - // Skip WAV header (44 bytes) - file.seekg(44); - - // Read 16-bit PCM samples - std::vector samples; - int16_t sample; - while (file.read(reinterpret_cast(&sample), sizeof(sample))) { - samples.push_back(sample); - } - - // Convert to float [-1.0, 1.0] - std::vector audioData; - audioData.reserve(samples.size()); - for (int16_t s : samples) { - audioData.push_back(static_cast(s) / 32768.0f); - } - - spdlog::info("Loaded {} samples from {}", audioData.size(), path); - return audioData; -} - -int main(int argc, char* argv[]) { - spdlog::set_level(spdlog::level::info); - spdlog::info("=== AISSIA STT Live Test ==="); - - // Load environment variables - loadEnv(); - - // Check command line - if (argc < 2) { - std::cout << "Usage: " << argv[0] << " \n"; - std::cout << "\nAvailable engines:\n"; - std::cout << " 1. Whisper.cpp (local, requires models/ggml-base.bin)\n"; - std::cout << " 2. Whisper API (requires OPENAI_API_KEY)\n"; - std::cout << " 3. Google Speech (requires GOOGLE_API_KEY)\n"; - std::cout << " 4. Azure STT (requires AZURE_SPEECH_KEY + AZURE_SPEECH_REGION)\n"; - std::cout << " 5. Deepgram (requires DEEPGRAM_API_KEY)\n"; - return 1; - } - - std::string audioFile = argv[1]; - - // Load audio - std::vector audioData = loadWavFile(audioFile); - if (audioData.empty()) { - spdlog::error("Failed to load audio data"); - return 1; - } - - // Test each engine - std::cout << "\n========================================\n"; - std::cout << "Testing STT Engines\n"; - std::cout << "========================================\n\n"; - - // 1. Whisper.cpp (local) - { - std::cout << "[1/5] Whisper.cpp (local)\n"; - std::cout << "----------------------------\n"; - - try { - auto engine = STTEngineFactory::create("whisper_cpp", "models/ggml-base.bin"); - if (engine && engine->isAvailable()) { - engine->setLanguage("fr"); - std::string result = engine->transcribe(audioData); - std::cout << "✅ Result: " << result << "\n\n"; - } else { - std::cout << "❌ Not available (model missing?)\n\n"; - } - } catch (const std::exception& e) { - std::cout << "❌ Error: " << e.what() << "\n\n"; - } - } - - // 2. Whisper API - { - std::cout << "[2/5] OpenAI Whisper API\n"; - std::cout << "----------------------------\n"; - - std::string apiKey = getEnvVar("OPENAI_API_KEY"); - if (apiKey.empty()) { - std::cout << "❌ OPENAI_API_KEY not set\n\n"; - } else { - try { - auto engine = STTEngineFactory::create("whisper_api", "", apiKey); - if (engine && engine->isAvailable()) { - engine->setLanguage("fr"); - std::string result = engine->transcribeFile(audioFile); - std::cout << "✅ Result: " << result << "\n\n"; - } else { - std::cout << "❌ Not available\n\n"; - } - } catch (const std::exception& e) { - std::cout << "❌ Error: " << e.what() << "\n\n"; - } - } - } - - // 3. Google Speech - { - std::cout << "[3/5] Google Speech-to-Text\n"; - std::cout << "----------------------------\n"; - - std::string apiKey = getEnvVar("GOOGLE_API_KEY"); - if (apiKey.empty()) { - std::cout << "❌ GOOGLE_API_KEY not set\n\n"; - } else { - try { - auto engine = STTEngineFactory::create("google", "", apiKey); - if (engine && engine->isAvailable()) { - engine->setLanguage("fr"); - std::string result = engine->transcribeFile(audioFile); - std::cout << "✅ Result: " << result << "\n\n"; - } else { - std::cout << "❌ Not available\n\n"; - } - } catch (const std::exception& e) { - std::cout << "❌ Error: " << e.what() << "\n\n"; - } - } - } - - // 4. Azure Speech - { - std::cout << "[4/5] Azure Speech-to-Text\n"; - std::cout << "----------------------------\n"; - - std::string apiKey = getEnvVar("AZURE_SPEECH_KEY"); - std::string region = getEnvVar("AZURE_SPEECH_REGION"); - - if (apiKey.empty() || region.empty()) { - std::cout << "❌ AZURE_SPEECH_KEY or AZURE_SPEECH_REGION not set\n\n"; - } else { - try { - auto engine = STTEngineFactory::create("azure", region, apiKey); - if (engine && engine->isAvailable()) { - engine->setLanguage("fr"); - std::string result = engine->transcribeFile(audioFile); - std::cout << "✅ Result: " << result << "\n\n"; - } else { - std::cout << "❌ Not available\n\n"; - } - } catch (const std::exception& e) { - std::cout << "❌ Error: " << e.what() << "\n\n"; - } - } - } - - // 5. Deepgram - { - std::cout << "[5/5] Deepgram\n"; - std::cout << "----------------------------\n"; - - std::string apiKey = getEnvVar("DEEPGRAM_API_KEY"); - if (apiKey.empty()) { - std::cout << "❌ DEEPGRAM_API_KEY not set\n\n"; - } else { - try { - auto engine = STTEngineFactory::create("deepgram", "", apiKey); - if (engine && engine->isAvailable()) { - engine->setLanguage("fr"); - std::string result = engine->transcribeFile(audioFile); - std::cout << "✅ Result: " << result << "\n\n"; - } else { - std::cout << "❌ Not available\n\n"; - } - } catch (const std::exception& e) { - std::cout << "❌ Error: " << e.what() << "\n\n"; - } - } - } - - std::cout << "========================================\n"; - std::cout << "Testing complete!\n"; - std::cout << "========================================\n"; - - return 0; -} +/** + * @file test_stt_live.cpp + * @brief Live STT testing tool - Test all 4 engines + */ + +#include "src/shared/audio/ISTTEngine.hpp" +#include +#include +#include +#include +#include + +using namespace aissia; + +// Helper: Load .env file +void loadEnv(const std::string& path = ".env") { + std::ifstream file(path); + if (!file.is_open()) { + spdlog::warn("No .env file found at: {}", path); + return; + } + + std::string line; + while (std::getline(file, line)) { + if (line.empty() || line[0] == '#') continue; + + auto pos = line.find('='); + if (pos != std::string::npos) { + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + // Remove quotes + if (!value.empty() && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.length() - 2); + } + + #ifdef _WIN32 + _putenv_s(key.c_str(), value.c_str()); + #else + setenv(key.c_str(), value.c_str(), 1); + #endif + } + } + spdlog::info("Loaded environment from {}", path); +} + +// Helper: Get API key from env +std::string getEnvVar(const std::string& name) { + const char* val = std::getenv(name.c_str()); + return val ? std::string(val) : ""; +} + +// Helper: Load audio file as WAV (simplified - assumes 16-bit PCM) +std::vector loadWavFile(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) { + spdlog::error("Failed to open audio file: {}", path); + return {}; + } + + // Skip WAV header (44 bytes) + file.seekg(44); + + // Read 16-bit PCM samples + std::vector samples; + int16_t sample; + while (file.read(reinterpret_cast(&sample), sizeof(sample))) { + samples.push_back(sample); + } + + // Convert to float [-1.0, 1.0] + std::vector audioData; + audioData.reserve(samples.size()); + for (int16_t s : samples) { + audioData.push_back(static_cast(s) / 32768.0f); + } + + spdlog::info("Loaded {} samples from {}", audioData.size(), path); + return audioData; +} + +int main(int argc, char* argv[]) { + spdlog::set_level(spdlog::level::info); + spdlog::info("=== AISSIA STT Live Test ==="); + + // Load environment variables + loadEnv(); + + // Check command line + if (argc < 2) { + std::cout << "Usage: " << argv[0] << " \n"; + std::cout << "\nAvailable engines:\n"; + std::cout << " 1. Whisper.cpp (local, requires models/ggml-base.bin)\n"; + std::cout << " 2. Whisper API (requires OPENAI_API_KEY)\n"; + std::cout << " 3. Google Speech (requires GOOGLE_API_KEY)\n"; + std::cout << " 4. Azure STT (requires AZURE_SPEECH_KEY + AZURE_SPEECH_REGION)\n"; + std::cout << " 5. Deepgram (requires DEEPGRAM_API_KEY)\n"; + return 1; + } + + std::string audioFile = argv[1]; + + // Load audio + std::vector audioData = loadWavFile(audioFile); + if (audioData.empty()) { + spdlog::error("Failed to load audio data"); + return 1; + } + + // Test each engine + std::cout << "\n========================================\n"; + std::cout << "Testing STT Engines\n"; + std::cout << "========================================\n\n"; + + // 1. Whisper.cpp (local) + { + std::cout << "[1/5] Whisper.cpp (local)\n"; + std::cout << "----------------------------\n"; + + try { + auto engine = STTEngineFactory::create("whisper_cpp", "models/ggml-base.bin"); + if (engine && engine->isAvailable()) { + engine->setLanguage("fr"); + std::string result = engine->transcribe(audioData); + std::cout << "✅ Result: " << result << "\n\n"; + } else { + std::cout << "❌ Not available (model missing?)\n\n"; + } + } catch (const std::exception& e) { + std::cout << "❌ Error: " << e.what() << "\n\n"; + } + } + + // 2. Whisper API + { + std::cout << "[2/5] OpenAI Whisper API\n"; + std::cout << "----------------------------\n"; + + std::string apiKey = getEnvVar("OPENAI_API_KEY"); + if (apiKey.empty()) { + std::cout << "❌ OPENAI_API_KEY not set\n\n"; + } else { + try { + auto engine = STTEngineFactory::create("whisper_api", "", apiKey); + if (engine && engine->isAvailable()) { + engine->setLanguage("fr"); + std::string result = engine->transcribeFile(audioFile); + std::cout << "✅ Result: " << result << "\n\n"; + } else { + std::cout << "❌ Not available\n\n"; + } + } catch (const std::exception& e) { + std::cout << "❌ Error: " << e.what() << "\n\n"; + } + } + } + + // 3. Google Speech + { + std::cout << "[3/5] Google Speech-to-Text\n"; + std::cout << "----------------------------\n"; + + std::string apiKey = getEnvVar("GOOGLE_API_KEY"); + if (apiKey.empty()) { + std::cout << "❌ GOOGLE_API_KEY not set\n\n"; + } else { + try { + auto engine = STTEngineFactory::create("google", "", apiKey); + if (engine && engine->isAvailable()) { + engine->setLanguage("fr"); + std::string result = engine->transcribeFile(audioFile); + std::cout << "✅ Result: " << result << "\n\n"; + } else { + std::cout << "❌ Not available\n\n"; + } + } catch (const std::exception& e) { + std::cout << "❌ Error: " << e.what() << "\n\n"; + } + } + } + + // 4. Azure Speech + { + std::cout << "[4/5] Azure Speech-to-Text\n"; + std::cout << "----------------------------\n"; + + std::string apiKey = getEnvVar("AZURE_SPEECH_KEY"); + std::string region = getEnvVar("AZURE_SPEECH_REGION"); + + if (apiKey.empty() || region.empty()) { + std::cout << "❌ AZURE_SPEECH_KEY or AZURE_SPEECH_REGION not set\n\n"; + } else { + try { + auto engine = STTEngineFactory::create("azure", region, apiKey); + if (engine && engine->isAvailable()) { + engine->setLanguage("fr"); + std::string result = engine->transcribeFile(audioFile); + std::cout << "✅ Result: " << result << "\n\n"; + } else { + std::cout << "❌ Not available\n\n"; + } + } catch (const std::exception& e) { + std::cout << "❌ Error: " << e.what() << "\n\n"; + } + } + } + + // 5. Deepgram + { + std::cout << "[5/5] Deepgram\n"; + std::cout << "----------------------------\n"; + + std::string apiKey = getEnvVar("DEEPGRAM_API_KEY"); + if (apiKey.empty()) { + std::cout << "❌ DEEPGRAM_API_KEY not set\n\n"; + } else { + try { + auto engine = STTEngineFactory::create("deepgram", "", apiKey); + if (engine && engine->isAvailable()) { + engine->setLanguage("fr"); + std::string result = engine->transcribeFile(audioFile); + std::cout << "✅ Result: " << result << "\n\n"; + } else { + std::cout << "❌ Not available\n\n"; + } + } catch (const std::exception& e) { + std::cout << "❌ Error: " << e.what() << "\n\n"; + } + } + } + + std::cout << "========================================\n"; + std::cout << "Testing complete!\n"; + std::cout << "========================================\n"; + + return 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b87e688..e7ac0de 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,176 +1,176 @@ -# ============================================================================ -# AISSIA Integration Tests -# ============================================================================ - -# Fetch Catch2 -include(FetchContent) -FetchContent_Declare( - Catch2 - GIT_REPOSITORY https://github.com/catchorg/Catch2.git - GIT_TAG v3.4.0 -) -FetchContent_MakeAvailable(Catch2) - -# ============================================================================ -# Test executable -# ============================================================================ -add_executable(aissia_tests - main.cpp - - # Mocks - mocks/MockIO.cpp - - # Module sources (needed for testing) - ${CMAKE_SOURCE_DIR}/src/modules/SchedulerModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/NotificationModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/MonitoringModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/AIModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/VoiceModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/StorageModule.cpp - ${CMAKE_SOURCE_DIR}/src/modules/WebModule.cpp - - # Module tests (70 TI) - modules/SchedulerModuleTests.cpp - modules/NotificationModuleTests.cpp - modules/MonitoringModuleTests.cpp - modules/AIModuleTests.cpp - modules/VoiceModuleTests.cpp - modules/StorageModuleTests.cpp - modules/WebModuleTests.cpp - - # MCP tests (50 TI) - mcp/MCPTypesTests.cpp - mcp/StdioTransportTests.cpp - mcp/MCPClientTests.cpp -) - -target_link_libraries(aissia_tests PRIVATE - Catch2::Catch2WithMain - GroveEngine::impl - AissiaTools - spdlog::spdlog -) - -# WebModule needs httplib and OpenSSL -target_include_directories(aissia_tests PRIVATE - ${httplib_SOURCE_DIR} -) -# Link Winsock for httplib on Windows -if(WIN32) - target_link_libraries(aissia_tests PRIVATE ws2_32) -endif() -if(OPENSSL_FOUND) - target_link_libraries(aissia_tests PRIVATE OpenSSL::SSL OpenSSL::Crypto) - target_compile_definitions(aissia_tests PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) -endif() - -# Disable module factory functions during testing -target_compile_definitions(aissia_tests PRIVATE AISSIA_TEST_BUILD) - -target_include_directories(aissia_tests PRIVATE - ${CMAKE_SOURCE_DIR}/src - ${CMAKE_CURRENT_SOURCE_DIR} -) - -# ============================================================================ -# Copy test fixtures to build directory -# ============================================================================ -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/ - DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures) - -# ============================================================================ -# CTest integration -# ============================================================================ -include(CTest) -# Note: catch_discover_tests requires running the exe at build time -# which can fail due to missing DLLs. Use manual test registration instead. -add_test(NAME aissia_tests COMMAND aissia_tests) - -# ============================================================================ -# Custom targets -# ============================================================================ - -# Run all tests -add_custom_target(test_all - COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure - DEPENDS aissia_tests - COMMENT "Running all integration tests" -) - -# Run module tests only -add_custom_target(test_modules - COMMAND $ "[scheduler],[notification],[monitoring],[ai],[voice],[storage],[web]" - DEPENDS aissia_tests - COMMENT "Running module integration tests" -) - -# Run MCP tests only -add_custom_target(test_mcp - COMMAND $ "[mcp]" - DEPENDS aissia_tests - COMMENT "Running MCP integration tests" -) - -# ============================================================================ -# Integration Test Modules (Dynamic .so files) -# ============================================================================ - -# Helper macro to create integration test modules -macro(add_integration_test TEST_NAME) - add_library(${TEST_NAME} SHARED - integration/${TEST_NAME}.cpp - ) - target_include_directories(${TEST_NAME} PRIVATE - ${CMAKE_SOURCE_DIR}/src - ) - target_link_libraries(${TEST_NAME} PRIVATE - GroveEngine::impl - spdlog::spdlog - ) - set_target_properties(${TEST_NAME} PROPERTIES - PREFIX "" - LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests/integration - ) -endmacro() - -# Individual integration test modules (will be added as we create them) -# Phase 2: MCP Tests -add_integration_test(IT_001_GetCurrentTime) -add_integration_test(IT_002_FileSystemWrite) -add_integration_test(IT_003_FileSystemRead) -add_integration_test(IT_004_MCPToolsList) - -# Phase 3: Flow Tests -add_integration_test(IT_005_VoiceToAI) -add_integration_test(IT_006_AIToLLM) -add_integration_test(IT_007_StorageWrite) -add_integration_test(IT_008_StorageRead) - -# Phase 4: End-to-End Test -add_integration_test(IT_009_FullConversationLoop) - -# Phase 5: Module Tests -add_integration_test(IT_010_SchedulerHyperfocus) -add_integration_test(IT_011_NotificationAlert) -add_integration_test(IT_012_MonitoringActivity) -add_integration_test(IT_013_WebRequest) - -# Custom target to build all integration tests -add_custom_target(integration_tests - DEPENDS - IT_001_GetCurrentTime - IT_002_FileSystemWrite - IT_003_FileSystemRead - IT_004_MCPToolsList - IT_005_VoiceToAI - IT_006_AIToLLM - IT_007_StorageWrite - IT_008_StorageRead - IT_009_FullConversationLoop - IT_010_SchedulerHyperfocus - IT_011_NotificationAlert - IT_012_MonitoringActivity - IT_013_WebRequest - COMMENT "Building all integration test modules" -) - +# ============================================================================ +# AISSIA Integration Tests +# ============================================================================ + +# Fetch Catch2 +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.4.0 +) +FetchContent_MakeAvailable(Catch2) + +# ============================================================================ +# Test executable +# ============================================================================ +add_executable(aissia_tests + main.cpp + + # Mocks + mocks/MockIO.cpp + + # Module sources (needed for testing) + ${CMAKE_SOURCE_DIR}/src/modules/SchedulerModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/NotificationModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/MonitoringModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/AIModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/VoiceModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/StorageModule.cpp + ${CMAKE_SOURCE_DIR}/src/modules/WebModule.cpp + + # Module tests (70 TI) + modules/SchedulerModuleTests.cpp + modules/NotificationModuleTests.cpp + modules/MonitoringModuleTests.cpp + modules/AIModuleTests.cpp + modules/VoiceModuleTests.cpp + modules/StorageModuleTests.cpp + modules/WebModuleTests.cpp + + # MCP tests (50 TI) + mcp/MCPTypesTests.cpp + mcp/StdioTransportTests.cpp + mcp/MCPClientTests.cpp +) + +target_link_libraries(aissia_tests PRIVATE + Catch2::Catch2WithMain + GroveEngine::impl + AissiaTools + spdlog::spdlog +) + +# WebModule needs httplib and OpenSSL +target_include_directories(aissia_tests PRIVATE + ${httplib_SOURCE_DIR} +) +# Link Winsock for httplib on Windows +if(WIN32) + target_link_libraries(aissia_tests PRIVATE ws2_32) +endif() +if(OPENSSL_FOUND) + target_link_libraries(aissia_tests PRIVATE OpenSSL::SSL OpenSSL::Crypto) + target_compile_definitions(aissia_tests PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) +endif() + +# Disable module factory functions during testing +target_compile_definitions(aissia_tests PRIVATE AISSIA_TEST_BUILD) + +target_include_directories(aissia_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# ============================================================================ +# Copy test fixtures to build directory +# ============================================================================ +file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/ + DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures) + +# ============================================================================ +# CTest integration +# ============================================================================ +include(CTest) +# Note: catch_discover_tests requires running the exe at build time +# which can fail due to missing DLLs. Use manual test registration instead. +add_test(NAME aissia_tests COMMAND aissia_tests) + +# ============================================================================ +# Custom targets +# ============================================================================ + +# Run all tests +add_custom_target(test_all + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS aissia_tests + COMMENT "Running all integration tests" +) + +# Run module tests only +add_custom_target(test_modules + COMMAND $ "[scheduler],[notification],[monitoring],[ai],[voice],[storage],[web]" + DEPENDS aissia_tests + COMMENT "Running module integration tests" +) + +# Run MCP tests only +add_custom_target(test_mcp + COMMAND $ "[mcp]" + DEPENDS aissia_tests + COMMENT "Running MCP integration tests" +) + +# ============================================================================ +# Integration Test Modules (Dynamic .so files) +# ============================================================================ + +# Helper macro to create integration test modules +macro(add_integration_test TEST_NAME) + add_library(${TEST_NAME} SHARED + integration/${TEST_NAME}.cpp + ) + target_include_directories(${TEST_NAME} PRIVATE + ${CMAKE_SOURCE_DIR}/src + ) + target_link_libraries(${TEST_NAME} PRIVATE + GroveEngine::impl + spdlog::spdlog + ) + set_target_properties(${TEST_NAME} PROPERTIES + PREFIX "" + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests/integration + ) +endmacro() + +# Individual integration test modules (will be added as we create them) +# Phase 2: MCP Tests +add_integration_test(IT_001_GetCurrentTime) +add_integration_test(IT_002_FileSystemWrite) +add_integration_test(IT_003_FileSystemRead) +add_integration_test(IT_004_MCPToolsList) + +# Phase 3: Flow Tests +add_integration_test(IT_005_VoiceToAI) +add_integration_test(IT_006_AIToLLM) +add_integration_test(IT_007_StorageWrite) +add_integration_test(IT_008_StorageRead) + +# Phase 4: End-to-End Test +add_integration_test(IT_009_FullConversationLoop) + +# Phase 5: Module Tests +add_integration_test(IT_010_SchedulerHyperfocus) +add_integration_test(IT_011_NotificationAlert) +add_integration_test(IT_012_MonitoringActivity) +add_integration_test(IT_013_WebRequest) + +# Custom target to build all integration tests +add_custom_target(integration_tests + DEPENDS + IT_001_GetCurrentTime + IT_002_FileSystemWrite + IT_003_FileSystemRead + IT_004_MCPToolsList + IT_005_VoiceToAI + IT_006_AIToLLM + IT_007_StorageWrite + IT_008_StorageRead + IT_009_FullConversationLoop + IT_010_SchedulerHyperfocus + IT_011_NotificationAlert + IT_012_MonitoringActivity + IT_013_WebRequest + COMMENT "Building all integration test modules" +) + diff --git a/tests/fixtures/echo_server.py b/tests/fixtures/echo_server.py index 92caadb..8810ad5 100644 --- a/tests/fixtures/echo_server.py +++ b/tests/fixtures/echo_server.py @@ -1,34 +1,34 @@ -#!/usr/bin/env python3 -""" -Simple JSON-RPC echo server for testing StdioTransport. -Echoes back the params of any request as the result. -""" -import json -import sys - - -def main(): - while True: - try: - line = sys.stdin.readline() - if not line: - break - - request = json.loads(line.strip()) - response = { - "jsonrpc": "2.0", - "id": request.get("id"), - "result": request.get("params", {}) - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - except json.JSONDecodeError: - # Invalid JSON, ignore - pass - except Exception: - # Other errors, continue - pass - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +Simple JSON-RPC echo server for testing StdioTransport. +Echoes back the params of any request as the result. +""" +import json +import sys + + +def main(): + while True: + try: + line = sys.stdin.readline() + if not line: + break + + request = json.loads(line.strip()) + response = { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": request.get("params", {}) + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + except json.JSONDecodeError: + # Invalid JSON, ignore + pass + except Exception: + # Other errors, continue + pass + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/mock_mcp.json b/tests/fixtures/mock_mcp.json index ef9dee4..ecb1071 100644 --- a/tests/fixtures/mock_mcp.json +++ b/tests/fixtures/mock_mcp.json @@ -1,19 +1,19 @@ -{ - "servers": { - "mock_server": { - "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", - "args": ["-u", "tests/fixtures/mock_mcp_server.py"], - "enabled": true - }, - "disabled_server": { - "command": "nonexistent_command", - "args": [], - "enabled": false - }, - "echo_server": { - "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", - "args": ["-u", "tests/fixtures/echo_server.py"], - "enabled": true - } - } -} +{ + "servers": { + "mock_server": { + "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", + "args": ["-u", "tests/fixtures/mock_mcp_server.py"], + "enabled": true + }, + "disabled_server": { + "command": "nonexistent_command", + "args": [], + "enabled": false + }, + "echo_server": { + "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", + "args": ["-u", "tests/fixtures/echo_server.py"], + "enabled": true + } + } +} diff --git a/tests/fixtures/mock_mcp_server.py b/tests/fixtures/mock_mcp_server.py index 9f5315f..0c748cc 100644 --- a/tests/fixtures/mock_mcp_server.py +++ b/tests/fixtures/mock_mcp_server.py @@ -1,136 +1,136 @@ -#!/usr/bin/env python3 -""" -Mock MCP server for integration testing. -Implements the MCP protocol (initialize, tools/list, tools/call). -""" -import json -import sys -import os - -# Tools exposed by this mock server -TOOLS = [ - { - "name": "test_tool", - "description": "A test tool that echoes its input", - "inputSchema": { - "type": "object", - "properties": { - "message": {"type": "string", "description": "Message to echo"} - }, - "required": ["message"] - } - }, - { - "name": "get_time", - "description": "Returns the current server time", - "inputSchema": { - "type": "object", - "properties": {} - } - } -] - - -def handle_initialize(params): - """Handle initialize request""" - return { - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "MockMCPServer", - "version": "1.0.0" - } - } - - -def handle_tools_list(params): - """Handle tools/list request""" - return {"tools": TOOLS} - - -def handle_tools_call(params): - """Handle tools/call request""" - tool_name = params.get("name", "") - arguments = params.get("arguments", {}) - - if tool_name == "test_tool": - message = arguments.get("message", "no message") - return { - "content": [ - {"type": "text", "text": f"Echo: {message}"} - ] - } - elif tool_name == "get_time": - import datetime - return { - "content": [ - {"type": "text", "text": datetime.datetime.now().isoformat()} - ] - } - else: - return { - "content": [ - {"type": "text", "text": f"Unknown tool: {tool_name}"} - ], - "isError": True - } - - -def handle_request(request): - """Route request to appropriate handler""" - method = request.get("method", "") - - handlers = { - "initialize": handle_initialize, - "tools/list": handle_tools_list, - "tools/call": handle_tools_call, - } - - handler = handlers.get(method) - if handler: - return handler(request.get("params", {})) - else: - return {"error": {"code": -32601, "message": f"Method not found: {method}"}} - - -def main(): - while True: - try: - line = sys.stdin.readline() - if not line: - break - - request = json.loads(line.strip()) - result = handle_request(request) - - response = { - "jsonrpc": "2.0", - "id": request.get("id") - } - - if "error" in result: - response["error"] = result["error"] - else: - response["result"] = result - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - except json.JSONDecodeError as e: - error_response = { - "jsonrpc": "2.0", - "id": None, - "error": {"code": -32700, "message": f"Parse error: {str(e)}"} - } - sys.stdout.write(json.dumps(error_response) + "\n") - sys.stdout.flush() - except Exception as e: - # Log to stderr for debugging - sys.stderr.write(f"Error: {str(e)}\n") - sys.stderr.flush() - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +Mock MCP server for integration testing. +Implements the MCP protocol (initialize, tools/list, tools/call). +""" +import json +import sys +import os + +# Tools exposed by this mock server +TOOLS = [ + { + "name": "test_tool", + "description": "A test tool that echoes its input", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "Message to echo"} + }, + "required": ["message"] + } + }, + { + "name": "get_time", + "description": "Returns the current server time", + "inputSchema": { + "type": "object", + "properties": {} + } + } +] + + +def handle_initialize(params): + """Handle initialize request""" + return { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "MockMCPServer", + "version": "1.0.0" + } + } + + +def handle_tools_list(params): + """Handle tools/list request""" + return {"tools": TOOLS} + + +def handle_tools_call(params): + """Handle tools/call request""" + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + + if tool_name == "test_tool": + message = arguments.get("message", "no message") + return { + "content": [ + {"type": "text", "text": f"Echo: {message}"} + ] + } + elif tool_name == "get_time": + import datetime + return { + "content": [ + {"type": "text", "text": datetime.datetime.now().isoformat()} + ] + } + else: + return { + "content": [ + {"type": "text", "text": f"Unknown tool: {tool_name}"} + ], + "isError": True + } + + +def handle_request(request): + """Route request to appropriate handler""" + method = request.get("method", "") + + handlers = { + "initialize": handle_initialize, + "tools/list": handle_tools_list, + "tools/call": handle_tools_call, + } + + handler = handlers.get(method) + if handler: + return handler(request.get("params", {})) + else: + return {"error": {"code": -32601, "message": f"Method not found: {method}"}} + + +def main(): + while True: + try: + line = sys.stdin.readline() + if not line: + break + + request = json.loads(line.strip()) + result = handle_request(request) + + response = { + "jsonrpc": "2.0", + "id": request.get("id") + } + + if "error" in result: + response["error"] = result["error"] + else: + response["result"] = result + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + except json.JSONDecodeError as e: + error_response = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": f"Parse error: {str(e)}"} + } + sys.stdout.write(json.dumps(error_response) + "\n") + sys.stdout.flush() + except Exception as e: + # Log to stderr for debugging + sys.stderr.write(f"Error: {str(e)}\n") + sys.stderr.flush() + + +if __name__ == "__main__": + main() diff --git a/tests/main.cpp b/tests/main.cpp index d9d047c..cfc624f 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -1,8 +1,8 @@ -// AISSIA Integration Tests - Entry Point -// Using Catch2 v3 with main provided by Catch2::Catch2WithMain - -// This file is intentionally minimal. -// Catch2WithMain provides the main() function automatically. - -// Include common test utilities -#include "utils/TestHelpers.hpp" +// AISSIA Integration Tests - Entry Point +// Using Catch2 v3 with main provided by Catch2::Catch2WithMain + +// This file is intentionally minimal. +// Catch2WithMain provides the main() function automatically. + +// Include common test utilities +#include "utils/TestHelpers.hpp" diff --git a/tests/mcp/MCPClientTests.cpp b/tests/mcp/MCPClientTests.cpp index 75a5c2e..c567389 100644 --- a/tests/mcp/MCPClientTests.cpp +++ b/tests/mcp/MCPClientTests.cpp @@ -1,392 +1,392 @@ -/** - * @file MCPClientTests.cpp - * @brief Integration tests for MCPClient (15 TI) - */ - -#include -#include "shared/mcp/MCPClient.hpp" -#include "mocks/MockTransport.hpp" - -#include -#include - -using namespace aissia::mcp; -using namespace aissia::tests; -using json = nlohmann::json; - -// ============================================================================ -// Helper: Create test config file -// ============================================================================ - -std::string createTestConfigFile(const json& config) { - std::string path = "test_mcp_config.json"; - std::ofstream file(path); - file << config.dump(2); - file.close(); - return path; -} - -void cleanupTestConfigFile(const std::string& path) { - std::filesystem::remove(path); -} - -// ============================================================================ -// TI_CLIENT_001: Load config valid -// ============================================================================ - -TEST_CASE("TI_CLIENT_001_LoadConfigValid", "[mcp][client]") { - json config = { - {"servers", { - {"test_server", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"server.py"})}, - {"enabled", true} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - bool loaded = client.loadConfig(path); - - REQUIRE(loaded == true); - - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_002: Load config invalid -// ============================================================================ - -TEST_CASE("TI_CLIENT_002_LoadConfigInvalid", "[mcp][client]") { - // Create file with invalid JSON - std::string path = "invalid_config.json"; - std::ofstream file(path); - file << "{ invalid json }"; - file.close(); - - MCPClient client; - bool loaded = client.loadConfig(path); - - REQUIRE(loaded == false); - - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_003: Load config missing file -// ============================================================================ - -TEST_CASE("TI_CLIENT_003_LoadConfigMissingFile", "[mcp][client]") { - MCPClient client; - bool loaded = client.loadConfig("nonexistent_file.json"); - - REQUIRE(loaded == false); -} - -// ============================================================================ -// TI_CLIENT_004: ConnectAll starts servers -// ============================================================================ - -TEST_CASE("TI_CLIENT_004_ConnectAllStartsServers", "[mcp][client]") { - // Use the real mock MCP server fixture - MCPClient client; - bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); - - if (loaded) { - int connected = client.connectAll(); - // Should connect to enabled servers - REQUIRE(connected >= 0); - client.disconnectAll(); - } else { - // Skip if fixture not available - SUCCEED(); - } -} - -// ============================================================================ -// TI_CLIENT_005: ConnectAll skips disabled -// ============================================================================ - -TEST_CASE("TI_CLIENT_005_ConnectAllSkipsDisabled", "[mcp][client]") { - json config = { - {"servers", { - {"enabled_server", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }}, - {"disabled_server", { - {"command", "nonexistent"}, - {"enabled", false} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - client.loadConfig(path); - int connected = client.connectAll(); - - // disabled_server should not be connected - REQUIRE(client.isConnected("disabled_server") == false); - - client.disconnectAll(); - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_006: Connect single server -// ============================================================================ - -TEST_CASE("TI_CLIENT_006_ConnectSingleServer", "[mcp][client]") { - json config = { - {"servers", { - {"server1", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }}, - {"server2", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - client.loadConfig(path); - - // Connect only server1 - bool connected = client.connect("server1"); - - REQUIRE(connected == true); - REQUIRE(client.isConnected("server1") == true); - REQUIRE(client.isConnected("server2") == false); - - client.disconnectAll(); - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_007: Disconnect single server -// ============================================================================ - -TEST_CASE("TI_CLIENT_007_DisconnectSingleServer", "[mcp][client]") { - json config = { - {"servers", { - {"server1", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - client.loadConfig(path); - client.connect("server1"); - REQUIRE(client.isConnected("server1") == true); - - client.disconnect("server1"); - REQUIRE(client.isConnected("server1") == false); - - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_008: DisconnectAll cleans up -// ============================================================================ - -TEST_CASE("TI_CLIENT_008_DisconnectAllCleansUp", "[mcp][client]") { - json config = { - {"servers", { - {"server1", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }}, - {"server2", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - client.loadConfig(path); - client.connectAll(); - - client.disconnectAll(); - - REQUIRE(client.isConnected("server1") == false); - REQUIRE(client.isConnected("server2") == false); - REQUIRE(client.getConnectedServers().empty() == true); - - cleanupTestConfigFile(path); -} - -// ============================================================================ -// TI_CLIENT_009: ListAllTools aggregates -// ============================================================================ - -TEST_CASE("TI_CLIENT_009_ListAllToolsAggregates", "[mcp][client]") { - MCPClient client; - bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); - - if (loaded) { - client.connectAll(); - auto tools = client.listAllTools(); - - // Should have tools from mock server - REQUIRE(tools.size() >= 0); - - client.disconnectAll(); - } else { - SUCCEED(); - } -} - -// ============================================================================ -// TI_CLIENT_010: Tool name prefixed -// ============================================================================ - -TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") { - MCPClient client; - bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); - - if (loaded) { - client.connectAll(); - auto tools = client.listAllTools(); - - bool hasPrefix = false; - for (const auto& tool : tools) { - if (tool.name.find(":") != std::string::npos) { - hasPrefix = true; - break; - } - } - - if (!tools.empty()) { - REQUIRE(hasPrefix == true); - } - - client.disconnectAll(); - } else { - SUCCEED(); - } -} - -// ============================================================================ -// TI_CLIENT_011: CallTool routes to server -// ============================================================================ - -TEST_CASE("TI_CLIENT_011_CallToolRoutesToServer", "[mcp][client]") { - MCPClient client; - bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); - - if (loaded) { - client.connectAll(); - auto tools = client.listAllTools(); - - if (!tools.empty()) { - // Call the first available tool - auto result = client.callTool(tools[0].name, json::object()); - // Should get some result (success or error) - REQUIRE(result.content.size() >= 0); - } - - client.disconnectAll(); - } else { - SUCCEED(); - } -} - -// ============================================================================ -// TI_CLIENT_012: CallTool invalid name -// ============================================================================ - -TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") { - MCPClient client; - client.loadConfig("tests/fixtures/mock_mcp.json"); - client.connectAll(); - - auto result = client.callTool("nonexistent:tool", json::object()); - - REQUIRE(result.isError == true); - - client.disconnectAll(); -} - -// ============================================================================ -// TI_CLIENT_013: CallTool disconnected server -// ============================================================================ - -TEST_CASE("TI_CLIENT_013_CallToolDisconnectedServer", "[mcp][client]") { - MCPClient client; - // Don't connect any servers - - auto result = client.callTool("server:tool", json::object()); - - REQUIRE(result.isError == true); -} - -// ============================================================================ -// TI_CLIENT_014: ToolCount accurate -// ============================================================================ - -TEST_CASE("TI_CLIENT_014_ToolCountAccurate", "[mcp][client]") { - MCPClient client; - bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); - - if (loaded) { - client.connectAll(); - - size_t count = client.toolCount(); - auto tools = client.listAllTools(); - - REQUIRE(count == tools.size()); - - client.disconnectAll(); - } else { - SUCCEED(); - } -} - -// ============================================================================ -// TI_CLIENT_015: IsConnected accurate -// ============================================================================ - -TEST_CASE("TI_CLIENT_015_IsConnectedAccurate", "[mcp][client]") { - json config = { - {"servers", { - {"test_server", { - {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, - {"args", json::array({"tests/fixtures/echo_server.py"})}, - {"enabled", true} - }} - }} - }; - auto path = createTestConfigFile(config); - - MCPClient client; - client.loadConfig(path); - - // Not connected yet - REQUIRE(client.isConnected("test_server") == false); - - // Connect - client.connect("test_server"); - REQUIRE(client.isConnected("test_server") == true); - - // Disconnect - client.disconnect("test_server"); - REQUIRE(client.isConnected("test_server") == false); - - cleanupTestConfigFile(path); -} +/** + * @file MCPClientTests.cpp + * @brief Integration tests for MCPClient (15 TI) + */ + +#include +#include "shared/mcp/MCPClient.hpp" +#include "mocks/MockTransport.hpp" + +#include +#include + +using namespace aissia::mcp; +using namespace aissia::tests; +using json = nlohmann::json; + +// ============================================================================ +// Helper: Create test config file +// ============================================================================ + +std::string createTestConfigFile(const json& config) { + std::string path = "test_mcp_config.json"; + std::ofstream file(path); + file << config.dump(2); + file.close(); + return path; +} + +void cleanupTestConfigFile(const std::string& path) { + std::filesystem::remove(path); +} + +// ============================================================================ +// TI_CLIENT_001: Load config valid +// ============================================================================ + +TEST_CASE("TI_CLIENT_001_LoadConfigValid", "[mcp][client]") { + json config = { + {"servers", { + {"test_server", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"server.py"})}, + {"enabled", true} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + bool loaded = client.loadConfig(path); + + REQUIRE(loaded == true); + + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_002: Load config invalid +// ============================================================================ + +TEST_CASE("TI_CLIENT_002_LoadConfigInvalid", "[mcp][client]") { + // Create file with invalid JSON + std::string path = "invalid_config.json"; + std::ofstream file(path); + file << "{ invalid json }"; + file.close(); + + MCPClient client; + bool loaded = client.loadConfig(path); + + REQUIRE(loaded == false); + + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_003: Load config missing file +// ============================================================================ + +TEST_CASE("TI_CLIENT_003_LoadConfigMissingFile", "[mcp][client]") { + MCPClient client; + bool loaded = client.loadConfig("nonexistent_file.json"); + + REQUIRE(loaded == false); +} + +// ============================================================================ +// TI_CLIENT_004: ConnectAll starts servers +// ============================================================================ + +TEST_CASE("TI_CLIENT_004_ConnectAllStartsServers", "[mcp][client]") { + // Use the real mock MCP server fixture + MCPClient client; + bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); + + if (loaded) { + int connected = client.connectAll(); + // Should connect to enabled servers + REQUIRE(connected >= 0); + client.disconnectAll(); + } else { + // Skip if fixture not available + SUCCEED(); + } +} + +// ============================================================================ +// TI_CLIENT_005: ConnectAll skips disabled +// ============================================================================ + +TEST_CASE("TI_CLIENT_005_ConnectAllSkipsDisabled", "[mcp][client]") { + json config = { + {"servers", { + {"enabled_server", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }}, + {"disabled_server", { + {"command", "nonexistent"}, + {"enabled", false} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + client.loadConfig(path); + int connected = client.connectAll(); + + // disabled_server should not be connected + REQUIRE(client.isConnected("disabled_server") == false); + + client.disconnectAll(); + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_006: Connect single server +// ============================================================================ + +TEST_CASE("TI_CLIENT_006_ConnectSingleServer", "[mcp][client]") { + json config = { + {"servers", { + {"server1", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }}, + {"server2", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + client.loadConfig(path); + + // Connect only server1 + bool connected = client.connect("server1"); + + REQUIRE(connected == true); + REQUIRE(client.isConnected("server1") == true); + REQUIRE(client.isConnected("server2") == false); + + client.disconnectAll(); + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_007: Disconnect single server +// ============================================================================ + +TEST_CASE("TI_CLIENT_007_DisconnectSingleServer", "[mcp][client]") { + json config = { + {"servers", { + {"server1", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + client.loadConfig(path); + client.connect("server1"); + REQUIRE(client.isConnected("server1") == true); + + client.disconnect("server1"); + REQUIRE(client.isConnected("server1") == false); + + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_008: DisconnectAll cleans up +// ============================================================================ + +TEST_CASE("TI_CLIENT_008_DisconnectAllCleansUp", "[mcp][client]") { + json config = { + {"servers", { + {"server1", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }}, + {"server2", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + client.loadConfig(path); + client.connectAll(); + + client.disconnectAll(); + + REQUIRE(client.isConnected("server1") == false); + REQUIRE(client.isConnected("server2") == false); + REQUIRE(client.getConnectedServers().empty() == true); + + cleanupTestConfigFile(path); +} + +// ============================================================================ +// TI_CLIENT_009: ListAllTools aggregates +// ============================================================================ + +TEST_CASE("TI_CLIENT_009_ListAllToolsAggregates", "[mcp][client]") { + MCPClient client; + bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); + + if (loaded) { + client.connectAll(); + auto tools = client.listAllTools(); + + // Should have tools from mock server + REQUIRE(tools.size() >= 0); + + client.disconnectAll(); + } else { + SUCCEED(); + } +} + +// ============================================================================ +// TI_CLIENT_010: Tool name prefixed +// ============================================================================ + +TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") { + MCPClient client; + bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); + + if (loaded) { + client.connectAll(); + auto tools = client.listAllTools(); + + bool hasPrefix = false; + for (const auto& tool : tools) { + if (tool.name.find(":") != std::string::npos) { + hasPrefix = true; + break; + } + } + + if (!tools.empty()) { + REQUIRE(hasPrefix == true); + } + + client.disconnectAll(); + } else { + SUCCEED(); + } +} + +// ============================================================================ +// TI_CLIENT_011: CallTool routes to server +// ============================================================================ + +TEST_CASE("TI_CLIENT_011_CallToolRoutesToServer", "[mcp][client]") { + MCPClient client; + bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); + + if (loaded) { + client.connectAll(); + auto tools = client.listAllTools(); + + if (!tools.empty()) { + // Call the first available tool + auto result = client.callTool(tools[0].name, json::object()); + // Should get some result (success or error) + REQUIRE(result.content.size() >= 0); + } + + client.disconnectAll(); + } else { + SUCCEED(); + } +} + +// ============================================================================ +// TI_CLIENT_012: CallTool invalid name +// ============================================================================ + +TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") { + MCPClient client; + client.loadConfig("tests/fixtures/mock_mcp.json"); + client.connectAll(); + + auto result = client.callTool("nonexistent:tool", json::object()); + + REQUIRE(result.isError == true); + + client.disconnectAll(); +} + +// ============================================================================ +// TI_CLIENT_013: CallTool disconnected server +// ============================================================================ + +TEST_CASE("TI_CLIENT_013_CallToolDisconnectedServer", "[mcp][client]") { + MCPClient client; + // Don't connect any servers + + auto result = client.callTool("server:tool", json::object()); + + REQUIRE(result.isError == true); +} + +// ============================================================================ +// TI_CLIENT_014: ToolCount accurate +// ============================================================================ + +TEST_CASE("TI_CLIENT_014_ToolCountAccurate", "[mcp][client]") { + MCPClient client; + bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); + + if (loaded) { + client.connectAll(); + + size_t count = client.toolCount(); + auto tools = client.listAllTools(); + + REQUIRE(count == tools.size()); + + client.disconnectAll(); + } else { + SUCCEED(); + } +} + +// ============================================================================ +// TI_CLIENT_015: IsConnected accurate +// ============================================================================ + +TEST_CASE("TI_CLIENT_015_IsConnectedAccurate", "[mcp][client]") { + json config = { + {"servers", { + {"test_server", { + {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, + {"args", json::array({"tests/fixtures/echo_server.py"})}, + {"enabled", true} + }} + }} + }; + auto path = createTestConfigFile(config); + + MCPClient client; + client.loadConfig(path); + + // Not connected yet + REQUIRE(client.isConnected("test_server") == false); + + // Connect + client.connect("test_server"); + REQUIRE(client.isConnected("test_server") == true); + + // Disconnect + client.disconnect("test_server"); + REQUIRE(client.isConnected("test_server") == false); + + cleanupTestConfigFile(path); +} diff --git a/tests/mcp/MCPTypesTests.cpp b/tests/mcp/MCPTypesTests.cpp index 4a8bbf9..227b3f4 100644 --- a/tests/mcp/MCPTypesTests.cpp +++ b/tests/mcp/MCPTypesTests.cpp @@ -1,298 +1,298 @@ -/** - * @file MCPTypesTests.cpp - * @brief Integration tests for MCP Types (15 TI) - */ - -#include -#include "shared/mcp/MCPTypes.hpp" - -using namespace aissia::mcp; -using json = nlohmann::json; - -// ============================================================================ -// TI_TYPES_001: MCPTool toJson -// ============================================================================ - -TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") { - MCPTool tool; - tool.name = "read_file"; - tool.description = "Read a file from the filesystem"; - tool.inputSchema = { - {"type", "object"}, - {"properties", { - {"path", {{"type", "string"}}} - }}, - {"required", json::array({"path"})} - }; - - json j = tool.toJson(); - - REQUIRE(j["name"] == "read_file"); - REQUIRE(j["description"] == "Read a file from the filesystem"); - REQUIRE(j["inputSchema"]["type"] == "object"); - REQUIRE(j["inputSchema"]["properties"]["path"]["type"] == "string"); -} - -// ============================================================================ -// TI_TYPES_002: MCPTool fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_002_MCPToolFromJson", "[mcp][types]") { - json j = { - {"name", "write_file"}, - {"description", "Write content to a file"}, - {"inputSchema", { - {"type", "object"}, - {"properties", { - {"path", {{"type", "string"}}}, - {"content", {{"type", "string"}}} - }} - }} - }; - - auto tool = MCPTool::fromJson(j); - - REQUIRE(tool.name == "write_file"); - REQUIRE(tool.description == "Write content to a file"); - REQUIRE(tool.inputSchema["type"] == "object"); -} - -// ============================================================================ -// TI_TYPES_003: MCPTool fromJson with missing fields -// ============================================================================ - -TEST_CASE("TI_TYPES_003_MCPToolFromJsonMissingFields", "[mcp][types]") { - json j = {{"name", "minimal_tool"}}; - - auto tool = MCPTool::fromJson(j); - - REQUIRE(tool.name == "minimal_tool"); - REQUIRE(tool.description == ""); - REQUIRE(tool.inputSchema.is_object()); -} - -// ============================================================================ -// TI_TYPES_004: MCPResource fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_004_MCPResourceFromJson", "[mcp][types]") { - json j = { - {"uri", "file:///home/user/doc.txt"}, - {"name", "Document"}, - {"description", "A text document"}, - {"mimeType", "text/plain"} - }; - - auto resource = MCPResource::fromJson(j); - - REQUIRE(resource.uri == "file:///home/user/doc.txt"); - REQUIRE(resource.name == "Document"); - REQUIRE(resource.description == "A text document"); - REQUIRE(resource.mimeType == "text/plain"); -} - -// ============================================================================ -// TI_TYPES_005: MCPToolResult toJson -// ============================================================================ - -TEST_CASE("TI_TYPES_005_MCPToolResultToJson", "[mcp][types]") { - MCPToolResult result; - result.content = { - {{"type", "text"}, {"text", "File contents here"}}, - {{"type", "text"}, {"text", "More content"}} - }; - result.isError = false; - - json j = result.toJson(); - - REQUIRE(j["content"].size() == 2); - REQUIRE(j["content"][0]["type"] == "text"); - REQUIRE(j["isError"] == false); -} - -// ============================================================================ -// TI_TYPES_006: MCPCapabilities fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_006_MCPCapabilitiesFromJson", "[mcp][types]") { - json j = { - {"tools", json::object()}, - {"resources", {{"subscribe", true}}}, - {"prompts", json::object()} - }; - - auto caps = MCPCapabilities::fromJson(j); - - REQUIRE(caps.hasTools == true); - REQUIRE(caps.hasResources == true); - REQUIRE(caps.hasPrompts == true); -} - -// ============================================================================ -// TI_TYPES_007: MCPCapabilities empty -// ============================================================================ - -TEST_CASE("TI_TYPES_007_MCPCapabilitiesEmpty", "[mcp][types]") { - json j = json::object(); - - auto caps = MCPCapabilities::fromJson(j); - - REQUIRE(caps.hasTools == false); - REQUIRE(caps.hasResources == false); - REQUIRE(caps.hasPrompts == false); -} - -// ============================================================================ -// TI_TYPES_008: MCPServerInfo fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_008_MCPServerInfoFromJson", "[mcp][types]") { - json j = { - {"name", "filesystem-server"}, - {"version", "1.2.3"}, - {"capabilities", { - {"tools", json::object()} - }} - }; - - auto info = MCPServerInfo::fromJson(j); - - REQUIRE(info.name == "filesystem-server"); - REQUIRE(info.version == "1.2.3"); - REQUIRE(info.capabilities.hasTools == true); -} - -// ============================================================================ -// TI_TYPES_009: JsonRpcRequest toJson -// ============================================================================ - -TEST_CASE("TI_TYPES_009_JsonRpcRequestToJson", "[mcp][types]") { - JsonRpcRequest request; - request.id = 42; - request.method = "tools/call"; - request.params = {{"name", "read_file"}, {"arguments", {{"path", "/tmp/test"}}}}; - - json j = request.toJson(); - - REQUIRE(j["jsonrpc"] == "2.0"); - REQUIRE(j["id"] == 42); - REQUIRE(j["method"] == "tools/call"); - REQUIRE(j["params"]["name"] == "read_file"); -} - -// ============================================================================ -// TI_TYPES_010: JsonRpcResponse fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_010_JsonRpcResponseFromJson", "[mcp][types]") { - json j = { - {"jsonrpc", "2.0"}, - {"id", 42}, - {"result", {{"tools", json::array()}}} - }; - - auto response = JsonRpcResponse::fromJson(j); - - REQUIRE(response.jsonrpc == "2.0"); - REQUIRE(response.id == 42); - REQUIRE(response.result.has_value()); - REQUIRE(response.result.value()["tools"].is_array()); -} - -// ============================================================================ -// TI_TYPES_011: JsonRpcResponse isError -// ============================================================================ - -TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") { - json errorJson = { - {"jsonrpc", "2.0"}, - {"id", 1}, - {"error", {{"code", -32600}, {"message", "Invalid Request"}}} - }; - - auto response = JsonRpcResponse::fromJson(errorJson); - - REQUIRE(response.isError() == true); - REQUIRE(response.error.has_value()); - REQUIRE(response.error.value()["code"] == -32600); - REQUIRE(response.error.value()["message"] == "Invalid Request"); -} - -// ============================================================================ -// TI_TYPES_012: MCPServerConfig fromJson -// ============================================================================ - -TEST_CASE("TI_TYPES_012_MCPServerConfigFromJson", "[mcp][types]") { - json j = { - {"command", "mcp-server-filesystem"}, - {"args", json::array({"--root", "/home"})}, - {"env", {{"DEBUG", "true"}}}, - {"enabled", true} - }; - - auto config = MCPServerConfig::fromJson("filesystem", j); - - REQUIRE(config.name == "filesystem"); - REQUIRE(config.command == "mcp-server-filesystem"); - REQUIRE(config.args.size() == 2); - REQUIRE(config.args[0] == "--root"); - REQUIRE(config.args[1] == "/home"); - REQUIRE(config.env["DEBUG"] == "true"); - REQUIRE(config.enabled == true); -} - -// ============================================================================ -// TI_TYPES_013: MCPServerConfig env expansion -// ============================================================================ - -TEST_CASE("TI_TYPES_013_MCPServerConfigEnvExpansion", "[mcp][types]") { - json j = { - {"command", "mcp-server"}, - {"env", {{"API_KEY", "${MY_API_KEY}"}}} - }; - - auto config = MCPServerConfig::fromJson("test", j); - - // Note: Actual env expansion happens in MCPClient, not in fromJson - // This test verifies the raw value is stored - REQUIRE(config.env["API_KEY"] == "${MY_API_KEY}"); -} - -// ============================================================================ -// TI_TYPES_014: MCPServerConfig disabled -// ============================================================================ - -TEST_CASE("TI_TYPES_014_MCPServerConfigDisabled", "[mcp][types]") { - json j = { - {"command", "some-server"}, - {"enabled", false} - }; - - auto config = MCPServerConfig::fromJson("disabled_server", j); - - REQUIRE(config.enabled == false); -} - -// ============================================================================ -// TI_TYPES_015: JsonRpcRequest ID increment -// ============================================================================ - -TEST_CASE("TI_TYPES_015_JsonRpcRequestIdIncrement", "[mcp][types]") { - JsonRpcRequest req1; - req1.id = 1; - req1.method = "test"; - - JsonRpcRequest req2; - req2.id = 2; - req2.method = "test"; - - // IDs should be different - REQUIRE(req1.id != req2.id); - - // Both should serialize correctly - json j1 = req1.toJson(); - json j2 = req2.toJson(); - - REQUIRE(j1["id"] == 1); - REQUIRE(j2["id"] == 2); -} +/** + * @file MCPTypesTests.cpp + * @brief Integration tests for MCP Types (15 TI) + */ + +#include +#include "shared/mcp/MCPTypes.hpp" + +using namespace aissia::mcp; +using json = nlohmann::json; + +// ============================================================================ +// TI_TYPES_001: MCPTool toJson +// ============================================================================ + +TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") { + MCPTool tool; + tool.name = "read_file"; + tool.description = "Read a file from the filesystem"; + tool.inputSchema = { + {"type", "object"}, + {"properties", { + {"path", {{"type", "string"}}} + }}, + {"required", json::array({"path"})} + }; + + json j = tool.toJson(); + + REQUIRE(j["name"] == "read_file"); + REQUIRE(j["description"] == "Read a file from the filesystem"); + REQUIRE(j["inputSchema"]["type"] == "object"); + REQUIRE(j["inputSchema"]["properties"]["path"]["type"] == "string"); +} + +// ============================================================================ +// TI_TYPES_002: MCPTool fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_002_MCPToolFromJson", "[mcp][types]") { + json j = { + {"name", "write_file"}, + {"description", "Write content to a file"}, + {"inputSchema", { + {"type", "object"}, + {"properties", { + {"path", {{"type", "string"}}}, + {"content", {{"type", "string"}}} + }} + }} + }; + + auto tool = MCPTool::fromJson(j); + + REQUIRE(tool.name == "write_file"); + REQUIRE(tool.description == "Write content to a file"); + REQUIRE(tool.inputSchema["type"] == "object"); +} + +// ============================================================================ +// TI_TYPES_003: MCPTool fromJson with missing fields +// ============================================================================ + +TEST_CASE("TI_TYPES_003_MCPToolFromJsonMissingFields", "[mcp][types]") { + json j = {{"name", "minimal_tool"}}; + + auto tool = MCPTool::fromJson(j); + + REQUIRE(tool.name == "minimal_tool"); + REQUIRE(tool.description == ""); + REQUIRE(tool.inputSchema.is_object()); +} + +// ============================================================================ +// TI_TYPES_004: MCPResource fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_004_MCPResourceFromJson", "[mcp][types]") { + json j = { + {"uri", "file:///home/user/doc.txt"}, + {"name", "Document"}, + {"description", "A text document"}, + {"mimeType", "text/plain"} + }; + + auto resource = MCPResource::fromJson(j); + + REQUIRE(resource.uri == "file:///home/user/doc.txt"); + REQUIRE(resource.name == "Document"); + REQUIRE(resource.description == "A text document"); + REQUIRE(resource.mimeType == "text/plain"); +} + +// ============================================================================ +// TI_TYPES_005: MCPToolResult toJson +// ============================================================================ + +TEST_CASE("TI_TYPES_005_MCPToolResultToJson", "[mcp][types]") { + MCPToolResult result; + result.content = { + {{"type", "text"}, {"text", "File contents here"}}, + {{"type", "text"}, {"text", "More content"}} + }; + result.isError = false; + + json j = result.toJson(); + + REQUIRE(j["content"].size() == 2); + REQUIRE(j["content"][0]["type"] == "text"); + REQUIRE(j["isError"] == false); +} + +// ============================================================================ +// TI_TYPES_006: MCPCapabilities fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_006_MCPCapabilitiesFromJson", "[mcp][types]") { + json j = { + {"tools", json::object()}, + {"resources", {{"subscribe", true}}}, + {"prompts", json::object()} + }; + + auto caps = MCPCapabilities::fromJson(j); + + REQUIRE(caps.hasTools == true); + REQUIRE(caps.hasResources == true); + REQUIRE(caps.hasPrompts == true); +} + +// ============================================================================ +// TI_TYPES_007: MCPCapabilities empty +// ============================================================================ + +TEST_CASE("TI_TYPES_007_MCPCapabilitiesEmpty", "[mcp][types]") { + json j = json::object(); + + auto caps = MCPCapabilities::fromJson(j); + + REQUIRE(caps.hasTools == false); + REQUIRE(caps.hasResources == false); + REQUIRE(caps.hasPrompts == false); +} + +// ============================================================================ +// TI_TYPES_008: MCPServerInfo fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_008_MCPServerInfoFromJson", "[mcp][types]") { + json j = { + {"name", "filesystem-server"}, + {"version", "1.2.3"}, + {"capabilities", { + {"tools", json::object()} + }} + }; + + auto info = MCPServerInfo::fromJson(j); + + REQUIRE(info.name == "filesystem-server"); + REQUIRE(info.version == "1.2.3"); + REQUIRE(info.capabilities.hasTools == true); +} + +// ============================================================================ +// TI_TYPES_009: JsonRpcRequest toJson +// ============================================================================ + +TEST_CASE("TI_TYPES_009_JsonRpcRequestToJson", "[mcp][types]") { + JsonRpcRequest request; + request.id = 42; + request.method = "tools/call"; + request.params = {{"name", "read_file"}, {"arguments", {{"path", "/tmp/test"}}}}; + + json j = request.toJson(); + + REQUIRE(j["jsonrpc"] == "2.0"); + REQUIRE(j["id"] == 42); + REQUIRE(j["method"] == "tools/call"); + REQUIRE(j["params"]["name"] == "read_file"); +} + +// ============================================================================ +// TI_TYPES_010: JsonRpcResponse fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_010_JsonRpcResponseFromJson", "[mcp][types]") { + json j = { + {"jsonrpc", "2.0"}, + {"id", 42}, + {"result", {{"tools", json::array()}}} + }; + + auto response = JsonRpcResponse::fromJson(j); + + REQUIRE(response.jsonrpc == "2.0"); + REQUIRE(response.id == 42); + REQUIRE(response.result.has_value()); + REQUIRE(response.result.value()["tools"].is_array()); +} + +// ============================================================================ +// TI_TYPES_011: JsonRpcResponse isError +// ============================================================================ + +TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") { + json errorJson = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"error", {{"code", -32600}, {"message", "Invalid Request"}}} + }; + + auto response = JsonRpcResponse::fromJson(errorJson); + + REQUIRE(response.isError() == true); + REQUIRE(response.error.has_value()); + REQUIRE(response.error.value()["code"] == -32600); + REQUIRE(response.error.value()["message"] == "Invalid Request"); +} + +// ============================================================================ +// TI_TYPES_012: MCPServerConfig fromJson +// ============================================================================ + +TEST_CASE("TI_TYPES_012_MCPServerConfigFromJson", "[mcp][types]") { + json j = { + {"command", "mcp-server-filesystem"}, + {"args", json::array({"--root", "/home"})}, + {"env", {{"DEBUG", "true"}}}, + {"enabled", true} + }; + + auto config = MCPServerConfig::fromJson("filesystem", j); + + REQUIRE(config.name == "filesystem"); + REQUIRE(config.command == "mcp-server-filesystem"); + REQUIRE(config.args.size() == 2); + REQUIRE(config.args[0] == "--root"); + REQUIRE(config.args[1] == "/home"); + REQUIRE(config.env["DEBUG"] == "true"); + REQUIRE(config.enabled == true); +} + +// ============================================================================ +// TI_TYPES_013: MCPServerConfig env expansion +// ============================================================================ + +TEST_CASE("TI_TYPES_013_MCPServerConfigEnvExpansion", "[mcp][types]") { + json j = { + {"command", "mcp-server"}, + {"env", {{"API_KEY", "${MY_API_KEY}"}}} + }; + + auto config = MCPServerConfig::fromJson("test", j); + + // Note: Actual env expansion happens in MCPClient, not in fromJson + // This test verifies the raw value is stored + REQUIRE(config.env["API_KEY"] == "${MY_API_KEY}"); +} + +// ============================================================================ +// TI_TYPES_014: MCPServerConfig disabled +// ============================================================================ + +TEST_CASE("TI_TYPES_014_MCPServerConfigDisabled", "[mcp][types]") { + json j = { + {"command", "some-server"}, + {"enabled", false} + }; + + auto config = MCPServerConfig::fromJson("disabled_server", j); + + REQUIRE(config.enabled == false); +} + +// ============================================================================ +// TI_TYPES_015: JsonRpcRequest ID increment +// ============================================================================ + +TEST_CASE("TI_TYPES_015_JsonRpcRequestIdIncrement", "[mcp][types]") { + JsonRpcRequest req1; + req1.id = 1; + req1.method = "test"; + + JsonRpcRequest req2; + req2.id = 2; + req2.method = "test"; + + // IDs should be different + REQUIRE(req1.id != req2.id); + + // Both should serialize correctly + json j1 = req1.toJson(); + json j2 = req2.toJson(); + + REQUIRE(j1["id"] == 1); + REQUIRE(j2["id"] == 2); +} diff --git a/tests/mcp/StdioTransportTests.cpp b/tests/mcp/StdioTransportTests.cpp index f5c0e1d..107d755 100644 --- a/tests/mcp/StdioTransportTests.cpp +++ b/tests/mcp/StdioTransportTests.cpp @@ -1,445 +1,445 @@ -/** - * @file StdioTransportTests.cpp - * @brief Integration tests for StdioTransport (20 TI) - */ - -#include -#include "shared/mcp/StdioTransport.hpp" -#include "shared/mcp/MCPTypes.hpp" - -#include -#include - -using namespace aissia::mcp; -using json = nlohmann::json; - -// ============================================================================ -// Helper: Create config for echo server -// ============================================================================ - -MCPServerConfig makeEchoServerConfig() { - MCPServerConfig config; - config.name = "echo"; - config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; - config.args = {"tests/fixtures/echo_server.py"}; - config.enabled = true; - return config; -} - -MCPServerConfig makeMockMCPServerConfig() { - MCPServerConfig config; - config.name = "mock_mcp"; - config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; - config.args = {"tests/fixtures/mock_mcp_server.py"}; - config.enabled = true; - return config; -} - -// ============================================================================ -// TI_TRANSPORT_001: Start spawns process -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_001_StartSpawnsProcess", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - bool started = transport.start(); - - REQUIRE(started == true); - REQUIRE(transport.isRunning() == true); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_002: Start fails with invalid command -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_002_StartFailsInvalidCommand", "[mcp][transport]") { - MCPServerConfig config; - config.name = "invalid"; - config.command = "nonexistent_command_xyz"; - config.enabled = true; - - StdioTransport transport(config); - - bool started = transport.start(); - - REQUIRE(started == false); - REQUIRE(transport.isRunning() == false); -} - -// ============================================================================ -// TI_TRANSPORT_003: Stop kills process -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_003_StopKillsProcess", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - transport.start(); - REQUIRE(transport.isRunning() == true); - - transport.stop(); - REQUIRE(transport.isRunning() == false); -} - -// ============================================================================ -// TI_TRANSPORT_004: IsRunning reflects state -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_004_IsRunningReflectsState", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - REQUIRE(transport.isRunning() == false); - - transport.start(); - REQUIRE(transport.isRunning() == true); - - transport.stop(); - REQUIRE(transport.isRunning() == false); -} - -// ============================================================================ -// TI_TRANSPORT_005: SendRequest writes to stdin -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_005_SendRequestWritesToStdin", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - request.params = {{"message", "hello"}}; - - // Echo server will echo back params as result - auto response = transport.sendRequest(request, 5000); - - // If we got a response, the request was written - REQUIRE(response.isError() == false); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_006: SendRequest reads response -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - JsonRpcRequest request; - request.id = 42; - request.method = "echo"; - request.params = {{"value", 123}}; - - auto response = transport.sendRequest(request, 5000); - - REQUIRE(response.isError() == false); - REQUIRE(response.id == 42); - REQUIRE(response.result.has_value()); - REQUIRE(response.result.value()["value"] == 123); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_007: SendRequest timeout -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") { - // Use cat which doesn't respond to JSON-RPC - MCPServerConfig config; - config.name = "cat"; - config.command = "cat"; - config.enabled = true; - - StdioTransport transport(config); - transport.start(); - - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - - // Very short timeout - auto response = transport.sendRequest(request, 100); - - // Should timeout and return error - REQUIRE(response.isError() == true); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_008: SendRequest ID matching -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_008_SendRequestIdMatching", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - // Send request with specific ID - JsonRpcRequest request; - request.id = 999; - request.method = "test"; - - auto response = transport.sendRequest(request, 5000); - - // Response ID should match request ID - REQUIRE(response.id == 999); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_009: Concurrent requests -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_009_ConcurrentRequests", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - std::vector threads; - std::vector results(5, false); - - for (int i = 0; i < 5; i++) { - threads.emplace_back([&transport, &results, i]() { - JsonRpcRequest request; - request.id = 100 + i; - request.method = "test"; - request.params = {{"index", i}}; - - auto response = transport.sendRequest(request, 5000); - results[i] = !response.isError() && response.id == 100 + i; - }); - } - - for (auto& t : threads) { - t.join(); - } - - // All requests should succeed - for (bool result : results) { - REQUIRE(result == true); - } - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_010: SendNotification no response -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_010_SendNotificationNoResponse", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - // Should not block or throw - REQUIRE_NOTHROW(transport.sendNotification("notification/test", {{"data", "value"}})); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_011: Reader thread starts on start -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_011_ReaderThreadStartsOnStart", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - transport.start(); - - // If reader thread didn't start, sendRequest would hang - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - - auto response = transport.sendRequest(request, 1000); - - // Got response means reader thread is working - REQUIRE(response.isError() == false); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_012: Reader thread stops on stop -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_012_ReaderThreadStopsOnStop", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - transport.start(); - transport.stop(); - - // Should not hang or crash on destruction - SUCCEED(); -} - -// ============================================================================ -// TI_TRANSPORT_013: JSON parse error handled -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_013_JsonParseErrorHandled", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - // Send valid request - server will respond with valid JSON - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - - // Should not crash even if server sends invalid JSON - REQUIRE_NOTHROW(transport.sendRequest(request, 1000)); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_014: Process crash detected -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_014_ProcessCrashDetected", "[mcp][transport]") { - // TODO: Need a server that crashes to test this - // For now, just verify we can handle stop - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - transport.start(); - transport.stop(); - - REQUIRE(transport.isRunning() == false); -} - -// ============================================================================ -// TI_TRANSPORT_015: Large message handling -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_015_LargeMessageHandling", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - // Create large params - std::string largeString(10000, 'x'); - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - request.params = {{"data", largeString}}; - - auto response = transport.sendRequest(request, 10000); - - REQUIRE(response.isError() == false); - REQUIRE(response.result.value()["data"] == largeString); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_016: Multiline JSON handling -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_016_MultilineJsonHandling", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - - // JSON with newlines in strings should work - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - request.params = {{"text", "line1\nline2\nline3"}}; - - auto response = transport.sendRequest(request, 5000); - - REQUIRE(response.isError() == false); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_017: Env variables passed to process -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_017_EnvVariablesPassedToProcess", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - config.env["TEST_VAR"] = "test_value"; - - StdioTransport transport(config); - bool started = transport.start(); - - REQUIRE(started == true); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_018: Args passed to process -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_018_ArgsPassedToProcess", "[mcp][transport]") { - auto config = makeMockMCPServerConfig(); - // Args are already set in the helper function - - StdioTransport transport(config); - bool started = transport.start(); - - REQUIRE(started == true); - - transport.stop(); -} - -// ============================================================================ -// TI_TRANSPORT_019: Destructor cleans up -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_019_DestructorCleansUp", "[mcp][transport]") { - { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - transport.start(); - // Destructor called here - } - - // Should not leak resources or hang - SUCCEED(); -} - -// ============================================================================ -// TI_TRANSPORT_020: Restart after stop -// ============================================================================ - -TEST_CASE("TI_TRANSPORT_020_RestartAfterStop", "[mcp][transport]") { - auto config = makeEchoServerConfig(); - StdioTransport transport(config); - - // First start/stop - transport.start(); - transport.stop(); - REQUIRE(transport.isRunning() == false); - - // Second start - bool restarted = transport.start(); - REQUIRE(restarted == true); - REQUIRE(transport.isRunning() == true); - - // Verify it works - JsonRpcRequest request; - request.id = 1; - request.method = "test"; - auto response = transport.sendRequest(request, 5000); - REQUIRE(response.isError() == false); - - transport.stop(); -} +/** + * @file StdioTransportTests.cpp + * @brief Integration tests for StdioTransport (20 TI) + */ + +#include +#include "shared/mcp/StdioTransport.hpp" +#include "shared/mcp/MCPTypes.hpp" + +#include +#include + +using namespace aissia::mcp; +using json = nlohmann::json; + +// ============================================================================ +// Helper: Create config for echo server +// ============================================================================ + +MCPServerConfig makeEchoServerConfig() { + MCPServerConfig config; + config.name = "echo"; + config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; + config.args = {"tests/fixtures/echo_server.py"}; + config.enabled = true; + return config; +} + +MCPServerConfig makeMockMCPServerConfig() { + MCPServerConfig config; + config.name = "mock_mcp"; + config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; + config.args = {"tests/fixtures/mock_mcp_server.py"}; + config.enabled = true; + return config; +} + +// ============================================================================ +// TI_TRANSPORT_001: Start spawns process +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_001_StartSpawnsProcess", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + bool started = transport.start(); + + REQUIRE(started == true); + REQUIRE(transport.isRunning() == true); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_002: Start fails with invalid command +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_002_StartFailsInvalidCommand", "[mcp][transport]") { + MCPServerConfig config; + config.name = "invalid"; + config.command = "nonexistent_command_xyz"; + config.enabled = true; + + StdioTransport transport(config); + + bool started = transport.start(); + + REQUIRE(started == false); + REQUIRE(transport.isRunning() == false); +} + +// ============================================================================ +// TI_TRANSPORT_003: Stop kills process +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_003_StopKillsProcess", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + transport.start(); + REQUIRE(transport.isRunning() == true); + + transport.stop(); + REQUIRE(transport.isRunning() == false); +} + +// ============================================================================ +// TI_TRANSPORT_004: IsRunning reflects state +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_004_IsRunningReflectsState", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + REQUIRE(transport.isRunning() == false); + + transport.start(); + REQUIRE(transport.isRunning() == true); + + transport.stop(); + REQUIRE(transport.isRunning() == false); +} + +// ============================================================================ +// TI_TRANSPORT_005: SendRequest writes to stdin +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_005_SendRequestWritesToStdin", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + request.params = {{"message", "hello"}}; + + // Echo server will echo back params as result + auto response = transport.sendRequest(request, 5000); + + // If we got a response, the request was written + REQUIRE(response.isError() == false); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_006: SendRequest reads response +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + JsonRpcRequest request; + request.id = 42; + request.method = "echo"; + request.params = {{"value", 123}}; + + auto response = transport.sendRequest(request, 5000); + + REQUIRE(response.isError() == false); + REQUIRE(response.id == 42); + REQUIRE(response.result.has_value()); + REQUIRE(response.result.value()["value"] == 123); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_007: SendRequest timeout +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") { + // Use cat which doesn't respond to JSON-RPC + MCPServerConfig config; + config.name = "cat"; + config.command = "cat"; + config.enabled = true; + + StdioTransport transport(config); + transport.start(); + + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + + // Very short timeout + auto response = transport.sendRequest(request, 100); + + // Should timeout and return error + REQUIRE(response.isError() == true); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_008: SendRequest ID matching +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_008_SendRequestIdMatching", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + // Send request with specific ID + JsonRpcRequest request; + request.id = 999; + request.method = "test"; + + auto response = transport.sendRequest(request, 5000); + + // Response ID should match request ID + REQUIRE(response.id == 999); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_009: Concurrent requests +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_009_ConcurrentRequests", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + std::vector threads; + std::vector results(5, false); + + for (int i = 0; i < 5; i++) { + threads.emplace_back([&transport, &results, i]() { + JsonRpcRequest request; + request.id = 100 + i; + request.method = "test"; + request.params = {{"index", i}}; + + auto response = transport.sendRequest(request, 5000); + results[i] = !response.isError() && response.id == 100 + i; + }); + } + + for (auto& t : threads) { + t.join(); + } + + // All requests should succeed + for (bool result : results) { + REQUIRE(result == true); + } + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_010: SendNotification no response +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_010_SendNotificationNoResponse", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + // Should not block or throw + REQUIRE_NOTHROW(transport.sendNotification("notification/test", {{"data", "value"}})); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_011: Reader thread starts on start +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_011_ReaderThreadStartsOnStart", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + transport.start(); + + // If reader thread didn't start, sendRequest would hang + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + + auto response = transport.sendRequest(request, 1000); + + // Got response means reader thread is working + REQUIRE(response.isError() == false); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_012: Reader thread stops on stop +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_012_ReaderThreadStopsOnStop", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + transport.start(); + transport.stop(); + + // Should not hang or crash on destruction + SUCCEED(); +} + +// ============================================================================ +// TI_TRANSPORT_013: JSON parse error handled +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_013_JsonParseErrorHandled", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + // Send valid request - server will respond with valid JSON + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + + // Should not crash even if server sends invalid JSON + REQUIRE_NOTHROW(transport.sendRequest(request, 1000)); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_014: Process crash detected +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_014_ProcessCrashDetected", "[mcp][transport]") { + // TODO: Need a server that crashes to test this + // For now, just verify we can handle stop + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + transport.start(); + transport.stop(); + + REQUIRE(transport.isRunning() == false); +} + +// ============================================================================ +// TI_TRANSPORT_015: Large message handling +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_015_LargeMessageHandling", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + // Create large params + std::string largeString(10000, 'x'); + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + request.params = {{"data", largeString}}; + + auto response = transport.sendRequest(request, 10000); + + REQUIRE(response.isError() == false); + REQUIRE(response.result.value()["data"] == largeString); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_016: Multiline JSON handling +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_016_MultilineJsonHandling", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + + // JSON with newlines in strings should work + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + request.params = {{"text", "line1\nline2\nline3"}}; + + auto response = transport.sendRequest(request, 5000); + + REQUIRE(response.isError() == false); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_017: Env variables passed to process +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_017_EnvVariablesPassedToProcess", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + config.env["TEST_VAR"] = "test_value"; + + StdioTransport transport(config); + bool started = transport.start(); + + REQUIRE(started == true); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_018: Args passed to process +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_018_ArgsPassedToProcess", "[mcp][transport]") { + auto config = makeMockMCPServerConfig(); + // Args are already set in the helper function + + StdioTransport transport(config); + bool started = transport.start(); + + REQUIRE(started == true); + + transport.stop(); +} + +// ============================================================================ +// TI_TRANSPORT_019: Destructor cleans up +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_019_DestructorCleansUp", "[mcp][transport]") { + { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + transport.start(); + // Destructor called here + } + + // Should not leak resources or hang + SUCCEED(); +} + +// ============================================================================ +// TI_TRANSPORT_020: Restart after stop +// ============================================================================ + +TEST_CASE("TI_TRANSPORT_020_RestartAfterStop", "[mcp][transport]") { + auto config = makeEchoServerConfig(); + StdioTransport transport(config); + + // First start/stop + transport.start(); + transport.stop(); + REQUIRE(transport.isRunning() == false); + + // Second start + bool restarted = transport.start(); + REQUIRE(restarted == true); + REQUIRE(transport.isRunning() == true); + + // Verify it works + JsonRpcRequest request; + request.id = 1; + request.method = "test"; + auto response = transport.sendRequest(request, 5000); + REQUIRE(response.isError() == false); + + transport.stop(); +} diff --git a/tests/mocks/MockIO.cpp b/tests/mocks/MockIO.cpp index 9873315..85b63a1 100644 --- a/tests/mocks/MockIO.cpp +++ b/tests/mocks/MockIO.cpp @@ -1,88 +1,88 @@ -#include "MockIO.hpp" - -namespace aissia::tests { - -void MockIO::publish(const std::string& topic, std::unique_ptr data) { - // Convert IDataNode to JSON for easy verification - json jsonData; - - if (data) { - // Try to extract JSON from JsonDataNode - auto* jsonNode = dynamic_cast(data.get()); - if (jsonNode) { - jsonData = jsonNode->getJsonData(); - } else { - // Fallback: create basic JSON from IDataNode interface - jsonData = json::object(); - } - } - - m_publishedMessages.emplace_back(topic, jsonData); -} - -grove::Message MockIO::pullMessage() { - if (m_incomingMessages.empty()) { - throw std::runtime_error("No messages available"); - } - - grove::Message msg = std::move(m_incomingMessages.front()); - m_incomingMessages.pop(); - return msg; -} - -void MockIO::injectMessage(const std::string& topic, const json& data) { - grove::Message message; - message.topic = topic; - message.data = std::make_unique("data", data); - message.timestamp = 0; - m_incomingMessages.push(std::move(message)); -} - -void MockIO::injectMessages(const std::vector>& messages) { - for (const auto& [topic, data] : messages) { - injectMessage(topic, data); - } -} - -bool MockIO::wasPublished(const std::string& topic) const { - return std::any_of(m_publishedMessages.begin(), m_publishedMessages.end(), - [&topic](const auto& msg) { return msg.first == topic; }); -} - -json MockIO::getLastPublished(const std::string& topic) const { - for (auto it = m_publishedMessages.rbegin(); it != m_publishedMessages.rend(); ++it) { - if (it->first == topic) { - return it->second; - } - } - return json::object(); -} - -std::vector MockIO::getAllPublished(const std::string& topic) const { - std::vector result; - for (const auto& [t, data] : m_publishedMessages) { - if (t == topic) { - result.push_back(data); - } - } - return result; -} - -size_t MockIO::countPublished(const std::string& topic) const { - return std::count_if(m_publishedMessages.begin(), m_publishedMessages.end(), - [&topic](const auto& msg) { return msg.first == topic; }); -} - -void MockIO::clear() { - m_publishedMessages.clear(); - while (!m_incomingMessages.empty()) { - m_incomingMessages.pop(); - } - m_subscriptions.clear(); -} - -void MockIO::clearPublished() { - m_publishedMessages.clear(); -} - -} // namespace aissia::tests +#include "MockIO.hpp" + +namespace aissia::tests { + +void MockIO::publish(const std::string& topic, std::unique_ptr data) { + // Convert IDataNode to JSON for easy verification + json jsonData; + + if (data) { + // Try to extract JSON from JsonDataNode + auto* jsonNode = dynamic_cast(data.get()); + if (jsonNode) { + jsonData = jsonNode->getJsonData(); + } else { + // Fallback: create basic JSON from IDataNode interface + jsonData = json::object(); + } + } + + m_publishedMessages.emplace_back(topic, jsonData); +} + +grove::Message MockIO::pullMessage() { + if (m_incomingMessages.empty()) { + throw std::runtime_error("No messages available"); + } + + grove::Message msg = std::move(m_incomingMessages.front()); + m_incomingMessages.pop(); + return msg; +} + +void MockIO::injectMessage(const std::string& topic, const json& data) { + grove::Message message; + message.topic = topic; + message.data = std::make_unique("data", data); + message.timestamp = 0; + m_incomingMessages.push(std::move(message)); +} + +void MockIO::injectMessages(const std::vector>& messages) { + for (const auto& [topic, data] : messages) { + injectMessage(topic, data); + } +} + +bool MockIO::wasPublished(const std::string& topic) const { + return std::any_of(m_publishedMessages.begin(), m_publishedMessages.end(), + [&topic](const auto& msg) { return msg.first == topic; }); +} + +json MockIO::getLastPublished(const std::string& topic) const { + for (auto it = m_publishedMessages.rbegin(); it != m_publishedMessages.rend(); ++it) { + if (it->first == topic) { + return it->second; + } + } + return json::object(); +} + +std::vector MockIO::getAllPublished(const std::string& topic) const { + std::vector result; + for (const auto& [t, data] : m_publishedMessages) { + if (t == topic) { + result.push_back(data); + } + } + return result; +} + +size_t MockIO::countPublished(const std::string& topic) const { + return std::count_if(m_publishedMessages.begin(), m_publishedMessages.end(), + [&topic](const auto& msg) { return msg.first == topic; }); +} + +void MockIO::clear() { + m_publishedMessages.clear(); + while (!m_incomingMessages.empty()) { + m_incomingMessages.pop(); + } + m_subscriptions.clear(); +} + +void MockIO::clearPublished() { + m_publishedMessages.clear(); +} + +} // namespace aissia::tests diff --git a/tests/mocks/MockIO.hpp b/tests/mocks/MockIO.hpp index c119878..d0967e7 100644 --- a/tests/mocks/MockIO.hpp +++ b/tests/mocks/MockIO.hpp @@ -1,129 +1,129 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace aissia::tests { - -using json = nlohmann::json; - -/** - * @brief Mock implementation of grove::IIO for testing - * - * Captures published messages and allows injecting incoming messages. - */ -class MockIO : public grove::IIO { -public: - // ======================================================================== - // IIO Interface Implementation - // ======================================================================== - - void publish(const std::string& topic, std::unique_ptr data) override; - - void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { - // Mock: just record subscription - m_subscriptions.push_back(topicPattern); - } - - void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { - // Mock: same as subscribe - m_subscriptions.push_back(topicPattern); - } - - int hasMessages() const override { - return static_cast(m_incomingMessages.size()); - } - - grove::Message pullMessage() override; - - grove::IOHealth getHealth() const override { - return grove::IOHealth{ - .queueSize = static_cast(m_incomingMessages.size()), - .maxQueueSize = 1000, - .dropping = false, - .averageProcessingRate = 100.0f, - .droppedMessageCount = 0 - }; - } - - grove::IOType getType() const override { - return grove::IOType::INTRA; - } - - // ======================================================================== - // Test Helpers - Message Injection - // ======================================================================== - - /** - * @brief Inject a message to be received by the module under test - */ - void injectMessage(const std::string& topic, const json& data); - - /** - * @brief Inject multiple messages at once - */ - void injectMessages(const std::vector>& messages); - - // ======================================================================== - // Test Helpers - Verification - // ======================================================================== - - /** - * @brief Check if a message was published to a specific topic - */ - bool wasPublished(const std::string& topic) const; - - /** - * @brief Get the last message published to a topic - */ - json getLastPublished(const std::string& topic) const; - - /** - * @brief Get all messages published to a topic - */ - std::vector getAllPublished(const std::string& topic) const; - - /** - * @brief Count messages published to a topic - */ - size_t countPublished(const std::string& topic) const; - - /** - * @brief Get all published messages (topic -> data pairs) - */ - const std::vector>& getPublishedMessages() const { - return m_publishedMessages; - } - - /** - * @brief Clear all captured and pending messages - */ - void clear(); - - /** - * @brief Clear only published messages (keep incoming queue) - */ - void clearPublished(); - - // ======================================================================== - // Test State - // ======================================================================== - - /// All messages published by the module under test - std::vector> m_publishedMessages; - - /// Messages waiting to be received by the module - std::queue m_incomingMessages; - - /// Subscribed topic patterns (for verification) - std::vector m_subscriptions; -}; - -} // namespace aissia::tests +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace aissia::tests { + +using json = nlohmann::json; + +/** + * @brief Mock implementation of grove::IIO for testing + * + * Captures published messages and allows injecting incoming messages. + */ +class MockIO : public grove::IIO { +public: + // ======================================================================== + // IIO Interface Implementation + // ======================================================================== + + void publish(const std::string& topic, std::unique_ptr data) override; + + void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { + // Mock: just record subscription + m_subscriptions.push_back(topicPattern); + } + + void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { + // Mock: same as subscribe + m_subscriptions.push_back(topicPattern); + } + + int hasMessages() const override { + return static_cast(m_incomingMessages.size()); + } + + grove::Message pullMessage() override; + + grove::IOHealth getHealth() const override { + return grove::IOHealth{ + .queueSize = static_cast(m_incomingMessages.size()), + .maxQueueSize = 1000, + .dropping = false, + .averageProcessingRate = 100.0f, + .droppedMessageCount = 0 + }; + } + + grove::IOType getType() const override { + return grove::IOType::INTRA; + } + + // ======================================================================== + // Test Helpers - Message Injection + // ======================================================================== + + /** + * @brief Inject a message to be received by the module under test + */ + void injectMessage(const std::string& topic, const json& data); + + /** + * @brief Inject multiple messages at once + */ + void injectMessages(const std::vector>& messages); + + // ======================================================================== + // Test Helpers - Verification + // ======================================================================== + + /** + * @brief Check if a message was published to a specific topic + */ + bool wasPublished(const std::string& topic) const; + + /** + * @brief Get the last message published to a topic + */ + json getLastPublished(const std::string& topic) const; + + /** + * @brief Get all messages published to a topic + */ + std::vector getAllPublished(const std::string& topic) const; + + /** + * @brief Count messages published to a topic + */ + size_t countPublished(const std::string& topic) const; + + /** + * @brief Get all published messages (topic -> data pairs) + */ + const std::vector>& getPublishedMessages() const { + return m_publishedMessages; + } + + /** + * @brief Clear all captured and pending messages + */ + void clear(); + + /** + * @brief Clear only published messages (keep incoming queue) + */ + void clearPublished(); + + // ======================================================================== + // Test State + // ======================================================================== + + /// All messages published by the module under test + std::vector> m_publishedMessages; + + /// Messages waiting to be received by the module + std::queue m_incomingMessages; + + /// Subscribed topic patterns (for verification) + std::vector m_subscriptions; +}; + +} // namespace aissia::tests diff --git a/tests/mocks/MockTransport.hpp b/tests/mocks/MockTransport.hpp index 5813a2c..be0b500 100644 --- a/tests/mocks/MockTransport.hpp +++ b/tests/mocks/MockTransport.hpp @@ -1,192 +1,192 @@ -#pragma once - -#include "shared/mcp/MCPTransport.hpp" -#include "shared/mcp/MCPTypes.hpp" - -#include -#include -#include - -namespace aissia::tests { - -using namespace aissia::mcp; - -/** - * @brief Mock implementation of IMCPTransport for testing MCPClient - */ -class MockTransport : public IMCPTransport { -public: - // ======================================================================== - // IMCPTransport Interface - // ======================================================================== - - bool start() override { - if (m_startShouldFail) { - return false; - } - m_running = true; - return true; - } - - void stop() override { - m_running = false; - } - - bool isRunning() const override { - return m_running; - } - - JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs = 30000) override { - m_sentRequests.push_back(request); - - // If we have a custom handler, use it - if (m_requestHandler) { - return m_requestHandler(request); - } - - // Otherwise, use prepared responses - if (!m_preparedResponses.empty()) { - auto response = m_preparedResponses.front(); - m_preparedResponses.pop(); - response.id = request.id; // Match the request ID - return response; - } - - // Default: return error - JsonRpcResponse errorResponse; - errorResponse.id = request.id; - errorResponse.error = json{{"code", -32603}, {"message", "No prepared response"}}; - return errorResponse; - } - - void sendNotification(const std::string& method, const json& params) override { - m_sentNotifications.emplace_back(method, params); - } - - // ======================================================================== - // Test Configuration - // ======================================================================== - - /** - * @brief Make start() fail - */ - void setStartShouldFail(bool fail) { - m_startShouldFail = fail; - } - - /** - * @brief Add a response to be returned on next sendRequest - */ - void prepareResponse(const JsonRpcResponse& response) { - m_preparedResponses.push(response); - } - - /** - * @brief Prepare a successful response with result - */ - void prepareSuccessResponse(const json& result) { - JsonRpcResponse response; - response.result = result; - m_preparedResponses.push(response); - } - - /** - * @brief Prepare an error response - */ - void prepareErrorResponse(int code, const std::string& message) { - JsonRpcResponse response; - response.error = json{{"code", code}, {"message", message}}; - m_preparedResponses.push(response); - } - - /** - * @brief Set a custom handler for all requests - */ - void setRequestHandler(std::function handler) { - m_requestHandler = std::move(handler); - } - - /** - * @brief Simulate MCP server with initialize and tools/list - */ - void setupAsMCPServer(const std::string& serverName, const std::vector& tools) { - m_requestHandler = [serverName, tools](const JsonRpcRequest& req) -> JsonRpcResponse { - JsonRpcResponse resp; - resp.id = req.id; - - if (req.method == "initialize") { - resp.result = json{ - {"protocolVersion", "2024-11-05"}, - {"capabilities", {{"tools", json::object()}}}, - {"serverInfo", {{"name", serverName}, {"version", "1.0.0"}}} - }; - } else if (req.method == "tools/list") { - json toolsJson = json::array(); - for (const auto& tool : tools) { - toolsJson.push_back(tool.toJson()); - } - resp.result = json{{"tools", toolsJson}}; - } else if (req.method == "tools/call") { - resp.result = json{ - {"content", json::array({{{"type", "text"}, {"text", "Tool executed"}}})} - }; - } else { - resp.error = json{{"code", -32601}, {"message", "Method not found"}}; - } - - return resp; - }; - } - - // ======================================================================== - // Test Verification - // ======================================================================== - - /** - * @brief Get all sent requests - */ - const std::vector& getSentRequests() const { - return m_sentRequests; - } - - /** - * @brief Check if a method was called - */ - bool wasMethodCalled(const std::string& method) const { - return std::any_of(m_sentRequests.begin(), m_sentRequests.end(), - [&method](const auto& req) { return req.method == method; }); - } - - /** - * @brief Get count of calls to a method - */ - size_t countMethodCalls(const std::string& method) const { - return std::count_if(m_sentRequests.begin(), m_sentRequests.end(), - [&method](const auto& req) { return req.method == method; }); - } - - /** - * @brief Clear all state - */ - void clear() { - m_sentRequests.clear(); - m_sentNotifications.clear(); - while (!m_preparedResponses.empty()) { - m_preparedResponses.pop(); - } - m_requestHandler = nullptr; - } - - // ======================================================================== - // Test State - // ======================================================================== - - bool m_running = false; - bool m_startShouldFail = false; - std::vector m_sentRequests; - std::vector> m_sentNotifications; - std::queue m_preparedResponses; - std::function m_requestHandler; -}; - -} // namespace aissia::tests +#pragma once + +#include "shared/mcp/MCPTransport.hpp" +#include "shared/mcp/MCPTypes.hpp" + +#include +#include +#include + +namespace aissia::tests { + +using namespace aissia::mcp; + +/** + * @brief Mock implementation of IMCPTransport for testing MCPClient + */ +class MockTransport : public IMCPTransport { +public: + // ======================================================================== + // IMCPTransport Interface + // ======================================================================== + + bool start() override { + if (m_startShouldFail) { + return false; + } + m_running = true; + return true; + } + + void stop() override { + m_running = false; + } + + bool isRunning() const override { + return m_running; + } + + JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs = 30000) override { + m_sentRequests.push_back(request); + + // If we have a custom handler, use it + if (m_requestHandler) { + return m_requestHandler(request); + } + + // Otherwise, use prepared responses + if (!m_preparedResponses.empty()) { + auto response = m_preparedResponses.front(); + m_preparedResponses.pop(); + response.id = request.id; // Match the request ID + return response; + } + + // Default: return error + JsonRpcResponse errorResponse; + errorResponse.id = request.id; + errorResponse.error = json{{"code", -32603}, {"message", "No prepared response"}}; + return errorResponse; + } + + void sendNotification(const std::string& method, const json& params) override { + m_sentNotifications.emplace_back(method, params); + } + + // ======================================================================== + // Test Configuration + // ======================================================================== + + /** + * @brief Make start() fail + */ + void setStartShouldFail(bool fail) { + m_startShouldFail = fail; + } + + /** + * @brief Add a response to be returned on next sendRequest + */ + void prepareResponse(const JsonRpcResponse& response) { + m_preparedResponses.push(response); + } + + /** + * @brief Prepare a successful response with result + */ + void prepareSuccessResponse(const json& result) { + JsonRpcResponse response; + response.result = result; + m_preparedResponses.push(response); + } + + /** + * @brief Prepare an error response + */ + void prepareErrorResponse(int code, const std::string& message) { + JsonRpcResponse response; + response.error = json{{"code", code}, {"message", message}}; + m_preparedResponses.push(response); + } + + /** + * @brief Set a custom handler for all requests + */ + void setRequestHandler(std::function handler) { + m_requestHandler = std::move(handler); + } + + /** + * @brief Simulate MCP server with initialize and tools/list + */ + void setupAsMCPServer(const std::string& serverName, const std::vector& tools) { + m_requestHandler = [serverName, tools](const JsonRpcRequest& req) -> JsonRpcResponse { + JsonRpcResponse resp; + resp.id = req.id; + + if (req.method == "initialize") { + resp.result = json{ + {"protocolVersion", "2024-11-05"}, + {"capabilities", {{"tools", json::object()}}}, + {"serverInfo", {{"name", serverName}, {"version", "1.0.0"}}} + }; + } else if (req.method == "tools/list") { + json toolsJson = json::array(); + for (const auto& tool : tools) { + toolsJson.push_back(tool.toJson()); + } + resp.result = json{{"tools", toolsJson}}; + } else if (req.method == "tools/call") { + resp.result = json{ + {"content", json::array({{{"type", "text"}, {"text", "Tool executed"}}})} + }; + } else { + resp.error = json{{"code", -32601}, {"message", "Method not found"}}; + } + + return resp; + }; + } + + // ======================================================================== + // Test Verification + // ======================================================================== + + /** + * @brief Get all sent requests + */ + const std::vector& getSentRequests() const { + return m_sentRequests; + } + + /** + * @brief Check if a method was called + */ + bool wasMethodCalled(const std::string& method) const { + return std::any_of(m_sentRequests.begin(), m_sentRequests.end(), + [&method](const auto& req) { return req.method == method; }); + } + + /** + * @brief Get count of calls to a method + */ + size_t countMethodCalls(const std::string& method) const { + return std::count_if(m_sentRequests.begin(), m_sentRequests.end(), + [&method](const auto& req) { return req.method == method; }); + } + + /** + * @brief Clear all state + */ + void clear() { + m_sentRequests.clear(); + m_sentNotifications.clear(); + while (!m_preparedResponses.empty()) { + m_preparedResponses.pop(); + } + m_requestHandler = nullptr; + } + + // ======================================================================== + // Test State + // ======================================================================== + + bool m_running = false; + bool m_startShouldFail = false; + std::vector m_sentRequests; + std::vector> m_sentNotifications; + std::queue m_preparedResponses; + std::function m_requestHandler; +}; + +} // namespace aissia::tests diff --git a/tests/modules/AIModuleTests.cpp b/tests/modules/AIModuleTests.cpp index 651f9d7..3108116 100644 --- a/tests/modules/AIModuleTests.cpp +++ b/tests/modules/AIModuleTests.cpp @@ -1,293 +1,293 @@ -/** - * @file AIModuleTests.cpp - * @brief Integration tests for AIModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/AIModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class AITestFixture { -public: - MockIO io; - TimeSimulator time; - AIModule module; - - void configure(const json& config = json::object()) { - json fullConfig = { - {"system_prompt", "Tu es un assistant personnel intelligent."}, - {"max_iterations", 10} - }; - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } -}; - -// ============================================================================ -// TI_AI_001: Query Sends LLM Request -// ============================================================================ - -TEST_CASE("TI_AI_001_QuerySendsLLMRequest", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Send query - f.io.injectMessage("ai:query", {{"query", "Quelle heure est-il?"}}); - f.process(); - - // Verify LLM request published - REQUIRE(f.io.wasPublished("llm:request")); - auto msg = f.io.getLastPublished("llm:request"); - REQUIRE(msg["query"] == "Quelle heure est-il?"); -} - -// ============================================================================ -// TI_AI_002: Voice Transcription Triggers Query -// ============================================================================ - -TEST_CASE("TI_AI_002_VoiceTranscriptionTriggersQuery", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Send voice transcription - f.io.injectMessage("voice:transcription", { - {"text", "Aide-moi avec mon code"}, - {"confidence", 0.95} - }); - f.process(); - - // Verify LLM request - REQUIRE(f.io.wasPublished("llm:request")); - auto msg = f.io.getLastPublished("llm:request"); - REQUIRE(msg["query"] == "Aide-moi avec mon code"); -} - -// ============================================================================ -// TI_AI_003: LLM Response Handled -// ============================================================================ - -TEST_CASE("TI_AI_003_LLMResponseHandled", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Send query to set awaiting state - f.io.injectMessage("ai:query", {{"query", "Test"}}); - f.process(); - REQUIRE(f.module.isIdle() == false); - - // Receive response - f.io.injectMessage("llm:response", { - {"text", "Voici la reponse"}, - {"tokens", 100}, - {"conversationId", "default"} - }); - f.process(); - - // Verify no longer awaiting - REQUIRE(f.module.isIdle() == true); -} - -// ============================================================================ -// TI_AI_004: LLM Error Handled -// ============================================================================ - -TEST_CASE("TI_AI_004_LLMErrorHandled", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Send query - f.io.injectMessage("ai:query", {{"query", "Test"}}); - f.process(); - REQUIRE(f.module.isIdle() == false); - - // Receive error - f.io.injectMessage("llm:error", { - {"message", "API rate limit exceeded"}, - {"conversationId", "default"} - }); - f.process(); - - // Should no longer be awaiting - REQUIRE(f.module.isIdle() == true); -} - -// ============================================================================ -// TI_AI_005: Hyperfocus Alert Generates Suggestion -// ============================================================================ - -TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Receive hyperfocus alert - f.io.injectMessage("scheduler:hyperfocus_alert", { - {"sessionMinutes", 130}, - {"task", "coding"} - }); - f.process(); - - // Verify LLM request published - REQUIRE(f.io.wasPublished("llm:request")); - auto req = f.io.getLastPublished("llm:request"); - std::string convId = req["conversationId"]; - - // Simulate LLM response - f.io.injectMessage("llm:response", { - {"text", "Time to take a break!"}, - {"conversationId", convId} - }); - f.process(); - - // Verify suggestion published - REQUIRE(f.io.wasPublished("ai:suggestion")); - auto msg = f.io.getLastPublished("ai:suggestion"); - REQUIRE(msg.contains("message")); -} - -// ============================================================================ -// TI_AI_006: Break Reminder Generates Suggestion -// ============================================================================ - -TEST_CASE("TI_AI_006_BreakReminderGeneratesSuggestion", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Receive break reminder - f.io.injectMessage("scheduler:break_reminder", { - {"workMinutes", 45} - }); - f.process(); - - // Verify LLM request published - REQUIRE(f.io.wasPublished("llm:request")); - auto req = f.io.getLastPublished("llm:request"); - std::string convId = req["conversationId"]; - - // Simulate LLM response - f.io.injectMessage("llm:response", { - {"text", "Take a short break now!"}, - {"conversationId", convId} - }); - f.process(); - - // Verify suggestion - REQUIRE(f.io.wasPublished("ai:suggestion")); -} - -// ============================================================================ -// TI_AI_007: System Prompt In Request -// ============================================================================ - -TEST_CASE("TI_AI_007_SystemPromptInRequest", "[ai][integration]") { - AITestFixture f; - f.configure({{"system_prompt", "Custom prompt here"}}); - - f.io.injectMessage("ai:query", {{"query", "Test"}}); - f.process(); - - REQUIRE(f.io.wasPublished("llm:request")); - auto msg = f.io.getLastPublished("llm:request"); - REQUIRE(msg["systemPrompt"] == "Custom prompt here"); -} - -// ============================================================================ -// TI_AI_008: Conversation ID Tracking -// ============================================================================ - -TEST_CASE("TI_AI_008_ConversationIdTracking", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // First query - f.io.injectMessage("ai:query", {{"query", "Question 1"}}); - f.process(); - - auto msg1 = f.io.getLastPublished("llm:request"); - std::string convId = msg1["conversationId"]; - REQUIRE(!convId.empty()); - - // Simulate response - f.io.injectMessage("llm:response", {{"text", "Response"}, {"conversationId", convId}}); - f.process(); - f.io.clearPublished(); - - // Second query should use same conversation - f.io.injectMessage("ai:query", {{"query", "Question 2"}}); - f.process(); - - auto msg2 = f.io.getLastPublished("llm:request"); - REQUIRE(msg2["conversationId"] == convId); -} - -// ============================================================================ -// TI_AI_009: Token Counting Accumulates -// ============================================================================ - -TEST_CASE("TI_AI_009_TokenCountingAccumulates", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Query 1 - f.io.injectMessage("ai:query", {{"query", "Q1"}}); - f.process(); - f.io.injectMessage("llm:response", {{"text", "R1"}, {"tokens", 50}}); - f.process(); - - // Query 2 - f.io.injectMessage("ai:query", {{"query", "Q2"}}); - f.process(); - f.io.injectMessage("llm:response", {{"text", "R2"}, {"tokens", 75}}); - f.process(); - - // Verify total - auto state = f.module.getState(); - // TODO: Verify totalTokens == 125 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_AI_010: State Serialization -// ============================================================================ - -TEST_CASE("TI_AI_010_StateSerialization", "[ai][integration]") { - AITestFixture f; - f.configure(); - - // Build state - f.io.injectMessage("ai:query", {{"query", "Test"}}); - f.process(); - f.io.injectMessage("llm:response", {{"text", "Response"}, {"tokens", 100}}); - f.process(); - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Restore - AIModule module2; - grove::JsonDataNode configNode2("config", json::object()); - module2.setConfiguration(configNode2, &f.io, nullptr); - module2.setState(*state); - - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - SUCCEED(); // Placeholder -} +/** + * @file AIModuleTests.cpp + * @brief Integration tests for AIModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/AIModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class AITestFixture { +public: + MockIO io; + TimeSimulator time; + AIModule module; + + void configure(const json& config = json::object()) { + json fullConfig = { + {"system_prompt", "Tu es un assistant personnel intelligent."}, + {"max_iterations", 10} + }; + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } +}; + +// ============================================================================ +// TI_AI_001: Query Sends LLM Request +// ============================================================================ + +TEST_CASE("TI_AI_001_QuerySendsLLMRequest", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Send query + f.io.injectMessage("ai:query", {{"query", "Quelle heure est-il?"}}); + f.process(); + + // Verify LLM request published + REQUIRE(f.io.wasPublished("llm:request")); + auto msg = f.io.getLastPublished("llm:request"); + REQUIRE(msg["query"] == "Quelle heure est-il?"); +} + +// ============================================================================ +// TI_AI_002: Voice Transcription Triggers Query +// ============================================================================ + +TEST_CASE("TI_AI_002_VoiceTranscriptionTriggersQuery", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Send voice transcription + f.io.injectMessage("voice:transcription", { + {"text", "Aide-moi avec mon code"}, + {"confidence", 0.95} + }); + f.process(); + + // Verify LLM request + REQUIRE(f.io.wasPublished("llm:request")); + auto msg = f.io.getLastPublished("llm:request"); + REQUIRE(msg["query"] == "Aide-moi avec mon code"); +} + +// ============================================================================ +// TI_AI_003: LLM Response Handled +// ============================================================================ + +TEST_CASE("TI_AI_003_LLMResponseHandled", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Send query to set awaiting state + f.io.injectMessage("ai:query", {{"query", "Test"}}); + f.process(); + REQUIRE(f.module.isIdle() == false); + + // Receive response + f.io.injectMessage("llm:response", { + {"text", "Voici la reponse"}, + {"tokens", 100}, + {"conversationId", "default"} + }); + f.process(); + + // Verify no longer awaiting + REQUIRE(f.module.isIdle() == true); +} + +// ============================================================================ +// TI_AI_004: LLM Error Handled +// ============================================================================ + +TEST_CASE("TI_AI_004_LLMErrorHandled", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Send query + f.io.injectMessage("ai:query", {{"query", "Test"}}); + f.process(); + REQUIRE(f.module.isIdle() == false); + + // Receive error + f.io.injectMessage("llm:error", { + {"message", "API rate limit exceeded"}, + {"conversationId", "default"} + }); + f.process(); + + // Should no longer be awaiting + REQUIRE(f.module.isIdle() == true); +} + +// ============================================================================ +// TI_AI_005: Hyperfocus Alert Generates Suggestion +// ============================================================================ + +TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Receive hyperfocus alert + f.io.injectMessage("scheduler:hyperfocus_alert", { + {"sessionMinutes", 130}, + {"task", "coding"} + }); + f.process(); + + // Verify LLM request published + REQUIRE(f.io.wasPublished("llm:request")); + auto req = f.io.getLastPublished("llm:request"); + std::string convId = req["conversationId"]; + + // Simulate LLM response + f.io.injectMessage("llm:response", { + {"text", "Time to take a break!"}, + {"conversationId", convId} + }); + f.process(); + + // Verify suggestion published + REQUIRE(f.io.wasPublished("ai:suggestion")); + auto msg = f.io.getLastPublished("ai:suggestion"); + REQUIRE(msg.contains("message")); +} + +// ============================================================================ +// TI_AI_006: Break Reminder Generates Suggestion +// ============================================================================ + +TEST_CASE("TI_AI_006_BreakReminderGeneratesSuggestion", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Receive break reminder + f.io.injectMessage("scheduler:break_reminder", { + {"workMinutes", 45} + }); + f.process(); + + // Verify LLM request published + REQUIRE(f.io.wasPublished("llm:request")); + auto req = f.io.getLastPublished("llm:request"); + std::string convId = req["conversationId"]; + + // Simulate LLM response + f.io.injectMessage("llm:response", { + {"text", "Take a short break now!"}, + {"conversationId", convId} + }); + f.process(); + + // Verify suggestion + REQUIRE(f.io.wasPublished("ai:suggestion")); +} + +// ============================================================================ +// TI_AI_007: System Prompt In Request +// ============================================================================ + +TEST_CASE("TI_AI_007_SystemPromptInRequest", "[ai][integration]") { + AITestFixture f; + f.configure({{"system_prompt", "Custom prompt here"}}); + + f.io.injectMessage("ai:query", {{"query", "Test"}}); + f.process(); + + REQUIRE(f.io.wasPublished("llm:request")); + auto msg = f.io.getLastPublished("llm:request"); + REQUIRE(msg["systemPrompt"] == "Custom prompt here"); +} + +// ============================================================================ +// TI_AI_008: Conversation ID Tracking +// ============================================================================ + +TEST_CASE("TI_AI_008_ConversationIdTracking", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // First query + f.io.injectMessage("ai:query", {{"query", "Question 1"}}); + f.process(); + + auto msg1 = f.io.getLastPublished("llm:request"); + std::string convId = msg1["conversationId"]; + REQUIRE(!convId.empty()); + + // Simulate response + f.io.injectMessage("llm:response", {{"text", "Response"}, {"conversationId", convId}}); + f.process(); + f.io.clearPublished(); + + // Second query should use same conversation + f.io.injectMessage("ai:query", {{"query", "Question 2"}}); + f.process(); + + auto msg2 = f.io.getLastPublished("llm:request"); + REQUIRE(msg2["conversationId"] == convId); +} + +// ============================================================================ +// TI_AI_009: Token Counting Accumulates +// ============================================================================ + +TEST_CASE("TI_AI_009_TokenCountingAccumulates", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Query 1 + f.io.injectMessage("ai:query", {{"query", "Q1"}}); + f.process(); + f.io.injectMessage("llm:response", {{"text", "R1"}, {"tokens", 50}}); + f.process(); + + // Query 2 + f.io.injectMessage("ai:query", {{"query", "Q2"}}); + f.process(); + f.io.injectMessage("llm:response", {{"text", "R2"}, {"tokens", 75}}); + f.process(); + + // Verify total + auto state = f.module.getState(); + // TODO: Verify totalTokens == 125 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_AI_010: State Serialization +// ============================================================================ + +TEST_CASE("TI_AI_010_StateSerialization", "[ai][integration]") { + AITestFixture f; + f.configure(); + + // Build state + f.io.injectMessage("ai:query", {{"query", "Test"}}); + f.process(); + f.io.injectMessage("llm:response", {{"text", "Response"}, {"tokens", 100}}); + f.process(); + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Restore + AIModule module2; + grove::JsonDataNode configNode2("config", json::object()); + module2.setConfiguration(configNode2, &f.io, nullptr); + module2.setState(*state); + + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + SUCCEED(); // Placeholder +} diff --git a/tests/modules/MonitoringModuleTests.cpp b/tests/modules/MonitoringModuleTests.cpp index 8f7bd1f..fa0c607 100644 --- a/tests/modules/MonitoringModuleTests.cpp +++ b/tests/modules/MonitoringModuleTests.cpp @@ -1,285 +1,285 @@ -/** - * @file MonitoringModuleTests.cpp - * @brief Integration tests for MonitoringModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/MonitoringModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class MonitoringTestFixture { -public: - MockIO io; - TimeSimulator time; - MonitoringModule module; - - void configure(const json& config = json::object()) { - json fullConfig = { - {"enabled", true}, - {"productive_apps", json::array({"Code", "CLion", "Visual Studio"})}, - {"distracting_apps", json::array({"Discord", "Steam", "YouTube"})} - }; - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } -}; - -// ============================================================================ -// TI_MONITOR_001: App Changed -// ============================================================================ - -TEST_CASE("TI_MONITOR_001_AppChanged", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Inject window change - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Code"}, - {"duration", 0} - }); - f.process(); - - // Verify app_changed published - REQUIRE(f.io.wasPublished("monitoring:app_changed")); - auto msg = f.io.getLastPublished("monitoring:app_changed"); - REQUIRE(msg["appName"] == "Code"); -} - -// ============================================================================ -// TI_MONITOR_002: Productive App Classification -// ============================================================================ - -TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Code"}, - {"duration", 0} - }); - f.process(); - - REQUIRE(f.io.wasPublished("monitoring:app_changed")); - auto msg = f.io.getLastPublished("monitoring:app_changed"); - REQUIRE(msg["classification"] == "productive"); -} - -// ============================================================================ -// TI_MONITOR_003: Distracting App Classification -// ============================================================================ - -TEST_CASE("TI_MONITOR_003_DistractingAppClassification", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Discord"}, - {"duration", 0} - }); - f.process(); - - REQUIRE(f.io.wasPublished("monitoring:app_changed")); - auto msg = f.io.getLastPublished("monitoring:app_changed"); - REQUIRE(msg["classification"] == "distracting"); -} - -// ============================================================================ -// TI_MONITOR_004: Neutral App Classification -// ============================================================================ - -TEST_CASE("TI_MONITOR_004_NeutralAppClassification", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Notepad"}, - {"duration", 0} - }); - f.process(); - - REQUIRE(f.io.wasPublished("monitoring:app_changed")); - auto msg = f.io.getLastPublished("monitoring:app_changed"); - REQUIRE(msg["classification"] == "neutral"); -} - -// ============================================================================ -// TI_MONITOR_005: Duration Tracking -// ============================================================================ - -TEST_CASE("TI_MONITOR_005_DurationTracking", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Start with Code - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Code"}, - {"duration", 0} - }); - f.process(); - f.io.clearPublished(); - - // Switch after 60 seconds - f.io.injectMessage("platform:window_changed", { - {"oldApp", "Code"}, - {"newApp", "Discord"}, - {"duration", 60} - }); - f.process(); - - // Verify duration tracked - auto state = f.module.getState(); - // TODO: Verify appDurations["Code"] == 60 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_MONITOR_006: Idle Detected Pauses Tracking -// ============================================================================ - -TEST_CASE("TI_MONITOR_006_IdleDetectedPausesTracking", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Start tracking - f.io.injectMessage("platform:window_changed", { - {"oldApp", ""}, - {"newApp", "Code"}, - {"duration", 0} - }); - f.process(); - - // Go idle - f.io.injectMessage("platform:idle_detected", {{"idleSeconds", 300}}); - f.process(); - - // Verify idle state - auto state = f.module.getState(); - // TODO: Verify isIdle == true - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_MONITOR_007: Activity Resumed Resumes Tracking -// ============================================================================ - -TEST_CASE("TI_MONITOR_007_ActivityResumedResumesTracking", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Setup idle state - f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); - f.process(); - f.io.injectMessage("platform:idle_detected", {}); - f.process(); - - // Resume - f.io.injectMessage("platform:activity_resumed", {}); - f.process(); - - // Verify not idle - auto state = f.module.getState(); - // TODO: Verify isIdle == false - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_MONITOR_008: Productivity Stats -// ============================================================================ - -TEST_CASE("TI_MONITOR_008_ProductivityStats", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Use productive app for 60s - f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); - f.process(); - f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 60}}); - f.process(); - - // Use distracting app for 30s - f.io.injectMessage("platform:window_changed", {{"oldApp", "Discord"}, {"newApp", "Code"}, {"duration", 30}}); - f.process(); - - // Verify stats - auto state = f.module.getState(); - // TODO: Verify totalProductiveSeconds == 60, totalDistractingSeconds == 30 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_MONITOR_009: Tool Query Get Current App -// ============================================================================ - -TEST_CASE("TI_MONITOR_009_ToolQueryGetCurrentApp", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Set current app - f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); - f.process(); - f.io.clearPublished(); - - // Query - f.io.injectMessage("monitoring:query", { - {"action", "get_current_app"}, - {"correlation_id", "test-456"} - }); - f.process(); - - // Verify response - REQUIRE(f.io.wasPublished("monitoring:response")); - auto resp = f.io.getLastPublished("monitoring:response"); - REQUIRE(resp["correlation_id"] == "test-456"); -} - -// ============================================================================ -// TI_MONITOR_010: State Serialization -// ============================================================================ - -TEST_CASE("TI_MONITOR_010_StateSerialization", "[monitoring][integration]") { - MonitoringTestFixture f; - f.configure(); - - // Build up some state - f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); - f.process(); - f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 120}}); - f.process(); - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Restore to new module - MonitoringModule module2; - grove::JsonDataNode configNode2("config", json::object()); - module2.setConfiguration(configNode2, &f.io, nullptr); - module2.setState(*state); - - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - SUCCEED(); // Placeholder -} +/** + * @file MonitoringModuleTests.cpp + * @brief Integration tests for MonitoringModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/MonitoringModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class MonitoringTestFixture { +public: + MockIO io; + TimeSimulator time; + MonitoringModule module; + + void configure(const json& config = json::object()) { + json fullConfig = { + {"enabled", true}, + {"productive_apps", json::array({"Code", "CLion", "Visual Studio"})}, + {"distracting_apps", json::array({"Discord", "Steam", "YouTube"})} + }; + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } +}; + +// ============================================================================ +// TI_MONITOR_001: App Changed +// ============================================================================ + +TEST_CASE("TI_MONITOR_001_AppChanged", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Inject window change + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Code"}, + {"duration", 0} + }); + f.process(); + + // Verify app_changed published + REQUIRE(f.io.wasPublished("monitoring:app_changed")); + auto msg = f.io.getLastPublished("monitoring:app_changed"); + REQUIRE(msg["appName"] == "Code"); +} + +// ============================================================================ +// TI_MONITOR_002: Productive App Classification +// ============================================================================ + +TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Code"}, + {"duration", 0} + }); + f.process(); + + REQUIRE(f.io.wasPublished("monitoring:app_changed")); + auto msg = f.io.getLastPublished("monitoring:app_changed"); + REQUIRE(msg["classification"] == "productive"); +} + +// ============================================================================ +// TI_MONITOR_003: Distracting App Classification +// ============================================================================ + +TEST_CASE("TI_MONITOR_003_DistractingAppClassification", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Discord"}, + {"duration", 0} + }); + f.process(); + + REQUIRE(f.io.wasPublished("monitoring:app_changed")); + auto msg = f.io.getLastPublished("monitoring:app_changed"); + REQUIRE(msg["classification"] == "distracting"); +} + +// ============================================================================ +// TI_MONITOR_004: Neutral App Classification +// ============================================================================ + +TEST_CASE("TI_MONITOR_004_NeutralAppClassification", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Notepad"}, + {"duration", 0} + }); + f.process(); + + REQUIRE(f.io.wasPublished("monitoring:app_changed")); + auto msg = f.io.getLastPublished("monitoring:app_changed"); + REQUIRE(msg["classification"] == "neutral"); +} + +// ============================================================================ +// TI_MONITOR_005: Duration Tracking +// ============================================================================ + +TEST_CASE("TI_MONITOR_005_DurationTracking", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Start with Code + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Code"}, + {"duration", 0} + }); + f.process(); + f.io.clearPublished(); + + // Switch after 60 seconds + f.io.injectMessage("platform:window_changed", { + {"oldApp", "Code"}, + {"newApp", "Discord"}, + {"duration", 60} + }); + f.process(); + + // Verify duration tracked + auto state = f.module.getState(); + // TODO: Verify appDurations["Code"] == 60 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_MONITOR_006: Idle Detected Pauses Tracking +// ============================================================================ + +TEST_CASE("TI_MONITOR_006_IdleDetectedPausesTracking", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Start tracking + f.io.injectMessage("platform:window_changed", { + {"oldApp", ""}, + {"newApp", "Code"}, + {"duration", 0} + }); + f.process(); + + // Go idle + f.io.injectMessage("platform:idle_detected", {{"idleSeconds", 300}}); + f.process(); + + // Verify idle state + auto state = f.module.getState(); + // TODO: Verify isIdle == true + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_MONITOR_007: Activity Resumed Resumes Tracking +// ============================================================================ + +TEST_CASE("TI_MONITOR_007_ActivityResumedResumesTracking", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Setup idle state + f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); + f.process(); + f.io.injectMessage("platform:idle_detected", {}); + f.process(); + + // Resume + f.io.injectMessage("platform:activity_resumed", {}); + f.process(); + + // Verify not idle + auto state = f.module.getState(); + // TODO: Verify isIdle == false + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_MONITOR_008: Productivity Stats +// ============================================================================ + +TEST_CASE("TI_MONITOR_008_ProductivityStats", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Use productive app for 60s + f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); + f.process(); + f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 60}}); + f.process(); + + // Use distracting app for 30s + f.io.injectMessage("platform:window_changed", {{"oldApp", "Discord"}, {"newApp", "Code"}, {"duration", 30}}); + f.process(); + + // Verify stats + auto state = f.module.getState(); + // TODO: Verify totalProductiveSeconds == 60, totalDistractingSeconds == 30 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_MONITOR_009: Tool Query Get Current App +// ============================================================================ + +TEST_CASE("TI_MONITOR_009_ToolQueryGetCurrentApp", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Set current app + f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); + f.process(); + f.io.clearPublished(); + + // Query + f.io.injectMessage("monitoring:query", { + {"action", "get_current_app"}, + {"correlation_id", "test-456"} + }); + f.process(); + + // Verify response + REQUIRE(f.io.wasPublished("monitoring:response")); + auto resp = f.io.getLastPublished("monitoring:response"); + REQUIRE(resp["correlation_id"] == "test-456"); +} + +// ============================================================================ +// TI_MONITOR_010: State Serialization +// ============================================================================ + +TEST_CASE("TI_MONITOR_010_StateSerialization", "[monitoring][integration]") { + MonitoringTestFixture f; + f.configure(); + + // Build up some state + f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); + f.process(); + f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 120}}); + f.process(); + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Restore to new module + MonitoringModule module2; + grove::JsonDataNode configNode2("config", json::object()); + module2.setConfiguration(configNode2, &f.io, nullptr); + module2.setState(*state); + + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + SUCCEED(); // Placeholder +} diff --git a/tests/modules/NotificationModuleTests.cpp b/tests/modules/NotificationModuleTests.cpp index 0e0f4c0..e596d99 100644 --- a/tests/modules/NotificationModuleTests.cpp +++ b/tests/modules/NotificationModuleTests.cpp @@ -1,303 +1,303 @@ -/** - * @file NotificationModuleTests.cpp - * @brief Integration tests for NotificationModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/NotificationModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class NotificationTestFixture { -public: - MockIO io; - TimeSimulator time; - NotificationModule module; - - void configure(const json& config = json::object()) { - json fullConfig = { - {"language", "fr"}, - {"silentMode", false}, - {"ttsEnabled", false}, - {"maxQueueSize", 50} - }; - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } - - int getPendingCount() { - auto state = module.getState(); - return state ? state->getInt("pendingCount", -1) : -1; - } - - int getNotificationCount() { - auto state = module.getState(); - return state ? state->getInt("notificationCount", -1) : -1; - } - - int getUrgentCount() { - auto state = module.getState(); - return state ? state->getInt("urgentCount", -1) : -1; - } -}; - -// ============================================================================ -// TI_NOTIF_001: Queue Notification -// ============================================================================ - -TEST_CASE("TI_NOTIF_001_QueueNotification", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Add notification - f.module.notify("Test Title", "Test Message", NotificationModule::Priority::NORMAL); - - // Verify queue has 1 item (before processing) - REQUIRE(f.getPendingCount() == 1); - - // Verify notification count incremented - REQUIRE(f.getNotificationCount() == 1); -} - -// ============================================================================ -// TI_NOTIF_002: Process Queue (max 3 per frame) -// ============================================================================ - -TEST_CASE("TI_NOTIF_002_ProcessQueue", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Add 5 notifications - for (int i = 0; i < 5; i++) { - f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); - } - - // Verify 5 pending before process - REQUIRE(f.getPendingCount() == 5); - - // Process one frame (should handle max 3) - f.process(); - - // Verify 2 remaining in queue - REQUIRE(f.getPendingCount() == 2); -} - -// ============================================================================ -// TI_NOTIF_003: Priority Ordering -// NOTE: Current implementation uses FIFO queue without priority sorting. -// This test verifies that URGENT notifications can still be added -// alongside other priorities. True priority ordering would require -// a priority queue implementation. -// ============================================================================ - -TEST_CASE("TI_NOTIF_003_PriorityOrdering", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Add notifications in reverse priority order - f.module.notify("Low", "Low priority", NotificationModule::Priority::LOW); - f.module.notify("Urgent", "Urgent priority", NotificationModule::Priority::URGENT); - f.module.notify("Normal", "Normal priority", NotificationModule::Priority::NORMAL); - - // Verify all 3 are queued - REQUIRE(f.getPendingCount() == 3); - - // Verify urgent count is tracked - REQUIRE(f.getUrgentCount() == 1); - - // Process - verify all are processed - f.process(); - REQUIRE(f.getPendingCount() == 0); -} - -// ============================================================================ -// TI_NOTIF_004: Silent Mode Blocks Non-Urgent -// ============================================================================ - -TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") { - NotificationTestFixture f; - f.configure({{"silentMode", true}}); - - // Add non-urgent notifications - f.module.notify("Low", "Should be blocked", NotificationModule::Priority::LOW); - f.module.notify("Normal", "Should be blocked", NotificationModule::Priority::NORMAL); - f.module.notify("High", "Should be blocked", NotificationModule::Priority::HIGH); - - // Verify all were blocked (queue empty) - REQUIRE(f.getPendingCount() == 0); - - // Verify notification count was NOT incremented for blocked notifications - // Note: Current implementation increments count before checking silentMode - // So count will be 0 (notify returns early before incrementing) - REQUIRE(f.getNotificationCount() == 0); -} - -// ============================================================================ -// TI_NOTIF_005: Silent Mode Allows Urgent -// ============================================================================ - -TEST_CASE("TI_NOTIF_005_SilentModeAllowsUrgent", "[notification][integration]") { - NotificationTestFixture f; - f.configure({{"silentMode", true}}); - - // Add urgent notification - f.module.notify("Urgent", "Should pass", NotificationModule::Priority::URGENT); - - // Verify URGENT notification was queued - REQUIRE(f.getPendingCount() == 1); - - // Verify counts - REQUIRE(f.getNotificationCount() == 1); - REQUIRE(f.getUrgentCount() == 1); -} - -// ============================================================================ -// TI_NOTIF_006: Max Queue Size -// ============================================================================ - -TEST_CASE("TI_NOTIF_006_MaxQueueSize", "[notification][integration]") { - NotificationTestFixture f; - f.configure({{"maxQueueSize", 5}}); - - // Add more than max (10 notifications) - for (int i = 0; i < 10; i++) { - f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); - } - - // Verify queue is capped at maxQueueSize - REQUIRE(f.getPendingCount() <= 5); - - // Notification count should still reflect all attempts - REQUIRE(f.getNotificationCount() == 10); -} - -// ============================================================================ -// TI_NOTIF_007: Language Config -// ============================================================================ - -TEST_CASE("TI_NOTIF_007_LanguageConfig", "[notification][integration]") { - NotificationTestFixture f; - f.configure({{"language", "en"}}); - - // Verify module accepted configuration (no crash) - // The language is stored internally and used for notification display - // We can verify via getHealthStatus which doesn't expose language directly - auto health = f.module.getHealthStatus(); - REQUIRE(health != nullptr); - REQUIRE(health->getString("status", "") == "running"); -} - -// ============================================================================ -// TI_NOTIF_008: Notification Count Tracking -// ============================================================================ - -TEST_CASE("TI_NOTIF_008_NotificationCountTracking", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Add various notifications - f.module.notify("Normal1", "msg", NotificationModule::Priority::NORMAL); - f.module.notify("Urgent1", "msg", NotificationModule::Priority::URGENT); - f.module.notify("Urgent2", "msg", NotificationModule::Priority::URGENT); - f.module.notify("Low1", "msg", NotificationModule::Priority::LOW); - - // Verify counts - REQUIRE(f.getNotificationCount() == 4); - REQUIRE(f.getUrgentCount() == 2); - REQUIRE(f.getPendingCount() == 4); - - // Process all - f.process(); // processes 3 - f.process(); // processes 1 - - // Verify queue empty but counts preserved - REQUIRE(f.getPendingCount() == 0); - REQUIRE(f.getNotificationCount() == 4); - REQUIRE(f.getUrgentCount() == 2); -} - -// ============================================================================ -// TI_NOTIF_009: State Serialization -// ============================================================================ - -TEST_CASE("TI_NOTIF_009_StateSerialization", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Create some state - f.module.notify("Test1", "msg", NotificationModule::Priority::NORMAL); - f.module.notify("Test2", "msg", NotificationModule::Priority::URGENT); - f.process(); // Process some - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Verify state contains expected fields - REQUIRE(state->getInt("notificationCount", -1) == 2); - REQUIRE(state->getInt("urgentCount", -1) == 1); - - // Create new module and restore - NotificationModule module2; - MockIO io2; - grove::JsonDataNode configNode("config", json::object()); - module2.setConfiguration(configNode, &io2, nullptr); - module2.setState(*state); - - // Verify counters were restored - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - REQUIRE(state2->getInt("notificationCount", -1) == 2); - REQUIRE(state2->getInt("urgentCount", -1) == 1); - // Note: pending queue is NOT restored (documented behavior) - REQUIRE(state2->getInt("pendingCount", -1) == 0); -} - -// ============================================================================ -// TI_NOTIF_010: Multiple Frame Processing -// ============================================================================ - -TEST_CASE("TI_NOTIF_010_MultipleFrameProcessing", "[notification][integration]") { - NotificationTestFixture f; - f.configure(); - - // Add 7 notifications (needs 3 frames to process at 3/frame) - for (int i = 0; i < 7; i++) { - f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); - } - - // Verify initial count - REQUIRE(f.getPendingCount() == 7); - - // Frame 1: 3 processed, 4 remaining - f.process(); - REQUIRE(f.getPendingCount() == 4); - - // Frame 2: 3 processed, 1 remaining - f.process(); - REQUIRE(f.getPendingCount() == 1); - - // Frame 3: 1 processed, 0 remaining - f.process(); - REQUIRE(f.getPendingCount() == 0); - - // Total notification count should be unchanged - REQUIRE(f.getNotificationCount() == 7); -} +/** + * @file NotificationModuleTests.cpp + * @brief Integration tests for NotificationModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/NotificationModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class NotificationTestFixture { +public: + MockIO io; + TimeSimulator time; + NotificationModule module; + + void configure(const json& config = json::object()) { + json fullConfig = { + {"language", "fr"}, + {"silentMode", false}, + {"ttsEnabled", false}, + {"maxQueueSize", 50} + }; + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } + + int getPendingCount() { + auto state = module.getState(); + return state ? state->getInt("pendingCount", -1) : -1; + } + + int getNotificationCount() { + auto state = module.getState(); + return state ? state->getInt("notificationCount", -1) : -1; + } + + int getUrgentCount() { + auto state = module.getState(); + return state ? state->getInt("urgentCount", -1) : -1; + } +}; + +// ============================================================================ +// TI_NOTIF_001: Queue Notification +// ============================================================================ + +TEST_CASE("TI_NOTIF_001_QueueNotification", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Add notification + f.module.notify("Test Title", "Test Message", NotificationModule::Priority::NORMAL); + + // Verify queue has 1 item (before processing) + REQUIRE(f.getPendingCount() == 1); + + // Verify notification count incremented + REQUIRE(f.getNotificationCount() == 1); +} + +// ============================================================================ +// TI_NOTIF_002: Process Queue (max 3 per frame) +// ============================================================================ + +TEST_CASE("TI_NOTIF_002_ProcessQueue", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Add 5 notifications + for (int i = 0; i < 5; i++) { + f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); + } + + // Verify 5 pending before process + REQUIRE(f.getPendingCount() == 5); + + // Process one frame (should handle max 3) + f.process(); + + // Verify 2 remaining in queue + REQUIRE(f.getPendingCount() == 2); +} + +// ============================================================================ +// TI_NOTIF_003: Priority Ordering +// NOTE: Current implementation uses FIFO queue without priority sorting. +// This test verifies that URGENT notifications can still be added +// alongside other priorities. True priority ordering would require +// a priority queue implementation. +// ============================================================================ + +TEST_CASE("TI_NOTIF_003_PriorityOrdering", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Add notifications in reverse priority order + f.module.notify("Low", "Low priority", NotificationModule::Priority::LOW); + f.module.notify("Urgent", "Urgent priority", NotificationModule::Priority::URGENT); + f.module.notify("Normal", "Normal priority", NotificationModule::Priority::NORMAL); + + // Verify all 3 are queued + REQUIRE(f.getPendingCount() == 3); + + // Verify urgent count is tracked + REQUIRE(f.getUrgentCount() == 1); + + // Process - verify all are processed + f.process(); + REQUIRE(f.getPendingCount() == 0); +} + +// ============================================================================ +// TI_NOTIF_004: Silent Mode Blocks Non-Urgent +// ============================================================================ + +TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") { + NotificationTestFixture f; + f.configure({{"silentMode", true}}); + + // Add non-urgent notifications + f.module.notify("Low", "Should be blocked", NotificationModule::Priority::LOW); + f.module.notify("Normal", "Should be blocked", NotificationModule::Priority::NORMAL); + f.module.notify("High", "Should be blocked", NotificationModule::Priority::HIGH); + + // Verify all were blocked (queue empty) + REQUIRE(f.getPendingCount() == 0); + + // Verify notification count was NOT incremented for blocked notifications + // Note: Current implementation increments count before checking silentMode + // So count will be 0 (notify returns early before incrementing) + REQUIRE(f.getNotificationCount() == 0); +} + +// ============================================================================ +// TI_NOTIF_005: Silent Mode Allows Urgent +// ============================================================================ + +TEST_CASE("TI_NOTIF_005_SilentModeAllowsUrgent", "[notification][integration]") { + NotificationTestFixture f; + f.configure({{"silentMode", true}}); + + // Add urgent notification + f.module.notify("Urgent", "Should pass", NotificationModule::Priority::URGENT); + + // Verify URGENT notification was queued + REQUIRE(f.getPendingCount() == 1); + + // Verify counts + REQUIRE(f.getNotificationCount() == 1); + REQUIRE(f.getUrgentCount() == 1); +} + +// ============================================================================ +// TI_NOTIF_006: Max Queue Size +// ============================================================================ + +TEST_CASE("TI_NOTIF_006_MaxQueueSize", "[notification][integration]") { + NotificationTestFixture f; + f.configure({{"maxQueueSize", 5}}); + + // Add more than max (10 notifications) + for (int i = 0; i < 10; i++) { + f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); + } + + // Verify queue is capped at maxQueueSize + REQUIRE(f.getPendingCount() <= 5); + + // Notification count should still reflect all attempts + REQUIRE(f.getNotificationCount() == 10); +} + +// ============================================================================ +// TI_NOTIF_007: Language Config +// ============================================================================ + +TEST_CASE("TI_NOTIF_007_LanguageConfig", "[notification][integration]") { + NotificationTestFixture f; + f.configure({{"language", "en"}}); + + // Verify module accepted configuration (no crash) + // The language is stored internally and used for notification display + // We can verify via getHealthStatus which doesn't expose language directly + auto health = f.module.getHealthStatus(); + REQUIRE(health != nullptr); + REQUIRE(health->getString("status", "") == "running"); +} + +// ============================================================================ +// TI_NOTIF_008: Notification Count Tracking +// ============================================================================ + +TEST_CASE("TI_NOTIF_008_NotificationCountTracking", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Add various notifications + f.module.notify("Normal1", "msg", NotificationModule::Priority::NORMAL); + f.module.notify("Urgent1", "msg", NotificationModule::Priority::URGENT); + f.module.notify("Urgent2", "msg", NotificationModule::Priority::URGENT); + f.module.notify("Low1", "msg", NotificationModule::Priority::LOW); + + // Verify counts + REQUIRE(f.getNotificationCount() == 4); + REQUIRE(f.getUrgentCount() == 2); + REQUIRE(f.getPendingCount() == 4); + + // Process all + f.process(); // processes 3 + f.process(); // processes 1 + + // Verify queue empty but counts preserved + REQUIRE(f.getPendingCount() == 0); + REQUIRE(f.getNotificationCount() == 4); + REQUIRE(f.getUrgentCount() == 2); +} + +// ============================================================================ +// TI_NOTIF_009: State Serialization +// ============================================================================ + +TEST_CASE("TI_NOTIF_009_StateSerialization", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Create some state + f.module.notify("Test1", "msg", NotificationModule::Priority::NORMAL); + f.module.notify("Test2", "msg", NotificationModule::Priority::URGENT); + f.process(); // Process some + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Verify state contains expected fields + REQUIRE(state->getInt("notificationCount", -1) == 2); + REQUIRE(state->getInt("urgentCount", -1) == 1); + + // Create new module and restore + NotificationModule module2; + MockIO io2; + grove::JsonDataNode configNode("config", json::object()); + module2.setConfiguration(configNode, &io2, nullptr); + module2.setState(*state); + + // Verify counters were restored + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + REQUIRE(state2->getInt("notificationCount", -1) == 2); + REQUIRE(state2->getInt("urgentCount", -1) == 1); + // Note: pending queue is NOT restored (documented behavior) + REQUIRE(state2->getInt("pendingCount", -1) == 0); +} + +// ============================================================================ +// TI_NOTIF_010: Multiple Frame Processing +// ============================================================================ + +TEST_CASE("TI_NOTIF_010_MultipleFrameProcessing", "[notification][integration]") { + NotificationTestFixture f; + f.configure(); + + // Add 7 notifications (needs 3 frames to process at 3/frame) + for (int i = 0; i < 7; i++) { + f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); + } + + // Verify initial count + REQUIRE(f.getPendingCount() == 7); + + // Frame 1: 3 processed, 4 remaining + f.process(); + REQUIRE(f.getPendingCount() == 4); + + // Frame 2: 3 processed, 1 remaining + f.process(); + REQUIRE(f.getPendingCount() == 1); + + // Frame 3: 1 processed, 0 remaining + f.process(); + REQUIRE(f.getPendingCount() == 0); + + // Total notification count should be unchanged + REQUIRE(f.getNotificationCount() == 7); +} diff --git a/tests/modules/SchedulerModuleTests.cpp b/tests/modules/SchedulerModuleTests.cpp index 16ee98c..af216fc 100644 --- a/tests/modules/SchedulerModuleTests.cpp +++ b/tests/modules/SchedulerModuleTests.cpp @@ -1,315 +1,315 @@ -/** - * @file SchedulerModuleTests.cpp - * @brief Integration tests for SchedulerModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/SchedulerModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class SchedulerTestFixture { -public: - MockIO io; - TimeSimulator time; - SchedulerModule module; - - void configure(const json& config = json::object()) { - json fullConfig = { - {"hyperfocusThresholdMinutes", 120}, - {"breakReminderIntervalMinutes", 45}, - {"breakDurationMinutes", 10} - }; - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } - - void processWithTime(float gameTime) { - time.setTime(gameTime); - grove::JsonDataNode input("input", time.createInput(0.1f)); - module.process(input); - } -}; - -// ============================================================================ -// TI_SCHEDULER_001: Start Task -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Inject task switch message - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - - // Process - f.process(); - - // Verify task_started was published - REQUIRE(f.io.wasPublished("scheduler:task_started")); - auto msg = f.io.getLastPublished("scheduler:task_started"); - REQUIRE(msg["taskId"] == "task-1"); - REQUIRE(msg.contains("taskName")); -} - -// ============================================================================ -// TI_SCHEDULER_002: Complete Task -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_002_CompleteTask", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Start a task at time 0 - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.io.clearPublished(); - - // Advance time 30 minutes (1800 seconds) - f.time.setTime(1800.0f); - - // Switch to another task (completes current task implicitly) - f.io.injectMessage("user:task_switch", {{"taskId", "task-2"}}); - f.process(); - - // Verify task_completed was published with duration - REQUIRE(f.io.wasPublished("scheduler:task_completed")); - auto msg = f.io.getLastPublished("scheduler:task_completed"); - REQUIRE(msg["taskId"] == "task-1"); - REQUIRE(msg.contains("duration")); - // Duration should be around 30 minutes - int duration = msg["duration"].get(); - REQUIRE(duration >= 29); - REQUIRE(duration <= 31); -} - -// ============================================================================ -// TI_SCHEDULER_003: Hyperfocus Detection -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_003_HyperfocusDetection", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure({{"hyperfocusThresholdMinutes", 120}}); - - // Start a task at time 0 - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.io.clearPublished(); - - // Advance time past threshold (121 minutes = 7260 seconds) - f.processWithTime(7260.0f); - - // Verify hyperfocus alert - REQUIRE(f.io.wasPublished("scheduler:hyperfocus_alert")); - auto msg = f.io.getLastPublished("scheduler:hyperfocus_alert"); - REQUIRE(msg["type"] == "hyperfocus"); - REQUIRE(msg["task"] == "task-1"); - REQUIRE(msg["duration_minutes"].get() >= 120); -} - -// ============================================================================ -// TI_SCHEDULER_004: Hyperfocus Alert Only Once -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_004_HyperfocusAlertOnce", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure({{"hyperfocusThresholdMinutes", 120}}); - - // Start task - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - - // Trigger hyperfocus (121 min) - f.processWithTime(7260.0f); - - // Count first alert - size_t alertCount = f.io.countPublished("scheduler:hyperfocus_alert"); - REQUIRE(alertCount == 1); - - // Continue processing (130 min, 140 min) - f.processWithTime(7800.0f); - f.processWithTime(8400.0f); - - // Should still be only 1 alert - REQUIRE(f.io.countPublished("scheduler:hyperfocus_alert") == 1); -} - -// ============================================================================ -// TI_SCHEDULER_005: Break Reminder -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_005_BreakReminder", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure({{"breakReminderIntervalMinutes", 45}}); - - // Process at time 0 (sets lastBreakTime) - f.processWithTime(0.0f); - f.io.clearPublished(); - - // Advance past break reminder interval (46 minutes = 2760 seconds) - f.processWithTime(2760.0f); - - // Verify break reminder - REQUIRE(f.io.wasPublished("scheduler:break_reminder")); - auto msg = f.io.getLastPublished("scheduler:break_reminder"); - REQUIRE(msg["type"] == "break"); - REQUIRE(msg.contains("break_duration")); -} - -// ============================================================================ -// TI_SCHEDULER_006: Idle Pauses Session -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_006_IdlePausesSession", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Start task - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - - // Go idle - f.io.injectMessage("monitoring:idle_detected", {{"idleSeconds", 300}}); - f.processWithTime(60.0f); - - // Verify module received and processed the idle message - // (Module logs "User idle" - we can verify via state) - auto state = f.module.getState(); - REQUIRE(state != nullptr); - // Task should still be tracked (idle doesn't clear it) - REQUIRE(state->getString("currentTaskId", "") == "task-1"); -} - -// ============================================================================ -// TI_SCHEDULER_007: Activity Resumes Session -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_007_ActivityResumesSession", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Start task, go idle, resume - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.io.injectMessage("monitoring:idle_detected", {}); - f.processWithTime(60.0f); - f.io.injectMessage("monitoring:activity_resumed", {}); - f.processWithTime(120.0f); - - // Verify session continues - task still active - auto state = f.module.getState(); - REQUIRE(state != nullptr); - REQUIRE(state->getString("currentTaskId", "") == "task-1"); -} - -// ============================================================================ -// TI_SCHEDULER_008: Tool Query Get Current Task -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_008_ToolQueryGetCurrentTask", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Start a task - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.io.clearPublished(); - - // Query current task - f.io.injectMessage("scheduler:query", { - {"action", "get_current_task"}, - {"correlation_id", "test-123"} - }); - f.processWithTime(60.0f); - - // Verify response - REQUIRE(f.io.wasPublished("scheduler:response")); - auto resp = f.io.getLastPublished("scheduler:response"); - REQUIRE(resp["correlation_id"] == "test-123"); - REQUIRE(resp["task_id"] == "task-1"); -} - -// ============================================================================ -// TI_SCHEDULER_009: Tool Command Start Break -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_009_ToolCommandStartBreak", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Start task - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.io.clearPublished(); - - // Command to start break - f.io.injectMessage("scheduler:command", { - {"action", "start_break"}, - {"duration_minutes", 15}, - {"reason", "test break"} - }); - f.processWithTime(60.0f); - - // Verify break started was published - REQUIRE(f.io.wasPublished("scheduler:break_started")); - auto msg = f.io.getLastPublished("scheduler:break_started"); - REQUIRE(msg["duration"] == 15); - REQUIRE(msg["reason"] == "test break"); - - // Verify response was also published - REQUIRE(f.io.wasPublished("scheduler:response")); - auto resp = f.io.getLastPublished("scheduler:response"); - REQUIRE(resp["success"] == true); -} - -// ============================================================================ -// TI_SCHEDULER_010: State Serialization -// ============================================================================ - -TEST_CASE("TI_SCHEDULER_010_StateSerialization", "[scheduler][integration]") { - SchedulerTestFixture f; - f.configure(); - - // Setup some state - f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); - f.processWithTime(0.0f); - f.processWithTime(1800.0f); // 30 minutes - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Verify state content - REQUIRE(state->getString("currentTaskId", "") == "task-1"); - REQUIRE(state->getBool("hyperfocusAlertSent", true) == false); - - // Create new module and restore state - SchedulerModule module2; - MockIO io2; - grove::JsonDataNode configNode("config", json::object()); - module2.setConfiguration(configNode, &io2, nullptr); - module2.setState(*state); - - // Verify state was restored - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - REQUIRE(state2->getString("currentTaskId", "") == "task-1"); - REQUIRE(state2->getBool("hyperfocusAlertSent", true) == false); -} +/** + * @file SchedulerModuleTests.cpp + * @brief Integration tests for SchedulerModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/SchedulerModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class SchedulerTestFixture { +public: + MockIO io; + TimeSimulator time; + SchedulerModule module; + + void configure(const json& config = json::object()) { + json fullConfig = { + {"hyperfocusThresholdMinutes", 120}, + {"breakReminderIntervalMinutes", 45}, + {"breakDurationMinutes", 10} + }; + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } + + void processWithTime(float gameTime) { + time.setTime(gameTime); + grove::JsonDataNode input("input", time.createInput(0.1f)); + module.process(input); + } +}; + +// ============================================================================ +// TI_SCHEDULER_001: Start Task +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Inject task switch message + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + + // Process + f.process(); + + // Verify task_started was published + REQUIRE(f.io.wasPublished("scheduler:task_started")); + auto msg = f.io.getLastPublished("scheduler:task_started"); + REQUIRE(msg["taskId"] == "task-1"); + REQUIRE(msg.contains("taskName")); +} + +// ============================================================================ +// TI_SCHEDULER_002: Complete Task +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_002_CompleteTask", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Start a task at time 0 + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.io.clearPublished(); + + // Advance time 30 minutes (1800 seconds) + f.time.setTime(1800.0f); + + // Switch to another task (completes current task implicitly) + f.io.injectMessage("user:task_switch", {{"taskId", "task-2"}}); + f.process(); + + // Verify task_completed was published with duration + REQUIRE(f.io.wasPublished("scheduler:task_completed")); + auto msg = f.io.getLastPublished("scheduler:task_completed"); + REQUIRE(msg["taskId"] == "task-1"); + REQUIRE(msg.contains("duration")); + // Duration should be around 30 minutes + int duration = msg["duration"].get(); + REQUIRE(duration >= 29); + REQUIRE(duration <= 31); +} + +// ============================================================================ +// TI_SCHEDULER_003: Hyperfocus Detection +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_003_HyperfocusDetection", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure({{"hyperfocusThresholdMinutes", 120}}); + + // Start a task at time 0 + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.io.clearPublished(); + + // Advance time past threshold (121 minutes = 7260 seconds) + f.processWithTime(7260.0f); + + // Verify hyperfocus alert + REQUIRE(f.io.wasPublished("scheduler:hyperfocus_alert")); + auto msg = f.io.getLastPublished("scheduler:hyperfocus_alert"); + REQUIRE(msg["type"] == "hyperfocus"); + REQUIRE(msg["task"] == "task-1"); + REQUIRE(msg["duration_minutes"].get() >= 120); +} + +// ============================================================================ +// TI_SCHEDULER_004: Hyperfocus Alert Only Once +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_004_HyperfocusAlertOnce", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure({{"hyperfocusThresholdMinutes", 120}}); + + // Start task + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + + // Trigger hyperfocus (121 min) + f.processWithTime(7260.0f); + + // Count first alert + size_t alertCount = f.io.countPublished("scheduler:hyperfocus_alert"); + REQUIRE(alertCount == 1); + + // Continue processing (130 min, 140 min) + f.processWithTime(7800.0f); + f.processWithTime(8400.0f); + + // Should still be only 1 alert + REQUIRE(f.io.countPublished("scheduler:hyperfocus_alert") == 1); +} + +// ============================================================================ +// TI_SCHEDULER_005: Break Reminder +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_005_BreakReminder", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure({{"breakReminderIntervalMinutes", 45}}); + + // Process at time 0 (sets lastBreakTime) + f.processWithTime(0.0f); + f.io.clearPublished(); + + // Advance past break reminder interval (46 minutes = 2760 seconds) + f.processWithTime(2760.0f); + + // Verify break reminder + REQUIRE(f.io.wasPublished("scheduler:break_reminder")); + auto msg = f.io.getLastPublished("scheduler:break_reminder"); + REQUIRE(msg["type"] == "break"); + REQUIRE(msg.contains("break_duration")); +} + +// ============================================================================ +// TI_SCHEDULER_006: Idle Pauses Session +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_006_IdlePausesSession", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Start task + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + + // Go idle + f.io.injectMessage("monitoring:idle_detected", {{"idleSeconds", 300}}); + f.processWithTime(60.0f); + + // Verify module received and processed the idle message + // (Module logs "User idle" - we can verify via state) + auto state = f.module.getState(); + REQUIRE(state != nullptr); + // Task should still be tracked (idle doesn't clear it) + REQUIRE(state->getString("currentTaskId", "") == "task-1"); +} + +// ============================================================================ +// TI_SCHEDULER_007: Activity Resumes Session +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_007_ActivityResumesSession", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Start task, go idle, resume + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.io.injectMessage("monitoring:idle_detected", {}); + f.processWithTime(60.0f); + f.io.injectMessage("monitoring:activity_resumed", {}); + f.processWithTime(120.0f); + + // Verify session continues - task still active + auto state = f.module.getState(); + REQUIRE(state != nullptr); + REQUIRE(state->getString("currentTaskId", "") == "task-1"); +} + +// ============================================================================ +// TI_SCHEDULER_008: Tool Query Get Current Task +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_008_ToolQueryGetCurrentTask", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Start a task + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.io.clearPublished(); + + // Query current task + f.io.injectMessage("scheduler:query", { + {"action", "get_current_task"}, + {"correlation_id", "test-123"} + }); + f.processWithTime(60.0f); + + // Verify response + REQUIRE(f.io.wasPublished("scheduler:response")); + auto resp = f.io.getLastPublished("scheduler:response"); + REQUIRE(resp["correlation_id"] == "test-123"); + REQUIRE(resp["task_id"] == "task-1"); +} + +// ============================================================================ +// TI_SCHEDULER_009: Tool Command Start Break +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_009_ToolCommandStartBreak", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Start task + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.io.clearPublished(); + + // Command to start break + f.io.injectMessage("scheduler:command", { + {"action", "start_break"}, + {"duration_minutes", 15}, + {"reason", "test break"} + }); + f.processWithTime(60.0f); + + // Verify break started was published + REQUIRE(f.io.wasPublished("scheduler:break_started")); + auto msg = f.io.getLastPublished("scheduler:break_started"); + REQUIRE(msg["duration"] == 15); + REQUIRE(msg["reason"] == "test break"); + + // Verify response was also published + REQUIRE(f.io.wasPublished("scheduler:response")); + auto resp = f.io.getLastPublished("scheduler:response"); + REQUIRE(resp["success"] == true); +} + +// ============================================================================ +// TI_SCHEDULER_010: State Serialization +// ============================================================================ + +TEST_CASE("TI_SCHEDULER_010_StateSerialization", "[scheduler][integration]") { + SchedulerTestFixture f; + f.configure(); + + // Setup some state + f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); + f.processWithTime(0.0f); + f.processWithTime(1800.0f); // 30 minutes + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Verify state content + REQUIRE(state->getString("currentTaskId", "") == "task-1"); + REQUIRE(state->getBool("hyperfocusAlertSent", true) == false); + + // Create new module and restore state + SchedulerModule module2; + MockIO io2; + grove::JsonDataNode configNode("config", json::object()); + module2.setConfiguration(configNode, &io2, nullptr); + module2.setState(*state); + + // Verify state was restored + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + REQUIRE(state2->getString("currentTaskId", "") == "task-1"); + REQUIRE(state2->getBool("hyperfocusAlertSent", true) == false); +} diff --git a/tests/modules/StorageModuleTests.cpp b/tests/modules/StorageModuleTests.cpp index dd99833..b2f0289 100644 --- a/tests/modules/StorageModuleTests.cpp +++ b/tests/modules/StorageModuleTests.cpp @@ -1,293 +1,293 @@ -/** - * @file StorageModuleTests.cpp - * @brief Integration tests for StorageModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/StorageModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class StorageTestFixture { -public: - MockIO io; - TimeSimulator time; - StorageModule module; - - void configure(const json& config = json::object()) { - json fullConfig = json::object(); - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } -}; - -// ============================================================================ -// TI_STORAGE_001: Task Completed Saves Session -// ============================================================================ - -TEST_CASE("TI_STORAGE_001_TaskCompletedSavesSession", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Receive task completed - f.io.injectMessage("scheduler:task_completed", { - {"taskId", "task-1"}, - {"taskName", "Coding session"}, - {"durationMinutes", 45}, - {"hyperfocus", false} - }); - f.process(); - - // Verify save_session published - REQUIRE(f.io.wasPublished("storage:save_session")); - auto msg = f.io.getLastPublished("storage:save_session"); - REQUIRE(msg["taskName"] == "Coding session"); - REQUIRE(msg["durationMinutes"] == 45); -} - -// ============================================================================ -// TI_STORAGE_002: App Changed Saves Usage -// ============================================================================ - -TEST_CASE("TI_STORAGE_002_AppChangedSavesUsage", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Receive app changed with duration - f.io.injectMessage("monitoring:app_changed", { - {"appName", "Code"}, - {"oldApp", "Discord"}, - {"duration", 120}, - {"classification", "productive"} - }); - f.process(); - - // Verify save_app_usage published - REQUIRE(f.io.wasPublished("storage:save_app_usage")); - auto msg = f.io.getLastPublished("storage:save_app_usage"); - REQUIRE(msg["appName"] == "Discord"); // Old app that ended - REQUIRE(msg["durationSeconds"] == 120); -} - -// ============================================================================ -// TI_STORAGE_003: Session Saved Updates Last ID -// ============================================================================ - -TEST_CASE("TI_STORAGE_003_SessionSavedUpdatesLastId", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Receive session saved confirmation - f.io.injectMessage("storage:session_saved", { - {"sessionId", 42} - }); - f.process(); - - // Verify state updated - auto state = f.module.getState(); - // TODO: Verify lastSessionId == 42 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_STORAGE_004: Storage Error Handled -// ============================================================================ - -TEST_CASE("TI_STORAGE_004_StorageErrorHandled", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Receive storage error - f.io.injectMessage("storage:error", { - {"message", "Database locked"} - }); - - // Should not throw - REQUIRE_NOTHROW(f.process()); -} - -// ============================================================================ -// TI_STORAGE_005: Pending Saves Tracking -// ============================================================================ - -TEST_CASE("TI_STORAGE_005_PendingSavesTracking", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Trigger save - f.io.injectMessage("scheduler:task_completed", { - {"taskId", "t1"}, - {"taskName", "Task"}, - {"durationMinutes", 10} - }); - f.process(); - - // Verify pending incremented - auto state = f.module.getState(); - // TODO: Verify pendingSaves == 1 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_STORAGE_006: Total Saved Tracking -// ============================================================================ - -TEST_CASE("TI_STORAGE_006_TotalSavedTracking", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Save and confirm multiple times - for (int i = 0; i < 3; i++) { - f.io.injectMessage("scheduler:task_completed", { - {"taskId", "t" + std::to_string(i)}, - {"taskName", "Task"}, - {"durationMinutes", 10} - }); - f.process(); - f.io.injectMessage("storage:session_saved", {{"sessionId", i}}); - f.process(); - } - - // Verify total - auto state = f.module.getState(); - // TODO: Verify totalSaved == 3 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_STORAGE_007: Tool Query Notes -// ============================================================================ - -TEST_CASE("TI_STORAGE_007_ToolQueryNotes", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Add a note first - f.io.injectMessage("storage:command", { - {"action", "save_note"}, - {"content", "Test note"}, - {"tags", json::array({"test", "important"})} - }); - f.process(); - f.io.clearPublished(); - - // Query notes - f.io.injectMessage("storage:query", { - {"action", "query_notes"}, - {"correlation_id", "query-1"} - }); - f.process(); - - // Verify response - REQUIRE(f.io.wasPublished("storage:response")); - auto resp = f.io.getLastPublished("storage:response"); - REQUIRE(resp["correlation_id"] == "query-1"); -} - -// ============================================================================ -// TI_STORAGE_008: Tool Command Save Note -// ============================================================================ - -TEST_CASE("TI_STORAGE_008_ToolCommandSaveNote", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Save note - f.io.injectMessage("storage:command", { - {"action", "save_note"}, - {"content", "Remember to check logs"}, - {"tags", json::array({"reminder"})} - }); - f.process(); - - // Verify note added to state - auto state = f.module.getState(); - // TODO: Verify notes contains the new note - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_STORAGE_009: Note Tags Filtering -// ============================================================================ - -TEST_CASE("TI_STORAGE_009_NoteTagsFiltering", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Add notes with different tags - f.io.injectMessage("storage:command", { - {"action", "save_note"}, - {"content", "Work note"}, - {"tags", json::array({"work"})} - }); - f.process(); - f.io.injectMessage("storage:command", { - {"action", "save_note"}, - {"content", "Personal note"}, - {"tags", json::array({"personal"})} - }); - f.process(); - f.io.clearPublished(); - - // Query with tag filter - f.io.injectMessage("storage:query", { - {"action", "query_notes"}, - {"tags", json::array({"work"})}, - {"correlation_id", "filter-1"} - }); - f.process(); - - // Verify filtered response - REQUIRE(f.io.wasPublished("storage:response")); - auto resp = f.io.getLastPublished("storage:response"); - // TODO: Verify only work notes returned - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_STORAGE_010: State Serialization -// ============================================================================ - -TEST_CASE("TI_STORAGE_010_StateSerialization", "[storage][integration]") { - StorageTestFixture f; - f.configure(); - - // Build state with notes - f.io.injectMessage("storage:command", { - {"action", "save_note"}, - {"content", "Test note for serialization"}, - {"tags", json::array({"test"})} - }); - f.process(); - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Restore - StorageModule module2; - grove::JsonDataNode configNode2("config", json::object()); - module2.setConfiguration(configNode2, &f.io, nullptr); - module2.setState(*state); - - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - SUCCEED(); // Placeholder -} +/** + * @file StorageModuleTests.cpp + * @brief Integration tests for StorageModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/StorageModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class StorageTestFixture { +public: + MockIO io; + TimeSimulator time; + StorageModule module; + + void configure(const json& config = json::object()) { + json fullConfig = json::object(); + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } +}; + +// ============================================================================ +// TI_STORAGE_001: Task Completed Saves Session +// ============================================================================ + +TEST_CASE("TI_STORAGE_001_TaskCompletedSavesSession", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Receive task completed + f.io.injectMessage("scheduler:task_completed", { + {"taskId", "task-1"}, + {"taskName", "Coding session"}, + {"durationMinutes", 45}, + {"hyperfocus", false} + }); + f.process(); + + // Verify save_session published + REQUIRE(f.io.wasPublished("storage:save_session")); + auto msg = f.io.getLastPublished("storage:save_session"); + REQUIRE(msg["taskName"] == "Coding session"); + REQUIRE(msg["durationMinutes"] == 45); +} + +// ============================================================================ +// TI_STORAGE_002: App Changed Saves Usage +// ============================================================================ + +TEST_CASE("TI_STORAGE_002_AppChangedSavesUsage", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Receive app changed with duration + f.io.injectMessage("monitoring:app_changed", { + {"appName", "Code"}, + {"oldApp", "Discord"}, + {"duration", 120}, + {"classification", "productive"} + }); + f.process(); + + // Verify save_app_usage published + REQUIRE(f.io.wasPublished("storage:save_app_usage")); + auto msg = f.io.getLastPublished("storage:save_app_usage"); + REQUIRE(msg["appName"] == "Discord"); // Old app that ended + REQUIRE(msg["durationSeconds"] == 120); +} + +// ============================================================================ +// TI_STORAGE_003: Session Saved Updates Last ID +// ============================================================================ + +TEST_CASE("TI_STORAGE_003_SessionSavedUpdatesLastId", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Receive session saved confirmation + f.io.injectMessage("storage:session_saved", { + {"sessionId", 42} + }); + f.process(); + + // Verify state updated + auto state = f.module.getState(); + // TODO: Verify lastSessionId == 42 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_STORAGE_004: Storage Error Handled +// ============================================================================ + +TEST_CASE("TI_STORAGE_004_StorageErrorHandled", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Receive storage error + f.io.injectMessage("storage:error", { + {"message", "Database locked"} + }); + + // Should not throw + REQUIRE_NOTHROW(f.process()); +} + +// ============================================================================ +// TI_STORAGE_005: Pending Saves Tracking +// ============================================================================ + +TEST_CASE("TI_STORAGE_005_PendingSavesTracking", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Trigger save + f.io.injectMessage("scheduler:task_completed", { + {"taskId", "t1"}, + {"taskName", "Task"}, + {"durationMinutes", 10} + }); + f.process(); + + // Verify pending incremented + auto state = f.module.getState(); + // TODO: Verify pendingSaves == 1 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_STORAGE_006: Total Saved Tracking +// ============================================================================ + +TEST_CASE("TI_STORAGE_006_TotalSavedTracking", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Save and confirm multiple times + for (int i = 0; i < 3; i++) { + f.io.injectMessage("scheduler:task_completed", { + {"taskId", "t" + std::to_string(i)}, + {"taskName", "Task"}, + {"durationMinutes", 10} + }); + f.process(); + f.io.injectMessage("storage:session_saved", {{"sessionId", i}}); + f.process(); + } + + // Verify total + auto state = f.module.getState(); + // TODO: Verify totalSaved == 3 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_STORAGE_007: Tool Query Notes +// ============================================================================ + +TEST_CASE("TI_STORAGE_007_ToolQueryNotes", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Add a note first + f.io.injectMessage("storage:command", { + {"action", "save_note"}, + {"content", "Test note"}, + {"tags", json::array({"test", "important"})} + }); + f.process(); + f.io.clearPublished(); + + // Query notes + f.io.injectMessage("storage:query", { + {"action", "query_notes"}, + {"correlation_id", "query-1"} + }); + f.process(); + + // Verify response + REQUIRE(f.io.wasPublished("storage:response")); + auto resp = f.io.getLastPublished("storage:response"); + REQUIRE(resp["correlation_id"] == "query-1"); +} + +// ============================================================================ +// TI_STORAGE_008: Tool Command Save Note +// ============================================================================ + +TEST_CASE("TI_STORAGE_008_ToolCommandSaveNote", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Save note + f.io.injectMessage("storage:command", { + {"action", "save_note"}, + {"content", "Remember to check logs"}, + {"tags", json::array({"reminder"})} + }); + f.process(); + + // Verify note added to state + auto state = f.module.getState(); + // TODO: Verify notes contains the new note + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_STORAGE_009: Note Tags Filtering +// ============================================================================ + +TEST_CASE("TI_STORAGE_009_NoteTagsFiltering", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Add notes with different tags + f.io.injectMessage("storage:command", { + {"action", "save_note"}, + {"content", "Work note"}, + {"tags", json::array({"work"})} + }); + f.process(); + f.io.injectMessage("storage:command", { + {"action", "save_note"}, + {"content", "Personal note"}, + {"tags", json::array({"personal"})} + }); + f.process(); + f.io.clearPublished(); + + // Query with tag filter + f.io.injectMessage("storage:query", { + {"action", "query_notes"}, + {"tags", json::array({"work"})}, + {"correlation_id", "filter-1"} + }); + f.process(); + + // Verify filtered response + REQUIRE(f.io.wasPublished("storage:response")); + auto resp = f.io.getLastPublished("storage:response"); + // TODO: Verify only work notes returned + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_STORAGE_010: State Serialization +// ============================================================================ + +TEST_CASE("TI_STORAGE_010_StateSerialization", "[storage][integration]") { + StorageTestFixture f; + f.configure(); + + // Build state with notes + f.io.injectMessage("storage:command", { + {"action", "save_note"}, + {"content", "Test note for serialization"}, + {"tags", json::array({"test"})} + }); + f.process(); + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Restore + StorageModule module2; + grove::JsonDataNode configNode2("config", json::object()); + module2.setConfiguration(configNode2, &f.io, nullptr); + module2.setState(*state); + + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + SUCCEED(); // Placeholder +} diff --git a/tests/modules/VoiceModuleTests.cpp b/tests/modules/VoiceModuleTests.cpp index 6e8e18f..8a94211 100644 --- a/tests/modules/VoiceModuleTests.cpp +++ b/tests/modules/VoiceModuleTests.cpp @@ -1,258 +1,258 @@ -/** - * @file VoiceModuleTests.cpp - * @brief Integration tests for VoiceModule (10 TI) - */ - -#include -#include "mocks/MockIO.hpp" -#include "utils/TimeSimulator.hpp" -#include "utils/TestHelpers.hpp" - -#include "modules/VoiceModule.h" -#include - -using namespace aissia; -using namespace aissia::tests; - -// ============================================================================ -// Test Fixture -// ============================================================================ - -class VoiceTestFixture { -public: - MockIO io; - TimeSimulator time; - VoiceModule module; - - void configure(const json& config = json::object()) { - json fullConfig = { - {"ttsEnabled", true}, - {"sttEnabled", true}, - {"language", "fr"} - }; - fullConfig.merge_patch(config); - - grove::JsonDataNode configNode("config", fullConfig); - module.setConfiguration(configNode, &io, nullptr); - } - - void process() { - grove::JsonDataNode input("input", time.createInput()); - module.process(input); - } -}; - -// ============================================================================ -// TI_VOICE_001: AI Response Triggers Speak -// ============================================================================ - -TEST_CASE("TI_VOICE_001_AIResponseTriggersSpeak", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Receive AI response - f.io.injectMessage("ai:response", { - {"text", "Voici la reponse a ta question"} - }); - f.process(); - - // Verify speak request - REQUIRE(f.io.wasPublished("voice:speak")); - auto msg = f.io.getLastPublished("voice:speak"); - REQUIRE(msg["text"] == "Voici la reponse a ta question"); -} - -// ============================================================================ -// TI_VOICE_002: Suggestion Priority Speak -// ============================================================================ - -TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Receive suggestion (should be priority) - f.io.injectMessage("ai:suggestion", { - {"message", "Tu devrais faire une pause"}, - {"duration", 5} - }); - f.process(); - - // Verify speak with priority - REQUIRE(f.io.wasPublished("voice:speak")); - auto msg = f.io.getLastPublished("voice:speak"); - REQUIRE(msg["priority"] == true); -} - -// ============================================================================ -// TI_VOICE_003: Speaking Started Updates State -// ============================================================================ - -TEST_CASE("TI_VOICE_003_SpeakingStartedUpdatesState", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Initially idle - REQUIRE(f.module.isIdle() == true); - - // Receive speaking started - f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); - f.process(); - - // Should be speaking - REQUIRE(f.module.isIdle() == false); -} - -// ============================================================================ -// TI_VOICE_004: Speaking Ended Updates State -// ============================================================================ - -TEST_CASE("TI_VOICE_004_SpeakingEndedUpdatesState", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Start speaking - f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); - f.process(); - REQUIRE(f.module.isIdle() == false); - - // End speaking - f.io.injectMessage("voice:speaking_ended", {}); - f.process(); - - // Should be idle - REQUIRE(f.module.isIdle() == true); -} - -// ============================================================================ -// TI_VOICE_005: IsIdle Reflects Speaking -// ============================================================================ - -TEST_CASE("TI_VOICE_005_IsIdleReflectsSpeaking", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Not speaking = idle - REQUIRE(f.module.isIdle() == true); - - // Start speaking - f.io.injectMessage("voice:speaking_started", {}); - f.process(); - REQUIRE(f.module.isIdle() == false); - - // Stop speaking - f.io.injectMessage("voice:speaking_ended", {}); - f.process(); - REQUIRE(f.module.isIdle() == true); -} - -// ============================================================================ -// TI_VOICE_006: Transcription Forwarded (No Re-publish) -// ============================================================================ - -TEST_CASE("TI_VOICE_006_TranscriptionForwarded", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Receive transcription - f.io.injectMessage("voice:transcription", { - {"text", "Test transcription"}, - {"confidence", 0.9} - }); - f.process(); - - // VoiceModule should NOT re-publish transcription - // It just updates internal state - REQUIRE(f.io.countPublished("voice:transcription") == 0); -} - -// ============================================================================ -// TI_VOICE_007: Total Spoken Incremented -// ============================================================================ - -TEST_CASE("TI_VOICE_007_TotalSpokenIncremented", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Complete one speak cycle - f.io.injectMessage("voice:speaking_started", {}); - f.process(); - f.io.injectMessage("voice:speaking_ended", {}); - f.process(); - - // Complete another - f.io.injectMessage("voice:speaking_started", {}); - f.process(); - f.io.injectMessage("voice:speaking_ended", {}); - f.process(); - - // Verify counter - auto state = f.module.getState(); - // TODO: Verify totalSpoken == 2 - SUCCEED(); // Placeholder -} - -// ============================================================================ -// TI_VOICE_008: TTS Disabled Config -// ============================================================================ - -TEST_CASE("TI_VOICE_008_TTSDisabledConfig", "[voice][integration]") { - VoiceTestFixture f; - f.configure({{"ttsEnabled", false}}); - - // Try to trigger speak - f.io.injectMessage("ai:response", {{"text", "Should not speak"}}); - f.process(); - - // Should NOT publish speak request - REQUIRE(f.io.wasPublished("voice:speak") == false); -} - -// ============================================================================ -// TI_VOICE_009: Tool Command Speak -// ============================================================================ - -TEST_CASE("TI_VOICE_009_ToolCommandSpeak", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Send speak command via tool - f.io.injectMessage("voice:command", { - {"action", "speak"}, - {"text", "Hello from tool"} - }); - f.process(); - - // Verify speak published - REQUIRE(f.io.wasPublished("voice:speak")); - auto msg = f.io.getLastPublished("voice:speak"); - REQUIRE(msg["text"] == "Hello from tool"); -} - -// ============================================================================ -// TI_VOICE_010: State Serialization -// ============================================================================ - -TEST_CASE("TI_VOICE_010_StateSerialization", "[voice][integration]") { - VoiceTestFixture f; - f.configure(); - - // Build state - f.io.injectMessage("voice:speaking_started", {}); - f.process(); - f.io.injectMessage("voice:speaking_ended", {}); - f.process(); - - // Get state - auto state = f.module.getState(); - REQUIRE(state != nullptr); - - // Restore - VoiceModule module2; - grove::JsonDataNode configNode2("config", json::object()); - module2.setConfiguration(configNode2, &f.io, nullptr); - module2.setState(*state); - - auto state2 = module2.getState(); - REQUIRE(state2 != nullptr); - SUCCEED(); // Placeholder -} +/** + * @file VoiceModuleTests.cpp + * @brief Integration tests for VoiceModule (10 TI) + */ + +#include +#include "mocks/MockIO.hpp" +#include "utils/TimeSimulator.hpp" +#include "utils/TestHelpers.hpp" + +#include "modules/VoiceModule.h" +#include + +using namespace aissia; +using namespace aissia::tests; + +// ============================================================================ +// Test Fixture +// ============================================================================ + +class VoiceTestFixture { +public: + MockIO io; + TimeSimulator time; + VoiceModule module; + + void configure(const json& config = json::object()) { + json fullConfig = { + {"ttsEnabled", true}, + {"sttEnabled", true}, + {"language", "fr"} + }; + fullConfig.merge_patch(config); + + grove::JsonDataNode configNode("config", fullConfig); + module.setConfiguration(configNode, &io, nullptr); + } + + void process() { + grove::JsonDataNode input("input", time.createInput()); + module.process(input); + } +}; + +// ============================================================================ +// TI_VOICE_001: AI Response Triggers Speak +// ============================================================================ + +TEST_CASE("TI_VOICE_001_AIResponseTriggersSpeak", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Receive AI response + f.io.injectMessage("ai:response", { + {"text", "Voici la reponse a ta question"} + }); + f.process(); + + // Verify speak request + REQUIRE(f.io.wasPublished("voice:speak")); + auto msg = f.io.getLastPublished("voice:speak"); + REQUIRE(msg["text"] == "Voici la reponse a ta question"); +} + +// ============================================================================ +// TI_VOICE_002: Suggestion Priority Speak +// ============================================================================ + +TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Receive suggestion (should be priority) + f.io.injectMessage("ai:suggestion", { + {"message", "Tu devrais faire une pause"}, + {"duration", 5} + }); + f.process(); + + // Verify speak with priority + REQUIRE(f.io.wasPublished("voice:speak")); + auto msg = f.io.getLastPublished("voice:speak"); + REQUIRE(msg["priority"] == true); +} + +// ============================================================================ +// TI_VOICE_003: Speaking Started Updates State +// ============================================================================ + +TEST_CASE("TI_VOICE_003_SpeakingStartedUpdatesState", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Initially idle + REQUIRE(f.module.isIdle() == true); + + // Receive speaking started + f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); + f.process(); + + // Should be speaking + REQUIRE(f.module.isIdle() == false); +} + +// ============================================================================ +// TI_VOICE_004: Speaking Ended Updates State +// ============================================================================ + +TEST_CASE("TI_VOICE_004_SpeakingEndedUpdatesState", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Start speaking + f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); + f.process(); + REQUIRE(f.module.isIdle() == false); + + // End speaking + f.io.injectMessage("voice:speaking_ended", {}); + f.process(); + + // Should be idle + REQUIRE(f.module.isIdle() == true); +} + +// ============================================================================ +// TI_VOICE_005: IsIdle Reflects Speaking +// ============================================================================ + +TEST_CASE("TI_VOICE_005_IsIdleReflectsSpeaking", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Not speaking = idle + REQUIRE(f.module.isIdle() == true); + + // Start speaking + f.io.injectMessage("voice:speaking_started", {}); + f.process(); + REQUIRE(f.module.isIdle() == false); + + // Stop speaking + f.io.injectMessage("voice:speaking_ended", {}); + f.process(); + REQUIRE(f.module.isIdle() == true); +} + +// ============================================================================ +// TI_VOICE_006: Transcription Forwarded (No Re-publish) +// ============================================================================ + +TEST_CASE("TI_VOICE_006_TranscriptionForwarded", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Receive transcription + f.io.injectMessage("voice:transcription", { + {"text", "Test transcription"}, + {"confidence", 0.9} + }); + f.process(); + + // VoiceModule should NOT re-publish transcription + // It just updates internal state + REQUIRE(f.io.countPublished("voice:transcription") == 0); +} + +// ============================================================================ +// TI_VOICE_007: Total Spoken Incremented +// ============================================================================ + +TEST_CASE("TI_VOICE_007_TotalSpokenIncremented", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Complete one speak cycle + f.io.injectMessage("voice:speaking_started", {}); + f.process(); + f.io.injectMessage("voice:speaking_ended", {}); + f.process(); + + // Complete another + f.io.injectMessage("voice:speaking_started", {}); + f.process(); + f.io.injectMessage("voice:speaking_ended", {}); + f.process(); + + // Verify counter + auto state = f.module.getState(); + // TODO: Verify totalSpoken == 2 + SUCCEED(); // Placeholder +} + +// ============================================================================ +// TI_VOICE_008: TTS Disabled Config +// ============================================================================ + +TEST_CASE("TI_VOICE_008_TTSDisabledConfig", "[voice][integration]") { + VoiceTestFixture f; + f.configure({{"ttsEnabled", false}}); + + // Try to trigger speak + f.io.injectMessage("ai:response", {{"text", "Should not speak"}}); + f.process(); + + // Should NOT publish speak request + REQUIRE(f.io.wasPublished("voice:speak") == false); +} + +// ============================================================================ +// TI_VOICE_009: Tool Command Speak +// ============================================================================ + +TEST_CASE("TI_VOICE_009_ToolCommandSpeak", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Send speak command via tool + f.io.injectMessage("voice:command", { + {"action", "speak"}, + {"text", "Hello from tool"} + }); + f.process(); + + // Verify speak published + REQUIRE(f.io.wasPublished("voice:speak")); + auto msg = f.io.getLastPublished("voice:speak"); + REQUIRE(msg["text"] == "Hello from tool"); +} + +// ============================================================================ +// TI_VOICE_010: State Serialization +// ============================================================================ + +TEST_CASE("TI_VOICE_010_StateSerialization", "[voice][integration]") { + VoiceTestFixture f; + f.configure(); + + // Build state + f.io.injectMessage("voice:speaking_started", {}); + f.process(); + f.io.injectMessage("voice:speaking_ended", {}); + f.process(); + + // Get state + auto state = f.module.getState(); + REQUIRE(state != nullptr); + + // Restore + VoiceModule module2; + grove::JsonDataNode configNode2("config", json::object()); + module2.setConfiguration(configNode2, &f.io, nullptr); + module2.setState(*state); + + auto state2 = module2.getState(); + REQUIRE(state2 != nullptr); + SUCCEED(); // Placeholder +} diff --git a/tests/utils/TestHelpers.hpp b/tests/utils/TestHelpers.hpp index 12a5ef0..3517d6e 100644 --- a/tests/utils/TestHelpers.hpp +++ b/tests/utils/TestHelpers.hpp @@ -1,82 +1,82 @@ -#pragma once - -#include -#include -#include - -namespace aissia::tests { - -using json = nlohmann::json; - -// ============================================================================ -// Custom Catch2 Matchers and Macros -// ============================================================================ - -/** - * @brief Require that a message was published to a topic - */ -#define REQUIRE_PUBLISHED(io, topic) \ - REQUIRE_MESSAGE(io.wasPublished(topic), "Expected message on topic: " << topic) - -/** - * @brief Require that no message was published to a topic - */ -#define REQUIRE_NOT_PUBLISHED(io, topic) \ - REQUIRE_MESSAGE(!io.wasPublished(topic), "Did not expect message on topic: " << topic) - -/** - * @brief Require specific count of messages on a topic - */ -#define REQUIRE_PUBLISH_COUNT(io, topic, count) \ - REQUIRE(io.countPublished(topic) == count) - -// ============================================================================ -// JSON Helpers -// ============================================================================ - -/** - * @brief Create a minimal valid config for a module - */ -inline json makeConfig(const json& overrides = json::object()) { - json config = json::object(); - for (auto& [key, value] : overrides.items()) { - config[key] = value; - } - return config; -} - -/** - * @brief Check if JSON contains expected fields - */ -inline bool jsonContains(const json& j, const json& expected) { - for (auto& [key, value] : expected.items()) { - if (!j.contains(key) || j[key] != value) { - return false; - } - } - return true; -} - -// ============================================================================ -// Test Tags -// ============================================================================ - -// Module tags -constexpr const char* TAG_SCHEDULER = "[scheduler]"; -constexpr const char* TAG_NOTIFICATION = "[notification]"; -constexpr const char* TAG_MONITORING = "[monitoring]"; -constexpr const char* TAG_AI = "[ai]"; -constexpr const char* TAG_VOICE = "[voice]"; -constexpr const char* TAG_STORAGE = "[storage]"; - -// MCP tags -constexpr const char* TAG_MCP = "[mcp]"; -constexpr const char* TAG_MCP_TYPES = "[mcp][types]"; -constexpr const char* TAG_MCP_TRANSPORT = "[mcp][transport]"; -constexpr const char* TAG_MCP_CLIENT = "[mcp][client]"; - -// Common tags -constexpr const char* TAG_INTEGRATION = "[integration]"; -constexpr const char* TAG_UNIT = "[unit]"; - -} // namespace aissia::tests +#pragma once + +#include +#include +#include + +namespace aissia::tests { + +using json = nlohmann::json; + +// ============================================================================ +// Custom Catch2 Matchers and Macros +// ============================================================================ + +/** + * @brief Require that a message was published to a topic + */ +#define REQUIRE_PUBLISHED(io, topic) \ + REQUIRE_MESSAGE(io.wasPublished(topic), "Expected message on topic: " << topic) + +/** + * @brief Require that no message was published to a topic + */ +#define REQUIRE_NOT_PUBLISHED(io, topic) \ + REQUIRE_MESSAGE(!io.wasPublished(topic), "Did not expect message on topic: " << topic) + +/** + * @brief Require specific count of messages on a topic + */ +#define REQUIRE_PUBLISH_COUNT(io, topic, count) \ + REQUIRE(io.countPublished(topic) == count) + +// ============================================================================ +// JSON Helpers +// ============================================================================ + +/** + * @brief Create a minimal valid config for a module + */ +inline json makeConfig(const json& overrides = json::object()) { + json config = json::object(); + for (auto& [key, value] : overrides.items()) { + config[key] = value; + } + return config; +} + +/** + * @brief Check if JSON contains expected fields + */ +inline bool jsonContains(const json& j, const json& expected) { + for (auto& [key, value] : expected.items()) { + if (!j.contains(key) || j[key] != value) { + return false; + } + } + return true; +} + +// ============================================================================ +// Test Tags +// ============================================================================ + +// Module tags +constexpr const char* TAG_SCHEDULER = "[scheduler]"; +constexpr const char* TAG_NOTIFICATION = "[notification]"; +constexpr const char* TAG_MONITORING = "[monitoring]"; +constexpr const char* TAG_AI = "[ai]"; +constexpr const char* TAG_VOICE = "[voice]"; +constexpr const char* TAG_STORAGE = "[storage]"; + +// MCP tags +constexpr const char* TAG_MCP = "[mcp]"; +constexpr const char* TAG_MCP_TYPES = "[mcp][types]"; +constexpr const char* TAG_MCP_TRANSPORT = "[mcp][transport]"; +constexpr const char* TAG_MCP_CLIENT = "[mcp][client]"; + +// Common tags +constexpr const char* TAG_INTEGRATION = "[integration]"; +constexpr const char* TAG_UNIT = "[unit]"; + +} // namespace aissia::tests diff --git a/tests/utils/TimeSimulator.hpp b/tests/utils/TimeSimulator.hpp index 94e0769..b6f2989 100644 --- a/tests/utils/TimeSimulator.hpp +++ b/tests/utils/TimeSimulator.hpp @@ -1,92 +1,92 @@ -#pragma once - -#include -#include -#include - -namespace aissia::tests { - -using json = nlohmann::json; - -/** - * @brief Simulates game time for testing modules - * - * Modules receive time info via process() input: - * { - * "gameTime": 123.45, // Total elapsed time in seconds - * "deltaTime": 0.1 // Time since last frame - * } - */ -class TimeSimulator { -public: - TimeSimulator() = default; - - /** - * @brief Create input data for module.process() - * @param deltaTime Time since last frame (default 0.1s = 10Hz) - */ - json createInput(float deltaTime = 0.1f) { - json input = { - {"gameTime", m_gameTime}, - {"deltaTime", deltaTime} - }; - m_gameTime += deltaTime; - return input; - } - - /** - * @brief Create input as IDataNode - */ - std::unique_ptr createInputNode(float deltaTime = 0.1f) { - return std::make_unique("input", createInput(deltaTime)); - } - - /** - * @brief Advance time without creating input - */ - void advance(float seconds) { - m_gameTime += seconds; - } - - /** - * @brief Advance time by minutes (convenience for hyperfocus tests) - */ - void advanceMinutes(float minutes) { - m_gameTime += minutes * 60.0f; - } - - /** - * @brief Set absolute time - */ - void setTime(float time) { - m_gameTime = time; - } - - /** - * @brief Get current game time - */ - float getTime() const { - return m_gameTime; - } - - /** - * @brief Reset to zero - */ - void reset() { - m_gameTime = 0.0f; - } - - /** - * @brief Simulate multiple frames - * @param count Number of frames to simulate - * @param deltaTime Time per frame - */ - void simulateFrames(int count, float deltaTime = 0.1f) { - m_gameTime += count * deltaTime; - } - -private: - float m_gameTime = 0.0f; -}; - -} // namespace aissia::tests +#pragma once + +#include +#include +#include + +namespace aissia::tests { + +using json = nlohmann::json; + +/** + * @brief Simulates game time for testing modules + * + * Modules receive time info via process() input: + * { + * "gameTime": 123.45, // Total elapsed time in seconds + * "deltaTime": 0.1 // Time since last frame + * } + */ +class TimeSimulator { +public: + TimeSimulator() = default; + + /** + * @brief Create input data for module.process() + * @param deltaTime Time since last frame (default 0.1s = 10Hz) + */ + json createInput(float deltaTime = 0.1f) { + json input = { + {"gameTime", m_gameTime}, + {"deltaTime", deltaTime} + }; + m_gameTime += deltaTime; + return input; + } + + /** + * @brief Create input as IDataNode + */ + std::unique_ptr createInputNode(float deltaTime = 0.1f) { + return std::make_unique("input", createInput(deltaTime)); + } + + /** + * @brief Advance time without creating input + */ + void advance(float seconds) { + m_gameTime += seconds; + } + + /** + * @brief Advance time by minutes (convenience for hyperfocus tests) + */ + void advanceMinutes(float minutes) { + m_gameTime += minutes * 60.0f; + } + + /** + * @brief Set absolute time + */ + void setTime(float time) { + m_gameTime = time; + } + + /** + * @brief Get current game time + */ + float getTime() const { + return m_gameTime; + } + + /** + * @brief Reset to zero + */ + void reset() { + m_gameTime = 0.0f; + } + + /** + * @brief Simulate multiple frames + * @param count Number of frames to simulate + * @param deltaTime Time per frame + */ + void simulateFrames(int count, float deltaTime = 0.1f) { + m_gameTime += count * deltaTime; + } + +private: + float m_gameTime = 0.0f; +}; + +} // namespace aissia::tests