feat: refactor APIClient and ToolRegistry to classes

- api_client.py: APIClient class with ProviderConfig types,
  chat_stream() returns str instead of printing,
  module-level wrappers for backward compat
- tools.py: ToolRegistry class with instance methods,
  pure Python list_files (os.walk) and search_in_files (re.search)
  replacing find/grep for Windows compat,
  English tool descriptions and error messages,
  module-level wrappers for backward compat
- 74 new tests (21 api_client + 53 tools)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 00:09:41 +03:00
parent d292d32afa
commit 6bbbefaa6f
4 changed files with 1170 additions and 274 deletions

View File

@@ -1,26 +1,157 @@
"""Universal API client supporting multiple OpenAI-compatible providers.""" """Universal API client supporting multiple OpenAI-compatible providers."""
import httpx from __future__ import annotations
import json import json
from typing import Iterator
import httpx
from meeting_room.models import ProviderConfig
PROVIDERS = {} class APIClient:
"""Stateful client that holds provider configs and makes LLM calls.
Parameters
----------
providers:
Mapping of provider name to ``ProviderConfig`` (base_url + api_key).
"""
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
self.providers: dict[str, ProviderConfig] = providers
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_client(self, provider_name: str) -> httpx.Client:
"""Create a short-lived ``httpx.Client`` for *provider_name*."""
p = self.providers.get(provider_name)
if not p:
raise ValueError(f"Unknown provider: {provider_name}")
return httpx.Client(
base_url=p.base_url,
headers={"Authorization": f"Bearer {p.api_key}"},
timeout=120.0,
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def chat(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
tools: list[dict] | None = None,
) -> dict:
"""Send a non-streaming chat completion request.
Returns a dict with keys ``content`` (str) and ``tool_calls``
(list | None).
"""
client = self._get_client(provider)
payload: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
resp = client.post("/chat/completions", json=payload)
resp.raise_for_status()
data = resp.json()
message = data["choices"][0]["message"]
result: dict = {
"content": message.get("content") or "",
"tool_calls": None,
}
if message.get("tool_calls"):
result["tool_calls"] = [
{
"id": tc["id"],
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
for tc in message["tool_calls"]
]
return result
def chat_stream(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
) -> str:
"""Send a streaming chat completion request.
Accumulates all content deltas and **returns** the full text.
Does NOT print to stdout.
"""
client = self._get_client(provider)
payload: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
full_text = ""
with client.stream("POST", "/chat/completions", json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"]
if "content" in delta and delta["content"]:
full_text += delta["content"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
return full_text
def init_providers(config: dict): # ======================================================================
global PROVIDERS # Module-level backward-compatible wrappers
PROVIDERS = config.get("providers", {}) # ======================================================================
_default_client: APIClient | None = None
def _get_client(provider_name: str) -> httpx.Client: def init_providers(config: dict) -> None:
p = PROVIDERS.get(provider_name) """Initialise the module-level default ``APIClient``.
if not p:
raise ValueError(f"Unknown provider: {provider_name}") *config* is the raw dict produced by ``yaml.safe_load()`` — it must
return httpx.Client( contain a ``providers`` key whose value maps provider names to
base_url=p["base_url"], ``{base_url, api_key}`` dicts.
headers={"Authorization": f"Bearer {p['api_key']}"}, """
timeout=120.0, global _default_client
) raw: dict = config.get("providers", {})
providers: dict[str, ProviderConfig] = {
name: ProviderConfig(**vals) for name, vals in raw.items()
}
_default_client = APIClient(providers)
def _require_default() -> APIClient:
if _default_client is None:
raise RuntimeError(
"init_providers() must be called before using module-level chat/chat_stream"
)
return _default_client
def chat( def chat(
@@ -30,36 +161,8 @@ def chat(
temperature: float = 0.7, temperature: float = 0.7,
tools: list[dict] | None = None, tools: list[dict] | None = None,
) -> dict: ) -> dict:
client = _get_client(provider) """Module-level wrapper that delegates to the default ``APIClient``."""
payload = { return _require_default().chat(provider, model, messages, temperature, tools)
"model": model,
"messages": messages,
"temperature": temperature,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
resp = client.post("/chat/completions", json=payload)
resp.raise_for_status()
data = resp.json()
message = data["choices"][0]["message"]
result = {
"content": message.get("content") or "",
"tool_calls": None,
}
if message.get("tool_calls"):
result["tool_calls"] = []
for tc in message["tool_calls"]:
result["tool_calls"].append({
"id": tc["id"],
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
})
return result
def chat_stream( def chat_stream(
@@ -68,29 +171,8 @@ def chat_stream(
messages: list[dict], messages: list[dict],
temperature: float = 0.7, temperature: float = 0.7,
) -> str: ) -> str:
client = _get_client(provider) """Module-level wrapper that delegates to the default ``APIClient``.
payload = {
"model": model, Returns the accumulated text — does NOT print to stdout.
"messages": messages, """
"temperature": temperature, return _require_default().chat_stream(provider, model, messages, temperature)
"stream": True,
}
full_text = ""
with client.stream("POST", "/chat/completions", json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"]
if "content" in delta and delta["content"]:
print(delta["content"], end="", flush=True)
full_text += delta["content"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
print()
return full_text

View File

@@ -1,32 +1,17 @@
""" """
Tools system for Meeting Room agents. Tools system for Meeting Room agents.
Provides file access and web capabilities via OpenAI function calling. Provides file access and web capabilities via OpenAI function calling.
The ToolRegistry class encapsulates all tool logic with a configurable
workdir. Module-level wrappers maintain backward compatibility.
""" """
import fnmatch
import httpx
import json
import os import os
import re import re
import json
import subprocess import subprocess
import httpx
WORKDIR = "."
def set_workdir(path: str):
global WORKDIR
WORKDIR = os.path.abspath(path)
def _resolve_path(path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(WORKDIR, path))
def _truncate(text: str, max_len: int = 5000) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f"\n... [обрезано, всего {len(text)} символов]"
# ─── Tool schemas (OpenAI function calling format) ─── # ─── Tool schemas (OpenAI function calling format) ───
@@ -36,13 +21,13 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "read_file", "name": "read_file",
"description": "Прочитать содержимое файла. Возвращает текст с номерами строк.", "description": "Read file contents. Returns text with line numbers.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"path": {"type": "string", "description": "Путь к файлу"}, "path": {"type": "string", "description": "Path to the file"},
"offset": {"type": "integer", "description": "Начать с этой строки (1-indexed)", "default": 1}, "offset": {"type": "integer", "description": "Start from this line (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Максимум строк", "default": 100}, "limit": {"type": "integer", "description": "Maximum number of lines", "default": 100},
}, },
"required": ["path"], "required": ["path"],
}, },
@@ -52,13 +37,13 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "list_files", "name": "list_files",
"description": "Показать список файлов и директорий.", "description": "List files and directories.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"path": {"type": "string", "description": "Директория для просмотра"}, "path": {"type": "string", "description": "Directory to list"},
"pattern": {"type": "string", "description": "Фильтр по имени (glob)"}, "pattern": {"type": "string", "description": "Filter by name (glob)"},
"max_depth": {"type": "integer", "description": "Глубина обхода", "default": 3}, "max_depth": {"type": "integer", "description": "Traversal depth", "default": 3},
}, },
"required": [], "required": [],
}, },
@@ -68,13 +53,13 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "search_in_files", "name": "search_in_files",
"description": "Поиск текста в файлах (regex).", "description": "Search text in files (regex).",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"query": {"type": "string", "description": "Поисковый запрос (regex)"}, "query": {"type": "string", "description": "Search query (regex)"},
"path": {"type": "string", "description": "Директория для поиска"}, "path": {"type": "string", "description": "Directory to search in"},
"file_pattern": {"type": "string", "description": "Фильтр по расширению"}, "file_pattern": {"type": "string", "description": "Filter by extension (e.g. *.py)"},
}, },
"required": ["query"], "required": ["query"],
}, },
@@ -84,12 +69,12 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "web_search", "name": "web_search",
"description": "Поиск в интернете. Возвращает заголовки, сниппеты и URL.", "description": "Search the web. Returns titles, snippets and URLs.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"query": {"type": "string", "description": "Поисковый запрос"}, "query": {"type": "string", "description": "Search query"},
"max_results": {"type": "integer", "description": "Максимум результатов", "default": 5}, "max_results": {"type": "integer", "description": "Maximum results", "default": 5},
}, },
"required": ["query"], "required": ["query"],
}, },
@@ -99,12 +84,12 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "web_fetch", "name": "web_fetch",
"description": "Скачать содержимое веб-страницы.", "description": "Fetch web page content.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"url": {"type": "string", "description": "URL для загрузки"}, "url": {"type": "string", "description": "URL to fetch"},
"max_length": {"type": "integer", "description": "Максимум символов", "default": 5000}, "max_length": {"type": "integer", "description": "Maximum characters", "default": 5000},
}, },
"required": ["url"], "required": ["url"],
}, },
@@ -114,12 +99,12 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "write_file", "name": "write_file",
"description": "Записать содержимое в файл.", "description": "Write content to a file.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"path": {"type": "string", "description": "Путь к файлу"}, "path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Содержимое"}, "content": {"type": "string", "description": "Content to write"},
}, },
"required": ["path", "content"], "required": ["path", "content"],
}, },
@@ -129,12 +114,12 @@ TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "run_command", "name": "run_command",
"description": "Выполнить shell-команду.", "description": "Run a shell command.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"command": {"type": "string", "description": "Команда"}, "command": {"type": "string", "description": "Command to run"},
"timeout": {"type": "integer", "description": "Таймаут (сек)", "default": 30}, "timeout": {"type": "integer", "description": "Timeout in seconds", "default": 30},
}, },
"required": ["command"], "required": ["command"],
}, },
@@ -142,170 +127,251 @@ TOOL_SCHEMAS = [
}, },
] ]
# Directories to skip during file traversal
_SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", ".pytest_cache"}
# ─── Implementations ─── # Tool set definitions
def tool_read_file(path: str, offset: int = 1, limit: int = 100) -> str:
resolved = _resolve_path(path)
if not os.path.isfile(resolved):
return f"Ошибка: файл не найден — {resolved}"
try:
with open(resolved, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
total = len(lines)
selected = lines[offset - 1 : offset - 1 + limit]
result = "".join(f"{i}|{line}" for i, line in enumerate(selected, start=offset))
return _truncate(f"[{resolved}{total} строк]\n{result}")
except Exception as e:
return f"Ошибка чтения: {e}"
def tool_list_files(path: str = "", pattern: str = "", max_depth: int = 3) -> str:
base = _resolve_path(path or ".")
if not os.path.isdir(base):
return f"Ошибка: директория не найдена — {base}"
try:
result = subprocess.run(
["find", base, "-maxdepth", str(max_depth),
"-not", "-path", "*/.git/*", "-not", "-path", "*/.venv/*",
"-not", "-path", "*/node_modules/*", "-not", "-path", "*/__pycache__/*"],
capture_output=True, text=True, timeout=10,
)
lines = result.stdout.strip().split("\n")
if pattern:
import fnmatch
lines = [l for l in lines if fnmatch.fnmatch(os.path.basename(l), pattern)]
lines.sort()
return _truncate("\n".join(lines))
except Exception as e:
return f"Ошибка: {e}"
def tool_search_in_files(query: str, path: str = "", file_pattern: str = "") -> str:
base = _resolve_path(path or ".")
cmd = ["grep", "-rn", "--color=never", "-E", query, base]
if file_pattern:
cmd.extend(["--include", file_pattern])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode == 1:
return "Совпадений не найдено."
return _truncate(result.stdout.strip())
except subprocess.TimeoutExpired:
return "Таймаут поиска."
except Exception as e:
return f"Ошибка: {e}"
def tool_web_search(query: str, max_results: int = 5) -> str:
try:
client = httpx.Client(timeout=15, follow_redirects=True)
resp = client.get(
"https://html.duckduckgo.com/html/", params={"q": query},
headers={"User-Agent": "Mozilla/5.0"},
)
resp.raise_for_status()
results = []
for match in re.finditer(
r'<a rel="nofollow" class="result__a" href="([^"]+)">\s*(.*?)\s*</a>.*?'
r'<a class="result__snippet".*?>(.*?)</a>',
resp.text, re.DOTALL,
):
url = match.group(1)
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
snippet = re.sub(r'<[^>]+>', '', match.group(3)).strip()
results.append(f"{title}\n {url}\n {snippet}")
if len(results) >= max_results:
break
if not results:
return "Результатов не найдено."
return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results)))
except Exception as e:
return f"Ошибка поиска: {e}"
def tool_web_fetch(url: str, max_length: int = 5000) -> str:
try:
client = httpx.Client(timeout=20, follow_redirects=True)
resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"})
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
if "html" in content_type:
text = re.sub(r'<script[^>]*>.*?</script>', '', resp.text, flags=re.DOTALL)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
else:
text = resp.text
return _truncate(f"[{url}{len(resp.text)} символов]\n{text}", max_length)
except Exception as e:
return f"Ошибка загрузки: {e}"
def tool_write_file(path: str, content: str) -> str:
resolved = _resolve_path(path)
try:
os.makedirs(os.path.dirname(resolved), exist_ok=True)
with open(resolved, "w", encoding="utf-8") as f:
f.write(content)
return f"Записано {len(content)} символов в {resolved}"
except Exception as e:
return f"Ошибка записи: {e}"
def tool_run_command(command: str, timeout: int = 30) -> str:
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=WORKDIR,
)
output = result.stdout
if result.stderr:
output += f"\nSTDERR:\n{result.stderr}"
if result.returncode != 0:
output += f"\n[exit code: {result.returncode}]"
return _truncate(output)
except subprocess.TimeoutExpired:
return f"Таймаут ({timeout}с)"
except Exception as e:
return f"Ошибка: {e}"
# ─── Dispatcher ───
TOOL_FUNCTIONS = {
"read_file": tool_read_file,
"list_files": tool_list_files,
"search_in_files": tool_search_in_files,
"web_search": tool_web_search,
"web_fetch": tool_web_fetch,
"write_file": tool_write_file,
"run_command": tool_run_command,
}
TOOL_SETS = { TOOL_SETS = {
"all": list(TOOL_FUNCTIONS.keys()), "all": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"full": list(TOOL_FUNCTIONS.keys()), "full": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"files": ["read_file", "list_files", "search_in_files", "write_file"], "files": ["read_file", "list_files", "search_in_files", "write_file"],
"readonly": ["read_file", "list_files", "search_in_files"], "readonly": ["read_file", "list_files", "search_in_files"],
"web": ["web_search", "web_fetch"], "web": ["web_search", "web_fetch"],
"none": [], "none": [],
} }
def get_tool_schemas(tool_set: str = "all") -> list[dict]: def _truncate(text: str, max_len: int = 5000) -> str:
if isinstance(tool_set, list): if len(text) <= max_len:
names = tool_set return text
else: return text[:max_len] + f"\n... [truncated, {len(text)} chars total]"
names = TOOL_SETS.get(tool_set, TOOL_SETS["all"])
return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names]
class ToolRegistry:
"""Manages tool schemas and execution for a given working directory."""
def __init__(self, workdir: str = ".") -> None:
self._workdir = os.path.abspath(workdir)
# ── Path resolution ──
def _resolve_path(self, path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(self._workdir, path))
# ── Schema / dispatch ──
def get_tool_schemas(self, tool_set: str | list = "all") -> list[dict]:
if isinstance(tool_set, list):
names = tool_set
else:
names = TOOL_SETS.get(tool_set, TOOL_SETS["all"])
return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names]
def execute_tool(self, name: str, arguments: dict) -> str:
method_map = {
"read_file": self.tool_read_file,
"list_files": self.tool_list_files,
"search_in_files": self.tool_search_in_files,
"web_search": self.tool_web_search,
"web_fetch": self.tool_web_fetch,
"write_file": self.tool_write_file,
"run_command": self.tool_run_command,
}
func = method_map.get(name)
if not func:
return f"Unknown tool: {name}"
try:
return func(**arguments)
except Exception as e:
return f"Error executing {name}: {e}"
# ── Tool implementations ──
def tool_read_file(self, path: str, offset: int = 1, limit: int = 100) -> str:
resolved = self._resolve_path(path)
if not os.path.isfile(resolved):
return f"Error: file not found — {resolved}"
try:
with open(resolved, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
total = len(lines)
selected = lines[offset - 1 : offset - 1 + limit]
result = "".join(f"{i}|{line}" for i, line in enumerate(selected, start=offset))
return _truncate(f"[{resolved}{total} lines]\n{result}")
except Exception as e:
return f"Error reading file: {e}"
def tool_list_files(self, path: str = "", pattern: str = "", max_depth: int = 3) -> str:
base = self._resolve_path(path or ".")
if not os.path.isdir(base):
return f"Error: directory not found — {base}"
try:
results: list[str] = []
base_depth = base.count(os.sep)
for dirpath, dirnames, filenames in os.walk(base):
# Prune skipped directories in-place so os.walk skips them
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
# Check depth
current_depth = dirpath.count(os.sep) - base_depth
if current_depth >= max_depth:
# Don't descend further, but still list this dir's files
dirnames.clear()
# Add directory itself
rel_dir = os.path.relpath(dirpath, base)
if rel_dir == ".":
results.append(dirpath)
else:
results.append(dirpath)
# Add files
for fname in filenames:
if pattern and not fnmatch.fnmatch(fname, pattern):
continue
results.append(os.path.join(dirpath, fname))
results.sort()
return _truncate("\n".join(results))
except Exception as e:
return f"Error: {e}"
def tool_search_in_files(self, query: str, path: str = "", file_pattern: str = "") -> str:
base = self._resolve_path(path or ".")
if not os.path.isdir(base):
return f"Error: directory not found — {base}"
try:
regex = re.compile(query, re.IGNORECASE)
except re.error as e:
return f"Error: invalid regex — {e}"
try:
results: list[str] = []
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
for fname in filenames:
if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
continue
fpath = os.path.join(dirpath, fname)
try:
with open(fpath, encoding="utf-8", errors="replace") as f:
for line_num, line in enumerate(f, start=1):
if regex.search(line):
results.append(f"{fpath}:{line_num}:{line.rstrip()}")
if len(results) >= 200:
return _truncate("\n".join(results))
except (OSError, UnicodeDecodeError):
continue
if not results:
return "No matches found."
return _truncate("\n".join(results))
except Exception as e:
return f"Error: {e}"
def tool_web_search(self, query: str, max_results: int = 5) -> str:
try:
client = httpx.Client(timeout=15, follow_redirects=True)
resp = client.get(
"https://html.duckduckgo.com/html/",
params={"q": query},
headers={"User-Agent": "Mozilla/5.0"},
)
resp.raise_for_status()
results = []
for match in re.finditer(
r'<a rel="nofollow" class="result__a" href="([^"]+)">\s*(.*?)\s*</a>.*?'
r'<a class="result__snippet".*?>(.*?)</a>',
resp.text, re.DOTALL,
):
url = match.group(1)
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
snippet = re.sub(r'<[^>]+>', '', match.group(3)).strip()
results.append(f"{title}\n {url}\n {snippet}")
if len(results) >= max_results:
break
if not results:
return "No results found."
return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results)))
except Exception as e:
return f"Search error: {e}"
def tool_web_fetch(self, url: str, max_length: int = 5000) -> str:
try:
client = httpx.Client(timeout=20, follow_redirects=True)
resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"})
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
if "html" in content_type:
text = re.sub(r'<script[^>]*>.*?</script>', '', resp.text, flags=re.DOTALL)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
else:
text = resp.text
return _truncate(f"[{url}{len(resp.text)} chars]\n{text}", max_length)
except Exception as e:
return f"Fetch error: {e}"
def tool_write_file(self, path: str, content: str) -> str:
resolved = self._resolve_path(path)
try:
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
with open(resolved, "w", encoding="utf-8") as f:
f.write(content)
return f"Wrote {len(content)} chars to {resolved}"
except Exception as e:
return f"Write error: {e}"
def tool_run_command(self, command: str, timeout: int = 30) -> str:
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=self._workdir,
)
output = result.stdout
if result.stderr:
output += f"\nSTDERR:\n{result.stderr}"
if result.returncode != 0:
output += f"\n[exit code: {result.returncode}]"
return _truncate(output)
except subprocess.TimeoutExpired:
return f"Timeout ({timeout}s)"
except Exception as e:
return f"Error: {e}"
# ─── Module-level backward-compatible wrappers ───
_default_registry: ToolRegistry | None = None
def set_workdir(path: str) -> None:
"""Set the working directory for module-level tool functions."""
global _default_registry
_default_registry = ToolRegistry(workdir=path)
def _get_registry() -> ToolRegistry:
global _default_registry
if _default_registry is None:
_default_registry = ToolRegistry()
return _default_registry
def get_tool_schemas(tool_set: str | list = "all") -> list[dict]:
"""Return tool schemas for the given set name or explicit list."""
return _get_registry().get_tool_schemas(tool_set)
def execute_tool(name: str, arguments: dict) -> str: def execute_tool(name: str, arguments: dict) -> str:
func = TOOL_FUNCTIONS.get(name) """Execute a tool by name with the given arguments."""
if not func: return _get_registry().execute_tool(name, arguments)
return f"Неизвестный инструмент: {name}"
try:
return func(**arguments) # Legacy dict-based dispatch (kept for any code that imports TOOL_FUNCTIONS)
except Exception as e: TOOL_FUNCTIONS = {
return f"Ошибка выполнения {name}: {e}" "read_file": lambda **kw: _get_registry().tool_read_file(**kw),
"list_files": lambda **kw: _get_registry().tool_list_files(**kw),
"search_in_files": lambda **kw: _get_registry().tool_search_in_files(**kw),
"web_search": lambda **kw: _get_registry().tool_web_search(**kw),
"web_fetch": lambda **kw: _get_registry().tool_web_fetch(**kw),
"write_file": lambda **kw: _get_registry().tool_write_file(**kw),
"run_command": lambda **kw: _get_registry().tool_run_command(**kw),
}

