kin/tests/test_auto_mode.py

478 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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