""" Tests for KIN-012 auto mode features: - TestAutoApprove: pipeline auto-approves (status → done) без ручного review - TestAutoRerunOnPermissionDenied: runner делает retry при permission error, останавливается после одного retry (лимит = 1) - TestAutoFollowup: generate_followups вызывается сразу, без ожидания - Регрессия: review-режим работает как раньше """ import json import pytest from unittest.mock import patch, MagicMock, call from core.db import init_db from core import models from agents.runner import run_pipeline, _is_permission_error # --------------------------------------------------------------------------- # Fixtures & helpers # --------------------------------------------------------------------------- @pytest.fixture def conn(): c = init_db(":memory:") models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek", tech_stack=["vue3"]) models.create_task(c, "VDOL-001", "vdol", "Fix bug", brief={"route_type": "debug"}) yield c c.close() def _mock_success(output="done"): """Мок успешного subprocess.run (claude).""" mock = MagicMock() mock.stdout = json.dumps({"result": output}) mock.stderr = "" mock.returncode = 0 return mock def _mock_permission_denied(): """Мок subprocess.run, возвращающего permission denied.""" mock = MagicMock() mock.stdout = json.dumps({"result": "permission denied on write to config.js"}) mock.stderr = "Error: permission denied" mock.returncode = 1 return mock def _mock_failure(error="Agent failed"): """Мок subprocess.run, возвращающего общую ошибку.""" mock = MagicMock() mock.stdout = "" mock.stderr = error mock.returncode = 1 return mock def _get_hook_events(mock_hooks): """Извлечь список event из всех вызовов mock_hooks.""" return [c[1].get("event") for c in mock_hooks.call_args_list] # --------------------------------------------------------------------------- # test_auto_approve # --------------------------------------------------------------------------- class TestAutoApprove: """Pipeline auto-approve: в auto-режиме задача переходит в done без ручного review.""" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_mode_sets_status_done(self, mock_run, mock_hooks, mock_followup, conn): """Auto-режим: статус задачи становится 'done', а не 'review'.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find bug"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True task = models.get_task(conn, "VDOL-001") assert task["status"] == "done", "Auto-mode должен auto-approve: status=done" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_mode_fires_task_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn): """В auto-режиме срабатывает хук task_auto_approved.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find bug"}] run_pipeline(conn, "VDOL-001", steps) events = _get_hook_events(mock_hooks) assert "task_auto_approved" in events, "Хук task_auto_approved должен сработать" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_review_mode_sets_status_review(self, mock_run, mock_hooks, mock_followup, conn): """Регрессия: review-режим НЕ auto-approve — статус остаётся 'review'.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} # Проект остаётся в default "review" mode steps = [{"role": "debugger", "brief": "find bug"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True task = models.get_task(conn, "VDOL-001") assert task["status"] == "review", "Review-mode НЕ должен auto-approve" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_review_mode_does_not_fire_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn): """Регрессия: в review-режиме хук task_auto_approved НЕ срабатывает.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} steps = [{"role": "debugger", "brief": "find bug"}] run_pipeline(conn, "VDOL-001", steps) events = _get_hook_events(mock_hooks) assert "task_auto_approved" not in events @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_task_level_auto_overrides_project_review(self, mock_run, mock_hooks, mock_followup, conn): """Если у задачи execution_mode=auto, pipeline auto-approve, даже если проект в review.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} # Проект в review, но задача — auto models.update_task(conn, "VDOL-001", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True task = models.get_task(conn, "VDOL-001") assert task["status"] == "done", "Task-level auto должен override project review" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_pipeline_result_includes_mode(self, mock_run, mock_hooks, mock_followup, conn): """Pipeline result должен содержать поле mode.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result.get("mode") == "auto" # --------------------------------------------------------------------------- # test_auto_rerun_on_permission_denied # --------------------------------------------------------------------------- class TestAutoRerunOnPermissionDenied: """Runner повторяет шаг при permission issues, останавливается по лимиту (1 retry).""" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_mode_retries_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn): """Auto-режим: при permission denied runner делает 1 retry с allow_write=True.""" mock_run.side_effect = [ _mock_permission_denied(), # 1-й вызов: permission error _mock_success("fixed"), # 2-й вызов (retry): успех ] mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "fix file"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True assert mock_run.call_count == 2, "Должен быть ровно 1 retry" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_retry_uses_dangerously_skip_permissions(self, mock_run, mock_hooks, mock_followup, conn): """Retry при permission error использует --dangerously-skip-permissions.""" mock_run.side_effect = [ _mock_permission_denied(), _mock_success("fixed"), ] mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "fix"}] run_pipeline(conn, "VDOL-001", steps) # Второй вызов (retry) должен содержать --dangerously-skip-permissions second_cmd = mock_run.call_args_list[1][0][0] assert "--dangerously-skip-permissions" in second_cmd @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_retry_fires_permission_retry_hook(self, mock_run, mock_hooks, mock_followup, conn): """При авто-retry срабатывает хук task_permission_retry.""" mock_run.side_effect = [ _mock_permission_denied(), _mock_success(), ] mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "fix"}] run_pipeline(conn, "VDOL-001", steps) events = _get_hook_events(mock_hooks) assert "task_permission_retry" in events, "Хук task_permission_retry должен сработать" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_retry_failure_blocks_task(self, mock_run, mock_hooks, mock_followup, conn): """Если retry тоже провалился → задача blocked (лимит в 1 retry исчерпан).""" mock_run.side_effect = [ _mock_permission_denied(), # 1-й: permission error _mock_failure("still denied"), # retry: снова ошибка ] mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "fix"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is False assert mock_run.call_count == 2, "Стоп после лимита: ровно 1 retry" task = models.get_task(conn, "VDOL-001") assert task["status"] == "blocked" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_subsequent_steps_use_allow_write_after_retry(self, mock_run, mock_hooks, mock_followup, conn): """После успешного retry все следующие шаги тоже используют allow_write.""" mock_run.side_effect = [ _mock_permission_denied(), # Шаг 1: permission error _mock_success("fixed"), # Шаг 1 retry: успех _mock_success("tested"), # Шаг 2: должен получить allow_write ] mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [ {"role": "debugger", "brief": "fix"}, {"role": "tester", "brief": "test"}, ] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True assert mock_run.call_count == 3 # Третий вызов (шаг 2) должен содержать --dangerously-skip-permissions third_cmd = mock_run.call_args_list[2][0][0] assert "--dangerously-skip-permissions" in third_cmd @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_normal_failure_does_not_trigger_retry(self, mock_run, mock_hooks, mock_followup, conn): """Обычная ошибка (не permission) НЕ вызывает авто-retry даже в auto-режиме.""" mock_run.return_value = _mock_failure("compilation error: undefined variable") mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "fix"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is False assert mock_run.call_count == 1, "Retry не нужен для обычных ошибок" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn): """В review-режиме при permission denied runner НЕ делает retry.""" mock_run.return_value = _mock_permission_denied() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} # Проект в default review mode steps = [{"role": "debugger", "brief": "fix file"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is False assert mock_run.call_count == 1, "В review-режиме retry НЕ должен происходить" task = models.get_task(conn, "VDOL-001") assert task["status"] == "blocked" # --------------------------------------------------------------------------- # test_auto_followup # --------------------------------------------------------------------------- class TestAutoFollowup: """Followup запускается без ожидания сразу после pipeline в auto-режиме.""" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_followup_triggered_immediately(self, mock_run, mock_hooks, mock_followup, conn): """В auto-режиме generate_followups вызывается сразу после pipeline.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True mock_followup.assert_called_once_with(conn, "VDOL-001") @patch("core.followup.auto_resolve_pending_actions") @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_followup_resolves_pending_actions( self, mock_run, mock_hooks, mock_followup, mock_resolve, conn ): """Pending actions из followup авто-резолвятся без ожидания.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] pending = [{"type": "permission_fix", "description": "Fix nginx.conf", "original_item": {}, "options": ["rerun"]}] mock_followup.return_value = {"created": [], "pending_actions": pending} mock_resolve.return_value = [{"resolved": "rerun", "result": {}}] models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] run_pipeline(conn, "VDOL-001", steps) mock_resolve.assert_called_once_with(conn, "VDOL-001", pending) @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_review_mode_no_auto_followup(self, mock_run, mock_hooks, mock_followup, conn): """Регрессия: в review-режиме generate_followups НЕ вызывается.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} # Проект в default review mode steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True mock_followup.assert_not_called() task = models.get_task(conn, "VDOL-001") assert task["status"] == "review" @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_auto_followup_not_triggered_for_followup_tasks( self, mock_run, mock_hooks, mock_followup, conn ): """Для followup-задач generate_followups НЕ вызывается (защита от рекурсии).""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} models.update_project(conn, "vdol", execution_mode="auto") models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"}) steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True mock_followup.assert_not_called() @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_followup_exception_does_not_block_pipeline( self, mock_run, mock_hooks, mock_followup, conn ): """Ошибка в followup не должна блокировать pipeline (success=True).""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.side_effect = Exception("followup PM crashed") models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] result = run_pipeline(conn, "VDOL-001", steps) assert result["success"] is True # Pipeline succeeded, followup failure absorbed @patch("core.followup.auto_resolve_pending_actions") @patch("core.followup.generate_followups") @patch("agents.runner.run_hooks") @patch("agents.runner.subprocess.run") def test_no_pending_actions_skips_auto_resolve( self, mock_run, mock_hooks, mock_followup, mock_resolve, conn ): """Если pending_actions пустой, auto_resolve_pending_actions НЕ вызывается.""" mock_run.return_value = _mock_success() mock_hooks.return_value = [] mock_followup.return_value = {"created": [], "pending_actions": []} mock_resolve.return_value = [] models.update_project(conn, "vdol", execution_mode="auto") steps = [{"role": "debugger", "brief": "find"}] run_pipeline(conn, "VDOL-001", steps) mock_resolve.assert_not_called() # --------------------------------------------------------------------------- # _is_permission_error unit tests # --------------------------------------------------------------------------- class TestIsPermissionError: """Unit-тесты для функции _is_permission_error.""" def test_detects_permission_denied_in_raw_output(self): result = {"raw_output": "Error: permission denied writing to nginx.conf", "returncode": 1} assert _is_permission_error(result) is True def test_detects_read_only_in_output(self): result = {"raw_output": "File is read-only, cannot write", "returncode": 1} assert _is_permission_error(result) is True def test_detects_manual_apply_in_output(self): result = {"raw_output": "Apply manually to /etc/nginx/nginx.conf", "returncode": 1} assert _is_permission_error(result) is True def test_normal_failure_not_permission_error(self): result = {"raw_output": "Compilation error: undefined variable x", "returncode": 1} assert _is_permission_error(result) is False def test_empty_output_not_permission_error(self): result = {"raw_output": "", "returncode": 1} assert _is_permission_error(result) is False def test_success_with_permission_word_not_flagged(self): """Если returncode=0 и текст содержит 'permission', это не ошибка.""" # Функция проверяет только текст, не returncode # Но с success output вряд ли содержит "permission denied" result = {"raw_output": "All permissions granted, build successful", "returncode": 0} assert _is_permission_error(result) is False