359
tests/test_api_client.py Normal file
View File

@@ -0,0 +1,359 @@
"""Tests for meeting_room.api_client — APIClient class and module-level wrappers."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from meeting_room.api_client import APIClient, chat, chat_stream, init_providers
from meeting_room.models import ProviderConfig
# ---------------------------------------------------------------------------
# Fixtures & helpers
# ---------------------------------------------------------------------------
PROVIDERS = {
"testprov": ProviderConfig(
base_url="https://api.test.example.com/v1",
api_key="sk-test-key-123",
),
}
def _make_chat_response(
content: str = "Hello!",
tool_calls: list[dict] | None = None,
) -> dict:
"""Build a minimal OpenAI-style chat completion response."""
message: dict = {"content": content}
if tool_calls:
message["tool_calls"] = tool_calls
return {"choices": [{"message": message}]}
def _make_stream_lines(chunks: list[str]) -> list[str]:
"""Build SSE lines that look like what OpenAI streaming returns.
Each chunk becomes a ``data: {json}`` line. A ``data: [DONE]``
sentinel is appended automatically.
"""
lines: list[str] = []
for text in chunks:
delta = {"choices": [{"delta": {"content": text}}]}
lines.append(f"data: {json.dumps(delta)}")
lines.append("data: [DONE]")
return lines
# ---------------------------------------------------------------------------
# APIClient — construction
# ---------------------------------------------------------------------------
class TestAPIClientInit:
def test_stores_providers(self) -> None:
client = APIClient(PROVIDERS)
assert "testprov" in client.providers
assert client.providers["testprov"].base_url == "https://api.test.example.com/v1"
def test_empty_providers_ok(self) -> None:
client = APIClient({})
assert client.providers == {}
# ---------------------------------------------------------------------------
# APIClient.chat
# ---------------------------------------------------------------------------
class TestAPIClientChat:
@patch("meeting_room.api_client.httpx.Client")
def test_basic_text_response(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("Hi there")
mock_resp.raise_for_status = MagicMock()
mock_client_instance = mock_client_cls.return_value
mock_client_instance.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "gpt-4o-mini", [{"role": "user", "content": "Hi"}])
assert result == {"content": "Hi there", "tool_calls": None}
mock_client_instance.post.assert_called_once()
call_args = mock_client_instance.post.call_args
assert call_args[0][0] == "/chat/completions"
payload = call_args[1]["json"]
assert payload["model"] == "gpt-4o-mini"
assert payload["messages"] == [{"role": "user", "content": "Hi"}]
assert payload["temperature"] == 0.7
@patch("meeting_room.api_client.httpx.Client")
def test_custom_temperature(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("cold")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
client.chat("testprov", "m", [], temperature=0.2)
payload = mock_client_cls.return_value.post.call_args[1]["json"]
assert payload["temperature"] == 0.2
@patch("meeting_room.api_client.httpx.Client")
def test_tool_calls_in_response(self, mock_client_cls: MagicMock) -> None:
tool_calls = [
{
"id": "call_abc",
"function": {"name": "read_file", "arguments": '{"path": "/tmp/x"}'},
"type": "function",
}
]
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("", tool_calls=tool_calls)
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "m", [])
assert result["tool_calls"] == [
{"id": "call_abc", "name": "read_file", "arguments": '{"path": "/tmp/x"}'}
]
@patch("meeting_room.api_client.httpx.Client")
def test_tools_sent_in_payload(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("ok")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
tools = [{"type": "function", "function": {"name": "search"}}]
client = APIClient(PROVIDERS)
client.chat("testprov", "m", [], tools=tools)
payload = mock_client_cls.return_value.post.call_args[1]["json"]
assert "tools" in payload
assert payload["tool_choice"] == "auto"
def test_unknown_provider_raises(self) -> None:
client = APIClient(PROVIDERS)
with pytest.raises(ValueError, match="Unknown provider: nonexistent"):
client.chat("nonexistent", "m", [])
@patch("meeting_room.api_client.httpx.Client")
def test_empty_content_yields_empty_string(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = {"choices": [{"message": {"content": None}}]}
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "m", [])
assert result["content"] == ""
@patch("meeting_room.api_client.httpx.Client")
def test_http_error_propagates(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"Bad Request",
request=MagicMock(),
response=httpx.Response(400),
)
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
with pytest.raises(httpx.HTTPStatusError):
client.chat("testprov", "m", [])
# ---------------------------------------------------------------------------
# APIClient.chat_stream
# ---------------------------------------------------------------------------
class TestAPIClientChatStream:
@patch("meeting_room.api_client.httpx.Client")
def test_accumulates_text(self, mock_client_cls: MagicMock) -> None:
"""chat_stream must return the concatenated text, NOT print."""
lines = _make_stream_lines(["Hello", " world", "!"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_instance = mock_client_cls.return_value
mock_client_instance.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "gpt-4o-mini", [{"role": "user", "content": "Hi"}])
assert result == "Hello world!"
@patch("meeting_room.api_client.httpx.Client")
def test_stream_payload_includes_stream_flag(self, mock_client_cls: MagicMock) -> None:
lines = _make_stream_lines(["x"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
client.chat_stream("testprov", "m", [])
call_args = mock_client_cls.return_value.stream.call_args
payload = call_args[1]["json"]
assert payload["stream"] is True
@patch("meeting_room.api_client.httpx.Client")
def test_empty_stream_returns_empty_string(self, mock_client_cls: MagicMock) -> None:
lines = _make_stream_lines([])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
assert result == ""
@patch("meeting_room.api_client.httpx.Client")
def test_malformed_lines_skipped(self, mock_client_cls: MagicMock) -> None:
"""Lines that are not valid SSE data should be silently skipped."""
raw_lines = [
"", # empty line — skipped
"not SSE", # no "data: " prefix — skipped
"data: {bad json", # malformed JSON — skipped
'data: {"choices":[{"delta":{"content":"ok"}}]}', # valid
"data: [DONE]",
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = raw_lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
assert result == "ok"
@patch("meeting_room.api_client.httpx.Client")
def test_does_not_print(self, mock_client_cls: MagicMock, capsys: pytest.CaptureFixture) -> None:
"""chat_stream must NOT write to stdout."""
lines = _make_stream_lines(["Hello", " world"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
captured = capsys.readouterr()
assert captured.out == ""
assert result == "Hello world"
def test_unknown_provider_raises(self) -> None:
client = APIClient(PROVIDERS)
with pytest.raises(ValueError, match="Unknown provider: nope"):
client.chat_stream("nope", "m", [])
# ---------------------------------------------------------------------------
# Module-level wrappers — backward compatibility
# ---------------------------------------------------------------------------
class TestModuleLevelWrappers:
def test_init_providers_creates_default_client(self) -> None:
import meeting_room.api_client as mod
config = {
"providers": {
"myprov": {"base_url": "https://api.example.com/v1", "api_key": "sk-abc"},
}
}
init_providers(config)
assert mod._default_client is not None
assert "myprov" in mod._default_client.providers
assert mod._default_client.providers["myprov"].api_key == "sk-abc"
@patch("meeting_room.api_client.httpx.Client")
def test_module_chat_delegates(self, mock_client_cls: MagicMock) -> None:
import meeting_room.api_client as mod
init_providers({"providers": {"prov": {"base_url": "https://x/v1", "api_key": "k"}}})
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("delegated")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
result = mod.chat("prov", "m", [])
assert result["content"] == "delegated"
@patch("meeting_room.api_client.httpx.Client")
def test_module_chat_stream_delegates(self, mock_client_cls: MagicMock) -> None:
import meeting_room.api_client as mod
init_providers({"providers": {"prov": {"base_url": "https://x/v1", "api_key": "k"}}})
lines = _make_stream_lines(["abc"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
result = mod.chat_stream("prov", "m", [])
assert result == "abc"
def test_module_chat_without_init_raises(self) -> None:
import meeting_room.api_client as mod
mod._default_client = None # ensure clean state
with pytest.raises(RuntimeError, match="init_providers"):
mod.chat("prov", "m", [])
def test_module_chat_stream_without_init_raises(self) -> None:
import meeting_room.api_client as mod
mod._default_client = None
with pytest.raises(RuntimeError, match="init_providers"):
mod.chat_stream("prov", "m", [])
# ---------------------------------------------------------------------------
# APIClient._get_client — httpx.Client construction
# ---------------------------------------------------------------------------
class TestGetClient:
@patch("meeting_room.api_client.httpx.Client")
def test_client_created_with_correct_params(self, mock_client_cls: MagicMock) -> None:
client = APIClient(PROVIDERS)
# Trigger _get_client by calling chat with a mocked response
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("ok")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client.chat("testprov", "m", [])
mock_client_cls.assert_called_once_with(
base_url="https://api.test.example.com/v1",
headers={"Authorization": "Bearer sk-test-key-123"},
timeout=120.0,
)

389
tests/test_tools.py Normal file
View File

@@ -0,0 +1,389 @@
"""Tests for meeting_room.tools — ToolRegistry class and module-level wrappers."""
import os
import textwrap
import pytest
from meeting_room.tools import (
TOOL_SCHEMAS,
TOOL_SETS,
ToolRegistry,
execute_tool,
get_tool_schemas,
set_workdir,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_workdir(tmp_path):
"""Create a temporary directory with some files for testing."""
# Create directories
(tmp_path / "src").mkdir()
(tmp_path / "src" / "sub").mkdir()
(tmp_path / "docs").mkdir()
(tmp_path / ".git" / "objects").mkdir(parents=True)
(tmp_path / "node_modules" / "pkg").mkdir(parents=True)
(tmp_path / "__pycache__").mkdir()
# Create files
(tmp_path / "README.md").write_text("# Hello\nWorld", encoding="utf-8")
(tmp_path / "src" / "main.py").write_text("def main():\n pass\n", encoding="utf-8")
(tmp_path / "src" / "utils.py").write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
(tmp_path / "src" / "sub" / "deep.py").write_text("# deep file\nx = 42\n", encoding="utf-8")
(tmp_path / "docs" / "notes.txt").write_text("Meeting notes\n", encoding="utf-8")
(tmp_path / "config.yaml").write_text("key: value\n", encoding="utf-8")
# Files in skip dirs
(tmp_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
(tmp_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {};\n", encoding="utf-8")
(tmp_path / "__pycache__" / "cache.pyc").write_text("bytecode", encoding="utf-8")
return tmp_path
@pytest.fixture
def reg(tmp_workdir):
"""ToolRegistry pointed at the temp workdir."""
return ToolRegistry(workdir=str(tmp_workdir))
# ===========================================================================
# TOOL_SCHEMAS — structure checks
# ===========================================================================
class TestToolSchemas:
def test_schema_names_match_expected(self):
names = {s["function"]["name"] for s in TOOL_SCHEMAS}
expected = {"read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"}
assert names == expected
def test_each_schema_has_required_fields(self):
for s in TOOL_SCHEMAS:
func = s["function"]
assert "name" in func
assert "description" in func
assert "parameters" in func
params = func["parameters"]
assert params["type"] == "object"
assert "properties" in params
def test_descriptions_are_in_english(self):
"""All descriptions must be in English (no Cyrillic)."""
for s in TOOL_SCHEMAS:
desc = s["function"]["description"]
# Check no Cyrillic characters
assert not re.search(r"[а-яА-ЯёЁ]", desc), f"Cyrillic in description: {desc}"
# ===========================================================================
# TOOL_SETS — grouping checks
# ===========================================================================
class TestToolSets:
def test_all_sets_exist(self):
for name in ("all", "full", "files", "readonly", "web", "none"):
assert name in TOOL_SETS
def test_all_equals_full(self):
assert TOOL_SETS["all"] == TOOL_SETS["full"]
def test_none_is_empty(self):
assert TOOL_SETS["none"] == []
def test_readonly_subset_of_files(self):
assert set(TOOL_SETS["readonly"]).issubset(set(TOOL_SETS["files"]))
def test_web_only_web_tools(self):
assert set(TOOL_SETS["web"]) == {"web_search", "web_fetch"}
# ===========================================================================
# ToolRegistry — construction and path resolution
# ===========================================================================
class TestToolRegistryInit:
def test_default_workdir(self):
r = ToolRegistry()
assert r._workdir == os.path.abspath(".")
def test_custom_workdir(self, tmp_path):
r = ToolRegistry(workdir=str(tmp_path))
assert r._workdir == str(tmp_path)
def test_resolve_absolute_path(self, reg):
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "some", "file.txt"))
assert reg._resolve_path(abs_path) == abs_path
def test_resolve_relative_path(self, reg, tmp_workdir):
result = reg._resolve_path("src/main.py")
assert result == os.path.normpath(os.path.join(str(tmp_workdir), "src/main.py"))
# ===========================================================================
# get_tool_schemas
# ===========================================================================
class TestGetToolSchemas:
def test_all_set(self, reg):
schemas = reg.get_tool_schemas("all")
assert len(schemas) == len(TOOL_SCHEMAS)
def test_none_set(self, reg):
schemas = reg.get_tool_schemas("none")
assert schemas == []
def test_readonly_set(self, reg):
schemas = reg.get_tool_schemas("readonly")
names = {s["function"]["name"] for s in schemas}
assert names == {"read_file", "list_files", "search_in_files"}
def test_explicit_list(self, reg):
schemas = reg.get_tool_schemas(["read_file", "web_search"])
names = {s["function"]["name"] for s in schemas}
assert names == {"read_file", "web_search"}
def test_unknown_set_falls_back_to_all(self, reg):
schemas = reg.get_tool_schemas("nonexistent_set")
assert len(schemas) == len(TOOL_SCHEMAS)
# ===========================================================================
# execute_tool dispatch
# ===========================================================================
class TestExecuteTool:
def test_unknown_tool(self, reg):
result = reg.execute_tool("nonexistent_tool", {})
assert "Unknown tool" in result
def test_execute_read_file(self, reg):
result = reg.execute_tool("read_file", {"path": "README.md"})
assert "Hello" in result
def test_execute_write_then_read(self, reg, tmp_workdir):
reg.execute_tool("write_file", {"path": "new_file.txt", "content": "test content"})
result = reg.execute_tool("read_file", {"path": "new_file.txt"})
assert "test content" in result
# ===========================================================================
# tool_read_file
# ===========================================================================
class TestToolReadFile:
def test_read_existing_file(self, reg):
result = reg.tool_read_file("README.md")
assert "Hello" in result
def test_read_with_offset(self, reg):
result = reg.tool_read_file("README.md", offset=2)
assert "World" in result
# Line numbers start at offset
assert "2|" in result
def test_read_with_limit(self, reg):
result = reg.tool_read_file("src/main.py", limit=1)
# Should have only 1 content line
lines = [l for l in result.split("\n") if "|" in l]
assert len(lines) == 1
def test_read_nonexistent_file(self, reg):
result = reg.tool_read_file("nonexistent.txt")
assert "not found" in result
def test_read_shows_total_lines(self, reg):
result = reg.tool_read_file("README.md")
assert "lines" in result
# ===========================================================================
# tool_list_files — pure Python, Windows-compatible
# ===========================================================================
class TestToolListFiles:
def test_list_root(self, reg, tmp_workdir):
result = reg.tool_list_files()
# Must include README.md and config.yaml at root
assert "README.md" in result
assert "config.yaml" in result
def test_list_skips_git(self, reg):
result = reg.tool_list_files()
# .git contents should NOT appear
assert ".git" not in result or "HEAD" not in result
def test_list_skips_node_modules(self, reg):
result = reg.tool_list_files()
assert "index.js" not in result
def test_list_skips_pycache(self, reg):
result = reg.tool_list_files()
assert "cache.pyc" not in result
def test_list_with_pattern(self, reg):
result = reg.tool_list_files(pattern="*.py")
lines = result.strip().split("\n") if result.strip() else []
# Only .py files should appear
for line in lines:
if line.strip():
assert line.endswith(".py") or os.path.isdir(line), f"Non-Python file in filtered results: {line}"
def test_list_with_path(self, reg, tmp_workdir):
result = reg.tool_list_files(path="src")
assert "main.py" in result
assert "utils.py" in result
def test_list_depth_limit(self, reg):
result = reg.tool_list_files(max_depth=1)
# With depth 1, should include root dir and its immediate files
# but NOT deeply nested files like src/sub/deep.py
# (it may list the src directory itself)
assert "config.yaml" in result
def test_list_nonexistent_dir(self, reg):
result = reg.tool_list_files(path="nonexistent_dir")
assert "not found" in result
def test_no_find_command_used(self):
"""Verify list_files implementation does not shell out to find."""
import inspect
source = inspect.getsource(ToolRegistry.tool_list_files)
assert "subprocess" not in source
assert '"find"' not in source
# ===========================================================================
# tool_search_in_files — pure Python, Windows-compatible
# ===========================================================================
class TestToolSearchInFiles:
def test_search_basic(self, reg):
result = reg.tool_search_in_files(query="def main")
assert "main.py" in result
def test_search_regex(self, reg):
result = reg.tool_search_in_files(query=r"def \w+\(")
# Should find function definitions
assert "def " in result
def test_search_with_file_pattern(self, reg):
result = reg.tool_search_in_files(query="value", file_pattern="*.yaml")
assert "config.yaml" in result
def test_search_no_matches(self, reg):
result = reg.tool_search_in_files(query="zzz_nonexistent_pattern_xyz")
assert "No matches" in result
def test_search_invalid_regex(self, reg):
result = reg.tool_search_in_files(query="[invalid")
assert "invalid regex" in result
def test_search_nonexistent_dir(self, reg):
result = reg.tool_search_in_files(query="test", path="nonexistent_dir")
assert "not found" in result
def test_search_skips_git(self, reg):
result = reg.tool_search_in_files(query="refs")
# Should not find content inside .git/HEAD
# (unless something else matches, but the file .git/HEAD itself should be skipped)
lines = result.strip().split("\n") if result.strip() else []
for line in lines:
assert ".git" not in line
def test_search_shows_line_numbers(self, reg):
result = reg.tool_search_in_files(query="def add")
# Format is filepath:linenum:content
assert ":" in result
def test_no_grep_command_used(self):
"""Verify search_in_files implementation does not shell out to grep."""
import inspect
source = inspect.getsource(ToolRegistry.tool_search_in_files)
assert "subprocess" not in source
assert '"grep"' not in source
# ===========================================================================
# tool_write_file
# ===========================================================================
class TestToolWriteFile:
def test_write_new_file(self, reg, tmp_workdir):
result = reg.tool_write_file("output.txt", "hello world")
assert "Wrote" in result
assert os.path.isfile(os.path.join(str(tmp_workdir), "output.txt"))
def test_write_creates_subdirs(self, reg, tmp_workdir):
result = reg.tool_write_file("sub/dir/test.txt", "nested")
assert "Wrote" in result
assert os.path.isfile(os.path.join(str(tmp_workdir), "sub", "dir", "test.txt"))
def test_write_overwrites(self, reg, tmp_workdir):
reg.tool_write_file("overwrite.txt", "first")
reg.tool_write_file("overwrite.txt", "second")
result = reg.tool_read_file("overwrite.txt")
assert "second" in result
# ===========================================================================
# tool_run_command
# ===========================================================================
class TestToolRunCommand:
def test_run_simple_command(self, reg):
result = reg.tool_run_command("echo hello")
assert "hello" in result.lower()
def test_run_command_timeout(self, reg):
# Use a command that sleeps longer than the timeout
result = reg.tool_run_command("ping -n 10 127.0.0.1" if os.name == "nt" else "sleep 30", timeout=1)
assert "Timeout" in result or "timeout" in result.lower()
def test_run_command_nonzero_exit(self, reg):
result = reg.tool_run_command("exit 1" if os.name != "nt" else "cmd /c exit 1")
# Should show exit code
assert "exit code" in result or result.strip() != ""
# ===========================================================================
# Module-level backward compatibility wrappers
# ===========================================================================
class TestModuleLevelWrappers:
def test_set_workdir_and_execute(self, tmp_workdir):
set_workdir(str(tmp_workdir))
result = execute_tool("read_file", {"path": "README.md"})
assert "Hello" in result
def test_get_tool_schemas_default(self):
# Without set_workdir, should still work (defaults to ".")
schemas = get_tool_schemas("all")
assert len(schemas) == len(TOOL_SCHEMAS)
def test_get_tool_schemas_module_level(self):
schemas = get_tool_schemas("readonly")
names = {s["function"]["name"] for s in schemas}
assert "read_file" in names
assert "web_search" not in names
def test_execute_unknown_tool(self):
result = execute_tool("does_not_exist", {})
assert "Unknown tool" in result
# Need re import for Cyrillic check
import re