day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests
This commit is contained in:
parent
8d9facda4f
commit
8a6f280cbd
22 changed files with 1907 additions and 103 deletions
|
|
@ -348,6 +348,24 @@ class TestAutoMode:
|
|||
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_auto_mode_fires_task_done_event(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto mode должен вызывать run_hooks с event='task_done' после task_auto_approved."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "done"})
|
||||
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
|
||||
events_fired = [call[1].get("event") or call[0][3]
|
||||
for call in mock_hooks.call_args_list]
|
||||
assert "task_done" in events_fired
|
||||
|
||||
@patch("core.followup.auto_resolve_pending_actions")
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
|
|
@ -370,6 +388,50 @@ class TestAutoMode:
|
|||
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry on permission error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRetryOnPermissionError:
|
||||
@patch("core.followup.generate_followups")
|
||||
@patch("agents.runner.run_hooks")
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_retry_on_permission_error_auto_mode(self, mock_run, mock_hooks, mock_followup, conn):
|
||||
"""Auto mode: retry при permission error должен срабатывать."""
|
||||
permission_fail = _mock_claude_failure("permission denied: cannot write file")
|
||||
retry_success = _mock_claude_success({"result": "fixed"})
|
||||
|
||||
mock_run.side_effect = [permission_fail, retry_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
|
||||
assert mock_run.call_count == 2
|
||||
# Second call must include --dangerously-skip-permissions
|
||||
second_cmd = mock_run.call_args_list[1][0][0]
|
||||
assert "--dangerously-skip-permissions" in second_cmd
|
||||
|
||||
@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, conn):
|
||||
"""Review mode: retry при permission error НЕ должен срабатывать."""
|
||||
permission_fail = _mock_claude_failure("permission denied: cannot write file")
|
||||
|
||||
mock_run.return_value = permission_fail
|
||||
mock_hooks.return_value = []
|
||||
|
||||
# Проект остаётся в default "review" mode
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is False
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -417,20 +479,22 @@ class TestNonInteractive:
|
|||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("timeout") == 300
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_interactive_uses_600s_timeout(self, mock_run, conn):
|
||||
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("timeout") == 300
|
||||
assert call_kwargs.get("timeout") == 600
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_interactive_no_stdin_override(self, mock_run, conn):
|
||||
"""In interactive mode, stdin should not be set to DEVNULL."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("stdin") == subprocess.DEVNULL
|
||||
assert call_kwargs.get("stdin") is None
|
||||
|
||||
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"})
|
||||
@patch("agents.runner.subprocess.run")
|
||||
|
|
@ -582,3 +646,108 @@ class TestRunAudit:
|
|||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dangerously-skip-permissions" in cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-019: Silent FAILED diagnostics (regression tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSilentFailedDiagnostics:
|
||||
"""Regression: агент падает без вывода — runner должен сохранять диагностику в БД."""
|
||||
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_agent_empty_stdout_saves_stderr_as_error_message_in_db(self, mock_run, conn):
|
||||
"""Когда stdout пустой и returncode != 0, stderr должен сохраняться как error_message в agent_logs."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = ""
|
||||
mock.stderr = "API rate limit exceeded (429)"
|
||||
mock.returncode = 1
|
||||
mock_run.return_value = mock
|
||||
|
||||
run_agent(conn, "debugger", "VDOL-001", "vdol")
|
||||
|
||||
log = conn.execute(
|
||||
"SELECT error_message FROM agent_logs WHERE task_id='VDOL-001'"
|
||||
).fetchone()
|
||||
assert log is not None
|
||||
assert log["error_message"] is not None
|
||||
assert "rate limit" in log["error_message"]
|
||||
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_agent_empty_stdout_returns_error_key_with_stderr(self, mock_run, conn):
|
||||
"""run_agent должен вернуть ключ 'error' с содержимым stderr при пустом stdout и ненулевом returncode."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = ""
|
||||
mock.stderr = "Permission denied: cannot write to /etc/hosts"
|
||||
mock.returncode = 1
|
||||
mock_run.return_value = mock
|
||||
|
||||
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["error"] is not None
|
||||
assert "Permission denied" in result["error"]
|
||||
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_pipeline_error_message_includes_agent_stderr(self, mock_run, conn):
|
||||
"""Сообщение об ошибке pipeline должно включать stderr агента, а не только generic 'step failed'."""
|
||||
mock = MagicMock()
|
||||
mock.stdout = ""
|
||||
mock.stderr = "Internal server error: unexpected EOF"
|
||||
mock.returncode = 1
|
||||
mock_run.return_value = mock
|
||||
|
||||
steps = [{"role": "tester", "brief": "run tests"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Internal server error" in result["error"] or "unexpected EOF" in result["error"]
|
||||
|
||||
@patch("agents.runner.build_context")
|
||||
def test_pipeline_exception_in_run_agent_marks_task_blocked(self, mock_ctx, conn):
|
||||
"""Исключение внутри run_agent (например, из build_context) должно ставить задачу в blocked."""
|
||||
mock_ctx.side_effect = RuntimeError("DB connection lost")
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
result = run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
task = models.get_task(conn, "VDOL-001")
|
||||
assert task["status"] == "blocked"
|
||||
|
||||
@patch("agents.runner.build_context")
|
||||
def test_pipeline_exception_logs_to_agent_logs(self, mock_ctx, conn):
|
||||
"""Исключение в run_agent должно быть залогировано в agent_logs с success=False."""
|
||||
mock_ctx.side_effect = ValueError("bad context data")
|
||||
|
||||
steps = [{"role": "tester", "brief": "test"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
logs = conn.execute(
|
||||
"SELECT * FROM agent_logs WHERE task_id='VDOL-001' AND success=0"
|
||||
).fetchall()
|
||||
assert len(logs) >= 1
|
||||
|
||||
@patch("agents.runner.build_context")
|
||||
def test_pipeline_exception_marks_pipeline_failed_in_db(self, mock_ctx, conn):
|
||||
"""При исключении запись pipeline должна существовать в БД и иметь статус failed."""
|
||||
mock_ctx.side_effect = RuntimeError("network timeout")
|
||||
|
||||
steps = [{"role": "debugger", "brief": "find"}]
|
||||
run_pipeline(conn, "VDOL-001", steps)
|
||||
|
||||
pipe = conn.execute("SELECT * FROM pipelines WHERE task_id='VDOL-001'").fetchone()
|
||||
assert pipe is not None
|
||||
assert pipe["status"] == "failed"
|
||||
|
||||
@patch("agents.runner.subprocess.run")
|
||||
def test_agent_success_has_no_error_key_populated(self, mock_run, conn):
|
||||
"""При успешном запуске агента ключ 'error' в результате должен быть None (нет ложных срабатываний)."""
|
||||
mock_run.return_value = _mock_claude_success({"result": "all good"})
|
||||
|
||||
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result.get("error") is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue