Merge pull request 'func_improvments' (#19) from func_improvments into main

Reviewed-on: meulilivio/AISE1_Project#19
This commit is contained in:
Livio Meuli 2026-05-27 07:54:56 +02:00
commit 8ff46921b6
23 changed files with 585 additions and 876 deletions

View File

@ -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']}")
```

351
README.md
View File

@ -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_<modulname>.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 │
└─────────────────────────────────────────────────────────────────┘
```

View File

@ -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_<modulname>.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 │
└─────────────────────────────────────────────────────────────────┘
```

View File

@ -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()

View File

@ -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.

View File

@ -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

View File

@ -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}'."

View File

@ -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})
@ -40,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")
@ -66,10 +80,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,
@ -132,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
]

View File

@ -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.
@ -161,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)
@ -317,42 +318,34 @@ 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]:
"""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()

View File

@ -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

View File

@ -2,6 +2,7 @@
import asyncio
import json
import re
from pathlib import Path
import streamlit as st
@ -18,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.
@ -28,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):
@ -285,6 +278,22 @@ def render_agent_mode():
# ── Normal Chat helpers ───────────────────────────────────────────────────────
def _strip_search_context_from_history(chat_manager: ChatManager) -> None:
"""Remove <search_context> 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 "<search_context>" in msg["content"]:
msg["content"] = re.sub(
r"<search_context>.*?</search_context>\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 +376,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 +406,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 +424,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 +485,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)

View File

@ -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"""<script>
(function() {{
setTimeout(function() {{
const tabs = window.parent.document
.querySelectorAll('button[data-baseweb="tab"]');
if (tabs[{idx}]) tabs[{idx}].click();
}}, 100);
}})();
</script>""",
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"""<script>
(function() {{
setTimeout(function() {{
const tabs = window.parent.document
.querySelectorAll('button[data-baseweb="tab"]');
if (tabs[{idx}]) tabs[{idx}].click();
}}, 100);
}})();
</script>""",
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__":

View File

@ -390,8 +390,22 @@ def render_sidebar():
if st.button("Add Folder", key="btn_add_folder", use_container_width=True):
_add_folder_dialog("")
# REVIEW: bare `return` at end of void function — no-op; can be removed.
return
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.")
if __name__ == "__main__":

View File

@ -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 = {}

View File

@ -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"

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
# ---------------------------------------------------------

View File

@ -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: <exception>" — 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")

View File

@ -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

View File

@ -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