day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests

This commit is contained in:
Gros Frumos 2026-03-15 23:22:49 +02:00
parent 8d9facda4f
commit 8a6f280cbd
22 changed files with 1907 additions and 103 deletions

View file

@ -105,6 +105,18 @@ def test_approve_not_found(client):
assert r.status_code == 404
def test_approve_fires_task_done_hooks(client):
"""Ручной апрув задачи должен вызывать хуки с event='task_done'."""
from unittest.mock import patch
with patch("core.hooks.run_hooks") as mock_hooks:
mock_hooks.return_value = []
r = client.post("/api/tasks/P1-001/approve", json={})
assert r.status_code == 200
events_fired = [call[1].get("event") or call[0][3]
for call in mock_hooks.call_args_list]
assert "task_done" in events_fired
def test_reject_task(client):
from core.db import init_db
from core import models
@ -173,14 +185,15 @@ def test_run_not_found(client):
assert r.status_code == 404
def test_run_with_allow_write(client):
"""POST /run with allow_write=true should be accepted."""
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
def test_run_kin_038_without_allow_write(client):
"""Регрессионный тест KIN-038: allow_write удалён из схемы,
эндпоинт принимает запросы с пустым телом без этого параметра."""
r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202
def test_run_with_empty_body(client):
"""POST /run with empty JSON body should default allow_write=false."""
"""POST /run with empty JSON body should be accepted."""
r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202
@ -256,14 +269,61 @@ def test_patch_task_status_persisted(client):
assert r.json()["status"] == "blocked"
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "cancelled"])
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"])
def test_patch_task_all_valid_statuses(client, status):
"""Все 6 допустимых статусов должны приниматься."""
"""Все 7 допустимых статусов должны приниматься (включая decomposed)."""
r = client.patch("/api/tasks/P1-001", json={"status": status})
assert r.status_code == 200
assert r.json()["status"] == status
def test_patch_task_status_decomposed(client):
"""Регрессионный тест KIN-033: API принимает статус 'decomposed'."""
r = client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
def test_patch_task_status_decomposed_persisted(client):
"""После установки 'decomposed' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
# ---------------------------------------------------------------------------
# KIN-033 — единый источник истины для статусов
# ---------------------------------------------------------------------------
def test_api_valid_statuses_match_models():
"""API использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
import web.api as api_module
assert api_module.VALID_STATUSES == set(models.VALID_TASK_STATUSES)
def test_cli_valid_statuses_match_models():
"""CLI использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == set(models.VALID_TASK_STATUSES)
def test_cli_and_api_statuses_are_identical():
"""Список статусов в CLI и API идентичен."""
from core import models
import web.api as api_module
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == api_module.VALID_STATUSES
assert "decomposed" in cli_choices
assert "decomposed" in api_module.VALID_STATUSES
def test_patch_task_invalid_status(client):
"""Недопустимый статус → 400."""
r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
@ -274,3 +334,258 @@ def test_patch_task_not_found(client):
"""Несуществующая задача → 404."""
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
assert r.status_code == 404
def test_patch_task_empty_body_returns_400(client):
"""PATCH с пустым телом (нет status и нет execution_mode) → 400."""
r = client.patch("/api/tasks/P1-001", json={})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# KIN-022 — blocked_reason: регрессионные тесты
# ---------------------------------------------------------------------------
def test_blocked_reason_saved_and_returned(client):
"""При переходе в blocked с blocked_reason поле сохраняется и отдаётся в GET."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="Step 1/2 (debugger) failed")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "Step 1/2 (debugger) failed"
def test_blocked_reason_present_in_full(client):
"""blocked_reason также присутствует в /full эндпоинте."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="tester agent crashed")
conn.close()
r = client.get("/api/tasks/P1-001/full")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "tester agent crashed"
def test_blocked_reason_none_by_default(client):
"""Новая задача не имеет blocked_reason."""
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["blocked_reason"] is None
def test_blocked_without_reason_allowed(client):
"""Переход в blocked без причины допустим (reason=None)."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] is None
def test_blocked_reason_cleared_on_retry(client):
"""При повторном запуске (статус pending) blocked_reason сбрасывается."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="failed once")
models.update_task(conn, "P1-001", status="pending", blocked_reason=None)
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "pending"
assert data["blocked_reason"] is None
# ---------------------------------------------------------------------------
# KIN-029 — DELETE /api/projects/{project_id}/decisions/{decision_id}
# ---------------------------------------------------------------------------
def test_delete_decision_ok(client):
"""Создаём decision через POST, удаляем DELETE → 200 с телом {"deleted": id}."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Use SQLite",
"description": "Chosen for simplicity",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p1/decisions/{decision_id}")
assert r.status_code == 200
assert r.json() == {"deleted": decision_id}
r = client.get("/api/decisions?project=p1")
assert r.status_code == 200
ids = [d["id"] for d in r.json()]
assert decision_id not in ids
def test_delete_decision_not_found(client):
"""DELETE несуществующего decision → 404."""
r = client.delete("/api/projects/p1/decisions/99999")
assert r.status_code == 404
def test_delete_decision_wrong_project(client):
"""DELETE decision с чужим project_id → 404 (не раскрываем существование)."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Cross-project check",
"description": "Should not be deletable from p2",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p2/decisions/{decision_id}")
assert r.status_code == 404
# Decision должен остаться нетронутым
r = client.get("/api/decisions?project=p1")
ids = [d["id"] for d in r.json()]
assert decision_id in ids
# ---------------------------------------------------------------------------
# KIN-035 — регрессионный тест: смена статуса на cancelled
# ---------------------------------------------------------------------------
def test_patch_task_status_cancelled(client):
"""Регрессионный тест KIN-035: PATCH /api/tasks/{id} с status='cancelled' → 200."""
r = client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_patch_task_status_cancelled_persisted(client):
"""После установки 'cancelled' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_cancelled_in_valid_statuses():
"""'cancelled' присутствует в VALID_TASK_STATUSES модели и в VALID_STATUSES API."""
from core import models
import web.api as api_module
assert "cancelled" in models.VALID_TASK_STATUSES
assert "cancelled" in api_module.VALID_STATUSES
# ---------------------------------------------------------------------------
# KIN-036 — регрессионный тест: --allow-write всегда в команде через web API
# ---------------------------------------------------------------------------
def test_run_always_includes_allow_write_when_body_false(client):
"""Регрессионный тест KIN-036: --allow-write присутствует в команде,
даже если allow_write=False в теле запроса.
Баг: условие `if body and body.allow_write` не добавляло флаг при
allow_write=False, что приводило к блокировке агента на 300 с."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run", json={"allow_write": False})
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd, (
"--allow-write обязан присутствовать всегда: без него агент зависает "
"при попытке записи, потому что stdin=DEVNULL и нет интерактивного подтверждения"
)
def test_run_always_includes_allow_write_without_body(client):
"""Регрессионный тест KIN-036: --allow-write присутствует даже без тела запроса."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd
def test_run_sets_kin_noninteractive_env(client):
"""Регрессионный тест KIN-036: KIN_NONINTERACTIVE=1 всегда устанавливается
при запуске через web API, что вместе с --allow-write предотвращает зависание."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 99
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
env = call_kwargs.get("env", {})
assert env.get("KIN_NONINTERACTIVE") == "1"
def test_run_sets_stdin_devnull(client):
"""Регрессионный тест KIN-036: stdin=DEVNULL всегда устанавливается,
что является причиной, по которой --allow-write обязателен."""
import subprocess as _subprocess
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 42
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
assert call_kwargs.get("stdin") == _subprocess.DEVNULL
# ---------------------------------------------------------------------------
# KIN-040 — регрессионные тесты: удаление TaskRun / allow_write из схемы
# ---------------------------------------------------------------------------
def test_run_kin_040_no_taskrun_class():
"""Регрессионный тест KIN-040: класс TaskRun удалён из web/api.py.
allow_write больше не является частью схемы эндпоинта /run."""
import web.api as api_module
assert not hasattr(api_module, "TaskRun"), (
"Класс TaskRun должен быть удалён из web/api.py (KIN-040)"
)
def test_run_kin_040_allow_write_true_ignored(client):
"""Регрессионный тест KIN-040: allow_write=True в теле игнорируется (не 422).
Эндпоинт не имеет body-параметра, поэтому FastAPI не валидирует тело."""
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
assert r.status_code == 202

View file

@ -257,24 +257,6 @@ class TestAutoRerunOnPermissionDenied:
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")
@ -318,6 +300,24 @@ class TestAutoRerunOnPermissionDenied:
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

View file

@ -333,7 +333,8 @@ def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
r = invoke(runner, ["hook", "list", "--project", "p1"])
assert r.exit_code == 0
assert "rebuild-frontend" in r.output
assert "web/frontend/*" in r.output
# KIN-050: trigger_module_path должен быть NULL — хук срабатывает безусловно
assert "web/frontend/*" not in r.output
def test_hook_setup_idempotent(runner, tmp_path):
@ -352,3 +353,123 @@ def test_hook_setup_project_not_found(runner):
r = invoke(runner, ["hook", "setup", "--project", "nope"])
assert r.exit_code == 1
assert "not found" in r.output
# ===========================================================================
# KIN-018 — project set-mode / task update --mode / show with mode labels
# ===========================================================================
def test_project_set_mode_auto(runner):
"""project set-mode auto — обновляет режим, выводит подтверждение."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_review(runner):
"""project set-mode review — обновляет режим обратно в review."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_project_set_mode_persisted(runner):
"""После project set-mode режим сохраняется в БД и виден в project show."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_not_found(runner):
"""project set-mode для несуществующего проекта → exit code 1."""
r = invoke(runner, ["project", "set-mode", "--project", "nope", "auto"])
assert r.exit_code == 1
assert "not found" in r.output
def test_project_set_mode_invalid(runner):
"""project set-mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "turbo"])
assert r.exit_code != 0
def test_project_show_displays_mode(runner):
"""project show отображает строку Mode: ..."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "Mode:" in r.output
def test_task_update_mode_auto(runner):
"""task update --mode auto задаёт execution_mode на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_task_update_mode_review(runner):
"""task update --mode review задаёт execution_mode=review на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_task_update_mode_persisted(runner):
"""После task update --mode режим сохраняется и виден в task show как (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_update_mode_invalid(runner):
"""task update --mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "turbo"])
assert r.exit_code != 0
def test_task_show_mode_inherited(runner):
"""task show без явного execution_mode показывает (inherited)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "inherited" in r.output
def test_task_show_mode_overridden(runner):
"""task show с task-level execution_mode показывает (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_show_mode_label_reflects_project_mode(runner):
"""Если у проекта auto, у задачи нет mode — task show показывает 'auto (inherited)'."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "auto" in r.output
assert "inherited" in r.output

View file

@ -8,7 +8,7 @@ from core.db import init_db
from core import models
from core.hooks import (
create_hook, get_hooks, update_hook, delete_hook,
run_hooks, get_hook_logs, HookResult,
run_hooks, get_hook_logs, HookResult, _substitute_vars,
)
@ -273,3 +273,298 @@ class TestGetHookLogs:
event="pipeline_completed", task_modules=modules)
logs = get_hook_logs(conn, project_id="vdol", limit=3)
assert len(logs) == 3
# ---------------------------------------------------------------------------
# Variable substitution in hook commands
# ---------------------------------------------------------------------------
class TestSubstituteVars:
def test_substitutes_task_id_and_title(self, conn):
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert result == 'git commit -m "kin: VDOL-001 Fix bug"'
def test_no_substitution_when_task_id_is_none(self, conn):
cmd = 'git commit -m "kin: {task_id} {title}"'
result = _substitute_vars(cmd, None, conn)
assert result == cmd
def test_sanitizes_double_quotes_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
('Fix "bug" here', "VDOL-001"))
conn.commit()
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert '"' not in result.split('"kin:')[1].split('"')[0]
assert "Fix 'bug' here" in result
def test_sanitizes_newlines_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
("Fix\nbug\r\nhere", "VDOL-001"))
conn.commit()
result = _substitute_vars("{title}", "VDOL-001", conn)
assert "\n" not in result
assert "\r" not in result
def test_unknown_task_id_uses_empty_title(self, conn):
result = _substitute_vars("{task_id} {title}", "NONEXISTENT", conn)
assert result == "NONEXISTENT "
def test_no_placeholders_returns_command_unchanged(self, conn):
cmd = "npm run build"
result = _substitute_vars(cmd, "VDOL-001", conn)
assert result == cmd
@patch("core.hooks.subprocess.run")
def test_autocommit_hook_command_substituted(self, mock_run, conn):
"""auto-commit hook должен получать реальные task_id и title в команде."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(conn, "vdol", "auto-commit", "task_done",
'git add -A && git commit -m "kin: {task_id} {title}"',
working_dir="/tmp")
run_hooks(conn, "vdol", "VDOL-001", event="task_done", task_modules=[])
call_kwargs = mock_run.call_args[1]
# shell=True: command is the first positional arg
command = mock_run.call_args[0][0]
assert "VDOL-001" in command
assert "Fix bug" in command
# ---------------------------------------------------------------------------
# KIN-050: rebuild-frontend hook — unconditional firing after pipeline
# ---------------------------------------------------------------------------
class TestRebuildFrontendHookSetup:
"""Regression tests for KIN-050.
Баг: rebuild-frontend не срабатывал, если pipeline не трогал web/frontend/*.
Фикс: убран trigger_module_path из hook_setup хук должен срабатывать всегда.
"""
def test_rebuild_frontend_created_without_trigger_module_path(self, conn):
"""rebuild-frontend hook должен быть создан без trigger_module_path (KIN-050).
Воспроизводит логику hook_setup: создаём хук без фильтра и убеждаемся,
что он сохраняется в БД с trigger_module_path=NULL.
"""
hook = create_hook(
conn, "vdol",
name="rebuild-frontend",
event="pipeline_completed",
command="scripts/rebuild-frontend.sh",
trigger_module_path=None, # фикс KIN-050: без фильтра
working_dir="/tmp",
timeout_seconds=300,
)
assert hook["trigger_module_path"] is None, (
"trigger_module_path должен быть NULL — хук не должен фильтровать по модулям"
)
# Перечитываем из БД — убеждаемся, что NULL сохранился
hooks = get_hooks(conn, "vdol", enabled_only=False)
rebuild = next((h for h in hooks if h["name"] == "rebuild-frontend"), None)
assert rebuild is not None
assert rebuild["trigger_module_path"] is None
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_when_only_backend_modules_changed(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать при изменении backend-файлов.
Регрессия KIN-050: раньше хук молчал, если не было web/frontend/* файлов.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None, # фикс: нет фильтра
working_dir="/tmp",
)
backend_modules = [
{"path": "core/models.py", "name": "models"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 1, "Хук должен сработать несмотря на отсутствие frontend-файлов"
assert results[0].name == "rebuild-frontend"
assert results[0].success is True
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_exactly_once_per_pipeline(self, mock_run, conn):
"""Хук rebuild-frontend должен срабатывать ровно один раз за pipeline_completed."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
any_modules = [
{"path": "core/hooks.py", "name": "hooks"},
{"path": "web/frontend/App.vue", "name": "App"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=any_modules)
assert len(results) == 1, "Хук должен выполниться ровно один раз"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_with_empty_module_list(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать даже с пустым списком модулей."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].name == "rebuild-frontend"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_with_module_path_skips_non_frontend(self, mock_run, conn):
"""Контрольный тест: хук С trigger_module_path НЕ срабатывает на backend-файлы.
Подтверждает, что фикс (удаление trigger_module_path) был необходим.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend-filtered", "pipeline_completed",
"npm run build",
trigger_module_path="web/frontend/*", # старое (сломанное) поведение
working_dir="/tmp",
)
backend_modules = [{"path": "core/models.py", "name": "models"}]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 0, (
"Хук с trigger_module_path НЕ должен срабатывать на backend-файлы — "
"именно это было первопричиной бага KIN-050"
)
# ---------------------------------------------------------------------------
# KIN-052: rebuild-frontend hook — команда cd+&& и персистентность в БД
# ---------------------------------------------------------------------------
class TestKIN052RebuildFrontendCommand:
"""Регрессионные тесты для KIN-052.
Хук rebuild-frontend использует команду вида:
cd /path/to/frontend && npm run build
то есть цепочку shell-команд без working_dir.
Тесты проверяют, что такая форма работает корректно и хук переживает
пересоздание соединения с БД (симуляция рестарта).
"""
@patch("core.hooks.subprocess.run")
def test_cd_chained_command_passes_as_string_to_shell(self, mock_run, conn):
"""Команда с && должна передаваться в subprocess как строка (не список) с shell=True.
Если передать список ['cd', '/path', '&&', 'npm', 'run', 'build'] с shell=True,
shell проигнорирует аргументы после первого. Строковая форма обязательна.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
call_args = mock_run.call_args
passed_cmd = call_args[0][0]
assert isinstance(passed_cmd, str), (
"Команда с && должна передаваться как строка, иначе shell не раскроет &&"
)
assert "&&" in passed_cmd
assert call_args[1].get("shell") is True
@patch("core.hooks.subprocess.run")
def test_cd_command_without_working_dir_uses_cwd_none(self, mock_run, conn):
"""Хук с cd-командой и working_dir=None должен вызывать subprocess с cwd=None.
Директория смены задаётся через cd в самой команде, а не через cwd.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
cwd = mock_run.call_args[1].get("cwd")
assert cwd is None, (
f"cwd должен быть None когда working_dir не задан, получили: {cwd!r}"
)
@patch("core.hooks.subprocess.run")
def test_cd_command_exits_zero_returns_success(self, mock_run, conn):
"""Хук с cd+npm run build при returncode=0 должен вернуть success=True."""
mock_run.return_value = MagicMock(returncode=0, stdout="✓ build complete", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].success is True
assert results[0].name == "rebuild-frontend"
@patch("core.hooks.subprocess.run")
def test_hook_persists_after_db_reconnect(self, mock_run):
"""Хук должен сохраняться в файловой БД и быть доступен после пересоздания соединения.
Симулирует рестарт: создаём хук, закрываем соединение, открываем новое хук на месте.
"""
import tempfile
import os
from core.db import init_db
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
# Первое соединение — создаём проект и хук
conn1 = init_db(db_path)
from core import models as _models
_models.create_project(conn1, "kin", "Kin", "/projects/kin", tech_stack=["vue3"])
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
hook = create_hook(conn1, "kin", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
hook_id = hook["id"]
conn1.close()
# Второе соединение — «рестарт», хук должен быть на месте
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=True)
conn2.close()
assert len(hooks) == 1, "После пересоздания соединения хук должен оставаться в БД"
assert hooks[0]["id"] == hook_id
assert hooks[0]["name"] == "rebuild-frontend"
assert hooks[0]["command"] == cmd
assert hooks[0]["trigger_module_path"] is None
finally:
os.unlink(db_path)

View file

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

View file

@ -0,0 +1,195 @@
"""Tests for KIN-037: tech_researcher specialist — YAML validation and prompt structure."""
from pathlib import Path
import yaml
import pytest
SPECIALISTS_YAML = Path(__file__).parent.parent / "agents" / "specialists.yaml"
PROMPTS_DIR = Path(__file__).parent.parent / "agents" / "prompts"
TECH_RESEARCHER_PROMPT = PROMPTS_DIR / "tech_researcher.md"
REQUIRED_SPECIALIST_FIELDS = {"name", "model", "tools", "description", "permissions"}
REQUIRED_OUTPUT_SCHEMA_FIELDS = {
"status", "api_overview", "endpoints", "rate_limits", "auth_method",
"data_schemas", "limitations", "gotchas", "codebase_diff", "notes",
}
@pytest.fixture(scope="module")
def spec():
"""Load and parse specialists.yaml once for all tests."""
return yaml.safe_load(SPECIALISTS_YAML.read_text())
@pytest.fixture(scope="module")
def tech_researcher(spec):
return spec["specialists"]["tech_researcher"]
@pytest.fixture(scope="module")
def prompt_text():
return TECH_RESEARCHER_PROMPT.read_text()
# ---------------------------------------------------------------------------
# YAML validity
# ---------------------------------------------------------------------------
class TestSpecialistsYaml:
def test_yaml_parses_without_error(self):
content = SPECIALISTS_YAML.read_text()
parsed = yaml.safe_load(content)
assert parsed is not None
def test_yaml_has_specialists_key(self, spec):
assert "specialists" in spec
def test_yaml_has_routes_key(self, spec):
assert "routes" in spec
# ---------------------------------------------------------------------------
# tech_researcher entry structure
# ---------------------------------------------------------------------------
class TestTechResearcherEntry:
def test_tech_researcher_exists_in_specialists(self, spec):
assert "tech_researcher" in spec["specialists"]
def test_tech_researcher_has_required_fields(self, tech_researcher):
missing = REQUIRED_SPECIALIST_FIELDS - set(tech_researcher.keys())
assert not missing, f"Missing fields: {missing}"
def test_tech_researcher_name_is_string(self, tech_researcher):
assert isinstance(tech_researcher["name"], str)
assert tech_researcher["name"].strip()
def test_tech_researcher_model_is_sonnet(self, tech_researcher):
assert tech_researcher["model"] == "sonnet"
def test_tech_researcher_tools_is_list(self, tech_researcher):
assert isinstance(tech_researcher["tools"], list)
assert len(tech_researcher["tools"]) > 0
def test_tech_researcher_tools_include_webfetch(self, tech_researcher):
assert "WebFetch" in tech_researcher["tools"]
def test_tech_researcher_tools_include_read_grep_glob(self, tech_researcher):
for tool in ("Read", "Grep", "Glob"):
assert tool in tech_researcher["tools"], f"Missing tool: {tool}"
def test_tech_researcher_permissions_is_read_only(self, tech_researcher):
assert tech_researcher["permissions"] == "read_only"
def test_tech_researcher_description_is_non_empty_string(self, tech_researcher):
assert isinstance(tech_researcher["description"], str)
assert len(tech_researcher["description"]) > 10
def test_tech_researcher_has_output_schema(self, tech_researcher):
assert "output_schema" in tech_researcher
def test_tech_researcher_output_schema_has_required_fields(self, tech_researcher):
schema = tech_researcher["output_schema"]
missing = REQUIRED_OUTPUT_SCHEMA_FIELDS - set(schema.keys())
assert not missing, f"Missing output_schema fields: {missing}"
def test_tech_researcher_context_rules_decisions_is_list(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions")
assert isinstance(decisions, list)
def test_tech_researcher_context_rules_includes_gotcha(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions", [])
assert "gotcha" in decisions
# ---------------------------------------------------------------------------
# api_research route
# ---------------------------------------------------------------------------
class TestApiResearchRoute:
def test_api_research_route_exists(self, spec):
assert "api_research" in spec["routes"]
def test_api_research_route_has_steps(self, spec):
route = spec["routes"]["api_research"]
assert "steps" in route
assert isinstance(route["steps"], list)
assert len(route["steps"]) >= 1
def test_api_research_route_starts_with_tech_researcher(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert steps[0] == "tech_researcher"
def test_api_research_route_includes_architect(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert "architect" in steps
def test_api_research_route_has_description(self, spec):
route = spec["routes"]["api_research"]
assert "description" in route
assert isinstance(route["description"], str)
# ---------------------------------------------------------------------------
# Prompt file existence
# ---------------------------------------------------------------------------
class TestTechResearcherPromptFile:
def test_prompt_file_exists(self):
assert TECH_RESEARCHER_PROMPT.exists(), (
f"Prompt file not found: {TECH_RESEARCHER_PROMPT}"
)
def test_prompt_file_is_not_empty(self, prompt_text):
assert len(prompt_text.strip()) > 100
# ---------------------------------------------------------------------------
# Prompt content — structured review instructions
# ---------------------------------------------------------------------------
class TestTechResearcherPromptContent:
def test_prompt_contains_json_output_instruction(self, prompt_text):
assert "JSON" in prompt_text or "json" in prompt_text
def test_prompt_defines_status_field(self, prompt_text):
assert '"status"' in prompt_text
def test_prompt_defines_done_partial_blocked_statuses(self, prompt_text):
assert "done" in prompt_text
assert "partial" in prompt_text
assert "blocked" in prompt_text
def test_prompt_defines_api_overview_field(self, prompt_text):
assert "api_overview" in prompt_text
def test_prompt_defines_endpoints_field(self, prompt_text):
assert "endpoints" in prompt_text
def test_prompt_defines_rate_limits_field(self, prompt_text):
assert "rate_limits" in prompt_text
def test_prompt_defines_codebase_diff_field(self, prompt_text):
assert "codebase_diff" in prompt_text
def test_prompt_defines_gotchas_field(self, prompt_text):
assert "gotchas" in prompt_text
def test_prompt_contains_webfetch_instruction(self, prompt_text):
assert "WebFetch" in prompt_text
def test_prompt_mentions_no_secrets_logging(self, prompt_text):
"""Prompt must instruct agent not to log secret values."""
lower = prompt_text.lower()
assert "secret" in lower or "credential" in lower or "token" in lower
def test_prompt_specifies_readonly_bash(self, prompt_text):
"""Bash must be restricted to read-only operations per rules."""
assert "read-only" in prompt_text or "read only" in prompt_text or "GET" in prompt_text
def test_prompt_defines_partial_reason_for_partial_status(self, prompt_text):
assert "partial_reason" in prompt_text
def test_prompt_defines_blocked_reason_for_blocked_status(self, prompt_text):
assert "blocked_reason" in prompt_text