kin/tests/test_auto_mode.py

479 lines
21 KiB
Python
Raw Normal View History

"""
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_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
"""Регрессия: review-режим НЕ делает авто-retry при permission error."""
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"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1, "Review-mode не должен 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 не нужен для обычных ошибок"
# ---------------------------------------------------------------------------
# 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