From b83cd2a790ae880f9fcae5e23c5cb9426e296fb9 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Tue, 26 May 2026 17:48:23 +0200 Subject: [PATCH 1/2] search improvments, chat, improvments --- README.md | 351 ++++++++++++++++++++++----- READMEnew.md | 392 ------------------------------- backend/managers/chat_manager.py | 19 +- backend/managers/file_manager.py | 33 ++- frontend/chat.py | 32 ++- frontend/editor.py | 228 +++++++++--------- frontend/sidebar.py | 16 ++ 7 files changed, 487 insertions(+), 584 deletions(-) delete mode 100644 READMEnew.md diff --git a/README.md b/README.md index be4f596..44a32e2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,24 @@ -# AISE AI Code Editor +# AISE AI Code Editor — Technische Dokumentation AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) +Diese Datei enthält die ausführliche technische Dokumentation des Projekts. Für eine Kurzübersicht siehe `README.md`. + +--- + +## Inhaltsverzeichnis + +1. [Projektstruktur](#projektstruktur) +2. [Frontend](#frontend) +3. [Backend Manager](#backend-manager) +4. [Backend Agent (MCP-System)](#backend-agent-mcp-system) +5. [MCP-Server-Konfiguration](#mcp-server-konfiguration) +6. [Tests](#tests) +7. [Setup](#setup) +8. [Architektur-Übersicht](#architektur-übersicht) + +--- + ## Projektstruktur ``` @@ -45,48 +62,248 @@ AISE_AIAgent/ │ └── test_mcp_server_web_search.py │ ├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) -├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent └── .env.example # Vorlage für Umgebungsvariablen ``` -## Komponenten +--- -### Frontend (`frontend/`) -- **app.py**: Streamlit-Applikation, Layout-Orchestrierung -- **state.py**: Zentralisierte Session-State-Verwaltung -- **sidebar.py**: Datei-Browser und Projekt-Navigation -- **editor.py**: Code-Editor mit Syntax-Highlighting -- **chat.py**: AI-Assistent Chat-Interface +## Frontend -### Backend Manager (`backend/managers/`) -Werden direkt vom Frontend für UI-Operationen genutzt: -- **file_manager.py**: CRUD-Operationen auf Projektdateien (`get_file_tree()` liefert die verschachtelte Baumstruktur für den File Explorer; eine flache `list_files()` wurde bewusst nicht implementiert, da das Frontend die Baumstruktur benötigt — für den Agent Mode übernimmt `mcp_server_file_search.py` die Dateisuche) -- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung (Fehler aus der Code-Ausführung werden im normalen Chat bewusst per "Debug with AI"-Button manuell an den Chat übergeben — der User entscheidet selbst wann die AI eingeschaltet wird; im Agent Mode geschieht dies automatisch über den Plan-Act-Observe-Loop) -- **system_prompter.py**: System-Prompt-Generierung und Datei-Kontext -- **execution_engine.py**: Sichere Code-Ausführung mit Output-Capture -- **debug_logger.py**: Fehler-Tracking und Log-Formatierung (`format_debug_output()` formatiert Execution-Output für UI und AI-Chat; `log_error()` wurde bewusst nicht als separate Methode implementiert — Python's Standard-`logging`-Modul mit `logger.error()` deckt diese Funktionalität bereits vollständig ab und wird konsequent im gesamten Code verwendet) -- **search_manager.py**: Web-Suche via DuckDuckGo (`ddgs`-Bibliothek) +Das Frontend besteht aus Streamlit-Komponenten, die zusammen eine interaktive Code-Editor-Oberfläche bilden. -### Backend Agent (`backend/agent/`) -Autonomes AI-Agent-System für komplexe Coding-Aufgaben: -- **coding_agent.py**: Agent-Loop (Plan → Act → Observe → Wiederholen) -- **mcp_server_adapter.py**: Verbindet den Agent mit MCP-Servern via Konfigurationsdatei -- **mcp_server_adapter_RAG.py**: Erweiterter Adapter mit semantischer Tool-Auswahl (RAG) -- **mcp_server_config.json**: Definiert welche MCP-Server gestartet werden und mit welchen Argumenten -- **servers/**: Die eigentlichen MCP-Tool-Server (Code-Ausführung, Datei-Suche, Web-Suche) +### `app.py` +Haupteinstiegspunkt der Applikation. Orchestriert das Layout und initialisiert alle UI-Komponenten (Sidebar, Editor, Chat). -### Workspace (`workspace/`) -- Sandbox-Verzeichnis, in dem der Agent Dateien erstellt und ausführt -- Verhindert, dass der Agent auf Dateien ausserhalb dieses Verzeichnisses zugreift +### `state.py` +Zentralisierte Verwaltung des Streamlit Session-State. Stellt sicher, dass alle Komponenten denselben Zustand (geöffnete Datei, Chat-History, Agent-Status) teilen. -## Features +### `sidebar.py` +Datei-Browser und Projekt-Navigation. Erlaubt das Durchsuchen des Workspaces und das Öffnen von Dateien im Editor. -- **Datei-Verwaltung**: Dateien im Workspace durchsuchen und bearbeiten -- **Chat-Interface**: KI-gestützter Code-Assistent -- **Code-Ausführung**: Python-Code sicher ausführen mit Debug-Output -- **Web-Suche**: Dokumentation und Beispiele via DuckDuckGo abrufen -- **Autonomer Agent**: MCP-basierter Coding-Agent mit Plan-Act-Observe-Loop -- **RAG Tool-Auswahl**: Semantische Tool-Selektion via Sentence Transformers +### `editor.py` +Code-Editor-Pane mit Syntax-Highlighting. Ermöglicht das Bearbeiten und Speichern von Code-Dateien direkt im Browser. + +### `chat.py` +Chat-Interface für den KI-Assistenten. Zeigt die Konversations-History und ermöglicht Eingaben an das AI-Modell. + +--- + +## Backend Manager + +Die Manager-Klassen kapseln die Business-Logik und werden direkt vom Frontend aufgerufen. + +### `file_manager.py` +Stellt CRUD-Operationen auf dem Workspace-Verzeichnis bereit: +- Dateien lesen, schreiben, umbenennen, löschen +- Verzeichnisstruktur auflisten +- Sichere Pfadvalidierung (verhindert Path-Traversal) + +**Designentscheidung — `list_files()` vs. `get_file_tree()`:** +Die Projektspezifikation nennt `list_files()` als `FileManager`-Methode. Im vorliegenden Design wurde bewusst `get_file_tree()` implementiert, da das Frontend eine verschachtelte Baumstruktur benötigt (für den interaktiven File Explorer in der Sidebar). Eine flache Liste würde die Navigation nicht unterstützen. Für den Agent Mode übernimmt der MCP-Server `mcp_server_file_search.py` die Dateisuche — die Funktionalität ist damit im System vorhanden, nur architektonisch sauber getrennt. + +### `chat_manager.py` +Verwaltet AI-Chat-Interaktionen: +- Aufbau und Verwaltung der Chat-History +- Senden von Nachrichten an das AI-Modell +- Formatierung von System- und User-Nachrichten + +**Designentscheidung — Fehler-Output im normalen Chat:** +Laufzeitfehler und stderr-Output werden im normalen Chat bewusst **nicht automatisch** in den Chat-Kontext injiziert. Stattdessen gibt es den "Debug with AI"-Button im Editor, über den der User selbst entscheidet wann er die AI einschalten möchte. Dies verhindert, dass die Chat-History mit ungewollten Fehlermeldungen geflutet wird. Im Agent Mode wird dies anders gelöst: dort landet jeder Execution-Fehler automatisch als Observation im Plan-Act-Observe-Loop und der Agent replant ohne User-Eingriff. + +### `system_prompter.py` +Generiert kontextreiche System-Prompts für den AI-Assistenten: +- Injektion von aktuellem Dateiinhalt als Kontext +- Steuerung des AI-Verhaltens (Coding-Assistent-Persona) + +### `execution_engine.py` +Führt Python-Code sicher aus: +- Subprocess-basierte Code-Ausführung +- Timeout-Schutz und Output-Capture +- Fehler- und Exception-Handling + +### `debug_logger.py` +Logging und Fehler-Tracking: +- Formatierte Log-Ausgaben für Debugging +- `format_debug_output(output)` formatiert den Execution-Output (`stdout`, `stderr`, `return_code`) in einen einheitlichen String für die UI-Anzeige und den AI-Chat-Kontext + +**Designentscheidung — `log_error()` nicht implementiert:** +Die Projektspezifikation nennt `log_error()` als `DebugLogger`-Methode. Diese wurde bewusst nicht als separate Methode implementiert, da Python's eingebautes `logging`-Modul diese Funktionalität mit `logger.error()` bereits vollständig abdeckt. Im gesamten Projekt wird konsistent `logger = get_logger(__name__)` gefolgt von `logger.error(...)` verwendet — eine eigene Wrapper-Methode wäre toter Code ohne Mehrwert. + +### `search_manager.py` +Web-Suche für den KI-Assistenten via DuckDuckGo: +- Nutzt die `ddgs`-Bibliothek (DuckDuckGo Search) für API-freie Websuche +- Gibt strukturierte Suchergebnisse zurück (Titel, URL, Snippet) +- Wird vom Chat-Manager aufgerufen, wenn der Assistent externe Dokumentation oder Code-Beispiele benötigt +- Keine API-Key-Konfiguration notwendig (da DuckDuckGo öffentlich zugänglich ist) + +--- + +## Backend Agent (MCP-System) + +Der Agent ist ein autonomes System, das komplexe Coding-Aufgaben selbstständig löst. Er kommuniziert mit externen Tool-Servern über das **Model Context Protocol (MCP)**. + +### `coding_agent.py` +Implementiert den Plan-Act-Observe-Loop: +1. **Plan**: Das AI-Modell wählt das nächste Tool und Argumente +2. **Act**: Das Tool wird via MCP-Adapter aufgerufen (nach User-Bestätigung) +3. **Observe**: Das Ergebnis wird in die Message-History eingefügt +4. Der Loop wiederholt sich bis zur Fertigstellung oder einem `done`-Tool-Aufruf + +Wichtige Klassen und Funktionen: +- `CodingAgent`: Haupt-Klasse mit `start_task()`, `propose_next_action()`, `approve()`, `reject()` +- `truncate_result()`: Kürzt lange Tool-Outputs bevor sie in die History gehen +- `trim_messages()`: Entfernt alte Turns aus der History wenn das Kontextfenster voll wird +- `_strip_code_fences()`: Bereinigt Markdown-Fences aus LLM-JSON-Antworten + +Konstanten: `MAX_ITERATIONS`, `MAX_RESULT_LENGTH`, `MAX_HISTORY_CHARS` + +### `mcp_server_adapter.py` +Verbindet den Coding-Agent mit den MCP-Tool-Servern: +- Liest `mcp_server_config.json` und startet die konfigurierten Server als Subprozesse +- Baut MCP-Sessions via `stdio_client` auf +- Registriert alle verfügbaren Tools aus allen Servern in einem zentralen `tool_registry` +- Delegiert Tool-Aufrufe an den richtigen Server via `call_tool()` + +Hauptmethoden: +- `initialize_all_servers()`: Startet alle Server und baut Sessions auf +- `get_all_tools()`: Gibt alle registrierten Tool-Definitionen zurück +- `call_tool(tool_name, arguments)`: Führt ein Tool auf dem zuständigen Server aus + +### `mcp_server_adapter_RAG.py` +Erweiterter MCP-Adapter mit semantischer Tool-Auswahl via Retrieval-Augmented Generation (RAG): + +**Motivation**: Bei vielen MCP-Tools kann das LLM-Kontextfenster überfüllt werden, wenn alle Tool-Definitionen mitgesendet werden. Der RAG-Adapter löst dies durch semantische Vorauswahl. + +**Funktionsweise**: +1. Beim Initialisieren werden alle Tool-Beschreibungen mit `SentenceTransformer('all-MiniLM-L6-v2')` in Embeddings umgewandelt +2. Bei jedem Agent-Schritt wird der aktuelle Task als Query kodiert +3. Cosine-Similarity zwischen Query- und Tool-Embeddings bestimmt die `top_k` relevantesten Tools +4. Nur diese Tools werden dem LLM als verfügbare Aktionen präsentiert + +Hauptmethoden: +- `initialize_all_sessions()`: Startet Server, baut Sessions auf, erstellt Embedding-Index +- `get_relevant_tools(query, top_k=5)`: Gibt die `top_k` semantisch ähnlichsten Tools zurück +- `call_tool(tool_name, arguments)`: Findet den zuständigen Server und führt das Tool aus +- `shutdown_all_sessions()`: Schliesst alle offenen MCP-Sessions sauber + +Abhängigkeit: `sentence-transformers`, `numpy` + +**Hinweis**: Diese Klasse befindet sich noch in der Entwicklung (Work in Progress). Es gibt bekannte Bugs (z.B. Tippfehler `commanf` statt `command`, falsche Verwendung von `result.get()` vs. `result.tools`). + +--- + +## MCP-Server-Konfiguration + +### Format: `mcp_server_config.json` + +Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Adapter starten soll. Das Format ist ein JSON-Objekt, wobei jeder Key ein frei wählbarer Servername ist: + +```json +{ + "ServerName": { + "command": "py", + "args": ["servers/mcp_server_datei.py"], + "env": { + "API_KEY": "optional_key" + } + } +} +``` + +| Feld | Pflicht | Beschreibung | +|-----------|---------|--------------| +| `command` | Ja | Ausführbares Programm (z.B. `py`, `python3`, `node`) | +| `args` | Ja | Argumente als Array (Pfad zum Server-Script) | +| `env` | Nein | Umgebungsvariablen für den Serverprozess | + +### Aktuelle Server + +```json +{ + "FileSearchServer": { + "command": "py", + "args": ["servers/mcp_server_file_search.py"] + }, + "WebSearchServer": { + "command": "py", + "args": ["servers/mcp_server_web_search.py"], + "env": { "DDGS_API_KEY": "your_ddgs_api_key_here" } + }, + "CodeExecutionServer": { + "command": "py", + "args": ["servers/mcp_server_code_execution.py"] + } +} +``` + +### Neuen MCP-Server hinzufügen + +1. Neues Server-Script in `backend/agent/servers/` erstellen (MCP-konformes Python-Script) +2. Eintrag in `mcp_server_config.json` ergänzen: + ```json + "MeinNeuerServer": { + "command": "py", + "args": ["servers/mcp_server_mein_tool.py"] + } + ``` +3. Der Adapter erkennt den neuen Server beim nächsten Start automatisch und registriert seine Tools + +--- + +## Tests + +### Tests ausführen + +```bash +# Alle Tests ausführen +pytest tests/ -v + +# Einzelnes Test-Modul ausführen +pytest tests/test_coding_agent.py -v + +# Tests mit Kurzausgabe +pytest tests/ +``` + +### Teststruktur + +Die Tests liegen in `tests/` und folgen dem Muster `test_.py`. + +#### `conftest.py` +Globale Pytest-Konfiguration. Patcht den `MCPToolAdapter` auf `sys.modules`-Ebene, bevor irgendein Test-Modul importiert wird. Dadurch werden beim Import von `coding_agent` keine echten MCP-Subprozesse gestartet. Der Mock-Adapter liefert sofort leere Ergebnisse zurück. + +#### Was wird getestet + +| Test-Datei | Getestetes Modul | Schwerpunkt | +|---|---|---| +| `test_file_manager.py` | `backend/managers/file_manager.py` | Datei-CRUD, Pfad-Validierung | +| `test_chat_manager.py` | `backend/managers/chat_manager.py` | Chat-History, Nachrichtenformatierung | +| `test_execution_engine.py` | `backend/managers/execution_engine.py` | Code-Ausführung, Timeouts, Fehlerbehandlung | +| `test_system_prompter.py` | `backend/managers/system_prompter.py` | Prompt-Generierung, Kontext-Injektion | +| `test_debug_logger.py` | `backend/managers/debug_logger.py` | Log-Formatierung, Fehler-Aggregation | +| `test_coding_agent.py` | `backend/agent/coding_agent.py` | Agent-Loop, Tool-Dispatch, History-Trimming | +| `test_mcp_server_code_execution.py` | `backend/agent/servers/mcp_server_code_execution.py` | MCP Code-Execution-Tool | +| `test_mcp_server_file_search.py` | `backend/agent/servers/mcp_server_file_search.py` | MCP Datei-Such-Tool | +| `test_mcp_server_web_search.py` | `backend/agent/servers/mcp_server_web_search.py` | MCP Web-Such-Tool | + +#### Testklassen in `test_coding_agent.py` + +- **`TestTruncateResult`**: Prüft, dass lange Tool-Outputs korrekt gekürzt werden +- **`TestTrimMessages`**: Prüft, dass alte History-Turns entfernt werden wenn der Kontext zu gross wird; System-Message und Original-Task bleiben immer erhalten +- **`TestStripCodeFences`**: Prüft, dass Markdown-Codeblöcke aus LLM-Antworten entfernt werden +- **`TestCodingAgentInit`**: Prüft initialen Zustand und `start_task()`-Reset-Verhalten +- **`TestProposeNextAction`**: Prüft den API-Aufruf-Zyklus mit gemockter API; testet Fehler-Handling (JSON-Parse-Fehler, API-Exceptions, Max-Iterations) +- **`TestApprove`**: Prüft `approve()` mit gemocktem `dispatch_tool`; testet Tool-Ergebnis-Injektion und Error-Replan-Tagging +- **`TestReject`**: Prüft, dass `reject()` das Feedback korrekt in die History injiziert und kein Tool ausführt + +#### Test-Konventionen + +- MCP-Server werden in Tests **nicht** als echte Subprozesse gestartet (via `conftest.py`-Mock) +- Streamlit-Aufrufe werden mit `patch("modul.st")` gemockt +- Dateisystem-Tests nutzen `tmp_path` (pytest-Fixture) für isolierte temporäre Verzeichnisse +- Async-Tests verwenden `@pytest.mark.asyncio` (benötigt `pytest-asyncio`) + +--- ## Setup @@ -132,37 +349,43 @@ streamlit run frontend/app.py pytest tests/ -v ``` -## Konfiguration: `mcp_server_config.json` +--- -Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Agent starten soll. Jeder Eintrag enthält den Servernamen, den Startbefehl (`command`) und optionale Argumente (`args`) sowie Umgebungsvariablen (`env`): - -```json -{ - "FileSearchServer": { - "command": "py", - "args": ["servers/mcp_server_file_search.py"] - }, - "WebSearchServer": { - "command": "py", - "args": ["servers/mcp_server_web_search.py"], - "env": { "DDGS_API_KEY": "your_key_here" } - } -} -``` - -## Architektur +## Architektur-Übersicht ``` -Frontend (Streamlit) ──► Backend Manager ──► AI API - │ - └──► Coding Agent ──► MCP-Adapter ──► MCP-Server - (Code / File / Web) -``` - -## Entwicklung - -```bash -git add . -git commit -m "Deine Nachricht" -git push origin main +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Streamlit) │ +│ app.py → sidebar.py / editor.py / chat.py │ +│ │ │ +│ state.py (Session-State) │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend Manager │ +│ FileManager / ChatManager / SystemPrompter / │ +│ SearchManager / ExecutionEngine / DebugLogger │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Coding Agent (backend/agent/) │ +│ coding_agent.py ←→ mcp_server_adapter.py │ +│ │ │ +│ mcp_server_config.json │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ mcp_server_file_search mcp_server_web mcp_server_code │ +│ │ +│ (Optional: mcp_server_adapter_RAG.py für semantische │ +│ Tool-Auswahl via Sentence Transformers) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ workspace/ │ +│ Isoliertes Sandbox-Verzeichnis für Agent-Dateien │ +└─────────────────────────────────────────────────────────────────┘ ``` diff --git a/READMEnew.md b/READMEnew.md deleted file mode 100644 index 4722fb9..0000000 --- a/READMEnew.md +++ /dev/null @@ -1,392 +0,0 @@ -# AISE AI Code Editor — Technische Dokumentation - -AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) - -Diese Datei enthält die ausführliche technische Dokumentation des Projekts. Für eine Kurzübersicht siehe `README.md`. - ---- - -## Inhaltsverzeichnis - -1. [Projektstruktur](#projektstruktur) -2. [Frontend](#frontend) -3. [Backend Manager](#backend-manager) -4. [Backend Agent (MCP-System)](#backend-agent-mcp-system) -5. [MCP-Server-Konfiguration](#mcp-server-konfiguration) -6. [Tests](#tests) -7. [Setup](#setup) -8. [Architektur-Übersicht](#architektur-übersicht) - ---- - -## Projektstruktur - -``` -AISE_AIAgent/ -├── frontend/ # Streamlit UI-Komponenten -│ ├── app.py # Haupteinstiegspunkt der Streamlit-App -│ ├── state.py # Session-State-Verwaltung -│ ├── sidebar.py # Datei-Navigation (Sidebar) -│ ├── editor.py # Code-Editor-Pane -│ └── chat.py # Chat-Interface -│ -├── backend/ # Backend-Logik -│ ├── managers/ # Business-Logik für UI-Operationen -│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen) -│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History -│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion -│ │ ├── search_manager.py # Web-Suche (DuckDuckGo) -│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing -│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben -│ │ -│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert) -│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe) -│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern -│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl -│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle) -│ └── servers/ # MCP-Server-Implementierungen -│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen -│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen -│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo -│ -├── tests/ # Unit-Tests (pytest) -│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks -│ ├── test_file_manager.py -│ ├── test_chat_manager.py -│ ├── test_execution_engine.py -│ ├── test_coding_agent.py -│ ├── test_debug_logger.py -│ ├── test_system_prompter.py -│ ├── test_mcp_server_code_execution.py -│ ├── test_mcp_server_file_search.py -│ └── test_mcp_server_web_search.py -│ -├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) -├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent -└── .env.example # Vorlage für Umgebungsvariablen -``` - ---- - -## Frontend - -Das Frontend besteht aus Streamlit-Komponenten, die zusammen eine interaktive Code-Editor-Oberfläche bilden. - -### `app.py` -Haupteinstiegspunkt der Applikation. Orchestriert das Layout und initialisiert alle UI-Komponenten (Sidebar, Editor, Chat). - -### `state.py` -Zentralisierte Verwaltung des Streamlit Session-State. Stellt sicher, dass alle Komponenten denselben Zustand (geöffnete Datei, Chat-History, Agent-Status) teilen. - -### `sidebar.py` -Datei-Browser und Projekt-Navigation. Erlaubt das Durchsuchen des Workspaces und das Öffnen von Dateien im Editor. - -### `editor.py` -Code-Editor-Pane mit Syntax-Highlighting. Ermöglicht das Bearbeiten und Speichern von Code-Dateien direkt im Browser. - -### `chat.py` -Chat-Interface für den KI-Assistenten. Zeigt die Konversations-History und ermöglicht Eingaben an das AI-Modell. - ---- - -## Backend Manager - -Die Manager-Klassen kapseln die Business-Logik und werden direkt vom Frontend aufgerufen. - -### `file_manager.py` -Stellt CRUD-Operationen auf dem Workspace-Verzeichnis bereit: -- Dateien lesen, schreiben, umbenennen, löschen -- Verzeichnisstruktur auflisten -- Sichere Pfadvalidierung (verhindert Path-Traversal) - -> **Designentscheidung — `list_files()` vs. `get_file_tree()`:** -> Die Projektspezifikation nennt `list_files()` als `FileManager`-Methode. Im vorliegenden Design wurde bewusst `get_file_tree()` implementiert, da das Frontend eine verschachtelte Baumstruktur benötigt (für den interaktiven File Explorer in der Sidebar). Eine flache Liste würde die Navigation nicht unterstützen. Für den Agent Mode übernimmt der MCP-Server `mcp_server_file_search.py` die Dateisuche — die Funktionalität ist damit im System vorhanden, nur architektonisch sauber getrennt. - -### `chat_manager.py` -Verwaltet AI-Chat-Interaktionen: -- Aufbau und Verwaltung der Chat-History -- Senden von Nachrichten an das AI-Modell -- Formatierung von System- und User-Nachrichten - -> **Designentscheidung — Fehler-Output im normalen Chat:** -> Laufzeitfehler und stderr-Output werden im normalen Chat bewusst **nicht automatisch** in den Chat-Kontext injiziert. Stattdessen gibt es den "Debug with AI"-Button im Editor, über den der User selbst entscheidet wann er die AI einschalten möchte. Dies verhindert, dass die Chat-History mit ungewollten Fehlermeldungen geflutet wird. Im Agent Mode wird dies anders gelöst: dort landet jeder Execution-Fehler automatisch als Observation im Plan-Act-Observe-Loop und der Agent replant ohne User-Eingriff. - -### `system_prompter.py` -Generiert kontextreiche System-Prompts für den AI-Assistenten: -- Injektion von aktuellem Dateiinhalt als Kontext -- Steuerung des AI-Verhaltens (Coding-Assistent-Persona) - -### `execution_engine.py` -Führt Python-Code sicher aus: -- Subprocess-basierte Code-Ausführung -- Timeout-Schutz und Output-Capture -- Fehler- und Exception-Handling - -### `debug_logger.py` -Logging und Fehler-Tracking: -- Formatierte Log-Ausgaben für Debugging -- `format_debug_output(output)` formatiert den Execution-Output (`stdout`, `stderr`, `return_code`) in einen einheitlichen String für die UI-Anzeige und den AI-Chat-Kontext - -> **Designentscheidung — `log_error()` nicht implementiert:** -> Die Projektspezifikation nennt `log_error()` als `DebugLogger`-Methode. Diese wurde bewusst nicht als separate Methode implementiert, da Python's eingebautes `logging`-Modul diese Funktionalität mit `logger.error()` bereits vollständig abdeckt. Im gesamten Projekt wird konsistent `logger = get_logger(__name__)` gefolgt von `logger.error(...)` verwendet — eine eigene Wrapper-Methode wäre toter Code ohne Mehrwert. - -### `search_manager.py` -Web-Suche für den KI-Assistenten via DuckDuckGo: -- Nutzt die `ddgs`-Bibliothek (DuckDuckGo Search) für API-freie Websuche -- Gibt strukturierte Suchergebnisse zurück (Titel, URL, Snippet) -- Wird vom Chat-Manager aufgerufen, wenn der Assistent externe Dokumentation oder Code-Beispiele benötigt -- Keine API-Key-Konfiguration notwendig (da DuckDuckGo öffentlich zugänglich ist) - ---- - -## Backend Agent (MCP-System) - -Der Agent ist ein autonomes System, das komplexe Coding-Aufgaben selbstständig löst. Er kommuniziert mit externen Tool-Servern über das **Model Context Protocol (MCP)**. - -### `coding_agent.py` -Implementiert den Plan-Act-Observe-Loop: -1. **Plan**: Das AI-Modell wählt das nächste Tool und Argumente -2. **Act**: Das Tool wird via MCP-Adapter aufgerufen (nach User-Bestätigung) -3. **Observe**: Das Ergebnis wird in die Message-History eingefügt -4. Der Loop wiederholt sich bis zur Fertigstellung oder einem `done`-Tool-Aufruf - -Wichtige Klassen und Funktionen: -- `CodingAgent`: Haupt-Klasse mit `start_task()`, `propose_next_action()`, `approve()`, `reject()` -- `truncate_result()`: Kürzt lange Tool-Outputs bevor sie in die History gehen -- `trim_messages()`: Entfernt alte Turns aus der History wenn das Kontextfenster voll wird -- `_strip_code_fences()`: Bereinigt Markdown-Fences aus LLM-JSON-Antworten - -Konstanten: `MAX_ITERATIONS`, `MAX_RESULT_LENGTH`, `MAX_HISTORY_CHARS` - -### `mcp_server_adapter.py` -Verbindet den Coding-Agent mit den MCP-Tool-Servern: -- Liest `mcp_server_config.json` und startet die konfigurierten Server als Subprozesse -- Baut MCP-Sessions via `stdio_client` auf -- Registriert alle verfügbaren Tools aus allen Servern in einem zentralen `tool_registry` -- Delegiert Tool-Aufrufe an den richtigen Server via `call_tool()` - -Hauptmethoden: -- `initialize_all_servers()`: Startet alle Server und baut Sessions auf -- `get_all_tools()`: Gibt alle registrierten Tool-Definitionen zurück -- `call_tool(tool_name, arguments)`: Führt ein Tool auf dem zuständigen Server aus - -### `mcp_server_adapter_RAG.py` -Erweiterter MCP-Adapter mit semantischer Tool-Auswahl via Retrieval-Augmented Generation (RAG): - -**Motivation**: Bei vielen MCP-Tools kann das LLM-Kontextfenster überfüllt werden, wenn alle Tool-Definitionen mitgesendet werden. Der RAG-Adapter löst dies durch semantische Vorauswahl. - -**Funktionsweise**: -1. Beim Initialisieren werden alle Tool-Beschreibungen mit `SentenceTransformer('all-MiniLM-L6-v2')` in Embeddings umgewandelt -2. Bei jedem Agent-Schritt wird der aktuelle Task als Query kodiert -3. Cosine-Similarity zwischen Query- und Tool-Embeddings bestimmt die `top_k` relevantesten Tools -4. Nur diese Tools werden dem LLM als verfügbare Aktionen präsentiert - -Hauptmethoden: -- `initialize_all_sessions()`: Startet Server, baut Sessions auf, erstellt Embedding-Index -- `get_relevant_tools(query, top_k=5)`: Gibt die `top_k` semantisch ähnlichsten Tools zurück -- `call_tool(tool_name, arguments)`: Findet den zuständigen Server und führt das Tool aus -- `shutdown_all_sessions()`: Schliesst alle offenen MCP-Sessions sauber - -Abhängigkeit: `sentence-transformers`, `numpy` - -**Hinweis**: Diese Klasse befindet sich noch in der Entwicklung (Work in Progress). Es gibt bekannte Bugs (z.B. Tippfehler `commanf` statt `command`, falsche Verwendung von `result.get()` vs. `result.tools`). - ---- - -## MCP-Server-Konfiguration - -### Format: `mcp_server_config.json` - -Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Adapter starten soll. Das Format ist ein JSON-Objekt, wobei jeder Key ein frei wählbarer Servername ist: - -```json -{ - "ServerName": { - "command": "py", - "args": ["servers/mcp_server_datei.py"], - "env": { - "API_KEY": "optional_key" - } - } -} -``` - -| Feld | Pflicht | Beschreibung | -|-----------|---------|--------------| -| `command` | Ja | Ausführbares Programm (z.B. `py`, `python3`, `node`) | -| `args` | Ja | Argumente als Array (Pfad zum Server-Script) | -| `env` | Nein | Umgebungsvariablen für den Serverprozess | - -### Aktuelle Server - -```json -{ - "FileSearchServer": { - "command": "py", - "args": ["servers/mcp_server_file_search.py"] - }, - "WebSearchServer": { - "command": "py", - "args": ["servers/mcp_server_web_search.py"], - "env": { "DDGS_API_KEY": "your_ddgs_api_key_here" } - }, - "CodeExecutionServer": { - "command": "py", - "args": ["servers/mcp_server_code_execution.py"] - } -} -``` - -### Neuen MCP-Server hinzufügen - -1. Neues Server-Script in `backend/agent/servers/` erstellen (MCP-konformes Python-Script) -2. Eintrag in `mcp_server_config.json` ergänzen: - ```json - "MeinNeuerServer": { - "command": "py", - "args": ["servers/mcp_server_mein_tool.py"] - } - ``` -3. Der Adapter erkennt den neuen Server beim nächsten Start automatisch und registriert seine Tools - ---- - -## Tests - -### Tests ausführen - -```bash -# Alle Tests ausführen -pytest tests/ -v - -# Einzelnes Test-Modul ausführen -pytest tests/test_coding_agent.py -v - -# Tests mit Kurzausgabe -pytest tests/ -``` - -### Teststruktur - -Die Tests liegen in `tests/` und folgen dem Muster `test_.py`. - -#### `conftest.py` -Globale Pytest-Konfiguration. Patcht den `MCPToolAdapter` auf `sys.modules`-Ebene, bevor irgendein Test-Modul importiert wird. Dadurch werden beim Import von `coding_agent` keine echten MCP-Subprozesse gestartet. Der Mock-Adapter liefert sofort leere Ergebnisse zurück. - -#### Was wird getestet - -| Test-Datei | Getestetes Modul | Schwerpunkt | -|---|---|---| -| `test_file_manager.py` | `backend/managers/file_manager.py` | Datei-CRUD, Pfad-Validierung | -| `test_chat_manager.py` | `backend/managers/chat_manager.py` | Chat-History, Nachrichtenformatierung | -| `test_execution_engine.py` | `backend/managers/execution_engine.py` | Code-Ausführung, Timeouts, Fehlerbehandlung | -| `test_system_prompter.py` | `backend/managers/system_prompter.py` | Prompt-Generierung, Kontext-Injektion | -| `test_debug_logger.py` | `backend/managers/debug_logger.py` | Log-Formatierung, Fehler-Aggregation | -| `test_coding_agent.py` | `backend/agent/coding_agent.py` | Agent-Loop, Tool-Dispatch, History-Trimming | -| `test_mcp_server_code_execution.py` | `backend/agent/servers/mcp_server_code_execution.py` | MCP Code-Execution-Tool | -| `test_mcp_server_file_search.py` | `backend/agent/servers/mcp_server_file_search.py` | MCP Datei-Such-Tool | -| `test_mcp_server_web_search.py` | `backend/agent/servers/mcp_server_web_search.py` | MCP Web-Such-Tool | - -#### Testklassen in `test_coding_agent.py` - -- **`TestTruncateResult`**: Prüft, dass lange Tool-Outputs korrekt gekürzt werden -- **`TestTrimMessages`**: Prüft, dass alte History-Turns entfernt werden wenn der Kontext zu gross wird; System-Message und Original-Task bleiben immer erhalten -- **`TestStripCodeFences`**: Prüft, dass Markdown-Codeblöcke aus LLM-Antworten entfernt werden -- **`TestCodingAgentInit`**: Prüft initialen Zustand und `start_task()`-Reset-Verhalten -- **`TestProposeNextAction`**: Prüft den API-Aufruf-Zyklus mit gemockter API; testet Fehler-Handling (JSON-Parse-Fehler, API-Exceptions, Max-Iterations) -- **`TestApprove`**: Prüft `approve()` mit gemocktem `dispatch_tool`; testet Tool-Ergebnis-Injektion und Error-Replan-Tagging -- **`TestReject`**: Prüft, dass `reject()` das Feedback korrekt in die History injiziert und kein Tool ausführt - -#### Test-Konventionen - -- MCP-Server werden in Tests **nicht** als echte Subprozesse gestartet (via `conftest.py`-Mock) -- Streamlit-Aufrufe werden mit `patch("modul.st")` gemockt -- Dateisystem-Tests nutzen `tmp_path` (pytest-Fixture) für isolierte temporäre Verzeichnisse -- Async-Tests verwenden `@pytest.mark.asyncio` (benötigt `pytest-asyncio`) - ---- - -## Setup - -### 1. Repository klonen - -```bash -git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git -cd AISE1_Project -``` - -### 2. Virtuelle Umgebung aktivieren - -```bash -# Windows -.\.venv\Scripts\Activate.ps1 - -# macOS/Linux -source .venv/bin/activate -``` - -### 3. Abhängigkeiten installieren - -```bash -pip install -r requirements.txt -``` - -### 4. Umgebungsvariablen konfigurieren - -```bash -cp .env.example .env -# .env mit API-Keys befüllen -``` - -### 5. Applikation starten - -```bash -streamlit run frontend/app.py -``` - -### 6. Tests ausführen - -```bash -pytest tests/ -v -``` - ---- - -## Architektur-Übersicht - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Frontend (Streamlit) │ -│ app.py → sidebar.py / editor.py / chat.py │ -│ │ │ -│ state.py (Session-State) │ -└──────────────┬──────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Backend Manager │ -│ FileManager / ChatManager / SystemPrompter / │ -│ SearchManager / ExecutionEngine / DebugLogger │ -└──────────────┬──────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Coding Agent (backend/agent/) │ -│ coding_agent.py ←→ mcp_server_adapter.py │ -│ │ │ -│ mcp_server_config.json │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ ▼ ▼ ▼ │ -│ mcp_server_file_search mcp_server_web mcp_server_code │ -│ │ -│ (Optional: mcp_server_adapter_RAG.py für semantische │ -│ Tool-Auswahl via Sentence Transformers) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ workspace/ │ -│ Isoliertes Sandbox-Verzeichnis für Agent-Dateien │ -└─────────────────────────────────────────────────────────────────┘ -``` diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index 65a9114..be72c53 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -32,6 +32,21 @@ class ChatManager: # Chat history stored in memory self.chat_history = [] + # Maximum number of non-system messages sent to the API. + # The system prompt is always included on top regardless of this limit. + self.max_history_messages = 20 + + def _build_payload_messages(self) -> list: + """Return the messages to send to the API. + + Always puts the system prompt first, then the most recent + max_history_messages non-system messages. This guarantees the system + prompt is never dropped even in long conversations. + """ + system = [m for m in self.chat_history if m["role"] == "system"] + others = [m for m in self.chat_history if m["role"] != "system"] + return system + others[-self.max_history_messages:] + def add_message(self, role: str, content: str) -> None: """Append a single message to the conversation history.""" self.chat_history.append({"role": role, "content": content}) @@ -66,10 +81,10 @@ class ChatManager: if self.api_key and self.api_key != "EMPTY": headers["Authorization"] = f"Bearer {self.api_key}" - # Full history is sent so the model has multi-turn conversation context + # System prompt + most recent messages — system prompt is always preserved. payload = { "model": self.model, - "messages": self.chat_history, + "messages": self._build_payload_messages(), "temperature": 0.7, "max_tokens": 2000, "stream": False, diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index 0c87dd9..9fe7a77 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -14,6 +14,13 @@ logger = get_logger(__name__) WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) +# File extensions that are shown in the explorer when filter_extensions=True. +CODE_EXTENSIONS = { + ".py", ".js", ".ts", ".html", ".css", ".json", + ".yaml", ".yml", ".sh", ".md", ".txt", ".tex", + ".c", ".cpp", ".java", ".rs", ".go", +} + class FileManager: """Manages all file and folder operations inside the workspace directory. @@ -317,27 +324,33 @@ class FileManager: logger.exception("Error deleting folder %s: %s", relative_path, str(e)) return False - def get_file_tree(self): - """ - Builds a nested dictionary representing the file tree starting from the base path. - Directories are represented as keys with dictionary values, - and files are represented as keys with None + def get_file_tree(self, filter_extensions: bool = True) -> dict: + """Builds a nested dictionary representing the file tree. + + Directories are represented as keys with dictionary values, + files as keys with None. + + Args: + filter_extensions: When True (default), only files whose suffix is + in CODE_EXTENSIONS are included. Directories are always shown, + even when they are empty after filtering. Returns: dict: A nested dictionary representing the file tree. """ logger.info("Getting file tree ...") - - def build_tree(path: Path): + def build_tree(path: Path) -> dict: tree = {} - for item in sorted(path.iterdir()): if item.is_dir(): - tree[item.name] = build_tree(item) # recurse into sub-folders + tree[item.name] = build_tree(item) else: - tree[item.name] = None # leaf node for files + if filter_extensions and item.suffix not in CODE_EXTENSIONS: + continue + tree[item.name] = None return tree + return build_tree(self.base_path) def list_files(self, extensions: list[str] | None = None) -> list[Path]: diff --git a/frontend/chat.py b/frontend/chat.py index 3d4c32c..be6cf7a 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -2,6 +2,7 @@ import asyncio import json +import re from pathlib import Path import streamlit as st @@ -285,6 +286,22 @@ def render_agent_mode(): # ── Normal Chat helpers ─────────────────────────────────────────────────────── +def _strip_search_context_from_history(chat_manager: ChatManager) -> None: + """Remove blocks from all user messages in the API history. + + Called when the user clears search results so the AI no longer receives + the stale context in follow-up messages. + """ + for msg in chat_manager.chat_history: + if msg["role"] == "user" and "" in msg["content"]: + msg["content"] = re.sub( + r".*?\n\n", + "", + msg["content"], + flags=re.DOTALL, + ).strip() + + def _detect_task_type(user_input: str) -> str: """Infer the task type from keywords in the user message.""" lower = user_input.lower() @@ -367,7 +384,7 @@ def _render_search_panel(): search_results = st.session_state.get("search_results", []) label = f"🔍 Web Search ({len(search_results)} result{'s' if len(search_results) != 1 else ''} active)" if search_results else "🔍 Web Search" - with st.expander(label, expanded=False): + with st.expander(label, expanded=bool(search_results)): col_input, col_btn = st.columns([5, 1]) with col_input: query = st.text_input( @@ -397,6 +414,9 @@ def _render_search_panel(): st.divider() if st.button("Clear search results", use_container_width=True): st.session_state.search_results = [] + cm = st.session_state.get("chat_manager") + if cm: + _strip_search_context_from_history(cm) st.rerun() @@ -412,6 +432,9 @@ def render_normal_chat(): logger.info("Chat mode") chat_manager: ChatManager = st.session_state.chat_manager + # Search panel always rendered at the top — expands automatically when results are active. + _render_search_panel() + # Apply model/token overrides from the Settings panel before any API call. if st.session_state.get("selected_model"): chat_manager.model = st.session_state.selected_model @@ -470,11 +493,8 @@ def render_normal_chat(): if results: st.session_state.search_results = results - summary = f"Found {len(results)} result(s) for **{arg}**. They are now in context for this chat session.\n\n" - for i, r in enumerate(results, 1): - summary += f"**{i}. [{r['title']}]({r['url']})** \n{r['snippet']}\n\n" - st.markdown(summary) - response_text = summary + response_text = f"🔍 Found {len(results)} result(s) for **'{arg}'**. Results are shown in the search panel above." + st.markdown(response_text) else: msg = f'No results found for "{arg}".' st.warning(msg) diff --git a/frontend/editor.py b/frontend/editor.py index ba1c13e..2264ae9 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -146,131 +146,139 @@ def run_active_file(): st.session_state.exec_results[active_file] = result return result -def render_editor(): - """Render the full Code Editor view with tabs, Ace editor, and run output.""" - st.subheader("Code Editor") +class FileViewer: + """UI component that renders the code editor tabs, Ace editor, and run output.""" - if not st.session_state.open_files: - st.info("Please select a file to edit.") - return + def __init__(self): + self.fm = FileManager() - fm = FileManager() + def render(self): + """Render the full Code Editor view with tabs, Ace editor, and run output.""" + st.subheader("Code Editor") - # ── Tab bar via st.tabs() ───────────────────────────────────────────────── - # Build one tab per open file, named by the file's basename. - tab_names = [Path(f).name for f in st.session_state.open_files] - tabs = st.tabs(tab_names) + if not st.session_state.open_files: + st.info("Please select a file to edit.") + return - # Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun. - # Wenn _jump_to_tab gesetzt ist, klickt das Script den richtigen Tab an. - jump_target = st.session_state.pop("_jump_to_tab", None) - if jump_target and jump_target in st.session_state.open_files: - idx = st.session_state.open_files.index(jump_target) - st.components.v1.html( - f"""""", - height=0, - ) + # ── Tab bar via st.tabs() ───────────────────────────────────────────── + tab_names = [Path(f).name for f in st.session_state.open_files] + tabs = st.tabs(tab_names) - for idx, file_path in enumerate(st.session_state.open_files): - with tabs[idx]: - # Load file content from disk on first open; afterwards use the cached version. - if file_path not in st.session_state.files_content: - st.session_state.files_content[file_path] = fm.read_file(Path(file_path)) - - file_language = LANG_MAP.get(Path(file_path).suffix, "text") - - # Ace editor widget — auto_update sends content to Python on each keystroke. - code = st_ace.st_ace( - value=st.session_state.files_content[file_path], - language=file_language, - theme="monokai", - key=f"code_editor_{file_path}", - auto_update=True, - height=400, + # Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun. + jump_target = st.session_state.pop("_jump_to_tab", None) + if jump_target and jump_target in st.session_state.open_files: + idx = st.session_state.open_files.index(jump_target) + st.components.v1.html( + f"""""", + height=0, ) - # Keep the in-memory cache in sync with what the editor currently shows. - # REVIEW: redundant round-trip — st_ace returns the same value that was passed as - # `value=` unless the user edited the content; comparing and re-assigning on every - # rerun is a no-op most of the time and adds overhead. - if code != st.session_state.files_content[file_path]: - st.session_state.files_content[file_path] = code + for idx, file_path in enumerate(st.session_state.open_files): + with tabs[idx]: + # Load file content from disk on first open; use cached version afterwards. + if file_path not in st.session_state.files_content: + st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path)) - cols = st.columns([1, 1, 1, 1]) - with cols[0]: - if st.button("Save Changes", key=f"save_{file_path}"): - if fm.save_file(file_path, code): - st.success("File saved successfully!") + file_language = LANG_MAP.get(Path(file_path).suffix, "text") - with cols[1]: - if st.button("Close File", key=f"close_{file_path}"): - st.session_state.open_files.remove(file_path) - st.session_state.files_content.pop(file_path, None) - # Switch active_file to the next available tab. - st.session_state.active_file = ( - st.session_state.open_files[0] - if st.session_state.open_files else None + code = st_ace.st_ace( + value=st.session_state.files_content[file_path], + language=file_language, + theme="monokai", + key=f"code_editor_{file_path}", + auto_update=True, + height=400, + ) + + if code != st.session_state.files_content[file_path]: + st.session_state.files_content[file_path] = code + + cols = st.columns([1, 1, 1, 1, 1]) + with cols[0]: + if st.button("Save Changes", key=f"save_{file_path}"): + if self.fm.save_file(file_path, code): + st.success("File saved successfully!") + + with cols[1]: + st.download_button( + label="⬇ Download", + data=st.session_state.files_content.get(file_path, ""), + file_name=Path(file_path).name, + mime="text/plain", + key=f"download_{file_path}", ) - st.rerun() - with cols[2]: - if st.button("Rename File", key=f"rename_{file_path}"): - _rename_dialog(file_path) - - with cols[3]: - if st.button("Delete File", key=f"delete_{file_path}"): - _delete_dialog(file_path) - - # ── Run + Output ────────────────────────────────────────────────── - if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"): - run_active_file() - st.rerun() - - result = st.session_state.get("exec_results", {}).get(file_path) - if result: - st.subheader("Execution Output") - - if result.get("ast_error"): - st.warning("⚠️ Syntax Error detected before execution — code was not run.") - elif result["return_code"] == 0: - st.success(f"✅ Exit code: 0") - else: - st.error(f"❌ Exit code: {result['return_code']}") - - # Debug with AI — only shown when there is an error or stderr output. - if result["return_code"] != 0 or result.get("stderr"): - if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"): - file_name = Path(file_path).name - code_content = st.session_state.files_content.get(file_path, "") - lang = LANG_MAP.get(Path(file_path).suffix, "python") - formatted_output = DebugLogger.format_debug_output(result) - debug_message = ( - f"I got an error while running **{file_name}**:\n\n" - f"```\n{formatted_output}\n```\n\n" - f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n" - f"Can you help me fix this?" + with cols[2]: + if st.button("Close File", key=f"close_{file_path}"): + st.session_state.open_files.remove(file_path) + st.session_state.files_content.pop(file_path, None) + st.session_state.active_file = ( + st.session_state.open_files[0] + if st.session_state.open_files else None ) - st.session_state.pending_debug_message = debug_message - st.session_state["_navigate_to_chat"] = True st.rerun() - if result.get("stdout"): - st.text_area("Standard Output", value=result["stdout"], height=200, - disabled=True, key=f"run_stdout_{file_path}") - if result.get("stderr"): - st.text_area("Standard Error", value=result["stderr"], height=200, - disabled=True, key=f"run_stderr_{file_path}") - if not result.get("stdout") and not result.get("stderr"): - st.info("No output produced by the code execution.") + with cols[3]: + if st.button("Rename File", key=f"rename_{file_path}"): + _rename_dialog(file_path) + with cols[4]: + if st.button("Delete File", key=f"delete_{file_path}"): + _delete_dialog(file_path) + + # ── Run + Output ────────────────────────────────────────────── + if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"): + run_active_file() + st.rerun() + + result = st.session_state.get("exec_results", {}).get(file_path) + if result: + st.subheader("Execution Output") + + if result.get("ast_error"): + st.warning("⚠️ Syntax Error detected before execution — code was not run.") + elif result["return_code"] == 0: + st.success("✅ Exit code: 0") + else: + st.error(f"❌ Exit code: {result['return_code']}") + + if result["return_code"] != 0 or result.get("stderr"): + if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"): + file_name = Path(file_path).name + code_content = st.session_state.files_content.get(file_path, "") + lang = LANG_MAP.get(Path(file_path).suffix, "python") + formatted_output = DebugLogger.format_debug_output(result) + debug_message = ( + f"I got an error while running **{file_name}**:\n\n" + f"```\n{formatted_output}\n```\n\n" + f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n" + f"Can you help me fix this?" + ) + st.session_state.pending_debug_message = debug_message + st.session_state["_navigate_to_chat"] = True + st.rerun() + + if result.get("stdout"): + st.text_area("Standard Output", value=result["stdout"], height=200, + disabled=True, key=f"run_stdout_{file_path}") + if result.get("stderr"): + st.text_area("Standard Error", value=result["stderr"], height=200, + disabled=True, key=f"run_stderr_{file_path}") + if not result.get("stdout") and not result.get("stderr"): + st.info("No output produced by the code execution.") + + +def render_editor(): + """Entry point for app.py — delegates to FileViewer.""" + FileViewer().render() if __name__ == "__main__": diff --git a/frontend/sidebar.py b/frontend/sidebar.py index e265d61..94ed3a2 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -390,6 +390,22 @@ def render_sidebar(): if st.button("Add Folder", key="btn_add_folder", use_container_width=True): _add_folder_dialog("") + st.divider() + + uploaded = st.file_uploader( + "Upload File", + type=["py", "js", "html", "css", "json", "yaml", "txt", "md"], + key="sidebar_file_upload", + ) + if uploaded is not None: + if uploaded.size > 1_000_000: + st.error("File is too large (max 1 MB).") + else: + content = uploaded.getvalue().decode("utf-8", errors="replace") + dest = str(fm.base_path / uploaded.name) + if fm.save_file(dest, content): + st.success(f"'{uploaded.name}' uploaded successfully.") + # REVIEW: bare `return` at end of void function — no-op; can be removed. return From 91f816168aecce8dc051a6d3320c741de4de143e Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Wed, 27 May 2026 07:54:07 +0200 Subject: [PATCH 2/2] Minor Updates --- DEBUG_LOGGER_USAGE.md | 46 ------ backend/agent/coding_agent.py | 49 +----- backend/agent/mcp_server_adapter.py | 4 +- .../servers/mcp_server_code_execution.py | 10 +- .../agent/servers/mcp_server_file_search.py | 16 +- backend/managers/chat_manager.py | 11 -- backend/managers/file_manager.py | 20 --- frontend/app.py | 5 - frontend/chat.py | 16 +- frontend/sidebar.py | 2 - frontend/state.py | 23 --- tests/test_chat_manager.py | 21 --- tests/test_coding_agent.py | 2 - tests/test_debug_logger.py | 139 +++++++++--------- tests/test_execution_engine.py | 1 - tests/test_mcp_server_code_execution.py | 4 - tests/test_mcp_server_file_search.py | 5 +- tests/test_mcp_server_web_search.py | 12 +- tests/test_search_manager.py | 3 +- tests/test_system_prompter.py | 1 - 20 files changed, 98 insertions(+), 292 deletions(-) delete mode 100644 DEBUG_LOGGER_USAGE.md diff --git a/DEBUG_LOGGER_USAGE.md b/DEBUG_LOGGER_USAGE.md deleted file mode 100644 index 965b61f..0000000 --- a/DEBUG_LOGGER_USAGE.md +++ /dev/null @@ -1,46 +0,0 @@ -# DebugLogger Usage - -## Import -```python -from backend.managers.debug_logger import DebugLogger -logger = DebugLogger() -``` - -## Methoden -```python -logger.clear() # vor jeder neuen Ausführung aufrufen -logger.log("Nachricht") # INFO-Eintrag -logger.log_error("Fehler") # ERROR-Eintrag -logger.get_logs() # gibt Liste aller Einträge zurück -logger.format_debug_output({ # gibt formatierten String zurück - "rc": 0, - "stdout": "...", - "stderr": "..." -}) -``` - -## Eintrag-Format -```python -{ - "level": "INFO", # oder "ERROR" - "message": "Nachricht", - "timestamp": "14:23:01" -} -``` - -## Beispiel -```python -logger = DebugLogger() -logger.clear() -logger.log("Starte Ausführung...") - -try: - result = run_something() - logger.log("Erfolgreich abgeschlossen.") -except Exception as e: - logger.log_error(f"Fehler: {e}") - -# Logs anzeigen -for entry in logger.get_logs(): - print(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") -``` diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index 49fc1b4..4d7bd31 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -21,8 +21,6 @@ import asyncio import requests from dotenv import load_dotenv -# REVIEW: commented-out import — remove once the package import above is confirmed stable. -#from mcp_server_adapter import MCPToolAdapter # Import from current directory for easier testing without package structure from backend.agent.mcp_server_adapter import MCPToolAdapter from backend.managers.debug_logger import get_logger @@ -95,7 +93,7 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str: logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name) result = await adapter.call_tool(tool_name, arguments) - logger.info(f"Result from tool '%s' recieved", tool_name) + logger.info("Result from tool '%s' received", tool_name) if result.isError: # MCP servers signal tool-level errors via the isError flag rather @@ -108,7 +106,7 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str: return "\n".join(texts) except Exception as e: - logger.exception(f"Error calling tool '%s' with argument: %s", tool_name, arguments) + logger.exception("Error calling tool '%s' with argument: %s", tool_name, arguments) return f"Error calling tool '{tool_name}': {e}" # ═════════════════════════════════════════════════════════════════════════════ @@ -417,10 +415,6 @@ class CodingAgent: timeout=60) response.raise_for_status() logger.info("LLM API response requested") - - if response.status_code != 200: - logger.error("API Error %s: %s", response.status_code, response.text) - raise Exception(f"API Error {response.status_code}: {response.text}") except requests.RequestException as exc: logger.exception("API Error; HTTP-Fehler: %s", exc) raise Exception(f"HTTP-Fehler: {exc}") from exc @@ -594,42 +588,3 @@ class CodingAgent: }) self.pending_action = None logger.info("Rejection message appended.") - -# REVIEW: dead code — this module is always imported, never run as a script. -# The __main__ guard below is unreachable in normal use. Move this to run_agent.py or delete it. -def main(): - """Example of how to use the CodingAgent in a simple loop.""" - agent = CodingAgent() - task = "Write a Python function that returns the nth Fibonacci number." - agent.start_task(task) - - if agent.pending_action: - print(f"Initial proposed action: {agent.pending_action['action']}") - - - while not agent.is_done: - action = asyncio.run(agent.propose_next_action()) - print(f"Proposed action: {action}") - - if action["tool"] == "done": - print("Task completed.") - break - else: - user_feedback = input("Approve this action? (y/n) ") - if user_feedback.lower() == "y": - result = asyncio.run(agent.approve()) - print(f"Tool result: {result}") - elif user_feedback.lower() == "n": - feedback = input("Enter feedback for the agent: ") - agent.reject(feedback) - - # REVIEW: unreachable when action["tool"] == "done" (we break above); also `result` is - # unbound when the elif branch runs — this will raise UnboundLocalError at runtime. - if result["is_done"]: - print("Task completed.") - break - -if __name__ == "__main__": - main() - - diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index 7229693..cb8ff3c 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -97,10 +97,8 @@ class MCPToolAdapter: await session.initialize() logger.info("Session initialized for %s. Requesting tools...", server_name) result = await session.list_tools() - # REVIEW: debug print — remove before shipping. - print(f"Tools received from {server_name}: {result}") tools = result.tools - logger.info(f"Tools received from %s: %s Tools", server_name, str(len(tools))) + logger.info("Tools received from %s: %s Tools", server_name, str(len(tools))) for tool in tools: # Build a human-readable parameter description for the system prompt. diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index 66a0ed9..3d371d4 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -15,6 +15,7 @@ blocks dangerous imports and builtins before spawning any subprocess. """ import ast +import os import subprocess import sys @@ -22,7 +23,6 @@ import io from pyflakes.api import check # For linting Code from pyflakes.reporter import Reporter # For linting Code from mcp.server.fastmcp import FastMCP -from pathlib import Path #from backend.managers.debug_logger import get_logger #logger = get_logger(__name__) @@ -275,12 +275,18 @@ def run_python_sandboxed(code: str) -> str: return f"Code rejected:{static_safety}" try: + # Force UTF-8 I/O so the subprocess can print unicode on Windows + # (default console encoding is cp1252 which cannot encode emoji). + utf8_env = {**os.environ, "PYTHONIOENCODING": "utf-8"} result = subprocess.run( [sys.executable, "-c", code], stdin=subprocess.DEVNULL, capture_output=True, text=True, - timeout=EXEC_TIMEOUT) + encoding="utf-8", + timeout=EXEC_TIMEOUT, + env=utf8_env, + ) # Merge stdout and stderr so the agent sees all output in one block. output = result.stdout + result.stderr diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index 79cedd6..54be960 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -77,11 +77,7 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: #logger.info("Tool get_file_tree is being executed on MCP file search server") try: safe_dir = _safe_path(dir_path) - # REVIEW: unreachable code — _safe_path() always returns a Path object (never None/falsy) - # or raises ValueError; this check can never be True. - if not safe_dir: - return f"Error: Invalid directory path '{dir_path}'." - elif not safe_dir.exists(): + if not safe_dir.exists(): #logger.warning("Directory '%s' does not exist.", dir_path) return f"Error: Directory '{dir_path}' does not exist." elif not safe_dir.is_dir(): @@ -153,11 +149,7 @@ def search_files(query: str) -> str: if query_lower in line.lower(): snippet = line.strip()[:100] results.append(f"[content] {rel}:{i} -- {snippet}") - except UnicodeDecodeError as e: - #logger.warning("Decode error in file/folder '%s': %s", f, e) - pass - except PermissionError as e: - #logger.warning("Permission error in file/folder: '%s': %s", f, e) + except (UnicodeDecodeError, PermissionError): pass if not results: @@ -274,9 +266,7 @@ def create_new_directory(path: str) -> str: #logger.warning("Requested path '%s' already exists, overwriting not allowed.", path) return f"Error: File '{path}' already exists." - # REVIEW: redundant — `resolved.suffix != None` is always True (Path.suffix always returns str); - # the None check is unnecessary. Simplify to `if resolved.suffix != "":`. - if resolved.suffix != None and resolved.suffix != "": + if resolved.suffix != "": #logger.warning("Can only create directories, got '%s'.", resolved.suffix) return f"Error: can only create directories, got '{resolved.suffix}'." diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index be72c53..adf5b19 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -55,7 +55,6 @@ class ChatManager: """Return a copy of the conversation history.""" return list(self.chat_history) - # REVIEW: dead code — clear_history() is never called anywhere in the codebase. def clear_history(self) -> None: """Wipe the conversation history (starts a fresh chat).""" logger.info("Chat history was cleared") @@ -147,13 +146,3 @@ class ChatManager: logger.exception("JSON parsing and message formatting failed: %s", e) raise RuntimeError("JSON parsing and message formatting failed") from e - # REVIEW: dead code — get_chat_display() is never called anywhere in the codebase. - # The UI renders st.session_state.chat_history directly. This method also does the - # same thing as get_history() (returns a copy of chat_history with the same fields), - # making it redundant even if it were used. - def get_chat_display(self) -> list: - """Return a copy of the history suitable for display in the UI.""" - return [ - {"role": msg["role"], "content": msg["content"]} - for msg in self.chat_history - ] diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index 9fe7a77..1a8981b 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -168,12 +168,6 @@ class FileManager: content = f.read() logger.info("File read successfully.") return content - except FileNotFoundError: - # REVIEW: unreachable code — FileNotFoundError cannot be raised here because - # `file_path.exists()` is already checked above and returns "" on failure. - st.error(f"File not found: {relative_path}") - logger.warning("File not found") - return "" except Exception as e: st.error(f"Error reading file {relative_path}: {str(e)}") logger.exception("Error reading file at %s: %s", relative_path, e) @@ -353,19 +347,5 @@ class FileManager: return build_tree(self.base_path) - def list_files(self, extensions: list[str] | None = None) -> list[Path]: - """Returns a flat list of all files in the workspace. - - Args: - extensions: Optional list of extensions to filter by, e.g. ['.py', '.js']. - If None, all files are returned. - Returns: - List of absolute Path objects for all matching files. - """ - files = (p for p in self.base_path.rglob("*") if p.is_file()) - if extensions is not None: - files = (p for p in files if p.suffix in extensions) - return sorted(files) - if __name__ == "__main__": FileManager() diff --git a/frontend/app.py b/frontend/app.py index 0625395..8308f47 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -47,11 +47,6 @@ def main(): st.title("Lightweight code editor") - # REVIEW: redundant — init_state() is already called at module level (line 26) before main() runs; - # calling it again here is unnecessary since Streamlit reruns the whole module on each reload. - # Re-run init_state to cover any keys that might have been missed on cold start - init_state() - render_sidebar() # Switch between the two main views based on the sidebar radio button diff --git a/frontend/chat.py b/frontend/chat.py index be6cf7a..8ad1ffd 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -19,9 +19,8 @@ logger = get_logger(__name__) def _run_async(coro): """Execute an async coroutine from synchronous Streamlit code. - Streamlit runs in a synchronous context, but the CodingAgent uses async - methods (for MCP tool calls). This helper bridges the gap by reusing an - already-running event loop when one exists, or creating a new one otherwise. + Streamlit always runs in a plain synchronous thread with no running event + loop, so we always create a fresh loop here. Args: coro: The coroutine to run. @@ -29,15 +28,8 @@ def _run_async(coro): Returns: The return value of the coroutine. """ - # REVIEW: asyncio.get_running_loop() always raises RuntimeError in a Streamlit context; - # the try branch is dead code. The except branch always runs. - try: - # Reuse the loop that is already running (e.g. inside pytest-asyncio). - loop = asyncio.get_running_loop() - except RuntimeError: - # No running loop in this thread — create a fresh one. - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) return loop.run_until_complete(coro) def _start_agent(task: str): diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 94ed3a2..72b1b96 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -406,8 +406,6 @@ def render_sidebar(): if fm.save_file(dest, content): st.success(f"'{uploaded.name}' uploaded successfully.") - # REVIEW: bare `return` at end of void function — no-op; can be removed. - return if __name__ == "__main__": diff --git a/frontend/state.py b/frontend/state.py index 787dd81..87a7a59 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -43,41 +43,18 @@ def init_state(): # Ordered list of absolute file paths currently open as editor tabs. # The list order determines the visual tab order in the UI. if "open_files" not in st.session_state: - # REVIEW: dead code — docstrings inside `if` blocks are plain string literals that Python - # evaluates and immediately discards; they are never visible as __doc__ and have no effect. st.session_state.open_files = [] # Dict mapping absolute file path → current editor content (may differ from # disk if the user has unsaved changes). if "files_content" not in st.session_state: - # REVIEW: dead code — same issue: string literal inside `if` block is never used as a docstring. st.session_state.files_content = {} # Absolute path of the file whose tab is currently active in the editor. # Must always be one of the paths in open_files, or None if no file is open. if "active_file" not in st.session_state: - # REVIEW: dead code — string literal inside `if` block is never used as a docstring. st.session_state.active_file = None - # REVIEW: dead code — active_tab is initialised here but never read or written anywhere else - # in the codebase; st.tabs() in editor.py does not use this key. - # Index of the active tab — kept in sync with active_file for st.tabs(). - if "active_tab" not in st.session_state: - st.session_state.active_tab = 0 - - # REVIEW: dead code — is_editing is initialised here but never read or written anywhere else. - if "is_editing" not in st.session_state: - st.session_state.is_editing = False - - # REVIEW: dead code — code_suggestions is initialised here but never read or written anywhere else. - if "code_suggestions" not in st.session_state: - st.session_state.code_suggestions = [] - - # Output dict from the last code execution: {stdout, stderr, return_code}. - # Initialised as empty string so the editor view can safely check falsyness. - if "code_execution_output" not in st.session_state: - st.session_state.code_execution_output = "" - # Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}} if "exec_results" not in st.session_state: st.session_state.exec_results = {} diff --git a/tests/test_chat_manager.py b/tests/test_chat_manager.py index 009e51e..2217532 100644 --- a/tests/test_chat_manager.py +++ b/tests/test_chat_manager.py @@ -134,24 +134,3 @@ class TestSendMessage: cm.send_message("Hello") -# ── get_chat_display ────────────────────────────────────────────────────────── - -class TestGetChatDisplay: - """Tests for get_chat_display: correct shape and ordering.""" - - @pytest.fixture - def cm(self): - return ChatManager() - - def test_display_has_role_and_content_keys(self, cm): - cm.add_message("user", "Hello") - entry = cm.get_chat_display()[0] - assert "role" in entry - assert "content" in entry - - def test_display_preserves_message_order(self, cm): - cm.add_message("user", "First") - cm.add_message("assistant", "Second") - display = cm.get_chat_display() - assert display[0]["role"] == "user" - assert display[1]["role"] == "assistant" diff --git a/tests/test_coding_agent.py b/tests/test_coding_agent.py index bb69056..5d5ee1f 100644 --- a/tests/test_coding_agent.py +++ b/tests/test_coding_agent.py @@ -21,12 +21,10 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import pytest_asyncio sys.path.insert(0, str(Path(__file__).parent.parent)) from backend.agent.coding_agent import ( - MAX_HISTORY_CHARS, MAX_ITERATIONS, MAX_RESULT_LENGTH, CodingAgent, diff --git a/tests/test_debug_logger.py b/tests/test_debug_logger.py index d0de98a..7bd7ae3 100644 --- a/tests/test_debug_logger.py +++ b/tests/test_debug_logger.py @@ -1,4 +1,9 @@ -"""Tests for DebugLogger (backend/managers/debug_logger.py).""" +"""Tests for DebugLogger (backend/managers/debug_logger.py). + +DebugLogger is a classmethod-based utility. Its _error_log class variable +persists across tests, so every test that modifies it must call +DebugLogger.clear_errors() in teardown (handled by the autouse fixture). +""" import sys from pathlib import Path @@ -6,105 +11,99 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) -from backend.managers._debug_logger import DebugLogger +from backend.managers.debug_logger import DebugLogger # ── Fixtures ────────────────────────────────────────────────────────────────── -@pytest.fixture -def logger(): - """Return a fresh DebugLogger for each test.""" - return DebugLogger() - - -# ── log() ───────────────────────────────────────────────────────────────────── - -class TestLog: - """Tests for log(): each call appends an INFO-level entry with message.""" - - def test_log_appends_entry(self, logger): - logger.log("started") - assert len(logger.logs) == 1 - - def test_log_sets_level_info(self, logger): - logger.log("started") - assert logger.logs[0]["level"] == "INFO" - - def test_log_stores_message(self, logger): - logger.log("executing file.py") - assert logger.logs[0]["message"] == "executing file.py" +@pytest.fixture(autouse=True) +def clear_error_log(): + """Reset the shared _error_log class variable before and after each test.""" + DebugLogger.clear_errors() + yield + DebugLogger.clear_errors() # ── log_error() ─────────────────────────────────────────────────────────────── class TestLogError: - """Tests for log_error(): level is ERROR, not INFO.""" + """Tests for log_error(): appends the message to the in-memory error list.""" - def test_log_error_sets_level_error(self, logger): - logger.log_error("something broke") - assert logger.logs[0]["level"] == "ERROR" + def test_log_error_appends_to_error_log(self): + DebugLogger.log_error("something broke") + assert "something broke" in DebugLogger.get_errors() - def test_log_and_log_error_are_distinct_levels(self, logger): - logger.log("info message") - logger.log_error("error message") - assert logger.logs[0]["level"] == "INFO" - assert logger.logs[1]["level"] == "ERROR" + def test_log_error_multiple_messages_all_stored(self): + DebugLogger.log_error("first error") + DebugLogger.log_error("second error") + errors = DebugLogger.get_errors() + assert "first error" in errors + assert "second error" in errors + + def test_log_error_preserves_order(self): + DebugLogger.log_error("alpha") + DebugLogger.log_error("beta") + errors = DebugLogger.get_errors() + assert errors.index("alpha") < errors.index("beta") -# ── get_logs() ──────────────────────────────────────────────────────────────── +# ── get_errors() ────────────────────────────────────────────────────────────── -class TestGetLogs: - """Tests for get_logs().""" +class TestGetErrors: + """Tests for get_errors(): returns the current in-memory error list.""" - def test_get_logs_returns_all_entries(self, logger): - logger.log("first") - logger.log_error("second") - assert len(logger.get_logs()) == 2 + def test_get_errors_empty_initially(self): + assert DebugLogger.get_errors() == [] + + def test_get_errors_reflects_logged_errors(self): + DebugLogger.log_error("boom") + assert len(DebugLogger.get_errors()) == 1 -# ── clear() ─────────────────────────────────────────────────────────────────── +# ── clear_errors() ──────────────────────────────────────────────────────────── -class TestClear: - """Tests for clear().""" +class TestClearErrors: + """Tests for clear_errors(): wipes the in-memory error list.""" - def test_clear_removes_all_entries(self, logger): - logger.log("first") - logger.log_error("second") - logger.clear() - assert logger.logs == [] + def test_clear_errors_empties_list(self): + DebugLogger.log_error("will be cleared") + DebugLogger.clear_errors() + assert DebugLogger.get_errors() == [] + + def test_clear_errors_on_empty_list_does_not_raise(self): + DebugLogger.clear_errors() # already empty from autouse fixture # ── format_debug_output() ───────────────────────────────────────────────────── class TestFormatDebugOutput: - """Tests for format_debug_output(): renders rc, stdout, stderr, and log entries.""" + """Tests for format_debug_output(): renders return_code, stdout, and stderr.""" - def test_success_exit_code_shows_success(self, logger): - result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) - assert "[SUCCESS]" in result + def test_contains_execution_result_header(self): + result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""}) + assert "=== Execution Result ===" in result - def test_nonzero_exit_code_shows_failed(self, logger): - result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""}) - assert "[FAILED]" in result + def test_exit_code_zero_appears_in_output(self): + result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""}) + assert "Exit Code: 0" in result - def test_stdout_included_when_present(self, logger): - result = logger.format_debug_output({"rc": 0, "stdout": "Hello", "stderr": ""}) + def test_nonzero_exit_code_appears_in_output(self): + result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": ""}) + assert "Exit Code: 1" in result + + def test_stdout_included_when_present(self): + result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "Hello", "stderr": ""}) assert "Hello" in result - assert "stdout" in result - def test_stderr_included_when_present(self, logger): - result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": "NameError"}) + def test_stderr_included_when_present(self): + result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": "NameError"}) assert "NameError" in result - assert "stderr" in result - def test_log_entries_appended_to_output(self, logger): - logger.log("Executing file.py...") - logger.log_error("exit code 1") - result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""}) - assert "Executing file.py..." in result - assert "exit code 1" in result + def test_empty_stdout_shows_none_placeholder(self): + result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""}) + assert "(none)" in result - def test_missing_keys_do_not_raise(self, logger): - # Defensive: format_debug_output uses .get() so missing keys are safe - result = logger.format_debug_output({}) + def test_missing_keys_do_not_raise(self): + # format_debug_output uses .get() so absent keys fall back to defaults. + result = DebugLogger.format_debug_output({}) assert isinstance(result, str) diff --git a/tests/test_execution_engine.py b/tests/test_execution_engine.py index 6120ece..6f5d5bf 100644 --- a/tests/test_execution_engine.py +++ b/tests/test_execution_engine.py @@ -1,6 +1,5 @@ import pytest import subprocess -from pathlib import Path from unittest.mock import Mock, patch from backend.managers.execution_engine import ExecutionEngine diff --git a/tests/test_mcp_server_code_execution.py b/tests/test_mcp_server_code_execution.py index 8bef986..231eec6 100644 --- a/tests/test_mcp_server_code_execution.py +++ b/tests/test_mcp_server_code_execution.py @@ -1,7 +1,3 @@ -import pytest -from unittest.mock import Mock, patch -import subprocess - from backend.agent.servers import mcp_server_code_execution as server diff --git a/tests/test_mcp_server_file_search.py b/tests/test_mcp_server_file_search.py index 61481e9..066a7b0 100644 --- a/tests/test_mcp_server_file_search.py +++ b/tests/test_mcp_server_file_search.py @@ -1,5 +1,4 @@ import pytest -from pathlib import Path from backend.agent.servers import mcp_server_file_search as server @@ -66,7 +65,9 @@ def test_list_files_recursive(workspace): result = server.list_files() - assert "src/main.py" in result + # list_files() uses Path.relative_to() which yields OS-native separators; + # check for the components instead of a hard-coded slash style. + assert "src" in result and "main.py" in result # --------------------------------------------------------- diff --git a/tests/test_mcp_server_web_search.py b/tests/test_mcp_server_web_search.py index 66835ea..ed3b939 100644 --- a/tests/test_mcp_server_web_search.py +++ b/tests/test_mcp_server_web_search.py @@ -63,7 +63,7 @@ def test_validate_url_private_ip(): # 6. web_search liefert Suchergebnisse # --------------------------------------------------------- -@patch("ddgs.DDGS") +@patch("backend.agent.servers.mcp_server_web_search.DDGS") def test_web_search_success(mock_ddgs): mock_instance = Mock() @@ -87,7 +87,7 @@ def test_web_search_success(mock_ddgs): # 7. web_search ohne Ergebnisse # --------------------------------------------------------- -@patch("ddgs.DDGS") +@patch("backend.agent.servers.mcp_server_web_search.DDGS") def test_web_search_no_results(mock_ddgs): mock_instance = Mock() mock_instance.text.return_value = [] @@ -230,7 +230,9 @@ def test_fetch_page_timeout(mock_get): result = server.fetch_page("https://example.com") - assert "Error fetching page" in result + # fetch_page catches RequestException (which includes Timeout) and returns + # "HTTP-Fehler: " — verify an error string comes back. + assert "HTTP-Fehler" in result or "Error" in result # --------------------------------------------------------- @@ -245,7 +247,7 @@ def test_fetch_page_connection_error(mock_get): result = server.fetch_page("https://example.com") - assert "Error fetching page" in result + assert "HTTP-Fehler" in result or "Error" in result # --------------------------------------------------------- @@ -288,7 +290,7 @@ def test_fetch_page_empty_content(mock_get): # 20. web_search behandelt Exception sauber # --------------------------------------------------------- -@patch("ddgs.DDGS") +@patch("backend.agent.servers.mcp_server_web_search.DDGS") def test_web_search_exception(mock_ddgs): mock_ddgs.side_effect = Exception("DDGS failed") diff --git a/tests/test_search_manager.py b/tests/test_search_manager.py index 73fbcc9..d1f0434 100644 --- a/tests/test_search_manager.py +++ b/tests/test_search_manager.py @@ -1,8 +1,7 @@ """Tests for SearchManager — no real network calls, all I/O mocked.""" -import socket import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch from backend.managers.search_manager import SearchManager, MAX_PAGE_CHARS diff --git a/tests/test_system_prompter.py b/tests/test_system_prompter.py index b17c69d..662c811 100644 --- a/tests/test_system_prompter.py +++ b/tests/test_system_prompter.py @@ -5,7 +5,6 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) -import pytest from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS