diff --git a/bot/README.md b/bot/README.md index d0fee9f37843..f06c1c1d23aa 100644 --- a/bot/README.md +++ b/bot/README.md @@ -55,7 +55,7 @@ cp config.json.example config.json | Параметр | Описание | По умолчанию | |----------|----------|-------------| | `vk_token` | VK API токен (получить [тут](https://vk.com/dev)) | — | -| `opencode_url` | URL opencode serve | `http://127.0.0.1:4096` | +| `opencode_url` | URL opencode serve | `http://127.0.0.1:4098` | | `session_file` | Файл хранения сессий | `sessions.json` | | `vk_api_version` | Версия VK API | `5.200` | | `longpoll_wait` | Время ожидания longpoll (сек) | `25` | @@ -65,6 +65,7 @@ cp config.json.example config.json | `opencode_bin_path` | Путь к бинарнику opencode | — | | `llama_server_path` | Путь к llama-server | `llama-server` | | `llama_server_host` | URL llama-server | `http://localhost:8081` | +| `llama_server_env` | Переменные окружения для llama-server (например, `{"TURBOPREFILL": "1"}`) | `{}` | | `models` | Словарь моделей и параметров запуска | — | | `default_model` | Алиас модели по умолчанию | — | | `mcp_servers` | (опционально) MCP серверы для opencode | — | diff --git a/bot/config-turbo.json b/bot/config-turbo.json new file mode 100644 index 000000000000..15597dace7dc --- /dev/null +++ b/bot/config-turbo.json @@ -0,0 +1,61 @@ +{ + "vk_token": "vk1.a.Iff3QxRdMeE9iG6HPbTYSMyj6-4Uv-euwpd6sM1EUWtL9Tf3ZH8zRW5G7nHqmYPu--JFdvBtepc4Yj3AtQyDuQW4OuNxQGU-CwDxYezHK2-kzKi4_h3RORGeriMbKMDyBCJwwDjgsD8ssYd5rHrt0MYh1JB9rnDjiPtu1Xlr6Gr5yqnt8fOBFINHJCsAjUjvax8HT0_IW0VRbC5DYwnYFw", + "opencode_url": "http://127.0.0.1:4098", + "session_file": "sessions.json", + "vk_api_version": "5.200", + "longpoll_wait": 25, + "thinking_peer_id": 2000000003, + "llama_server_path": "/home/grishberg/llama.cpp.turbo/build/bin/llama-server", + "llama_server_host": "http://127.0.0.1:8081", + "llama_server_env": { + "TURBOPREFILL": "1" + }, + "opencode_config_path": "/home/grishberg/.config/opencode2/config.json", + "models": { + "qwen3.8-27b": { + "model": "qwen3.8-27b", + "args": "-m /home/grishberg/models/Qwen3.8-27B-UD-Q8_K_XL.gguf --port 8081 --host 0.0.0.0 --ctx-size 200000 -fit off -sm tensor --tensor-split 1,1 -fa on --spec-type draft-mtp --spec-draft-n-max 3 --no-mmap --cache-type-k q8_0 --cache-type-v q8_0 --mmproj /home/grishberg/models/Qwen3.8-27B-mmproj-F16.gguf --no-mmproj-offload --repeat-penalty 1.1 --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0" + }, + "qwen3.6-27b-vision": { + "model": "qwen3.6-27b-vision", + "args": "-m /home/grishberg/models/Qwen3.6-27B-UD-Q8_K_XL.gguf --port 8081 --host 0.0.0.0 --ctx-size 200000 -fit off -sm tensor --tensor-split 1,1 -fa on --spec-type draft-mtp --spec-draft-n-max 3 --no-mmap --cache-type-k q8_0 --cache-type-v q8_0 --mmproj /home/grishberg/models/Qwen3.6-27B-mmproj-F16.gguf --no-mmproj-offload --repeat-penalty 1.1" + } + }, + "default_model": "qwen3.8-27b", + "allowed_folders": [ + "/tmp", + "/home/grishberg/projects" + ], + "reasoning_timeout_seconds": 900, + "server_type": "llama", + "permission": { + "external_directory": { + "/tmp/**": "allow", + "*": "ask" + } + }, + "peer_id": 2000000002, + "opencode_bin_path": "/home/grishberg/projects/ts/opencode/packages/opencode/dist/opencode-linux-x64/bin/opencode", + "opencode-app-name": "opencode2", + "shutdown": "/home/grishberg/shutdown-pc.sh", + "mcp_servers": { + "ya-disk-uploader": { + "type": "local", + "command": [ + "/home/grishberg/projects/go/ya-disk-uploader/ya-disk-uploader", + "mcp" + ], + "enabled": true + }, + "send-files": { + "type": "local", + "command": [ + "/home/grishberg/projects/go/mcp-vk-files/mcp-vk-files", + "mcp" + ], + "enabled": true + } + }, + "voice_auto_transcribe_enabled": true, + "whisper_api_url": "http://127.0.0.1:8013" +} \ No newline at end of file diff --git a/bot/config.py b/bot/config.py index d1a0ffe84adb..a853f9d494f9 100644 --- a/bot/config.py +++ b/bot/config.py @@ -55,6 +55,7 @@ def _resolve_config_path(name: str) -> Path: "models": [], "default_model": "qwen3.5-122b", "allowed_folders": ["/tmp"], + "reasoning_timeout_seconds": 900, } @@ -126,6 +127,7 @@ def load_config(config_path: str = "config.json") -> dict: ALLOWED_FOLDERS = CONFIG.get("allowed_folders", ["/tmp"]) SHUTDOWN_SCRIPT = CONFIG.get("shutdown") RESTART_SCRIPT = CONFIG.get("restart") +REASONING_TIMEOUT_SECONDS = CONFIG.get("reasoning_timeout_seconds", 900) if not VK_TOKEN: raise ValueError("VK_TOKEN is required in config file") @@ -210,6 +212,7 @@ def switch_config(config_name: str) -> bool: current_module.ALLOWED_FOLDERS = new_config.get("allowed_folders", ["/tmp"]) current_module.SHUTDOWN_SCRIPT = new_config.get("shutdown") current_module.RESTART_SCRIPT = new_config.get("restart") + current_module.REASONING_TIMEOUT_SECONDS = new_config.get("reasoning_timeout_seconds", 900) current_module.OPENCODE_BIN = Path(new_config["opencode_bin_path"]) if not current_module.OPENCODE_BIN.is_absolute(): current_module.OPENCODE_BIN = (SCRIPT_DIR / current_module.OPENCODE_BIN).resolve() diff --git a/bot/llama_server.py b/bot/llama_server.py index e6712350b48c..252a376506b9 100644 --- a/bot/llama_server.py +++ b/bot/llama_server.py @@ -99,6 +99,12 @@ async def restart_llama_server( env = os.environ.copy() env.pop("TMUX", None) + # Дополнительные переменные окружения для llama-server из конфига + # (например, {"TURBOPREFILL": "1"} в config-turbo.json) + import config + for key, value in (config.CONFIG.get("llama_server_env") or {}).items(): + env[str(key)] = str(value) + log_path = f"/tmp/llama-server-{alias or 'unknown'}.log" logger.info(f"Starting llama server, logging to {log_path}") @@ -196,7 +202,7 @@ async def test_llama_server_speed(complete_url: str, model_name: str = None) -> async with aiohttp.ClientSession() as session: payload = { "prompt": "Test", - "n_predict": 10, + "n_predict": 100, "stream": False, "temperature": 0.7, } diff --git a/bot/sse_listener.py b/bot/sse_listener.py index 003b4a872a78..804e26ee2823 100644 --- a/bot/sse_listener.py +++ b/bot/sse_listener.py @@ -4,6 +4,7 @@ """ import asyncio import json +from pathlib import Path from typing import Awaitable, Callable, Optional import aiohttp @@ -18,8 +19,9 @@ class SSEEventListener: """Асинхронный SSE клиент для OpenCode events.""" - def __init__(self, base_url: str): + def __init__(self, base_url: str, directory: Optional[str] = None): self.base_url = base_url.rstrip("/") + self.directory = directory self._task: Optional[asyncio.Task] = None self.running = False self.processed_events: set[str] = set() @@ -45,8 +47,14 @@ async def stop(self): except (asyncio.CancelledError, Exception): pass - async def _run(self): + def _build_url(self) -> str: url = f"{self.base_url}/event" + if self.directory: + url += f"?directory={self.directory}" + return url + + async def _run(self): + url = self._build_url() while self.running: try: async with aiohttp.ClientSession(timeout=SSE_TIMEOUT) as session: diff --git a/bot/tests/test_auto_grant.py b/bot/tests/test_auto_grant.py index ee6a65cb7f73..509d34d5d6cb 100644 --- a/bot/tests/test_auto_grant.py +++ b/bot/tests/test_auto_grant.py @@ -12,41 +12,36 @@ class TestAutoGrantPermissionResponse: @pytest.mark.asyncio async def test_auto_grant_uses_always_not_once(self, mock_vk_client, mock_opencode_client, mock_opencode_process, temp_config_file): - """ - BUG: При авто-одобрении используется "once" вместо "always". - "once" означает разовое одобрение, но пользователь всё равно видит запрос. - "always" означает постоянное одобрение. - """ + """При включённом grant_mode авто-одобрение использует "always".""" from vk_longpoll import VKLongPoll from session_manager import SessionManager - + # Создаём SessionManager и VKLongPoll session_mgr = SessionManager(temp_config_file) longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process) longpoll.opencode_client = mock_opencode_client session_id = "test-session-auto-grant" - longpoll.user_session[999] = session_id - longpoll.seen_permissions[session_id] = set() + longpoll.session_to_user[session_id] = 999 # Включаем авто-одобрение через session_mgr longpoll.session_mgr.set_grant_mode(session_id, True) - - # Создаём разрешение - permission = { + + # SSE-событие permission.asked + permission_event = { + "sessionID": session_id, "id": "perm-auto-123", - "session_id": session_id, "permission": "write_file", "metadata": {"filepath": "/tmp/test.txt"} } - + # Вызываем обработку разрешения - await longpoll._process_permission(permission, session_id, 999) + await longpoll._on_permission("permission.asked", permission_event) # Проверяем, что ответ был отправлен mock_opencode_client.send_permission_response.assert_called_once() - # BUG: Проверка, что используется "always" не "once" + # Ответ уходит третьим позиционным аргументом: (session_id, perm_id, response) call_args = mock_opencode_client.send_permission_response.call_args - response_type = call_args[0][2] # Третий аргумент: response + response_type = call_args[0][2] assert response_type == "always", \ f"Авто-одобрение должно использовать 'always', а не '{response_type}'" @@ -62,19 +57,18 @@ async def test_auto_grant_does_not_send_prompt(self, mock_vk_client, mock_openco longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process) longpoll.opencode_client = mock_opencode_client session_id = "test-session-no-prompt" - longpoll.user_session[999] = session_id - longpoll.seen_permissions[session_id] = set() + longpoll.session_to_user[session_id] = 999 # Включаем авто-одобрение через session_mgr longpoll.session_mgr.set_grant_mode(session_id, True) - - permission = { + + permission_event = { + "sessionID": session_id, "id": "perm-no-prompt", - "session_id": session_id, "permission": "write_file", "metadata": {"filepath": "/tmp/test.txt"} } - - await longpoll._process_permission(permission, session_id, 999) + + await longpoll._on_permission("permission.asked", permission_event) # mock_vk_client.send_message не должен вызываться для permission send_message_calls = [ @@ -95,19 +89,18 @@ async def test_normal_mode_sends_permission_prompt(self, mock_vk_client, mock_op longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process) longpoll.opencode_client = mock_opencode_client session_id = "test-session-normal-mode" - longpoll.user_session[999] = session_id - longpoll.seen_permissions[session_id] = set() + longpoll.session_to_user[session_id] = 999 # Выключаем авто-одобрение через session_mgr longpoll.session_mgr.set_grant_mode(session_id, False) - - permission = { + + permission_event = { + "sessionID": session_id, "id": "perm-normal-456", - "session_id": session_id, "permission": "write_file", "metadata": {"filepath": "/tmp/test.txt"} } - - await longpoll._process_permission(permission, session_id, 999) + + await longpoll._on_permission("permission.asked", permission_event) # Должно быть отправлено сообщение пользователю mock_vk_client.send_message.assert_called_once() diff --git a/bot/tests/test_config.py b/bot/tests/test_config.py index 99dacebc6f16..546d3704772f 100644 --- a/bot/tests/test_config.py +++ b/bot/tests/test_config.py @@ -6,7 +6,7 @@ ROOT_DIR = Path(__file__).parent.parent sys.path.insert(0, str(ROOT_DIR)) -from opencode_vk_gateway import load_config +from config import load_config class TestLoadConfig: @@ -60,7 +60,7 @@ def test_load_config_file_not_found(self, tmp_path): config = load_config(str(config_file)) assert config["vk_token"] == "token" - assert config["opencode_url"] == "http://127.0.0.1:4096" + assert config["opencode_url"] == "http://127.0.0.1:4098" assert config["session_file"] == "sessions.json" def test_load_config_invalid_json(self, tmp_path): diff --git a/bot/tests/test_message_parser.py b/bot/tests/test_message_parser.py index 08a151b27031..a60ef6d33fe6 100644 --- a/bot/tests/test_message_parser.py +++ b/bot/tests/test_message_parser.py @@ -1,106 +1,102 @@ -"""Тесты парсера сообщений OpenCode.""" - -import json -import os -import subprocess -import sys -import unittest - -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") -MESSAGES_FILE = os.path.join(FIXTURES_DIR, "session_messages.json") - - -def download_messages(limit: int = 20) -> list: - """Скачать сообщения из API и сохранить в файл.""" - result = subprocess.run( - ["curl", "-s", f"http://localhost:4096/session/ses_1e7b46739ffeDV1M7q9QrxZF8j/message?limit={limit}"], - capture_output=True, - text=True - ) - if result.returncode != 0: - raise Exception(f"Failed to get messages: {result.stderr}") - messages = json.loads(result.stdout) - - os.makedirs(FIXTURES_DIR, exist_ok=True) - with open(MESSAGES_FILE, "w", encoding="utf-8") as f: - json.dump(messages, f, ensure_ascii=False, indent=2) - print(f"Saved {len(messages)} messages to {MESSAGES_FILE}") - return messages - - -def load_messages() -> list: - """Загрузить сообщения из файла.""" - if not os.path.exists(MESSAGES_FILE): - raise FileNotFoundError( - f"Fixtures file not found: {MESSAGES_FILE}\n" - f"Run 'python test_message_parser.py --download' to fetch messages." - ) - with open(MESSAGES_FILE, encoding="utf-8") as f: - return json.load(f) - - -class TestMessageParser(unittest.TestCase): - def setUp(self): - self.messages = load_messages() - from message_parser import parse_session_messages, get_new_parts, ParsedSession - self.parse_session_messages = parse_session_messages - self.get_new_parts = get_new_parts - self.ParsedSession = ParsedSession - - def test_parse_all_messages(self): - """Тест: парсер корректно обрабатывает все сообщения из файла.""" - parsed = self.parse_session_messages(self.messages) - - self.assertIsInstance(parsed, self.ParsedSession) - self.assertGreater(len(parsed.assistant_texts), 0, "Should have assistant texts") - self.assertGreater(len(parsed.user_messages), 0, "Should have user messages") - - print(f"✓ Found {len(parsed.assistant_texts)} assistant texts") - print(f"✓ Found {len(parsed.assistant_reasonings)} reasonings") - print(f"✓ Found {len(parsed.user_messages)} user messages") - - def test_get_new_parts_empty_seen(self): - """Тест: get_new_parts с пустым seen_part_ids.""" - new_texts, new_reasonings = self.get_new_parts(self.messages, set()) - - self.assertGreater(len(new_texts), 0, "Should find new texts with empty seen set") - print(f"✓ With empty seen set: {len(new_texts)} texts, {len(new_reasonings)} reasonings") - - def test_get_new_parts_all_seen(self): - """Тест: get_new_parts - все part_ids уже в seen.""" - all_part_ids = set() - for msg in self.messages: - for part in msg.get("parts", []): - pid = part.get("id", "") - if pid: - all_part_ids.add(pid) - - new_texts, new_reasonings = self.get_new_parts(self.messages, all_part_ids) - - self.assertEqual(len(new_texts), 0, "Should find no new texts when all seen") - self.assertEqual(len(new_reasonings), 0, "Should find no new reasonings when all seen") - print(f"✓ With all seen: {len(new_texts)} texts, {len(new_reasonings)} reasonings") - - def test_last_assistant_text(self): - """Тест: последний ответ assistant есть в результатах.""" - parsed = self.parse_session_messages(self.messages) - assistant_texts = parsed.assistant_texts - if assistant_texts: - last_text = assistant_texts[-1] - self.assertIsInstance(last_text, str) - self.assertGreater(len(last_text), 0) - print(f"✓ Last assistant text: {last_text[:50]}...") - - def test_fixtures_exist(self): - """Тест: fixtures файл существует и содержит данные.""" - self.assertTrue(os.path.exists(MESSAGES_FILE), f"Fixtures file not found: {MESSAGES_FILE}") - self.assertGreater(len(self.messages), 0, "Messages file is empty") - - -if __name__ == "__main__": - if "--download" in sys.argv: - download_messages() - sys.exit(0) - unittest.main(verbosity=2) +"""Юнит-тесты парсера сообщений OpenCode (формат v2 API).""" + +import pytest + +from message_parser import Part, ParsedSession, get_new_parts, parse_session_messages + + +# Синтетическое сообщение пользователя (v2: type/text) +USER_MSG_1 = {"type": "user", "text": "Привет!"} +USER_MSG_2 = {"type": "user", "text": "Что-нибудь сделай"} + +ASSISTANT_MSG_1 = { + "type": "assistant", + "id": "msg-1", + "content": [ + {"id": "part-1a", "type": "reasoning", "text": "Думаю над вопросом..."}, + {"id": "part-1b", "type": "text", "text": "Привет! Чем помочь?"}, + ], +} + +ASSISTANT_MSG_2 = { + "type": "assistant", + "id": "msg-2", + "content": [ + {"id": "part-2a", "type": "tool", "tool": "bash", "state": {"status": "completed"}}, + {"id": "part-2b", "type": "text", "text": "Готово."}, + ], +} + +MESSAGES = [USER_MSG_1, ASSISTANT_MSG_1, USER_MSG_2, ASSISTANT_MSG_2] + + +class TestParseSessionMessages: + def test_returns_parsed_session(self): + parsed = parse_session_messages(MESSAGES) + assert isinstance(parsed, ParsedSession) + + def test_parses_user_messages(self): + parsed = parse_session_messages(MESSAGES) + assert parsed.user_messages == ["Привет!", "Что-нибудь сделай"] + + def test_parses_assistant_texts_and_skips_other_parts(self): + parsed = parse_session_messages(MESSAGES) + assert parsed.assistant_texts == ["Привет! Чем помочь?", "Готово."] + + def test_parses_reasonings(self): + parsed = parse_session_messages(MESSAGES) + assert parsed.assistant_reasonings == ["Думаю над вопросом..."] + + def test_empty_input(self): + parsed = parse_session_messages([]) + assert parsed.assistant_texts == [] + assert parsed.assistant_reasonings == [] + assert parsed.user_messages == [] + + +class TestGetNewParts: + def test_empty_seen_returns_all_parts(self): + parts = get_new_parts(MESSAGES, set()) + assert [p.id for p in parts] == [ + "msg-1:part-1a", + "msg-1:part-1b", + "msg-2:part-2a", + "msg-2:part-2b", + ] + + def test_part_carries_type_and_text(self): + parts = get_new_parts(MESSAGES, set()) + by_id = {p.id: p for p in parts} + + assert by_id["msg-1:part-1b"].type == "text" + assert by_id["msg-1:part-1b"].text == "Привет! Чем помочь?" + + assert by_id["msg-1:part-1a"].type == "reasoning" + assert by_id["msg-1:part-1a"].text == "Думаю над вопросом..." + + def test_tool_part_text_is_composed(self): + parts = get_new_parts(MESSAGES, set()) + tool_part = next(p for p in parts if p.type == "tool") + assert tool_part.text == "bash - completed" + + def test_seen_unique_ids_are_filtered(self): + seen = {"msg-1:part-1b", "msg-2:part-2b"} + parts = get_new_parts(MESSAGES, seen) + assert [p.id for p in parts] == ["msg-1:part-1a", "msg-2:part-2a"] + + def test_whole_message_can_be_marked_seen(self): + # Если в seen лежит id всего сообщения, все его части пропускаются + seen = {"msg-1"} + parts = get_new_parts(MESSAGES, seen) + assert [p.id for p in parts] == ["msg-2:part-2a", "msg-2:part-2b"] + + def test_all_seen_returns_nothing(self): + seen = { + "msg-1:part-1a", "msg-1:part-1b", + "msg-2:part-2a", "msg-2:part-2b", + } + assert get_new_parts(MESSAGES, seen) == [] + + def test_user_messages_are_ignored(self): + parts = get_new_parts([USER_MSG_1, USER_MSG_2], set()) + assert parts == [] diff --git a/bot/tests/test_nvidia.py b/bot/tests/test_nvidia.py index 8b9cd71e5688..4d31eee6bb56 100644 --- a/bot/tests/test_nvidia.py +++ b/bot/tests/test_nvidia.py @@ -1,15 +1,15 @@ """ -Tests for shared.nvidia module. +Tests for nvidia module. """ import asyncio import subprocess -import unittest from unittest.mock import AsyncMock, patch -from typing import AsyncGenerator -from shared.nvidia import ( - NvidiaInfo, +import pytest + +from nvidia import ( GPUInfo, + NvidiaInfo, run_nvidia_smi, parse_nvidia_smi, format_for_vk, @@ -17,11 +17,6 @@ ) -async def _async_return(value): - """Helper to return value in async context.""" - return value - - # Sample nvidia-smi output for testing SAMPLE_NVIDIA_OUTPUT = """Fri May 8 12:36:23 2026 +-----------------------------------------------------------------------------------------+ @@ -59,80 +54,50 @@ async def _async_return(value): """.encode("utf-8") -class TestRunNvidiaSmi(unittest.TestCase): +class TestRunNvidiaSmi: """Tests for run_nvidia_smi function.""" - @patch('asyncio.create_subprocess_exec') - def test_run_nvidia_smi_success(self, mock_create): + @pytest.mark.asyncio + async def test_run_nvidia_smi_success(self): """Test successful nvidia-smi execution.""" - # Setup mock - async def mock_coro(): - mock_process = AsyncMock() - mock_process.communicate = AsyncMock(return_value=(SAMPLE_NVIDIA_OUTPUT, b"")) - mock_process.returncode = 0 - return mock_process - - mock_create.return_value = asyncio.create_task(mock_coro()) - - # Test - loop = asyncio.new_event_loop() - try: - result = loop.run_until_complete(run_nvidia_smi()) - - # Verify - self.assertEqual(result, SAMPLE_NVIDIA_OUTPUT) - finally: - loop.close() - - def test_run_nvidia_smi_not_found(self): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(SAMPLE_NVIDIA_OUTPUT, b"")) + mock_process.returncode = 0 + + with patch("nvidia.asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)): + result = await run_nvidia_smi() + + assert result == SAMPLE_NVIDIA_OUTPUT + + @pytest.mark.asyncio + async def test_run_nvidia_smi_not_found(self): """Test nvidia-smi not found error.""" - loop = asyncio.new_event_loop() - try: - async def mock_coro(): - raise FileNotFoundError("nvidia-smi not found") - - with patch('asyncio.create_subprocess_exec') as mock_create: - mock_create.return_value = asyncio.create_task(mock_coro()) - try: - loop.run_until_complete(run_nvidia_smi()) - self.fail("Expected FileNotFoundError") - except FileNotFoundError as e: - self.assertIn("nvidia-smi not found", str(e)) - finally: - loop.close() - - def test_run_nvidia_smi_timeout(self): + with patch("nvidia.asyncio.create_subprocess_exec", AsyncMock(side_effect=FileNotFoundError("nvidia-smi not found"))): + with pytest.raises(FileNotFoundError, match="nvidia-smi not found"): + await run_nvidia_smi() + + @pytest.mark.asyncio + async def test_run_nvidia_smi_timeout(self): """Test nvidia-smi timeout error.""" - loop = asyncio.new_event_loop() - try: - async def mock_coro(): - mock_process = AsyncMock() - mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError()) - return mock_process - - with patch('asyncio.create_subprocess_exec') as mock_create: - mock_create.return_value = asyncio.create_task(mock_coro()) - - try: - loop.run_until_complete(run_nvidia_smi(timeout=1)) - self.fail("Expected TimeoutError") - except asyncio.TimeoutError: - pass - finally: - loop.close() - - -class TestParseNvidiaSmi(unittest.TestCase): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError()) + + with patch("nvidia.asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)): + with pytest.raises(asyncio.TimeoutError): + await run_nvidia_smi(timeout=1) + + +class TestParseNvidiaSmi: """Tests for parse_nvidia_smi function.""" def test_parse_sample_output(self): """Test parsing sample nvidia-smi output.""" info = parse_nvidia_smi(SAMPLE_NVIDIA_OUTPUT) - + # Verify basic info - self.assertEqual(info.gpu_count, 3) - self.assertEqual(info.driver_version, "595.58.03") - self.assertEqual(info.cuda_version, "13.2") + assert info.gpu_count == 3 + assert info.driver_version == "595.58.03" + assert info.cuda_version == "13.2" def test_parse_single_gpu(self): """Test parsing output with single GPU.""" @@ -150,32 +115,31 @@ def test_parse_single_gpu(self): | Processes: | +-----------------------------------------------------------------------------------------+ """.encode("utf-8") - + info = parse_nvidia_smi(single_gpu_output) - - self.assertEqual(info.gpu_count, 1) + + assert info.gpu_count == 1 gpu = info.gpus[0] - self.assertEqual(gpu.id, 0) - self.assertIn("RTX 4090", gpu.name) - self.assertEqual(gpu.fan_speed, 50) - self.assertEqual(gpu.temperature, 70) - self.assertEqual(gpu.perf_state, "P2") - self.assertEqual(gpu.power_usage, 150) - self.assertEqual(gpu.power_cap, 450) - self.assertEqual(gpu.memory_used, 10000) - self.assertEqual(gpu.memory_total, 24576) - self.assertEqual(gpu.gpu_utilization, 15) + assert gpu.id == 0 + assert "RTX 4090" in gpu.name + assert gpu.temperature == 70 + assert gpu.perf_state == "P2" + assert gpu.power_usage == 150 + assert gpu.power_cap == 450 + assert gpu.memory_used == 10000 + assert gpu.memory_total == 24576 + assert gpu.utilization == 15 def test_parse_empty_output(self): """Test parsing empty output.""" info = parse_nvidia_smi(b"") - - self.assertEqual(info.gpu_count, 0) - self.assertEqual(info.driver_version, "unknown") - self.assertEqual(info.cuda_version, "unknown") + + assert info.gpu_count == 0 + assert info.driver_version == "unknown" + assert info.cuda_version == "unknown" -class TestFormatForVk(unittest.TestCase): +class TestFormatForVk: """Tests for format_for_vk function.""" def test_format_single_gpu(self): @@ -183,34 +147,32 @@ def test_format_single_gpu(self): gpu = GPUInfo( id=0, name="NVIDIA GeForce RTX 3090", - fan_speed=80, + utilization=45, temperature=75, perf_state="P2", power_usage=200, power_cap=350, memory_used=15000, memory_total=24576, - gpu_utilization=45, - compute_mode="Default" ) info = NvidiaInfo( + smi_version="595.58.03", driver_version="595.58.03", cuda_version="13.2", - gpus=[gpu] + gpus=[gpu], ) - + message = format_for_vk(info) - + # Verify message contains key information - self.assertIn("NVIDIA", message) - self.assertIn("3090", message) - self.assertIn("75°C", message) - self.assertIn("80%", message) - self.assertIn("200W", message) - self.assertIn("350W", message) - self.assertIn("15000", message) - self.assertIn("24576", message) - self.assertIn("45%", message) + assert "NVIDIA" in message + assert "3090" in message + assert "75C" in message + assert "200W" in message + assert "350W" in message + assert "15000" in message + assert "24576" in message + assert "45%" in message def test_format_multiple_gpus(self): """Test formatting multiple GPU info.""" @@ -218,79 +180,67 @@ def test_format_multiple_gpus(self): GPUInfo( id=i, name=f"NVIDIA GeForce RTX 3090 #{i}", - fan_speed=70 + i * 5, + utilization=30 + i * 5, temperature=70 + i * 2, perf_state="P2", power_usage=200 + i * 10, power_cap=350, memory_used=10000 + i * 2000, memory_total=24576, - gpu_utilization=30 + i * 5, - compute_mode="Default" ) for i in range(3) ] info = NvidiaInfo( + smi_version="595.58.03", driver_version="595.58.03", cuda_version="13.2", - gpus=gpus + gpus=gpus, ) - + message = format_for_vk(info) - + # Verify all GPUs are mentioned - self.assertIn("GPU 0:", message) - self.assertIn("GPU 1:", message) - self.assertIn("GPU 2:", message) + assert "GPU 0:" in message + assert "GPU 1:" in message + assert "GPU 2:" in message -class TestGetGpuInfoVkMessage(unittest.TestCase): +class TestGetGpuInfoVkMessage: """Tests for get_gpu_info_vk_message function.""" - def test_get_gpu_info_vk_message_success(self): + @pytest.mark.asyncio + async def test_get_gpu_info_vk_message_success(self): """Test successful GPU info retrieval.""" - loop = asyncio.new_event_loop() - try: - with patch('shared.nvidia.run_nvidia_smi') as mock_run: - mock_run.return_value = asyncio.create_task(_async_return(SAMPLE_NVIDIA_OUTPUT)) - message, error = loop.run_until_complete(get_gpu_info_vk_message()) - - self.assertIsNotNone(message) - self.assertIsNone(error) - self.assertIn("NVIDIA", message) - finally: - loop.close() - - def test_get_gpu_info_vk_message_not_found(self): + with patch("nvidia.run_nvidia_smi", AsyncMock(return_value=SAMPLE_NVIDIA_OUTPUT)), \ + patch("nvidia.run_gputemps", AsyncMock(side_effect=FileNotFoundError())): + message, error = await get_gpu_info_vk_message() + + assert message is not None + assert error is None + assert "NVIDIA" in message + + @pytest.mark.asyncio + async def test_get_gpu_info_vk_message_not_found(self): """Test GPU info retrieval when nvidia-smi not found.""" - loop = asyncio.new_event_loop() - try: - with patch('shared.nvidia.run_nvidia_smi') as mock_run: - mock_run.side_effect = FileNotFoundError("nvidia-smi not found") - message, error = loop.run_until_complete(get_gpu_info_vk_message()) - - self.assertIsNone(message) - self.assertIsNotNone(error) - self.assertIn("not found", error.lower()) - finally: - loop.close() - - def test_get_gpu_info_vk_message_timeout(self): + with patch("nvidia.run_nvidia_smi", AsyncMock(side_effect=FileNotFoundError("nvidia-smi not found"))): + message, error = await get_gpu_info_vk_message() + + assert message is None + assert error is not None + assert "not found" in error.lower() + + @pytest.mark.asyncio + async def test_get_gpu_info_vk_message_timeout(self): """Test GPU info retrieval on timeout.""" - loop = asyncio.new_event_loop() - try: - with patch('shared.nvidia.run_nvidia_smi') as mock_run: - mock_run.side_effect = asyncio.TimeoutError() - message, error = loop.run_until_complete(get_gpu_info_vk_message()) - - self.assertIsNone(message) - self.assertIsNotNone(error) - self.assertIn("timed out", error.lower()) - finally: - loop.close() - - -class TestGpuInfoProperties(unittest.TestCase): + with patch("nvidia.run_nvidia_smi", AsyncMock(side_effect=asyncio.TimeoutError())): + message, error = await get_gpu_info_vk_message() + + assert message is None + assert error is not None + assert "timed out" in error.lower() + + +class TestGpuInfoProperties: """Tests for GPUInfo dataclass properties.""" def test_memory_percent(self): @@ -298,37 +248,29 @@ def test_memory_percent(self): gpu = GPUInfo( id=0, name="Test GPU", - fan_speed=0, + utilization=0, temperature=0, perf_state="", power_usage=0, power_cap=0, memory_used=12288, memory_total=24576, - gpu_utilization=0, - compute_mode="" ) - - self.assertEqual(gpu.memory_percent, 50) - def test_power_percent(self): - """Test power usage percentage calculation.""" + assert gpu.memory_percent == 50 + + def test_memory_percent_zero_total(self): + """Test memory percent does not divide by zero.""" gpu = GPUInfo( id=0, name="Test GPU", - fan_speed=0, + utilization=0, temperature=0, perf_state="", - power_usage=175, - power_cap=350, + power_usage=0, + power_cap=0, memory_used=0, memory_total=0, - gpu_utilization=0, - compute_mode="" ) - - self.assertEqual(gpu.power_percent, 50) - -if __name__ == '__main__': - unittest.main() + assert gpu.memory_percent == 0 diff --git a/bot/tests/test_restarter.py b/bot/tests/test_restarter.py index 28f246047000..b95292476bf2 100644 --- a/bot/tests/test_restarter.py +++ b/bot/tests/test_restarter.py @@ -9,14 +9,25 @@ ROOT_DIR = Path(__file__).parent.parent sys.path.insert(0, str(ROOT_DIR)) +# gateway-restarter.py содержит дефис (так назван файл в systemd-юните), +# обычный import недоступен - грузим через importlib +import importlib.util + +_spec = importlib.util.spec_from_file_location( + "gateway_restarter", ROOT_DIR / "gateway-restarter.py" +) +gateway_restarter = importlib.util.module_from_spec(_spec) +sys.modules["gateway_restarter"] = gateway_restarter +_spec.loader.exec_module(gateway_restarter) + from gateway_restarter import ( - load_vk_token, get_gateway_pid, save_gateway_pid, + load_config, get_gateway_pid, save_gateway_pid, remove_pid_file, is_process_running, restart_gateway ) -class TestLoadVkToken: - def test_load_vk_token_success(self, tmp_path): +class TestLoadConfig: + def test_load_config_success(self, tmp_path): config_data = {"vk_token": "test_vk_token"} config_file = tmp_path / "config.json" config_file.write_text(json.dumps(config_data)) @@ -26,12 +37,12 @@ def test_load_vk_token_success(self, tmp_path): gateway_restarter.SCRIPT_DIR = tmp_path try: - token = load_vk_token() + token, _, _ = load_config() assert token == "test_vk_token" finally: gateway_restarter.SCRIPT_DIR = original_dir - def test_load_vk_token_missing_file(self, tmp_path): + def test_load_config_missing_file(self, tmp_path): config_file = tmp_path / "config.json" import gateway_restarter @@ -40,11 +51,11 @@ def test_load_vk_token_missing_file(self, tmp_path): try: with pytest.raises(FileNotFoundError): - load_vk_token() + load_config() finally: gateway_restarter.SCRIPT_DIR = original_dir - def test_load_vk_token_empty_token(self, tmp_path): + def test_load_config_empty_token(self, tmp_path): config_data = {"vk_token": ""} config_file = tmp_path / "config.json" config_file.write_text(json.dumps(config_data)) @@ -55,7 +66,7 @@ def test_load_vk_token_empty_token(self, tmp_path): try: with pytest.raises(ValueError, match="vk_token is empty"): - load_vk_token() + load_config() finally: gateway_restarter.SCRIPT_DIR = original_dir diff --git a/bot/tests/test_session.py b/bot/tests/test_session.py index 7a8f74b0aa65..d3bc5fae8e09 100644 --- a/bot/tests/test_session.py +++ b/bot/tests/test_session.py @@ -3,18 +3,20 @@ import sys from pathlib import Path -ROOT_DIR = Path(__file__).parent.parent.parent +ROOT_DIR = Path(__file__).parent.parent sys.path.insert(0, str(ROOT_DIR)) -from opencode_vk_gateway import SessionManager +from session_manager import SessionManager class TestSessionManager: @pytest.fixture def sessions_file(self, tmp_path): sessions_data = { - "12345": "session-abc-123", - "67890": "session-def-456", + "sessions": { + "12345": "session-abc-123", + "67890": "session-def-456", + } } file_path = tmp_path / "sessions.json" file_path.write_text(json.dumps(sessions_data)) @@ -47,7 +49,7 @@ def test_save_sessions(self, tmp_path): mgr._save() loaded = json.loads(file_path.read_text()) - assert loaded == {"12345": "session-test", "67890": "session-test-2"} + assert loaded["sessions"] == {"12345": "session-test", "67890": "session-test-2"} @pytest.mark.asyncio async def test_get_or_create_existing_session(self, sessions_file): diff --git a/bot/tests/test_sse_listener.py b/bot/tests/test_sse_listener.py new file mode 100644 index 000000000000..ee5550f31859 --- /dev/null +++ b/bot/tests/test_sse_listener.py @@ -0,0 +1,23 @@ +""" +Тесты для SSE listener +""" +import pytest +from sse_listener import SSEEventListener + + +class TestSSEEventListener: + def test_build_url_without_directory(self): + listener = SSEEventListener("http://localhost:4098") + assert listener._build_url() == "http://localhost:4098/event" + + def test_build_url_with_directory(self): + listener = SSEEventListener("http://localhost:4098", directory="/home/user/project") + assert listener._build_url() == "http://localhost:4098/event?directory=/home/user/project" + + def test_build_url_strips_trailing_slash(self): + listener = SSEEventListener("http://localhost:4098/") + assert listener._build_url() == "http://localhost:4098/event" + + def test_build_url_with_directory_and_trailing_slash(self): + listener = SSEEventListener("http://localhost:4098/", directory="/home/user/project") + assert listener._build_url() == "http://localhost:4098/event?directory=/home/user/project" diff --git a/bot/tests/test_timeout_tracker.py b/bot/tests/test_timeout_tracker.py new file mode 100644 index 000000000000..3d5ee7aeb982 --- /dev/null +++ b/bot/tests/test_timeout_tracker.py @@ -0,0 +1,128 @@ +""" +Тесты для timeout_tracker — таймер бездействия модели +""" +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock +from timeout_tracker import TimeoutTracker + + +class TestTimeoutTrackerInit: + def test_default_timeout(self): + tracker = TimeoutTracker(timeout_seconds=900) + assert tracker.timeout_seconds == 900 + + def test_custom_timeout(self): + tracker = TimeoutTracker(timeout_seconds=60) + assert tracker.timeout_seconds == 60 + + +class TestOnReasoning: + @pytest.mark.asyncio + async def test_tracks_session_on_reasoning(self): + tracker = TimeoutTracker(timeout_seconds=5) + await tracker.on_reasoning("session-1") + assert "session-1" in tracker.sessions + assert tracker._current_session == "session-1" + + @pytest.mark.asyncio + async def test_restarts_timer_on_subsequent_reasoning(self): + kick_fn = AsyncMock() + tracker = TimeoutTracker(timeout_seconds=0.05, kick_callback=kick_fn) + await tracker.on_reasoning("session-1") + await asyncio.sleep(0.02) + await tracker.on_reasoning("session-1") + await asyncio.sleep(0.08) + kick_fn.assert_called_once_with("session-1") + + @pytest.mark.asyncio + async def test_updates_timestamp_on_reasoning(self): + tracker = TimeoutTracker(timeout_seconds=5) + loop = asyncio.get_event_loop() + await tracker.on_reasoning("session-1") + t1 = tracker.sessions["session-1"] + await asyncio.sleep(0.05) + await tracker.on_reasoning("session-1") + t2 = tracker.sessions["session-1"] + assert t2 > t1 + + +class TestOnTextResponse: + @pytest.mark.asyncio + async def test_clears_session_on_text(self): + tracker = TimeoutTracker(timeout_seconds=5) + await tracker.on_reasoning("session-1") + await tracker.on_text_response("session-1") + assert "session-1" not in tracker.sessions + assert tracker._current_session is None + + @pytest.mark.asyncio + async def test_noop_when_no_active_session(self): + tracker = TimeoutTracker(timeout_seconds=5) + await tracker.on_text_response("nonexistent") + assert not tracker.sessions + + @pytest.mark.asyncio + async def test_cancels_pending_timeout(self): + kick_fn = AsyncMock() + tracker = TimeoutTracker(timeout_seconds=0.05, kick_callback=kick_fn) + await tracker.on_reasoning("session-1") + await tracker.on_text_response("session-1") + await asyncio.sleep(0.15) + kick_fn.assert_not_called() + + +class TestTimerExpiration: + @pytest.mark.asyncio + async def test_calls_kick_callback_on_timeout(self): + kick_fn = AsyncMock() + tracker = TimeoutTracker(timeout_seconds=0.05, kick_callback=kick_fn) + await tracker.on_reasoning("session-1") + await asyncio.sleep(0.15) + kick_fn.assert_called_once_with("session-1") + + @pytest.mark.asyncio + async def test_does_not_kick_if_text_came_before_timeout(self): + kick_fn = AsyncMock() + tracker = TimeoutTracker(timeout_seconds=0.1, kick_callback=kick_fn) + await tracker.on_reasoning("session-1") + await tracker.on_text_response("session-1") + await asyncio.sleep(0.2) + kick_fn.assert_not_called() + + @pytest.mark.asyncio + async def test_no_crash_without_callback(self): + tracker = TimeoutTracker(timeout_seconds=0.02) + await tracker.on_reasoning("session-1") + await asyncio.sleep(0.1) + + +class TestMultipleSessions: + @pytest.mark.asyncio + async def test_tracks_multiple_sessions(self): + tracker = TimeoutTracker(timeout_seconds=5) + await tracker.on_reasoning("session-1") + await tracker.on_reasoning("session-2") + assert "session-1" in tracker.sessions + assert "session-2" in tracker.sessions + + @pytest.mark.asyncio + async def test_text_for_one_session_doesnt_affect_other(self): + tracker = TimeoutTracker(timeout_seconds=5) + await tracker.on_reasoning("session-1") + await tracker.on_reasoning("session-2") + await tracker.on_text_response("session-1") + assert "session-1" not in tracker.sessions + assert "session-2" in tracker.sessions + + @pytest.mark.asyncio + async def test_last_reasoning_wins_single_timer(self): + kick_fn = AsyncMock() + tracker = TimeoutTracker(timeout_seconds=0.1, kick_callback=kick_fn) + await tracker.on_reasoning("session-1") + await asyncio.sleep(0.05) + await tracker.on_reasoning("session-2") + await asyncio.sleep(0.08) + await asyncio.sleep(0.08) + assert kick_fn.call_count == 1 + kick_fn.assert_called_with("session-2") diff --git a/bot/tests/test_vk_client.py b/bot/tests/test_vk_client.py index 514928864d25..44992adc3899 100644 --- a/bot/tests/test_vk_client.py +++ b/bot/tests/test_vk_client.py @@ -7,13 +7,13 @@ ROOT_DIR = Path(__file__).parent.parent sys.path.insert(0, str(ROOT_DIR)) -import opencode_vk_gateway as gateway +from vk_client import VKClient class TestVKClient: @pytest.fixture def vk_client(self): - return gateway.VKClient("test_token") + return VKClient("test_token") @pytest.mark.asyncio async def test_enter_exit(self, vk_client): @@ -98,45 +98,43 @@ async def test_get_messages_by_ids(self, vk_client): @pytest.mark.asyncio async def test_send_message_text(self, vk_client): - mock_response = [{"message_id": 12345}] - - with patch.object(vk_client, "_api_request", new_callable=AsyncMock) as mock_api: - mock_api.return_value = mock_response - + with patch.object(vk_client, "_send_single_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 12345 + result = await vk_client.send_message(peer_id=12345, text="Test message") - + assert result == 12345 + mock_send.assert_awaited_once_with(12345, "Test message", "", None) @pytest.mark.asyncio async def test_send_message_with_attachment(self, vk_client): - mock_response = [{"message_id": 12345}] - - with patch.object(vk_client, "_api_request", new_callable=AsyncMock) as mock_api: - mock_api.return_value = mock_response - + with patch.object(vk_client, "_send_single_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 12345 + result = await vk_client.send_message( peer_id=12345, text="Test", attachment="doc123_456" ) - + assert result == 12345 + mock_send.assert_awaited_once_with(12345, "Test", "doc123_456", None) @pytest.mark.asyncio async def test_send_message_with_keyboard(self, vk_client): - mock_response = [{"message_id": 12345}] keyboard = { "inline": True, "buttons": [[{"action": {"type": "text", "label": "OK"}, "color": "primary"}]] } - - with patch.object(vk_client, "_api_request", new_callable=AsyncMock) as mock_api: - mock_api.return_value = mock_response - + + with patch.object(vk_client, "_send_single_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 12345 + result = await vk_client.send_message( peer_id=12345, text="Test", keyboard=keyboard ) - - assert result == 12345 \ No newline at end of file + + assert result == 12345 + mock_send.assert_awaited_once_with(12345, "Test", "", keyboard) \ No newline at end of file diff --git a/bot/timeout_tracker.py b/bot/timeout_tracker.py new file mode 100644 index 000000000000..0c373ebf9a76 --- /dev/null +++ b/bot/timeout_tracker.py @@ -0,0 +1,58 @@ +""" +Таймер бездействия модели. + +Отслеживает события reasoning и text ответа. Когда приходит reasoning — +взводит таймер. При следующем reasoning перезапускает. При text ответе — +отменяет. Когда таймер сработал — вызывает kick_callback(session_id). +""" +import asyncio +from typing import Awaitable, Callable, Optional + + +class TimeoutTracker: + """Следит за таймаутом reasoning модели.""" + + def __init__( + self, + timeout_seconds: float = 900, + kick_callback: Optional[Callable[[str], Awaitable[None]]] = None, + ): + self.timeout_seconds = timeout_seconds + self.kick_callback = kick_callback + self.sessions: dict[str, float] = {} + self._timer: Optional[asyncio.TimerHandle] = None + self._current_session: Optional[str] = None + + async def on_reasoning(self, session_id: str) -> None: + """Вызывается при получении reasoning события.""" + self.sessions[session_id] = asyncio.get_event_loop().time() + self._current_session = session_id + self._start_timer() + + async def on_text_response(self, session_id: str) -> None: + """Вызывается при получении text ответа.""" + self._cancel_timer() + self.sessions.pop(session_id, None) + if self._current_session == session_id: + self._current_session = None + + def _start_timer(self) -> None: + """Запускает или перезапускает таймер.""" + self._cancel_timer() + loop = asyncio.get_event_loop() + self._timer = loop.call_later(self.timeout_seconds, self._on_timeout) + + def _cancel_timer(self) -> None: + """Отменяет активный таймер.""" + if self._timer: + self._timer.cancel() + self._timer = None + + def _on_timeout(self) -> None: + """Коллбэк при срабатывании таймера.""" + session_id = self._current_session + if not session_id or session_id not in self.sessions: + return + self._current_session = None + if self.kick_callback: + asyncio.ensure_future(self.kick_callback(session_id)) diff --git a/bot/vk_longpoll.py b/bot/vk_longpoll.py index b3bd38fc2df6..0975ddf60223 100644 --- a/bot/vk_longpoll.py +++ b/bot/vk_longpoll.py @@ -21,6 +21,7 @@ LLAMA_SERVER_HOST, LONGPOLL_WAIT, OPENCODE_URL, + REASONING_TIMEOUT_SECONDS, RESTART_SCRIPT, SCRIPT_DIR, SHUTDOWN_SCRIPT, @@ -37,6 +38,7 @@ from opencode_process import OpenCodeProcess from session_manager import SessionManager from sse_listener import SSEEventListener as SSEListener +from timeout_tracker import TimeoutTracker from vk_client import VKClient @@ -117,6 +119,12 @@ def __init__( # SSE слушатель событий OpenCode self.sse_listener: Optional[SSEListener] = None + # Таймер бездействия модели + self.timeout_tracker = TimeoutTracker( + timeout_seconds=REASONING_TIMEOUT_SECONDS, + kick_callback=self._on_reasoning_timeout, + ) + # session_id -> user_id для маршрутизации SSE событий self.session_to_user: Dict[str, int] = {} # session_id -> user_id @@ -134,6 +142,18 @@ def __init__( self.seen_permissions: Dict[str, set] = {} self.seen_questions: Dict[str, set] = {} + # ---------- Reasoning timeout kick ---------- + + async def _on_reasoning_timeout(self, session_id: str) -> None: + """Вызывается когда таймер reasoning сработал — отправляет пинг модели.""" + user_id = self.session_to_user.get(session_id) + if not user_id: + return + logger.warning(f"Reasoning timeout for session {session_id}, user {user_id}. Sending kick.") + target = THINKING_PEER_ID if THINKING_PEER_ID else user_id + await self.vk.send_message(target, "⏰ Модель молчит слишком долго...") + await self.opencode_client.send_prompt(session_id, "На чем остановился?") + # ---------- SSE callbacks ---------- def _register_sse_callbacks(self): @@ -165,6 +185,7 @@ async def _on_text_ended(self, event_type: str, data: dict): text = data.get("text", "") if not text.strip(): return + await self.timeout_tracker.on_text_response(session_id) self._pending_texts.setdefault(session_id, []).append(text) async def _on_reasoning_ended(self, event_type: str, data: dict): @@ -176,6 +197,7 @@ async def _on_reasoning_ended(self, event_type: str, data: dict): text = data.get("text", "") if not text.strip(): return + await self.timeout_tracker.on_reasoning(session_id) target = THINKING_PEER_ID if THINKING_PEER_ID else user_id await self.vk.send_message(target, f"🧠:\n{text}") @@ -910,6 +932,9 @@ async def _handle_restart_command(self, user_id: int, text: str): if saved_workdir: self.opencode_process.workdir = saved_workdir + # Перезапускаем SSE слушатель с актуальным workdir + await self._restart_sse_listener() + # Пересоздаём aiohttp сессию после рестарта opencode await self.opencode_client.__aexit__(None, None, None) await self.opencode_client.__aenter__() @@ -1109,6 +1134,9 @@ async def _new_session(self, user_id: int, workdir: Path = None): logger.info(f"Restarting opencode serve with new workdir: {workdir}") await self.opencode_process.restart(workdir=workdir) + # Перезапускаем SSE слушатель с актуальным workdir + await self._restart_sse_listener() + # Создаем новую сессию через API logger.info("Creating new session via API...") try: @@ -1413,6 +1441,9 @@ async def _handle_config_command(self, user_id: int, cmd: str): except Exception as e: logger.warning(f"Failed to restart opencode after config switch: {e}") + # Перезапускаем SSE слушатель + await self._restart_sse_listener() + # Удаляем старые сессии из opencode сервера (провайдер сменился) async with OpenCodeClient() as clean_client: for uid, old_sid in self.session_mgr.sessions.items(): @@ -1509,6 +1540,15 @@ async def _send_help(self, user_id: int): await self.vk.send_message(user_id, help_text) # ---------- Основной цикл ---------- + async def _restart_sse_listener(self): + """Перезапускает SSE слушатель с актуальным workdir.""" + if self.sse_listener: + await self.sse_listener.stop() + self.sse_listener = SSEListener(OPENCODE_URL, directory=str(self.opencode_process.workdir)) + self._register_sse_callbacks() + await self.sse_listener.start() + logger.info(f"SSE listener restarted with directory={self.opencode_process.workdir}") + async def run(self): """Запускает VK Long Poll и SSE слушатель OpenCode""" self.running = True @@ -1516,7 +1556,7 @@ async def run(self): self.opencode_client = OpenCodeClient() await self.opencode_client.__aenter__() - self.sse_listener = SSEListener(OPENCODE_URL) + self.sse_listener = SSEListener(OPENCODE_URL, directory=str(self.opencode_process.workdir)) self._register_sse_callbacks() await self.sse_listener.start()