478 lines
21 KiB
Python
478 lines
21 KiB
Python
"""
|
||
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
|